fix(admin): 通知分页、登录过期提示、附件打开反馈与导出统一 loading
- 通知中心改为游标分页 + 加载更多,历史通知不再被 50 条上限截断; 加载失败显示错误态与重试 - AI 流式请求 401 被登出时,登录页提示"登录已过期",不再无声踢出 - AI 附件/引用来源打开失败时给出明确错误提示,不再"点了没反应" - 新增 useDownload hook:统一导出/下载的防重复、loading 与成功/失败反馈; 接入学生/账单/房间/入住/费用五个页面的模板下载与导出按钮 - 学生页移除不检查响应状态的私有下载实现,统一走 downloadBlob
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { notificationsSchema } from '../../api/schemas';
|
||||
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, Grid, Select } from 'antd';
|
||||
@@ -14,10 +13,13 @@ import { useNavigate } from 'react-router';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { formatNotificationText } from '../../utils/notification-display';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface NotificationItem {
|
||||
id: number;
|
||||
type: string;
|
||||
@@ -65,31 +67,55 @@ const NotificationsPage: React.FC = () => {
|
||||
const isMobile = !screens.sm;
|
||||
const [filter, setFilter] = useState('all');
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: notifications = [], isLoading, isFetching } = useQuery<NotificationItem[]>({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return validateResponse<NotificationItem[]>(
|
||||
notificationsSchema,
|
||||
await api.get('/notifications?limit=50'),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error('加载通知失败', e);
|
||||
message.error(e?.message || '加载通知失败');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
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`);
|
||||
queryClient.setQueryData<NotificationItem[]>(['notifications'], (prev) =>
|
||||
(prev ?? []).map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error('标记已读失败', e);
|
||||
@@ -102,9 +128,7 @@ const NotificationsPage: React.FC = () => {
|
||||
const handleMarkAll = async () => {
|
||||
try {
|
||||
await api.put('/notifications/read-all');
|
||||
queryClient.setQueryData<NotificationItem[]>(['notifications'], (prev) =>
|
||||
(prev ?? []).map((n) => ({ ...n, isRead: true })),
|
||||
);
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
|
||||
} catch (e: any) {
|
||||
console.error('全部已读失败', e);
|
||||
message.error(e?.message || '操作失败');
|
||||
@@ -141,76 +165,91 @@ const NotificationsPage: React.FC = () => {
|
||||
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 }}
|
||||
{error && !loading ? (
|
||||
<QueryErrorState
|
||||
title="通知加载失败"
|
||||
description="请检查网络后重试。"
|
||||
onRetry={() => void loadPage()}
|
||||
/>
|
||||
) : (
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{formatNotificationText(item.content)}
|
||||
</Typography.Paragraph>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user