fix: harden notification polling and simplify pagination controls
This commit is contained in:
+108
-96
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
@@ -35,6 +35,7 @@ import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/ses
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
import { request } from '@/api/core/httpClient';
|
||||
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
|
||||
|
||||
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||
enterpriseCertifications: 0,
|
||||
@@ -44,92 +45,95 @@ const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||
drainageInfos: 0,
|
||||
};
|
||||
|
||||
type NotificationValue<T> = { value: T; status: 'loading' | 'ready' | 'unavailable'; hasValue: boolean };
|
||||
|
||||
function initialNotification<T>(value: T): NotificationValue<T> {
|
||||
return { value, status: 'loading', hasValue: false };
|
||||
}
|
||||
|
||||
function updateNotification<T>(
|
||||
previous: NotificationValue<T>,
|
||||
result: PromiseSettledResult<T>,
|
||||
available = true,
|
||||
): NotificationValue<T> {
|
||||
return result.status === 'fulfilled' && available
|
||||
? { value: result.value, status: 'ready', hasValue: true }
|
||||
: { ...previous, status: 'unavailable' };
|
||||
}
|
||||
|
||||
function notificationDescription<T>(notification: NotificationValue<T>, description: string) {
|
||||
if (notification.status === 'loading') return '计数加载中';
|
||||
if (notification.status === 'unavailable') {
|
||||
return notification.hasValue ? '计数暂不可用,显示上次结果' : '计数暂不可用,尚未取得结果';
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
function canPollNotifications(userId: string) {
|
||||
const session = readSession('admin');
|
||||
return Boolean(
|
||||
session &&
|
||||
session.user.id === userId &&
|
||||
!session.locked &&
|
||||
Date.now() - getLastUserActivityAt() < session.idleTimeoutSeconds * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminLayout() {
|
||||
return (
|
||||
<PortalSessionBoundary portal="admin">
|
||||
{(session) => <AdminAuthenticatedLayout session={session} />}
|
||||
{(session) => <AdminAuthenticatedLayout key={session.user.id} session={session} />}
|
||||
</PortalSessionBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
||||
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
|
||||
const [reportSummary, setReportSummary] = useState({ count: 0, unavailable: false });
|
||||
const [monitorSummary, setMonitorSummary] = useState({ count: 0, unavailable: false });
|
||||
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||
const [pendingAudits, setPendingAudits] = useState(() => initialNotification(EMPTY_PENDING_AUDITS));
|
||||
const [retirementSummary, setRetirementSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [reportSummary, setReportSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [monitorSummary, setMonitorSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [securityAlertSummary, setSecurityAlertSummary] = useState(() =>
|
||||
initialNotification({ count: 0, criticalCount: 0 }),
|
||||
);
|
||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState(() =>
|
||||
initialNotification({ count: 0, criticalCount: 0 }),
|
||||
);
|
||||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
const currentSession = readSession('admin');
|
||||
if (
|
||||
!currentSession ||
|
||||
currentSession.locked ||
|
||||
Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const canPoll = useCallback(() => canPollNotifications(session.user.id), [session.user.id]);
|
||||
const loadPendingAuditCount = useCallback(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||||
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
||||
Promise.allSettled([
|
||||
adminApi.getPendingAudits(),
|
||||
adminApi.getSignatureRetirementUnreadCount(),
|
||||
adminApi.getSecurityNotificationSummary(),
|
||||
adminApi.getInfrastructureMonitoringNotificationSummary(),
|
||||
request<{ count: number }>('/admin/report-notifications/summary'),
|
||||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary'),
|
||||
])
|
||||
.then(([audits, retirement, security, infrastructure, reporting, monitor]) => {
|
||||
setMonitorSummary((previous) =>
|
||||
monitor.status === 'fulfilled'
|
||||
? { count: monitor.value.count, unavailable: Boolean(monitor.value.unavailable) }
|
||||
: { ...previous, unavailable: true },
|
||||
);
|
||||
setReportSummary((previous) =>
|
||||
reporting.status === 'fulfilled'
|
||||
? { count: reporting.value.count, unavailable: false }
|
||||
: { ...previous, unavailable: true },
|
||||
);
|
||||
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
|
||||
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
|
||||
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
|
||||
setInfrastructureAlertSummary(
|
||||
infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 },
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setPendingAudits(EMPTY_PENDING_AUDITS);
|
||||
setRetirementUnreadCount(0);
|
||||
setSecurityAlertSummary({ count: 0, criticalCount: 0 });
|
||||
setInfrastructureAlertSummary({ count: 0, criticalCount: 0 });
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
settleNotificationRequest(adminApi.getPendingAudits(undefined, signal), signal),
|
||||
settleNotificationRequest(adminApi.getSignatureRetirementUnreadCount(signal), signal),
|
||||
settleNotificationRequest(adminApi.getSecurityNotificationSummary(signal), signal),
|
||||
settleNotificationRequest(adminApi.getInfrastructureMonitoringNotificationSummary(signal), signal),
|
||||
settleNotificationRequest(request<{ count: number }>('/admin/report-notifications/summary', { signal }), signal),
|
||||
settleNotificationRequest(
|
||||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary', { signal }),
|
||||
signal,
|
||||
),
|
||||
]);
|
||||
if (!isCurrent()) return false;
|
||||
const [audits, retirement, security, infrastructure, reporting, monitor] = results;
|
||||
const monitorAvailable = monitor.status === 'fulfilled' && !monitor.value.unavailable;
|
||||
setPendingAudits((previous) => updateNotification(previous, audits));
|
||||
setRetirementSummary((previous) => updateNotification(previous, retirement));
|
||||
setSecurityAlertSummary((previous) => updateNotification(previous, security));
|
||||
setInfrastructureAlertSummary((previous) => updateNotification(previous, infrastructure));
|
||||
setReportSummary((previous) => updateNotification(previous, reporting));
|
||||
setMonitorSummary((previous) => updateNotification(previous, monitor, monitorAvailable));
|
||||
return results.every((result) => result.status === 'fulfilled') && monitorAvailable;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.portal !== 'admin' || sessionLocked) {
|
||||
return;
|
||||
}
|
||||
loadPendingAuditCount();
|
||||
const timer = window.setInterval(loadPendingAuditCount, 30000);
|
||||
const onFocus = () => loadPendingAuditCount();
|
||||
const onAuditRefresh = () => loadPendingAuditCount();
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
||||
};
|
||||
}, [loadPendingAuditCount, session.portal, sessionLocked]);
|
||||
useNotificationPolling({
|
||||
enabled: session.portal === 'admin' && !sessionLocked,
|
||||
canPoll,
|
||||
poll: loadPendingAuditCount,
|
||||
});
|
||||
const auditLabel = (label: string) =>
|
||||
pendingAudits.status === 'ready'
|
||||
? label
|
||||
: `${label}(${pendingAudits.status === 'loading' ? '计数加载中' : '计数暂不可用'})`;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -144,14 +148,14 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
reportingNotifications={[
|
||||
{
|
||||
label: '报备状态变化通知',
|
||||
count: reportSummary.count,
|
||||
description: reportSummary.unavailable ? '消息计数暂不可用,请重试' : '按企业与小时汇总的未读消息',
|
||||
count: reportSummary.value.count,
|
||||
description: notificationDescription(reportSummary, '按企业与小时汇总的未读消息'),
|
||||
to: '/admin/report-records?tab=readiness',
|
||||
},
|
||||
{
|
||||
label: '签名清退预警',
|
||||
count: retirementUnreadCount,
|
||||
description: '今日未读且未抑制',
|
||||
count: retirementSummary.value.count,
|
||||
description: notificationDescription(retirementSummary, '今日未读且未抑制'),
|
||||
to: '/admin/signature-retirement',
|
||||
},
|
||||
{
|
||||
@@ -163,40 +167,48 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
alertNotifications={[
|
||||
{
|
||||
label: '发送质量告警',
|
||||
count: monitorSummary.count,
|
||||
description: monitorSummary.unavailable ? '告警计数暂不可用,请重试' : '未读活动发送质量告警',
|
||||
count: monitorSummary.value.count,
|
||||
description: notificationDescription(monitorSummary, '未读活动发送质量告警'),
|
||||
to: '/admin/monitor?tab=alerts',
|
||||
},
|
||||
{
|
||||
label: '安全检测与封禁',
|
||||
count: securityAlertSummary.count,
|
||||
description:
|
||||
securityAlertSummary.criticalCount > 0
|
||||
? `${securityAlertSummary.criticalCount} 条严重告警待处置`
|
||||
count: securityAlertSummary.value.count,
|
||||
description: notificationDescription(
|
||||
securityAlertSummary,
|
||||
securityAlertSummary.value.criticalCount > 0
|
||||
? `${securityAlertSummary.value.criticalCount} 条严重告警待处置`
|
||||
: '待处置安全告警',
|
||||
),
|
||||
to: '/admin/security-detection',
|
||||
},
|
||||
{
|
||||
label: '系统监控告警',
|
||||
count: infrastructureAlertSummary.count,
|
||||
description:
|
||||
infrastructureAlertSummary.criticalCount > 0
|
||||
? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警`
|
||||
count: infrastructureAlertSummary.value.count,
|
||||
description: notificationDescription(
|
||||
infrastructureAlertSummary,
|
||||
infrastructureAlertSummary.value.criticalCount > 0
|
||||
? `${infrastructureAlertSummary.value.criticalCount} 条 Prometheus 严重告警`
|
||||
: 'Prometheus 活动告警',
|
||||
),
|
||||
to: '/admin/system-monitoring#active-alerts',
|
||||
},
|
||||
]}
|
||||
auditNotifications={[
|
||||
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
|
||||
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
|
||||
{ label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' },
|
||||
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/signatures' },
|
||||
{
|
||||
label: '签名导入待审',
|
||||
count: pendingAudits.signatureImports ?? 0,
|
||||
label: auditLabel('企业认证待审'),
|
||||
count: pendingAudits.value.enterpriseCertifications,
|
||||
to: '/admin/enterprise-audit',
|
||||
},
|
||||
{ label: auditLabel('短信审核待审'), count: pendingAudits.value.smsAudits, to: '/admin/sms-audit' },
|
||||
{ label: auditLabel('模板待审'), count: pendingAudits.value.templates, to: '/admin/templates' },
|
||||
{ label: auditLabel('签名待审'), count: pendingAudits.value.signatures, to: '/admin/signatures' },
|
||||
{
|
||||
label: auditLabel('签名导入待审'),
|
||||
count: pendingAudits.value.signatureImports ?? 0,
|
||||
to: '/admin/signatures?tab=import',
|
||||
},
|
||||
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
|
||||
{ label: auditLabel('引流信息待审'), count: pendingAudits.value.drainageInfos, to: '/admin/drainage-audits' },
|
||||
]}
|
||||
navSections={[
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user