import React, { useCallback, useEffect, useState } from 'react'; import dayjs from 'dayjs'; import { validateResponse } from '../../utils/validate'; import { notificationsSchema } from '../../api/schemas'; import { List, Typography, Menu, Layout, Button, Spin, Space, Grid, Select } from 'antd'; import { BellOutlined, DollarOutlined, HomeOutlined, TeamOutlined, SettingOutlined, } from '@ant-design/icons'; import { useNavigate } from 'react-router'; import api from '../../api'; import { message } from '../../ui/app-message'; import { formatNotificationText } from '../../utils/notification-display'; import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; const { Sider, Content } = Layout; const { useBreakpoint } = Grid; const PAGE_SIZE = 50; interface NotificationItem { id: number; type: string; title: string; content: string; link: string | null; isRead: boolean; createdAt: string; } const typeMap: Record = { bill_generated: { label: '账单', icon: }, bill_paid: { label: '账单', icon: }, check_in: { label: '入住', icon: }, check_out: { label: '退宿', icon: }, deposit_due: { label: '押金', icon: }, deposit_refunded: { label: '押金', icon: }, class_change: { label: '班级', icon: }, schedule_conflict: { label: '排课', icon: }, announcement: { label: '公告', icon: }, }; function timeAgo(dateStr: string): string { const diff = Date.now() - dayjs(dateStr).valueOf(); if (diff < 60_000) return '刚刚'; // 7 天内用相对时间(dayjs relativeTime 已全局配置),更早显示具体日期 if (diff < 7 * 86_400_000) return dayjs(dateStr).fromNow(); return dayjs(dateStr).format('YYYY/M/D'); } const FILTER_ITEMS: Array<{ key: string; icon: React.ReactNode; label: string }> = [ { key: 'all', icon: , label: '全部' }, { key: 'bill_generated', icon: , label: '账单' }, { key: 'check_in', icon: , label: '入住' }, { key: 'class_change', icon: , label: '班级' }, { key: 'announcement', icon: , label: '公告' }, ]; const NotificationsPage: React.FC = () => { const screens = useBreakpoint(); const isMobile = !screens.sm; const [filter, setFilter] = useState('all'); const navigate = useNavigate(); const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(false); const [hasMore, setHasMore] = useState(true); const loadPage = useCallback(async (after?: number) => { if (after === undefined) { setLoading(true); } else { setLoadingMore(true); } setError(false); try { const params = after !== undefined ? `?after=${after}&limit=${PAGE_SIZE}` : `?limit=${PAGE_SIZE}`; const res = validateResponse( notificationsSchema, await api.get(`/notifications${params}`), ); setNotifications((prev) => (after === undefined ? res : [...prev, ...res])); setHasMore(res.length === PAGE_SIZE); } catch (e: any) { console.error('加载通知失败', e); setError(true); } finally { setLoading(false); setLoadingMore(false); } }, []); useEffect(() => { void loadPage(); }, [loadPage]); const loadMore = () => { const last = notifications[notifications.length - 1]; if (!last || loadingMore || loading) return; // 列表按 createdAt DESC 排序,最后一条 id 最小,作为下一页游标 void loadPage(last.id); }; 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 ( {!isMobile && ( setFilter(key)} items={FILTER_ITEMS} /> )}
通知中心
{isMobile && (