|
|
@@ -6,12 +6,27 @@
|
|
|
</div>
|
|
|
|
|
|
<nav class="nav-main">
|
|
|
- <a
|
|
|
+ <div
|
|
|
v-for="item in topMenus"
|
|
|
:key="item.id"
|
|
|
- :class="{ on: item.id === activeId }"
|
|
|
- @click="onSelect(item)"
|
|
|
- >{{ item.name }}</a>
|
|
|
+ class="nav-item"
|
|
|
+ :ref="(el) => bindL1Ref(item.id, el)"
|
|
|
+ @mouseenter="onL1Enter(item)"
|
|
|
+ @mouseleave="onL1Leave"
|
|
|
+ >
|
|
|
+ <!-- 仅目录(无 route)的 L1:渲染为 span,点击不导航;只用于 hover 弹出下拉 -->
|
|
|
+ <span
|
|
|
+ v-if="!item.route"
|
|
|
+ class="nav-link nav-category"
|
|
|
+ >{{ item.name }}</span>
|
|
|
+ <!-- 真正可点击进入的 L1(有 route) -->
|
|
|
+ <a
|
|
|
+ v-else
|
|
|
+ class="nav-link"
|
|
|
+ :class="{ on: isL1Active(item) }"
|
|
|
+ @click="onL1Click(item)"
|
|
|
+ >{{ item.name }}</a>
|
|
|
+ </div>
|
|
|
<span v-if="!loading && topMenus.length === 0" class="nav-empty">暂无菜单</span>
|
|
|
</nav>
|
|
|
|
|
|
@@ -25,10 +40,56 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
</header>
|
|
|
+
|
|
|
+ <!--
|
|
|
+ 弹出层通过 Teleport 渲染到 body,避开 .nav-main / .app-shell 的 overflow 裁剪,
|
|
|
+ 使用 position: fixed + 实时 getBoundingClientRect() 定位
|
|
|
+ -->
|
|
|
+ <Teleport to="body">
|
|
|
+ <!-- L1 下拉:L2 列表 -->
|
|
|
+ <div
|
|
|
+ v-if="openL1Item && showL1Dropdown(openL1Item)"
|
|
|
+ class="g-dropdown"
|
|
|
+ :style="l1DropdownStyle"
|
|
|
+ @mouseenter="cancelL1Close"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ v-for="l2 in openL1Item.children"
|
|
|
+ :key="l2.id"
|
|
|
+ class="g-dropdown-item"
|
|
|
+ :class="{ active: isL2Active(l2) }"
|
|
|
+ :ref="(el) => bindL2Ref(l2.id, el)"
|
|
|
+ @mouseenter="onL2Enter(l2)"
|
|
|
+ @mouseleave="onL2Leave"
|
|
|
+ @click="onL2Click(l2)"
|
|
|
+ >
|
|
|
+ <span class="g-dropdown-label">{{ l2.name }}</span>
|
|
|
+ <span v-if="showL2SubDropdown(l2, openL1Item)" class="caret">›</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- L2 子下拉:L3 列表 -->
|
|
|
+ <div
|
|
|
+ v-if="openL2Item && showL2SubDropdown(openL2Item, openL1Item)"
|
|
|
+ class="g-dropdown"
|
|
|
+ :style="l2DropdownStyle"
|
|
|
+ @mouseenter="cancelL2Close"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ v-for="l3 in openL2Item.children"
|
|
|
+ :key="l3.id"
|
|
|
+ class="g-dropdown-item"
|
|
|
+ :class="{ active: isL3Active(l3) }"
|
|
|
+ @click="onL3Click(l3)"
|
|
|
+ >
|
|
|
+ <span>{{ l3.name }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Teleport>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { computed, onMounted, watch } from 'vue'
|
|
|
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
|
import { useAuth } from '@/store/auth'
|
|
|
import { useMenu } from '@/store/menu'
|
|
|
@@ -39,16 +100,185 @@ defineEmits(['logout'])
|
|
|
const router = useRouter()
|
|
|
const route = useRoute()
|
|
|
const { auth } = useAuth()
|
|
|
-const { state, load, setActive, activeTopMenu } = useMenu()
|
|
|
+const {
|
|
|
+ state,
|
|
|
+ load,
|
|
|
+ selectLevel,
|
|
|
+ activeByLevel,
|
|
|
+} = useMenu()
|
|
|
|
|
|
-const topMenus = computed(() => state.topMenus)
|
|
|
-const activeId = computed(() => state.activeId)
|
|
|
const loading = computed(() => state.loading)
|
|
|
+const topMenus = computed(() => state.topMenus)
|
|
|
+
|
|
|
+function hasChildren(item) {
|
|
|
+ return Array.isArray(item?.children) && item.children.length > 0
|
|
|
+}
|
|
|
+
|
|
|
+// 计算单个节点所在子树的深度(包含自身)
|
|
|
+function branchDepth(item) {
|
|
|
+ if (!item) return 0
|
|
|
+ let max = 1
|
|
|
+ if (Array.isArray(item.children) && item.children.length) {
|
|
|
+ for (const c of item.children) {
|
|
|
+ const d = branchDepth(c)
|
|
|
+ if (d + 1 > max) max = d + 1
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return max
|
|
|
+}
|
|
|
+
|
|
|
+// L1 下拉:仅当 L1 子树深度 ≥ 3 时弹出(深度 = 2 时 L2 只在侧边栏)
|
|
|
+function showL1Dropdown(item) {
|
|
|
+ return hasChildren(item) && branchDepth(item) >= 3
|
|
|
+}
|
|
|
+// L2 子下拉:仅当所属 L1 子树深度 ≥ 4 时弹出(深度 = 3 时 L3 只在侧边栏)
|
|
|
+function showL2SubDropdown(l2Item, l1Item) {
|
|
|
+ return hasChildren(l2Item) && branchDepth(l1Item) >= 4
|
|
|
+}
|
|
|
+
|
|
|
+// 当前打开的菜单 id
|
|
|
+const openL1Id = ref(null)
|
|
|
+const openL2Id = ref(null)
|
|
|
+// 当前打开菜单对应的 DOM 矩形(用于 fixed 定位)
|
|
|
+const l1Rect = ref(null)
|
|
|
+const l2Rect = ref(null)
|
|
|
+// 用 Map 保存各级菜单项的 DOM 引用
|
|
|
+const l1Refs = new Map()
|
|
|
+const l2Refs = new Map()
|
|
|
+
|
|
|
+let l1CloseTimer = null
|
|
|
+let l2CloseTimer = null
|
|
|
+
|
|
|
+function bindL1Ref(id, el) {
|
|
|
+ if (el) l1Refs.set(id, el)
|
|
|
+ else l1Refs.delete(id)
|
|
|
+}
|
|
|
+function bindL2Ref(id, el) {
|
|
|
+ if (el) l2Refs.set(id, el)
|
|
|
+ else l2Refs.delete(id)
|
|
|
+}
|
|
|
+
|
|
|
+const openL1Item = computed(() => {
|
|
|
+ if (!openL1Id.value) return null
|
|
|
+ return topMenus.value.find((m) => m.id === openL1Id.value) || null
|
|
|
+})
|
|
|
+const openL2Item = computed(() => {
|
|
|
+ if (!openL1Item.value || !openL2Id.value) return null
|
|
|
+ const children = Array.isArray(openL1Item.value.children) ? openL1Item.value.children : []
|
|
|
+ return children.find((c) => c.id === openL2Id.value) || null
|
|
|
+})
|
|
|
+
|
|
|
+const l1DropdownStyle = computed(() => {
|
|
|
+ const rect = l1Rect.value
|
|
|
+ if (!rect) return { display: 'none' }
|
|
|
+ return {
|
|
|
+ position: 'fixed',
|
|
|
+ top: `${Math.round(rect.bottom)}px`,
|
|
|
+ left: `${Math.round(rect.left)}px`,
|
|
|
+ }
|
|
|
+})
|
|
|
+const l2DropdownStyle = computed(() => {
|
|
|
+ const rect = l2Rect.value
|
|
|
+ if (!rect) return { display: 'none' }
|
|
|
+ return {
|
|
|
+ position: 'fixed',
|
|
|
+ top: `${Math.round(rect.top)}px`,
|
|
|
+ left: `${Math.round(rect.right + 4)}px`,
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+function onL1Enter(item) {
|
|
|
+ if (l1CloseTimer) { clearTimeout(l1CloseTimer); l1CloseTimer = null }
|
|
|
+ if (openL1Id.value !== item.id) {
|
|
|
+ openL2Id.value = null
|
|
|
+ l2Rect.value = null
|
|
|
+ }
|
|
|
+ openL1Id.value = item.id
|
|
|
+ const el = l1Refs.get(item.id)
|
|
|
+ if (el) l1Rect.value = el.getBoundingClientRect()
|
|
|
+}
|
|
|
+function onL1Leave() {
|
|
|
+ l1CloseTimer = setTimeout(() => {
|
|
|
+ openL1Id.value = null
|
|
|
+ openL2Id.value = null
|
|
|
+ l1Rect.value = null
|
|
|
+ l2Rect.value = null
|
|
|
+ l1CloseTimer = null
|
|
|
+ }, 150)
|
|
|
+}
|
|
|
+function cancelL1Close() {
|
|
|
+ if (l1CloseTimer) { clearTimeout(l1CloseTimer); l1CloseTimer = null }
|
|
|
+}
|
|
|
+
|
|
|
+function onL2Enter(item) {
|
|
|
+ if (l2CloseTimer) { clearTimeout(l2CloseTimer); l2CloseTimer = null }
|
|
|
+ openL2Id.value = item.id
|
|
|
+ const el = l2Refs.get(item.id)
|
|
|
+ if (el) l2Rect.value = el.getBoundingClientRect()
|
|
|
+}
|
|
|
+function onL2Leave() {
|
|
|
+ l2CloseTimer = setTimeout(() => {
|
|
|
+ openL2Id.value = null
|
|
|
+ l2Rect.value = null
|
|
|
+ l2CloseTimer = null
|
|
|
+ }, 150)
|
|
|
+}
|
|
|
+function cancelL2Close() {
|
|
|
+ if (l2CloseTimer) { clearTimeout(l2CloseTimer); l2CloseTimer = null }
|
|
|
+}
|
|
|
+
|
|
|
+function isL1Active(item) {
|
|
|
+ return activeByLevel.value[0]?.id === item.id
|
|
|
+}
|
|
|
+function isL2Active(item) {
|
|
|
+ return activeByLevel.value[1]?.id === item.id
|
|
|
+}
|
|
|
+function isL3Active(item) {
|
|
|
+ return activeByLevel.value[2]?.id === item.id
|
|
|
+}
|
|
|
+
|
|
|
+function onL1Click(item) {
|
|
|
+ // 没有 route 的 L1 已经在模板里渲染为 span,不会触发此函数
|
|
|
+ selectLevel(1, item)
|
|
|
+}
|
|
|
+function onL2Click(item) { selectLevel(2, item) }
|
|
|
+function onL3Click(item) { selectLevel(3, item) }
|
|
|
+
|
|
|
+function refreshRects() {
|
|
|
+ if (openL1Id.value) {
|
|
|
+ const el = l1Refs.get(openL1Id.value)
|
|
|
+ if (el) l1Rect.value = el.getBoundingClientRect()
|
|
|
+ }
|
|
|
+ if (openL2Id.value) {
|
|
|
+ const el = l2Refs.get(openL2Id.value)
|
|
|
+ if (el) l2Rect.value = el.getBoundingClientRect()
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
onMounted(() => {
|
|
|
if (!state.loaded) load()
|
|
|
+ window.addEventListener('resize', refreshRects)
|
|
|
+ window.addEventListener('scroll', refreshRects, true)
|
|
|
})
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ window.removeEventListener('resize', refreshRects)
|
|
|
+ window.removeEventListener('scroll', refreshRects, true)
|
|
|
+ if (l1CloseTimer) clearTimeout(l1CloseTimer)
|
|
|
+ if (l2CloseTimer) clearTimeout(l2CloseTimer)
|
|
|
+})
|
|
|
+
|
|
|
+// 路由变化时关闭弹出(避免残留)
|
|
|
+watch(
|
|
|
+ () => route.path,
|
|
|
+ () => {
|
|
|
+ openL1Id.value = null
|
|
|
+ openL2Id.value = null
|
|
|
+ l1Rect.value = null
|
|
|
+ l2Rect.value = null
|
|
|
+ },
|
|
|
+)
|
|
|
|
|
|
+// 菜单加载完成后,如果浏览器当前正处在根路径 /,引导到首个有路由的菜单项
|
|
|
let didInitNav = false
|
|
|
watch(
|
|
|
() => state.loaded,
|
|
|
@@ -57,33 +287,17 @@ watch(
|
|
|
didInitNav = true
|
|
|
const first = state.topMenus[0]
|
|
|
if (!first) return
|
|
|
- let target = null
|
|
|
- if (first.route) {
|
|
|
- target = first
|
|
|
- } else if (Array.isArray(first.children) && first.children.length > 0) {
|
|
|
- target = first.children[0]
|
|
|
- }
|
|
|
- if (target?.route && route.path !== target.route) {
|
|
|
- router.replace(target.route)
|
|
|
+ const currentPath = typeof window !== 'undefined' ? window.location.pathname : (route.path || '')
|
|
|
+ if (currentPath !== '/') return
|
|
|
+ let target = first
|
|
|
+ while (target && !target.route) {
|
|
|
+ target = Array.isArray(target.children) && target.children.length ? target.children[0] : null
|
|
|
}
|
|
|
+ if (target?.route) router.replace(target.route)
|
|
|
},
|
|
|
{ immediate: true },
|
|
|
)
|
|
|
|
|
|
-function onSelect(item) {
|
|
|
- setActive(item.id)
|
|
|
- const active = activeTopMenu.value
|
|
|
- if (!active) return
|
|
|
- if (active.route) {
|
|
|
- router.push(active.route)
|
|
|
- return
|
|
|
- }
|
|
|
- const firstChild = Array.isArray(active.children) ? active.children[0] : null
|
|
|
- if (firstChild?.route && route.path !== firstChild.route) {
|
|
|
- router.push(firstChild.route)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
const displayName = computed(() => auth.nickname || auth.account || '未登录')
|
|
|
const avatarText = computed(() => {
|
|
|
const src = auth.nickname || auth.account
|
|
|
@@ -112,6 +326,7 @@ const roleText = ''
|
|
|
background: #fff;
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
z-index: 10;
|
|
|
+ position: relative;
|
|
|
}
|
|
|
|
|
|
.brand {
|
|
|
@@ -141,36 +356,54 @@ const roleText = ''
|
|
|
color: #1A2438;
|
|
|
}
|
|
|
|
|
|
+/* === L1 一级菜单:保持原样式(横向胶囊式 tab) === */
|
|
|
.nav-main {
|
|
|
display: flex;
|
|
|
gap: 6px;
|
|
|
margin-left: 50px;
|
|
|
flex: 1;
|
|
|
min-width: 0;
|
|
|
- overflow-x: auto;
|
|
|
+ overflow: visible;
|
|
|
+ align-items: center;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.nav-item {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
}
|
|
|
|
|
|
-.nav-main a {
|
|
|
+.nav-link {
|
|
|
padding: 7px 14px;
|
|
|
border-radius: 6px;
|
|
|
font-size: 13px;
|
|
|
color: var(--mut);
|
|
|
transition: 0.15s;
|
|
|
- cursor: pointer;
|
|
|
white-space: nowrap;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+
|
|
|
+a.nav-link {
|
|
|
+ cursor: pointer;
|
|
|
}
|
|
|
|
|
|
-.nav-main a:hover {
|
|
|
+.nav-link:hover {
|
|
|
background: #f0f3f8;
|
|
|
color: var(--ink);
|
|
|
}
|
|
|
|
|
|
-.nav-main a.on {
|
|
|
+a.nav-link.on {
|
|
|
background: var(--brand-lt);
|
|
|
color: var(--brand-d);
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
+/* 仅目录的 L1:不可点击 */
|
|
|
+.nav-category {
|
|
|
+ cursor: default;
|
|
|
+}
|
|
|
+
|
|
|
.nav-empty {
|
|
|
padding: 7px 14px;
|
|
|
font-size: 12px;
|
|
|
@@ -184,34 +417,6 @@ const roleText = ''
|
|
|
gap: 14px;
|
|
|
}
|
|
|
|
|
|
-.icon-btn {
|
|
|
- width: 32px;
|
|
|
- height: 32px;
|
|
|
- border-radius: 6px;
|
|
|
- display: grid;
|
|
|
- place-items: center;
|
|
|
- cursor: pointer;
|
|
|
- color: var(--mut);
|
|
|
- position: relative;
|
|
|
- font-size: 14px;
|
|
|
-}
|
|
|
-
|
|
|
-.icon-btn:hover {
|
|
|
- background: #f0f3f8;
|
|
|
- color: var(--ink);
|
|
|
-}
|
|
|
-
|
|
|
-.icon-btn .badge-dot {
|
|
|
- position: absolute;
|
|
|
- top: 6px;
|
|
|
- right: 6px;
|
|
|
- width: 6px;
|
|
|
- height: 6px;
|
|
|
- background: var(--up);
|
|
|
- border-radius: 50%;
|
|
|
- border: 1.5px solid #fff;
|
|
|
-}
|
|
|
-
|
|
|
.user-info {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
@@ -247,4 +452,51 @@ const roleText = ''
|
|
|
font-size: 10.5px;
|
|
|
color: var(--faint);
|
|
|
}
|
|
|
+</style>
|
|
|
+
|
|
|
+<style>
|
|
|
+/* 全局样式:下拉面板由 Teleport 渲染到 body,必须脱离 scoped 才能命中 */
|
|
|
+.g-dropdown {
|
|
|
+ min-width: 180px;
|
|
|
+ max-width: 280px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e4e9f2;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 8px 28px rgba(15, 32, 64, 0.14);
|
|
|
+ padding: 6px;
|
|
|
+ z-index: 1000;
|
|
|
+}
|
|
|
+
|
|
|
+.g-dropdown-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-radius: 5px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #1a2438;
|
|
|
+ cursor: pointer;
|
|
|
+ white-space: nowrap;
|
|
|
+ transition: background 0.12s, color 0.12s;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+
|
|
|
+.g-dropdown-item:hover {
|
|
|
+ background: #f7f9fd;
|
|
|
+ color: #0e3a96;
|
|
|
+}
|
|
|
+
|
|
|
+.g-dropdown-item.active {
|
|
|
+ background: #eaf1ff;
|
|
|
+ color: #0e3a96;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.g-dropdown-item .caret {
|
|
|
+ color: #98a5bd;
|
|
|
+ font-size: 16px;
|
|
|
+ line-height: 1;
|
|
|
+ font-weight: 400;
|
|
|
+}
|
|
|
</style>
|