From 9a79bbccefdab55e1fb0d0892999d7ca95887d2d Mon Sep 17 00:00:00 2001 From: GouJ Date: Mon, 14 Sep 2026 14:01:09 +0800 Subject: [PATCH] fix: reveal collapsed desktop sidebar labels together on hover --- src/App.vue | 199 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 117 insertions(+), 82 deletions(-) diff --git a/src/App.vue b/src/App.vue index 3e686f6..d6deb3e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,7 +8,6 @@ import { NLayout, NLayoutHeader, NMenu, - NPopover, NScrollbar, zhCN, dateZhCN, @@ -34,8 +33,8 @@ import { projectAccessKey, type ProjectAccess } from './features/projects/access /** 窄屏将侧栏改为全屏覆盖层,关闭后不再占用正文宽度。 */ const NARROW_NAV = '(max-width: 800px)' -/** 紧凑侧栏为图标与常驻菜单名称保留空间。 */ -const SIDER_COLLAPSED_WIDTH = 144 +/** 折叠侧栏只占图标宽度,悬停文字覆盖正文而不挤动布局。 */ +const SIDER_COLLAPSED_WIDTH = 64 /** 桌面端展开侧栏宽度。 */ const SIDER_EXPANDED_WIDTH = 208 /** 菜单图标尺寸,折叠/展开共用,避免切换时缩放。 */ @@ -47,12 +46,8 @@ const narrowMedia = window.matchMedia(NARROW_NAV) const narrow = ref(narrowMedia.matches) const collapsed = ref(narrow.value) const settingsOpen = ref(false) -/** 桌面紧凑态保留所有菜单名称;移动端打开时仍展示完整菜单。 */ +/** 始终保留菜单文字节点,用 CSS 同步控制整列文字的进出动画。 */ const menuCollapsed = false -/** 菜单内部会缓存图标 VNode,切换显示模式时重建一次以同步 Popover 状态。 */ -const menuRenderKey = computed( - () => `${narrow.value ? 'mobile' : 'desktop'}-${collapsed.value ? 'collapsed' : 'expanded'}` -) /** Naive Menu 的桌面折叠宽度;移动端覆盖层不参与正文宽度计算。 */ const siderCollapsedWidth = SIDER_COLLAPSED_WIDTH /** 侧栏展开宽度仅用于桌面布局。 */ @@ -116,41 +111,18 @@ const workflowItems = [ ['production', '镜头生产', Clapperboard] ] as const -/** PC 折叠侧栏只显示图标时渲染 Popover;可用图标同时保留路由跳转能力。 */ -function createMenuIcon(icon: typeof FolderOpen, label: string, popoverEnabled: boolean, target?: string) { - return () => { - const trigger = () => - popoverEnabled && target - ? h(RouterLink, { to: target, class: 'admin-menu-popover-trigger' }, () => h(icon, { size: 18 })) - : h('span', { class: 'admin-menu-popover-trigger' }, [h(icon, { size: 18 })]) - if (!popoverEnabled) return trigger() - - return h( - NPopover, - { - trigger: 'hover', - placement: 'right', - to: 'body', - class: 'admin-nav-popover', - zIndex: 3000 - }, - { - trigger, - default: () => label - } - ) - } +/** 图标始终留在原位,名称由菜单本身显示,不再创建逐项浮层。 */ +function createMenuIcon(icon: typeof FolderOpen) { + return () => h('span', { class: 'admin-menu-icon' }, [h(icon, { size: NAV_ICON_SIZE })]) } /** 菜单链接使用 RouterLink,保留新标签打开和浏览器导航行为。 */ const menuOptions = computed(() => { - // 菜单名称在紧凑态也常驻显示,不再重复弹出逐项提示。 - const popoverEnabled = false return [ { key: '/projects', - label: () => h(RouterLink, { to: '/projects' }, () => '我的剧本'), - icon: createMenuIcon(FolderOpen, '我的剧本', popoverEnabled, '/projects') + label: () => h(RouterLink, { to: '/projects' }, () => h('span', { class: 'admin-menu-label' }, '我的剧本')), + icon: createMenuIcon(FolderOpen) }, ...(projectId.value ? [ @@ -163,14 +135,15 @@ const menuOptions = computed(() => { disabled, label: () => disabled - ? h('span', { title: '剧本完成后可用', 'aria-disabled': 'true' }, label) - : h(RouterLink, { to: target }, () => label), - icon: createMenuIcon( - icon, - disabled ? `${label}(剧本完成后可用)` : label, - popoverEnabled, - disabled ? undefined : target - ) + ? h( + 'span', + { class: 'admin-menu-label', title: '剧本完成后可用', 'aria-disabled': 'true' }, + label + ) + : h(RouterLink, { to: target }, () => + h('span', { class: 'admin-menu-label' }, label) + ), + icon: createMenuIcon(icon) } }) ] @@ -232,7 +205,6 @@ const menuOptions = computed(() => { (() => { :root-indent="navIconIndent" /> - +
- - - 后端连接 - + + 后端连接 +
@@ -319,7 +279,7 @@ const menuOptions = computed(() => { @apply top-2.5 z-[3000]; } .admin-layout { - --admin-sider-collapsed: 144px; + --admin-sider-collapsed: 64px; --admin-sider-expanded: 208px; @apply flex h-dvh overflow-hidden; } @@ -340,7 +300,7 @@ const menuOptions = computed(() => { @apply hidden shrink-0; } .admin-sider.is-collapsed { - @apply w-16; + width: var(--admin-sider-collapsed); } .admin-nav-scroll.n-scrollbar { @apply flex-1 min-h-0; @@ -348,14 +308,16 @@ const menuOptions = computed(() => { } .admin-sider .n-menu-item-content-header { opacity: 1; - transition: opacity 0.2s ease; + transition: + opacity 0.2s ease, + transform 0.2s ease; } .admin-sider.is-collapsed .n-menu-item-content-header { - opacity: 1; + opacity: 0; } -/* 折叠态将图标抬到透明路由链接上方,使点击与悬停都命中图标触发区。 */ +/* 点击图标时命中菜单链接覆盖层,隐藏名称也不会阻断跳转。 */ .admin-sider.is-collapsed .n-menu-item-content__icon { - @apply relative z-[2]; + pointer-events: none; } .admin-sider-footer { @apply w-full shrink-0 overflow-hidden; @@ -370,20 +332,22 @@ const menuOptions = computed(() => { .admin-settings-label { @apply whitespace-nowrap; opacity: 1; - transition: opacity 0.2s ease; + transition: + opacity 0.2s ease, + transform 0.2s ease; } .admin-sider.is-collapsed .admin-settings-label { - opacity: 1; + opacity: 0; } .admin-sider .n-menu .n-menu-item-content::before { @apply left-0 right-0 rounded-none; } -.admin-menu-popover-trigger { - @apply relative z-[1] inline-flex items-center justify-center text-inherit; +.admin-menu-icon { + @apply inline-flex items-center justify-center text-inherit; } -/* 折叠侧栏提示完整落在 64px 边界之外,并覆盖业务内容层。 */ -.n-popover.admin-nav-popover { - margin-inline-start: 24px !important; +.admin-menu-label { + display: inline-block; + transition: transform 0.2s ease; } .admin-brand { @apply flex shrink-0 items-center gap-3 h-[74px] overflow-hidden py-0 px-5 text-ink whitespace-nowrap; @@ -395,7 +359,9 @@ const menuOptions = computed(() => { .admin-brand span { @apply text-[15px] font-semibold; opacity: 1; - transition: opacity 0.2s ease; + transition: + opacity 0.2s ease, + transform 0.2s ease; } .admin-sider.is-collapsed .admin-brand span { opacity: 0; @@ -412,6 +378,70 @@ const menuOptions = computed(() => { .admin-content { @apply flex-1 min-h-0 min-w-0 overflow-hidden outline-none; } +/* 桌面折叠态:64px 图标轨道固定,整列名称在右侧浮现。 */ +@media (min-width: 801px) { + .admin-sider.is-collapsed { + --admin-nav-reveal-width: 160px; + position: relative; + z-index: 30; + overflow: visible; + } + /* 浮层使用当前主题底色,保证文字不会与正文混在一起。 */ + .admin-sider.is-collapsed::before { + content: ''; + position: absolute; + inset: 0 auto 0 var(--admin-sider-collapsed); + width: calc(var(--admin-nav-reveal-width) - var(--admin-sider-collapsed)); + background: var(--app-surface); + border-right: 1px solid var(--app-border); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + .admin-sider.is-collapsed .admin-nav-scroll.n-scrollbar, + .admin-sider.is-collapsed .admin-sider-footer { + position: relative; + width: var(--admin-nav-reveal-width); + min-width: var(--admin-nav-reveal-width); + /* 隐藏的名称不能阻挡正文点击,淡出结束后再收回命中区域。 */ + clip-path: inset(0 calc(var(--admin-nav-reveal-width) - var(--admin-sider-collapsed)) 0 0); + transition: clip-path 0s 0.2s; + } + .admin-sider.is-collapsed:is(:hover, :has(:focus-visible))::before { + opacity: 1; + } + .admin-sider.is-collapsed:is(:hover, :has(:focus-visible)) .admin-nav-scroll.n-scrollbar, + .admin-sider.is-collapsed:is(:hover, :has(:focus-visible)) .admin-sider-footer { + clip-path: inset(0); + transition-delay: 0s; + } + .admin-sider.is-collapsed .n-menu-item-content__icon { + margin-right: 34px !important; + } + .admin-sider.is-collapsed .admin-settings-label { + margin-left: 26px; + } + .admin-sider.is-collapsed .admin-menu-label, + .admin-sider.is-collapsed .admin-settings-label { + transform: translateX(-6px); + } + .admin-sider.is-collapsed:is(:hover, :has(:focus-visible)) .n-menu-item-content-header, + .admin-sider.is-collapsed:is(:hover, :has(:focus-visible)) .admin-settings-label { + opacity: 1; + } + .admin-sider.is-collapsed:is(:hover, :has(:focus-visible)) .admin-menu-label, + .admin-sider.is-collapsed:is(:hover, :has(:focus-visible)) .admin-settings-label { + transform: translateX(0); + } + /* 选中、悬停背景仅落在图标轨道内。 */ + .admin-sider.is-collapsed .n-menu .n-menu-item-content::before { + right: calc(var(--admin-nav-reveal-width) - var(--admin-sider-collapsed)); + } + .admin-sider.is-collapsed .admin-settings-button .n-button__state-border, + .admin-sider.is-collapsed .admin-settings-button .n-button__border { + right: calc(var(--admin-nav-reveal-width) - var(--admin-sider-collapsed)); + } +} @media (max-width: 800px) { .admin-sider { @apply fixed inset-0 z-50 overflow-hidden; @@ -457,7 +487,12 @@ const menuOptions = computed(() => { } @media (prefers-reduced-motion: reduce) { .admin-sider, + .admin-sider.is-collapsed, + .admin-sider.is-collapsed::before, + .admin-sider.is-collapsed .admin-nav-scroll.n-scrollbar, + .admin-sider.is-collapsed .admin-sider-footer, .admin-sider .n-menu-item-content-header, + .admin-menu-label, .admin-brand span, .admin-settings-label { transition: none;