/** * 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(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 ( <>
{/* Logo + 首页收缩模式下的分类与搜索卡片 */}
{headerLogoUrl && !logoError ? ( 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) } }} /> ) : (
{siteName.charAt(0)}
)} {siteName} {/* 首页收缩模式:分类 + 搜索卡片(含筛选与上传) */} {compactActive && compactMode && (
{/* 分类 三 */}
setShowCategoryMenu(true)} onMouseLeave={() => setShowCategoryMenu(false)} > {showCategoryMenu && compactCategoryItems.length > 0 && (
setShowCategoryMenu(true)} onMouseLeave={() => setShowCategoryMenu(false)} style={{ transformOrigin: 'top left' }} > {compactCategoryItems.map((item: HeaderCompactModeProps['categoryItems'][number]) => ( ))}
)}
{/* 搜索卡片:类别文字 + 输入框 + 筛选图标 + 上传按钮(集成在尾部) */}
{/* 左侧内容区域 */}
{/* 当前分类标签 */} {compactSelectedCategory && (
{compactSelectedCategory.label}
)} {/* 搜索输入 */}
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' )} /> {/* 右侧筛选图标按钮(内嵌在输入框尾部区域,只显示图标) */}
{/* 上传素材按钮(直接集成在卡片尾部,作为卡片的一部分) */}
)}
{/* Navigation */}
) }