Download vetcopilot-app/src/components/Header.tsx from WWsCa/VetNet-LCPS: direct link, hf CLI and curl.
- Browser
- Download file 14 kB
-
https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/Header.tsx
- Command line
-
hf download hf://WWsCa/VetNet-LCPS/vetcopilot-app/src/components/Header.tsx
-
curl -L -o Header.tsx https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/Header.tsx
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> | |
| ) | |
| } | |