diff --git a/web/navi.css b/web/navi.css index 6e11705..c0c5e95 100644 --- a/web/navi.css +++ b/web/navi.css @@ -144,7 +144,10 @@ html[data-theme] .navi-search-bar[data-state="collapsed"] { } html[data-theme] .navi-search-bar[data-state="open"] { - width: min(560px, calc(100% - 96px)) !important; + /* 必须给右下按钮组留位:按钮组 right:16px、宽 52+10+52=114px, + 其左边缘在 innerW-130;搜索栏 left:16 → 上限 innerW-130-12-16 = 100%-158px。 + 否则展开后发送按钮会压在视图切换/管理入口上(窄屏尤其明显)。 */ + width: min(560px, calc(100% - 158px)) !important; min-height: 58px !important; padding: 7px 8px !important; border-radius: 20px !important; @@ -192,12 +195,38 @@ html[data-theme] .navi-search-toggle:active { transform: scale(.96); } -/* 右侧管理入口:Dashy 页面本身没有去 /admin/ 的链接 */ -html[data-theme] .navi-admin-entry { - box-sizing: border-box !important; +/* ── 右下角按钮组(视图切换 + 管理入口)──── + 两者放进同一个 fixed 容器,便于在工作空间作为一组收边, + 避免各自位移后互相重叠。 */ +html[data-theme] .navi-dock { position: fixed !important; right: calc(16px + env(safe-area-inset-right, 0px)) !important; bottom: calc(76px + env(safe-area-inset-bottom, 0px)) !important; + display: flex !important; + align-items: center !important; + gap: 10px !important; + z-index: 900 !important; +} + +html[data-theme] .navi-dock > .navi-admin-entry { + position: static !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; +} + +html[data-theme] .navi-dock > .navi-view-switch { + position: relative !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; +} + +/* 右侧管理入口:Dashy 页面本身没有去 /admin/ 的链接 */ +html[data-theme] .navi-admin-entry { + box-sizing: border-box !important; width: 52px !important; height: 52px !important; display: grid !important; @@ -237,7 +266,8 @@ html[data-theme] .navi-admin-entry svg { height: 22px !important; } -html[data-theme] body:has(.modal-overlay) > .navi-admin-entry { +html[data-theme] body:has(.modal-overlay) > .navi-dock, +html[data-theme] body:has(.modal-overlay) .navi-admin-entry { visibility: hidden !important; pointer-events: none !important; } @@ -249,10 +279,7 @@ html[data-theme] nav.side-bar .switch-view-buttons { } html[data-theme] .navi-view-switch { - position: fixed !important; - right: calc(78px + env(safe-area-inset-right, 0px)) !important; - bottom: calc(76px + env(safe-area-inset-bottom, 0px)) !important; - z-index: 900 !important; + display: block !important; } html[data-theme] .navi-view-toggle { @@ -345,7 +372,7 @@ html[data-theme] .navi-view-option.is-active { font-weight: 600 !important; } -html[data-theme] body:has(.modal-overlay) > .navi-view-switch { +html[data-theme] body:has(.modal-overlay) .navi-view-switch { visibility: hidden !important; pointer-events: none !important; } @@ -652,7 +679,9 @@ html[data-theme] .minimal-section-inner a.item { } html[data-theme] .navi-search-bar[data-state="open"] { - width: calc(100% - 86px) !important; + /* 移动端按钮组 right:10px、宽 48+8+48=104px → 左边缘 innerW-114, + 搜索栏 left:10 → 上限 100%-132px */ + width: calc(100% - 132px) !important; min-height: 56px !important; border-radius: 18px !important; } @@ -660,12 +689,17 @@ html[data-theme] .minimal-section-inner a.item { html[data-theme] .navi-admin-entry { width: 48px !important; height: 48px !important; - right: calc(10px + env(safe-area-inset-right, 0px)) !important; - bottom: calc(76px + env(safe-area-inset-bottom, 0px)) !important; } - html[data-theme] .navi-view-switch { - right: calc(68px + env(safe-area-inset-right, 0px)) !important; + html[data-theme] .navi-dock { + right: calc(10px + env(safe-area-inset-right, 0px)) !important; + bottom: calc(76px + env(safe-area-inset-bottom, 0px)) !important; + gap: 8px !important; + } + + /* 移动端基准 right:10px,露出 14px → 位移 = 自身宽度 - 4px */ + html[data-theme][data-navi-view="workspace"] .navi-dock { + transform: translateX(calc(100% - 4px)) !important; } html[data-theme] .navi-view-toggle { @@ -683,6 +717,22 @@ html[data-theme] .minimal-section-inner a.item { } } +/* 极窄屏(≤380px):输入框展开时右下按钮组整组让位。 + 预留式收窄在 320px 下只剩 68px 可输入,不可用;搜索是聚焦动作, + 此时把两个按钮藏起来(visibility 不受 prefers-reduced-motion 影响, + 比 transform 更可靠),搜索栏即可用满宽度。 */ +@media (max-width: 380px) { + html[data-theme] .navi-search-bar[data-state="open"] { + width: calc(100% - 40px) !important; + } + + html[data-theme] .navi-search-bar[data-state="open"] ~ .navi-dock { + visibility: hidden !important; + pointer-events: none !important; + opacity: 0 !important; + } +} + /* ── 页脚:备案号胶囊 ── 备案号在 /admin/ 里编辑,这里只负责呈现;同时清掉历史上塞在 内联样式里的 margin-bottom:80px(那是给旧的流式搜索栏让位的)。 */ @@ -746,117 +796,217 @@ html[data-theme] .navi-footer-sep { color: rgba(100, 116, 139, .45) !important; } -/* ── 工作空间:右侧改为「分组应用列表」面板 ── - Dashy 原生面板是 iframe,目标站点多数用 X-Frame-Options 拒绝被嵌, - 会呈现一片空白;这里改为直接展示我们自己的卡片列表。 - 面板走正常文档流(而不是 fixed 钉在中间),否则会把页面切成 - 「顶栏 / 大方块 / 底部」三段,看起来上下都被截断。 */ -html[data-theme] .work-space .web-content, -html[data-theme] .work-space .multi-taking-view, -html[data-theme] .work-space .workspace-widget-view { - display: none !important; +/* ── 工作空间:悬浮控件与备案号收起到屏幕边缘 ── + 工作空间中间嵌的是别人的应用,这些常驻控件会遮挡内容。 + 搜索球收左边缘、右下角按钮组整组收右边缘、备案号收下边缘, + 各留 14px 露出;鼠标移入或点击、聚焦时滑出展开。 */ +html[data-theme][data-navi-view="workspace"] .navi-search-bar, +html[data-theme][data-navi-view="workspace"] .navi-dock, +html[data-theme][data-navi-view="workspace"] .navi-footer-host { + opacity: .55 !important; + transition: transform .26s cubic-bezier(.22, 1, .36, 1), opacity .26s ease !important; + animation: none !important; } +/* 基础 left 为 16px、宽 56px → 位移 58px 后恰好露出 14px */ +html[data-theme][data-navi-view="workspace"] .navi-search-bar { + transform: translateX(-58px) !important; +} + +/* 按钮组整组右移自身宽度减 14px,恰好露出 14px */ +html[data-theme][data-navi-view="workspace"] .navi-dock { + /* 基准 right:16px,露出 14px → 位移 = 自身宽度 + 16px - 14px */ + transform: translateX(calc(100% + 2px)) !important; +} + +html[data-theme][data-navi-view="workspace"] .navi-footer-host { + position: fixed !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + z-index: 880 !important; + padding: 0 !important; + transform: translateY(calc(100% - 13px)) !important; +} + +/* 展开:悬停 / 聚焦 / 按下 / 搜索已展开 */ +html[data-theme][data-navi-view="workspace"] .navi-search-bar:hover, +html[data-theme][data-navi-view="workspace"] .navi-search-bar:focus-within, +html[data-theme][data-navi-view="workspace"] .navi-search-bar[data-state="open"], +html[data-theme][data-navi-view="workspace"] .navi-dock:hover, +html[data-theme][data-navi-view="workspace"] .navi-dock:focus-within, +html[data-theme][data-navi-view="workspace"] .navi-footer-host:hover, +html[data-theme][data-navi-view="workspace"] .navi-footer-host:focus-within { + opacity: 1 !important; + transform: translateX(0) translateY(0) !important; +} + +/* ── 工作空间:点侧栏应用 → 在中间内嵌打开网页 ── + Dashy 的面板就是 iframe,我们只是让它在文档流里正常显示、并且只在 + 「可内嵌」时显示;被拒绝时改为明确提示,而不是一片空白。 + 用正常文档流(而不是 fixed 钉在视口中间),否则会把页面切成 + 「顶栏 / 大方块 / 底部」三段,看起来上下都被截断。 */ html[data-theme] .work-space { height: auto !important; min-height: 0 !important; } -html[data-theme] .navi-ws-pane { +html[data-theme] .work-space .multi-taking-view, +html[data-theme] .work-space .workspace-widget-view { + display: none !important; +} + +/* iframe 面板与提示卡共用同一块位置,切换状态时布局不跳动 */ +html[data-theme] .work-space .web-content, +html[data-theme] .work-space .navi-ws-notice { box-sizing: border-box !important; position: static !important; - display: flex !important; - flex-direction: column !important; - gap: 14px !important; - min-height: 340px !important; + display: none !important; + width: auto !important; + height: min(72vh, 720px) !important; + min-height: 300px !important; margin: 18px 18px 14px 72px !important; - padding: 20px !important; - background: rgba(255, 255, 255, .5) !important; - border: 1px solid rgba(255, 255, 255, .82) !important; + border: 1px solid rgba(255, 255, 255, .85) !important; border-radius: 20px !important; - box-shadow: 0 18px 44px rgba(15, 23, 42, .12), inset 0 1px 0 rgba(255, 255, 255, .92) !important; - backdrop-filter: blur(18px) saturate(180%) !important; - -webkit-backdrop-filter: blur(18px) saturate(180%) !important; + box-shadow: 0 18px 44px rgba(15, 23, 42, .14), inset 0 1px 0 rgba(255, 255, 255, .92) !important; + overflow: hidden !important; z-index: 1 !important; } -html[data-theme] .navi-ws-title { - margin: 0 !important; - color: #0f172a !important; - font-size: 19px !important; - font-weight: 650 !important; - letter-spacing: -.01em !important; +html[data-theme] .work-space .web-content { + padding: 0 !important; + background: #fff !important; } -html[data-theme] .navi-ws-hint { - color: #64748b !important; - font-size: 13.5px !important; +html[data-theme] .work-space[data-navi-ws="frame"] .web-content { + display: block !important; + margin-top: 8px !important; } -html[data-theme] .navi-ws-grid { - display: grid !important; - grid-template-columns: repeat(auto-fill, minmax(216px, 1fr)) !important; - gap: 12px !important; +/* 面板工具条:常驻退路(刷新 / 在新标签页打开),只在内嵌态出现 */ +html[data-theme] .navi-ws-bar { + display: none !important; } -html[data-theme] .navi-ws-card { +html[data-theme] .work-space[data-navi-ws="frame"] .navi-ws-bar { box-sizing: border-box !important; + position: static !important; display: flex !important; - flex-direction: column !important; - gap: 3px !important; - min-height: 78px !important; - padding: 14px 16px !important; - color: #0f172a !important; - text-decoration: none !important; - background: rgba(255, 255, 255, .68) !important; - border: 1px solid rgba(255, 255, 255, .9) !important; - border-radius: 14px !important; - box-shadow: 0 8px 20px rgba(15, 23, 42, .07) !important; - -webkit-tap-highlight-color: transparent !important; - transition: transform .22s cubic-bezier(.22, 1, .36, 1), box-shadow .22s ease !important; -} - -@media (hover: hover) { - html[data-theme] .navi-ws-card:hover { - transform: translateY(-2px); - box-shadow: 0 14px 30px rgba(15, 23, 42, .12) !important; - } -} - -html[data-theme] .navi-ws-card:active { - transform: scale(.99); -} - -html[data-theme] .navi-ws-card-icon { - display: inline-flex !important; align-items: center !important; - width: 26px !important; - height: 26px !important; - margin-bottom: 3px !important; + gap: 10px !important; + min-height: 40px !important; + margin: 18px 18px 0 72px !important; + padding: 5px 6px 5px 16px !important; + background: rgba(255, 255, 255, .55) !important; + border: 1px solid rgba(255, 255, 255, .82) !important; + border-radius: 999px !important; + box-shadow: 0 10px 26px rgba(15, 23, 42, .1), inset 0 1px 0 rgba(255, 255, 255, .92) !important; + backdrop-filter: blur(16px) saturate(180%) !important; + -webkit-backdrop-filter: blur(16px) saturate(180%) !important; } -html[data-theme] .navi-ws-card-icon img { - width: 26px !important; - height: 26px !important; - object-fit: contain !important; -} - -html[data-theme] .navi-ws-card-title { - font-size: 14.5px !important; +html[data-theme] .navi-ws-bar-host { + color: #0f172a !important; + font-size: 13.5px !important; font-weight: 600 !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } -html[data-theme] .navi-ws-card-url { - color: #64748b !important; - font-size: 12px !important; +html[data-theme] .navi-ws-bar-note { + color: #94a3b8 !important; + font-size: 12.5px !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } +html[data-theme] .navi-ws-bar-spacer { + flex: 1 1 auto !important; +} + +html[data-theme] .navi-ws-bar-btn { + flex: 0 0 auto !important; + padding: 6px 13px !important; + color: #334155 !important; + font-family: inherit !important; + font-size: 12.5px !important; + background: rgba(255, 255, 255, .72) !important; + border: 1px solid rgba(255, 255, 255, .9) !important; + border-radius: 999px !important; + cursor: pointer; + -webkit-tap-highlight-color: transparent !important; + outline: none !important; +} + +html[data-theme] .navi-ws-bar-btn:hover, +html[data-theme] .navi-ws-bar-btn:focus, +html[data-theme] .navi-ws-bar-btn:active { + color: #0f172a !important; + background: rgba(255, 255, 255, .95) !important; +} + +html[data-theme] .work-space .web-content iframe { + /* 必须是 static:Dashy 默认 absolute,包含块是 #dashy 而不是 .web-content, + 于是 .web-content 的 overflow:hidden 拦不住它,iframe 会按 #dashy 的高度 + 撑高整页(实测多出 77px 纵向滚动)。 */ + position: static !important; + display: block !important; + width: 100% !important; + height: 100% !important; + border: 0 !important; + background: #fff !important; +} + +html[data-theme] .work-space[data-navi-ws="hint"] .navi-ws-notice, +html[data-theme] .work-space[data-navi-ws="loading"] .navi-ws-notice, +html[data-theme] .work-space[data-navi-ws="blocked"] .navi-ws-notice { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + gap: 10px !important; + padding: 24px !important; + text-align: center !important; + background: rgba(255, 255, 255, .55) !important; + backdrop-filter: blur(18px) saturate(180%) !important; + -webkit-backdrop-filter: blur(18px) saturate(180%) !important; +} + +html[data-theme] .navi-ws-notice-title { + color: #0f172a !important; + font-size: 16px !important; + font-weight: 650 !important; +} + +html[data-theme] .navi-ws-notice-text { + color: #64748b !important; + font-size: 13.5px !important; +} + +html[data-theme] .navi-ws-notice-url { + max-width: 80% !important; + color: #94a3b8 !important; + font-size: 12.5px !important; + word-break: break-all !important; +} + +html[data-theme] .navi-ws-open { + margin-top: 6px !important; + padding: 9px 18px !important; + color: #fff !important; + font-family: inherit !important; + font-size: 13.5px !important; + background: linear-gradient(135deg, #0ea5e9, #2563eb) !important; + border: 0 !important; + border-radius: 999px !important; + box-shadow: 0 10px 24px rgba(37, 99, 235, .28) !important; + -webkit-tap-highlight-color: transparent !important; + outline: none !important; + cursor: pointer; +} + /* ── 工作空间:侧栏与内容区同样走玻璃圆角 ── */ html[data-theme] nav.side-bar { background: rgba(255, 255, 255, .62) !important; @@ -1020,6 +1170,7 @@ html[data-theme] .navi-footer a { html[data-theme] .minimal-section-inner.selected, html[data-theme] .work-space, html[data-theme] .navi-search-bar, + html[data-theme] .navi-dock, html[data-theme] .navi-admin-entry, html[data-theme] .navi-footer-host, html[data-theme] div.collapsible-content .item-wrapper, diff --git a/web/navi.js b/web/navi.js index 664ae32..85ff489 100644 --- a/web/navi.js +++ b/web/navi.js @@ -134,7 +134,7 @@ link.title = '导航管理'; link.setAttribute('aria-label', '导航管理'); link.append(gearIcon()); - document.body.append(link); + mountDock().append(link); } // 视图切换:Dashy 原生的切换按钮挤在侧栏底部、且是方形图标。 @@ -159,6 +159,19 @@ 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; @@ -210,137 +223,254 @@ }); wrap.append(btn, menu); - document.body.append(wrap); + mountDock().append(wrap); } - // Dashy 的「工作空间」右侧面板是一个 iframe:侧栏点应用时把目标 URL - // 内嵌进去。但多数应用(Gitea、VanBlog 等)用 X-Frame-Options 拒绝被嵌, - // 面板就是一片空白(实测:Refused to display … 'sameorigin')。 - // 所以这里改成:点左侧分组 → 右侧列出该分组的应用卡片,不依赖内嵌。 - const WS_ICON_RE = /\.(png|svg|jpe?g|webp|gif|ico)$/i; - let wsSections = null; - let wsPending = false; + // ── 工作空间:点侧栏应用 → 在中间内嵌打开 ── + // + // 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; - async function fetchWorkspaceSections() { - if (wsSections) return wsSections; - if (wsPending) return null; - wsPending = true; - try { - const res = await fetch('/admin/api/sections', { credentials: 'same-origin' }); - if (!res.ok) return null; - const data = await res.json(); - const list = Array.isArray(data) ? data : (data.sections || []); - // 与 Dashy 侧栏保持同序:剔除标记了 hideFromWorkspace 的分组。 - wsSections = list.filter((s) => !(s && s.displayData && s.displayData.hideFromWorkspace)); - return wsSections; - } catch (err) { - return null; - } finally { - wsPending = false; + 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 workspacePane() { - const host = document.querySelector('.work-space'); - if (!host) return null; - let pane = host.querySelector('.navi-ws-pane'); - if (!pane) { - pane = document.createElement('div'); - pane.className = 'navi-ws-pane'; - host.append(pane); - } - return pane; - } + 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); - function renderWorkspaceSection(section) { - const pane = workspacePane(); - if (!pane || !section) return; - while (pane.firstChild) pane.removeChild(pane.firstChild); - - const title = document.createElement('h2'); - title.className = 'navi-ws-title'; - title.textContent = section.name || ''; - pane.append(title); - - const items = (section.items || []).filter((it) => it && it.url); - if (!items.length) { - const empty = document.createElement('div'); - empty.className = 'navi-ws-hint'; - empty.textContent = '这个分组还没有项目'; - pane.append(empty); + 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); + } + } - const grid = document.createElement('div'); - grid.className = 'navi-ws-grid'; - items.forEach((it) => { - const card = document.createElement('a'); - card.className = 'navi-ws-card'; - card.href = it.url; - card.target = '_blank'; - card.rel = 'noopener noreferrer'; + function hostOf(url) { + try { + return new URL(url, location.origin).host || url; + } catch (err) { + return url; + } + } - const icon = String(it.icon || ''); - if (WS_ICON_RE.test(icon)) { - const wrap = document.createElement('span'); - wrap.className = 'navi-ws-card-icon'; - const img = document.createElement('img'); - img.src = '/item-icons/' + icon; - img.alt = ''; - img.loading = 'lazy'; - wrap.append(img); - card.append(wrap); + // 面板工具条:常驻的退路。即使内嵌失败(被 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; + } - const name = document.createElement('span'); - name.className = 'navi-ws-card-title'; - name.textContent = it.title || it.url; - card.append(name); + // 统一的唯一入口:无论目标 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 || '无法确认是否允许内嵌')); + } - const sub = document.createElement('span'); - sub.className = 'navi-ws-card-url'; - sub.textContent = String(it.description || '').trim() - || it.url.replace(/^https?:\/\//, '').replace(/\/$/, ''); - card.append(sub); - - grid.append(card); - }); - pane.append(grid); + // 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; - - document.addEventListener('click', async (evt) => { - if (!/^\/workspace/.test(location.pathname)) return; - const target = evt.target; - const head = target && target.closest ? target.closest('.side-bar-item-container') : null; - if (!head) return; - const sections = await fetchWorkspaceSections(); - if (!sections || !sections.length) return; - const heads = Array.from(document.querySelectorAll('nav.side-bar .side-bar-item-container')); - const idx = heads.indexOf(head); - if (idx >= 0 && sections[idx]) renderWorkspaceSection(sections[idx]); - }); + 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; - async function seedWorkspacePane() { + function seedWorkspacePane() { if (wsSeeded) return; if (!/^\/workspace/.test(location.pathname)) return; - if (!document.querySelector('.work-space')) return; + if (!document.querySelector('.work-space .web-content iframe')) return; wsSeeded = true; - const sections = await fetchWorkspaceSections(); - if (sections && sections.length) renderWorkspaceSection(sections[0]); + 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 的