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
@@ -2227,3 +2227,8 @@
## 2026-09-08 运营修复需求补充
每日签名清退预警按检测日期、企业和企业应用生成,每个应用每天仅一条消息;并发及重复执行不得重复生成,正文冻结,历史消息保留。首页保留原指标口径更新数字展示;质量检测四个Tab独立日期及分页,每页10/25/50/100默认25。修复监控阈值保存、创建通道误关闭、报备状态重复项及日期格式化器重复构造。具体规则与兼容边界见 [运营修复设计](operations-fixes-20260908.md)。不包含导入导出优化。
## 2026-09-08 分页交互补充
签名质量检测四个Tab的每页数量选择移至各自列表底部,与上一页、下一页和页码跳转放在同一分页区;保留独立日期、筛选和每页10/25/50/100,默认25,空结果也可选择数量。通道报备明细增加相同四档容量,默认25;修改容量回到第一页、清空当页勾选、保留已应用筛选,使用真实服务端分页,较早请求的成功或失败不得覆盖最新查询结果。本节补充前述运营修复要求;不更改后端接口、端口或数据模型,详见[运营修复设计](operations-fixes-20260908.md#分页交互补充2026-09-08)。
+12
View File
@@ -22,3 +22,15 @@
## 验收结果
精确归档前端108项、API673项、类型/格式/样式/构建与质量门禁通过。真实PG并发每日应用唯一、分组查询和抑制通过,19788条实际热力图服务RSS增长49.81MiB。测试环境三视口真实浏览器、100条分页、状态历史exporting合并、阈值保存及恢复通过。详细证据和未执行项见testing-progress.md的2026-09-08 13:39记录;只发布测试环境,历史预警保留。
## 分页交互补充(2026-09-08
本次单独授权为修改并本地提交,不包含推送、测试环境部署或预生产部署。补充上文第4项及通道报备明细的分页交互,不改变已部署版本的历史验收结论。
- 根因:质量检测容量Select位于顶部page-actions,与底部分页分离;报备明细硬编码pageSize=10,查询依赖只有page。现有GET /api/admin/report-details支持page/pageSize及最大100条,无需后端变更。
- 公共Pagination新增可选pageSize、pageSizeOptions、onPageSizeChange,将容量Select置于翻页区。只有传入容量和回调的消费者显示下拉;未传的既有消费者保持原界面与默认每页10的推算。页码输入独立按page重置草稿,避免effect级联更新;原Enter与页码边界保持。
- 签名质量、企业活跃度、通道活跃度、未报备签名都在底部显示10/25/50/100(默认25),包含零结果。日期与已应用查询条件继续按Tab独立保存;质量/未报备重新请求服务端第一页,热力图保留既有真实30天维度数据及客户端分页,改变容量回到第一页。
- 通道报备明细首次进入/刷新默认25,改变容量回到第一页、清空当页选择,保留已应用筛选及URL范围;总数与页面内容来自同一个真实分页接口。增加请求序号和卸载失效保护,过期成功与失败都不能覆盖最新列表或错误;当前请求失败继续真实展示。
- 实际新增容量消费者只有AdminAnalyticsPage的四个模块与AdminReportTasksPage;不改CSS、后端、数据库、权限、详情/状态修改/导出逻辑或其他列表的容量设置。共享Pagination的原消费者通过兼容单测和前端全量回归验证。
- 验收覆盖四档请求、翻页后改容量、空结果、独立日期/筛选、乱序响应、跳转输入草稿、三视口和刷新/路由切换。执行结果见testing-progress.md本轮分页记录;用例OPS-PAGE0908-0105。
+13
View File
@@ -5241,3 +5241,16 @@ npm run verify:phase8
### OPS0908 测试环境执行记录(2026-09-08 13:39
OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对应时间记录。真实PG覆盖并发分组与19788行规模;真实浏览器覆盖三视口和阈值保存恢复。通道失败历史状态暂无非空样本,外部Webhook和次日自然调度未执行,不扩写为全链路发送通过。
## 2026-09-08 分页交互补充
以下用例补充OPS0908-05及通道报备明细分页规则;功能验收使用真实API及其数据库结果,API替身只用于单元测试的乱序/错误隔离。
| 编号 | 操作 | 预期结果 |
|---|---|---|
| OPS-PAGE0908-01 | 依次进入签名质量检测四Tab,在有数据和空结果下查看列表底部并选择10/25/50/100 | 默认25;每页数量与翻页控件在同一底部分页区,顶部不再显示容量;空结果可选择且不能翻到不存在页面 |
| OPS-PAGE0908-02 | 各Tab设置不同日期/筛选和容量,翻页后改容量,切换Tab再返回 | 各自保留状态;改容量回第1页;未提交日期不影响分页请求;质量和未报备请求真实page/pageSize,热力图按既有真实维度分页 |
| OPS-PAGE0908-03 | 打开通道报备明细,设置筛选,到第2页勾选后依次改变四档容量 | 默认25;真实接口page=1及正确pageSize,总数/页数/行数一致;筛选与URL范围保留,旧勾选清空,不执行状态或发送操作 |
| OPS-PAGE0908-04 | 隔离测试让旧25条查询晚于新100条返回,分别返回成功和失败 | 旧结果和旧错误均不能覆盖最新查询;当前失败显示真实错误;组件卸载后旧请求失效 |
| OPS-PAGE0908-05 | 在1600×1000、1366×768、390×844检查两页面,刷新/跨路由,回归原公共分页消费者 | 下拉完整可见、可操作,表格内部滚动不造成页面整体溢出;旧消费者不新增容量控件;跳转输入在真实页码改变后重置,返回旧页不恢复未提交草稿 |
+14
View File
@@ -4736,3 +4736,17 @@ git diff --check
- 首次使用标准工具暴露并修复:测试sudo交互掩码输入、归档EOL绑定兼容现有CRLF、前端worker上限2、vendor链接以整个应用目录为边界(仍拒绝外部目标)。Windows31项29过2平台跳过,测试机Linux31项全部通过。工具及其原有第一期文档仍在受保护未提交集合中,未将前一会话整批工具夹带提交;实际工具摘要绑定发布计划。
- 原保护项AGENTS.md、metrics两文件、package.json、三份部署脚本的diff与开工快照相同。相关需求/设计/测试用例已同步;本轮验收文档另做精确增量提交。证据:本机.local-data/releases/上述编号/以及%TEMP%/cmpp-fixes-20260908/browser-acceptance.json、threshold-browser.log、status-api.log及deployed截图)。自动发布报告不自动导入人工业务结果,人工验收见同目录business-acceptance.md与本节。
- 未执行:预生产发布、Git推送、真实短信发送/重投、外部Webhook发送(测试机配置0个)、次日定时自然触发、长时导入压力与实际备份恢复演练。已有每小时日报事务超时线索另案保留,不在本轮六项及格式化器修复范围。
## 2026-09-08 14:49 分页交互修复与提交前验收
- 授权:两项分页修复并本地提交,不推送、不部署。开工main/HEAD为6d3c783;只读ls-remote确认远端main为50ae372,本地领先2提交、暂存为空。发布工具、metrics和既有文档等修改继续保护;相关脏文档只提交本轮追加内容。
- 需求/设计:[需求分页补充](first-version-development-requirements.md#2026-09-08-分页交互补充)、[运营修复分页补充](operations-fixes-20260908.md#分页交互补充2026-09-08)、用例OPS-PAGE0908-0105。没有后端、端口、CSS或数据库结构变更。
- 只读复现:真实测试页面质量Tab容量控件位于page-actionsGET /api/admin/report-details返回pageSize=10、total=294、10行且页面无容量选择。当前代码同样固定10。新增deferred回归还明确复现旧25条请求晚返回覆盖100条新列表(修复前失败),据此加请求序号/卸载失效保护,当前错误继续显示。
- 实现:四个质量Tab的容量Select与各自底部分页合并,空结果保留选择;报备明细默认25且支持10/25/50/100,改容量回首页、清空勾选、保留已应用筛选。公共Pagination新props可选,旧消费者不出现额外下拉;以独立输入组件按页码重置跳转草稿,消除既有effect触发的质量门禁错误。
- 代码验证:node node_modules/vitest/vitest.mjs run --maxWorkers=2,全量24文件118项通过;定向包括质量5项、报备工作台9项、公共分页3项。TypeScript --noEmit、Vite生产构建、changed-code格式与ESLint、结构检查、Stylelint、CSS治理及15项工具测试、安全/部署检查、包体门禁、git diff --check通过。当前环境npm不在PATH,直接执行package.json对应的已有Node CLI,未安装依赖。ESLint保留两个既有loadData依赖warning0 errorChart分包约181.65KiB gzip、入口106.42KiB gzip,包体门禁通过。
- 真实浏览器:Browser插件及其skill未提供,使用本机Playwright/Edge。最终生产构建本地127.0.0.1:4173临时预览代理到测试100.93.204.60:12026真实API;后端精确版本仍2c228a94e13b628447720c24bed45fb169acca6fcmpp-api active、Redis PONG。正常使用已有专用管理员登录并退出,无认证状态落盘。未修改测试运行产物或后端端口。
- 验收结果:四Tab底部容量、独立日期/容量保留、三视口1600×1000/1366×768/390×844下拉完整可操作、刷新和跨路由通过,页面无水平溢出,console/pageerror/HTTP业务错误均0。签名质量2026-08-20真实10个结果,四档均返回完整10行;未报备样本为空,四档空态可选且无越界。报备明细总294,四档分别返回10/25/50/100行,第二页请求容量正确,回25后回到第1页且勾选清空;按现有签名搜索命中6条,改容量后关键词/总数保留;无匹配词返回0且分页正常。原状态记录页面未新增下拉,真实页码跳转仍有效。
- 验收脚本问题与边界:独立Vite开发服务器曾出现本机连接超时,改用同一验收进程创建/关闭生产预览后通过;一次脚本假定Escape关闭Select导致等待超时,按当前触发按钮关闭方式修正后全流程通过,未扩改Select。错误/乱序以隔离测试验证;未报备真实非空、热力图超过100个维度、全部旧分页消费者的逐页人工验收未覆盖。真实API数据来自现有服务及PostgreSQL查询;本轮不修改数据库,未另取得测试受限数据库配置作直接SQL对账。
- 证据位于%TEMP%/cmpp-pagination-20260908before-quality/report截图、acceptance.json、filter-acceptance.json、三视口截图、frontend-tests.log、build.log、format.log、lint.log和开工diff快照。截图/脚本/凭据不进入提交。临时预览已关闭。
- 交付边界:本地代码、需求、设计和用例完成;本节随本轮精确文件/追加hunk作本地提交,提交号见Git记录。不推送、不测试部署、不预生产部署;不发送/补发/重投/入队短信,不变更余额、通道、客户配置或恢复管理员。测试环境仍运行旧2c228a9,本地提交不等于已上线。
@@ -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);
},
);
});
+37 -38
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,11 +296,12 @@ function AnalyticsPanel({ kind }: { kind: string }) {
pagination={false}
rowKey="signatureId"
/>
{(signatureQuality?.total ?? 0) > 0 ? (
<Pagination
pageSize={pageSize}
onPageSizeChange={changePageSize}
nextDisabled={
(signatureQuality?.page ?? 1) >=
Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))
Math.max(1, Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? pageSize)))
}
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
onPageChange={changeSignaturePage}
@@ -324,15 +309,18 @@ function AnalyticsPanel({ kind }: { kind: string }) {
page={signatureQuality?.page ?? 1}
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
total={signatureQuality?.total ?? 0}
totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
totalPages={Math.max(
1,
Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? pageSize)),
)}
/>
) : null}
</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,7 +497,17 @@ function RetirementHeatmap({
</tbody>
</table>
</div>
</>
) : (
<p className="empty-state">
{deferredKeyword
? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
)}
<Pagination
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
nextDisabled={currentPage >= totalPages}
onNext={() => setPage(currentPage + 1)}
onPageChange={setPage}
@@ -514,19 +517,13 @@ function RetirementHeatmap({
total={rows.length}
totalPages={totalPages}
/>
</>
) : (
<p className="empty-state">
{deferredKeyword
? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
)}
</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,8 +588,9 @@ function UnreportedSignaturesCard({
pagination={false}
rowKey={(record) => `${record.signatureId}:${record.applicationId ?? ''}`}
/>
{(data?.total ?? 0) > 0 ? (
<Pagination
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
nextDisabled={(data?.page ?? 1) >= totalPages}
onNext={() => onPageChange((data?.page ?? 1) + 1)}
onPageChange={onPageChange}
@@ -600,7 +600,6 @@ function UnreportedSignaturesCard({
total={data?.total ?? 0}
totalPages={totalPages}
/>
) : null}
</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',
+61
View File
@@ -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(
<Pagination
total={30}
page={2}
previousDisabled={false}
nextDisabled={false}
onPrevious={previous}
onNext={next}
onPageChange={changePage}
/>,
);
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(<Pagination total={60} pageSize={25} onPageSizeChange={vi.fn()} onPageChange={changePage} />);
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(<Pagination total={100} page={1} onPageChange={changePage} />);
fireEvent.change(screen.getByLabelText('跳转页码'), { target: { value: '8' } });
expect(screen.getByLabelText('跳转页码')).toHaveValue(8);
expect(changePage).not.toHaveBeenCalled();
rerender(<Pagination total={100} page={2} onPageChange={changePage} />);
expect(screen.getByLabelText('跳转页码')).toHaveValue(2);
rerender(<Pagination total={100} page={1} onPageChange={changePage} />);
expect(screen.getByLabelText('跳转页码')).toHaveValue(1);
fireEvent.change(screen.getByLabelText('跳转页码'), { target: { value: '20' } });
fireEvent.keyDown(screen.getByLabelText('跳转页码'), { key: 'Enter' });
expect(changePage).toHaveBeenLastCalledWith(10);
});
});
+70 -12
View File
@@ -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 (
<label className="ui-pagination__jump">
{' '}
<input
aria-label="跳转页码"
min="1"
max={pages}
onChange={(event) => setTargetPage(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') onPageChange(Number(targetPage));
}}
type="number"
value={targetPage}
/>{' '}
/ {pages}
</label>
);
}
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({
<div className="ui-pagination">
{typeof total === 'number' ? <span> {total} </span> : <span />}
<div>
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost"></Button>
{onPageChange ? <Button disabled={page <= 1} onClick={() => changePage(1)} size="sm" variant="ghost"></Button> : null}
<Button size="sm" variant="secondary">{page}</Button>
{onPageChange ? <label className="ui-pagination__jump"> <input aria-label="跳转页码" min="1" max={pages} onChange={(event) => setTargetPage(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') changePage(Number(targetPage)); }} type="number" value={targetPage} /> / {pages} </label> : null}
{onPageChange ? <Button disabled={page >= pages} onClick={() => changePage(pages)} size="sm" variant="ghost"></Button> : null}
<Button disabled={nextDisabled} onClick={onNext} size="sm" variant="ghost"></Button>
{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))}
/>
) : null}
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">
</Button>
{onPageChange ? (
<Button disabled={page <= 1} onClick={() => changePage(1)} size="sm" variant="ghost">
</Button>
) : null}
<Button size="sm" variant="secondary">
{page}
</Button>
{onPageChange ? <PaginationJump key={page} page={page} pages={pages} onPageChange={changePage} /> : null}
{onPageChange ? (
<Button disabled={page >= pages} onClick={() => changePage(pages)} size="sm" variant="ghost">
</Button>
) : null}
<Button disabled={nextDisabled} onClick={onNext} size="sm" variant="ghost">
</Button>
</div>
</div>
);
@@ -72,7 +127,10 @@ export function InlineTextPreview({ label, leading, children }: InlineTextPrevie
return (
<div className="ui-inline-text-preview">
<span>{label}</span>
<p>{leading}{children}</p>
<p>
{leading}
{children}
</p>
</div>
);
}