Download vetcopilot-app/src/components/NotificationBell.tsx from WWsCa/VetNet-LCPS: direct link, hf CLI and curl.
- Browser
- Download file 8.6 kB
-
https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/NotificationBell.tsx
- Command line
-
hf download hf://WWsCa/VetNet-LCPS/vetcopilot-app/src/components/NotificationBell.tsx
-
curl -L -o NotificationBell.tsx https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/NotificationBell.tsx
8.6 kB
| import React, { useState, useEffect, useCallback, useRef } from 'react' | |
| import { useNavigate } from 'react-router-dom' | |
| import { useTranslation } from '../i18n' | |
| import { | |
| getInAppNotifications, | |
| getInAppUnreadCount, | |
| markInAppAsRead, | |
| markAllInAppRead, | |
| type InAppNotificationData, | |
| } from '../api' | |
| /** 格式化相对时间 */ | |
| function formatRelativeTime(timestamp: string, locale: string): string { | |
| if (!timestamp) return '' | |
| const diff = Date.now() - new Date(timestamp).getTime() | |
| 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}天前` | |
| } | |
| const TYPE_ICONS: Record<string, string> = { | |
| appointment: '\u{1F4C5}', | |
| patient: '\u{1F43E}', | |
| vaccine: '\u{1F489}', | |
| bill: '\u{1F4B0}', | |
| system: '\u{1F514}', | |
| } | |
| export default function NotificationBell() { | |
| const { t, locale } = useTranslation() | |
| const navigate = useNavigate() | |
| const [unreadCount, setUnreadCount] = useState(0) | |
| const [open, setOpen] = useState(false) | |
| const [notifications, setNotifications] = useState<InAppNotificationData[]>([]) | |
| const [loading, setLoading] = useState(false) | |
| const dropdownRef = useRef<HTMLDivElement>(null) | |
| // 加载未读数 | |
| const loadUnreadCount = useCallback(async () => { | |
| try { | |
| const data = await getInAppUnreadCount() | |
| setUnreadCount(data.count) | |
| } catch { | |
| // 静默失败 | |
| } | |
| }, []) | |
| // 加载通知列表 | |
| const loadNotifications = useCallback(async () => { | |
| setLoading(true) | |
| try { | |
| const res = await getInAppNotifications({ page: 1, page_size: 10 }) | |
| setNotifications(res.items) | |
| } catch { | |
| // 静默失败 | |
| } finally { | |
| setLoading(false) | |
| } | |
| }, []) | |
| // 打开面板时加载 | |
| useEffect(() => { | |
| if (open) { | |
| loadNotifications() | |
| } | |
| }, [open, loadNotifications]) | |
| // 定时轮询未读数 | |
| useEffect(() => { | |
| loadUnreadCount() | |
| const interval = setInterval(loadUnreadCount, 30_000) | |
| return () => clearInterval(interval) | |
| }, [loadUnreadCount]) | |
| // 点击外部关闭 | |
| useEffect(() => { | |
| const handler = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpen(false) | |
| } | |
| } | |
| if (open) { | |
| document.addEventListener('mousedown', handler) | |
| } | |
| return () => document.removeEventListener('mousedown', handler) | |
| }, [open]) | |
| // 点击通知 | |
| const handleClickNotification = async (notif: InAppNotificationData) => { | |
| if (!notif.is_read) { | |
| try { | |
| await markInAppAsRead(notif.id) | |
| setUnreadCount((c) => Math.max(0, c - 1)) | |
| setNotifications((prev) => | |
| prev.map((n) => (n.id === notif.id ? { ...n, is_read: true } : n)) | |
| ) | |
| } catch { | |
| // 静默失败 | |
| } | |
| } | |
| if (notif.link) { | |
| navigate(notif.link) | |
| } | |
| setOpen(false) | |
| } | |
| // 全部已读 | |
| const handleMarkAllRead = async () => { | |
| try { | |
| await markAllInAppRead() | |
| setUnreadCount(0) | |
| setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true }))) | |
| } catch { | |
| // 静默失败 | |
| } | |
| } | |
| return ( | |
| <div ref={dropdownRef} className="relative"> | |
| {/* 铃铛按钮 */} | |
| <button | |
| onClick={() => setOpen(!open)} | |
| className="relative p-1.5 hover:bg-gray-100 rounded-lg text-gray-500 hover:text-gray-700 transition-colors min-h-[36px] min-w-[36px] flex items-center justify-center" | |
| aria-label={t('notifications.bellLabel') || '通知'} | |
| aria-haspopup="true" | |
| aria-expanded={open} | |
| > | |
| <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" /> | |
| </svg> | |
| {unreadCount > 0 && ( | |
| <span | |
| className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center" | |
| style={{ animation: 'count-badge 0.4s cubic-bezier(.34,1.56,.64,1)' }} | |
| > | |
| {unreadCount > 99 ? '99+' : unreadCount} | |
| </span> | |
| )} | |
| </button> | |
| {/* 下拉面板 */} | |
| {open && ( | |
| <div | |
| className="absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl z-50 overflow-hidden dropdown-enter origin-top-right" | |
| role="menu" | |
| aria-label={t('notifications.panelTitle') || '通知'} | |
| > | |
| {/* 头部 */} | |
| <div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between"> | |
| <h3 className="text-sm font-semibold text-gray-800"> | |
| {t('notifications.panelTitle') || '通知'} | |
| </h3> | |
| {unreadCount > 0 && ( | |
| <button | |
| onClick={handleMarkAllRead} | |
| className="text-xs text-medical-600 hover:text-medical-700 font-medium transition-colors" | |
| > | |
| {t('notifications.markAllRead') || '全部已读'} | |
| </button> | |
| )} | |
| </div> | |
| {/* 列表 */} | |
| <div className="max-h-80 overflow-y-auto"> | |
| {loading ? ( | |
| <div className="flex items-center justify-center py-8"> | |
| <div className="w-5 h-5 border-2 border-medical-200 border-t-medical-600 rounded-full animate-spin" /> | |
| </div> | |
| ) : notifications.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-8 text-gray-400"> | |
| <svg className="w-8 h-8 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" /> | |
| </svg> | |
| <p className="text-xs">{t('notifications.noInApp') || '暂无通知'}</p> | |
| </div> | |
| ) : ( | |
| notifications.map((n) => ( | |
| <button | |
| key={n.id} | |
| onClick={() => handleClickNotification(n)} | |
| className={`w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors border-b border-gray-50 last:border-0 ${ | |
| !n.is_read ? 'bg-medical-50/50' : '' | |
| }`} | |
| > | |
| <div className="flex items-start gap-2.5"> | |
| <span className="text-lg leading-none mt-0.5 shrink-0"> | |
| {TYPE_ICONS[n.type] || TYPE_ICONS.system} | |
| </span> | |
| <div className="min-w-0 flex-1"> | |
| <div className="flex items-center gap-2"> | |
| <p className={`text-sm truncate ${!n.is_read ? 'font-semibold text-gray-800' : 'text-gray-600'}`}> | |
| {n.title} | |
| </p> | |
| {!n.is_read && ( | |
| <span className="w-2 h-2 rounded-full bg-medical-500 shrink-0" /> | |
| )} | |
| </div> | |
| <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{n.message}</p> | |
| <p className="text-[10px] text-gray-400 mt-1"> | |
| {formatRelativeTime(n.created_at, locale)} | |
| </p> | |
| </div> | |
| </div> | |
| </button> | |
| )) | |
| )} | |
| </div> | |
| {/* 底部 */} | |
| <div className="px-4 py-2 border-t border-gray-100 bg-gray-50/50"> | |
| <button | |
| onClick={() => { navigate('/notifications'); setOpen(false) }} | |
| className="w-full text-center text-xs text-medical-600 hover:text-medical-700 font-medium py-1 transition-colors" | |
| > | |
| {t('notifications.viewAll') || '查看全部'} | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |