feat: add useNotifications hook and NotificationBell component
This commit is contained in:
210
apps/admin/src/components/NotificationBell.tsx
Normal file
210
apps/admin/src/components/NotificationBell.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user