Files
jczxw-pc/app/components/SiteHeader.vue
2026-04-29 10:05:55 +08:00

434 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-affix :offset-top="0">
<a-layout-header class="header">
<div class="nav-bar mx-auto max-w-screen-xl px-4 h-full">
<!-- 左侧Logo + 菜单 -->
<div class="flex items-center gap-8 nav-left">
<!-- Logo -->
<NuxtLink to="/" class="flex items-center logo-link cursor-pointer flex-shrink-0">
<div class="logo-text">决策咨询网</div>
</NuxtLink>
<!-- PC 导航菜单 -->
<a-menu
mode="horizontal"
theme="dark"
:selected-keys="selectedKeys"
class="menu hidden md:flex"
>
<template v-for="item in nav" :key="item.key">
<a-sub-menu v-if="item.children && item.children.length" :key="item.key">
<template #title>
<NuxtLink :to="item.to" class="nav-item-wrapper">
<span>{{ item.label }}</span>
<span v-if="item.badge" :class="getBadgeClass(item.badge)">{{ item.badge }}</span>
</NuxtLink>
</template>
<a-menu-item v-for="sub in item.children" :key="sub.key">
<a v-if="sub.href" :href="sub.href" target="_blank" rel="noopener" class="nav-item-wrapper">{{ sub.label }}</a>
<NuxtLink v-else :to="sub.to">{{ sub.label }}</NuxtLink>
</a-menu-item>
</a-sub-menu>
<a-menu-item v-else :key="item.key">
<NuxtLink :to="item.to" class="nav-item-wrapper">
<span>{{ item.label }}</span>
<span v-if="item.badge" :class="getBadgeClass(item.badge)">{{ item.badge }}</span>
</NuxtLink>
</a-menu-item>
</template>
</a-menu>
</div>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2 flex-shrink-0 nav-right">
<!-- PC 登录/头像 -->
<div class="hidden md:flex items-center gap-3">
<template v-if="!isAuthed">
<a-button type="primary" @click="navigateTo('/login')">{{ '登录' }}</a-button>
</template>
<template v-else>
<!-- 用户头像 -->
<a-dropdown :trigger="['hover']" placement="bottomRight">
<a-space>
<a-avatar :src="userAvatar" :size="32">
<template v-if="!userAvatar" #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="text-gray-100">{{ userName }}</span>
</a-space>
<template #overlay>
<a-menu @click="onUserMenuClick">
<a-menu-item key="profile"><ProfileOutlined style="margin-right: 8px" />个人信息</a-menu-item>
<a-menu-item key="my-suggestions"><MessageOutlined style="margin-right: 8px" />我的建言</a-menu-item>
<template v-if="isSuperAdmin">
<a-menu-divider />
<a-menu-item key="admin"> 后台管理</a-menu-item>
</template>
<a-menu-divider />
<a-menu-item key="logout">{{ '退出登录' }}</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</div>
<!-- 移动端汉堡菜单按钮 -->
<button class="md:hidden flex flex-col justify-center items-center w-10 h-10 gap-1.5 rounded-lg bg-white/10 hover:bg-white/20 border border-white/20 transition-colors" @click="open = true">
<span class="block w-5 h-0.5 bg-white rounded-full"></span>
<span class="block w-5 h-0.5 bg-white rounded-full"></span>
<span class="block w-5 h-0.5 bg-white rounded-full"></span>
</button>
</div>
</div>
</a-layout-header>
</a-affix>
<a-drawer v-model:open="open" :title="'导航' || '导航'" placement="right">
<a-menu mode="inline" :selected-keys="selectedKeys">
<template v-for="item in nav" :key="item.key">
<a-sub-menu v-if="item.children && item.children.length" :key="item.key">
<template #title>{{ item.label }}</template>
<a-menu-item v-for="sub in item.children" :key="sub.key">
<a v-if="sub.href" :href="sub.href" target="_blank" rel="noopener" @click="open = false">{{ sub.label }}</a>
<span v-else @click="onNav(sub.to)">{{ sub.label }}</span>
</a-menu-item>
</a-sub-menu>
<a-menu-item v-else :key="item.key" @click="onNav(item.to)">
<NuxtLink :to="item.to" class="nav-item-wrapper">
<span>{{ item.label }}</span>
<span v-if="item.badge" :class="getBadgeClass(item.badge)">{{ item.badge }}</span>
</NuxtLink>
</a-menu-item>
</template>
</a-menu>
<div class="mt-4">
<a-button v-if="!isAuthed" block type="primary" @click="onNav('/login')">{{ '登录' || '登录' }}</a-button>
<template v-else>
<a-button block type="primary" class="mb-2" @click="onNav('/profile')">个人中心</a-button>
<a-button v-if="isSuperAdmin" block @click="onNav('/admin')"> 后台管理</a-button>
<a-button block danger class="mt-2" @click="logout">{{ '退出登录' || '退出登录' }}</a-button>
</template>
</div>
</a-drawer>
</template>
<script setup lang="ts">
import { mainNav } from '@/config/nav'
import { getUserInfo } from '@/api/layout'
import type { User } from '@/api/system/user/model'
import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import { UserOutlined, ProfileOutlined, MessageOutlined } from '@ant-design/icons-vue'
const nav = computed(() => mainNav)
const route = useRoute()
const open = ref(false)
const selectedKeys = computed(() => {
const currentPath = route.path
const exactHit = nav.value.find((item) => item.to === currentPath)
if (exactHit) return [exactHit.key]
const prefixHit = nav.value.find((item) => item.to !== '/' && currentPath.startsWith(`${item.to}/`))
if (prefixHit) return [prefixHit.key]
const sectionHit = nav.value.find((item) => item.to !== '/' && currentPath.startsWith(item.to))
if (sectionHit) return [sectionHit.key]
return ['home']
})
// 获取 badge 样式类
function getBadgeClass(badge: string) {
const baseClass = 'ml-1.5 px-1.5 py-0.5 text-xs font-medium rounded'
if (badge === 'HOT') {
return `${baseClass} bg-orange-500 text-white`
}
if (badge === 'NEW') {
return `${baseClass} bg-green-500 text-white`
}
return `${baseClass} bg-gray-500 text-white`
}
const token = ref('')
const user = ref<User | null>(null)
const isAuthed = computed(() => !!token.value)
const userName = computed(() => String(user.value?.nickname || user.value?.username || '已登录'))
const isSuperAdmin = computed(() => !!(user.value as any)?.isAdmin)
const userAvatar = computed(() => {
const candidate =
user.value?.avatarUrl ||
user.value?.avatar ||
user.value?.merchantAvatar ||
user.value?.logo ||
''
if (typeof candidate !== 'string') return ''
const normalized = candidate.trim()
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
return normalized
})
function onNav(to: string) {
open.value = false
navigateTo(to)
}
async function refreshAuth() {
token.value = getToken()
if (!token.value) {
user.value = null
clearAuthz()
return
}
try {
user.value = await getUserInfo()
setAuthzFromUser(user.value)
} catch {
// token may be expired; keep authed UI but without profile info
clearAuthz()
}
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch {
// ignore
}
clearAuthz()
user.value = null
token.value = ''
open.value = false
navigateTo('/')
}
function onUserMenuClick(info: { key: string }) {
if (info.key === 'profile') return navigateTo('/profile')
if (info.key === 'my-suggestions') return navigateTo('/my/suggestions')
if (info.key === 'admin') return navigateTo('/admin')
if (info.key === 'logout') return logout()
}
onMounted(() => {
refreshAuth()
window.addEventListener('auth-token-changed', refreshAuth)
window.addEventListener('storage', refreshAuth)
})
onUnmounted(() => {
window.removeEventListener('auth-token-changed', refreshAuth)
window.removeEventListener('storage', refreshAuth)
})
</script>
<style scoped>
.header {
background: linear-gradient(180deg, #1773c2 0%, #0e63b1 100%);
border-bottom: 0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28);
padding: 0;
height: 64px;
}
/* 两栏布局:左侧 logo+菜单,右侧操作按钮 */
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.nav-left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
gap: 2rem;
}
.nav-right {
display: flex;
align-items: center;
}
.menu {
background: transparent;
border-bottom: none;
min-width: 0;
flex: 1;
}
.logo-link {
text-decoration: none;
display: flex;
align-items: center;
min-height: 64px;
padding-right: 8px;
}
.logo-text {
color: #fff;
font-family: 'Alimama FangYuanTi VF,sans-serif', sans-serif;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
line-height: 1;
background: linear-gradient(135deg, #ffffff 0%, #a5c8ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
transition: opacity 0.2s;
}
.logo-link:hover .logo-text {
opacity: 0.85;
}
.nav-item-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 62px;
padding: 0 4px;
}
:deep(.ant-menu-dark.ant-menu-horizontal) {
background: transparent !important;
border-bottom: 0 !important;
line-height: 64px;
}
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu) {
top: 0;
margin: 0 !important;
padding: 0 16px !important;
border-right: 1px solid rgba(255, 255, 255, 0.18);
}
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item:first-child),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu:first-child) {
border-left: 1px solid rgba(255, 255, 255, 0.18);
}
:deep(.ant-menu-dark .ant-menu-item-selected),
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-selected),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-selected > .ant-menu-submenu-title) {
background-color: rgba(6, 38, 78, 0.22) !important;
color: #fff !important;
}
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item:hover),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu:hover),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-active),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-active),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-selected),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-selected) {
background-color: rgba(6, 38, 78, 0.22) !important;
}
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item::after),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu::after) {
border-bottom: none !important;
}
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title),
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-open),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-active) {
background-color: rgba(6, 38, 78, 0.22) !important;
color: #fff !important;
}
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-title-content),
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title > .ant-menu-title-content),
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title > .ant-menu-title-content),
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-title-content a),
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title > .ant-menu-title-content a),
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title > .ant-menu-title-content a) {
color: #fff !important;
}
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title .ant-menu-submenu-arrow),
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title .ant-menu-submenu-arrow),
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title .ant-menu-submenu-arrow) {
color: rgba(255, 255, 255, 0.75) !important;
}
:deep(.ant-menu-dark .ant-menu-item-active > .ant-menu-item-content),
:deep(.ant-menu-dark .ant-menu-item-active > .ant-menu-item-content a),
:deep(.ant-menu-dark .ant-menu-item-open > .ant-menu-item-content),
:deep(.ant-menu-dark .ant-menu-item-open > .ant-menu-item-content a),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-active),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-active a) {
color: #fff !important;
}
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu > .ant-menu-submenu-title) {
display: flex;
align-items: center;
min-height: 64px;
color: rgba(255, 255, 255, 0.92) !important;
font-size: 15px;
}
:deep(.ant-menu-dark .ant-menu-item a),
:deep(.ant-menu-dark .ant-menu-submenu-title a) {
color: rgba(255, 255, 255, 0.85) !important;
}
:deep(.ant-menu-dark .ant-menu-item a:hover),
:deep(.ant-menu-dark .ant-menu-submenu-title a:hover) {
color: #fff !important;
}
:deep(.ant-menu-dark .ant-menu-item-selected a),
:deep(.ant-menu-dark .ant-menu-submenu-selected .ant-menu-submenu-title a) {
color: #fff !important;
}
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu .ant-menu-submenu-arrow),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu .ant-menu-submenu-arrow::before),
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu .ant-menu-submenu-arrow::after) {
color: rgba(255, 255, 255, 0.72) !important;
}
:deep(.ant-dropdown .ant-menu),
:deep(.ant-menu-submenu-popup .ant-menu) {
background: rgba(20, 90, 150, 0.96) !important;
box-shadow: 0 10px 20px rgba(13, 45, 82, 0.16) !important;
}
:deep(.ant-menu-submenu-popup .ant-menu-item),
:deep(.ant-dropdown .ant-menu-item) {
color: #fff !important;
font-size: 13px;
line-height: 1.5;
}
:deep(.ant-menu-submenu-popup .ant-menu-item:hover),
:deep(.ant-menu-submenu-popup .ant-menu-item-active),
:deep(.ant-menu-submenu-popup .ant-menu-item-selected) {
background: rgba(8, 56, 97, 0.44) !important;
}
:deep(.ant-drawer .ant-menu-item .nav-item-wrapper) {
min-height: auto;
justify-content: flex-start;
padding: 0;
}
@media (max-width: 1024px) {
.nav-left {
gap: 1rem;
}
.logo-text {
font-size: 20px;
}
}
</style>