Files
gongxue-base/apps/admin/src/components/NotificationBell.tsx

209 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import dayjs from 'dayjs';
import { useInterval } from 'usehooks-ts';
import api from '../api';
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
import { useUserStore } from '../store/user/userStore';
interface NotificationItem {
id: number;
type: string;
title: string;
content: string;
link: string | null;
isRead: boolean;
createdAt: string;
}
function timeAgo(dateStr: string): string {
return dayjs(dateStr).fromNow();
}
const NotificationBell: React.FC = () => {
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [open, setOpen] = useState(false);
const [sseDown, setSseDown] = useState(false);
const navigate = useNavigate();
const fetchNotifications = async () => {
try {
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
setNotifications(data);
} catch {
/* ignore */
}
};
const fetchUnread = async () => {
try {
const data = await api.get<{ count: number }>('/notifications/unread-count');
setUnreadCount(data.count);
} catch {
/* ignore */
}
};
const openRef = useRef(open);
openRef.current = open;
useInterval(() => {
void fetchUnread();
}, sseDown ? 60_000 : null);
// SSE connection — decoupled from popover open state
useEffect(() => {
fetchUnread();
const token = useUserStore.getState().token;
if (!token) return;
const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`);
es.onopen = () => setSseDown(false);
es.onmessage = (event) => {
try {
JSON.parse(event.data);
setUnreadCount((c) => c + 1);
if (openRef.current) fetchNotifications();
} catch {
/* ignore */
}
};
es.onerror = () => {
// 不主动关闭EventSource 会自动重连,主动关闭会导致一次超时后实时通知永久断流
setSseDown(true);
};
return () => {
es.close();
setSseDown(false);
};
}, []);
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 }}>
[{notificationTypeLabels[item.type] || item.type}]{' '}
{formatNotificationText(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;