<script setup lang="ts">
import { ref, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useMenuStore, type TreeMenuItem } from '@core/stores/menu';
import { useUserStore } from '@core/stores/user';
import { getConfig } from '@core/configure';
import { openMenuTab } from '@core/utils/menuTab';
import ThemeSwitcher from '@core/components/ThemeSwitcher.vue';
import LayoutSwitcher from '@core/components/LayoutSwitcher.vue';
const menuStore = useMenuStore();
const userStore = useUserStore();
const { treeMenus, topLevelActiveMenu } = storeToRefs(menuStore);
const title = getConfig().base.title;
const openMenuId = ref<string | null>(null);
const currentUser = computed(() => userStore.userInfo);
const userInitial = computed(() =>
(currentUser.value?.displayName || currentUser.value?.name || 'U').charAt(0).toUpperCase(),
);
const userName = computed(() => currentUser.value?.displayName || currentUser.value?.name || '');
const isMenuActive = (menu: TreeMenuItem) => topLevelActiveMenu.value?.id === menu.id;
const hasChildren = (menu: TreeMenuItem) =>
Array.isArray(menu.children) && menu.children.length > 0;
const closeMenu = () => {
openMenuId.value = null;
};
const handleNavItemClick = (menu: TreeMenuItem) => {
if (!hasChildren(menu)) {
openMenuTab({ url: menu.path, title: menu.name });
menuStore.setActiveMenu(menu);
closeMenu();
} else {
openMenuId.value = openMenuId.value === menu.id ? null : menu.id;
}
};
const handleSubItemClick = (menu: TreeMenuItem) => {
openMenuTab({ url: menu.path, title: menu.name });
menuStore.setActiveMenu(menu);
closeMenu();
};
const handleMouseEnter = (menu: TreeMenuItem) => {
if (hasChildren(menu)) {
openMenuId.value = menu.id;
}
};
const handleMouseLeave = () => {
openMenuId.value = null;
};
const handleMegaMouseEnter = () => {
// 保持在菜单展开状态
};
const handleMegaMouseLeave = () => {
openMenuId.value = null;
};
const handleLogout = () => {
userStore.logout();
};
</script>
<template>
<header class="topnav">
<!-- Logo -->
<div class="tn-logo">
<div class="tn-mark">
<svg viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 1C1.45 1 1 1.45 1 2v5h3V4h3V1H2zm6 0v3h3v3h3V2c0-.55-.45-1-1-1H8zM1 8v6c0 .55.45 1 1 1h6v-3H5V8H1zm11 3v3H9v3h5c.55 0 1-.45 1-1v-5h-3z"
/>
</svg>
</div>
<div class="tn-name">{{ title }}</div>
</div>
<!-- 导航菜单 -->
<nav class="tn-nav">
<div
v-for="menu in treeMenus"
:key="menu.id"
class="tn-item"
:class="{ open: openMenuId === menu.id }"
@mouseenter="handleMouseEnter(menu)"
@mouseleave="handleMouseLeave"
>
<!-- 导航链接包装器 -->
<div class="tn-link-wrap">
<span
class="tn-link"
:class="{ active: isMenuActive(menu) && openMenuId !== menu.id }"
@click="handleNavItemClick(menu)"
>
{{ menu.title || menu.name }}
<svg
v-if="hasChildren(menu)"
class="chv"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M6 9l6 6 6-6" />
</svg>
</span>
<!-- 悬浮桥梁 - 连接导航和下拉菜单 -->
<div
v-if="hasChildren(menu) && openMenuId === menu.id"
class="mega-bridge"
@mouseenter="handleMegaMouseEnter"
@mouseleave="handleMegaMouseLeave"
></div>
<!-- 巨型下拉菜单 -->
<div
v-show="hasChildren(menu) && openMenuId === menu.id"
class="mega"
@mouseenter="handleMegaMouseEnter"
@mouseleave="handleMegaMouseLeave"
>
<div class="mega-cols">
<template v-for="(col, colIdx) in menu.children" :key="col.id">
<div v-if="colIdx > 0" class="mega-col-sep"></div>
<div class="mega-col">
<div class="mc-title">{{ col.title || col.name }}</div>
<template v-if="col.children && col.children.length > 0">
<div
v-for="item in col.children"
:key="item.id"
class="mc-item"
@click="handleSubItemClick(item)"
>
{{ item.title || item.name }}
</div>
</template>
<div v-else class="mc-item" @click="handleSubItemClick(col)">
{{ col.title || col.name }}
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</nav>
<!-- 右侧操作区 -->
<div class="tn-acts">
<!-- 主题切换 -->
<ThemeSwitcher>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="3" />
<path
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
/>
</svg>
</template>
</ThemeSwitcher>
<!-- 布局切换(注册了多个布局时自动显示) -->
<LayoutSwitcher>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="4" rx="1" />
<rect x="3" y="10" width="7" height="11" rx="1" />
<rect x="13" y="10" width="8" height="11" rx="1" />
</svg>
</template>
</LayoutSwitcher>
<div class="tn-dvd"></div>
<button class="tn-btn" title="全局搜索">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</button>
<button class="tn-btn" title="消息通知">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</button>
<div class="tn-dvd"></div>
<div class="tn-user">
<div class="tn-av">{{ userInitial }}</div>
<span class="tn-un">{{ userName }}</span>
</div>
<button class="tn-btn tn-logout" title="退出登录" @click="handleLogout">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
</header>
<!-- 遮罩层 -->
<div v-if="openMenuId" class="mega-overlay" @click="closeMenu"></div>
</template>
<style lang="scss" scoped>
$tn-h: 60px;
.topnav {
height: $tn-h;
background: var(--tn, #1a3328);
display: flex;
align-items: center;
padding: 0 18px 0 14px;
position: relative;
z-index: 100;
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.22);
}
// Logo
.tn-logo {
display: flex;
align-items: center;
gap: 10px;
padding-right: 18px;
border-right: 1px solid var(--tn-b, #224538);
margin-right: 6px;
flex-shrink: 0;
}
.tn-mark {
width: 32px;
height: 32px;
background: var(--tn-ac, #4ec685);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 17px;
height: 17px;
fill: var(--tn, #1a3328);
}
}
.tn-name {
font-size: 17px;
font-weight: 700;
color: #fff;
letter-spacing: 0.01em;
white-space: nowrap;
}
// 导航菜单
.tn-nav {
display: flex;
align-items: stretch;
flex: 1;
height: 100%;
overflow: hidden;
}
.tn-item {
position: relative;
display: flex;
align-items: center;
height: 100%;
&:hover {
.tn-link {
color: var(--tn-ac, #4ec685);
background: rgba(255, 255, 255, 0.06);
}
}
&.open {
.tn-link {
color: var(--tn-ac, #4ec685);
background: rgba(255, 255, 255, 0.08);
}
}
}
.tn-link {
display: flex;
align-items: center;
gap: 5px;
padding: 0 13px;
height: 100%;
cursor: pointer;
user-select: none;
color: var(--tn-t, #74b898);
font-size: 13.5px;
font-weight: 500;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition:
color 0.15s,
background 0.15s;
&:hover {
color: var(--tn-ac, #4ec685);
background: rgba(255, 255, 255, 0.06);
}
&.active {
color: var(--tn-ac, #4ec685);
border-bottom-color: var(--tn-ac, #4ec685);
}
}
.tn-item.open .tn-link {
color: var(--tn-ac, #4ec685);
background: rgba(255, 255, 255, 0.08);
}
.chv {
transition: transform 0.2s;
opacity: 0.6;
}
.tn-item.open .chv {
transform: rotate(180deg);
}
// 巨型下拉菜单
.mega {
position: fixed;
left: 0;
right: 0;
top: $tn-h;
background: #fff;
border-top: 2px solid var(--tn-ac, #4ec685);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
padding: 22px 28px 24px;
display: flex;
z-index: 200;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.tn-item.open .mega,
.tn-item:hover .mega {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
// 导航链接包装器
.tn-link-wrap {
position: relative;
height: 100%;
display: flex;
align-items: center;
}
// 悬浮桥梁 - 连接导航和下拉菜单
.mega-bridge {
position: absolute;
left: 0;
right: 0;
top: 100%;
height: 20px;
background: transparent;
z-index: 199;
pointer-events: auto;
}
.mega-cols {
display: flex;
flex-wrap: wrap;
flex: 1;
gap: 0;
}
.mega-col {
min-width: 150px;
padding: 0 24px 0 0;
margin: 0 0 14px;
&:first-child {
padding-left: 0;
}
}
.mega-col-sep {
width: 1px;
background: #e0e6da;
margin: 0 10px 14px;
flex-shrink: 0;
align-self: stretch;
}
.mc-title {
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #87a080;
margin-bottom: 9px;
padding-bottom: 7px;
border-bottom: 1px solid #e0e6da;
}
.mc-item {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 8px;
border-radius: 5px;
color: #4a604a;
font-size: 13px;
cursor: pointer;
transition:
background 0.12s,
color 0.12s;
white-space: nowrap;
&::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: #e0e6da;
flex-shrink: 0;
transition: background 0.12s;
}
&:hover {
background: #e8f5ee;
color: #1d7040;
&::before {
background: #1d7040;
}
}
}
// 遮罩
.mega-overlay {
position: fixed;
inset: 0;
top: $tn-h;
background: rgba(0, 0, 0, 0.06);
z-index: 150;
pointer-events: none;
}
// 右侧操作区
.tn-acts {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
flex-shrink: 0;
}
.tn-btn {
width: 34px;
height: 34px;
border-radius: 8px;
border: none;
background: rgba(255, 255, 255, 0.06);
color: var(--tn-t, #74b898);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--tn-ac, #4ec685);
}
}
.tn-logout:hover {
background: rgba(220, 38, 38, 0.15) !important;
color: #f87171 !important;
}
.tn-dvd {
width: 1px;
height: 20px;
background: var(--tn-b, #224538);
margin: 0 3px;
}
.tn-user {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 4px 8px;
border-radius: 8px;
cursor: pointer;
color: var(--tn-t, #74b898);
transition:
background 0.15s,
color 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--tn-ac, #4ec685);
}
}
.tn-av {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, var(--tn-ac, #4ec685) 0%, #a8e6c8 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--tn, #1a3328);
flex-shrink: 0;
}
.tn-un {
font-size: 13px;
font-weight: 500;
}
</style>
|