feat: global shell — header, footer, sidebars, and layout integration

This commit is contained in:
zetaloop
2026-02-20 13:27:33 +08:00
parent 02cd8a23df
commit f7c76db00f
9 changed files with 520 additions and 36 deletions
+9 -11
View File
@@ -1,16 +1,14 @@
import { AccountSidebar } from "@/components/account-sidebar"
import { Header } from "@/components/header"
export default function AccountLayout({ children }: { children: React.ReactNode }) { export default function AccountLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen flex-col">
<aside className="w-64 border-r bg-muted/30 p-4"> <Header />
<h2 className="mb-4 text-lg font-semibold"></h2> <div className="flex flex-1">
<nav className="space-y-2 text-sm text-muted-foreground"> <AccountSidebar />
<div></div> <main className="flex-1 p-6">{children}</main>
<div></div> </div>
<div></div>
<div></div>
</nav>
</aside>
<main className="flex-1 p-6">{children}</main>
</div> </div>
) )
} }
+9 -10
View File
@@ -1,15 +1,14 @@
import { DashboardSidebar } from "@/components/dashboard-sidebar"
import { Header } from "@/components/header"
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen flex-col">
<aside className="w-64 border-r bg-muted/30 p-4"> <Header />
<h2 className="mb-4 text-lg font-semibold"></h2> <div className="flex flex-1">
<nav className="space-y-2 text-sm text-muted-foreground"> <DashboardSidebar />
<div></div> <main className="flex-1 p-6">{children}</main>
<div></div> </div>
<div></div>
</nav>
</aside>
<main className="flex-1 p-6">{children}</main>
</div> </div>
) )
} }
+5 -13
View File
@@ -1,20 +1,12 @@
import { Footer } from "@/components/footer"
import { Header } from "@/components/header"
export default function MainLayout({ children }: { children: React.ReactNode }) { export default function MainLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<header className="border-b"> <Header />
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
<span className="text-xl font-bold"></span>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground"></span>
</div>
</nav>
</header>
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<footer className="border-t py-6"> <Footer />
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
© -
</div>
</footer>
</div> </div>
) )
} }
+8 -1
View File
@@ -1,3 +1,10 @@
import { Header } from "@/components/header"
export default function OrderLayout({ children }: { children: React.ReactNode }) { export default function OrderLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-muted/30">{children}</div> return (
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1 bg-muted/30">{children}</main>
</div>
)
} }
+6 -1
View File
@@ -2,6 +2,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useState } from "react" import { useState } from "react"
import { TooltipProvider } from "@/components/ui/tooltip"
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState( const [queryClient] = useState(
@@ -16,5 +17,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
}), }),
) )
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>{children}</TooltipProvider>
</QueryClientProvider>
)
} }
+44
View File
@@ -0,0 +1,44 @@
"use client"
import { Bell, CreditCard, Settings, ShieldCheck } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
const links = [
{ href: "/settings", label: "个人设置", icon: Settings },
{ href: "/wallet", label: "钱包", icon: CreditCard },
{ href: "/notifications", label: "通知中心", icon: Bell },
{ href: "/verify", label: "身份认证", icon: ShieldCheck },
]
export function AccountSidebar() {
const pathname = usePathname()
return (
<aside className="w-56 shrink-0 border-r bg-muted/30">
<div className="flex h-14 items-center border-b px-4 font-semibold"></div>
<nav className="p-2 space-y-1">
{links.map((link) => {
const Icon = link.icon
const isActive = pathname === link.href
return (
<Link
key={link.href}
href={link.href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
)}
>
<Icon className="h-4 w-4" />
{link.label}
</Link>
)
})}
</nav>
</aside>
)
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import {
Gamepad2,
LayoutDashboard,
ListOrdered,
Palette,
Settings2,
Store,
Users,
} from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { useAuthStore } from "@/store/auth"
const playerLinks = [
{ href: "/dashboard", label: "概览", icon: LayoutDashboard },
{ href: "/dashboard/services", label: "服务管理", icon: ListOrdered },
]
const ownerLinks = [
{ href: "/dashboard", label: "概览", icon: LayoutDashboard },
{ href: "/dashboard/services", label: "服务管理", icon: ListOrdered },
{ href: "/dashboard/shop", label: "店铺管理", icon: Store },
{ href: "/dashboard/shop/employees", label: "员工管理", icon: Users },
{ href: "/dashboard/shop/templates", label: "模板编辑", icon: Palette },
{ href: "/dashboard/settings", label: "店铺设置", icon: Settings2 },
]
export function DashboardSidebar() {
const pathname = usePathname()
const { currentRole } = useAuthStore()
const links = currentRole === "owner" ? ownerLinks : playerLinks
return (
<aside className="w-56 shrink-0 border-r bg-muted/30">
<div className="flex h-14 items-center gap-2 border-b px-4 font-semibold">
<Gamepad2 className="h-4 w-4" />
</div>
<nav className="p-2 space-y-1">
{links.map((link) => {
const Icon = link.icon
const isActive = pathname === link.href
return (
<Link
key={link.href}
href={link.href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
)}
>
<Icon className="h-4 w-4" />
{link.label}
</Link>
)
})}
</nav>
</aside>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { Gamepad2 } from "lucide-react"
import Link from "next/link"
export function Footer() {
return (
<footer className="border-t bg-muted/30">
<div className="container mx-auto px-4 py-8">
<div className="grid gap-8 md:grid-cols-4">
<div>
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
<Gamepad2 className="h-5 w-5" />
</Link>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div>
<h3 className="font-semibold text-sm mb-3"></h3>
<nav className="flex flex-col gap-2 text-sm text-muted-foreground">
<Link href="/search" className="hover:text-foreground transition-colors">
</Link>
<Link href="/search" className="hover:text-foreground transition-colors">
</Link>
<Link href="/community" className="hover:text-foreground transition-colors">
</Link>
</nav>
</div>
<div>
<h3 className="font-semibold text-sm mb-3"></h3>
<nav className="flex flex-col gap-2 text-sm text-muted-foreground">
<span></span>
<span></span>
<span></span>
</nav>
</div>
<div>
<h3 className="font-semibold text-sm mb-3"></h3>
<nav className="flex flex-col gap-2 text-sm text-muted-foreground">
<span>客服邮箱: support@juwan.gg</span>
<span>工作时间: 9:00 - 22:00</span>
</nav>
</div>
</div>
<div className="mt-8 border-t pt-4 text-center text-xs text-muted-foreground">
© 2025 -
</div>
</div>
</footer>
)
}
+321
View File
@@ -0,0 +1,321 @@
"use client"
import {
Bell,
Gamepad2,
LogOut,
Menu,
MessageSquare,
Search,
Settings,
ShoppingBag,
User,
Wallet,
} from "lucide-react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { currentUser, mockNotifications } from "@/lib/mock-data"
import type { UserRole } from "@/lib/types"
import { cn } from "@/lib/utils"
import { useAuthStore } from "@/store/auth"
const roleLabels: Record<UserRole, string> = {
consumer: "消费者",
player: "打手",
owner: "店主",
}
const navLinks = [
{ href: "/", label: "首页" },
{ href: "/community", label: "社区" },
{ href: "/orders", label: "订单" },
{ href: "/chat", label: "消息" },
]
export function Header() {
const [mobileOpen, setMobileOpen] = useState(false)
const pathname = usePathname()
const router = useRouter()
const { isAuthenticated, currentRole, switchRole, login, logout } = useAuthStore()
const unreadCount = mockNotifications.filter((n) => !n.read).length
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const q = formData.get("q") as string
if (q.trim()) router.push(`/search?q=${encodeURIComponent(q.trim())}`)
}
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex h-14 items-center gap-4 px-4">
<Link href="/" className="flex items-center gap-2 font-bold text-lg shrink-0">
<Gamepad2 className="h-5 w-5" />
</Link>
<nav className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
"px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
pathname === link.href || (link.href !== "/" && pathname.startsWith(link.href))
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
)}
>
{link.label}
</Link>
))}
</nav>
<form onSubmit={handleSearch} className="hidden md:flex flex-1 max-w-sm">
<div className="relative w-full">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input name="q" placeholder="搜索打手、店铺..." className="pl-8 h-9" />
</div>
</form>
<div className="flex flex-1 md:flex-none items-center justify-end gap-1">
{isAuthenticated ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="hidden md:flex text-xs h-8">
{roleLabels[currentRole]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.entries(roleLabels) as [UserRole, string][]).map(([role, label]) => (
<DropdownMenuItem
key={role}
onClick={() => switchRole(role)}
className={cn(currentRole === role && "bg-accent")}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" className="relative h-9 w-9" asChild>
<Link href="/notifications">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1 h-4 min-w-4 px-1 flex items-center justify-center text-[10px]">
{unreadCount}
</Badge>
)}
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full h-9 w-9">
<Avatar className="h-7 w-7">
<AvatarImage src={currentUser.avatar} />
<AvatarFallback>{currentUser.nickname[0]}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>{currentUser.nickname}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href={`/player/${currentUser.id}`}>
<User className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/orders">
<ShoppingBag className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/chat">
<MessageSquare className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/wallet">
<Wallet className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
{(currentRole === "player" || currentRole === "owner") && (
<DropdownMenuItem asChild>
<Link href="/dashboard">
<Gamepad2 className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/settings">
<Settings className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
<LogOut className="mr-2 h-4 w-4" />
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<div className="hidden md:flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={login}>
</Button>
<Button size="sm" asChild>
<Link href="/register"></Link>
</Button>
</div>
)}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden h-9 w-9">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-72">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Gamepad2 className="h-5 w-5" />
</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-4">
<form onSubmit={handleSearch}>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input name="q" placeholder="搜索打手、店铺..." className="pl-8" />
</div>
</form>
{isAuthenticated && (
<div className="flex items-center gap-2 px-1">
<Avatar className="h-8 w-8">
<AvatarImage src={currentUser.avatar} />
<AvatarFallback>{currentUser.nickname[0]}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{currentUser.nickname}</p>
<p className="text-xs text-muted-foreground">{roleLabels[currentRole]}</p>
</div>
</div>
)}
<nav className="flex flex-col gap-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
pathname === link.href ||
(link.href !== "/" && pathname.startsWith(link.href))
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
)}
>
{link.label}
</Link>
))}
{isAuthenticated && (
<>
<Link
href="/wallet"
onClick={() => setMobileOpen(false)}
className="px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50"
>
</Link>
<Link
href="/settings"
onClick={() => setMobileOpen(false)}
className="px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50"
>
</Link>
{(currentRole === "player" || currentRole === "owner") && (
<Link
href="/dashboard"
onClick={() => setMobileOpen(false)}
className="px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50"
>
</Link>
)}
</>
)}
</nav>
{isAuthenticated && (
<div className="border-t pt-4">
<p className="px-3 text-xs text-muted-foreground mb-2"></p>
<div className="flex gap-1 px-3">
{(Object.entries(roleLabels) as [UserRole, string][]).map(([role, label]) => (
<Button
key={role}
variant={currentRole === role ? "default" : "outline"}
size="sm"
className="text-xs flex-1"
onClick={() => switchRole(role)}
>
{label}
</Button>
))}
</div>
</div>
)}
{!isAuthenticated && (
<div className="border-t pt-4 flex gap-2 px-3">
<Button variant="outline" className="flex-1" onClick={login}>
</Button>
<Button className="flex-1" asChild>
<Link href="/register" onClick={() => setMobileOpen(false)}>
</Link>
</Button>
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
)
}