gongxiangp2/components/board/MaterialsHeader.tsx

481 lines
22 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 } from 'react'
import { initializeNetwork, setupNetworkChangeDetection, addNetworkChangeListener } from '@/lib/network'
import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal'
import MaintenanceBanner from '@/components/MaintenanceBanner'
import HeaderTaobaoSearch from '@/components/HeaderTaobaoSearch'
import BrandLogo from '@/components/BrandLogo'
import SystemPerformanceCard, { type SystemPerformanceData } from '@/components/board/SystemPerformanceCard'
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
/*
* MaterialsHeader —— 创意资产库板块顶栏(看板语言 · Zen Minimal)
* 移植自 components/Header.tsx 的全部功能(淘宝搜索/内外网状态/管理员性能监控/用户菜单/更新日志),
* 视觉统一到品牌中心白皮书语言:浅底、发丝线、墨字。
* 用户菜单是“个人工作台”的统一入口;链接保留原有权限与业务行为,只迁移其入口体验。
*/
interface MaterialsHeaderProps {
session: Session | null
/** 板块模式:传入板块名显示面包屑;不传则首页模式(显示五大板块导航) */
sectionLabel?: string
}
const BOARD_NAV: Array<{ key: string; label: string; href?: string; planned?: boolean; adminOnly?: boolean }> = [
{ key: 'dashboard', label: '中心仪表盘', href: '/' },
{ key: 'assets', label: '创意资产库', href: '/materials' },
{ key: 'organization', label: '组织管理', href: '/organization', adminOnly: true },
{ key: 'business', label: '业务经营', planned: true },
{ key: 'governance', label: '治理与设置', planned: true },
]
export default function MaterialsHeader({ session, sectionLabel }: MaterialsHeaderProps) {
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)
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<SystemPerformanceData | null>(null)
const perfHoverTimerRef = useRef<number | null>(null)
const perfHideTimerRef = useRef<number | null>(null)
const perfIntervalRef = useRef<number | null>(null)
const { showModal: showUpdateLogModal, currentLog, showAllHistory, handleClose: handleUpdateLogClose } = useUpdateLog()
// 品牌设置(logo):浅底表面用浅底适配版
const [brandLogoUrl, setBrandLogoUrl] = useState(DEFAULT_LOGIN_LOGO_URL)
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json()
setBrandLogoUrl(settings.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL)
}
} catch {
// 设置加载失败时保留默认 logo
}
}
void loadSettings()
}, [])
// 更新浏览器标题(板块身份)
useEffect(() => {
document.title = sectionLabel ? `由壹点·品牌中心 · ${sectionLabel}` : '由壹点·品牌中心'
}, [sectionLabel])
// 权威档案身份行(菜单头:花名下的职位·部门;无绑定时回退邮箱)
const [employeeLine, setEmployeeLine] = useState<string | null>(null)
useEffect(() => {
const load = async () => {
try {
const res = await fetch('/api/user/profile')
if (!res.ok) return
const data = await res.json()
const employee = data?.employee
if (employee) {
const dept = employee.departmentPath ? employee.departmentPath.split('/').filter(Boolean).slice(-2).join(' / ') : ''
const line = [employee.positionTitle, dept].filter(Boolean).join(' · ')
setEmployeeLine(line || employee.jobNumber || null)
}
} catch {
// 档案行加载失败时回退邮箱展示
}
}
void load()
}, [])
// 检测网络状态
useEffect(() => {
const detectNetwork = async () => {
try {
const networkStatus = await initializeNetwork()
setIsInternalNetwork(networkStatus.isInternal)
setNetworkChecked(true)
} catch (error) {
console.error('网络检测失败:', error)
setIsInternalNetwork(false)
setNetworkChecked(true)
}
}
detectNetwork()
setupNetworkChangeDetection()
const unsubscribe = addNetworkChangeListener((status) => {
setIsInternalNetwork(status.isInternal)
})
return () => {
unsubscribe()
}
}, [])
// 点击外部或按 Escape 关闭用户菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setShowDropdown(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
// 性能监控:根据 showPerfCard 控制轮询(2 秒)
useEffect(() => {
const clearIntervalSafe = () => {
if (perfIntervalRef.current !== null) {
window.clearInterval(perfIntervalRef.current)
perfIntervalRef.current = null
}
}
if (!canViewPerformance || !showPerfCard) {
clearIntervalSafe()
return
}
const fetchPerf = async (isInitial: boolean) => {
try {
if (isInitial) 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)
}
}
void fetchPerf(true)
perfIntervalRef.current = window.setInterval(() => {
void fetchPerf(false)
}, 2000)
return () => {
clearIntervalSafe()
}
}, [canViewPerformance, showPerfCard])
// 悬停事件处理:延迟显示 / 隐藏性能卡片
const handlePerfMouseEnter = () => {
if (!canViewPerformance) return
if (perfHideTimerRef.current !== null) {
window.clearTimeout(perfHideTimerRef.current)
perfHideTimerRef.current = null
}
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
}
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 menuItemCls =
'group flex w-full items-start gap-3 rounded-md px-3 py-3 text-left text-[#1a1c1d] transition-colors hover:bg-[#f1f2f4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white'
const menuIconCls =
'mt-0.5 h-4 w-4 shrink-0 text-[#6b7075] transition-colors group-hover:text-[#1a1c1d]'
return (
<>
<MaintenanceBanner />
<header className="sticky top-0 z-50 border-b border-[#e7e8ea] bg-[#fafafa]">
<div className="mx-auto flex h-14 max-w-[1280px] items-center gap-3 px-4 sm:px-6">
{/* 品牌 */}
<Link
href="/"
className="flex min-w-0 items-center gap-2.5 rounded-sm text-[14px] font-semibold tracking-tight text-[#1a1c1d] transition-opacity hover:opacity-70"
>
<BrandLogo
src={brandLogoUrl}
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
alt=""
width={28}
height={28}
unoptimized
className="h-7 w-7 shrink-0 rounded-md object-cover"
/>
<span className="hidden sm:inline">由壹点·品牌中心</span>
<span className="sr-only sm:hidden">由壹点·品牌中心</span>
</Link>
{sectionLabel ? (
/* 板块模式:面包屑 */
<>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da]">/</span>
<span className="text-[13px] font-medium text-[#6b7075]">{sectionLabel}</span>
</>
) : (
/* 首页模式:五大板块导航 */
<nav className="ml-auto hidden items-center gap-6 md:flex" aria-label="看板板块导航">
{BOARD_NAV.filter((i) => !i.adminOnly || isAdmin).map((item) =>
item.planned ? (
<span key={item.key} className="text-[13px] text-[#86898e]" title="规划中,尚未开放">
{item.label}
</span>
) : (
<Link
key={item.key}
href={item.href ?? '/'}
aria-current={item.key === 'dashboard' ? 'page' : undefined}
className={`rounded-sm text-[13px] transition-opacity hover:opacity-60 ${
item.key === 'dashboard' ? 'font-semibold text-[#1a1c1d]' : 'text-[#6b7075]'
}`}
>
{item.label}
</Link>
),
)}
</nav>
)}
<nav className={`flex items-center gap-3 sm:gap-5 ${sectionLabel ? 'ml-auto' : 'md:ml-0'}`} aria-label="页面导航">
{session && (
<div className="hidden sm:block">
<HeaderTaobaoSearch />
</div>
)}
{/* 网络状态 + 性能监控悬停卡片 */}
{networkChecked && (
<div
className="relative hidden sm:block"
onMouseEnter={canViewPerformance ? handlePerfMouseEnter : undefined}
onMouseLeave={canViewPerformance ? handlePerfMouseLeave : undefined}
>
{canViewPerformance ? (
<button
type="button"
aria-expanded={showPerfCard}
aria-controls="system-performance-panel"
onClick={() => setShowPerfCard((open) => !open)}
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa] ${
isInternalNetwork
? 'border-emerald-600/20 bg-emerald-600/10 text-emerald-700'
: 'border-sky-600/20 bg-sky-600/10 text-sky-700'
}`}
>
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="4" />
</svg>
<span>{isInternalNetwork ? '内网' : '公网'}</span>
</button>
) : (
<div
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium ${
isInternalNetwork
? 'border-emerald-600/20 bg-emerald-600/10 text-emerald-700'
: 'border-sky-600/20 bg-sky-600/10 text-sky-700'
}`}
>
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="4" />
</svg>
<span>{isInternalNetwork ? '内网' : '公网'}</span>
</div>
)}
{canViewPerformance && showPerfCard && (
<SystemPerformanceCard data={perfData} loading={perfLoading} error={perfError} />
)}
</div>
)}
{/* 用户菜单 */}
{session && (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDropdown(!showDropdown)}
aria-label={showDropdown ? '关闭个人工作台菜单' : '打开个人工作台菜单'}
aria-expanded={showDropdown}
className="flex items-center rounded-md p-1.5 transition-colors hover:bg-[#f1f2f4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
>
{(session.user as any)?.avatar ? (
<Image
src={(session.user as any).avatar}
alt={session.user?.name || '用户'}
width={32}
height={32}
className="h-8 w-8 rounded-full border border-[#e7e8ea] object-cover transition-colors hover:border-[#9aa0a8]"
unoptimized
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#1a1c1d] text-base font-semibold text-white">
{getInitials(session.user?.name || '')}
</div>
)}
</button>
{showDropdown && (
<div
role="menu"
aria-label="个人工作台"
className="absolute right-0 z-50 mt-2 max-h-[calc(100dvh-4.5rem)] w-[min(21rem,calc(100vw-2rem))] overflow-y-auto border border-[#dfe1e4] bg-white p-2 [scrollbar-color:#c9ccd1_transparent] [scrollbar-width:thin]"
>
<div className="border-b border-[#e7e8ea] px-3 pb-4 pt-3">
<p className="font-display text-[17px] font-semibold tracking-[-0.02em] text-[#1a1c1d]">{session.user?.name}</p>
<p className="mt-1 truncate text-[12px] leading-relaxed text-[#6b7075]">
{employeeLine ?? session.user?.email}
</p>
<p className="mt-3 font-en text-[10px] uppercase tracking-[0.14em] text-[#86898e]">个人工作台</p>
</div>
{/* 个人档案 */}
<Link href="/profile" onClick={() => setShowDropdown(false)} className={menuItemCls} role="menuitem">
<svg className={menuIconCls} 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 className="min-w-0">
<span className="block text-[13px] font-semibold">我的档案</span>
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">账号资料、组织归属与我的素材</span>
</span>
</Link>
<div aria-hidden="true" className="my-1.5 border-t border-[#e7e8ea]" />
{/* 工具 */}
<button
onClick={() => {
setShowDropdown(false)
showUpdateLog('all')
}}
className={menuItemCls}
role="menuitem"
>
<svg className={menuIconCls} 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 className="min-w-0">
<span className="block text-[13px] font-semibold">更新记录</span>
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">查看已发布的功能与体验改进</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={menuItemCls}
role="menuitem"
>
<svg className={menuIconCls} 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 className="min-w-0">
<span className="flex items-center gap-1 text-[13px] font-semibold">提交反馈 <span aria-hidden="true" className="text-[#86898e]">↗</span></span>
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">在钉钉文档中留下问题或建议</span>
</span>
</a>
{isAdmin && (
<>
<div aria-hidden="true" className="my-1.5 border-t border-[#e7e8ea]" />
<Link href="/admin" onClick={() => setShowDropdown(false)} className={menuItemCls} role="menuitem">
<svg className={menuIconCls} 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 className="min-w-0">
<span className="block text-[13px] font-semibold">素材库后台</span>
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">素材管理、统计与系统配置</span>
</span>
</Link>
</>
)}
<div aria-hidden="true" className="my-1.5 border-t border-[#e7e8ea]" />
<button
onClick={async () => {
setShowDropdown(false)
try {
await signOut({ redirect: false })
await new Promise((resolve) => setTimeout(resolve, 100))
router.push('/login')
router.refresh()
} catch (error) {
console.error('退出登录失败:', error)
router.push('/login')
router.refresh()
}
}}
className="group flex w-full items-center gap-3 rounded-md px-3 py-3 text-left text-[13px] font-semibold text-[#6b7075] transition-colors hover:bg-[#f5f0f0] hover:text-[#a23c36] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
role="menuitem"
>
<svg className="h-4 w-4 shrink-0 transition-colors group-hover:text-[#a23c36]" 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>
</header>
<UpdateLogModal
isOpen={showUpdateLogModal}
onClose={handleUpdateLogClose}
updateLog={currentLog || undefined}
showAllHistory={showAllHistory}
/>
</>
)
}