gongxiangp2/components/Header.tsx

835 lines
39 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 { 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 role = session ? String((session.user as any)?.role || '') : ''
const isAdmin = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)
const canViewPerformance = ['ADMIN', 'SYSTEM_ADMIN'].includes(role) || Boolean((session?.user as any)?.isSuperAdmin)
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 (!canViewPerformance || !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()
}
}, [canViewPerformance, showPerfCard])
// 悬停事件处理:延迟显示 / 隐藏性能卡片
const handlePerfMouseEnter = () => {
if (!canViewPerformance) return
// 取消隐藏定时器
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 (!canViewPerformance) return
// 取消显示定时器
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={canViewPerformance ? handlePerfMouseEnter : undefined}
onMouseLeave={canViewPerformance ? handlePerfMouseLeave : undefined}
>
<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>
{/* 性能监控悬停卡片(优化版) */}
{canViewPerformance && 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>
{/* 作品过稿看板 */}
<Link
href="/work-review"
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="M4 5a2 2 0 012-2h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm4 4h8M8 13h5m-5 4h3" />
</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)}
/>
</>
)
}