fix(admin): 通知分页、登录过期提示、附件打开反馈与导出统一 loading

- 通知中心改为游标分页 + 加载更多,历史通知不再被 50 条上限截断;
  加载失败显示错误态与重试
- AI 流式请求 401 被登出时,登录页提示"登录已过期",不再无声踢出
- AI 附件/引用来源打开失败时给出明确错误提示,不再"点了没反应"
- 新增 useDownload hook:统一导出/下载的防重复、loading 与成功/失败反馈;
  接入学生/账单/房间/入住/费用五个页面的模板下载与导出按钮
- 学生页移除不检查响应状态的私有下载实现,统一走 downloadBlob
This commit is contained in:
2026-08-07 17:33:58 +08:00
parent 67435e46ca
commit 00e2bc5acf
14 changed files with 320 additions and 153 deletions

View File

@@ -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>
);