feat: add Notifications full page with sidebar filter
This commit is contained in:
183
apps/admin/src/pages/Notifications/index.tsx
Normal file
183
apps/admin/src/pages/Notifications/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space } from 'antd';
|
||||
import {
|
||||
BellOutlined,
|
||||
DollarOutlined,
|
||||
HomeOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
interface NotificationItem {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
link: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const typeMap: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
bill_generated: { label: '账单', icon: <DollarOutlined /> },
|
||||
bill_paid: { label: '账单', icon: <DollarOutlined /> },
|
||||
check_in: { label: '入住', icon: <HomeOutlined /> },
|
||||
check_out: { label: '退宿', icon: <HomeOutlined /> },
|
||||
deposit_due: { label: '押金', icon: <DollarOutlined /> },
|
||||
deposit_refunded: { label: '押金', icon: <DollarOutlined /> },
|
||||
class_change: { label: '班级', icon: <TeamOutlined /> },
|
||||
schedule_conflict: { label: '排课', icon: <BellOutlined /> },
|
||||
announcement: { label: '公告', icon: <SettingOutlined /> },
|
||||
};
|
||||
|
||||
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);
|
||||
if (days < 7) return `${days}天前`;
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
const NotificationsPage: React.FC = () => {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[];
|
||||
setNotifications(data);
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleClick = async (item: NotificationItem) => {
|
||||
if (!item.isRead) {
|
||||
try {
|
||||
await api.put(`/notifications/${item.id}/read`);
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (item.link) navigate(item.link);
|
||||
};
|
||||
|
||||
const handleMarkAll = async () => {
|
||||
try {
|
||||
await api.put('/notifications/read-all');
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => ({ ...n, isRead: true })),
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? notifications
|
||||
: notifications.filter((n) => n.type === filter);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100%', background: '#fff' }}>
|
||||
<Sider width={180} style={{ background: '#fff', borderRight: '1px solid #f0f0f0' }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[filter]}
|
||||
onClick={({ key }) => setFilter(key)}
|
||||
items={[
|
||||
{ key: 'all', icon: <BellOutlined />, label: '全部' },
|
||||
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
|
||||
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' },
|
||||
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' },
|
||||
{ key: 'announcement', icon: <SettingOutlined />, label: '公告' },
|
||||
]}
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>通知中心</Typography.Title>
|
||||
<Button onClick={handleMarkAll}>全部已读</Button>
|
||||
</div>
|
||||
<Spin spinning={loading}>
|
||||
{filtered.length === 0 ? (
|
||||
<Empty description="暂无通知" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={filtered}
|
||||
renderItem={(item) => {
|
||||
const meta = typeMap[item.type] || { label: item.type, icon: <BellOutlined /> };
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => handleClick(item)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '16px 0',
|
||||
backgroundColor: item.isRead ? 'transparent' : '#f0f7ff',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{meta.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<Space>
|
||||
<Typography.Text
|
||||
strong={!item.isRead}
|
||||
style={{ fontSize: 15 }}
|
||||
>
|
||||
{item.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{timeAgo(item.createdAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
item.content && (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 1 }}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{item.content}
|
||||
</Typography.Paragraph>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
Reference in New Issue
Block a user