256 lines
9.1 KiB
TypeScript
256 lines
9.1 KiB
TypeScript
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<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() - 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: <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 [filter, setFilter] = useState('all');
|
||
const navigate = useNavigate();
|
||
|
||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||
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<NotificationItem[]>(
|
||
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 (
|
||
<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"
|
||
/>
|
||
)}
|
||
{error && !loading ? (
|
||
<QueryErrorState
|
||
title="通知加载失败"
|
||
description="请检查网络后重试。"
|
||
onRetry={() => void loadPage()}
|
||
/>
|
||
) : (
|
||
<Spin spinning={loading}>
|
||
{filtered.length === 0 ? (
|
||
<QueryEmpty 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>
|
||
);
|
||
}}
|
||
/>
|
||
)}
|
||
{filtered.length > 0 && hasMore && (
|
||
<div style={{ textAlign: 'center', padding: '16px 0' }}>
|
||
<Button loading={loadingMore} onClick={loadMore}>
|
||
加载更多
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</Spin>
|
||
)}
|
||
</Content>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default NotificationsPage;
|