feat: add useNotifications hook and NotificationBell component

This commit is contained in:
2026-07-05 23:18:18 +08:00
parent adb875976a
commit f47b7aaf87
2 changed files with 286 additions and 0 deletions

View File

@@ -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<string, string> = {
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<NotificationItem[]>([]);
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 = (
<div style={{ width: 380, maxHeight: 480 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
borderBottom: '1px solid #f0f0f0',
}}
>
<Typography.Text strong></Typography.Text>
<Button type="link" size="small" onClick={handleMarkAll}>
</Button>
</div>
{notifications.length === 0 ? (
<div style={{ padding: 40 }}>
<Empty description="暂无通知" />
</div>
) : (
<List
style={{ maxHeight: 380, overflow: 'auto' }}
dataSource={notifications}
renderItem={(item) => (
<List.Item
onClick={() => handleClick(item)}
style={{
padding: '12px 16px',
cursor: 'pointer',
backgroundColor: item.isRead ? 'transparent' : '#f0f7ff',
}}
>
<List.Item.Meta
avatar={
!item.isRead && (
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: '#007aff',
marginTop: 6,
}}
/>
)
}
title={
<Typography.Text
strong={!item.isRead}
style={{ fontSize: 14 }}
>
[{typeLabels[item.type] || item.type}] {item.title}
</Typography.Text>
}
description={
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
>
{timeAgo(item.createdAt)}
</Typography.Text>
}
/>
</List.Item>
)}
/>
)}
<div
style={{
borderTop: '1px solid #f0f0f0',
padding: '8px 16px',
textAlign: 'center',
}}
>
<Button
type="link"
size="small"
onClick={() => {
setOpen(false);
navigate('/notifications');
}}
>
</Button>
</div>
</div>
);
return (
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={handleOpen}
placement="bottomRight"
>
<Badge count={unreadCount} size="small" offset={[-2, 2]}>
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
</Badge>
</Popover>
);
};
export default NotificationBell;