fix: unify analytics and report pagination controls

This commit is contained in:
hectorzhao
2026-09-08 14:52:12 +08:00
parent 6d3c78330d
commit 2a9d03be2e
10 changed files with 423 additions and 80 deletions
@@ -36,4 +36,61 @@ describe('independent analytics tabs', () => {
expect(within(unreported).getByLabelText('统计日期')).toHaveValue('2026-08-25');
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled();
});
it.each([
['签名通道发送质量', 'getSignatureQuality'],
['未报备签名', 'getUnreportedSignatures'],
] as const)('keeps %s page size in the footer and resets the page with applied filters', async (tab, method) => {
api[method].mockImplementation(async (query) => ({ ...query, total: 120, items: [] }));
render(<AdminAnalyticsPage />);
fireEvent.click(screen.getByRole('tab', { name: tab }));
const panel = screen.getByRole('region', { name: tab });
await waitFor(() => expect(within(panel).getByRole('button', { name: '下一页' })).toBeEnabled());
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
fireEvent.click(within(panel).getByRole('button', { name: '查询统计' }));
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })));
fireEvent.click(within(panel).getByRole('button', { name: '下一页' }));
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })));
// An unsubmitted date must not alter a pagination request.
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-21' } });
const size = within(panel).getByLabelText(/^每页数量/);
expect(size.closest('.ui-pagination')).not.toBeNull();
expect(size.closest('.page-actions')).toBeNull();
fireEvent.click(size);
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
'10 条/页',
'25 条/页',
'50 条/页',
'100 条/页',
]);
fireEvent.click(screen.getByRole('option', { name: '100 条/页' }));
await waitFor(() =>
expect(api[method]).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, pageSize: 100, date: '2026-08-20' }),
),
);
await waitFor(() => expect(within(panel).getByLabelText('跳转页码')).toHaveValue(1));
});
it.each(['企业签名活跃度', '通道签名活跃度'])(
'preserves %s footer size independently even with no rows',
async (tab) => {
render(<AdminAnalyticsPage />);
fireEvent.click(screen.getByRole('tab', { name: tab }));
await waitFor(() => expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1));
const panel = screen.getByRole('region', { name: tab });
const size = within(panel).getByLabelText(/^每页数量/);
expect(size.closest('.ui-pagination')).not.toBeNull();
expect(within(panel).getByRole('button', { name: '下一页' })).toBeDisabled();
fireEvent.click(size);
fireEvent.click(screen.getByRole('option', { name: '50 条/页' }));
fireEvent.click(screen.getByRole('tab', { name: '签名通道发送质量' }));
expect(
within(screen.getByRole('region', { name: '签名通道发送质量' })).getByLabelText(/^每页数量/),
).toHaveTextContent('25 条/页');
fireEvent.click(screen.getByRole('tab', { name: tab }));
expect(size).toHaveTextContent('50 条/页');
expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1);
},
);
});
+60 -61
View File
@@ -10,18 +10,7 @@ import {
type UnreportedSignatureItem,
type PagedResult,
} from '@/api/adminApi';
import {
Breadcrumb,
Button,
CarrierTag,
Input,
Pagination,
Select,
Tabs,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
import { Breadcrumb, Button, CarrierTag, Input, Pagination, Tabs, Table, Tag, type TableColumn } from '@/components/ui';
import './AdminAnalyticsPage.css';
import { successRateClassName, successRateTone } from '@/utils/successRate';
@@ -242,6 +231,12 @@ function AnalyticsPanel({ kind }: { kind: string }) {
void loadData(page, appliedKeyword, keyword, appliedDate);
}
function changePageSize(size: number) {
setPageSize(size);
if (kind === 'quality' || kind === 'unreported')
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
}
return (
<section className="page-stack" aria-label={analyticsTabs.find((tab) => tab.value === kind)?.label}>
<div className="page-heading">
@@ -249,17 +244,6 @@ function AnalyticsPanel({ kind }: { kind: string }) {
<p className="muted"></p>
</div>
<div className="page-actions">
<Select
label="每页数量"
value={String(pageSize)}
options={[10, 25, 50, 100].map((value) => ({ value: String(value), label: `${value} 条/页` }))}
onChange={(event) => {
const size = Number(event.target.value);
setPageSize(size);
if (kind === 'quality' || kind === 'unreported')
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
}}
/>
<Input
aria-label="统计日期"
max={shanghaiDateKey()}
@@ -312,27 +296,31 @@ function AnalyticsPanel({ kind }: { kind: string }) {
pagination={false}
rowKey="signatureId"
/>
{(signatureQuality?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={
(signatureQuality?.page ?? 1) >=
Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))
}
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
onPageChange={changeSignaturePage}
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
page={signatureQuality?.page ?? 1}
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
total={signatureQuality?.total ?? 0}
totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
/>
) : null}
<Pagination
pageSize={pageSize}
onPageSizeChange={changePageSize}
nextDisabled={
(signatureQuality?.page ?? 1) >=
Math.max(1, Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? pageSize)))
}
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
onPageChange={changeSignaturePage}
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
page={signatureQuality?.page ?? 1}
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
total={signatureQuality?.total ?? 0}
totalPages={Math.max(
1,
Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? pageSize)),
)}
/>
</div>
) : null}
{kind === 'enterprise' ? (
<RetirementHeatmap
pageSize={pageSize}
onPageSizeChange={changePageSize}
date={appliedDate}
dimensionType="enterprise"
dimensions={retirementDimensions}
@@ -343,6 +331,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
{kind === 'channel' ? (
<RetirementHeatmap
pageSize={pageSize}
onPageSizeChange={changePageSize}
date={appliedDate}
dimensionType="channel"
dimensions={retirementDimensions}
@@ -353,6 +342,8 @@ function AnalyticsPanel({ kind }: { kind: string }) {
{kind === 'unreported' ? (
<UnreportedSignaturesCard
pageSize={pageSize}
onPageSizeChange={changePageSize}
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
@@ -375,6 +366,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
function RetirementHeatmap({
pageSize,
onPageSizeChange,
date,
dimensionType,
dimensions,
@@ -382,6 +374,7 @@ function RetirementHeatmap({
title,
}: {
pageSize: number;
onPageSizeChange: (pageSize: number) => void;
date: string;
dimensionType: 'enterprise' | 'channel';
dimensions: SignatureRetirementHeatmapDimension[];
@@ -504,16 +497,6 @@ function RetirementHeatmap({
</tbody>
</table>
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage(currentPage + 1)}
onPageChange={setPage}
onPrevious={() => setPage(currentPage - 1)}
page={currentPage}
previousDisabled={currentPage <= 1}
total={rows.length}
totalPages={totalPages}
/>
</>
) : (
<p className="empty-state">
@@ -522,11 +505,25 @@ function RetirementHeatmap({
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
)}
<Pagination
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
nextDisabled={currentPage >= totalPages}
onNext={() => setPage(currentPage + 1)}
onPageChange={setPage}
onPrevious={() => setPage(currentPage - 1)}
page={currentPage}
previousDisabled={currentPage <= 1}
total={rows.length}
totalPages={totalPages}
/>
</div>
);
}
function UnreportedSignaturesCard({
pageSize,
onPageSizeChange,
data,
keyword,
loading,
@@ -534,6 +531,8 @@ function UnreportedSignaturesCard({
onPageChange,
onSearch,
}: {
pageSize: number;
onPageSizeChange: (pageSize: number) => void;
data: (PagedResult<UnreportedSignatureItem> & { date: string }) | null;
keyword: string;
loading: boolean;
@@ -553,7 +552,7 @@ function UnreportedSignaturesCard({
render: (record) => `${record.messageCount.toLocaleString('zh-CN')}`,
},
];
const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 10)));
const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? pageSize)));
return (
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
@@ -589,18 +588,18 @@ function UnreportedSignaturesCard({
pagination={false}
rowKey={(record) => `${record.signatureId}:${record.applicationId ?? ''}`}
/>
{(data?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={(data?.page ?? 1) >= totalPages}
onNext={() => onPageChange((data?.page ?? 1) + 1)}
onPageChange={onPageChange}
onPrevious={() => onPageChange((data?.page ?? 1) - 1)}
page={data?.page ?? 1}
previousDisabled={(data?.page ?? 1) <= 1}
total={data?.total ?? 0}
totalPages={totalPages}
/>
) : null}
<Pagination
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
nextDisabled={(data?.page ?? 1) >= totalPages}
onNext={() => onPageChange((data?.page ?? 1) + 1)}
onPageChange={onPageChange}
onPrevious={() => onPageChange((data?.page ?? 1) - 1)}
page={data?.page ?? 1}
previousDisabled={(data?.page ?? 1) <= 1}
total={data?.total ?? 0}
totalPages={totalPages}
/>
</div>
);
}
+18 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } from '@/api/adminApi';
@@ -192,6 +192,8 @@ export function AdminReportTasksPage() {
const [exportTask, setExportTask] = useState<ReportTask | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const requestId = useRef(0);
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({
keyword: '',
@@ -200,9 +202,8 @@ export function AdminReportTasksPage() {
status: initialStatus,
carrier: 'all',
});
const pageSize = 10;
function loadData(targetPage = page, filters = appliedFilters) {
const id = ++requestId.current;
adminApi
.listReportDetailsPage({
signatureId: searchParams.get('signatureId') || undefined,
@@ -216,17 +217,23 @@ export function AdminReportTasksPage() {
pageSize,
})
.then((result) => {
if (id !== requestId.current) return;
setTasks(result.items);
setTotal(result.total);
setSelected(new Set());
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备明细加载失败'));
.catch((failure: Error) => {
if (id === requestId.current) setError(failure.message || '报备明细加载失败');
});
}
useEffect(() => {
loadData(page);
}, [page]);
return () => {
requestId.current += 1;
};
}, [page, pageSize]);
async function saveTaskStatus() {
if (!statusTask) return;
@@ -521,8 +528,14 @@ export function AdminReportTasksPage() {
nextDisabled={page * pageSize >= total}
onNext={() => setPage((current) => current + 1)}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
setSelected(new Set());
}}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={page}
pageSize={pageSize}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
+113 -2
View File
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
@@ -57,7 +57,7 @@ describe('report workbench pages', () => {
items: [task('1'), task('2')],
total: 2,
page: 1,
pageSize: 10,
pageSize: 25,
});
render(
<MemoryRouter>
@@ -77,6 +77,117 @@ describe('report workbench pages', () => {
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
});
it('changes detail page size while retaining applied filters and clearing current-page selection', async () => {
adminApi.listReportDetailsPage.mockImplementation(async ({ page, pageSize }) => ({
items: [task(`${page}-1`), task(`${page}-2`)],
total: 258,
page,
pageSize,
}));
render(
<MemoryRouter initialEntries={['/admin/report-tasks?signatureId=signature-filter&scope=pending']}>
<AdminReportTasksPage />
</MemoryRouter>,
);
const user = userEvent.setup();
await screen.findByText('签名1-1');
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, pageSize: 25, signatureId: 'signature-filter', status: 'pending' }),
);
const sizeControl = screen.getByLabelText(/^每页数量/);
expect(sizeControl.closest('.ui-pagination')).not.toBeNull();
await user.click(sizeControl);
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
'10 条/页',
'25 条/页',
'50 条/页',
'100 条/页',
]);
await user.click(screen.getByRole('option', { name: '25 条/页' }));
await user.type(screen.getByRole('textbox', { name: '企业/应用/通道/报备对象' }), '测试企业');
await user.click(screen.getByRole('button', { name: '查询' }));
await user.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('签名2-1');
await user.click(screen.getByRole('button', { name: '全选当页' }));
expect(screen.getByRole('button', { name: '批量修改状态(2' })).toBeEnabled();
await user.click(sizeControl);
await user.click(screen.getByRole('option', { name: '100 条/页' }));
await screen.findByText('签名1-1');
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
expect.objectContaining({
page: 1,
pageSize: 100,
keyword: '测试企业',
signatureId: 'signature-filter',
status: 'pending',
}),
);
expect(screen.getByRole('button', { name: '批量修改状态(0' })).toBeDisabled();
expect(screen.getByRole('spinbutton', { name: '跳转页码' })).toHaveAttribute('max', '3');
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
for (const size of [10, 50, 25]) {
await user.click(sizeControl);
await user.click(screen.getByRole('option', { name: `${size} 条/页` }));
await waitFor(() =>
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, pageSize: size, keyword: '测试企业' }),
),
);
}
});
it('keeps the latest detail page size result when an earlier request finishes last', async () => {
const initialPage = { items: [task('old-25')], total: 258, page: 1, pageSize: 25 };
const latestPage = { items: [task('new-100')], total: 258, page: 1, pageSize: 100 };
let resolveInitial!: (value: typeof initialPage) => void;
let resolveLatest!: (value: typeof latestPage) => void;
adminApi.listReportDetailsPage
.mockImplementationOnce(() => new Promise((resolve) => (resolveInitial = resolve)))
.mockImplementationOnce(() => new Promise((resolve) => (resolveLatest = resolve)));
render(
<MemoryRouter>
<AdminReportTasksPage />
</MemoryRouter>,
);
const user = userEvent.setup();
await user.click(screen.getByLabelText(/^每页数量/));
await user.click(screen.getByRole('option', { name: '100 条/页' }));
await waitFor(() => expect(adminApi.listReportDetailsPage).toHaveBeenCalledTimes(2));
await act(async () => resolveLatest(latestPage));
expect(screen.getByText('签名new-100')).toBeVisible();
await act(async () => resolveInitial(initialPage));
expect(screen.queryByText('签名old-25')).not.toBeInTheDocument();
expect(screen.getByText('签名new-100')).toBeVisible();
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('100 条/页');
});
it('keeps the current detail request failure visible without replacing it with a stale failure', async () => {
let rejectInitial!: (error: Error) => void;
let rejectLatest!: (error: Error) => void;
adminApi.listReportDetailsPage
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectInitial = reject)))
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectLatest = reject)));
render(
<MemoryRouter>
<AdminReportTasksPage />
</MemoryRouter>,
);
const user = userEvent.setup();
await user.click(screen.getByLabelText(/^每页数量/));
await user.click(screen.getByRole('option', { name: '100 条/页' }));
await waitFor(() => expect(adminApi.listReportDetailsPage).toHaveBeenCalledTimes(2));
await act(async () => rejectLatest(new Error('当前分页加载失败')));
expect(screen.getByText('当前分页加载失败')).toBeVisible();
await act(async () => rejectInitial(new Error('已过期的分页请求失败')));
expect(screen.getByText('当前分页加载失败')).toBeVisible();
expect(screen.queryByText('已过期的分页请求失败')).not.toBeInTheDocument();
});
it('uses the shared current-page selection button in the material pool heading', async () => {
const material = {
id: 'signature:signature-1',