diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx new file mode 100644 index 0000000..87d7e69 --- /dev/null +++ b/apps/admin/src/components/NotificationBell.tsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect } 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'; + +interface NotificationItem { + id: number; + type: string; + title: string; + content: string; + link: string | null; + isRead: boolean; + createdAt: string; +} + +const typeLabels: Record = { + bill_generated: '账单', + bill_paid: '账单', + check_in: '入住', + check_out: '退宿', + deposit_due: '押金', + deposit_refunded: '押金', + class_change: '班级', + schedule_conflict: '排课', + announcement: '公告', +}; + +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 */ } + }; + + 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 (open) fetchNotifications(); + } catch { /* ignore */ } + }; + es.onerror = () => { + es.close(); + const interval = setInterval(fetchUnread, 60_000); + return () => clearInterval(interval); + }; + return () => es.close(); + }, [open]); + + 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={ + + [{typeLabels[item.type] || item.type}] {item.title} + + } + description={ + + {timeAgo(item.createdAt)} + + } + /> + + )} + /> + )} +
+ +
+
+ ); + + return ( + + + + + + ); +}; + +export default NotificationBell; diff --git a/apps/admin/src/hooks/useNotifications.ts b/apps/admin/src/hooks/useNotifications.ts new file mode 100644 index 0000000..deb3e85 --- /dev/null +++ b/apps/admin/src/hooks/useNotifications.ts @@ -0,0 +1,76 @@ +import { useState, useEffect, useCallback } from 'react'; +import api from '../api'; + +interface Notification { + id: number; + type: string; + title: string; + content: string; + link: string | null; + createdAt: string; +} + +export function useNotifications() { + const [unreadCount, setUnreadCount] = useState(0); + const [latestNotification, setLatestNotification] = useState(null); + + const fetchUnreadCount = useCallback(async () => { + try { + const data = await api.get('/notifications/unread-count') as unknown as { count: number }; + setUnreadCount(data.count); + } catch { + // silent + } + }, []); + + useEffect(() => { + fetchUnreadCount(); + + const token = localStorage.getItem('token'); + if (!token) return; + + const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); + + es.onmessage = (event) => { + try { + const notification = JSON.parse(event.data) as Notification; + setUnreadCount((c) => c + 1); + setLatestNotification(notification); + } catch { + // ignore parse errors + } + }; + + es.onerror = () => { + es.close(); + const interval = setInterval(() => { + fetchUnreadCount(); + }, 60_000); + return () => clearInterval(interval); + }; + + return () => { + es.close(); + }; + }, [fetchUnreadCount]); + + const markAsRead = useCallback(async (id: number) => { + try { + await api.put(`/notifications/${id}/read`); + setUnreadCount((c) => Math.max(0, c - 1)); + } catch { + // silent + } + }, []); + + const markAllAsRead = useCallback(async () => { + try { + await api.put('/notifications/read-all'); + setUnreadCount(0); + } catch { + // silent + } + }, []); + + return { unreadCount, latestNotification, markAsRead, markAllAsRead }; +}