From 6816f5617819546850b1188508eda37866f4ca3c Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Tue, 8 Sep 2026 21:44:08 +0800 Subject: [PATCH] fix: harden notification polling and simplify pagination controls --- .../first-version-development-requirements.md | 5 + docs/operations-fixes-20260908.md | 22 ++ docs/system-functional-test-cases.md | 13 + docs/testing-progress.md | 16 ++ docs/ui-design-guidelines.md | 5 + .../admin/infrastructure-monitoring.api.ts | 27 +- src/api/admin/operations.api.ts | 4 +- src/api/admin/security-detection.api.ts | 36 ++- src/api/admin/signature-retirement.api.ts | 85 ++++-- src/components/ui/PagePrimitives.test.tsx | 37 ++- src/components/ui/PagePrimitives.tsx | 20 +- src/layouts/AdminLayout.test.tsx | 179 ++++++++++++- src/layouts/AdminLayout.tsx | 204 ++++++++------- src/layouts/useNotificationPolling.test.tsx | 245 ++++++++++++++++++ src/layouts/useNotificationPolling.ts | 140 ++++++++++ 15 files changed, 897 insertions(+), 141 deletions(-) create mode 100644 src/layouts/useNotificationPolling.test.tsx create mode 100644 src/layouts/useNotificationPolling.ts diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index e99d2b6..67c2871 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2239,3 +2239,8 @@ 本节明确 5.19.1 中的事务范围为“每个日期独立事务”,四日窗口不共用一个长事务。对账、应用/通道利润及四类质量报表在同一日期内原子重建;失败保留该日旧报表,继续处理窗口内其他日期,只有四日全部成功才标记当天完成,失败下次检查仍可重试。默认启动 15 秒后执行、每小时检查,不承诺固定分钟触发。 应用利润成本只扫描目标日原短信关联的全部 accepted 提交,包括跨日补发;收入、成本快照、分片审计优先和历史成功回执兼容语义不变。通道维度仍按实际提交日统计。报表使用专属有限事务预算和数据库日锁,不调整发送/计费事务。T-5 及更早数据不被正常日任务改写,历史缺口须单独授权补齐;本轮不新增自动历史重算。实现与验收见 [日报生成超时修复](report-generation-reliability-20260908.md)。 + + +### 2026-09-08 夜补充:公共容量控件与顶栏通知可靠性 + +通道报备明细及签名质量四Tab统一使用公共Pagination,去掉可见“每页数量”文字,仅显示容量选项,保持可访问名称、10/25/50/100、默认25和各Tab独立日期。运营顶栏通知刷新应合并重复触发、限制在途批次、超时取消、隐藏/离线/锁定暂停和有上限的失败退避;计数失败保留上次真实值并明确暂不可用,不能归零冒充成功。实现与浏览器外部注入问题边界见operations-fixes-20260908.md“浏览器异常与通知刷新修复”,不改业务端口、后端计数口径或短信链路。 diff --git a/docs/operations-fixes-20260908.md b/docs/operations-fixes-20260908.md index d371424..774fcd4 100644 --- a/docs/operations-fixes-20260908.md +++ b/docs/operations-fixes-20260908.md @@ -34,3 +34,25 @@ - 通道报备明细首次进入/刷新默认25,改变容量回到第一页、清空当页选择,保留已应用筛选及URL范围;总数与页面内容来自同一个真实分页接口。增加请求序号和卸载失效保护,过期成功与失败都不能覆盖最新列表或错误;当前请求失败继续真实展示。 - 实际新增容量消费者只有AdminAnalyticsPage的四个模块与AdminReportTasksPage;不改CSS、后端、数据库、权限、详情/状态修改/导出逻辑或其他列表的容量设置。共享Pagination的原消费者通过兼容单测和前端全量回归验证。 - 验收覆盖四档请求、翻页后改容量、空结果、独立日期/筛选、乱序响应、跳转输入草稿、三视口和刷新/路由切换。执行结果见testing-progress.md本轮分页记录;用例OPS-PAGE0908-01~05。 + + +## 浏览器异常与通知刷新修复(2026-09-08 夜) + +本轮授权为修复、本地提交、测试环境部署;预生产只读诊断,不推送、不发布预生产。补充本方案的公共分页和运营顶栏通知行为,不改变业务数据、API端口或计数口径。 + +### 当前证据与修复边界 + +- `et.reportAllChanges (:2:19429)` / `n.timeout (:2:5652)` 与 GoogleChrome/web-vitals 官方 issue #792 的堆栈及偏移完全一致。DevTools 的 LiveMetrics 在名为 `DevTools Performance Metrics` 的隔离上下文注入性能脚本,并在前面加一行 `window.devToolsReportSoftNavs`,说明匿名脚本第2行的来源机制。用户报告另一台 Chrome 152.0.7977.77 打开 F12 控制台时出现;不要求先打开 Performance 面板,DevTools 初始化即启动 LiveMetrics。仓库业务源码和前端依赖未引入 reportAllChanges/web-vitals。历史2026-09-05记录只把它列为外部注入待核验,没有完成这个异常的业务代码修复。 +- 进一步在独立临时 Chrome 152.0.7977.76、真实预生产公开登录页捕获到了同名隔离上下文及匿名scriptId 5:长度20705 bytes,SHA256 `0f2eb3b63431416befd0d826255fb1736117e0ddbca120c5c3e54aca03a1810d`,首行为 `window.devToolsReportSoftNavs = true;`,第2行19429列正是未经空数组检查的 `t.entries[0].startTime`。这确认了报错代码的DevTools归属和直接缺陷:延迟报告回调读取不存在的第一个性能条目。该次正常浏览未触发异常,另一台电脑为何产生空entries仍需现场事件证据。不通过覆盖Performance API、全局error过滤或吞错伪装修复。web-vitals #792仍开放,但DevTools已在2026-08-31合入正式修复[6a47f933:Live Metrics: Handle empty INP entries](https://github.com/ChromeDevTools/devtools-frontend/commit/6a47f93393a72ca3ae77fdb0525296812d92f9a2),对应Chromium543499029,增加entries首项可选链和空条目测试;尚未确认包含该修复的已发布Chrome版本。隔离临时Chrome中已通过标准UI验证规避入口:DevTools F1 → 偏好设置 → Performance,关闭“启用软导航性能监控”,注入首行从true变false且刷新后保持。开关前后真实公开登录页各刷新验证码3次、异常均0;这证明控制开关生效,不冒称复现异常后已消除。关闭F12后同样应刷新以清除旧注入上下文。 +- 六类通知 GET 的 ERR_CONNECTION_CLOSED 是传输层失败,与匿名脚本异常分别诊断。2026-09-08 21:05–21:13,API/Nginx稳定、无OOM或新增上游超时/限流/5xx证据;本机公网H1/H2共36次请求均收到预期401,H2覆盖两个Cloudflare地址;源站本机约1–2ms,公网约1–4.65s。匿名401只证明链路可达,不代表登录后业务通过;没有依据修改Nginx、关闭HTTP2或认定Cloudflare/本机代理是根因。另一台电脑的历史连接关闭暂未复现,必须保留这一未验证项。 +- 可复核的应用缺陷:AdminLayout原先30秒定时、focus和多种业务事件均直接触发六GET,没有批次互斥/超时/隐藏暂停,线上只读日志出现1–4秒内重复整批;四类计数失败时归零。该缺陷会放大网络抖动并显示误导计数,但不能据此声称已定位TCP关闭原因。 + +### 预期行为和实现范围 + +1. 公共Pagination容量只显示“25 条/页”等选中值,不显示“每页数量”标题;复用既有sr-only隐藏标签及唯一id保留无障碍名称。适用于通道报备明细与签名质量四Tab;四档10/25/50/100、默认25、独立日期/分页、其他Select标签和无容量消费者保持。 +2. 运营顶栏仅保留一个通知请求批次,批次内六个独立只读GET并行。触发事件合并,业务刷新最多保留一次待执行刷新;失败后按30/60/120秒退避,上限120秒,focus不能绕过冷却连续发请求。 +3. 单批设置15秒超时并取消请求,页面隐藏/离线/锁屏/卸载停止轮询和在途请求,恢复后按调度条件刷新。旧批次响应不能覆盖新状态;不新增全局POST重试、短信重投或业务写入。 +4. 每类接口失败独立保留上次真实计数,并在对应通知入口说明计数暂不可用;首次未取得数据不能显示为已确认的零。成功后清除不可用状态。401/锁屏仍服从现有统一会话处理,不将权限失败包装成功。 +5. 定向测试验证批次去重、取消/超时、退避、状态保留和恢复;真实测试环境验证六接口及分页,不用静态响应或localStorage代替业务数据。故障注入仅验收失败路径,与真实成功请求证据分别记录。 + +官方依据:[web-vitals #792](https://github.com/GoogleChrome/web-vitals/issues/792)、[LiveMetrics注入](https://raw.githubusercontent.com/ChromeDevTools/devtools-frontend/main/front_end/models/live-metrics/LiveMetrics.ts)、[设置注册](https://raw.githubusercontent.com/ChromeDevTools/devtools-frontend/main/front_end/panels/timeline/timeline-meta.ts)。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 38f07b3..93e11b3 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5271,3 +5271,16 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对 | TC-DAILY-0908-07 | 默认/有效/非法/超大事务超时配置,实际 PostgreSQL 限时及事务结束 | 默认 30000ms、maxWait 5000ms;非法值回退默认、最大 120000ms;statement_timeout 仅当前事务有效,其他业务事务不改变 | | TC-DAILY-0908-08 | 失败后次周期、成功后同日重复周期,以及启动 15 秒内销毁服务 | 失败继续重试、全成功后同日跳过;销毁清除启动及周期定时器,无销毁后新任务 | | TC-DAILY-0908-09 | 真实生成后调用报表查询/汇总/分页及 CSV | 数据与 PostgreSQL 一致;既有日期/租户/维度筛选和金额精度不变,API 无假成功或静态数据 | + + +### 浏览器注入、通知刷新与公共容量回归(2026-09-08 夜) + +| 编号 | 场景/步骤 | 预期 | +| --- | --- | --- | +| OPS-PAGE0908-06 | 真实API打开通道报备明细和签名质量四Tab,在1600×1000、1366×768、390×844检查底部分页及刷新/跨路由 | 无可见“每页数量”标题,容量与翻页同区;隐藏名称可访问;10/25/50/100与默认25不变,各Tab日期独立,多个分页不串联,其他表单标签保持 | +| OPS-NOTIFY0908-01 | 通知批次仍在途时触发focus、定时和多个业务刷新事件 | 同时最多一批六GET,重复触发合并,业务事件至多一次后续刷新;不是触发多少次就发多少批 | +| OPS-NOTIFY0908-02 | 真实成功获取六类计数后单类/多类请求中断或超时 | 失败项保留上次真实值并提示暂不可用,成功项正常更新;首次失败不冒充已取得零值;没有全局POST重试 | +| OPS-NOTIFY0908-03 | 请求挂起超过15秒,连续失败,反复focus | 请求被abort;失败按30/60/120秒退避且有上限,focus不会造成重试风暴;恢复真实200后恢复普通轮询及可用说明 | +| OPS-NOTIFY0908-04 | 在途时隐藏页面/离线/锁屏/卸载,然后恢复可用状态 | 请求/计时器正确取消,旧响应不回写;恢复按调度刷新;现有401/权限/会话规则保持 | +| OPS-BROWSER0908-01 | Chrome152 F12报startTime时记录VM源码及执行上下文,对照DevTools软导航采集设置开/关并刷新切页 | 区分应用脚本与DevTools Performance Metrics;仅当实际A/B通过才记规避有效,不全局过滤异常;没有现场证据则记待验证 | +| OPS-BROWSER0908-02 | 连接关闭时对照同时间浏览器Network、源站access/error、API及Cloudflare链路 | 记录失败请求是否到达源站、协议/时间/状态;不能把匿名401或稍后正常当成历史故障已根除,不无依据关闭HTTP2或修改业务配置 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 6a6f392..c5a45be 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4764,3 +4764,19 @@ git diff --check - 证据:%TEMP%/cmpp-report-fix-20260908/{baseline.json,api-tests.log,report-tests-final.log};%TEMP%/cmpp-report-diagnosis-20260908/{profile-select.cjs.result.jsonl,compare-candidate.cjs.result.jsonl};%TEMP%/cmpp-report-pg-1788858316792/integration.log。脚本、源库结果未输出凭据或短信正文。 - 未执行:预生产完整任务写入验收/自然定时观察、两环境部署、历史缺口补齐、完整应用登录与浏览器。本轮无前端改动;本地 HTTP 是独立真实报表 Controller/Service/PG,不代表完整认证/UI 验收。旧 api/tools/verify-report-recalculation.ts 含过期金额断言和业务造数,不执行也不在本轮改写。T-5 更早缺口仍须部署修复后单独授权补齐。 - 交付:本地报表代码、测试工具、需求/设计/用例和本节一起按精确 7 文件/文档增量提交,提交号以 Git 为准;未推送、未测试部署、未预生产部署、未短信发送/补发/重投/入队,未修改业务余额、通道、客户配置或管理员。提交前保护校验确认全部已有跟踪修改未被覆盖。 + + +## 2026-09-08 夜:浏览器异常、顶栏连接失败与公共分页(进行中) + +- 本轮授权:修改、本地提交、发布测试环境;预生产只读诊断。开始分支main,HEAD/origin均ebb185b22b4457a3b5ed1c9bcc6c45992a3e7f21,分叉0/0,staged为空。19个既有tracked脏文件及release工具/诊断等untracked逐文件备份并保护,不能纳入本轮提交。两环境当前部署标记均ebb185b。 +- 已完成只读归因:公共Select可见label造成“每页数量”换行;DevTools异常与官方#792的函数名及偏移一致,用户反馈另一台Chrome152.0.7977.77 F12 Console;后续脚本捕获见下文,不把业务代码改动当成浏览器内置脚本修复。 +- 网络证据:2026-09-08 21:05–21:13源站API/Nginx稳定,API NRestarts0、无新OOM;36次公网H1/H2匿名六接口请求均401,无关闭、GOAWAY或5xx;Nginx没有限流/上游超时证据。历史另一台电脑ERR_CONNECTION_CLOSED未复现,实际关闭层尚未确定。确定应用存在整批刷新叠加和失败清零缺陷,按operations-fixes-20260908.md本轮设计做最小修复。 +- 本轮证据目录:本机TEMP/cmpp-starttime-pagination-20260908(基线保护/本轮证据),TEMP/cmpp-notification-network-20260908(只读链路采样及findings.md)。不写入认证状态或客户记录。后续追加测试、提交和标准测试发布结果。 + +- 深入浏览器证据:临时Chrome152.0.7977.76捕获DevTools Performance Metrics匿名脚本,首行softnav=true,第2行19429列确为`t.entries[0].startTime`,SHA256 0f2eb3b63431416befd0d826255fb1736117e0ddbca120c5c3e54aca03a1810d。官方修复6a47f933(2026-08-31/Chromium543499029)明确修正空entries;已在隔离浏览器F1标准设置取消软导航并刷新,脚本前缀变false,A/B均无pageerror。仅证明来源/直接缺陷及开关控制,未在另一台电脑复现;不改用户浏览器配置。证据TEMP/cmpp-devtools-headless-20260908/ab-valid。 +- 用户本机侧边浏览器已登录预生产,已读到真实运营看板与通知菜单,当前dev日志error/warn为0。进一步跨路由CUA调用发生工具超时及kernel reset,不当作应用错误或通过。预生产本轮未发布。 + +- 21:32追加排查安全误封:20:00后仅1次管理员登录失败,无新增安全告警/封禁;当前nft IPv4/IPv6封禁集合为空、Nginx deny记录0,安全代理及Fail2ban active且NRestarts0。Nginx信任22个网络并使用CF-Connecting-IP/real_ip_recursive,两观测Cloudflare地址均在信任范围,API只信任loopback;没有错误来源识别或安全误封依据,不调整安全策略。 + +- 本地实现完成:分页2文件、通知布局/调度/API透传共8文件,所有业务调用仍为原六GET。通知定向18项通过;分页与关联页面18项通过。全量前端25文件132项通过(21:36:08,45.74秒)。首轮类型检查抓到分页测试误用了Playwright的exact参数,已删除两处不属于Testing Library的参数,字符串名称仍默认精确匹配,分页4项重跑通过;随后完整lint(含TypeScript/结构/CSS)、format:check、生产build、bundle:verify、security:verify、deploy:verify均通过,git diff --check通过。未修改后端、数据库、Gateway或CSS,无本轮API/Go测试需求;真实页面待测试发布后验收。 +- 独立代码复审无阻断:覆盖单批/退避/超时部分成功、隐藏离线/锁定/卸载/身份失效和StrictMode。首次未知保留现有数字占位,但明确显示加载/未取得结果;六类失败均保留既有真实计数,不伪装正常零。实际signal→fetch测试使用测试隔离,单独标记,不当作业务验收。质量证据quality-results.json及各-final.log保存于本轮TEMP目录。 diff --git a/docs/ui-design-guidelines.md b/docs/ui-design-guidelines.md index 72d8e32..363ca99 100644 --- a/docs/ui-design-guidelines.md +++ b/docs/ui-design-guidelines.md @@ -137,3 +137,8 @@ - 弹窗:使用 `Modal`。 新增页面或改版时,先检查是否可以复用以上组件;只有现有组件无法表达业务交互时,才新增全局组件。 + + +### 公共分页容量展示补充(2026-09-08 夜) + +公共Pagination中的容量Select只显示“10/25/50/100 条/页”选中项,不显示“每页数量”标题;与翻页控件放在同一操作区。复用sr-only隐藏标签并以唯一id关联,保持键盘和读屏可访问;不要删除其他表单Select的可见标签。签名质量四Tab和通道报备明细沿用该组件,默认25和独立筛选状态保持。顶栏通知接口失败时须说明计数暂不可用,既有真实值只能作为上次数据保留,不能归零伪装无通知。 diff --git a/src/api/admin/infrastructure-monitoring.api.ts b/src/api/admin/infrastructure-monitoring.api.ts index cf5c60b..45149da 100644 --- a/src/api/admin/infrastructure-monitoring.api.ts +++ b/src/api/admin/infrastructure-monitoring.api.ts @@ -1,11 +1,28 @@ import { request, withQuery } from '../core/httpClient'; -import type { InfrastructureAlertSettings, InfrastructureAlertThresholds, InfrastructureMonitoringOverview, InfrastructureMonitoringRange } from '../types'; +import type { + InfrastructureAlertSettings, + InfrastructureAlertThresholds, + InfrastructureMonitoringOverview, + InfrastructureMonitoringRange, +} from '../types'; export const adminInfrastructureMonitoringApi = { getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) => request(withQuery('/admin/infrastructure-monitoring/overview', { range })), - getInfrastructureMonitoringNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary'), - getInfrastructureAlertThresholds: () => request('/admin/infrastructure-monitoring/alert-thresholds'), - updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) => request('/admin/infrastructure-monitoring/alert-thresholds', { method: 'PUT', body: JSON.stringify(body) }), - markInfrastructureAlertRead: (fingerprint: string, activeAt: string) => request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`, { method: 'POST', body: JSON.stringify({ activeAt }) }), + getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) => + request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary', { + signal, + }), + getInfrastructureAlertThresholds: () => + request('/admin/infrastructure-monitoring/alert-thresholds'), + updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) => + request('/admin/infrastructure-monitoring/alert-thresholds', { + method: 'PUT', + body: JSON.stringify(body), + }), + markInfrastructureAlertRead: (fingerprint: string, activeAt: string) => + request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>( + `/admin/infrastructure-monitoring/alerts/${fingerprint}/read`, + { method: 'POST', body: JSON.stringify({ activeAt }) }, + ), }; diff --git a/src/api/admin/operations.api.ts b/src/api/admin/operations.api.ts index e2b6220..5a2239c 100644 --- a/src/api/admin/operations.api.ts +++ b/src/api/admin/operations.api.ts @@ -39,8 +39,8 @@ import type { export const adminOperationsApi = { getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), - getPendingAudits: (tenantId?: string) => - request(withQuery('/admin/operations/pending-audits', { tenantId })), + getPendingAudits: (tenantId?: string, signal?: AbortSignal) => + request(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }), getSendQuality: (date?: string) => request(withQuery('/admin/operations/send-quality', { date })), getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => diff --git a/src/api/admin/security-detection.api.ts b/src/api/admin/security-detection.api.ts index d26c321..458a85c 100644 --- a/src/api/admin/security-detection.api.ts +++ b/src/api/admin/security-detection.api.ts @@ -2,15 +2,35 @@ import { request, withQuery } from '../core/httpClient'; import type { SecurityAlert, SecurityBlock, SecurityOverview, SecurityProtectedNetwork, SecurityRule } from '../types'; export const adminSecurityDetectionApi = { - getSecurityOverview: (range = '24h') => request(withQuery('/admin/security-detection/overview', { range })), - getSecurityNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary'), - listSecurityAlerts: (query: Record = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)), + getSecurityOverview: (range = '24h') => + request(withQuery('/admin/security-detection/overview', { range })), + getSecurityNotificationSummary: (signal?: AbortSignal) => + request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary', { signal }), + listSecurityAlerts: (query: Record = {}) => + request<{ items: SecurityAlert[]; total: number }>(withQuery('/admin/security-detection/alerts', query)), listSecurityRules: () => request('/admin/security-detection/rules'), - updateSecurityRule: (id: string, body: Partial) => request(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) => request(`/admin/security-detection/alerts/${id}/block`, { method: 'POST', body: JSON.stringify(body) }), - ignoreSecurityAlert: (id: string, reason: string) => request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, { method: 'POST', body: JSON.stringify({ reason }) }), + updateSecurityRule: (id: string, body: Partial) => + request(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) => + request(`/admin/security-detection/alerts/${id}/block`, { + method: 'POST', + body: JSON.stringify(body), + }), + ignoreSecurityAlert: (id: string, reason: string) => + request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), listSecurityBlocks: () => request('/admin/security-detection/blocks'), - unblockSecurityBlock: (id: string, reason: string) => request(`/admin/security-detection/blocks/${id}/unblock`, { method: 'POST', body: JSON.stringify({ reason }) }), + unblockSecurityBlock: (id: string, reason: string) => + request(`/admin/security-detection/blocks/${id}/unblock`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), listProtectedNetworks: () => request('/admin/security-detection/protected-networks'), - addProtectedNetwork: (body: { network: string; name: string; reason: string }) => request('/admin/security-detection/protected-networks', { method: 'POST', body: JSON.stringify(body) }), + addProtectedNetwork: (body: { network: string; name: string; reason: string }) => + request('/admin/security-detection/protected-networks', { + method: 'POST', + body: JSON.stringify(body), + }), }; diff --git a/src/api/admin/signature-retirement.api.ts b/src/api/admin/signature-retirement.api.ts index e7c5d32..42a6fab 100644 --- a/src/api/admin/signature-retirement.api.ts +++ b/src/api/admin/signature-retirement.api.ts @@ -12,26 +12,75 @@ import type { } from '../types'; export const adminSignatureRetirementApi = { - getSignatureRetirementConfiguration: () => request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>('/admin/signature-retirement/configuration'), + getSignatureRetirementConfiguration: () => + request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>( + '/admin/signature-retirement/configuration', + ), saveSignatureRetirementRule: (body: { - ruleType: SignatureRetirementRuleType; targetId?: string; enabled: boolean; - mobileWindowDays: number; mobileThreshold: number; unicomWindowDays: number; unicomThreshold: number; - telecomWindowDays: number; telecomThreshold: number; messageTemplate?: string; - }) => request('/admin/signature-retirement/rules', { method: 'PUT', body: JSON.stringify(body) }), + ruleType: SignatureRetirementRuleType; + targetId?: string; + enabled: boolean; + mobileWindowDays: number; + mobileThreshold: number; + unicomWindowDays: number; + unicomThreshold: number; + telecomWindowDays: number; + telecomThreshold: number; + messageTemplate?: string; + }) => + request('/admin/signature-retirement/rules', { + method: 'PUT', + body: JSON.stringify(body), + }), createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) => - request('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }), - deleteSignatureRetirementWebhook: (id: string) => request(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }), - listSignatureRetirementMessages: (query: { dateFrom?: string; dateTo?: string; dimensionType?: string; tenantId?: string; applicationId?: string; signatureKeyword?: string; channelId?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/signature-retirement/messages', query)), - getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'), - readSignatureRetirementMessage: (id: string) => request(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }), - readAllSignatureRetirementMessagesToday: () => request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }), - suppressSignatureRetirementMessage: (id: string, body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }) => - request(`/admin/signature-retirement/messages/${id}/suppress`, { method: 'POST', body: JSON.stringify(body) }), - listSignatureRetirementSuppressions: () => request('/admin/signature-retirement/suppressions'), + request('/admin/signature-retirement/webhooks', { + method: 'POST', + body: JSON.stringify(body), + }), + deleteSignatureRetirementWebhook: (id: string) => + request(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }), + listSignatureRetirementMessages: ( + query: { + dateFrom?: string; + dateTo?: string; + dimensionType?: string; + tenantId?: string; + applicationId?: string; + signatureKeyword?: string; + channelId?: string; + page?: number; + pageSize?: number; + } = {}, + ) => request>(withQuery('/admin/signature-retirement/messages', query)), + getSignatureRetirementUnreadCount: (signal?: AbortSignal) => + request<{ count: number }>('/admin/signature-retirement/unread-count', { signal }), + readSignatureRetirementMessage: (id: string) => + request(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }), + readAllSignatureRetirementMessagesToday: () => + request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }), + suppressSignatureRetirementMessage: ( + id: string, + body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }, + ) => + request(`/admin/signature-retirement/messages/${id}/suppress`, { + method: 'POST', + body: JSON.stringify(body), + }), + listSignatureRetirementSuppressions: () => + request('/admin/signature-retirement/suppressions'), cancelSignatureRetirementSuppression: (id: string, reason: string) => - request(`/admin/signature-retirement/suppressions/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) }), - getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })), + request(`/admin/signature-retirement/suppressions/${id}/cancel`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + getSignatureRetirementHeatmap: (date?: string) => + request<{ + date: string; + dimensions: SignatureRetirementHeatmapDimension[]; + items: SignatureRetirementHeatmapItem[]; + }>(withQuery('/admin/signature-retirement/heatmap', { date })), getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => - request & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)), + request & { date: string }>( + withQuery('/admin/signature-retirement/unreported-signatures', query), + ), }; diff --git a/src/components/ui/PagePrimitives.test.tsx b/src/components/ui/PagePrimitives.test.tsx index f4b2433..d2f74d7 100644 --- a/src/components/ui/PagePrimitives.test.tsx +++ b/src/components/ui/PagePrimitives.test.tsx @@ -34,12 +34,47 @@ describe('Pagination compatibility', () => { it('derives total pages from the opted-in size when totalPages is omitted', () => { const changePage = vi.fn(); - render(); + const changePageSize = vi.fn(); + render(); expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('25 条/页'); + expect(screen.getByLabelText(/^每页数量/)).toHaveAccessibleName(/^每页数量/); + expect(screen.getByText('每页数量')).toHaveClass('sr-only'); expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3'); fireEvent.click(screen.getByRole('button', { name: '末页' })); expect(changePage).toHaveBeenLastCalledWith(3); + for (const size of [10, 25, 50, 100]) { + fireEvent.click(screen.getByLabelText(/^每页数量/)); + expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([ + '10 条/页', + '25 条/页', + '50 条/页', + '100 条/页', + ]); + fireEvent.click(screen.getByRole('option', { name: `${size} 条/页` })); + expect(changePageSize).toHaveBeenLastCalledWith(size); + } + }); + + it('keeps hidden page-size labels uniquely associated across multiple paginations', () => { + const firstChange = vi.fn(); + const secondChange = vi.fn(); + render( + <> + + + , + ); + + const controls = screen.getAllByLabelText(/^每页数量/); + expect(controls).toHaveLength(2); + expect(controls[0]).toHaveAccessibleName(/^每页数量/); + expect(controls[1]).toHaveAccessibleName(/^每页数量/); + expect(controls[0].id).not.toBe(controls[1].id); + fireEvent.click(controls[1]); + fireEvent.click(screen.getByRole('option', { name: '100 条/页' })); + expect(secondChange).toHaveBeenCalledWith(100); + expect(firstChange).not.toHaveBeenCalled(); }); it('resets only the jump draft when the page changes and does not restore an older draft', () => { diff --git a/src/components/ui/PagePrimitives.tsx b/src/components/ui/PagePrimitives.tsx index 695b753..e3cd079 100644 --- a/src/components/ui/PagePrimitives.tsx +++ b/src/components/ui/PagePrimitives.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from 'react'; +import { useId, useState, type ReactNode } from 'react'; import { Button } from '@/components/ui/Button'; import { Select } from '@/components/ui/Select'; @@ -80,6 +80,7 @@ export function Pagination({ pageSizeOptions = [10, 25, 50, 100], onPageSizeChange, }: PaginationProps) { + const pageSizeId = useId(); const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / (pageSize ?? 10)) : page)); function changePage(nextPage: number) { @@ -91,12 +92,17 @@ export function Pagination({ {typeof total === 'number' ? 显示 {total} 条记录 : }
{typeof pageSize === 'number' && onPageSizeChange ? ( - ({ value: String(value), label: `${value} 条/页` }))} + onChange={(event) => onPageSizeChange(Number(event.target.value))} + /> + ) : null}