752 lines
32 KiB
TypeScript
752 lines
32 KiB
TypeScript
/**
|
||
* Header 组件
|
||
*
|
||
* 全局顶部导航栏组件
|
||
* 包含Logo、主题切换、用户下拉菜单等功能
|
||
* 使用三层架构样式系统,支持深浅色主题
|
||
*
|
||
* @created 2025-01-XX
|
||
* @component
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import Image from 'next/image'
|
||
import Link from 'next/link'
|
||
import { signOut } from 'next-auth/react'
|
||
import { Session } from 'next-auth'
|
||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||
import { initializeNetwork } from '@/lib/network'
|
||
import { useThemeStyles, cn } from '@/lib/ui-styles'
|
||
import Avatar from '@/components/ui/Avatar'
|
||
|
||
interface HeaderCompactModeProps {
|
||
isScrolled: boolean
|
||
searchKeyword: string
|
||
onSearchChange: (value: string) => void
|
||
onOpenFilter: () => void
|
||
onOpenUpload: () => void
|
||
categoryItems: Array<{ key: string; label: string }>
|
||
selectedCategoryKey: string
|
||
onSelectCategory: (key: string) => void
|
||
themeColor: string
|
||
}
|
||
|
||
interface HeaderProps {
|
||
session: Session | null
|
||
compactMode?: HeaderCompactModeProps
|
||
}
|
||
|
||
const THEME_STORAGE_KEY = 'admin-theme-preference'
|
||
|
||
// 默认导航栏 LOGO:优先使用构建时环境变量,否则使用内置静态资源
|
||
const DEFAULT_HEADER_LOGO =
|
||
process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || ''
|
||
|
||
export default function Header({ session, compactMode }: HeaderProps) {
|
||
const styles = useThemeStyles()
|
||
const isAdmin = session ? ((session.user as any)?.role === 'ADMIN' || (session.user as any)?.isSuperAdmin) : false
|
||
const isSuperAdmin = session ? Boolean((session.user as any)?.isSuperAdmin) : false
|
||
const [showDropdown, setShowDropdown] = useState(false)
|
||
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
|
||
const [networkChecked, setNetworkChecked] = useState(false)
|
||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||
const [showCategoryMenu, setShowCategoryMenu] = useState(false)
|
||
|
||
// 系统设置
|
||
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
|
||
const [headerLogoUrl, setHeaderLogoUrl] = useState(DEFAULT_HEADER_LOGO)
|
||
const [siteName, setSiteName] = useState('FLNXI素材库')
|
||
|
||
// Logo 加载状态
|
||
const [logoError, setLogoError] = useState(false)
|
||
const [logoRetryCount, setLogoRetryCount] = useState(0)
|
||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||
const [themeReady, setThemeReady] = useState(false)
|
||
const [isHeaderDarkBg, setIsHeaderDarkBg] = useState(false)
|
||
const fallbackHeaderBg = theme === 'dark' ? '#0f172a' : '#ffffff'
|
||
|
||
const parseColorToRGB = useCallback((color: string): { r: number; g: number; b: number } | null => {
|
||
if (!color) return null
|
||
const trimmed = color.trim()
|
||
const hexMatch = trimmed.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
|
||
if (hexMatch) {
|
||
let hex = hexMatch[1]
|
||
if (hex.length === 3) {
|
||
hex = hex.split('').map((ch) => ch + ch).join('')
|
||
}
|
||
const num = parseInt(hex, 16)
|
||
return {
|
||
r: (num >> 16) & 255,
|
||
g: (num >> 8) & 255,
|
||
b: num & 255,
|
||
}
|
||
}
|
||
const rgbMatch = trimmed.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
|
||
if (rgbMatch) {
|
||
return {
|
||
r: parseInt(rgbMatch[1], 10),
|
||
g: parseInt(rgbMatch[2], 10),
|
||
b: parseInt(rgbMatch[3], 10),
|
||
}
|
||
}
|
||
return null
|
||
}, [])
|
||
|
||
const isColorDark = useCallback((color: string) => {
|
||
const rgb = parseColorToRGB(color)
|
||
if (!rgb) {
|
||
return theme === 'dark'
|
||
}
|
||
const luminance = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255
|
||
return luminance < 0.55
|
||
}, [parseColorToRGB, theme])
|
||
|
||
const applyTheme = useCallback((nextTheme: 'light' | 'dark') => {
|
||
if (typeof document === 'undefined') return
|
||
const root = document.documentElement
|
||
if (nextTheme === 'dark') {
|
||
root.classList.add('dark')
|
||
} else {
|
||
root.classList.remove('dark')
|
||
}
|
||
root.dataset.theme = nextTheme
|
||
try {
|
||
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
|
||
} catch {
|
||
// ignore storage errors
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return
|
||
let initial: 'light' | 'dark' = 'light'
|
||
try {
|
||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||
if (stored === 'light' || stored === 'dark') {
|
||
initial = stored
|
||
} else {
|
||
initial = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||
}
|
||
} catch {
|
||
initial = 'light'
|
||
}
|
||
setTheme(initial)
|
||
applyTheme(initial)
|
||
setThemeReady(true)
|
||
}, [applyTheme])
|
||
|
||
useEffect(() => {
|
||
if (!headerLogoUrl) return
|
||
|
||
// 对于以 / 开头的相对路径(如 /logo/logo2.png),直接认为是站内静态资源,不做 URL 解析
|
||
if (headerLogoUrl.startsWith('/')) {
|
||
setLogoError(false)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(headerLogoUrl)
|
||
const host = parsed.hostname.toLowerCase()
|
||
const hostIsLocal = host === 'localhost' || host === '127.0.0.1'
|
||
if (hostIsLocal && typeof window !== 'undefined') {
|
||
const currentHost = window.location.hostname.toLowerCase()
|
||
if (currentHost !== host) {
|
||
console.warn(
|
||
`[Header] Logo 地址 ${headerLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位图`
|
||
)
|
||
setLogoError(true)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('[Header] Logo 地址解析失败,自动降级为占位图:', headerLogoUrl, error)
|
||
setLogoError(true)
|
||
}
|
||
}, [headerLogoUrl])
|
||
|
||
// 加载系统设置
|
||
useEffect(() => {
|
||
const loadSettings = async () => {
|
||
try {
|
||
const response = await fetch('/api/system-settings')
|
||
if (response.ok) {
|
||
const settings = await response.json()
|
||
setHeaderBgColor(settings.headerBgColor || '#1f2937')
|
||
setHeaderLogoUrl(settings.headerLogoUrl || DEFAULT_HEADER_LOGO)
|
||
setSiteName(settings.siteName || 'FLNXI素材库')
|
||
}
|
||
} catch (error) {
|
||
console.error('加载系统设置失败:', error)
|
||
}
|
||
}
|
||
loadSettings()
|
||
}, [])
|
||
|
||
// 更新浏览器标题
|
||
useEffect(() => {
|
||
if (siteName) {
|
||
document.title = siteName
|
||
}
|
||
}, [siteName])
|
||
|
||
useEffect(() => {
|
||
const detectionColor = headerBgColor || (theme === 'dark' ? '#0f172a' : '#ffffff')
|
||
setIsHeaderDarkBg(isColorDark(detectionColor))
|
||
}, [headerBgColor, theme, isColorDark])
|
||
|
||
// 检测网络状态(仅用于右上角「内网 / 公网」状态展示,不再弹出横幅)
|
||
useEffect(() => {
|
||
const detectNetwork = async () => {
|
||
try {
|
||
const networkStatus = await initializeNetwork()
|
||
setIsInternalNetwork(networkStatus.isInternal)
|
||
setNetworkChecked(true)
|
||
} catch (error) {
|
||
console.error('网络检测失败:', error)
|
||
setIsInternalNetwork(false)
|
||
setNetworkChecked(true)
|
||
}
|
||
}
|
||
detectNetwork()
|
||
}, [])
|
||
|
||
// 点击外部关闭下拉菜单
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||
setShowDropdown(false)
|
||
}
|
||
}
|
||
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [])
|
||
|
||
const headerPrimaryTextClass = isHeaderDarkBg ? 'text-white' : 'text-slate-900'
|
||
const compactActive = Boolean(compactMode?.isScrolled)
|
||
const compactCategoryItems = compactMode?.categoryItems || []
|
||
const compactSelectedCategory =
|
||
compactCategoryItems.find(
|
||
(item: HeaderCompactModeProps['categoryItems'][number]) => item.key === compactMode?.selectedCategoryKey
|
||
) || compactCategoryItems[0] || null
|
||
|
||
return (
|
||
<>
|
||
<header
|
||
className={cn(
|
||
'sticky z-50',
|
||
'top-0',
|
||
'border-b border-slate-200 dark:border-slate-700',
|
||
'transition-colors'
|
||
)}
|
||
style={{
|
||
backgroundColor: headerBgColor || fallbackHeaderBg
|
||
}}
|
||
>
|
||
<div className="container mx-auto px-4 py-3 max-w-7xl">
|
||
<div className="flex items-center justify-between gap-6 h-12">
|
||
{/* Logo + 首页收缩模式下的分类与搜索卡片 */}
|
||
<div className="flex items-center gap-4 flex-1 min-w-0 h-full">
|
||
<Link href="/" className="flex items-center space-x-3 flex-shrink-0 h-full">
|
||
{headerLogoUrl && !logoError ? (
|
||
<Image
|
||
key={logoRetryCount}
|
||
src={headerLogoUrl + (logoRetryCount > 0 ? `?retry=${logoRetryCount}` : '')}
|
||
alt={siteName}
|
||
width={40}
|
||
height={40}
|
||
unoptimized
|
||
className="rounded-lg"
|
||
onError={(e) => {
|
||
try {
|
||
const img = e.target as HTMLImageElement
|
||
if (!img) return
|
||
|
||
// 检查是否是 localhost 地址但当前访问域名不是 localhost
|
||
const currentHost = typeof window !== 'undefined' ? window.location.hostname : ''
|
||
const checkLogoUrl = (url: string, host: string) => {
|
||
try {
|
||
const logoHost = new URL(url).hostname
|
||
return (logoHost === 'localhost' || logoHost === '127.0.0.1') && host !== 'localhost' && host !== '127.0.0.1'
|
||
} catch {
|
||
return true // 如果 URL 无效,视为错误
|
||
}
|
||
}
|
||
|
||
if (checkLogoUrl(headerLogoUrl, currentHost)) {
|
||
console.warn(
|
||
`[Header] Logo 地址 ${headerLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位图`
|
||
)
|
||
setLogoError(true)
|
||
if (img) img.style.display = 'none'
|
||
return
|
||
}
|
||
|
||
setLogoRetryCount((prevCount) => {
|
||
const newCount = prevCount + 1
|
||
if (newCount <= 3) {
|
||
// 重试加载(最多3次)
|
||
console.warn(`Logo 加载失败,正在重试 (${newCount}/3):`, headerLogoUrl)
|
||
setTimeout(() => {
|
||
if (img && img.parentNode) {
|
||
img.src = headerLogoUrl + `?retry=${newCount}`
|
||
}
|
||
}, 1000 * newCount) // 递增延迟:1s, 2s, 3s
|
||
return newCount
|
||
} else {
|
||
// 重试失败,记录错误并显示占位符
|
||
console.error('Logo 加载失败,已重试3次:', headerLogoUrl)
|
||
setLogoError(true)
|
||
if (img) img.style.display = 'none'
|
||
return prevCount
|
||
}
|
||
})
|
||
} catch (error) {
|
||
// 捕获所有错误,防止崩溃
|
||
console.error('[Header] Logo 错误处理异常:', error)
|
||
setLogoError(true)
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<div
|
||
className={cn(
|
||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||
isHeaderDarkBg ? 'bg-white/15' : 'bg-slate-900/10'
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
'text-lg font-bold',
|
||
headerPrimaryTextClass
|
||
)}
|
||
>
|
||
{siteName.charAt(0)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<span
|
||
className={cn(
|
||
'text-2xl font-semibold transition-colors',
|
||
headerPrimaryTextClass
|
||
)}
|
||
>
|
||
{siteName}
|
||
</span>
|
||
</Link>
|
||
|
||
{/* 首页收缩模式:分类 + 搜索卡片(含筛选与上传) */}
|
||
{compactActive && compactMode && (
|
||
<div className="flex items-center gap-3 flex-1 min-w-0 max-w-xl h-full">
|
||
{/* 分类 三 */}
|
||
<div
|
||
className="relative flex-shrink-0 select-none h-full flex items-center"
|
||
onMouseEnter={() => setShowCategoryMenu(true)}
|
||
onMouseLeave={() => setShowCategoryMenu(false)}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-medium transition-colors h-8',
|
||
isHeaderDarkBg
|
||
? 'text-white/90'
|
||
: 'text-slate-800'
|
||
)}
|
||
>
|
||
<span>分类</span>
|
||
<span className="inline-flex items-center justify-center w-4 h-4">
|
||
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeWidth={1.6} d="M4 6h12M4 10h12M4 14h12" />
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
{showCategoryMenu && compactCategoryItems.length > 0 && (
|
||
<div
|
||
className={cn(
|
||
'absolute top-full left-0 mt-0 w-40 md:w-44 pt-1.5 pb-1.5 z-50',
|
||
'rounded-lg bg-white dark:bg-slate-900',
|
||
'border border-slate-200 dark:border-slate-700',
|
||
'shadow-sm',
|
||
'origin-top-left'
|
||
)}
|
||
onMouseEnter={() => setShowCategoryMenu(true)}
|
||
onMouseLeave={() => setShowCategoryMenu(false)}
|
||
style={{
|
||
transformOrigin: 'top left'
|
||
}}
|
||
>
|
||
{compactCategoryItems.map((item: HeaderCompactModeProps['categoryItems'][number]) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onClick={() => {
|
||
compactMode.onSelectCategory(item.key)
|
||
setShowCategoryMenu(false)
|
||
}}
|
||
className={cn(
|
||
'w-full px-4 py-2 text-left text-sm transition-colors',
|
||
'text-slate-800 dark:text-slate-100',
|
||
'hover:bg-slate-100 dark:hover:bg-slate-800'
|
||
)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 搜索卡片:类别文字 + 输入框 + 筛选图标 + 上传按钮(集成在尾部) */}
|
||
<div
|
||
className={cn(
|
||
'flex items-center gap-0 flex-1 min-w-0 h-9',
|
||
'rounded-lg overflow-hidden',
|
||
'bg-white dark:bg-slate-900',
|
||
'border border-slate-200 dark:border-slate-700',
|
||
'shadow-sm'
|
||
)}
|
||
>
|
||
{/* 左侧内容区域 */}
|
||
<div className="flex items-center gap-1.5 flex-1 min-w-0 px-2.5 h-full">
|
||
{/* 当前分类标签 */}
|
||
{compactSelectedCategory && (
|
||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-xs font-medium text-slate-600 dark:text-slate-200 whitespace-nowrap h-5">
|
||
<span>{compactSelectedCategory.label}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 搜索输入 */}
|
||
<div className="relative flex-1 min-w-0 h-full flex items-center">
|
||
<input
|
||
type="text"
|
||
value={compactMode.searchKeyword}
|
||
onChange={(e) => compactMode.onSearchChange(e.target.value)}
|
||
placeholder="搜索SPU、店铺、场景类别、作者..."
|
||
className={cn(
|
||
'w-full bg-transparent border-none outline-none text-sm text-slate-900 dark:text-slate-50 placeholder:text-slate-400 dark:placeholder:text-slate-500',
|
||
'pl-5 pr-10 h-full'
|
||
)}
|
||
/>
|
||
<svg
|
||
className="absolute left-1 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||
/>
|
||
</svg>
|
||
|
||
{/* 右侧筛选图标按钮(内嵌在输入框尾部区域,只显示图标) */}
|
||
<button
|
||
type="button"
|
||
onClick={compactMode.onOpenFilter}
|
||
className="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-6 h-6 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||
title="筛选"
|
||
aria-label="筛选"
|
||
>
|
||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M3 4h18l-7 8v6l-4 2v-8L3 4z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 上传素材按钮(直接集成在卡片尾部,作为卡片的一部分) */}
|
||
<button
|
||
type="button"
|
||
onClick={compactMode.onOpenUpload}
|
||
className={cn(
|
||
'flex items-center justify-center px-3 h-9 rounded-r-lg text-xs font-medium whitespace-nowrap',
|
||
'border-l border-slate-300 dark:border-slate-600',
|
||
'bg-slate-800 dark:bg-slate-100',
|
||
'text-white dark:text-slate-900',
|
||
'hover:bg-slate-700 dark:hover:bg-white',
|
||
'transition-colors'
|
||
)}
|
||
>
|
||
上传素材
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Navigation */}
|
||
<nav className="flex items-center space-x-4">
|
||
{/* Network Status Indicator */}
|
||
{networkChecked && (
|
||
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||
isInternalNetwork
|
||
? 'bg-green-500/10 text-green-400 border border-green-500/20'
|
||
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
||
}`}>
|
||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||
<circle cx="10" cy="10" r="4"/>
|
||
</svg>
|
||
<span>{isInternalNetwork ? '内网' : '公网'}</span>
|
||
</div>
|
||
)}
|
||
{/* Theme toggle */}
|
||
<div className="flex items-center">
|
||
{themeReady ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const next = theme === 'dark' ? 'light' : 'dark'
|
||
setTheme(next)
|
||
applyTheme(next)
|
||
}}
|
||
aria-label={`切换为${theme === 'dark' ? '浅色' : '深色'}模式`}
|
||
aria-pressed={theme === 'dark'}
|
||
className={cn(
|
||
"relative flex items-center px-2 py-1 rounded-full border focus:outline-none focus-visible:outline-none focus-visible:ring-0 transition-colors w-16 h-8 overflow-hidden",
|
||
isHeaderDarkBg
|
||
? 'border-white/25 bg-white/15'
|
||
: 'border-slate-300 bg-white'
|
||
)}
|
||
>
|
||
<div className="absolute inset-0 flex items-center justify-between px-2">
|
||
<svg
|
||
className={`w-4 h-4 transition-colors ${theme === 'light' ? 'text-amber-300' : 'text-white/35'}`}
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||
</svg>
|
||
<svg
|
||
className={`w-4 h-4 transition-colors ${theme === 'dark' ? 'text-indigo-200' : 'text-white/35'}`}
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
||
</svg>
|
||
</div>
|
||
<span
|
||
className={`absolute top-1 left-1 w-6 h-6 rounded-full bg-white shadow-sm transition-transform duration-300 ${
|
||
theme === 'dark' ? 'translate-x-7' : 'translate-x-0'
|
||
}`}
|
||
>
|
||
<span className="sr-only">{theme === 'dark' ? '深色模式' : '浅色模式'}</span>
|
||
<span className="relative block w-full h-full text-slate-700">
|
||
<svg
|
||
className={`absolute inset-0 m-auto w-3.5 h-3.5 transition-opacity ${theme === 'light' ? 'opacity-100 text-amber-500' : 'opacity-0'}`}
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||
</svg>
|
||
<svg
|
||
className={`absolute inset-0 m-auto w-3.5 h-3.5 transition-opacity ${theme === 'dark' ? 'opacity-100 text-indigo-400' : 'opacity-0'}`}
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
||
</svg>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
) : (
|
||
<div className="w-16 h-8 rounded-full border border-white/20 bg-white/10 animate-pulse" />
|
||
)}
|
||
</div>
|
||
|
||
{/* User Dropdown */}
|
||
{session && (
|
||
<div className="relative" ref={dropdownRef}>
|
||
<button
|
||
onClick={() => setShowDropdown(!showDropdown)}
|
||
aria-label="打开用户菜单"
|
||
className={cn(
|
||
'flex items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40',
|
||
isHeaderDarkBg ? 'hover:bg-white/15 p-1' : 'hover:bg-slate-900/5 p-1'
|
||
)}
|
||
>
|
||
{/* 使用 Avatar 组件 */}
|
||
<Avatar
|
||
src={(session.user as any)?.avatar || undefined}
|
||
name={session.user?.name || ''}
|
||
size="sm"
|
||
onClick={() => setShowDropdown(!showDropdown)}
|
||
/>
|
||
</button>
|
||
|
||
{/* 下拉菜单 - 使用三层架构样式 */}
|
||
{showDropdown && (
|
||
<div className={cn(
|
||
'absolute right-0 mt-2 w-48 py-2 z-50 overflow-hidden',
|
||
styles.cardStyles.base
|
||
)}>
|
||
<div className={cn('px-4 py-2 border-b', 'border-slate-200/70 dark:border-slate-700/70')}>
|
||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||
{session.user?.name}
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
{session.user?.email}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 个人中心 */}
|
||
<Link
|
||
href="/profile"
|
||
onClick={() => setShowDropdown(false)}
|
||
className={cn(
|
||
'w-full text-left px-4 py-2 text-sm transition-colors flex items-center space-x-2',
|
||
'text-slate-700 dark:text-slate-200',
|
||
'hover:bg-slate-100 dark:hover:bg-slate-700'
|
||
)}
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||
/>
|
||
</svg>
|
||
<span>个人中心</span>
|
||
</Link>
|
||
|
||
{isAdmin && (
|
||
<>
|
||
<Link
|
||
href="/admin"
|
||
onClick={() => setShowDropdown(false)}
|
||
className={cn(
|
||
'w-full text-left px-4 py-2 text-sm transition-colors flex items-center space-x-2',
|
||
'text-slate-700 dark:text-slate-200',
|
||
'hover:bg-slate-100 dark:hover:bg-slate-700'
|
||
)}
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
|
||
/>
|
||
</svg>
|
||
<span>企业管理</span>
|
||
</Link>
|
||
|
||
{isAdmin && (
|
||
<Link
|
||
href="/admin/settings"
|
||
onClick={() => setShowDropdown(false)}
|
||
className={cn(
|
||
'w-full text-left px-4 py-2 text-sm transition-colors flex items-center space-x-2',
|
||
'text-slate-700 dark:text-slate-200',
|
||
'hover:bg-slate-100 dark:hover:bg-slate-700'
|
||
)}
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||
/>
|
||
</svg>
|
||
<span>系统设置</span>
|
||
</Link>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{isSuperAdmin && (
|
||
<Link
|
||
href="/admin/monitor"
|
||
onClick={() => setShowDropdown(false)}
|
||
className={cn(
|
||
'w-full text-left px-4 py-2 text-sm transition-colors flex items-center space-x-2',
|
||
'text-slate-700 dark:text-slate-200',
|
||
'hover:bg-slate-100 dark:hover:bg-slate-700'
|
||
)}
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M3 10h4l3 8 4-16 3 8h4"
|
||
/>
|
||
</svg>
|
||
<span>监控仪表板</span>
|
||
</Link>
|
||
)}
|
||
|
||
{/* 退出登录 */}
|
||
<button
|
||
onClick={async () => {
|
||
setShowDropdown(false)
|
||
// 先退出登录,然后手动跳转到登录页
|
||
await signOut({ redirect: false })
|
||
window.location.href = '/login'
|
||
}}
|
||
className={cn(
|
||
'w-full text-left px-4 py-2 text-sm transition-colors flex items-center space-x-2',
|
||
'text-slate-700 dark:text-slate-200',
|
||
'hover:bg-slate-100 dark:hover:bg-slate-700'
|
||
)}
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||
/>
|
||
</svg>
|
||
<span>退出登录</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
</>
|
||
)
|
||
}
|
||
|