Files
juip-web/src/home/header.tsx

230 lines
6.8 KiB
TypeScript
Raw Normal View History

import { Menu, User } from "lucide-react"
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import { NavLink, useNavigate } from "react-router-dom"
import { AUTH_EVENT, isAuthed } from "@/api/token"
2026-08-13 18:28:20 +08:00
import ProductDropdown from "@/components/productDropdown"
import ProfileOrLogin from "@/components/profileOrLogin"
2026-07-24 18:00:22 +08:00
import Wrap from "@/components/wrap"
import { useMediaQuery } from "@/lib/hooks/useMediaQuery"
2026-07-24 18:00:22 +08:00
import { cn } from "@/lib/utils"
import MobileMenu from "./_components/header/menu-mobile"
2026-07-24 18:00:22 +08:00
export default function Header() {
const navigate = useNavigate()
const isWideDesktop = useMediaQuery("(min-width: 80rem)")
2026-07-24 18:00:22 +08:00
const scroll = useSyncExternalStore(
callback => {
window.addEventListener("scroll", callback)
return () => {
window.removeEventListener("scroll", callback)
}
},
() => window.scrollY > 48,
() => false,
)
2026-08-03 10:53:12 +08:00
// 菜单状态
const [openMenu, setOpenMenu] = useState(false)
const handleEnter = () => setOpenMenu(true)
const handleLeave = () => setOpenMenu(false)
// 移动端状态
const [mobileMenu, setMobileMenu] = useState(false)
const [authed, setAuthed] = useState(isAuthed)
useEffect(() => {
const update = () => setAuthed(isAuthed())
window.addEventListener("focus", update)
window.addEventListener(AUTH_EVENT, update)
return () => {
window.removeEventListener("focus", update)
window.removeEventListener(AUTH_EVENT, update)
}
}, [])
2026-07-24 18:00:22 +08:00
return (
2026-08-24 18:04:44 +08:00
<header className={cn("fixed top-0 left-0 w-screen z-50 flex flex-col")}>
2026-07-24 18:00:22 +08:00
<div
className={cn(
`flex-none pointer-events-auto`,
`transition-[background,shadow] duration-200 ease-in-out`,
scroll
? `bg-[#fffe] backdrop-blur-sm shadow-lg`
: `bg-transparent shadow-none`,
)}
>
<Wrap className="h-20 max-md:h-16 flex justify-between pl-15 pr-15 max-md:pl-4 max-md:pr-4">
<nav className="flex items-center gap-4 lg:gap-8">
{/* logo + 网站名称 */}
<NavLink to="/" className="flex items-center gap-2 shrink-0">
<img
src="/favicon.svg"
alt="聚IP"
className="w-10 h-10 max-md:w-8 max-md:h-8 object-contain"
/>
<span className="text-2xl max-md:text-xl font-bold bg-linear-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent">
聚IP
</span>
</NavLink>
{/* 桌面端菜单 */}
2026-07-24 18:00:22 +08:00
<ul className="h-full items-stretch hidden lg:flex">
<NavItem to="/">首页</NavItem>
2026-08-03 10:53:12 +08:00
<MenuItem
text="产品购买"
isOpen={openMenu}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
<ProductDropdown
size={isWideDesktop ? "lg" : "sm"}
onNavigate={() => setOpenMenu(false)}
/>
2026-08-03 10:53:12 +08:00
</MenuItem>
2026-07-24 18:00:22 +08:00
<NavItem to="/ipline">IP线路表</NavItem>
2026-08-07 16:54:21 +08:00
<NavItem to="/softdownload">软件下载</NavItem>
2026-07-24 18:00:22 +08:00
<NavItem to="/help">帮助/教程</NavItem>
</ul>
</nav>
{/* 桌面端登录/注册 */}
<div className="max-lg:hidden flex items-center">
<ProfileOrLogin />
</div>
{/* 移动端:未登录显示用户图标+汉堡菜单,已登录仅显示汉堡菜单 */}
<div className="lg:hidden flex items-center gap-2">
{!authed && (
<button
type="button"
aria-label="登录/注册"
onClick={() => navigate("/login")}
className="p-2 text-slate-700 hover:text-blue-500 transition-colors cursor-pointer"
>
<User className="w-6 h-6" />
</button>
)}
<button
type="button"
aria-label="打开菜单"
onClick={() => setMobileMenu(true)}
className="p-2 text-slate-700 hover:text-blue-500 transition-colors cursor-pointer"
>
<Menu className="w-6 h-6" />
</button>
</div>
2026-07-24 18:00:22 +08:00
</Wrap>
</div>
{/* 移动端左侧菜单栏 */}
{mobileMenu && (
<MobileMenu authed={authed} onClose={() => setMobileMenu(false)} />
)}
2026-07-24 18:00:22 +08:00
</header>
)
}
const NavItem = ({
to,
children,
}: {
to: string
children: React.ReactNode
}) => (
<NavLink
to={to}
2026-08-03 10:53:12 +08:00
className="group h-full flex items-center relative px-3 text-slate-700 hover:text-blue-500 transition-colors duration-200"
2026-07-24 18:00:22 +08:00
>
{children}
2026-08-03 10:53:12 +08:00
<span
className={cn(
"absolute bottom-0 left-0 right-0 h-0.5 transition-colors duration-200",
"bg-transparent group-hover:bg-blue-500",
)}
/>
2026-07-24 18:00:22 +08:00
</NavLink>
)
2026-08-03 10:53:12 +08:00
const MenuItem = ({
text,
children,
isOpen,
onMouseEnter,
onMouseLeave,
}: {
text: string
children: React.ReactNode
isOpen: boolean
onMouseEnter: () => void
onMouseLeave: () => void
}) => {
const timeoutRef = useRef<number | null>(null)
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
onMouseEnter()
}
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
onMouseLeave()
}, 150)
}
return (
<li
className="group relative h-full flex items-center"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
className={cn(
"h-full flex items-center gap-1 px-3 text-lg transition-colors duration-200",
isOpen ? "text-blue-500" : "text-slate-700 hover:text-blue-500",
)}
>
<span>{text}</span>
<svg
className={cn(
"w-4 h-4 transition-transform duration-200",
isOpen ? "rotate-180" : "",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<span
className={cn(
"absolute bottom-0 left-0 right-0 h-0.5 transition-colors duration-200",
isOpen ? "bg-blue-500" : "bg-transparent group-hover:bg-blue-500",
)}
/>
<div
className={cn(
"fixed left-1/2 top-20 -translate-x-1/2 w-[min(56rem,calc(100vw-2rem))] transition-all duration-200 z-50",
2026-08-03 10:53:12 +08:00
isOpen
? "opacity-100 visible translate-y-0"
: "opacity-0 invisible -translate-y-2 pointer-events-none",
)}
>
2026-08-24 18:04:44 +08:00
<div className="bg-white rounded-xl shadow-2xl border border-gray-100/80 p-6 overflow-visible">
2026-08-03 10:53:12 +08:00
{children}
</div>
</div>
</li>
)
}