390 lines
16 KiB
Vue
390 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { NIcon, NSpace } from 'naive-ui'
|
|
import { Logout, Settings, Shield, User } from '@vicons/tabler'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
|
|
|
const authStore = useAuthStore()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const isMapRoute = computed(() => route.name === 'map')
|
|
|
|
const headerHidden = ref(false)
|
|
const headerPinnedOpen = ref(false)
|
|
const accountCardOpen = ref(false)
|
|
const activeNavClass = 'bg-teal-100 text-teal-800 ring-1 ring-teal-200'
|
|
const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
|
|
|
|
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
|
|
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
|
|
|
|
let lastScrollY = 0
|
|
let accountCloseTimer: number | null = null
|
|
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
|
|
|
function showHeader(pin = false) {
|
|
headerHidden.value = false
|
|
headerPinnedOpen.value = pin
|
|
}
|
|
|
|
function hideHeader() {
|
|
if (window.scrollY <= 96) return
|
|
if (headerPinnedOpen.value) return
|
|
headerHidden.value = true
|
|
}
|
|
|
|
function releasePinnedHeader() {
|
|
headerPinnedOpen.value = false
|
|
}
|
|
|
|
function forceHideHeader() {
|
|
headerPinnedOpen.value = false
|
|
headerHidden.value = true
|
|
}
|
|
|
|
function handleScroll() {
|
|
const currentScrollY = window.scrollY
|
|
const delta = currentScrollY - lastScrollY
|
|
|
|
if (isMapRoute.value) {
|
|
lastScrollY = currentScrollY
|
|
return
|
|
}
|
|
|
|
if (currentScrollY <= 64) {
|
|
showHeader(false)
|
|
lastScrollY = currentScrollY
|
|
return
|
|
}
|
|
|
|
if (delta > 12) {
|
|
hideHeader()
|
|
} else if (delta < -8) {
|
|
showHeader(false)
|
|
}
|
|
|
|
lastScrollY = currentScrollY
|
|
}
|
|
|
|
onMounted(() => {
|
|
lastScrollY = window.scrollY
|
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
|
window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('scroll', handleScroll)
|
|
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
|
if (accountCloseTimer !== null) {
|
|
window.clearTimeout(accountCloseTimer)
|
|
accountCloseTimer = null
|
|
}
|
|
})
|
|
|
|
function syncHeaderForRoute() {
|
|
headerPinnedOpen.value = false
|
|
headerHidden.value = false
|
|
accountCardOpen.value = false
|
|
}
|
|
|
|
watch(() => route.fullPath, () => {
|
|
syncHeaderForRoute()
|
|
})
|
|
|
|
watch(isMapRoute, () => {
|
|
syncHeaderForRoute()
|
|
})
|
|
|
|
onMounted(() => {
|
|
syncHeaderForRoute()
|
|
})
|
|
|
|
function openAccountCard() {
|
|
if (accountCloseTimer !== null) {
|
|
window.clearTimeout(accountCloseTimer)
|
|
accountCloseTimer = null
|
|
}
|
|
accountCardOpen.value = true
|
|
}
|
|
|
|
function closeAccountCard() {
|
|
if (accountCloseTimer !== null) {
|
|
window.clearTimeout(accountCloseTimer)
|
|
}
|
|
accountCloseTimer = window.setTimeout(() => {
|
|
accountCardOpen.value = false
|
|
accountCloseTimer = null
|
|
}, 100)
|
|
}
|
|
|
|
async function handleLogout() {
|
|
await authStore.logout()
|
|
closeAccountCard()
|
|
if (route.meta.requiresAuth) {
|
|
router.push({ name: 'map' })
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
|
|
<header
|
|
class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300"
|
|
:class="headerHidden ? '-translate-y-full' : 'translate-y-0'"
|
|
@mouseenter="showHeader(true)"
|
|
@mouseleave="releasePinnedHeader"
|
|
>
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex h-16 items-center justify-between">
|
|
<RouterLink to="/" class="flex items-center gap-3">
|
|
<div class="flex h-10 w-10 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
|
|
<svg
|
|
class="h-8 w-8"
|
|
viewBox="0 0 16 16"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
shape-rendering="crispEdges"
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="11" y="3" width="1" height="1" fill="#f59e0b" />
|
|
<rect x="10" y="4" width="3" height="1" fill="#fbbf24" />
|
|
<rect x="11" y="5" width="1" height="1" fill="#f59e0b" />
|
|
|
|
<rect x="5" y="7" width="7" height="1" fill="#38bdf8" />
|
|
<rect x="4" y="8" width="8" height="1" fill="#38bdf8" />
|
|
<rect x="4" y="9" width="7" height="1" fill="#38bdf8" />
|
|
<rect x="5" y="10" width="4" height="1" fill="#38bdf8" />
|
|
|
|
<rect x="5" y="5" width="4" height="1" fill="#ffffff" />
|
|
<rect x="4" y="6" width="7" height="1" fill="#ffffff" />
|
|
<rect x="3" y="7" width="8" height="1" fill="#ffffff" />
|
|
<rect x="3" y="8" width="7" height="1" fill="#ffffff" />
|
|
<rect x="4" y="9" width="5" height="1" fill="#ffffff" />
|
|
|
|
<rect x="4" y="11" width="8" height="1" fill="#7dd3fc" opacity="0.55" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">Live Sky Atlas</div>
|
|
<div class="text-lg font-bold text-slate-900">OpenCloud</div>
|
|
</div>
|
|
</RouterLink>
|
|
|
|
<nav class="hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)] md:flex">
|
|
<RouterLink
|
|
to="/"
|
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
地图
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/community"
|
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
社区
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/gallery"
|
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
画廊
|
|
</RouterLink>
|
|
<RouterLink
|
|
v-if="authStore.isLoggedIn"
|
|
to="/encyclopedia"
|
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
图鉴
|
|
</RouterLink>
|
|
</nav>
|
|
|
|
<NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
|
|
<template v-if="!authStore.loading">
|
|
<RouterLink
|
|
v-if="authStore.isLoggedIn"
|
|
to="/upload"
|
|
class="no-underline"
|
|
>
|
|
<span
|
|
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium uppercase tracking-[0.12em] text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
|
|
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
|
>
|
|
上传
|
|
</span>
|
|
</RouterLink>
|
|
|
|
<template v-if="authStore.isLoggedIn">
|
|
<div
|
|
class="relative"
|
|
@mouseenter="openAccountCard"
|
|
@mouseleave="closeAccountCard"
|
|
@focusin="openAccountCard"
|
|
@focusout="closeAccountCard"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-8 max-w-[8.5rem] items-center gap-2 border border-teal-100 bg-white/80 px-3 text-sm font-medium text-teal-700 shadow-[3px_3px_0_0_rgba(20,184,166,0.08)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50/70 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(20,184,166,0.12)] md:h-10 md:max-w-none"
|
|
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200 shadow-[4px_4px_0_0_rgba(20,184,166,0.12)]' : ''"
|
|
aria-haspopup="menu"
|
|
:aria-expanded="accountCardOpen"
|
|
>
|
|
<NIcon size="16" class="shrink-0">
|
|
<User />
|
|
</NIcon>
|
|
<span class="truncate">@{{ displayUsername }}</span>
|
|
</button>
|
|
|
|
<div
|
|
v-if="accountCardOpen"
|
|
class="absolute right-0 top-full z-50 w-72 pt-3"
|
|
role="menu"
|
|
>
|
|
<div class="border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]">
|
|
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f0fdfa_0%,#ffffff_100%)] px-4 py-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-lg font-bold text-teal-800">
|
|
{{ displayUsername.slice(0, 1).toUpperCase() }}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-semibold text-slate-950">@{{ displayUsername }}</p>
|
|
<p class="mt-1 truncate text-xs text-slate-500">{{ userEmail }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
|
<!-- <span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
|
|
{{ authStore.isAdmin ? '管理员' : '观测者' }}
|
|
</span>
|
|
<span>OpenCloud 账号</span> -->
|
|
</div>
|
|
</div>
|
|
|
|
<div class="py-2">
|
|
<RouterLink
|
|
to="/profile"
|
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800"
|
|
role="menuitem"
|
|
>
|
|
<NIcon size="18"><User /></NIcon>
|
|
<span>个人中心</span>
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/profile/settings"
|
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800"
|
|
role="menuitem"
|
|
>
|
|
<NIcon size="18"><Settings /></NIcon>
|
|
<span>账号设置</span>
|
|
</RouterLink>
|
|
<RouterLink
|
|
v-if="authStore.isAdmin"
|
|
to="/admin"
|
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-amber-50 hover:text-amber-700"
|
|
role="menuitem"
|
|
>
|
|
<NIcon size="18"><Shield /></NIcon>
|
|
<span>管理后台</span>
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<div class="border-t border-slate-200 p-2">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-3 px-2 py-2.5 text-left text-sm font-medium text-rose-600 transition-colors hover:bg-rose-50 hover:text-rose-700"
|
|
role="menuitem"
|
|
@click="handleLogout"
|
|
>
|
|
<NIcon size="18"><Logout /></NIcon>
|
|
<span>登出</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<RouterLink
|
|
to="/login"
|
|
class="no-underline"
|
|
>
|
|
<span
|
|
class="inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3 text-sm font-medium text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] md:h-10 md:px-4"
|
|
:class="route.name === 'login' ? 'bg-teal-50 ring-1 ring-teal-200 text-teal-800' : ''"
|
|
>
|
|
登录
|
|
</span>
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/register"
|
|
class="no-underline"
|
|
>
|
|
<span
|
|
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
|
|
:class="route.name === 'register' ? 'ring-1 ring-sky-300' : ''"
|
|
>
|
|
注册
|
|
</span>
|
|
</RouterLink>
|
|
</template>
|
|
</template>
|
|
</NSpace>
|
|
</div>
|
|
|
|
<nav class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden">
|
|
<RouterLink
|
|
to="/"
|
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
地图
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/community"
|
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
社区
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/gallery"
|
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
画廊
|
|
</RouterLink>
|
|
<RouterLink
|
|
v-if="authStore.isLoggedIn"
|
|
to="/encyclopedia"
|
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
图鉴
|
|
</RouterLink>
|
|
<RouterLink
|
|
v-if="!authStore.loading && authStore.isLoggedIn"
|
|
to="/profile"
|
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
|
:class="route.name === 'profile' ? activeNavClass : inactiveNavClass"
|
|
>
|
|
主页
|
|
</RouterLink>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<button
|
|
v-if="headerHidden"
|
|
type="button"
|
|
class="absolute left-1/2 top-3 flex -translate-x-1/2 items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-slate-900 hover:text-slate-900"
|
|
@click="showHeader(true)"
|
|
>
|
|
<span>▼</span>
|
|
<span>展开导航</span>
|
|
</button>
|
|
</div>
|
|
</template>
|