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)。
This commit is contained in:
HomeDash Bot
2026-09-11 19:04:38 +08:00
parent 22c405d2a0
commit 8ba5e5117b
2 changed files with 485 additions and 192 deletions

View File

@ -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,

View File

@ -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 的 <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() {
if (footerPending || document.querySelector('.navi-footer')) return;
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' });
@ -351,7 +481,8 @@
// 用 DOMParser 解析(不用 innerHTML),只取 .navi-footer 节点。
const parsed = new DOMParser().parseFromString(html, 'text/html');
const node = parsed.querySelector('.navi-footer');
if (!node || document.querySelector('.navi-footer')) return;
if (!node || dashyFooterNode()) return;
if (document.querySelector('.navi-footer-host')) return;
const host = document.createElement('div');
host.className = 'navi-footer-host';
host.append(node);
@ -365,7 +496,18 @@
}
}
// 当前视图标记到 <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();