@@ -589,18 +588,18 @@ function UnreportedSignaturesCard({
pagination={false}
rowKey={(record) => `${record.signatureId}:${record.applicationId ?? ''}`}
/>
- {(data?.total ?? 0) > 0 ? (
-
= 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}
+ = 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}
+ />
);
}
diff --git a/src/apps/admin/AdminReportTasksPage.tsx b/src/apps/admin/AdminReportTasksPage.tsx
index 54ca64f..5c1dab3 100644
--- a/src/apps/admin/AdminReportTasksPage.tsx
+++ b/src/apps/admin/AdminReportTasksPage.tsx
@@ -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
(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))}
diff --git a/src/apps/admin/ReportWorkbenchPages.test.tsx b/src/apps/admin/ReportWorkbenchPages.test.tsx
index 317a55a..66a1575 100644
--- a/src/apps/admin/ReportWorkbenchPages.test.tsx
+++ b/src/apps/admin/ReportWorkbenchPages.test.tsx
@@ -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(
@@ -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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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',
diff --git a/src/components/ui/PagePrimitives.test.tsx b/src/components/ui/PagePrimitives.test.tsx
new file mode 100644
index 0000000..f4b2433
--- /dev/null
+++ b/src/components/ui/PagePrimitives.test.tsx
@@ -0,0 +1,61 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { Pagination } from './PagePrimitives';
+
+describe('Pagination compatibility', () => {
+ it('preserves existing navigation without showing page size controls unless opted in', () => {
+ const previous = vi.fn();
+ const next = vi.fn();
+ const changePage = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.queryByLabelText(/^每页数量/)).not.toBeInTheDocument();
+ expect(screen.getByText('显示 30 条记录')).toBeVisible();
+ expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
+ fireEvent.click(screen.getByRole('button', { name: '上一页' }));
+ fireEvent.click(screen.getByRole('button', { name: '下一页' }));
+ expect(previous).toHaveBeenCalledOnce();
+ expect(next).toHaveBeenCalledOnce();
+ fireEvent.click(screen.getByRole('button', { name: '首页' }));
+ expect(changePage).toHaveBeenLastCalledWith(1);
+ fireEvent.click(screen.getByRole('button', { name: '末页' }));
+ expect(changePage).toHaveBeenLastCalledWith(3);
+ });
+
+ it('derives total pages from the opted-in size when totalPages is omitted', () => {
+ const changePage = vi.fn();
+ render();
+
+ expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('25 条/页');
+ expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
+ fireEvent.click(screen.getByRole('button', { name: '末页' }));
+ expect(changePage).toHaveBeenLastCalledWith(3);
+ });
+
+ it('resets only the jump draft when the page changes and does not restore an older draft', () => {
+ const changePage = vi.fn();
+ const { rerender } = render();
+ fireEvent.change(screen.getByLabelText('跳转页码'), { target: { value: '8' } });
+ expect(screen.getByLabelText('跳转页码')).toHaveValue(8);
+ expect(changePage).not.toHaveBeenCalled();
+
+ rerender();
+ expect(screen.getByLabelText('跳转页码')).toHaveValue(2);
+ rerender();
+ expect(screen.getByLabelText('跳转页码')).toHaveValue(1);
+
+ fireEvent.change(screen.getByLabelText('跳转页码'), { target: { value: '20' } });
+ fireEvent.keyDown(screen.getByLabelText('跳转页码'), { key: 'Enter' });
+ expect(changePage).toHaveBeenLastCalledWith(10);
+ });
+});
diff --git a/src/components/ui/PagePrimitives.tsx b/src/components/ui/PagePrimitives.tsx
index 5fd1080..695b753 100644
--- a/src/components/ui/PagePrimitives.tsx
+++ b/src/components/ui/PagePrimitives.tsx
@@ -1,5 +1,6 @@
-import { useEffect, useState, type ReactNode } from 'react';
+import { useState, type ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
+import { Select } from '@/components/ui/Select';
type QueryPanelProps = {
title: ReactNode;
@@ -16,6 +17,9 @@ type PaginationProps = {
onPrevious?: () => void;
onNext?: () => void;
onPageChange?: (page: number) => void;
+ pageSize?: number;
+ pageSizeOptions?: number[];
+ onPageSizeChange?: (pageSize: number) => void;
};
type InlineTextPreviewProps = {
@@ -34,6 +38,35 @@ export function QueryPanel({ title, summary, children }: QueryPanelProps) {
);
}
+function PaginationJump({
+ page,
+ pages,
+ onPageChange,
+}: {
+ page: number;
+ pages: number;
+ onPageChange: (page: number) => void;
+}) {
+ const [targetPage, setTargetPage] = useState(String(page));
+ return (
+
+ );
+}
+
export function Pagination({
total,
page = 1,
@@ -43,11 +76,11 @@ export function Pagination({
onPrevious,
onNext,
onPageChange,
+ pageSize,
+ pageSizeOptions = [10, 25, 50, 100],
+ onPageSizeChange,
}: PaginationProps) {
- const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / 10) : page));
- const [targetPage, setTargetPage] = useState(String(page));
-
- useEffect(() => setTargetPage(String(page)), [page]);
+ const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / (pageSize ?? 10)) : page));
function changePage(nextPage: number) {
onPageChange?.(Math.min(pages, Math.max(1, nextPage)));
@@ -57,12 +90,34 @@ export function Pagination({
{typeof total === 'number' ?
显示 {total} 条记录 :
}
-
- {onPageChange ?
: null}
-
- {onPageChange ?
: null}
- {onPageChange ?
: null}
-
+ {typeof pageSize === 'number' && onPageSizeChange ? (
+
);
@@ -72,7 +127,10 @@ export function InlineTextPreview({ label, leading, children }: InlineTextPrevie
return (
{label}
-
{leading}{children}
+
+ {leading}
+ {children}
+
);
}