diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index add1596..1be4609 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -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)。 diff --git a/docs/operations-fixes-20260908.md b/docs/operations-fixes-20260908.md index 1766995..d371424 100644 --- a/docs/operations-fixes-20260908.md +++ b/docs/operations-fixes-20260908.md @@ -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-01~05。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 363948d..beb6a69 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -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检查两页面,刷新/跨路由,回归原公共分页消费者 | 下拉完整可见、可操作,表格内部滚动不造成页面整体溢出;旧消费者不新增容量控件;跳转输入在真实页码改变后重置,返回旧页不恢复未提交草稿 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 1364d33..88dcc81 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -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-01~05。没有后端、端口、CSS或数据库结构变更。 +- 只读复现:真实测试页面质量Tab容量控件位于page-actions;GET /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依赖warning,0 error;Chart分包约181.65KiB gzip、入口106.42KiB gzip,包体门禁通过。 +- 真实浏览器:Browser插件及其skill未提供,使用本机Playwright/Edge。最终生产构建本地127.0.0.1:4173临时预览代理到测试100.93.204.60:12026真实API;后端精确版本仍2c228a94e13b628447720c24bed45fb169acca6f,cmpp-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-20260908:before-quality/report截图、acceptance.json、filter-acceptance.json、三视口截图、frontend-tests.log、build.log、format.log、lint.log和开工diff快照。截图/脚本/凭据不进入提交。临时预览已关闭。 +- 交付边界:本地代码、需求、设计和用例完成;本节随本轮精确文件/追加hunk作本地提交,提交号见Git记录。不推送、不测试部署、不预生产部署;不发送/补发/重投/入队短信,不变更余额、通道、客户配置或恢复管理员。测试环境仍运行旧2c228a9,本地提交不等于已上线。 diff --git a/src/apps/admin/AdminAnalyticsPage.test.tsx b/src/apps/admin/AdminAnalyticsPage.test.tsx index 45e546e..53926a4 100644 --- a/src/apps/admin/AdminAnalyticsPage.test.tsx +++ b/src/apps/admin/AdminAnalyticsPage.test.tsx @@ -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(); + 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(); + 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); + }, + ); }); diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index f6370e0..306e240 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -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 (
tab.value === kind)?.label}>
@@ -249,17 +244,6 @@ function AnalyticsPanel({ kind }: { kind: string }) {

日期、筛选和分页在当前页签内独立生效。

- - {(signatureQuality?.total ?? 0) > 0 ? ( - = - 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} + = + 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)), + )} + />
) : null} {kind === 'enterprise' ? ( void; date: string; dimensionType: 'enterprise' | 'channel'; dimensions: SignatureRetirementHeatmapDimension[]; @@ -504,16 +497,6 @@ function RetirementHeatmap({ - = totalPages} - onNext={() => setPage(currentPage + 1)} - onPageChange={setPage} - onPrevious={() => setPage(currentPage - 1)} - page={currentPage} - previousDisabled={currentPage <= 1} - total={rows.length} - totalPages={totalPages} - /> ) : (

@@ -522,11 +505,25 @@ function RetirementHeatmap({ : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}

)} + = totalPages} + onNext={() => setPage(currentPage + 1)} + onPageChange={setPage} + onPrevious={() => setPage(currentPage - 1)} + page={currentPage} + previousDisabled={currentPage <= 1} + total={rows.length} + totalPages={totalPages} + /> ); } function UnreportedSignaturesCard({ + pageSize, + onPageSizeChange, data, keyword, loading, @@ -534,6 +531,8 @@ function UnreportedSignaturesCard({ onPageChange, onSearch, }: { + pageSize: number; + onPageSizeChange: (pageSize: number) => void; data: (PagedResult & { 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 (
@@ -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 ? ( +