Files
pc-10588/app/components/SiteHeader.vue

530 lines
14 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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>
<header class="site-header">
<a href="#main-content" class="skip-link">跳到主要内容</a>
<a-affix :offset-top="0">
<div class="header-affix">
<div v-if="showBrandbar" class="brandbar">
<div class="mx-auto grid max-w-screen-xl grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 py-3">
<div class="hidden md:block text-left text-xs text-white/85">
{{ todayText }}
</div>
<NuxtLink to="/" class="brand-center" aria-label="返回首页">
<img class="brand-logo" :src="logoUrl" :alt="siteName" />
<div class="brand-text">
<div class="brand-name">{{ siteName }}</div>
<div class="brand-slogan">{{ siteSlogan }}</div>
</div>
</NuxtLink>
<div class="brand-actions">
<a-button
size="small"
class="action-btn hidden md:inline-flex"
:aria-pressed="highContrast"
@click="toggleContrast"
>
无障碍
</a-button>
<a-segmented
class="lang-switch hidden md:inline-flex"
size="small"
:options="langOptions"
:value="locale"
@change="setLocale"
/>
<a-button
v-if="isLoggedIn"
size="small"
type="primary"
class="action-btn"
@click="navigateTo('/console')"
>
用户中心
</a-button>
<a-button
v-else
size="small"
type="primary"
class="action-btn"
@click="navigateTo('/login')"
>
登录
</a-button>
<a-button class="md:hidden action-btn" size="small" @click="open = true">菜单</a-button>
</div>
</div>
</div>
<div class="navbar">
<div class="mx-auto flex max-w-screen-xl items-center gap-3 px-4">
<nav class="nav hidden md:flex" aria-label="主导航">
<template v-for="item in navItems" :key="item.key">
<a-dropdown v-if="item.children?.length" :trigger="['hover', 'click']">
<a class="nav-link" :class="{ active: isActive(item) }" @click.prevent="onNavClick(item)">
{{ item.label }}
</a>
<template #overlay>
<a-menu>
<a-menu-item
v-for="child in item.children"
:key="child.key"
@click="onNavClick(child)"
>
{{ child.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<NuxtLink
v-else-if="item.to"
class="nav-link"
:class="{ active: isActive(item) }"
:to="item.to"
>
{{ item.label }}
</NuxtLink>
</template>
</nav>
<div class="hidden md:flex flex-1 justify-end">
<a-input-search
class="site-search"
placeholder="站内搜索"
:allow-clear="true"
:maxlength="50"
@search="onSearch"
/>
</div>
</div>
</div>
</div>
</a-affix>
</header>
<a-drawer v-model:open="open" title="导航" placement="right">
<a-input-search
class="mb-3"
placeholder="站内搜索"
:allow-clear="true"
:maxlength="50"
@search="onSearch"
/>
<a-menu mode="inline" :selected-keys="selectedKeys">
<template v-for="item in navItems" :key="item.key">
<a-menu-item v-if="!item.children?.length" :key="item.key" @click="onNavClick(item)">
{{ item.label }}
</a-menu-item>
<a-sub-menu v-else :key="item.key" :title="item.label">
<a-menu-item
v-for="child in item.children"
:key="child.key"
@click="onNavClick(child)"
>
{{ child.label }}
</a-menu-item>
</a-sub-menu>
</template>
</a-menu>
<div class="mt-4">
<a-space direction="vertical" class="w-full">
<a-button type="primary" block @click="onNav('/login')">登录</a-button>
<a-button block :aria-pressed="highContrast" @click="toggleContrast">无障碍高对比度</a-button>
<a-button block @click="onNav('/contact')">联系我们</a-button>
<a-button block @click="onNav('/sitemap')">站点地图</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script setup lang="ts">
import { mainNav } from '@/config/nav'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
import { getToken } from '@/utils/token-util'
const route = useRoute()
const open = ref(false)
const isHydrated = ref(false)
const token = ref('')
const TOKEN_EVENT = 'auth-token-changed'
const isLoggedIn = computed(() => isHydrated.value && !!token.value)
type HeaderNavItem = {
key: string
label: string
to?: string
href?: string
target?: string
children?: HeaderNavItem[]
}
const { data: siteInfo } = useSiteInfo()
const selectedKeys = computed(() => {
const flatten = (items: HeaderNavItem[]) =>
items.flatMap((i) => [i, ...(i.children?.length ? flatten(i.children) : [])])
const all = flatten(navItems.value)
const exactHit = all.find((n) => n.to === route.path)
if (exactHit) return [exactHit.key]
const prefixHit = all
.filter((n) => n.to && n.to !== '/' && route.path.startsWith(n.to))
.sort((a, b) => (b.to?.length ?? 0) - (a.to?.length ?? 0))[0]
if (prefixHit) return [prefixHit.key]
const home = all.find((n) => n.to === '/')
return home ? [home.key] : []
})
type SiteInfoData = {
websiteName?: unknown
websiteLogo?: unknown
websiteIcon?: unknown
topNavs?: unknown
setting?: unknown
config?: unknown
} & Record<string, unknown>
const siteData = computed<SiteInfoData | null>(() => {
const data = siteInfo.value?.data
if (data && typeof data === 'object') return data as SiteInfoData
return null
})
function pickString(source: unknown, key: string) {
if (!source || typeof source !== 'object') return ''
const record = source as Record<string, unknown>
const value = record[key]
return typeof value === 'string' ? value.trim() : ''
}
const siteName = computed(() => {
const websiteName = siteData.value?.websiteName
return typeof websiteName === 'string' && websiteName.trim()
? websiteName.trim()
: '广西决策咨询网'
})
const showBrandbar = computed(() => {
// Corporate site: keep brand bar consistent across pages.
return true
})
const logoUrl = computed(() => {
const data = siteData.value
const logo = typeof data?.websiteLogo === 'string' ? data.websiteLogo.trim() : ''
const icon = typeof data?.websiteIcon === 'string' ? data.websiteIcon.trim() : ''
return (
logo ||
icon ||
'https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png'
)
})
const siteSlogan = computed(() => {
const data = siteData.value
const slogan =
pickString(data?.setting, 'slogan') ||
pickString(data?.setting, 'subtitle') ||
pickString(data?.config, 'slogan')
return slogan || '权威发布 · 决策支持 · 服务发展'
})
function normalizePath(path: unknown) {
if (typeof path !== 'string') return ''
const p = path.trim()
if (!p) return ''
if (/^https?:\/\//i.test(p)) return p
if (p.startsWith('/')) return p
return `/${p}`
}
function normalizeNavTree(list: CmsNavigation[]): HeaderNavItem[] {
const normalizeOne = (n: CmsNavigation): HeaderNavItem => {
const label = String(n.title || n.label || '').trim() || '未命名'
const rawPath = normalizePath(n.path)
const isExternal = /^https?:\/\//i.test(rawPath)
const children =
Array.isArray(n.children) && n.children.length
? n.children
.slice()
.filter((c) => (c.hide ?? 0) !== 1)
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
.map(normalizeOne)
: undefined
const key = String(n.code || n.navigationId || rawPath || label)
const target = n.target ? String(n.target) : (isExternal ? '_blank' : undefined)
return {
key,
label,
...(isExternal ? { href: rawPath } : { to: rawPath || '/' }),
target,
...(children?.length ? { children } : {})
}
}
return list
.filter((n) => (n.hide ?? 0) !== 1)
.slice()
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
.map(normalizeOne)
}
function normalizeLocalNavTree(list: typeof mainNav): HeaderNavItem[] {
const normalizeOne = (n: (typeof mainNav)[number]): HeaderNavItem => {
const children = Array.isArray(n.children) && n.children.length ? n.children.map(normalizeOne) : undefined
return {
key: n.key || n.to || n.label,
label: n.label,
...(n.href ? { href: n.href, target: n.target || '_blank' } : { to: n.to || '/' }),
...(children?.length ? { children } : {})
}
}
return list.map(normalizeOne)
}
const navItems = computed<HeaderNavItem[]>(() => {
const apiNavs = siteData.value?.topNavs
if (Array.isArray(apiNavs) && apiNavs.length) {
// Prefer navigation from getShopInfo (CMS-managed).
return normalizeNavTree(apiNavs as CmsNavigation[])
}
// Fallback when CMS has not configured topNavs.
return normalizeLocalNavTree(mainNav)
})
function isActive(item: HeaderNavItem) {
const isHit = (candidate: HeaderNavItem): boolean => {
if (candidate.to && candidate.to === route.path) return true
return !!candidate.children?.some(isHit)
}
return isHit(item)
}
function onNavClick(item: HeaderNavItem) {
open.value = false
if (item.href) {
window.open(item.href, item.target || '_blank')
return
}
if (item.to) navigateTo(item.to)
}
function onNav(to: string) {
open.value = false
navigateTo(to)
}
const todayText = computed(() => {
const d = new Date()
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()] || ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}日 星期${week}`
})
const locale = useState<'zh-CN' | 'en'>('site-locale', () => 'zh-CN')
const highContrast = useState<boolean>('site-high-contrast', () => false)
const langOptions = [
{ label: '简体', value: 'zh-CN' },
{ label: 'EN', value: 'en' }
]
function applyPreferences() {
if (!import.meta.client) return
document.documentElement.lang = locale.value === 'en' ? 'en' : 'zh-CN'
document.documentElement.dataset.contrast = highContrast.value ? 'high' : 'normal'
}
function setLocale(value: unknown) {
locale.value = value === 'en' ? 'en' : 'zh-CN'
try {
localStorage.setItem('site-locale', locale.value)
} catch {
// ignore
}
applyPreferences()
}
function toggleContrast() {
highContrast.value = !highContrast.value
try {
localStorage.setItem('site-high-contrast', highContrast.value ? '1' : '0')
} catch {
// ignore
}
applyPreferences()
}
function onSearch(value: string) {
const q = String(value || '').trim()
if (!q) return
open.value = false
navigateTo({ path: '/search', query: { q } })
}
function syncToken() {
token.value = getToken()
}
onMounted(() => {
isHydrated.value = true
try {
const savedLocale = localStorage.getItem('site-locale')
locale.value = savedLocale === 'en' ? 'en' : 'zh-CN'
highContrast.value = localStorage.getItem('site-high-contrast') === '1'
} catch {
// ignore
}
applyPreferences()
syncToken()
window.addEventListener(TOKEN_EVENT, syncToken)
})
onBeforeUnmount(() => {
window.removeEventListener(TOKEN_EVENT, syncToken)
})
</script>
<style scoped>
.site-header {
background: transparent;
}
.skip-link {
position: absolute;
top: 0;
left: 0;
transform: translateY(-140%);
background: #ffffff;
color: #0f172a;
padding: 10px 12px;
border: 1px solid var(--border-subtle);
border-radius: 10px;
z-index: 1000;
}
.skip-link:focus {
transform: translateY(10px);
}
.header-affix {
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
}
.brandbar {
background: var(--gov-blue);
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
}
.brand-center {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
text-decoration: none;
min-width: 0;
}
.brand-logo {
width: 46px;
height: 46px;
object-fit: contain;
display: block;
flex: 0 0 auto;
}
.brand-text {
min-width: 0;
}
.brand-name {
color: #ffffff;
font-size: 20px;
line-height: 1.2;
font-weight: 800;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 520px;
}
.brand-slogan {
margin-top: 2px;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brand-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.action-btn {
box-shadow: none;
}
.lang-switch :deep(.ant-segmented) {
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.22);
}
.navbar {
background: #ffffff;
border-bottom: 1px solid var(--border-subtle);
}
.nav {
display: flex;
gap: 6px;
}
.nav-link {
display: inline-flex;
align-items: center;
height: 50px;
padding: 0 14px;
color: #111827;
font-weight: 600;
border-radius: 10px;
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
}
.nav-link:hover {
color: var(--gov-blue);
background: rgba(30, 111, 181, 0.08);
}
.nav-link.active {
background: rgba(30, 111, 181, 0.12);
color: var(--gov-blue);
}
.site-search {
max-width: 360px;
}
@media (max-width: 640px) {
.brand-name {
max-width: 240px;
font-size: 18px;
}
}
</style>