Files
gongxue-base/apps/admin/src/components/NotificationBell.tsx
wangziqi 375c7ec60b feat: refine admin forms, attendance and finance workflows
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
2026-07-18 12:54:10 +00:00

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;