thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
14 kB
import React, { useState, useRef, useEffect, useMemo } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import LanguageSwitcher from './LanguageSwitcher'
import NotificationBell from './NotificationBell'
import { useTranslation } from '../i18n'
import { useActivityLog } from '../hooks/useActivityLog'
interface HeaderProps {
onToggleSidebar: () => void
}
/** 格式化相对时间 */
function formatRelativeTime(timestamp: number, locale: string): string {
const diff = Date.now() - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (seconds < 60) return locale === 'en' ? 'Just now' : '刚刚'
if (minutes < 60) return locale === 'en' ? `${minutes}m ago` : `${minutes}分钟前`
if (hours < 24) return locale === 'en' ? `${hours}h ago` : `${hours}小时前`
const days = Math.floor(hours / 24)
return locale === 'en' ? `${days}d ago` : `${days}天前`
}
export default function Header({ onToggleSidebar }: HeaderProps) {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
const { t, locale } = useTranslation()
const { activities } = useActivityLog()
const pathname = location.pathname
const pageTitle = (() => {
if (pathname.startsWith('/consultation')) return t('nav.consultation')
if (pathname.startsWith('/history')) return t('nav.history')
if (pathname.startsWith('/record/')) return t('nav.recordDetail')
if (pathname.startsWith('/settings')) return t('nav.settings')
if (pathname.startsWith('/patients')) return t('nav.patients')
if (pathname.startsWith('/admin')) return t('nav.admin')
if (pathname.startsWith('/templates')) return t('nav.templates')
if (pathname.startsWith('/profile')) return t('nav.profile')
if (pathname.startsWith('/lab-results')) return t('nav.labResults')
if (pathname.startsWith('/knowledge-base')) return t('nav.knowledge')
if (pathname.startsWith('/inventory')) return t('nav.inventory')
if (pathname.startsWith('/followups')) return t('nav.followups')
if (pathname.startsWith('/appointments')) return t('nav.appointments')
return t('nav.dashboard')
})()
const breadcrumbs: { label: string; href?: string }[] = (() => {
const crumbs: { label: string; href?: string }[] = [{ label: t('nav.home'), href: '/' }]
if (pathname === '/') return crumbs
if (pathname.startsWith('/consultation')) crumbs.push({ label: t('nav.consultation') })
else if (pathname.startsWith('/history')) crumbs.push({ label: t('nav.history') })
else if (pathname.startsWith('/record/')) crumbs.push({ label: t('nav.recordDetail') })
else if (pathname.startsWith('/settings')) crumbs.push({ label: t('nav.settings') })
else if (pathname.startsWith('/patients')) {
crumbs.push({ label: t('nav.patients'), href: '/patients' })
if (pathname.includes('/edit') || pathname.includes('/new')) crumbs.push({ label: t('common.edit') })
}
else if (pathname.startsWith('/admin')) crumbs.push({ label: t('nav.admin') })
else if (pathname.startsWith('/templates')) crumbs.push({ label: t('nav.templates') })
else if (pathname.startsWith('/profile')) crumbs.push({ label: t('nav.profile') })
else if (pathname.startsWith('/lab-results')) crumbs.push({ label: t('nav.labResults') })
else if (pathname.startsWith('/knowledge-base')) crumbs.push({ label: t('nav.knowledge') })
else if (pathname.startsWith('/inventory')) crumbs.push({ label: t('nav.inventory') })
else if (pathname.startsWith('/followups')) crumbs.push({ label: t('nav.followups') })
return crumbs
})()
const [showDropdown, setShowDropdown] = useState(false)
const [scrolled, setScrolled] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLDivElement>(null)
// 滚动收缩 Header
useEffect(() => {
let lastScrollY = 0
const handleScroll = () => {
const currentScrollY = window.scrollY
if (currentScrollY > 40 && currentScrollY > lastScrollY) {
setScrolled(true)
} else if (currentScrollY < 20) {
setScrolled(false)
}
lastScrollY = currentScrollY
}
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// 点击外部关闭下拉
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDropdown(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const today = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
const displayName = user?.nickname || t('profile.defaultUser')
const initial = displayName[0]
// 最近5条操作
const recentActivities = useMemo(() => activities.slice(0, 5), [activities])
const handleMenuClick = (action: string) => {
setShowDropdown(false)
if (action === 'profile') {
navigate('/profile')
} else if (action === 'settings') {
navigate('/settings')
} else if (action === 'logout') {
logout()
navigate('/login')
}
}
return (
<header
ref={headerRef}
className={`glass flex items-center justify-between px-3 md:px-6 shrink-0 z-10 transition-all duration-300 ${
scrolled ? 'h-11 shadow-md' : 'h-14'
}`}
>
<div className="flex items-center gap-2 md:gap-3">
{/* 移动端汉堡菜单按钮 */}
<button
onClick={onToggleSidebar}
className="md:hidden p-1.5 hover:bg-gray-100 rounded-lg text-gray-500 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
aria-label={t('nav.openMenu')}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex items-center gap-2">
<div className={`rounded-lg flex items-center justify-center shrink-0 bg-medical-600 transition-all duration-300 ${
scrolled ? 'w-7 h-7' : 'w-8 h-8'
}`}>
<svg className={`text-white transition-all duration-300 ${scrolled ? 'w-4 h-4' : 'w-5 h-5'}`} fill="currentColor" viewBox="0 0 24 24">
<path d="M19 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5a2 2 0 00-2-2zm-5 13h-4v-3H7v-2h3V8h4v3h3v2h-3v3z" />
</svg>
</div>
<span className={`font-bold text-medical-700 tracking-tight hidden sm:inline transition-all duration-300 ${
scrolled ? 'text-sm' : 'text-base md:text-lg'
}`}>VetCopilot</span>
</div>
<span className={`text-gray-400 hidden sm:inline transition-all duration-300 ${scrolled ? 'text-xs' : 'text-sm'}`}>|</span>
{/* 面包屑 */}
<div className={`hidden sm:flex items-center gap-1.5 transition-all duration-300 ${
scrolled ? 'text-[10px]' : 'text-xs'
}`}>
{breadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
{i > 0 && <svg className="w-3 h-3 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>}
{crumb.href ? (
<button onClick={() => navigate(crumb.href!)} className="text-gray-400 hover:text-gray-600 transition-colors">{crumb.label}</button>
) : (
<span className="text-gray-500 font-medium truncate">{crumb.label}</span>
)}
</React.Fragment>
))}
</div>
</div>
<div className="flex items-center gap-2 md:gap-4">
<NotificationBell />
<LanguageSwitcher />
<span className={`text-gray-400 hidden sm:inline transition-all duration-300 ${
scrolled ? 'text-[10px]' : 'text-xs md:text-sm'
}`}>{today}</span>
<div ref={dropdownRef} className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="relative bg-medical-500 rounded-full flex items-center justify-center shrink-0 hover:scale-105 transition-all duration-300 focus-visible:ring-2 focus-visible:ring-medical-500 focus-visible:ring-offset-2"
style={{
width: scrolled ? 28 : 32,
height: scrolled ? 28 : 32,
}}
title={user?.nickname}
aria-label={`${t('common.userMenu')} - ${user?.nickname || t('auth.doctor')}`}
aria-haspopup="true"
aria-expanded={showDropdown}
>
<span className={`font-medium text-white transition-all duration-300 ${
scrolled ? 'text-[10px]' : 'text-xs'
}`}>{initial}</span>
{/* 在线状态环 */}
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-400 border-2 border-white rounded-full" style={{ animation: 'pulse-dot 2s ease-in-out infinite' }} />
</button>
{showDropdown && (
<div className="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded-xl shadow-xl z-50 overflow-hidden dropdown-enter origin-top-right" role="menu" aria-label={t('common.userMenu')}>
{/* 用户信息区 */}
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/50">
<p className="text-sm font-medium text-gray-800 truncate">{user?.nickname || t('auth.doctor')}</p>
<p className="text-[11px] text-gray-400 truncate mt-0.5">{user?.role === 'admin' ? t('auth.hospitalAdmin') : t('auth.doctor')}</p>
</div>
{/* 最近操作 */}
<div className="border-b border-gray-100">
<div className="px-4 py-1.5 flex items-center justify-between">
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
{t('ergonomics.recentActivity')}
</p>
</div>
{recentActivities.length === 0 ? (
<p className="px-4 py-2 text-xs text-gray-400">
{t('ergonomics.noActivity')}
</p>
) : (
<div className="max-h-36 overflow-y-auto">
{recentActivities.map((act) => (
<div
key={act.id}
className="px-4 py-1.5 text-xs text-gray-600 flex items-center gap-2 hover:bg-gray-50 transition-colors"
>
{act.icon && <span className="text-sm">{act.icon}</span>}
<span className="flex-1 truncate">{act.description}</span>
<span className="text-[10px] text-gray-400 shrink-0">
{formatRelativeTime(act.timestamp, locale)}
</span>
</div>
))}
</div>
)}
</div>
<button
onClick={() => handleMenuClick('profile')}
className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3 transition-colors"
role="menuitem"
>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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>
{t('nav.profile')}
</button>
<button
onClick={() => handleMenuClick('settings')}
className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3 transition-colors"
role="menuitem"
>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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.066 2.573c1.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.573 1.066c-.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.066-2.573c-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" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{t('nav.settings')}
</button>
<div className="h-px bg-gray-100" style={{ animation: 'menu-divider-grow 0.3s ease-out' }} />
<button
onClick={() => handleMenuClick('logout')}
className="w-full text-left px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 flex items-center gap-3 transition-colors rounded-b-xl"
role="menuitem"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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>
{t('nav.logout')}
</button>
</div>
)}
</div>
</div>
</header>
)
}