Files
gongxue-base/apps/admin/src/pages/Notifications/index.tsx

215 lines
7.5 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, Grid, Select } from 'antd';
import {
BellOutlined,
DollarOutlined,
HomeOutlined,
TeamOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import api from '../../api';
import { message } from '../../ui/app-message';
import { formatNotificationText } from '../../utils/notification-display';
const { Sider, Content } = Layout;
const { useBreakpoint } = Grid;
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 FILTER_ITEMS: Array<{ key: string; icon: React.ReactNode; label: string }> = [
{ 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: '公告' },
];
const NotificationsPage: React.FC = () => {
const screens = useBreakpoint();
const isMobile = !screens.sm;
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 (e: any) {
console.error('加载通知失败', e);
message.error(e?.message || '加载通知失败');
}
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 (e: any) {
console.error('标记已读失败', e);
message.error(e?.message || '标记已读失败');
}
}
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 (e: any) {
console.error('全部已读失败', e);
message.error(e?.message || '操作失败');
}
};
const filtered =
filter === 'all' ? notifications : notifications.filter((n) => n.type === filter);
return (
<Layout className="notifications-layout" style={{ minHeight: '100%', background: '#fff' }}>
{!isMobile && (
<Sider width={180} style={{ background: '#fff', borderRight: '1px solid #f0f0f0' }}>
<Menu
mode="inline"
selectedKeys={[filter]}
onClick={({ key }) => setFilter(key)}
items={FILTER_ITEMS}
/>
</Sider>
)}
<Content className="notifications-content" style={{ padding: isMobile ? 0 : 24 }}>
<div className="notifications-header">
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button onClick={handleMarkAll}></Button>
</div>
{isMobile && (
<Select
value={filter}
onChange={setFilter}
options={FILTER_ITEMS.map((item) => ({ value: item.key, label: item.label }))}
className="notifications-filter"
/>
)}
<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
role="button"
tabIndex={0}
aria-label={`通知: ${item.title}`}
onClick={() => handleClick(item)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
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 wrap size={[8, 2]}>
<Typography.Text strong={!item.isRead} style={{ fontSize: 15 }}>
{formatNotificationText(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 }}
>
{formatNotificationText(item.content)}
</Typography.Paragraph>
)
}
/>
</List.Item>
);
}}
/>
)}
</Spin>
</Content>
</Layout>
);
};
export default NotificationsPage;