209 lines
5.7 KiB
TypeScript
209 lines
5.7 KiB
TypeScript
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;
|