flnxi-Wu/components/Header.tsx
WuYongJie 8705b9846a
Some checks failed
CI Check / check (push) Has been cancelled
feat: 统一弹窗样式系统与分类管理优化
- 创建统一的Modal组件,采用Apple风格设计
- 实现毛玻璃效果、圆角、动画和细边框描边
- 修复弹窗打开时背景滚动问题
- 修复分类管理中重复卡片和数组字符串显示问题
- 优化选项解析逻辑,添加去重和过滤机制
2025-11-25 09:37:00 +08:00

504 lines
21 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.

/**
* 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 HeaderProps {
session: Session | null
}
const THEME_STORAGE_KEY = 'admin-theme-preference'
export default function Header({ session }: HeaderProps) {
const styles = useThemeStyles()
const isAdmin = session ? ((session.user as any)?.role === 'ADMIN' || (session.user as any)?.isSuperAdmin) : false
const [showDropdown, setShowDropdown] = useState(false)
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
const [networkChecked, setNetworkChecked] = useState(false)
const [showNetworkBanner, setShowNetworkBanner] = useState(false)
const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// 系统设置
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [headerLogoUrl, setHeaderLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')
const [siteName, setSiteName] = useState('由壹点3D素材')
// Logo 加载状态
const [logoError, setLogoError] = useState(false)
const [logoRetryCount, setLogoRetryCount] = useState(0)
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const [themeReady, setThemeReady] = useState(false)
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
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 || process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')
setSiteName(settings.siteName || '由壹点3D素材')
}
} catch (error) {
console.error('加载系统设置失败:', error)
}
}
loadSettings()
}, [])
// 更新浏览器标题
useEffect(() => {
if (siteName) {
document.title = siteName
}
}, [siteName])
// 检测网络状态
useEffect(() => {
const detectNetwork = async () => {
try {
const networkStatus = await initializeNetwork()
setIsInternalNetwork(networkStatus.isInternal)
setNetworkChecked(true)
// 检查当前是否从外网访问
const currentHost = window.location.hostname
const intranet = (process.env.NEXT_PUBLIC_INTRANET_URL || '').replace(/^https?:\/\//, '').split('/')[0]
const isExternal = currentHost !== intranet && currentHost !== 'localhost'
setIsAccessingFromExternal(isExternal)
// 如果内网可达但当前从外网访问,显示切换提示(除非用户已关闭)
const bannerDismissed = sessionStorage.getItem('networkBannerDismissed')
if (networkStatus.isInternal && isExternal && !bannerDismissed) {
setShowNetworkBanner(true)
}
} catch (error) {
console.error('网络检测失败:', error)
setIsInternalNetwork(false)
setNetworkChecked(true)
}
}
detectNetwork()
}, [])
// 切换到内网地址
const switchToInternal = () => {
const currentPath = window.location.pathname + window.location.search
const base = process.env.NEXT_PUBLIC_INTRANET_URL
if (base) {
window.location.href = `${base}${currentPath}`
}
}
// 关闭横幅
const dismissBanner = () => {
setShowNetworkBanner(false)
sessionStorage.setItem('networkBannerDismissed', 'true')
}
// 点击外部关闭下拉菜单
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)
}, [])
return (
<>
{/* 网络切换提示横幅 */}
{showNetworkBanner && (
<div className="fixed top-0 left-0 right-0 z-[60] bg-gradient-to-r from-green-500 to-emerald-500 text-white shadow-lg">
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<svg className="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<p className="font-semibold">检测到您在内网环境</p>
<p className="text-sm text-white/90">切换到内网地址可获得更快的上传下载速度,并避免登录问题</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={switchToInternal}
className="px-4 py-2 bg-white text-green-600 rounded-lg font-medium hover:bg-green-50 transition-all shadow-sm"
aria-pressed={theme === 'dark'}
>
立即切换
</button>
<button
onClick={dismissBanner}
className="px-3 py-2 text-white/80 hover:text-white transition-all"
aria-label="关闭"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
)}
<header
className={cn(
'sticky z-50 shadow-sm',
showNetworkBanner ? 'top-[72px]' : 'top-0',
'bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-b border-white/20 dark:border-slate-700/30',
'transition-all duration-300'
)}
>
<div className="container mx-auto px-4 py-4 max-w-7xl">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-3">
{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',
theme === 'dark' ? 'bg-white/10' : 'bg-slate-900/10'
)}>
<span className={cn(
'text-lg font-bold',
theme === 'dark' ? 'text-white' : 'text-slate-900'
)}>{siteName.charAt(0)}</span>
</div>
)}
<span className={cn(
'text-2xl font-semibold',
theme === 'dark' ? 'text-white' : 'text-slate-900'
)}>
{siteName}
</span>
</Link>
{/* 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-all ${
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 shadow-[0_5px_14px_rgba(15,23,42,0.25)] backdrop-blur focus:outline-none focus-visible:outline-none focus-visible:ring-0 transition-all w-16 h-8 overflow-hidden",
theme === 'dark'
? 'border-white/25 bg-white/15'
: 'border-slate-300 bg-white/80'
)}
>
<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-lg 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)}
className={cn(
'flex items-center p-1.5 rounded-lg transition-all',
'hover:bg-white/10'
)}
>
{/* 使用 Avatar 组件 */}
<Avatar
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>
{/* 系统设置(管理员) */}
{isAdmin && (
<Link
href="/admin"
onClick={() => setShowDropdown(false)}
className={cn(
'w-full text-left px-4 py-2 text-sm transition-all 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</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-all 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>
</>
)
}