import React, { useState, useEffect, useRef } from 'react'; import { Badge, Popover, Button, List, Typography, Empty } from 'antd'; import { BellOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import api from '../api'; import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display'; interface NotificationItem { id: number; type: string; title: string; content: string; link: string | null; isRead: boolean; createdAt: string; } function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return '刚刚'; if (mins < 60) return `${mins}分钟前`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}小时前`; const days = Math.floor(hours / 24); return `${days}天前`; } const NotificationBell: React.FC = () => { const [unreadCount, setUnreadCount] = useState(0); const [notifications, setNotifications] = useState([]); const [open, setOpen] = useState(false); const navigate = useNavigate(); const fetchNotifications = async () => { try { const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[]; setNotifications(data); } catch { /* ignore */ } }; const fetchUnread = async () => { try { const data = (await api.get('/notifications/unread-count')) as unknown as { count: number }; setUnreadCount(data.count); } catch { /* ignore */ } }; const openRef = useRef(open); openRef.current = open; const retryRef = useRef(null); // SSE connection — decoupled from popover open state useEffect(() => { fetchUnread(); const token = localStorage.getItem('token'); if (!token) return; const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); es.onmessage = (event) => { try { JSON.parse(event.data); setUnreadCount((c) => c + 1); if (openRef.current) fetchNotifications(); } catch { /* ignore */ } }; es.onerror = () => { es.close(); if (retryRef.current !== null) clearInterval(retryRef.current); retryRef.current = window.setInterval(fetchUnread, 60_000); }; return () => { es.close(); clearInterval(retryRef.current ?? undefined); retryRef.current = null; }; }, []); const handleOpen = (visible: boolean) => { setOpen(visible); if (visible) fetchNotifications(); }; const handleClick = async (item: NotificationItem) => { if (!item.isRead) { try { await api.put(`/notifications/${item.id}/read`); setUnreadCount((c) => Math.max(0, c - 1)); } catch { /* ignore */ } } setOpen(false); if (item.link) navigate(item.link); }; const handleMarkAll = async () => { try { await api.put('/notifications/read-all'); setUnreadCount(0); setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); } catch { /* ignore */ } }; const content = (
通知中心
{notifications.length === 0 ? (
) : ( ( handleClick(item)} style={{ padding: '12px 16px', cursor: 'pointer', backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', }} > ) } title={ [{notificationTypeLabels[item.type] || item.type}]{' '} {formatNotificationText(item.title)} } description={ {timeAgo(item.createdAt)} } /> )} /> )}
); return ( ); }; export default NotificationBell;