Files
admin/src/app/(root)/appbar.tsx

337 lines
12 KiB
TypeScript
Raw Normal View History

"use client"
2026-01-05 09:14:41 +08:00
import {
BadgeQuestionMarkIcon,
BellIcon,
ChevronDownIcon,
ChevronRightIcon,
InboxIcon,
LogOutIcon,
SearchIcon,
SettingsIcon,
UserIcon,
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
2026-03-31 11:59:00 +08:00
import { logout } from "@/actions/auth"
2026-01-05 09:14:41 +08:00
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
2026-03-31 11:59:00 +08:00
import type { Admin } from "@/models/admin"
2025-12-29 10:41:23 +08:00
2026-03-31 11:59:00 +08:00
export default function Appbar(props: { admin: Admin }) {
const router = useRouter()
2025-12-29 10:41:23 +08:00
const [showDropdown, setShowDropdown] = useState(false)
const [showNotifications, setShowNotifications] = useState(false)
const [notifications] = useState([
{
id: 1,
title: "系统通知",
content: "您有新的待审核内容",
time: "10分钟前",
read: false,
},
{
id: 2,
title: "安全提醒",
content: "您的账号于昨天登录了新设备",
time: "1小时前",
read: true,
},
{
id: 3,
title: "系统更新",
content: "系统将在今晚进行例行维护",
time: "2小时前",
read: true,
},
2025-12-29 10:41:23 +08:00
])
const pathname = usePathname()
const dropdownRef = useRef<HTMLDivElement>(null)
const notificationRef = useRef<HTMLDivElement>(null)
// 处理点击外部关闭下拉菜单
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
2025-12-29 10:41:23 +08:00
setShowDropdown(false)
}
if (
notificationRef.current &&
!notificationRef.current.contains(event.target as Node)
) {
2025-12-29 10:41:23 +08:00
setShowNotifications(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
2025-12-29 10:41:23 +08:00
}, [])
// 根据路径生成面包屑
const generateBreadcrumbs = () => {
const paths = pathname.split("/").filter(Boolean)
2025-12-29 10:41:23 +08:00
const breadcrumbs = [
{ path: "/", label: "首页" },
2025-12-29 10:41:23 +08:00
...paths.map((path, index) => {
const url = `/${paths.slice(0, index + 1).join("/")}`
2025-12-29 10:41:23 +08:00
const label = getBreadcrumbLabel(path)
return { path: url, label }
2025-12-29 10:41:23 +08:00
}),
]
return breadcrumbs
}
const getBreadcrumbLabel = (path: string) => {
const labels: Record<string, string> = {
dashboard: "控制台",
content: "内容管理",
articles: "文章管理",
media: "媒体库",
2026-01-05 09:14:41 +08:00
user: "用户管理",
roles: "角色权限",
settings: "系统设置",
logs: "系统日志",
2026-01-05 09:14:41 +08:00
proxy: "",
nodes: "节点列表",
trade: "交易明细",
billing: "账单详情",
resources: "套餐管理",
batch: "使用记录",
channel: "IP管理",
pools: "IP池管理",
2025-12-29 10:41:23 +08:00
}
return labels[path] || path
}
const breadcrumbs = generateBreadcrumbs()
const unreadCount = notifications.filter(n => !n.read).length
const doLogout = async () => {
const resp = await logout()
if (resp.success) {
router.replace("/")
router.refresh()
}
}
2025-12-29 10:41:23 +08:00
return (
<header className="bg-white h-16 border-b border-gray-200 flex items-center justify-between px-6">
{/* 面包屑导航 */}
<div className="flex items-center text-sm">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.path} className="flex items-center">
{index > 0 && (
2026-01-05 09:14:41 +08:00
<ChevronRightIcon size={18} className="text-gray-400" />
2025-12-29 10:41:23 +08:00
)}
<Link
href={crumb.path}
className={
index === breadcrumbs.length - 1
? "text-gray-800 font-medium"
: "text-gray-500 hover:text-gray-700"
2025-12-29 10:41:23 +08:00
}
>
{crumb.label}
</Link>
</div>
))}
</div>
{/* 右侧用户信息和工具栏 */}
<div className="flex items-center space-x-4">
{/* 搜索框 */}
<div className="hidden md:block relative">
2026-01-05 09:14:41 +08:00
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="搜索..."
className="pl-10 pr-4 py-2 bg-gray-100 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-56"
/>
2026-01-05 09:14:41 +08:00
</div>
2025-12-29 10:41:23 +08:00
</div>
{/* 通知图标 */}
<div className="relative" ref={notificationRef}>
2026-01-05 09:14:41 +08:00
<Button
2025-12-29 10:41:23 +08:00
onClick={() => setShowNotifications(!showNotifications)}
2026-01-05 09:14:41 +08:00
className="relative p-2 rounded-full text-gray-600 bg-gray-100 hover:bg-gray-100 hover:text-gray-800 transition-colors"
2025-12-29 10:41:23 +08:00
aria-label="通知"
>
2026-01-05 09:14:41 +08:00
<BellIcon />
2025-12-29 10:41:23 +08:00
{unreadCount > 0 && (
<span className="absolute top-1 right-1 h-4 w-4 text-xs flex items-center justify-center rounded-full bg-red-500 text-white">
{unreadCount}
</span>
2025-12-29 10:41:23 +08:00
)}
2026-01-05 09:14:41 +08:00
</Button>
2025-12-29 10:41:23 +08:00
{/* 通知下拉面板 */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-md shadow-lg py-1 z-20 border border-gray-200">
<div className="px-4 py-2 border-b border-gray-100 flex justify-between items-center">
<h3 className="font-medium text-gray-800"></h3>
2026-01-05 09:14:41 +08:00
<Button className="text-xs text-blue-600 hover:text-blue-800">
2025-12-29 10:41:23 +08:00
2026-01-05 09:14:41 +08:00
</Button>
2025-12-29 10:41:23 +08:00
</div>
<div className="max-h-72 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map(notification => (
2025-12-29 10:41:23 +08:00
<div
key={notification.id}
className={`px-4 py-3 border-b border-gray-100 hover:bg-gray-50 ${
notification.read ? "bg-white" : "bg-blue-50"
2025-12-29 10:41:23 +08:00
}`}
>
<div className="flex justify-between items-start">
<h4 className="text-sm font-medium text-gray-800">
{notification.title}
</h4>
<span className="text-xs text-gray-500">
{notification.time}
</span>
2025-12-29 10:41:23 +08:00
</div>
<p className="text-xs text-gray-600 mt-1">
{notification.content}
</p>
2025-12-29 10:41:23 +08:00
</div>
))
) : (
<div className="py-8 px-4 text-center">
2026-01-05 09:14:41 +08:00
<InboxIcon size={18} />
2025-12-29 10:41:23 +08:00
<p className="mt-2 text-sm text-gray-500"></p>
</div>
)}
</div>
<div className="border-t border-gray-100 p-2 text-center">
<Link
href="/notifications"
className="text-xs text-blue-600 hover:text-blue-800"
>
2025-12-29 10:41:23 +08:00
</Link>
</div>
</div>
)}
</div>
{/* 分隔线 */}
<div className="hidden md:block h-8 w-px bg-gray-200"></div>
{/* 用户下拉菜单 */}
<div className="relative" ref={dropdownRef}>
2026-01-05 09:14:41 +08:00
<Button
2025-12-29 10:41:23 +08:00
onClick={() => setShowDropdown(!showDropdown)}
2026-01-05 09:14:41 +08:00
className="flex items-center space-x-2 rounded-lg text-gray-800 bg-gray-100 hover:bg-gray-100 p-2 transition-colors"
2025-12-29 10:41:23 +08:00
aria-label="用户菜单"
>
<div className="h-8 w-8 rounded-full bg-blue-100 text-blue-800 flex items-center justify-center overflow-hidden border-2 border-white shadow-sm">
2026-03-31 11:59:00 +08:00
{props.admin ? (
props.admin.avatar ? (
<Image
2026-03-31 11:59:00 +08:00
src={props.admin.avatar}
alt="用户头像"
width={32}
height={32}
className="h-full w-full object-cover"
onError={e => {
const target = e.target as HTMLImageElement
target.style.display = "none"
const parent = target.parentElement
2026-03-31 11:59:00 +08:00
if (parent && props.admin?.name) {
parent.textContent = props.admin.name
.charAt(0)
.toUpperCase()
}
}}
/>
) : (
// 如果没有头像,直接显示用户名首字母
<span className="text-sm font-semibold">
2026-03-31 11:59:00 +08:00
{props.admin.name?.charAt(0).toUpperCase()}
</span>
)
) : (
// 加载状态或用户信息为空时
<UserIcon size={18} />
)}
2025-12-29 10:41:23 +08:00
</div>
<div className="hidden md:block text-left">
2026-03-31 11:59:00 +08:00
{props.admin && (
<div>
<p className="text-sm font-medium text-gray-800">
2026-03-31 11:59:00 +08:00
{props.admin.name}
</p>
<p className="text-xs text-gray-500">
{props.admin.username}
</p>
</div>
)}
2025-12-29 10:41:23 +08:00
</div>
2026-01-05 09:14:41 +08:00
<ChevronDownIcon />
</Button>
2025-12-29 10:41:23 +08:00
{/* 用户下拉内容 */}
{showDropdown && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg py-2 z-20 border border-gray-200">
2026-03-31 11:59:00 +08:00
{props.admin && (
<div className="px-4 py-2 border-b border-gray-100 md:hidden">
<p className="font-medium text-gray-800">
2026-03-31 11:59:00 +08:00
{props.admin.name}
</p>
2026-03-31 11:59:00 +08:00
<p className="text-xs text-gray-500">{props.admin.name}</p>
</div>
)}
2025-12-29 10:41:23 +08:00
<div className="py-1">
<Link
href="/profile"
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
2026-01-05 09:14:41 +08:00
<UserIcon size={18} />
<span className="pl-3"></span>
2025-12-29 10:41:23 +08:00
</Link>
<Link
href="/settings/account"
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
2026-01-05 09:14:41 +08:00
<SettingsIcon size={18} />
<span className="pl-3"></span>
2025-12-29 10:41:23 +08:00
</Link>
<Link
href="/system/help"
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
2026-01-05 09:14:41 +08:00
<BadgeQuestionMarkIcon size={18} />
<span className="pl-3"></span>
2025-12-29 10:41:23 +08:00
</Link>
</div>
<div className="border-t border-gray-100 pt-1">
<Button
variant="ghost"
onClick={doLogout}
className="flex items-center justify-start px-4 py-2 w-full text-sm text-red-600 hover:text-red-700 hover:bg-gray-100 font-normal"
>
<LogOutIcon size={18} className="ml-2" />
退
</Button>
2025-12-29 10:41:23 +08:00
</div>
</div>
)}
</div>
</div>
</header>
)
}