fix: harden notification polling and simplify pagination controls

This commit is contained in:
hectorzhao
2026-09-08 21:44:08 +08:00
parent ebb185b22b
commit 6816f56178
15 changed files with 897 additions and 141 deletions
+22 -5
View File
@@ -1,11 +1,28 @@
import { request, withQuery } from '../core/httpClient';
import type { InfrastructureAlertSettings, InfrastructureAlertThresholds, InfrastructureMonitoringOverview, InfrastructureMonitoringRange } from '../types';
import type {
InfrastructureAlertSettings,
InfrastructureAlertThresholds,
InfrastructureMonitoringOverview,
InfrastructureMonitoringRange,
} from '../types';
export const adminInfrastructureMonitoringApi = {
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
getInfrastructureMonitoringNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary'),
getInfrastructureAlertThresholds: () => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds', { method: 'PUT', body: JSON.stringify(body) }),
markInfrastructureAlertRead: (fingerprint: string, activeAt: string) => request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`, { method: 'POST', body: JSON.stringify({ activeAt }) }),
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary', {
signal,
}),
getInfrastructureAlertThresholds: () =>
request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) =>
request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds', {
method: 'PUT',
body: JSON.stringify(body),
}),
markInfrastructureAlertRead: (fingerprint: string, activeAt: string) =>
request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(
`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`,
{ method: 'POST', body: JSON.stringify({ activeAt }) },
),
};
+2 -2
View File
@@ -39,8 +39,8 @@ import type {
export const adminOperationsApi = {
getDashboard: (tenantId?: string) =>
request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
getPendingAudits: (tenantId?: string) =>
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
getPendingAudits: (tenantId?: string, signal?: AbortSignal) =>
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }),
getSendQuality: (date?: string) =>
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
+28 -8
View File
@@ -2,15 +2,35 @@ import { request, withQuery } from '../core/httpClient';
import type { SecurityAlert, SecurityBlock, SecurityOverview, SecurityProtectedNetwork, SecurityRule } from '../types';
export const adminSecurityDetectionApi = {
getSecurityOverview: (range = '24h') => request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
getSecurityNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary'),
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)),
getSecurityOverview: (range = '24h') =>
request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
getSecurityNotificationSummary: (signal?: AbortSignal) =>
request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary', { signal }),
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) =>
request<{ items: SecurityAlert[]; total: number }>(withQuery('/admin/security-detection/alerts', query)),
listSecurityRules: () => request<SecurityRule[]>('/admin/security-detection/rules'),
updateSecurityRule: (id: string, body: Partial<SecurityRule>) => request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) => request<SecurityBlock>(`/admin/security-detection/alerts/${id}/block`, { method: 'POST', body: JSON.stringify(body) }),
ignoreSecurityAlert: (id: string, reason: string) => request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, { method: 'POST', body: JSON.stringify({ reason }) }),
updateSecurityRule: (id: string, body: Partial<SecurityRule>) =>
request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) =>
request<SecurityBlock>(`/admin/security-detection/alerts/${id}/block`, {
method: 'POST',
body: JSON.stringify(body),
}),
ignoreSecurityAlert: (id: string, reason: string) =>
request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listSecurityBlocks: () => request<SecurityBlock[]>('/admin/security-detection/blocks'),
unblockSecurityBlock: (id: string, reason: string) => request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, { method: 'POST', body: JSON.stringify({ reason }) }),
unblockSecurityBlock: (id: string, reason: string) =>
request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listProtectedNetworks: () => request<SecurityProtectedNetwork[]>('/admin/security-detection/protected-networks'),
addProtectedNetwork: (body: { network: string; name: string; reason: string }) => request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', { method: 'POST', body: JSON.stringify(body) }),
addProtectedNetwork: (body: { network: string; name: string; reason: string }) =>
request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', {
method: 'POST',
body: JSON.stringify(body),
}),
};
+67 -18
View File
@@ -12,26 +12,75 @@ import type {
} from '../types';
export const adminSignatureRetirementApi = {
getSignatureRetirementConfiguration: () => request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>('/admin/signature-retirement/configuration'),
getSignatureRetirementConfiguration: () =>
request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>(
'/admin/signature-retirement/configuration',
),
saveSignatureRetirementRule: (body: {
ruleType: SignatureRetirementRuleType; targetId?: string; enabled: boolean;
mobileWindowDays: number; mobileThreshold: number; unicomWindowDays: number; unicomThreshold: number;
telecomWindowDays: number; telecomThreshold: number; messageTemplate?: string;
}) => request<SignatureRetirementRule>('/admin/signature-retirement/rules', { method: 'PUT', body: JSON.stringify(body) }),
ruleType: SignatureRetirementRuleType;
targetId?: string;
enabled: boolean;
mobileWindowDays: number;
mobileThreshold: number;
unicomWindowDays: number;
unicomThreshold: number;
telecomWindowDays: number;
telecomThreshold: number;
messageTemplate?: string;
}) =>
request<SignatureRetirementRule>('/admin/signature-retirement/rules', {
method: 'PUT',
body: JSON.stringify(body),
}),
createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) =>
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }),
deleteSignatureRetirementWebhook: (id: string) => request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
listSignatureRetirementMessages: (query: { dateFrom?: string; dateTo?: string; dimensionType?: string; tenantId?: string; applicationId?: string; signatureKeyword?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'),
readSignatureRetirementMessage: (id: string) => request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
readAllSignatureRetirementMessagesToday: () => request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
suppressSignatureRetirementMessage: (id: string, body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, { method: 'POST', body: JSON.stringify(body) }),
listSignatureRetirementSuppressions: () => request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', {
method: 'POST',
body: JSON.stringify(body),
}),
deleteSignatureRetirementWebhook: (id: string) =>
request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
listSignatureRetirementMessages: (
query: {
dateFrom?: string;
dateTo?: string;
dimensionType?: string;
tenantId?: string;
applicationId?: string;
signatureKeyword?: string;
channelId?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
getSignatureRetirementUnreadCount: (signal?: AbortSignal) =>
request<{ count: number }>('/admin/signature-retirement/unread-count', { signal }),
readSignatureRetirementMessage: (id: string) =>
request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
readAllSignatureRetirementMessagesToday: () =>
request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
suppressSignatureRetirementMessage: (
id: string,
body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string },
) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, {
method: 'POST',
body: JSON.stringify(body),
}),
listSignatureRetirementSuppressions: () =>
request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
cancelSignatureRetirementSuppression: (id: string, reason: string) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) }),
getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })),
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
getSignatureRetirementHeatmap: (date?: string) =>
request<{
date: string;
dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[];
}>(withQuery('/admin/signature-retirement/heatmap', { date })),
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<UnreportedSignatureItem> & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)),
request<PagedResult<UnreportedSignatureItem> & { date: string }>(
withQuery('/admin/signature-retirement/unreported-signatures', query),
),
};
+36 -1
View File
@@ -34,12 +34,47 @@ describe('Pagination compatibility', () => {
it('derives total pages from the opted-in size when totalPages is omitted', () => {
const changePage = vi.fn();
render(<Pagination total={60} pageSize={25} onPageSizeChange={vi.fn()} onPageChange={changePage} />);
const changePageSize = vi.fn();
render(<Pagination total={60} pageSize={25} onPageSizeChange={changePageSize} onPageChange={changePage} />);
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('25 条/页');
expect(screen.getByLabelText(/^每页数量/)).toHaveAccessibleName(/^每页数量/);
expect(screen.getByText('每页数量')).toHaveClass('sr-only');
expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
fireEvent.click(screen.getByRole('button', { name: '末页' }));
expect(changePage).toHaveBeenLastCalledWith(3);
for (const size of [10, 25, 50, 100]) {
fireEvent.click(screen.getByLabelText(/^每页数量/));
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
'10 条/页',
'25 条/页',
'50 条/页',
'100 条/页',
]);
fireEvent.click(screen.getByRole('option', { name: `${size} 条/页` }));
expect(changePageSize).toHaveBeenLastCalledWith(size);
}
});
it('keeps hidden page-size labels uniquely associated across multiple paginations', () => {
const firstChange = vi.fn();
const secondChange = vi.fn();
render(
<>
<Pagination pageSize={25} onPageSizeChange={firstChange} />
<Pagination pageSize={50} onPageSizeChange={secondChange} />
</>,
);
const controls = screen.getAllByLabelText(/^每页数量/);
expect(controls).toHaveLength(2);
expect(controls[0]).toHaveAccessibleName(/^每页数量/);
expect(controls[1]).toHaveAccessibleName(/^每页数量/);
expect(controls[0].id).not.toBe(controls[1].id);
fireEvent.click(controls[1]);
fireEvent.click(screen.getByRole('option', { name: '100 条/页' }));
expect(secondChange).toHaveBeenCalledWith(100);
expect(firstChange).not.toHaveBeenCalled();
});
it('resets only the jump draft when the page changes and does not restore an older draft', () => {
+13 -7
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react';
import { useId, useState, type ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
@@ -80,6 +80,7 @@ export function Pagination({
pageSizeOptions = [10, 25, 50, 100],
onPageSizeChange,
}: PaginationProps) {
const pageSizeId = useId();
const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / (pageSize ?? 10)) : page));
function changePage(nextPage: number) {
@@ -91,12 +92,17 @@ export function Pagination({
{typeof total === 'number' ? <span> {total} </span> : <span />}
<div>
{typeof pageSize === 'number' && onPageSizeChange ? (
<Select
label="每页数量"
value={String(pageSize)}
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
/>
<>
<label className="sr-only" htmlFor={pageSizeId}>
</label>
<Select
id={pageSizeId}
value={String(pageSize)}
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
/>
</>
) : null}
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">
+175 -4
View File
@@ -1,7 +1,8 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { beforeEach, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { readSession, writeSession } from '@/api/session';
import { AdminLayout } from './AdminLayout';
import { AppShell } from './AppShell';
@@ -11,9 +12,11 @@ const api = vi.hoisted(() => ({
getSecurityNotificationSummary: vi.fn(),
getInfrastructureMonitoringNotificationSummary: vi.fn(),
current: vi.fn(),
request: vi.fn(),
}));
vi.mock('@/api/adminApi', () => ({ adminApi: api, portalSessionApi: { current: api.current } }));
vi.mock('@/api/core/httpClient', () => ({ request: api.request }));
beforeEach(() => {
vi.clearAllMocks();
@@ -37,15 +40,18 @@ beforeEach(() => {
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 7 });
api.getSecurityNotificationSummary.mockResolvedValue({ count: 2, criticalCount: 1 });
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 5, criticalCount: 0 });
api.request.mockResolvedValue({ count: 0 });
});
afterEach(() => vi.useRealTimers());
function LocationProbe() {
const location = useLocation();
return <p data-testid="route">{location.pathname + location.search}</p>;
}
async function renderAdmin() {
render(
function renderAdminShell() {
return render(
<MemoryRouter initialEntries={['/admin']}>
<Routes>
<Route element={<AdminLayout />}>
@@ -54,6 +60,10 @@ async function renderAdmin() {
</Routes>
</MemoryRouter>,
);
}
async function renderAdmin() {
renderAdminShell();
await waitFor(() => expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('7'), {
timeout: 5000,
});
@@ -156,3 +166,164 @@ it('does not add notification controls to a client shell without notification it
expect(screen.queryByRole('button', { name: '报备任务提醒' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '预警通知' })).not.toBeInTheDocument();
});
const advancePolling = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
function openNotificationMenu(button: string) {
fireEvent.click(screen.getByRole('button', { name: button }));
}
it('retains all six known counts on failure and clears unavailable descriptions after a successful retry', async () => {
vi.useFakeTimers();
api.request.mockImplementation((path: string) =>
Promise.resolve({ count: path.includes('sending-monitor') ? 13 : 11 }),
);
const { unmount } = renderAdminShell();
await act(() => Promise.resolve());
await advancePolling(0);
expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('18');
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('20');
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
for (const endpoint of [
api.getPendingAudits,
api.getSignatureRetirementUnreadCount,
api.getSecurityNotificationSummary,
api.getInfrastructureMonitoringNotificationSummary,
api.request,
]) {
endpoint.mockRejectedValue(new Error('network unavailable'));
}
await act(async () => fireEvent(window, new Event('cmpp-retirement-count-refresh')));
openNotificationMenu('报备任务提醒');
const reporting = screen.getByRole('menu', { name: '报备任务提醒' });
expect(within(reporting).getAllByText('计数暂不可用,显示上次结果')).toHaveLength(2);
expect(
within(reporting)
.getByRole('menuitem', { name: /签名清退预警/ })
.querySelector('strong'),
).toHaveTextContent('7');
expect(
within(reporting)
.getByRole('menuitem', { name: /报备状态变化通知/ })
.querySelector('strong'),
).toHaveTextContent('11');
openNotificationMenu('预警通知');
const alerts = screen.getByRole('menu', { name: '预警中心' });
expect(within(alerts).getAllByText('计数暂不可用,显示上次结果')).toHaveLength(3);
for (const [label, count] of [
['发送质量告警', '13'],
['安全检测与封禁', '2'],
['系统监控告警', '5'],
]) {
expect(
within(alerts)
.getByRole('menuitem', { name: new RegExp(label) })
.querySelector('strong'),
).toHaveTextContent(count);
}
openNotificationMenu('通知');
expect(screen.getAllByRole('menuitem', { name: /计数暂不可用/ })).toHaveLength(6);
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
expect(screen.getByRole('menuitem', { name: /签名导入待审/ })).toHaveAttribute(
'href',
'/admin/signatures?tab=import',
);
api.getPendingAudits.mockResolvedValue({
enterpriseCertifications: 0,
smsAudits: 0,
templates: 0,
signatures: 0,
signatureImports: 2,
drainageInfos: 0,
total: 2,
});
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 3 });
api.getSecurityNotificationSummary.mockResolvedValue({ count: 1, criticalCount: 0 });
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 0, criticalCount: 0 });
api.request.mockResolvedValue({ count: 0 });
await advancePolling(30000);
expect(screen.queryByRole('menuitem', { name: /计数暂不可用/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('2');
expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('3');
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('1');
openNotificationMenu('报备任务提醒');
expect(screen.getByText('今日未读且未抑制')).toBeVisible();
expect(screen.queryByText(/计数暂不可用/)).not.toBeInTheDocument();
unmount();
});
it('marks unknown counts as loading then unavailable, while retaining successful domains at timeout', async () => {
vi.useFakeTimers();
let resolveStalled!: (value: { count: number }) => void;
api.getSignatureRetirementUnreadCount.mockImplementation(
() =>
new Promise<{ count: number }>((resolve) => {
resolveStalled = resolve;
}),
);
const { unmount } = renderAdminShell();
await act(() => Promise.resolve());
await advancePolling(0);
openNotificationMenu('报备任务提醒');
expect(screen.getAllByText('计数加载中')).toHaveLength(2);
openNotificationMenu('通知');
expect(screen.getAllByRole('menuitem', { name: /计数加载中/ })).toHaveLength(6);
const signal = api.getSignatureRetirementUnreadCount.mock.calls[0][0] as AbortSignal;
expect(api.getPendingAudits.mock.calls[0]).toEqual([undefined, signal]);
expect(api.getSecurityNotificationSummary.mock.calls[0]).toEqual([signal]);
expect(api.getInfrastructureMonitoringNotificationSummary.mock.calls[0]).toEqual([signal]);
expect(api.request.mock.calls.slice(0, 2).every(([, options]) => options.signal === signal)).toBe(true);
await advancePolling(15000);
expect(signal.aborted).toBe(true);
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('7');
openNotificationMenu('报备任务提醒');
const retirement = screen.getByRole('menuitem', { name: /签名清退预警/ });
expect(retirement).toHaveTextContent('计数暂不可用,尚未取得结果');
expect(screen.getByText('按企业与小时汇总的未读消息')).toBeVisible();
await act(async () => resolveStalled({ count: 999 }));
expect(retirement.querySelector('strong')).toHaveTextContent('0');
expect(screen.getByRole('button', { name: '报备任务提醒' })).not.toHaveTextContent('999');
unmount();
});
it('keeps the last monitor count when its successful HTTP response says unavailable', async () => {
vi.useFakeTimers();
api.request.mockResolvedValue({ count: 8 });
const { unmount } = renderAdminShell();
await act(() => Promise.resolve());
await advancePolling(0);
api.request.mockResolvedValue({ count: 0, unavailable: true });
await act(async () => fireEvent(window, new Event('cmpp-monitor-alert-refresh')));
openNotificationMenu('预警通知');
const monitor = screen.getByRole('menuitem', { name: /发送质量告警/ });
expect(monitor.querySelector('strong')).toHaveTextContent('8');
expect(monitor).toHaveTextContent('计数暂不可用,显示上次结果');
expect(screen.getByText('1 条严重告警待处置')).toBeVisible();
unmount();
});
it('does not apply or repeat an old account request after the local session identity changes', async () => {
vi.useFakeTimers();
let resolveStalled!: (value: { count: number }) => void;
api.getSignatureRetirementUnreadCount.mockImplementation(
() =>
new Promise<{ count: number }>((resolve) => {
resolveStalled = resolve;
}),
);
const { unmount } = renderAdminShell();
await act(() => Promise.resolve());
await advancePolling(0);
const session = readSession('admin')!;
writeSession({ ...session, user: { ...session.user, id: 'another-admin' } });
await act(async () => resolveStalled({ count: 999 }));
expect(screen.getByRole('button', { name: '报备任务提醒' })).not.toHaveTextContent('999');
fireEvent(window, new Event('focus'));
fireEvent(window, new Event('cmpp-audit-count-refresh'));
await advancePolling(60000);
expect(api.getPendingAudits).toHaveBeenCalledTimes(1);
unmount();
});
+108 -96
View File
@@ -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={[
{
+245
View File
@@ -0,0 +1,245 @@
import { StrictMode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { adminOperationsApi } from '@/api/admin/operations.api';
import { adminSignatureRetirementApi } from '@/api/admin/signature-retirement.api';
import { adminSecurityDetectionApi } from '@/api/admin/security-detection.api';
import { adminInfrastructureMonitoringApi } from '@/api/admin/infrastructure-monitoring.api';
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
const canPoll = () => true;
const refresh = () => window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
const focus = () => window.dispatchEvent(new Event('focus'));
const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(document, 'hidden', 'get').mockReturnValue(false);
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('keeps a single batch active and coalesces business events into one follow-up, without focus bursts', async () => {
const first = deferred<boolean>();
const second = deferred<boolean>();
const poll = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise).mockResolvedValue(true);
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
await advance(0);
act(() => {
refresh();
refresh();
window.dispatchEvent(new Event('cmpp-security-alert-count-refresh'));
focus();
});
expect(poll).toHaveBeenCalledTimes(1);
await act(async () => first.resolve(true));
expect(poll).toHaveBeenCalledTimes(2);
act(() => {
focus();
focus();
});
await act(async () => second.resolve(true));
await advance(29999);
expect(poll).toHaveBeenCalledTimes(2);
await advance(1);
expect(poll).toHaveBeenCalledTimes(3);
unmount();
});
it('aborts stalled requests at 15 seconds and retries at 30, 60, then at most 120 seconds despite focus or refresh', async () => {
const stalled = deferred<boolean>();
const signals: AbortSignal[] = [];
const poll = vi.fn((signal: AbortSignal) => {
signals.push(signal);
return settleNotificationRequest(stalled.promise, signal);
});
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
await advance(0);
for (const [attempt, delay] of [30000, 60000, 120000, 120000].entries()) {
await advance(14999);
expect(signals[attempt].aborted).toBe(false);
await advance(1);
expect(signals[attempt].aborted).toBe(true);
expect(signals[attempt].reason.name).toBe('TimeoutError');
act(() => {
focus();
refresh();
focus();
});
await advance(delay - 1);
expect(poll).toHaveBeenCalledTimes(attempt + 1);
await advance(1);
expect(poll).toHaveBeenCalledTimes(attempt + 2);
}
unmount();
await act(async () => stalled.resolve(true));
});
it('resets backoff after recovery and allows an explicit business refresh again', async () => {
const poll = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true);
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
await advance(0);
await advance(30000);
expect(poll).toHaveBeenCalledTimes(2);
await advance(60000);
expect(poll).toHaveBeenCalledTimes(3);
await advance(30000);
expect(poll).toHaveBeenCalledTimes(4);
await act(async () => refresh());
expect(poll).toHaveBeenCalledTimes(5);
unmount();
});
it.each(['hidden', 'offline'] as const)(
'cancels while %s, ignores a late result, and resumes only one batch',
async (state) => {
const first = deferred<number>();
const values: number[] = [];
const signals: AbortSignal[] = [];
const poll = vi.fn(async (signal: AbortSignal, isCurrent: () => boolean) => {
signals.push(signal);
const value = await settleNotificationRequest(first.promise, signal);
if (isCurrent()) values.push(value);
return true;
});
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
await advance(0);
const setAvailable = (available: boolean) => {
if (state === 'hidden') {
vi.spyOn(document, 'hidden', 'get').mockReturnValue(!available);
document.dispatchEvent(new Event('visibilitychange'));
} else {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(available);
window.dispatchEvent(new Event(available ? 'online' : 'offline'));
}
};
await act(async () => setAvailable(false));
expect(signals[0].aborted).toBe(true);
await act(async () => first.resolve(9));
await advance(120000);
act(() => {
focus();
refresh();
});
expect(values).toEqual([]);
expect(poll).toHaveBeenCalledTimes(1);
act(() => {
setAvailable(true);
setAvailable(true);
});
await advance(0);
expect(poll).toHaveBeenCalledTimes(2);
expect(values).toEqual([9]);
unmount();
},
);
it('preserves a failed retry deadline across hidden, offline, and locked transitions', async () => {
const poll = vi.fn().mockResolvedValue(false);
const { rerender, unmount } = renderHook(({ enabled }) => useNotificationPolling({ enabled, canPoll, poll }), {
initialProps: { enabled: true },
});
await advance(0);
await advance(30000);
expect(poll).toHaveBeenCalledTimes(2);
rerender({ enabled: false });
await advance(1000);
rerender({ enabled: true });
act(() => {
focus();
refresh();
window.dispatchEvent(new Event('online'));
document.dispatchEvent(new Event('visibilitychange'));
});
await advance(58999);
expect(poll).toHaveBeenCalledTimes(2);
await advance(1);
expect(poll).toHaveBeenCalledTimes(3);
unmount();
});
it('invalidates an old batch on lock or unmount even if its source ignores cancellation', async () => {
const requests = [deferred<number>(), deferred<number>()];
const applied: number[] = [];
const signals: AbortSignal[] = [];
const poll = vi.fn(async (signal: AbortSignal, isCurrent: () => boolean) => {
const request = requests[signals.length];
signals.push(signal);
const value = await request.promise;
if (isCurrent()) applied.push(value);
return true;
});
const { rerender, unmount } = renderHook(({ enabled }) => useNotificationPolling({ enabled, canPoll, poll }), {
initialProps: { enabled: true },
});
await advance(0);
rerender({ enabled: false });
expect(signals[0].aborted).toBe(true);
rerender({ enabled: true });
await advance(0);
expect(poll).toHaveBeenCalledTimes(2);
await act(async () => requests[0].resolve(100));
expect(applied).toEqual([]);
unmount();
expect(signals[1].aborted).toBe(true);
await act(async () => requests[1].resolve(200));
expect(applied).toEqual([]);
await advance(120000);
expect(poll).toHaveBeenCalledTimes(2);
});
it('starts only one batch under StrictMode and none when discarded before setup completes', async () => {
const poll = vi.fn().mockResolvedValue(true);
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }), {
wrapper: StrictMode,
});
expect(poll).not.toHaveBeenCalled();
await advance(0);
expect(poll).toHaveBeenCalledTimes(1);
unmount();
const discarded = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
discarded.unmount();
await advance(0);
expect(poll).toHaveBeenCalledTimes(1);
});
it('cancels all four notification API fetches and preserves the existing tenant filter argument', async () => {
const calls: { url: string; options: RequestInit | undefined }[] = [];
vi.spyOn(globalThis, 'fetch').mockImplementation((url, options) => {
calls.push({ url: String(url), options });
return new Promise<Response>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true });
});
});
const controller = new AbortController();
const { signal } = controller;
const pending = Promise.allSettled([
adminOperationsApi.getPendingAudits('tenant with spaces', signal),
adminSignatureRetirementApi.getSignatureRetirementUnreadCount(signal),
adminSecurityDetectionApi.getSecurityNotificationSummary(signal),
adminInfrastructureMonitoringApi.getInfrastructureMonitoringNotificationSummary(signal),
]);
expect(calls).toHaveLength(4);
expect(new URL(calls[0].url, 'http://localhost').searchParams.get('tenantId')).toBe('tenant with spaces');
expect(calls.every(({ options }) => options?.signal === signal && (options.method ?? 'GET') === 'GET')).toBe(true);
controller.abort();
const results = await pending;
expect(results.every((result) => result.status === 'rejected' && result.reason.name === 'AbortError')).toBe(true);
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ total: 4 }), { status: 200 }));
await expect(adminOperationsApi.getPendingAudits('existing-tenant')).resolves.toEqual({ total: 4 });
});
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef } from 'react';
const REFRESH_EVENTS = [
'cmpp-audit-count-refresh',
'cmpp-retirement-count-refresh',
'cmpp-report-notification-refresh',
'cmpp-monitor-alert-refresh',
'cmpp-security-alert-count-refresh',
'cmpp-infrastructure-alert-count-refresh',
];
const POLL_INTERVAL_MS = 30000;
const REQUEST_TIMEOUT_MS = 15000;
// Also settles an aborted request if it is waiting outside fetch (for example, for authentication).
export function settleNotificationRequest<T>(request: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener('abort', abort, { once: true });
request.then(
(value) => {
signal.removeEventListener('abort', abort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener('abort', abort);
reject(error);
},
);
if (signal.aborted) {
signal.removeEventListener('abort', abort);
abort();
}
});
}
type NotificationPollingOptions = {
enabled: boolean;
canPoll: () => boolean;
poll: (signal: AbortSignal, isCurrent: () => boolean) => Promise<boolean>;
};
export function useNotificationPolling({ enabled, canPoll, poll }: NotificationPollingOptions) {
const retry = useRef({ failures: 0, nextEligibleAt: 0 });
useEffect(() => {
if (!enabled) return;
let disposed = false;
let generation = 0;
let active: AbortController | undefined;
let timer: number | undefined;
let timeout: number | undefined;
let refreshPending = false;
const isAvailable = () => !document.hidden && navigator.onLine && canPoll();
const clearTimer = () => window.clearTimeout(timer);
const schedule = () => {
clearTimer();
if (disposed || active || !isAvailable()) return;
timer = window.setTimeout(() => start(false), Math.max(0, retry.current.nextEligibleAt - Date.now()));
};
const start = (businessRefresh: boolean) => {
if (disposed || !isAvailable()) return;
if (active) {
refreshPending ||= businessRefresh;
return;
}
if (Date.now() < retry.current.nextEligibleAt && (!businessRefresh || retry.current.failures > 0)) {
schedule();
return;
}
clearTimer();
refreshPending = false;
const controller = new AbortController();
active = controller;
const batchGeneration = generation;
const isCurrent = () => !disposed && generation === batchGeneration && isAvailable();
timeout = window.setTimeout(
() => controller.abort(new DOMException('通知计数请求超时', 'TimeoutError')),
REQUEST_TIMEOUT_MS,
);
void poll(controller.signal, isCurrent)
.catch(() => false)
.then((successful) => {
window.clearTimeout(timeout);
active = undefined;
if (disposed) return;
if (!isCurrent()) {
schedule();
return;
}
retry.current.failures = successful ? 0 : Math.min(retry.current.failures + 1, 3);
const delay = successful ? POLL_INTERVAL_MS : POLL_INTERVAL_MS * 2 ** (retry.current.failures - 1);
retry.current.nextEligibleAt = Date.now() + delay;
if (refreshPending && successful) {
start(true);
} else {
schedule();
}
});
};
const refresh = () => start(true);
const focus = () => start(false);
const availabilityChanged = () => {
if (!isAvailable()) {
clearTimer();
window.clearTimeout(timeout);
generation += 1;
refreshPending = false;
active?.abort();
} else {
schedule();
}
};
window.addEventListener('focus', focus);
window.addEventListener('online', availabilityChanged);
window.addEventListener('offline', availabilityChanged);
document.addEventListener('visibilitychange', availabilityChanged);
REFRESH_EVENTS.forEach((event) => window.addEventListener(event, refresh));
// StrictMode's discarded setup must not dispatch a second HTTP batch.
schedule();
return () => {
disposed = true;
generation += 1;
clearTimer();
window.clearTimeout(timeout);
active?.abort();
window.removeEventListener('focus', focus);
window.removeEventListener('online', availabilityChanged);
window.removeEventListener('offline', availabilityChanged);
document.removeEventListener('visibilitychange', availabilityChanged);
REFRESH_EVENTS.forEach((event) => window.removeEventListener(event, refresh));
};
}, [enabled, canPoll, poll]);
}