Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志)
461 lines
20 KiB
TypeScript
461 lines
20 KiB
TypeScript
'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>
|
||
</>
|
||
)
|
||
}
|
||
|