优化导航栏性能,完善导航栏弹出菜单的移动端适配;调整导航栏与 store 公共组件的所在目录结构

This commit is contained in:
2025-06-22 10:58:41 +08:00
parent b295fea52f
commit 483a33296a
24 changed files with 469 additions and 649 deletions

View File

@@ -25,6 +25,7 @@ const eslintConfig = [
}],
'@stylistic/jsx-closing-bracket-location': 'off',
'@stylistic/jsx-curly-newline': 'off',
'@stylistic/jsx-one-expression-per-line': 'off',
'@stylistic/multiline-ternary': 'off',
'@stylistic/block-spacing': 'off',
'@typescript-eslint/no-empty-object-type': 'off',

View File

@@ -27,7 +27,7 @@ import {ApiResponse} from '@/lib/api'
import {Label} from '@/components/ui/label'
import logo from '@/assets/logo.webp'
import bg from './_assets/bg.webp'
import {useProfileStore} from '@/app/stores'
import {useProfileStore} from '@/components/stores-provider'
import Link from 'next/link'
export type LoginPageProps = {}

View File

@@ -1,97 +0,0 @@
import Image from 'next/image'
import Link from 'next/link'
import down from '@/assets/header/down.svg'
export function LinkItem(props: {
text: string
href: string
}) {
return (
<li className="group relative flex-none">
<Link
href={props.href}
className={[
`h-full px-4 flex items-center text-lg`,
`transition-colors duration-200 ease-in-out`,
`hover:text-blue-500`,
].join(' ')}
>
{props.text}
</Link>
<div className={[
`absolute bottom-0 w-full h-0.5 bg-transparent `,
`transition-colors duration-200`,
`group-hover:bg-blue-500`,
].join(' ')}>
</div>
</li>
)
}
export function MenuItem(props: {
text: string
active: boolean
onEnter: (e: React.PointerEvent) => void
onLeave: (e: React.PointerEvent) => void
onTouchStart?: (e: React.TouchEvent) => void
}) {
return (
<li className="group relative flex-none">
<button
onPointerEnter={props.onEnter}
onPointerLeave={props.onLeave}
onTouchStart={props.onTouchStart}
className={[
`h-full px-4 flex gap-3 items-center cursor-pointer text-lg`,
`transition-colors duration-200 ease-in-out`,
props.active
? `lg:text-blue-500`
: ``,
].join(' ')}
>
<span>{props.text}</span>
<Image
src={down}
alt="drop_menu"
className={[
`transition-transform duration-200 ease-in-out`,
props.active
? `rotate-180`
: ``,
].join(' ')}
/>
</button>
<div
className={[
`absolute bottom-0 w-full h-0.5 pointer-events-none`,
`transition-colors duration-200`,
props.active
? `lg:bg-blue-500`
: 'bg-transparent',
].join(' ')}/>
</li>
)
}
// 移动端
export function MobileLinkItem(props: {
text: string
href: string
onClick?: () => void
}) {
return (
<li>
<Link
href={props.href}
onClick={props.onClick}
className={[
`block px-4 py-2 text-lg rounded-md`,
`transition-colors duration-200 ease-in-out`,
`hover:bg-blue-50 hover:text-blue-600`,
].join(' ')}
>
{props.text}
</Link>
</li>
)
}

View File

@@ -1,183 +0,0 @@
'use client'
import {ReactNode, useState, useEffect, MouseEvent} from 'react'
import Wrap from '@/components/wrap'
import Image from 'next/image'
import anno from '@/assets/header/product/anno.svg'
import Link from 'next/link'
import {merge} from '@/lib/utils'
import prod from '@/assets/header/product/prod.svg'
import custom from '@/assets/header/product/custom.svg'
import {useSearchParams} from 'next/navigation'
import {useRouter} from 'next/navigation'
type TabType = 'domestic' | 'oversea'
export function Tab(props: {
selected: boolean
onSelect: () => void
children: ReactNode
}) {
return (
<li role="tab">
<button
className={[
`p-8 text-lg cursor-pointer border-r`,
props.selected ? `bg-gradient-to-r from-transparent to-blue-200 border-blue-400` : `border-gray-200`,
].join(' ')}
onClick={props.onSelect}
>
{props.children}
</button>
</li>
)
}
export default function ProductMenu() {
const [type, setType] = useState<TabType>('domestic')
useEffect(() => {
const checkMobile = () => {
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
return (
<Wrap className="flex">
<ul role="tablist" className="w-48">
<Tab selected={type === 'domestic'} onSelect={() => setType('domestic')}></Tab>
<Tab selected={type === 'oversea'} onSelect={() => setType('oversea')}></Tab>
</ul>
<div className="flex-1">
{type === 'domestic'
? (
<Domestic/>
) : (
<Oversea/>
)
}
</div>
<aside className="hidden w-64 lg:block">
<h3 className="flex gap-3 items-center mb-4">
<Image src={anno} alt="公告" className="w-10 h-10"/>
<span></span>
</h3>
<div className="flex flex-col gap-2">
<p>线</p>
<p className="text-gray-400 text-sm">
1.使使
使
</p>
<p className="text-gray-400 text-sm">
2.使使
使
</p>
</div>
</aside>
</Wrap>
)
}
export function Domestic(props: {}) {
const searchParams = useSearchParams()
const currentType = searchParams?.get('type') || 'short'
return (
<section role="tabpanel" className="flex gap-16 mr-16">
<div className="w-64 flex flex-col">
<h3 className="mb-6 font-bold flex items-center gap-3">
<Image src={prod} alt="产品" className="w-10 h-=10"/>
<span></span>
</h3>
<DomesticLink
label="动态IP"
desc="全国300+城市级定位节点"
href="/product?type=short"
discount={45}
active={currentType === 'short'}
/>
<DomesticLink
label="长效静态IP"
desc="IP 资源覆盖全国"
href="/product?type=long"
discount={45}
active={currentType === 'long'}
/>
<DomesticLink
label="固定IP"
desc="全国300+城市级定位节点"
href="/product?type=fixed"
discount={45}
active={currentType === 'fixed'}
/>
</div>
<div className="w-64 flex flex-col gap-4 max-lg:hidden">
<h3 className="font-bold mb-2 flex items-center gap-3">
<Image src={custom} alt="定制" className="w-10 h-10"/>
<span></span>
</h3>
<div className="flex flex-col gap-2">
<p>//IP</p>
<p className="text-gray-400 text-sm">
1000
1 1 24
</p>
</div>
</div>
</section>
)
}
export function DomesticLink(props: {
label: string
desc: string
href: string
discount: number
active?: boolean
}) {
const router = useRouter()
// const ctx = useContext(HeaderContext)
// if (!ctx) {
// throw new Error(`HeaderContext not found`)
// }
const onClick = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// ctx.setMenu(false)
router.push(props.href)
}
return (
<Link
href={props.href}
className={merge(
`transition-colors duration-150 ease-in-out`,
`p-4 rounded-lg flex flex-col gap-2 hover:bg-blue-50`,
props.active ? 'bg-blue-100' : '',
)}
onClick={onClick}>
<p className="flex gap-2">
<span>{props.label}</span>
<span className="text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full">
{props.discount}
%
</span>
</p>
<p className="text-gray-400 text-sm">
{props.desc}
</p>
</Link>
)
}
export function Oversea(props: {}) {
return (
<section role="tabpanel">
</section>
)
}

View File

@@ -1,227 +0,0 @@
'use client'
import {createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'
import Link from 'next/link'
import Image from 'next/image'
import {LinkItem, MenuItem} from './navs'
import SolutionMenu from './solution'
import ProductMenu from './product'
import HelpMenu from './help'
import Wrap from '@/components/wrap'
import logo from '@/assets/logo.webp'
import {Button} from '@/components/ui/button'
import {useProfileStore} from '@/app/stores'
import UserCenter from '@/components/composites/user-center'
import {MenuIcon} from 'lucide-react'
import {merge} from '@/lib/utils'
export const HeaderContext = createContext<{
setMenu: (value: boolean) => void
isMobile: boolean
} | null>(null)
export type ProviderProps = {}
export default function Provider(props: ProviderProps) {
// ======================
// 滚动条状态
// ======================
const [scroll, setScroll] = useState(false) // Changed to false for client-side rendering
const handleScroll = useCallback(() => {
setScroll(window.scrollY > 48)
}, [])
useEffect(() => {
// Initialize scroll state on client
setScroll(window.scrollY > 48)
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [handleScroll])
// ======================
// 菜单状态
// ======================
const [menu, setMenu] = useState(false)
const [page, setPage] = useState(0)
const menuRef = useRef<HTMLDivElement>(null)
const [productDropdownOpen, setProductDropdownOpen] = useState(false)
useEffect(() => {
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
const target = e.target as HTMLElement
// 排除所有交互元素
if (!target.closest('a, button, [role="button"], [role="tab"]')) {
setMenu(false)
setProductDropdownOpen(false) // 同时关闭产品下拉菜单
}
}
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('touchstart', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('touchstart', handleClickOutside)
}
}, [])
const isMobile = () => {
if (typeof window === 'undefined') return false
return window.innerWidth <= 768
}
const handleMenuEnter = (pageIndex: number) => {
if (isMobile() && menu && page === pageIndex) {
setMenu(false)
}
else {
setPage(pageIndex)
setMenu(true)
}
}
const handleMenuLeave = () => {
if (!isMobile()) {
setMenu(false)
}
}
const pages = useMemo(() => [
<ProductMenu key="product"/>,
<SolutionMenu key="solution"/>,
<HelpMenu key="help"/>,
], [])
const toggleMobileMenu = () => {
setMenu(!menu)
}
// ======================
// 用户信息
// ======================
const profile = useProfileStore(store => store.profile)
// ======================
// render
// ======================
return (
<HeaderContext.Provider value={{setMenu, isMobile: isMobile()}}>
<div
ref={menuRef}
className={[
`transition-[background, shadow] duration-200 ease-in-out`,
menu
? `bg-[#fffe] backdrop-blur-sm`
: scroll
? `bg-[#fffe] backdrop-blur-sm shadow-lg`
: `bg-transparent shadow-none`,
].join(' ')}>
<Wrap className="h-14 lg:h-16 flex justify-between items-stretch">
<div className="flex justify-between items-stretch gap-2 lg:gap-8 h-9 lg:h-auto max-lg:flex-row-reverse lg:items-stretch">
{/* logo */}
<Link href="/" className="self-center">
<Image src={logo} alt="logo" height={36}/>
</Link>
{/* 菜单 */}
<nav className="flex flex-col">
<Button
theme="ghost"
className="w-9 h-9 lg:hidden"
onClick={toggleMobileMenu}
aria-expanded={productDropdownOpen}
>
<MenuIcon/>
</Button>
<ul
className={merge(
`h-full flex items-stretch`,
'max-lg:absolute max-lg:top-full max-lg:left-0 max-lg:w-screen max-lg:h-12 max-lg:overflow-auto',
)}
>
<LinkItem text="首页" href="/"/>
<MenuItem
text="产品订购"
active={menu && page === 0}
onEnter={() => handleMenuEnter(0)}
onLeave={handleMenuLeave}
onTouchStart={() => handleMenuEnter(0)}
/>
<MenuItem
text="业务场景"
active={menu && page === 1}
onEnter={() => handleMenuEnter(1)}
onLeave={handleMenuLeave}
/>
<MenuItem
text="帮助中心"
active={menu && page === 2}
onEnter={() => handleMenuEnter(2)}
onLeave={handleMenuLeave}
/>
<LinkItem
text="企业服务"
href="#"/>
<LinkItem
text="推广返利"
href="#"/>
</ul>
</nav>
</div>
{/* 登录 */}
<div className="flex items-center">
{profile == undefined
? (
<div className="flex items-center max-lg:gap-2">
<Link
href="/login"
className="px-2 lg:w-24 h-9 lg:h-12 flex items-center justify-center lg:text-lg"
>
<span></span>
</Link>
<Link
href="/login"
className={[
`px-2 lg:w-24 h-9 lg:h-12 bg-gradient-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`,
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
].join(' ')}
>
<span></span>
</Link>
</div>
)
: (
<div></div>
)
}
</div>
</Wrap>
</div>
{/* 下拉菜单 */}
<div
className={[
`shadow-lg`,
`overflow-hidden bg-[#fffe] backdrop-blur-sm`,
`transition-[opacity,padding,height] transition-discrete duration-200 ease-in-out`,
menu
? `delay-[0s,0s,0s] opacity-100 py-8 h-auto`
: `delay-[0s,0s,0.2s] opacity-0 py-0 h-0`,
isMobile()
? `max-lg:fixed max-lg:top-65 max-lg:left-0 max-lg:right-0 max-lg:w-full max-lg:z-[60] max-lg:overflow-y-auto max-lg:max-h-[calc(100vh-5rem)]`
: `absolute top-full left-0 right-0`,
].join(' ')}
onPointerEnter={() => !isMobile() && setMenu(true)}
onPointerLeave={() => !isMobile() && setMenu(false)}
>
{pages[page]}
</div>
</HeaderContext.Provider>
)
}

View File

@@ -0,0 +1,18 @@
import {createContext} from 'react'
import Image, {StaticImageData} from 'next/image'
export const HeaderContext = createContext<{
setMenu: (value: boolean) => void
} | null>(null)
export function FragmentTitle(props: {
text: string
img: StaticImageData
}) {
return (
<h3 className="font-bold flex items-center gap-3">
<Image src={props.img} alt="icon" aria-hidden className="size-8 lg:size-9"/>
<span>{props.text}</span>
</h3>
)
}

View File

@@ -5,16 +5,17 @@ import h01 from '@/assets/header/help/01.svg'
import h02 from '@/assets/header/help/02.svg'
import h03 from '@/assets/header/help/03.svg'
import banner from '@/assets/header/help/banner.webp'
import {FragmentTitle} from '@/app/(home)/@header/common'
export default function HelpMenu() {
return (
<Wrap className="w-full grid grid-cols-3 lg:grid-cols-4 gap-4 justify-items-start">
<Wrap className="w-full grid sm:grid-cols-3 lg:grid-cols-4 gap-4 justify-items-start">
<Column
icon={h01}
title="提取IP"
title="提取 IP"
items={[
{lead: '短效动态IP提取', href: '#'},
{lead: '长效静态IP提取', href: '#'},
{lead: '短效 IP 提取', href: '#'},
{lead: '长效 IP 提取', href: '#'},
]}
/>
<Column
@@ -49,14 +50,13 @@ function Column(props: {
}[]
}) {
return (
<div className="flex flex-col gap-4">
<h3 className="font-bold flex gap-3 items-center">
<Image src={props.icon} alt={props.title} className="w-10 h-10"/>
<span>{props.title}</span>
</h3>
<ul className=" text-gray-500 text-sm flex flex-col items-end gap-2">
<div className="flex-1 flex flex-col gap-4">
<FragmentTitle img={props.icon} text={props.title}/>
<ul className="flex flex-col gap-2">
{props.items.map((item, index) => (
<li key={index}><Link href={item.href}>{item.lead}</Link></li>
<li key={index}>
<Link href={item.href} className="px-4 py-2">{item.lead}</Link>
</li>
))}
</ul>
</div>

View File

@@ -0,0 +1,31 @@
import ProductMenu from './menu-product'
import HelpMenu from './menu-help'
import SolutionMenu from './menu-solution'
export type MobileMenuProps = {}
export default function MobileMenu(props: MobileMenuProps) {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<ProductMenu/>
</div>
<div className="flex flex-col gap-4">
<MenuTitle title="帮助中心"/>
<HelpMenu/>
</div>
<div className="flex flex-col gap-4">
<MenuTitle title="业务场景"/>
<SolutionMenu/>
</div>
</div>
)
}
function MenuTitle(props: {title: string}) {
return (
<h3 className="text-xl text-weak px-4">
{props.title}
</h3>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import {ReactNode, useContext, useState} from 'react'
import Wrap from '@/components/wrap'
import anno from '@/assets/header/product/anno.svg'
import Link from 'next/link'
import {merge} from '@/lib/utils'
import prod from '@/assets/header/product/prod.svg'
import custom from '@/assets/header/product/custom.svg'
import {useRouter} from 'next/navigation'
import {FragmentTitle, HeaderContext} from './common'
type TabType = 'domestic' | 'oversea'
export default function ProductMenu() {
const [type, setType] = useState<TabType>('domestic')
return (
<Wrap className="flex flex-col items-stretch lg:flex-row gap-4 lg:gap-0">
<ul role="tablist" className="flex-none lg:basis-36 flex lg:flex-col">
<Tab selected={type === 'domestic'} onSelect={() => setType('domestic')}></Tab>
<Tab selected={type === 'oversea'} onSelect={() => setType('oversea')}></Tab>
</ul>
{type === 'domestic'
? (
<Domestic/>
) : (
<Oversea/>
)
}
<aside className="w-full lg:w-64 hidden lg:block">
<FragmentTitle img={anno} text="网站公告"/>
<div className="flex flex-col gap-2 p-4">
<p>线</p>
<p className="text-gray-400 text-sm">
1.使使
使
</p>
<p className="text-gray-400 text-sm">
2.使使
使
</p>
</div>
</aside>
</Wrap>
)
}
export function Tab(props: {
selected: boolean
onSelect: () => void
children: ReactNode
}) {
return (
<li role="tab" className="flex-1 lg:flex-none">
<button
className={[
`w-full p-4 lg:p-6 text-base lg:text-lg cursor-pointer border-b lg:border-b-0 lg:border-r flex justify-center`,
props.selected ? `bg-gradient-to-b lg:bg-gradient-to-r from-transparent to-blue-200 border-blue-400` : `border-gray-200`,
].join(' ')}
onClick={props.onSelect}
>
{props.children}
</button>
</li>
)
}
export function Domestic(props: {}) {
return (
<section role="tabpanel" className="flex-auto flex flex-col lg:flex-row justify-evenly gap-3 lg:gap-0">
<div className="w-full lg:w-64 flex flex-col">
<FragmentTitle img={prod} text="代理产品"/>
<DomesticLink
label="短效动态 IP"
desc="全国300+城市级定位节点"
href="/product?type=short"
discount={45}
/>
<DomesticLink
label="长效动态 IP"
desc="IP 资源覆盖全国"
href="/product?type=long"
discount={45}
/>
<DomesticLink
label="静态 IP"
desc="全国300+城市级定位节点"
href="/product?type=fixed"
discount={45}
/>
</div>
<div className="w-full lg:w-64 flex flex-col lg:max-lg:hidden">
<FragmentTitle img={custom} text="业务定制"/>
<DomesticLink
label="优质/企业/精选IP"
desc="超 1000 家企业共同信赖之选!大客户经理全程 1 对 1 沟通,随时为您排忧解难,提供 24 小时不间断支持"
href="/"
/>
</div>
</section>
)
}
export function Oversea(props: {}) {
return (
<section role="tabpanel" className="flex-auto flex items-center justify-center">
<p className="text-2xl text-primary">线~</p>
</section>
)
}
export function DomesticLink(props: {
label: string
desc: string
href: string
discount?: number
}) {
const router = useRouter()
const ctx = useContext(HeaderContext)
if (!ctx) {
throw new Error(`HeaderContext not found`)
}
const onClick = () => {
ctx.setMenu(false)
router.push(props.href)
}
return (
<Link
href={props.href}
className={merge(
`transition-colors duration-150 ease-in-out`,
`p-4 rounded-lg flex flex-col gap-1 hover:bg-blue-50`,
)}
onClick={onClick}>
<p className="flex gap-2">
<span>{props.label}</span>
{props.discount && (
<span className="text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full">
{props.discount}%
</span>
)}
</p>
<p className="text-gray-400 text-sm">
{props.desc}
</p>
</Link>
)
}

View File

@@ -1,4 +1,3 @@
import Image from 'next/image'
import Wrap from '@/components/wrap'
import s01 from '@/assets/header/solution/01.svg'
import s02 from '@/assets/header/solution/02.svg'
@@ -9,10 +8,11 @@ import s06 from '@/assets/header/solution/06.svg'
import s07 from '@/assets/header/solution/07.svg'
import s08 from '@/assets/header/solution/08.svg'
import {StaticImageData} from 'next/image'
import {FragmentTitle} from '@/app/(home)/@header/common'
export default function SolutionMenu() {
return (
<Wrap className="grid grid-cols-2 lg:grid-cols-4 lg:auto-rows-fr gap-4">
<Wrap className="h-full grid grid-cols-2 lg:grid-cols-4 auto-rows-fr gap-4">
<SolutionItem
icon={s01}
title="数据抓取"
@@ -65,15 +65,12 @@ function SolutionItem(props: {
return (
<div
className={[
`h-full lg:p-4 flex gap-4 items-start rounded-md cursor-pointer`,
`flex flex-col gap-2 items-start rounded-md cursor-pointer`,
`transition-colors duration-200 hover:bg-blue-50`,
].join(' ')}
>
<Image src={props.icon} alt={props.title} className="w-10 h-10"/>
<div className="flex flex-col gap-1">
<h3 className="font-bold">{props.title}</h3>
<p className="text-gray-400 text-sm">{props.desc}</p>
</div>
<FragmentTitle img={props.icon} text={props.title}/>
<p className="text-gray-400 text-sm">{props.desc}</p>
</div>
)
}

View File

@@ -1,39 +1,36 @@
'use client'
import UserCenter from '@/components/composites/user-center'
import {useClientStore, useProfileStore} from '@/app/stores'
import {buttonVariants} from '@/components/ui/button'
import {
Navigation,
NavigationIndicator,
NavigationLink,
NavigationLinkItem,
NavigationGroup,
NavigationTriggerItem,
NavigationMenuViewport,
} from '@/components/ui/navigation-menu'
import Wrap from '@/components/wrap'
import {merge} from '@/lib/utils'
import {useState, useCallback, useEffect, useContext} from 'react'
import {useCallback, useEffect, useMemo, useState, PointerEvent, ComponentProps} from 'react'
import Link from 'next/link'
import Image from 'next/image'
import SolutionMenu from './menu-solution'
import ProductMenu from './menu-product'
import HelpMenu from './menu-help'
import MobileMenu from './menu-mobile'
import Wrap from '@/components/wrap'
import logo from '@/assets/logo.webp'
import ProductMenu, {Domestic} from '@/app/(home)/@header/_client/product'
import SolutionMenu from '@/app/(home)/@header/_client/solution'
import HelpMenu from '@/app/(home)/@header/_client/help'
import {Button} from '@/components/ui/button'
import {useProfileStore} from '@/components/stores-provider'
import UserCenter from '@/components/composites/user-center'
import {MenuIcon} from 'lucide-react'
import down from '@/assets/header/down.svg'
import {merge} from '@/lib/utils'
import {HeaderContext} from './common'
export type HeaderProps = {}
export type ProviderProps = {}
export default function Header(props: HeaderProps) {
export default function Page(props: ProviderProps) {
// ======================
// 背景显示状态
// 滚动条状态
// ======================
const [expand, setExpand] = useState(false)
const [scroll, setScroll] = useState(false)
const [scroll, setScroll] = useState(false) // Changed to false for client-side rendering
const handleScroll = useCallback(() => {
setScroll(window.scrollY > 48)
}, [])
useEffect(() => {
// Initialize scroll state on client
setScroll(window.scrollY > 48)
window.addEventListener('scroll', handleScroll)
return () => {
@@ -42,10 +39,57 @@ export default function Header(props: HeaderProps) {
}, [handleScroll])
// ======================
// 移动端
// 菜单状态
// ======================
const lg = useClientStore(state => state.breakpoint.lg)
const [menu, setMenu] = useState(false)
const [page, setPage] = useState(0)
const pages = useMemo(() => [
<ProductMenu key="product"/>,
<SolutionMenu key="solution"/>,
<HelpMenu key="help"/>,
<MobileMenu key="mobile"/>,
], [])
const enterMenu = (i: number) => {
return (e: PointerEvent) => {
setPage(i)
if (e.pointerType === 'mouse' || page !== i) {
setMenu(true)
}
else {
setMenu(!menu)
}
}
}
const exitMenu = (e: PointerEvent) => {
if (e.pointerType === 'mouse') {
setMenu(false)
}
}
const enterMenuContent = (e: PointerEvent) => {
setMenu(true)
}
const exitMenuContent = (e: PointerEvent) => {
if (e.pointerType === 'mouse') {
setMenu(false)
}
}
const enterMenuMask = (e: PointerEvent) => {
if (e.pointerType !== 'mouse') {
setMenu(false)
}
}
// ======================
// 用户信息
// ======================
const profile = useProfileStore(store => store.profile)
// ======================
// render
@@ -53,102 +97,188 @@ export default function Header(props: HeaderProps) {
return (
<header
data-expand={expand}
data-scroll={scroll}
data-effect={expand || scroll}
className="group/header w-full fixed left-0 top-0 z-20"
>
<Navigation
onValueChange={(value) => {
setExpand(!!value)
}}
>
className={merge(
'fixed top-0 left-0 w-screen z-10 flex flex-col',
menu && 'h-screen',
)}>
<HeaderContext.Provider value={{setMenu}}>
{/* 菜单栏 */}
<div className={merge(
`transition-[background-color,backdrop-filter,box-shadow] duration-200 ease-in-out`,
`bg-transparent backdrop-blur-none shadow-none`,
`group-data-[effect=true]/header:bg-card/90`,
`group-data-[effect=true]/header:backdrop-blur-sm`,
`group-data-[scroll=true]/header:shadow-lg`,
`flex-none`,
`transition-[background,shadow] duration-200 ease-in-out`,
menu
? `bg-[#fffe] backdrop-blur-sm`
: scroll
? `bg-[#fffe] backdrop-blur-sm shadow-lg`
: `bg-transparent shadow-none`,
)}>
<Wrap className={merge('h-14 md:h-16 flex justify-between items-stretch')}>
<div className="flex items-stretch lg:flex-row-reverse">
{lg ? (
<NavigationGroup className="gap-0 flex">
<NavigationLinkItem href="/" text="首页"/>
<Wrap className="h-20 max-md:h-16 flex justify-between">
<nav className="flex items-center justify-between lg:flex-row-reverse gap-4 lg:gap-8">
<NavigationTriggerItem text="产品订购" className="h-80">
<ProductMenu/>
</NavigationTriggerItem>
{/* 桌面端菜单 */}
<ul className="h-full items-stretch hidden lg:flex">
<LinkItem text="首页" href="/"/>
<MenuItem
text="产品订购"
active={menu && page === 0}
onPointerEnter={enterMenu(0)}
onPointerLeave={exitMenu}
/>
<MenuItem
text="业务场景"
active={menu && page === 1}
onPointerEnter={enterMenu(1)}
onPointerLeave={exitMenu}
/>
<MenuItem
text="帮助中心"
active={menu && page === 2}
onPointerEnter={enterMenu(2)}
onPointerLeave={exitMenu}
/>
<LinkItem
text="企业服务"
href="#"/>
<LinkItem
text="推广返利"
href="#"/>
</ul>
<NavigationTriggerItem text="业务场景" className="h-80">
<SolutionMenu/>
</NavigationTriggerItem>
{/* 移动端菜单 */}
<Button
theme="ghost"
className="lg:hidden size-10"
onPointerEnter={enterMenu(3)}
onPointerLeave={exitMenu}
>
<MenuIcon/>
</Button>
<NavigationTriggerItem text="帮助中心" className="h-80">
<HelpMenu/>
</NavigationTriggerItem>
{/* logo */}
<Link href="/" className="flex items-center">
<Image src={logo} alt="logo" height={40} className="translate-y-0.5"/>
</Link>
</nav>
<NavigationLinkItem href="/" text="企业服务"/>
<NavigationLinkItem href="/" text="推广返利"/>
<NavigationIndicator/>
</NavigationGroup>
) : (
<NavigationGroup className="flex">
<NavigationTriggerItem
suffix={false}
text={<MenuIcon/>}
className={merge(
`flex flex-col items-start gap-6`,
)}
>
<ProductMenu/>
<SolutionMenu/>
<HelpMenu/>
<NavigationLink href="/" text="企业服务"/>
<NavigationLink href="/" text="推广返利"/>
</NavigationTriggerItem>
</NavigationGroup>
)}
<NavigationLink href="/" text={<Image src={logo} alt="logo" height={36}/>}/>
{/* 登录 */}
<div className="flex items-center">
{profile == undefined
? (
<>
<Link
href="/login"
className="w-24 h-12 flex items-center justify-center lg:text-lg"
>
<span></span>
</Link>
<Link
href="/login"
className={[
`w-20 lg:w-24 h-10 lg:h-12 bg-gradient-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`,
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
].join(' ')}
>
<span></span>
</Link>
</>
)
: (
<UserCenter profile={profile}/>
)
}
</div>
<AccountRegion/>
</Wrap>
</div>
<NavigationMenuViewport className={merge(
`bg-card/90 backdrop-blur-sm shadow-lg`,
`transition-[padding,opacity,max-height]`,
`group-data-[expand=false]/header:delay-[0s,0s,0.2s] group-data-[expand=true]/header:delay-0`,
`p-0 group-data-[expand=true]/header:py-4`,
`opacity-0 group-data-[effect=true]/header:opacity-100`,
`max-h-0 group-data-[effect=true]/header:max-h-[calc(100vh-56px)] overflow-auto`,
)}/>
</Navigation>
{/* 下拉菜单 */}
<div
className={merge(
`flex-auto overflow-auto lg:flex-none lg:basis-72 shadow-lg box-content`,
`bg-[#fffe] backdrop-blur-sm`,
`transition-[opacity,padding] transition-discrete duration-200 ease-in-out`,
menu
? `delay-[0s,0s] opacity-100 py-4`
: `delay-[0s,0s] opacity-0 py-0 pointer-events-none`,
)}
onPointerEnter={enterMenuContent}
onPointerLeave={exitMenuContent}
>
{pages[page]}
</div>
{/* 遮罩层 */}
<div className="flex-auto" onPointerEnter={enterMenuMask}/>
</HeaderContext.Provider>
</header>
)
}
function AccountRegion() {
const profile = useProfileStore(state => state.profile)
function LinkItem(props: {
text: string
href: string
}) {
return (
<div className="self-center">
{profile ? (
<UserCenter profile={profile}/>
) : (
<NavigationLink
href="/login"
text="登录 / 注册"
classNameOverride={buttonVariants({
theme: 'gradient',
className: 'h-10 lg:h-12',
})}/>
)}
</div>
<li className="group relative">
<Link
href={props.href}
className={[
`h-full px-4 flex items-center text-lg`,
`transition-colors duration-200 ease-in-out`,
`hover:text-blue-500`,
].join(' ')}
>
{props.text}
</Link>
<div className={[
`absolute bottom-0 w-full h-0.5 bg-transparent `,
`transition-colors duration-200`,
`group-hover:bg-blue-500`,
].join(' ')}>
</div>
</li>
)
}
function MenuItem(props: {
text: string
active: boolean
} & ComponentProps<'button'>) {
return (
<li className="group relative">
<button
onPointerEnter={props.onPointerEnter}
onPointerLeave={props.onPointerLeave}
className={[
`h-full px-4 flex gap-3 items-center cursor-pointer text-lg`,
`transition-colors duration-200 ease-in-out`,
props.active
? `text-blue-500`
: ``,
].join(' ')}
>
<span>{props.text}</span>
<Image
src={down}
alt="drop_menu"
className={[
`transition-transform duration-200 ease-in-out`,
props.active
? `rotate-180`
: ``,
].join(' ')}
/>
</button>
<div
className={[
`absolute bottom-0 w-full h-0.5 pointer-events-none`,
`transition-colors duration-200`,
props.active
? `bg-blue-500`
: 'bg-transparent',
].join(' ')}/>
</li>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import {PanelLeftCloseIcon, PanelLeftOpenIcon} from 'lucide-react'
import {Button} from '@/components/ui/button'
import {useLayoutStore} from '@/app/stores'
import {useLayoutStore} from '@/components/stores-provider'
import {merge} from '@/lib/utils'
import UserCenter from '@/components/composites/user-center'
import {User} from '@/lib/models'

View File

@@ -1,6 +1,6 @@
'use client'
import {ReactNode} from 'react'
import {useLayoutStore} from '@/app/stores'
import {useLayoutStore} from '@/components/stores-provider'
import {merge} from '@/lib/utils'
type AdminLayoutProps = {

View File

@@ -1,7 +1,7 @@
'use client'
import {ComponentProps, ReactNode, useState} from 'react'
import {merge} from '@/lib/utils'
import {useLayoutStore} from '@/app/stores'
import {useLayoutStore} from '@/components/stores-provider'
import Link from 'next/link'
import Image from 'next/image'
import logoAvatar from '../_assets/logo-avatar.svg'

View File

@@ -11,7 +11,7 @@ import {Identify} from '@/actions/user'
import {toast} from 'sonner'
import {useEffect, useRef, useState} from 'react'
import * as qrcode from 'qrcode'
import {useProfileStore} from '@/app/stores'
import {useProfileStore} from '@/components/stores-provider'
import {merge} from '@/lib/utils'
import banner from './_assets/banner.webp'
import personal from './_assets/personal.webp'

View File

@@ -7,7 +7,7 @@ import {Button} from '@/components/ui/button'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import * as z from 'zod'
import {useProfileStore} from '@/app/stores'
import {useProfileStore} from '@/components/stores-provider'
import {toast} from 'sonner'
import {CheckCircle, QrCodeIcon} from 'lucide-react'
import * as qrcode from 'qrcode'

View File

@@ -1,6 +1,6 @@
'use client'
import {ReactNode, useEffect} from 'react'
import {useClientStore, useLayoutStore} from '@/app/stores'
import {useClientStore, useLayoutStore} from '@/components/stores-provider'
export type EffectProviderProps = {
children?: ReactNode

View File

@@ -4,7 +4,7 @@ import {Metadata} from 'next'
import './globals.css'
import localFont from 'next/font/local'
import {Toaster} from '@/components/ui/sonner'
import Stores from '@/app/stores'
import StoresProvider from '@/components/stores-provider'
import {getProfile} from '@/actions/auth'
import Effects from '@/app/effects'
@@ -29,11 +29,11 @@ export default async function RootLayout({
return (
<html lang="zh-CN">
<body className={`${font.className}`}>
<Stores user={user}>
<StoresProvider user={user}>
<Effects>
{children}
</Effects>
</Stores>
</StoresProvider>
<Toaster position="top-center" richColors expand/>
</body>
</html>

View File

@@ -8,7 +8,7 @@ import Image from 'next/image'
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import balance from '@/components/composites/purchase/_assets/balance.svg'
import {useProfileStore} from '@/app/stores'
import {useProfileStore} from '@/components/stores-provider'
import RechargeModal from '@/components/composites/recharge'
import Pay from '@/components/composites/purchase/pay'
import {buttonVariants} from '@/components/ui/button'

View File

@@ -6,7 +6,7 @@ import wechat from './_assets/wechat.svg'
import balance from './_assets/balance.svg'
import Image from 'next/image'
import {useEffect, useRef, useState} from 'react'
import {useProfileStore} from '@/app/stores'
import {useProfileStore} from '@/components/stores-provider'
import {Alert, AlertTitle} from '@/components/ui/alert'
import {ApiResponse, ExtraResp, ExtraReq} from '@/lib/api'
import {toast} from 'sonner'

View File

@@ -8,7 +8,7 @@ import Image from 'next/image'
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import balance from '@/components/composites/purchase/_assets/balance.svg'
import {useProfileStore} from '@/app/stores'
import {useProfileStore} from '@/components/stores-provider'
import RechargeModal from '@/components/composites/recharge'
import {buttonVariants} from '@/components/ui/button'
import Link from 'next/link'

View File

@@ -19,7 +19,7 @@ import {useEffect, useMemo, useRef, useState} from 'react'
import {Loader} from 'lucide-react'
import {RechargeComplete, RechargePrepare} from '@/actions/user'
import * as qrcode from 'qrcode'
import {useProfileStore} from '@/app/stores'
import {useProfileStore} from '@/components/stores-provider'
import {merge} from '@/lib/utils'
import {
Platform,

View File

@@ -1,5 +1,5 @@
'use client'
import {useProfileStore} from '@/app/stores'
import {useProfileStore} from '@/components/stores-provider'
import {Button} from '@/components/ui/button'
import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar'
import {LoaderIcon, LogOutIcon, UserIcon, UserPenIcon} from 'lucide-react'

View File

@@ -42,7 +42,7 @@ export type ProfileProviderProps = {
children: ReactNode
}
export default function Stores(props: ProfileProviderProps) {
export default function StoresProvider(props: ProfileProviderProps) {
const profile = useRef<StoreApi<ProfileStore>>(null)
if (!profile.current) {
console.log('📦 create profile store')