Files
wzdj/app/admin/layout.tsx
卡若 ea3a589f0b
Some checks failed
CI / notice (push) Has been cancelled
chore: sync docs, gitea wiki assets, templates
Made-with: Cursor
2026-04-14 18:44:44 +08:00

339 lines
13 KiB
TypeScript
Raw 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.

"use client"
import { useState, useEffect } from "react"
import { useRouter, usePathname } from "next/navigation"
import Link from "next/link"
import {
Users,
User,
Radio,
Building2,
ShoppingBag,
FileText,
Wallet,
MessageSquare,
Settings,
LogOut,
Gamepad2,
Hotel,
CreditCard,
Bell,
Search,
Activity,
ChevronLeft,
ChevronRight,
LayoutDashboard,
Trophy,
Monitor,
History,
PieChart,
CircleDollarSign,
} from "lucide-react"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
function AdminLoginForm({ onSuccess }: { onSuccess: () => void; theme?: "light" }) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [loading, setLoading] = useState(false)
const handleLogin = async () => {
if (!username.trim() || !password) {
alert("请输入用户名和密码")
return
}
setLoading(true)
try {
const res = await fetch("/api/admin/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), password }),
})
const json = await res.json()
if (json.success) {
localStorage.setItem("admin_logged_in", "true")
onSuccess()
} else {
alert(json.message || "用户名或密码错误")
}
} catch {
alert("登录请求失败")
}
setLoading(false)
}
return (
<>
<div>
<label className="text-sm mb-1 block text-gray-600"></label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
className="bg-gray-50 border-gray-200 text-gray-900 placeholder:text-gray-400"
/>
</div>
<div>
<label className="text-sm mb-1 block text-gray-600"></label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
className="bg-gray-50 border-gray-200 text-gray-900 placeholder:text-gray-400"
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
/>
</div>
<Button
onClick={handleLogin}
disabled={loading}
className="w-full admin-glass rounded-xl text-gray-800 hover:bg-white/70 border border-white/50"
>
{loading ? "登录中..." : "登录后台"}
</Button>
</>
)
}
const menuGroups: { title: string; items: { id: string; label: string; icon: typeof LayoutDashboard; path: string }[] }[] = [
{
title: "概览",
items: [
{ id: "dashboard", label: "数据概览", icon: LayoutDashboard, path: "/admin" },
{ id: "screen", label: "大屏", icon: Monitor, path: "/admin/screen" },
],
},
{
title: "用户与创作者",
items: [
{ id: "users", label: "用户管理", icon: Users, path: "/admin/users" },
{ id: "journey", label: "用户旅程", icon: History, path: "/admin/journey" },
{ id: "rfm-rules", label: "RFM规则", icon: PieChart, path: "/admin/rfm-rules" },
{ id: "user-value", label: "用户估值", icon: CircleDollarSign, path: "/admin/user-value" },
{ id: "streamers", label: "主播管理", icon: Radio, path: "/admin/streamers" },
{ id: "guilds", label: "公会管理", icon: Building2, path: "/admin/guilds" },
{ id: "live", label: "直播管理", icon: Gamepad2, path: "/admin/live" },
{ id: "party", label: "派对群管理", icon: MessageSquare, path: "/admin/party" },
{ id: "content", label: "内容管理", icon: Trophy, path: "/admin/content" },
],
},
{
title: "业务与订单",
items: [
{ id: "mall", label: "商城管理", icon: ShoppingBag, path: "/admin/mall" },
{ id: "points", label: "游戏点卡", icon: CreditCard, path: "/admin/points" },
{ id: "orders", label: "订单管理", icon: FileText, path: "/admin/orders" },
{ id: "leveling", label: "代练管理", icon: Activity, path: "/admin/leveling" },
{ id: "pawn", label: "典当管理", icon: Wallet, path: "/admin/pawn" },
{ id: "accounts", label: "游戏账号", icon: User, path: "/admin/accounts" },
{ id: "hotel", label: "酒店/网咖", icon: Hotel, path: "/admin/hotel" },
],
},
{
title: "财务与系统",
items: [
{ id: "finance", label: "财务管理", icon: CreditCard, path: "/admin/finance" },
{ id: "settings", label: "系统设置", icon: Settings, path: "/admin/settings" },
],
},
]
function getCurrentMenuId(pathname: string | null): string {
if (!pathname) return "dashboard"
if (pathname === "/admin" || pathname === "/admin/") return "dashboard"
for (const group of menuGroups) {
for (const item of group.items) {
if (item.path !== "/admin" && pathname.startsWith(item.path)) return item.id
if (pathname === item.path) return item.id
}
}
return "dashboard"
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null)
const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => {
const loggedIn = localStorage.getItem("admin_logged_in") === "true"
setIsLoggedIn(loggedIn)
if (!loggedIn && pathname !== "/admin" && !pathname?.endsWith("/admin")) {
router.replace("/admin")
}
}, [pathname, router])
const handleLogout = () => {
localStorage.removeItem("admin_logged_in")
setIsLoggedIn(false)
router.replace("/admin")
}
const currentMenuId = getCurrentMenuId(pathname)
if (isLoggedIn === null) {
return (
<div className="admin-light-theme admin-bg-archer min-h-screen flex items-center justify-center">
<div className="text-gray-500">...</div>
</div>
)
}
if (!isLoggedIn) {
return (
<div className="admin-light-theme admin-bg-archer min-h-screen flex flex-col items-center justify-center p-4 relative">
<Card className="admin-glass w-full max-w-md border border-slate-200/80 rounded-2xl overflow-hidden shadow-lg">
<CardHeader className="text-center">
<div className="w-16 h-16 rounded-2xl mx-auto mb-4 flex items-center justify-center bg-white/60 border border-white/50">
<Gamepad2 className="w-8 h-8 text-gray-600" />
</div>
<CardTitle className="text-2xl text-gray-900"> · </CardTitle>
<p className="text-sm mt-2 text-gray-500">使</p>
</CardHeader>
<CardContent className="space-y-4">
<AdminLoginForm onSuccess={() => setIsLoggedIn(true)} />
<p className="text-center text-xs text-gray-500">默认账号: admin / k123456</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="admin-light-theme admin-bg-archer min-h-screen h-screen flex">
{/* 左侧导航:神射手风格、可折叠、悬浮、苹果毛玻璃 */}
<div className="flex shrink-0 pt-4 pb-4 pl-4 pr-2">
<aside
className={cn(
"admin-glass-panel flex flex-col transition-[width] duration-200 ease-out overflow-hidden shrink-0 rounded-2xl shadow-lg",
"h-[calc(100vh-2rem)]",
sidebarOpen ? "w-64 rounded-r-2xl" : "w-[72px] rounded-r-2xl"
)}
>
<div className="h-14 border-b border-gray-200/60 flex items-center justify-between px-3 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0 bg-white/50 border border-white/50">
<Gamepad2 className="w-5 h-5 text-gray-600" />
</div>
{sidebarOpen && (
<span className="font-semibold truncate text-sm text-gray-900"></span>
)}
</div>
<button
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="shrink-0 p-1.5 rounded-lg transition-colors text-gray-500 hover:text-gray-900 hover:bg-gray-100"
title={sidebarOpen ? "收起侧栏" : "展开侧栏"}
>
{sidebarOpen ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
</div>
{sidebarOpen && (
<div className="p-4 border-b border-gray-200/60">
<p className="text-xs text-gray-500"></p>
</div>
)}
<nav className="flex-1 py-3 px-4 overflow-y-auto overflow-x-hidden">
<ul className="space-y-1">
{menuGroups.map((group) => (
<li key={group.title}>
{sidebarOpen && (
<div className="px-2 py-1.5 text-[11px] font-medium uppercase tracking-wider text-gray-500">
{group.title}
</div>
)}
<div className="space-y-1">
{group.items.map((item) => {
const isActive = currentMenuId === item.id
return (
<Link
key={item.id}
href={item.path}
className={cn(
"flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200",
isActive
? "bg-gradient-to-r from-violet-500 to-cyan-500 text-white shadow-md"
: "text-gray-600 hover:bg-white/60 hover:text-gray-900"
)}
>
<item.icon className="w-5 h-5 shrink-0" />
{sidebarOpen && <span className="truncate">{item.label}</span>}
</Link>
)
})}
</div>
</li>
))}
</ul>
</nav>
<div className="p-4 border-t border-gray-200/60 shrink-0">
<div className="flex items-center gap-3 px-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold bg-white/50 text-gray-700 border border-white/50">
</div>
{sidebarOpen && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-gray-900"></p>
<p className="text-xs truncate text-gray-500">admin@wanzhi.com</p>
</div>
)}
</div>
<button
type="button"
onClick={handleLogout}
className="mt-3 w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors text-sm text-gray-500 hover:bg-gray-100 hover:text-red-600"
>
<LogOut className="w-4 h-4 shrink-0" />
{sidebarOpen && <span>退</span>}
</button>
</div>
</aside>
</div>
{/* 主内容区:顶栏 + 内容区 */}
<main className="flex-1 flex flex-col min-w-0 h-screen overflow-hidden">
<header className="admin-glass h-14 rounded-bl-2xl flex items-center justify-between px-5 shrink-0 border-b border-l border-gray-200/60">
<div className="flex items-center gap-4">
<div className="relative w-56">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-500" />
<Input
placeholder="搜索..."
className="w-full pl-9 h-9 text-sm rounded-2xl bg-white border-gray-200 text-gray-800 placeholder:text-gray-500 shadow-sm"
/>
</div>
</div>
<div className="flex items-center gap-2">
<button type="button" className="relative p-2 rounded-xl transition-colors text-gray-500 hover:bg-gray-100 hover:text-gray-900" title="通知">
<Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-4 h-4 bg-amber-500/80 rounded-full text-[10px] text-slate-900 flex items-center justify-center font-medium">
3
</span>
</button>
<div className="flex items-center gap-3 pl-2 border-l border-gray-200/60">
{typeof process !== "undefined" && process.env.NEXT_PUBLIC_BUILD_TIME && (
<span className="text-[11px] font-mono text-gray-500" title="当前镜像构建时间,用于确认已部署最新">
{process.env.NEXT_PUBLIC_BUILD_TIME}
</span>
)}
<Avatar className="w-8 h-8 border border-white/50 bg-white/50">
<AvatarFallback className="text-sm bg-white/60 text-gray-700"></AvatarFallback>
</Avatar>
<span className="text-sm font-medium text-gray-700"></span>
</div>
</div>
</header>
<div className="flex-1 overflow-auto p-6 bg-transparent admin-content-area">
{children}
</div>
</main>
</div>
)
}