flnxi-Wu/components/Header.tsx
WuYongJie e4dcb6260a
Some checks failed
CI Check / check (push) Has been cancelled
feat: Admin页面重构 & UI三层架构系统
- 创建统一UI三层架构系统(基础层、颜色层、组合层)
- 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等)
- 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等)
- 提取工具函数(validators、formatters、api等)
- 创建统一弹窗组件(Modal)和useModal Hook
- 添加错误边界和统一状态组件
- 完善类型定义和可访问性支持
- 更新文档(优化记录、优化计划、更新日志)
2025-11-22 15:17:10 +08:00

461 lines
20 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 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'
interface HeaderProps {
session: Session | null
}
const THEME_STORAGE_KEY = 'admin-theme-preference'
export default function Header({ session }: HeaderProps) {
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)
}, [])
// 获取用户名首字母
const getInitials = (name: string) => {
return name?.charAt(0).toUpperCase() || 'U'
}
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={`sticky z-50 shadow-sm ${showNetworkBanner ? 'top-[72px]' : 'top-0'}`}
style={{ backgroundColor: headerBgColor }}
>
<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="w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<span className="text-lg font-bold text-white">{siteName.charAt(0)}</span>
</div>
)}
<span className="text-2xl font-semibold text-white">
{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="relative flex items-center px-2 py-1 rounded-full border border-white/25 bg-white/15 dark:bg-white/5 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"
>
<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="flex items-center p-1.5 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-900 transition-all"
>
{/* 圆形头像 */}
<div className="w-8 h-8 rounded-full bg-white dark:bg-gray-300 border-2 border-gray-700 dark:border-gray-600 flex items-center justify-center text-gray-900 dark:text-gray-800 font-semibold text-base shadow-lg hover:border-gray-500 dark:hover:border-gray-400 transition-all">
{getInitials(session.user?.name || '')}
</div>
</button>
{/* 下拉菜单 */}
{showDropdown && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-2 z-50 border border-gray-200 dark:border-gray-700">
<div className="px-4 py-2 border-b border-gray-100 dark:border-gray-700">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{session.user?.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{session.user?.email}
</p>
</div>
{/* 系统设置(管理员) */}
{isAdmin && (
<Link
href="/admin"
onClick={() => setShowDropdown(false)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
>
<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="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-all flex items-center space-x-2"
>
<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>
</>
)
}