forked from wangziqi/gongxue-base
Squash merge PR #23. Included changes: - complete occupancy check-in required fields/default payload - improve responsive admin management pages - fix attendance edge cases and attendance period config - refine wallet/finance-related workflow handling Checks: - npm run typecheck -w apps/admin - npm run typecheck -w apps/server
211 lines
5.9 KiB
TypeScript
211 lines
5.9 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-dom';
|
|
import api from '../api';
|
|
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
|
|
|
interface NotificationItem {
|
|
id: number;
|
|
type: string;
|
|
title: string;
|
|
content: string;
|
|
link: string | null;
|
|
isRead: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
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 */
|
|
}
|
|
};
|
|
const openRef = useRef(open);
|
|
openRef.current = open;
|
|
const retryRef = useRef<number | null>(null);
|
|
|
|
// SSE connection — decoupled from popover open state
|
|
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 (openRef.current) fetchNotifications();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
es.onerror = () => {
|
|
es.close();
|
|
if (retryRef.current !== null) clearInterval(retryRef.current);
|
|
retryRef.current = window.setInterval(fetchUnread, 60_000);
|
|
};
|
|
return () => {
|
|
es.close();
|
|
clearInterval(retryRef.current ?? undefined);
|
|
retryRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
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;
|