815 lines
38 KiB
TypeScript
815 lines
38 KiB
TypeScript
'use client'
|
||
|
||
import Image from 'next/image'
|
||
import Link from 'next/link'
|
||
import { signOut } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { Session } from 'next-auth'
|
||
import { useState, useRef, useEffect, type MouseEvent as ReactMouseEvent } from 'react'
|
||
import { initializeNetwork, setupNetworkChangeDetection, addNetworkChangeListener } from '@/lib/network'
|
||
import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal'
|
||
import NetworkDiagnoseModal from '@/components/NetworkDiagnoseModal'
|
||
import MaintenanceBanner from '@/components/MaintenanceBanner'
|
||
import HeaderTaobaoSearch from '@/components/HeaderTaobaoSearch'
|
||
import { formatHumanSpeed, formatTransferProgressSummary } from '@/lib/human-readable-transfer'
|
||
|
||
interface HeaderProps {
|
||
session: Session | null
|
||
}
|
||
|
||
const DEFAULT_HEADER_LOGO_URL = '/api/favicon'
|
||
const MATERIAL_LOCAL_STORAGE_KEYS = [
|
||
'searchHistory',
|
||
'ranking_panel_state',
|
||
'shadowIndicatorPosition',
|
||
]
|
||
const MATERIAL_LOCAL_STORAGE_PREFIXES = ['update_log_viewed_version']
|
||
const MATERIAL_SESSION_STORAGE_KEYS = [
|
||
'home_scroll_position',
|
||
'home_materials_cache',
|
||
]
|
||
const MATERIAL_SESSION_STORAGE_PREFIXES = ['maintenance_dismissed_']
|
||
const MATERIAL_INDEXED_DB_NAMES = ['chunk-upload-db']
|
||
|
||
function removeMaterialStorageItems(
|
||
storage: Storage,
|
||
exactKeys: string[],
|
||
prefixKeys: string[] = [],
|
||
) {
|
||
const keysToRemove: string[] = []
|
||
|
||
for (let i = 0; i < storage.length; i++) {
|
||
const key = storage.key(i)
|
||
if (!key) continue
|
||
|
||
if (exactKeys.includes(key) || prefixKeys.some((prefix) => key.startsWith(prefix))) {
|
||
keysToRemove.push(key)
|
||
}
|
||
}
|
||
|
||
keysToRemove.forEach((key) => storage.removeItem(key))
|
||
return keysToRemove
|
||
}
|
||
|
||
function deleteIndexedDatabase(name: string) {
|
||
return new Promise<string>((resolve) => {
|
||
const request = indexedDB.deleteDatabase(name)
|
||
request.onsuccess = () => resolve(name)
|
||
request.onerror = () => resolve(name)
|
||
request.onblocked = () => resolve(name)
|
||
})
|
||
}
|
||
|
||
function clearMaterialVersionCookie() {
|
||
document.cookie = 'app-version=; Max-Age=0; path=/; SameSite=Lax'
|
||
}
|
||
|
||
export default function Header({ session }: HeaderProps) {
|
||
const router = useRouter()
|
||
const isAdmin = session ? (session.user as any)?.role === 'ADMIN' : false
|
||
const [showDropdown, setShowDropdown] = useState(false)
|
||
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
|
||
const [networkChecked, setNetworkChecked] = useState(false)
|
||
// 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转
|
||
const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)
|
||
const [showNetworkDiagnose, setShowNetworkDiagnose] = useState(false)
|
||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 性能监控悬停卡片状态
|
||
const [showPerfCard, setShowPerfCard] = useState(false)
|
||
const [perfLoading, setPerfLoading] = useState(false)
|
||
const [perfError, setPerfError] = useState<string | null>(null)
|
||
const [perfData, setPerfData] = useState<{
|
||
cpu?: { usage: number; loadAverage: number[] }
|
||
memory?: { used: number; total: number; percentage: number }
|
||
network?: { uploadSpeed: number; downloadSpeed: number }
|
||
uploadQueue?: { total: number; processing: any[]; waiting: any[] }
|
||
transcodeQueue?: { total: number; maxConcurrent: number; processing: any[]; waiting: any[] }
|
||
} | null>(null)
|
||
const perfHoverTimerRef = useRef<number | null>(null)
|
||
const perfHideTimerRef = useRef<number | null>(null)
|
||
const perfIntervalRef = useRef<number | null>(null)
|
||
|
||
// 使用 useUpdateLog hook 来管理更新日志弹窗
|
||
const { showModal: showUpdateLogModal, currentLog, showAllHistory, handleClose: handleUpdateLogClose } = useUpdateLog()
|
||
|
||
// 系统设置
|
||
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
|
||
const [headerLogoUrl, setHeaderLogoUrl] = useState(DEFAULT_HEADER_LOGO_URL)
|
||
const [siteName, setSiteName] = useState('由壹点3D素材')
|
||
|
||
// 加载系统设置
|
||
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_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 isExternal = currentHost !== '192.168.58.20' && currentHost !== 'localhost'
|
||
setIsAccessingFromExternal(isExternal)
|
||
|
||
// 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转
|
||
} catch (error) {
|
||
console.error('网络检测失败:', error)
|
||
setIsInternalNetwork(false)
|
||
setNetworkChecked(true)
|
||
}
|
||
}
|
||
detectNetwork()
|
||
|
||
// 设置网络变化监听
|
||
setupNetworkChangeDetection()
|
||
|
||
// 监听网络状态变化,自动更新UI
|
||
const unsubscribe = addNetworkChangeListener((status) => {
|
||
setIsInternalNetwork(status.isInternal)
|
||
})
|
||
|
||
return () => {
|
||
unsubscribe()
|
||
}
|
||
}, [])
|
||
|
||
// 内网切换功能已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转
|
||
|
||
// 点击外部关闭下拉菜单
|
||
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)
|
||
}, [])
|
||
|
||
// 性能监控:根据 showPerfCard 控制轮询
|
||
useEffect(() => {
|
||
// 清理定时器的工具函数
|
||
const clearIntervalSafe = () => {
|
||
if (perfIntervalRef.current !== null) {
|
||
window.clearInterval(perfIntervalRef.current)
|
||
perfIntervalRef.current = null
|
||
}
|
||
}
|
||
|
||
if (!showPerfCard) {
|
||
clearIntervalSafe()
|
||
return
|
||
}
|
||
|
||
const fetchPerf = async () => {
|
||
try {
|
||
setPerfLoading(true)
|
||
setPerfError(null)
|
||
const res = await fetch('/api/performance/quick')
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}`)
|
||
}
|
||
const data = await res.json()
|
||
setPerfData(data)
|
||
} catch (err) {
|
||
console.error('加载性能数据失败:', err)
|
||
setPerfError('加载失败')
|
||
} finally {
|
||
setPerfLoading(false)
|
||
}
|
||
}
|
||
|
||
// 立即拉一次
|
||
fetchPerf()
|
||
// 每 2 秒轮询一次
|
||
perfIntervalRef.current = window.setInterval(fetchPerf, 2000)
|
||
|
||
return () => {
|
||
clearIntervalSafe()
|
||
}
|
||
}, [showPerfCard])
|
||
|
||
// 悬停事件处理:延迟显示 / 隐藏性能卡片
|
||
const handlePerfMouseEnter = () => {
|
||
// 取消隐藏定时器
|
||
if (perfHideTimerRef.current !== null) {
|
||
window.clearTimeout(perfHideTimerRef.current)
|
||
perfHideTimerRef.current = null
|
||
}
|
||
// 300ms 后显示,防止误触
|
||
if (perfHoverTimerRef.current === null) {
|
||
perfHoverTimerRef.current = window.setTimeout(() => {
|
||
setShowPerfCard(true)
|
||
perfHoverTimerRef.current = null
|
||
}, 300)
|
||
}
|
||
}
|
||
|
||
const handlePerfMouseLeave = () => {
|
||
// 取消显示定时器
|
||
if (perfHoverTimerRef.current !== null) {
|
||
window.clearTimeout(perfHoverTimerRef.current)
|
||
perfHoverTimerRef.current = null
|
||
}
|
||
// 100ms 延迟隐藏,避免抖动
|
||
if (perfHideTimerRef.current !== null) {
|
||
window.clearTimeout(perfHideTimerRef.current)
|
||
perfHideTimerRef.current = null
|
||
}
|
||
perfHideTimerRef.current = window.setTimeout(() => {
|
||
setShowPerfCard(false)
|
||
perfHideTimerRef.current = null
|
||
}, 100)
|
||
}
|
||
|
||
// 获取用户名首字母
|
||
const getInitials = (name: string) => {
|
||
return name?.charAt(0).toUpperCase() || 'U'
|
||
}
|
||
|
||
const formatAgo = (ms: number): string => {
|
||
if (!Number.isFinite(ms) || ms < 0) return '--'
|
||
const sec = Math.floor(ms / 1000)
|
||
if (sec < 60) return `${sec}s`
|
||
const min = Math.floor(sec / 60)
|
||
if (min < 60) return `${min}min`
|
||
const hr = Math.floor(min / 60)
|
||
return `${hr}h${min % 60}min`
|
||
}
|
||
|
||
const handleLogoHomeClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||
event.preventDefault()
|
||
window.location.assign('/')
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* 系统维护通知横幅 */}
|
||
<MaintenanceBanner />
|
||
|
||
{/* 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
|
||
|
||
<header
|
||
className="sticky top-0 z-50 shadow-sm"
|
||
style={{ backgroundColor: headerBgColor }}
|
||
>
|
||
<div className="container mx-auto max-w-7xl px-3 py-3 sm:px-4 sm:py-4">
|
||
<div className="flex items-center justify-between">
|
||
{/* Logo */}
|
||
<Link href="/" onClick={handleLogoHomeClick} className="flex min-w-0 items-center space-x-2 sm:space-x-3">
|
||
<Image
|
||
src={headerLogoUrl}
|
||
alt={siteName}
|
||
width={36}
|
||
height={36}
|
||
unoptimized
|
||
className="h-9 w-9 rounded-lg sm:h-10 sm:w-10"
|
||
/>
|
||
<span className="max-w-[11rem] truncate text-base font-semibold text-white sm:max-w-none sm:text-2xl">
|
||
{siteName}
|
||
</span>
|
||
</Link>
|
||
|
||
{/* Navigation */}
|
||
<nav className="flex items-center space-x-2 sm:space-x-4">
|
||
{session && (
|
||
<div className="hidden sm:block">
|
||
<HeaderTaobaoSearch />
|
||
</div>
|
||
)}
|
||
|
||
{/* Network Status Indicator + 性能监控悬停卡片 */}
|
||
{networkChecked && (
|
||
<div className="relative hidden sm:block" onMouseEnter={handlePerfMouseEnter} onMouseLeave={handlePerfMouseLeave}>
|
||
<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>
|
||
|
||
{/* 性能监控悬停卡片(优化版) */}
|
||
{showPerfCard && (
|
||
<div className="absolute right-0 mt-2 w-96 bg-gray-900 text-gray-100 rounded-lg shadow-xl border border-gray-700 p-4 z-50">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-semibold text-gray-300">系统性能</span>
|
||
{perfLoading && (
|
||
<span className="text-xs text-gray-400">加载中...</span>
|
||
)}
|
||
{perfError && !perfLoading && (
|
||
<span className="text-xs text-red-400">{perfError}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* CPU / 内存 / 网络 */}
|
||
<div className="space-y-2 mb-3">
|
||
{/* CPU */}
|
||
<div>
|
||
<div className="flex justify-between text-sm text-gray-300 mb-1">
|
||
<span>CPU</span>
|
||
<span>{perfData?.cpu?.usage ?? '--'}%</span>
|
||
</div>
|
||
<div className="w-full h-2 rounded-full bg-gray-800 overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full bg-emerald-500"
|
||
style={{ width: `${Math.min(Math.max(perfData?.cpu?.usage ?? 0, 0), 100)}%` }}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-between text-xs text-gray-500 mt-0.5">
|
||
<span>1/5/15 分钟负载</span>
|
||
<span>
|
||
{(perfData?.cpu?.loadAverage || []).map(v => v.toFixed(2)).join(' / ') || '--'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 内存 */}
|
||
<div>
|
||
<div className="flex justify-between text-sm text-gray-300 mb-1">
|
||
<span>内存</span>
|
||
<span>
|
||
{perfData?.memory
|
||
? `${perfData.memory.used} / ${perfData.memory.total} MB (${perfData.memory.percentage}%)`
|
||
: '--'}
|
||
</span>
|
||
</div>
|
||
<div className="w-full h-2 rounded-full bg-gray-800 overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full bg-sky-500"
|
||
style={{
|
||
width: `${Math.min(Math.max(perfData?.memory?.percentage ?? 0, 0), 100)}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 服务器整体网络流量 */}
|
||
<div>
|
||
<div className="flex justify-between text-sm text-gray-300 mb-1">
|
||
<span>服务器网络</span>
|
||
<span className="text-xs text-gray-500">全站实时</span>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between text-xs text-gray-400">
|
||
<span>入站</span>
|
||
<span>
|
||
{perfData?.network?.uploadSpeed
|
||
? formatHumanSpeed(perfData.network.uploadSpeed)
|
||
: '--'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between text-xs text-gray-400">
|
||
<span>出站</span>
|
||
<span>
|
||
{perfData?.network?.downloadSpeed
|
||
? formatHumanSpeed(perfData.network.downloadSpeed)
|
||
: '--'}
|
||
</span>
|
||
</div>
|
||
<div className="text-[11px] text-gray-500">
|
||
服务器整体流量,非当前浏览器测速
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 上传队列 / 转码队列 详细信息 */}
|
||
<div className="border-t border-gray-800 pt-3 mt-2 space-y-3">
|
||
{/* 上传队列 */}
|
||
<div>
|
||
<div className="flex justify-between text-sm text-gray-300 mb-2">
|
||
<span>上传队列</span>
|
||
<span>
|
||
{perfData?.uploadQueue
|
||
? `处理中 ${perfData.uploadQueue.processing?.length || 0} · 等待 ${perfData.uploadQueue.waiting?.length || 0}`
|
||
: '--'}
|
||
</span>
|
||
</div>
|
||
{perfData?.uploadQueue?.processing && perfData.uploadQueue.processing.length > 0 && (
|
||
<div className="space-y-1.5 max-h-32 overflow-y-auto">
|
||
{perfData.uploadQueue.processing.map((task: any, idx: number) => {
|
||
const uploadedBytes =
|
||
task.uploadedBytes || ((task.fileSize || 0) * ((task.progress || 0) / 100))
|
||
const speedText = task.currentSpeed
|
||
? formatTransferProgressSummary({
|
||
speedBytesPerSecond: task.currentSpeed,
|
||
loadedBytes: uploadedBytes,
|
||
totalBytes: task.fileSize,
|
||
})
|
||
: '速度: --'
|
||
|
||
return (
|
||
<div key={task.uploadId || idx} className="text-xs text-gray-400 bg-gray-800/50 rounded px-2.5 py-1.5">
|
||
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||
<div className="flex justify-between mt-1 text-xs">
|
||
<span>{task.username || '未知用户'}</span>
|
||
<span>{task.materialType || '未知类型'}</span>
|
||
</div>
|
||
<div className="mt-1 text-xs">
|
||
<div>进度: {task.progress ?? 0}%</div>
|
||
<div>{speedText}</div>
|
||
</div>
|
||
{task.isActive === false && (
|
||
<div className="text-xs text-amber-400 mt-1">
|
||
无活动: {formatAgo(task.inactiveMs)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
{perfData?.uploadQueue?.waiting && perfData.uploadQueue.waiting.length > 0 && (
|
||
<div className="space-y-1.5 max-h-32 overflow-y-auto mt-2">
|
||
{perfData.uploadQueue.waiting.map((task: any, idx: number) => (
|
||
<div key={task.uploadId || idx} className="text-xs text-gray-500 bg-gray-800/30 rounded px-2.5 py-1.5">
|
||
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||
<div className="flex justify-between mt-1 text-xs">
|
||
<span>{task.username || '未知用户'}</span>
|
||
<span>{task.materialType || '未知类型'}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-500 mt-1">等待中</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 转码队列 */}
|
||
<div>
|
||
<div className="flex justify-between text-sm text-gray-300 mb-2">
|
||
<span>转码队列</span>
|
||
<span>
|
||
{perfData?.transcodeQueue
|
||
? `处理中 ${perfData.transcodeQueue.processing?.length || 0} / ${perfData.transcodeQueue.maxConcurrent || 0} · 等待 ${perfData.transcodeQueue.waiting?.length || 0}`
|
||
: '--'}
|
||
</span>
|
||
</div>
|
||
{perfData?.transcodeQueue?.processing && perfData.transcodeQueue.processing.length > 0 && (
|
||
<div className="space-y-1.5 max-h-32 overflow-y-auto">
|
||
{perfData.transcodeQueue.processing.map((task: any, idx: number) => {
|
||
const stageMap: Record<string, string> = {
|
||
preview: '预览视频',
|
||
clip: '片段生成',
|
||
cover: '封面提取',
|
||
}
|
||
return (
|
||
<div key={task.materialId || idx} className="text-xs text-gray-400 bg-gray-800/50 rounded px-2.5 py-1.5">
|
||
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||
<div className="flex justify-between mt-1 text-xs">
|
||
<span>{task.materialType || '未知类型'} · {task.stage ? stageMap[task.stage] || task.stage : '处理中'}</span>
|
||
<span>{task.progress ?? 0}%</span>
|
||
</div>
|
||
<div className="text-xs text-gray-500 mt-1">上传人: {task.authorName || '未知用户'}</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
{perfData?.transcodeQueue?.waiting && perfData.transcodeQueue.waiting.length > 0 && (
|
||
<div className="space-y-1.5 max-h-32 overflow-y-auto mt-2">
|
||
{perfData.transcodeQueue.waiting.map((task: any, idx: number) => (
|
||
<div key={task.materialId || idx} className="text-xs text-gray-500 bg-gray-800/30 rounded px-2.5 py-1.5">
|
||
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||
<div className="flex justify-between mt-1 text-xs">
|
||
<span>{task.materialType || '未知类型'}</span>
|
||
<span>队列 #{task.queuePosition || idx + 1}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-500 mt-1">上传人: {task.authorName || '未知用户'}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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"
|
||
>
|
||
{/* 圆形头像 */}
|
||
{(session.user as any)?.avatar ? (
|
||
<Image
|
||
src={(session.user as any).avatar}
|
||
alt={session.user?.name || '用户'}
|
||
width={32}
|
||
height={32}
|
||
className="w-8 h-8 rounded-full border-2 border-gray-700 dark:border-gray-600 shadow-lg hover:border-gray-500 dark:hover:border-gray-400 transition-all object-cover"
|
||
unoptimized
|
||
/>
|
||
) : (
|
||
<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>
|
||
|
||
{/* 个人中心 */}
|
||
<Link
|
||
href="/profile"
|
||
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="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>
|
||
|
||
{/* 考核调研 */}
|
||
<Link
|
||
href="/performance-survey"
|
||
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="M9 17v-6m4 6V7m4 10v-3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
<span>考核调研</span>
|
||
</Link>
|
||
|
||
{/* 更新记录 */}
|
||
<button
|
||
onClick={() => {
|
||
setShowDropdown(false)
|
||
showUpdateLog('all') // 显示所有历史记录
|
||
}}
|
||
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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||
</svg>
|
||
<span>更新记录</span>
|
||
</button>
|
||
|
||
{/* 提交反馈 */}
|
||
<a
|
||
href="https://alidocs.dingtalk.com/i/nodes/NZQYprEoWoeEabj3srv6RwDkJ1waOeDk?utm_scene=person_space"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
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="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||
</svg>
|
||
<span>提交反馈</span>
|
||
</a>
|
||
|
||
{/* 系统设置(管理员) */}
|
||
{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={() => {
|
||
setShowDropdown(false)
|
||
setShowNetworkDiagnose(true)
|
||
}}
|
||
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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||
</svg>
|
||
<span>网络诊断</span>
|
||
</button>
|
||
|
||
{/* 清除缓存(仅清除素材网相关缓存) */}
|
||
<button
|
||
onClick={async () => {
|
||
setShowDropdown(false)
|
||
try {
|
||
const currentOrigin = window.location.origin
|
||
|
||
// 1. 清除当前素材库域名下的 Cache Storage。
|
||
// 浏览器同源策略会限制 caches.keys() 只能看到当前域名,不会影响其它网站。
|
||
if ('caches' in window) {
|
||
const cacheNames = await caches.keys()
|
||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||
console.log(`已清除 ${cacheNames.length} 个当前域名 Cache Storage:`, cacheNames)
|
||
}
|
||
|
||
// 2. 注销当前素材库域名下的 Service Worker
|
||
if ('serviceWorker' in navigator) {
|
||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||
const materialRegistrations = registrations.filter(reg => {
|
||
return reg.scope.startsWith(currentOrigin)
|
||
})
|
||
await Promise.all(materialRegistrations.map(reg => reg.unregister()))
|
||
console.log(`已清除 ${materialRegistrations.length} 个Service Worker`)
|
||
}
|
||
|
||
// 3. 精准清除素材库写入的 localStorage,不清其它无关 key。
|
||
try {
|
||
const keysToRemove = removeMaterialStorageItems(
|
||
localStorage,
|
||
MATERIAL_LOCAL_STORAGE_KEYS,
|
||
MATERIAL_LOCAL_STORAGE_PREFIXES,
|
||
)
|
||
console.log(`已清除 ${keysToRemove.length} 个localStorage项:`, keysToRemove)
|
||
} catch (e) {
|
||
console.warn('清除localStorage失败:', e)
|
||
}
|
||
|
||
// 4. 精准清除素材库写入的 sessionStorage,包含首页筛选/滚动缓存。
|
||
try {
|
||
const keysToRemove = removeMaterialStorageItems(
|
||
sessionStorage,
|
||
MATERIAL_SESSION_STORAGE_KEYS,
|
||
MATERIAL_SESSION_STORAGE_PREFIXES,
|
||
)
|
||
console.log(`已清除 ${keysToRemove.length} 个sessionStorage项:`, keysToRemove)
|
||
} catch (e) {
|
||
console.warn('清除sessionStorage失败:', e)
|
||
}
|
||
|
||
// 5. 清除素材库断点上传 IndexedDB
|
||
try {
|
||
if ('indexedDB' in window) {
|
||
const deletedDatabases = await Promise.all(
|
||
MATERIAL_INDEXED_DB_NAMES.map((name) => deleteIndexedDatabase(name))
|
||
)
|
||
console.log(`已清除 ${deletedDatabases.length} 个IndexedDB数据库:`, deletedDatabases)
|
||
}
|
||
} catch (e) {
|
||
console.warn('清除IndexedDB失败:', e)
|
||
}
|
||
|
||
// 6. 清除素材库版本 cookie,但保留登录 cookie,避免用户被登出。
|
||
try {
|
||
clearMaterialVersionCookie()
|
||
console.log('已清除素材库版本 cookie: app-version')
|
||
} catch (e) {
|
||
console.warn('清除版本 cookie 失败:', e)
|
||
}
|
||
|
||
// 显示提示信息
|
||
console.log('✅ 素材网缓存已清除,正在强制刷新页面...')
|
||
|
||
// 延迟一小段时间让用户看到提示,然后强制刷新
|
||
setTimeout(() => {
|
||
// 使用硬刷新(Ctrl+F5 效果)
|
||
// 方法1:通过修改 URL 添加时间戳参数强制绕过缓存
|
||
const url = new URL(window.location.href)
|
||
// 移除旧的缓存参数
|
||
url.searchParams.delete('_t')
|
||
url.searchParams.delete('_clear_cache')
|
||
// 添加新的时间戳参数
|
||
url.searchParams.set('_t', Date.now().toString())
|
||
|
||
// 使用 location.replace 强制刷新(不会在历史记录中留下记录)
|
||
window.location.replace(url.toString())
|
||
}, 300)
|
||
} catch (error) {
|
||
console.error('清除缓存失败:', error)
|
||
alert('清除缓存失败,请手动刷新页面(Ctrl+F5 或 Cmd+Shift+R)')
|
||
}
|
||
}}
|
||
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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
<span>清除缓存并刷新</span>
|
||
</button>
|
||
|
||
{/* 退出登录 */}
|
||
<button
|
||
onClick={async () => {
|
||
setShowDropdown(false)
|
||
try {
|
||
// 使用 redirect: false 避免 NextAuth 使用 NEXTAUTH_URL 重定向
|
||
// 然后手动使用 Next.js router 重定向,这样可以完全控制重定向逻辑
|
||
await signOut({ redirect: false })
|
||
// 短暂延迟,避免浏览器扩展脚本在处理事件时出错
|
||
await new Promise(resolve => setTimeout(resolve, 100))
|
||
// 使用 router.push 进行客户端路由,自动使用当前域名
|
||
router.push('/login')
|
||
router.refresh()
|
||
} catch (error) {
|
||
console.error('退出登录失败:', error)
|
||
// 即使出错也尝试跳转
|
||
router.push('/login')
|
||
router.refresh()
|
||
}
|
||
}}
|
||
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>
|
||
|
||
{/* 更新记录弹窗 */}
|
||
<UpdateLogModal
|
||
isOpen={showUpdateLogModal}
|
||
onClose={handleUpdateLogClose}
|
||
updateLog={currentLog || undefined}
|
||
showAllHistory={showAllHistory}
|
||
/>
|
||
|
||
{/* 网络诊断弹窗 */}
|
||
<NetworkDiagnoseModal
|
||
isOpen={showNetworkDiagnose}
|
||
onClose={() => setShowNetworkDiagnose(false)}
|
||
/>
|
||
</>
|
||
)
|
||
}
|