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:
331
web/navi.css
331
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,
|
||||
|
||||
346
web/navi.js
346
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 的 <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();
|
||||
|
||||
Reference in New Issue
Block a user