fix: unify analytics and report pagination controls
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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检查两页面,刷新/跨路由,回归原公共分页消费者 | 下拉完整可见、可操作,表格内部滚动不造成页面整体溢出;旧消费者不新增容量控件;跳转输入在真实页码改变后重置,返回旧页不恢复未提交草稿 |
|
||||
|
||||
@@ -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,本地提交不等于已上线。
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user