fix: harden notification polling and simplify pagination controls
This commit is contained in:
@@ -2239,3 +2239,8 @@
|
|||||||
本节明确 5.19.1 中的事务范围为“每个日期独立事务”,四日窗口不共用一个长事务。对账、应用/通道利润及四类质量报表在同一日期内原子重建;失败保留该日旧报表,继续处理窗口内其他日期,只有四日全部成功才标记当天完成,失败下次检查仍可重试。默认启动 15 秒后执行、每小时检查,不承诺固定分钟触发。
|
本节明确 5.19.1 中的事务范围为“每个日期独立事务”,四日窗口不共用一个长事务。对账、应用/通道利润及四类质量报表在同一日期内原子重建;失败保留该日旧报表,继续处理窗口内其他日期,只有四日全部成功才标记当天完成,失败下次检查仍可重试。默认启动 15 秒后执行、每小时检查,不承诺固定分钟触发。
|
||||||
|
|
||||||
应用利润成本只扫描目标日原短信关联的全部 accepted 提交,包括跨日补发;收入、成本快照、分片审计优先和历史成功回执兼容语义不变。通道维度仍按实际提交日统计。报表使用专属有限事务预算和数据库日锁,不调整发送/计费事务。T-5 及更早数据不被正常日任务改写,历史缺口须单独授权补齐;本轮不新增自动历史重算。实现与验收见 [日报生成超时修复](report-generation-reliability-20260908.md)。
|
应用利润成本只扫描目标日原短信关联的全部 accepted 提交,包括跨日补发;收入、成本快照、分片审计优先和历史成功回执兼容语义不变。通道维度仍按实际提交日统计。报表使用专属有限事务预算和数据库日锁,不调整发送/计费事务。T-5 及更早数据不被正常日任务改写,历史缺口须单独授权补齐;本轮不新增自动历史重算。实现与验收见 [日报生成超时修复](report-generation-reliability-20260908.md)。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-09-08 夜补充:公共容量控件与顶栏通知可靠性
|
||||||
|
|
||||||
|
通道报备明细及签名质量四Tab统一使用公共Pagination,去掉可见“每页数量”文字,仅显示容量选项,保持可访问名称、10/25/50/100、默认25和各Tab独立日期。运营顶栏通知刷新应合并重复触发、限制在途批次、超时取消、隐藏/离线/锁定暂停和有上限的失败退避;计数失败保留上次真实值并明确暂不可用,不能归零冒充成功。实现与浏览器外部注入问题边界见operations-fixes-20260908.md“浏览器异常与通知刷新修复”,不改业务端口、后端计数口径或短信链路。
|
||||||
|
|||||||
@@ -34,3 +34,25 @@
|
|||||||
- 通道报备明细首次进入/刷新默认25,改变容量回到第一页、清空当页选择,保留已应用筛选及URL范围;总数与页面内容来自同一个真实分页接口。增加请求序号和卸载失效保护,过期成功与失败都不能覆盖最新列表或错误;当前请求失败继续真实展示。
|
- 通道报备明细首次进入/刷新默认25,改变容量回到第一页、清空当页选择,保留已应用筛选及URL范围;总数与页面内容来自同一个真实分页接口。增加请求序号和卸载失效保护,过期成功与失败都不能覆盖最新列表或错误;当前请求失败继续真实展示。
|
||||||
- 实际新增容量消费者只有AdminAnalyticsPage的四个模块与AdminReportTasksPage;不改CSS、后端、数据库、权限、详情/状态修改/导出逻辑或其他列表的容量设置。共享Pagination的原消费者通过兼容单测和前端全量回归验证。
|
- 实际新增容量消费者只有AdminAnalyticsPage的四个模块与AdminReportTasksPage;不改CSS、后端、数据库、权限、详情/状态修改/导出逻辑或其他列表的容量设置。共享Pagination的原消费者通过兼容单测和前端全量回归验证。
|
||||||
- 验收覆盖四档请求、翻页后改容量、空结果、独立日期/筛选、乱序响应、跳转输入草稿、三视口和刷新/路由切换。执行结果见testing-progress.md本轮分页记录;用例OPS-PAGE0908-01~05。
|
- 验收覆盖四档请求、翻页后改容量、空结果、独立日期/筛选、乱序响应、跳转输入草稿、三视口和刷新/路由切换。执行结果见testing-progress.md本轮分页记录;用例OPS-PAGE0908-01~05。
|
||||||
|
|
||||||
|
|
||||||
|
## 浏览器异常与通知刷新修复(2026-09-08 夜)
|
||||||
|
|
||||||
|
本轮授权为修复、本地提交、测试环境部署;预生产只读诊断,不推送、不发布预生产。补充本方案的公共分页和运营顶栏通知行为,不改变业务数据、API端口或计数口径。
|
||||||
|
|
||||||
|
### 当前证据与修复边界
|
||||||
|
|
||||||
|
- `et.reportAllChanges (<anonymous>: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)。
|
||||||
|
|||||||
@@ -5271,3 +5271,16 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对
|
|||||||
| TC-DAILY-0908-07 | 默认/有效/非法/超大事务超时配置,实际 PostgreSQL 限时及事务结束 | 默认 30000ms、maxWait 5000ms;非法值回退默认、最大 120000ms;statement_timeout 仅当前事务有效,其他业务事务不改变 |
|
| TC-DAILY-0908-07 | 默认/有效/非法/超大事务超时配置,实际 PostgreSQL 限时及事务结束 | 默认 30000ms、maxWait 5000ms;非法值回退默认、最大 120000ms;statement_timeout 仅当前事务有效,其他业务事务不改变 |
|
||||||
| TC-DAILY-0908-08 | 失败后次周期、成功后同日重复周期,以及启动 15 秒内销毁服务 | 失败继续重试、全成功后同日跳过;销毁清除启动及周期定时器,无销毁后新任务 |
|
| TC-DAILY-0908-08 | 失败后次周期、成功后同日重复周期,以及启动 15 秒内销毁服务 | 失败继续重试、全成功后同日跳过;销毁清除启动及周期定时器,无销毁后新任务 |
|
||||||
| TC-DAILY-0908-09 | 真实生成后调用报表查询/汇总/分页及 CSV | 数据与 PostgreSQL 一致;既有日期/租户/维度筛选和金额精度不变,API 无假成功或静态数据 |
|
| 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或修改业务配置 |
|
||||||
|
|||||||
@@ -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。脚本、源库结果未输出凭据或短信正文。
|
- 证据:%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 更早缺口仍须部署修复后单独授权补齐。
|
- 未执行:预生产完整任务写入验收/自然定时观察、两环境部署、历史缺口补齐、完整应用登录与浏览器。本轮无前端改动;本地 HTTP 是独立真实报表 Controller/Service/PG,不代表完整认证/UI 验收。旧 api/tools/verify-report-recalculation.ts 含过期金额断言和业务造数,不执行也不在本轮改写。T-5 更早缺口仍须部署修复后单独授权补齐。
|
||||||
- 交付:本地报表代码、测试工具、需求/设计/用例和本节一起按精确 7 文件/文档增量提交,提交号以 Git 为准;未推送、未测试部署、未预生产部署、未短信发送/补发/重投/入队,未修改业务余额、通道、客户配置或管理员。提交前保护校验确认全部已有跟踪修改未被覆盖。
|
- 交付:本地报表代码、测试工具、需求/设计/用例和本节一起按精确 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目录。
|
||||||
|
|||||||
@@ -137,3 +137,8 @@
|
|||||||
- 弹窗:使用 `Modal`。
|
- 弹窗:使用 `Modal`。
|
||||||
|
|
||||||
新增页面或改版时,先检查是否可以复用以上组件;只有现有组件无法表达业务交互时,才新增全局组件。
|
新增页面或改版时,先检查是否可以复用以上组件;只有现有组件无法表达业务交互时,才新增全局组件。
|
||||||
|
|
||||||
|
|
||||||
|
### 公共分页容量展示补充(2026-09-08 夜)
|
||||||
|
|
||||||
|
公共Pagination中的容量Select只显示“10/25/50/100 条/页”选中项,不显示“每页数量”标题;与翻页控件放在同一操作区。复用sr-only隐藏标签并以唯一id关联,保持键盘和读屏可访问;不要删除其他表单Select的可见标签。签名质量四Tab和通道报备明细沿用该组件,默认25和独立筛选状态保持。顶栏通知接口失败时须说明计数暂不可用,既有真实值只能作为上次数据保留,不能归零伪装无通知。
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import { request, withQuery } from '../core/httpClient';
|
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 = {
|
export const adminInfrastructureMonitoringApi = {
|
||||||
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
|
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
|
||||||
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
|
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
|
||||||
getInfrastructureMonitoringNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary'),
|
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
|
||||||
getInfrastructureAlertThresholds: () => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
|
request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary', {
|
||||||
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds', { method: 'PUT', body: JSON.stringify(body) }),
|
signal,
|
||||||
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 }) }),
|
}),
|
||||||
|
getInfrastructureAlertThresholds: () =>
|
||||||
|
request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
|
||||||
|
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) =>
|
||||||
|
request<InfrastructureAlertSettings>('/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 }) },
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ import type {
|
|||||||
export const adminOperationsApi = {
|
export const adminOperationsApi = {
|
||||||
getDashboard: (tenantId?: string) =>
|
getDashboard: (tenantId?: string) =>
|
||||||
request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||||
getPendingAudits: (tenantId?: string) =>
|
getPendingAudits: (tenantId?: string, signal?: AbortSignal) =>
|
||||||
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
|
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }),
|
||||||
getSendQuality: (date?: string) =>
|
getSendQuality: (date?: string) =>
|
||||||
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||||
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
|
|||||||
@@ -2,15 +2,35 @@ import { request, withQuery } from '../core/httpClient';
|
|||||||
import type { SecurityAlert, SecurityBlock, SecurityOverview, SecurityProtectedNetwork, SecurityRule } from '../types';
|
import type { SecurityAlert, SecurityBlock, SecurityOverview, SecurityProtectedNetwork, SecurityRule } from '../types';
|
||||||
|
|
||||||
export const adminSecurityDetectionApi = {
|
export const adminSecurityDetectionApi = {
|
||||||
getSecurityOverview: (range = '24h') => request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
|
getSecurityOverview: (range = '24h') =>
|
||||||
getSecurityNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary'),
|
request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
|
||||||
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)),
|
getSecurityNotificationSummary: (signal?: AbortSignal) =>
|
||||||
|
request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary', { signal }),
|
||||||
|
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) =>
|
||||||
|
request<{ items: SecurityAlert[]; total: number }>(withQuery('/admin/security-detection/alerts', query)),
|
||||||
listSecurityRules: () => request<SecurityRule[]>('/admin/security-detection/rules'),
|
listSecurityRules: () => request<SecurityRule[]>('/admin/security-detection/rules'),
|
||||||
updateSecurityRule: (id: string, body: Partial<SecurityRule>) => request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
updateSecurityRule: (id: string, body: Partial<SecurityRule>) =>
|
||||||
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) => request<SecurityBlock>(`/admin/security-detection/alerts/${id}/block`, { method: 'POST', body: JSON.stringify(body) }),
|
request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
ignoreSecurityAlert: (id: string, reason: string) => request<{ success: boolean }>(`/admin/security-detection/alerts/${id}/ignore`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
blockSecurityAlert: (id: string, body: { durationSeconds: number; reason: string }) =>
|
||||||
|
request<SecurityBlock>(`/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<SecurityBlock[]>('/admin/security-detection/blocks'),
|
listSecurityBlocks: () => request<SecurityBlock[]>('/admin/security-detection/blocks'),
|
||||||
unblockSecurityBlock: (id: string, reason: string) => request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
unblockSecurityBlock: (id: string, reason: string) =>
|
||||||
|
request<SecurityBlock>(`/admin/security-detection/blocks/${id}/unblock`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
}),
|
||||||
listProtectedNetworks: () => request<SecurityProtectedNetwork[]>('/admin/security-detection/protected-networks'),
|
listProtectedNetworks: () => request<SecurityProtectedNetwork[]>('/admin/security-detection/protected-networks'),
|
||||||
addProtectedNetwork: (body: { network: string; name: string; reason: string }) => request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', { method: 'POST', body: JSON.stringify(body) }),
|
addProtectedNetwork: (body: { network: string; name: string; reason: string }) =>
|
||||||
|
request<SecurityProtectedNetwork>('/admin/security-detection/protected-networks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,26 +12,75 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
export const adminSignatureRetirementApi = {
|
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: {
|
saveSignatureRetirementRule: (body: {
|
||||||
ruleType: SignatureRetirementRuleType; targetId?: string; enabled: boolean;
|
ruleType: SignatureRetirementRuleType;
|
||||||
mobileWindowDays: number; mobileThreshold: number; unicomWindowDays: number; unicomThreshold: number;
|
targetId?: string;
|
||||||
telecomWindowDays: number; telecomThreshold: number; messageTemplate?: string;
|
enabled: boolean;
|
||||||
}) => request<SignatureRetirementRule>('/admin/signature-retirement/rules', { method: 'PUT', body: JSON.stringify(body) }),
|
mobileWindowDays: number;
|
||||||
|
mobileThreshold: number;
|
||||||
|
unicomWindowDays: number;
|
||||||
|
unicomThreshold: number;
|
||||||
|
telecomWindowDays: number;
|
||||||
|
telecomThreshold: number;
|
||||||
|
messageTemplate?: string;
|
||||||
|
}) =>
|
||||||
|
request<SignatureRetirementRule>('/admin/signature-retirement/rules', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) =>
|
createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) =>
|
||||||
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }),
|
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', {
|
||||||
deleteSignatureRetirementWebhook: (id: string) => request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
|
method: 'POST',
|
||||||
listSignatureRetirementMessages: (query: { dateFrom?: string; dateTo?: string; dimensionType?: string; tenantId?: string; applicationId?: string; signatureKeyword?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
body: JSON.stringify(body),
|
||||||
request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
|
}),
|
||||||
getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'),
|
deleteSignatureRetirementWebhook: (id: string) =>
|
||||||
readSignatureRetirementMessage: (id: string) => request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
|
request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
|
||||||
readAllSignatureRetirementMessagesToday: () => request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
|
listSignatureRetirementMessages: (
|
||||||
suppressSignatureRetirementMessage: (id: string, body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }) =>
|
query: {
|
||||||
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, { method: 'POST', body: JSON.stringify(body) }),
|
dateFrom?: string;
|
||||||
listSignatureRetirementSuppressions: () => request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
|
dateTo?: string;
|
||||||
|
dimensionType?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
signatureKeyword?: string;
|
||||||
|
channelId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
) => request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
|
||||||
|
getSignatureRetirementUnreadCount: (signal?: AbortSignal) =>
|
||||||
|
request<{ count: number }>('/admin/signature-retirement/unread-count', { signal }),
|
||||||
|
readSignatureRetirementMessage: (id: string) =>
|
||||||
|
request<SignatureRetirementMessage>(`/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<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
listSignatureRetirementSuppressions: () =>
|
||||||
|
request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
|
||||||
cancelSignatureRetirementSuppression: (id: string, reason: string) =>
|
cancelSignatureRetirementSuppression: (id: string, reason: string) =>
|
||||||
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, {
|
||||||
getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })),
|
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 } = {}) =>
|
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<PagedResult<UnreportedSignatureItem> & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)),
|
request<PagedResult<UnreportedSignatureItem> & { date: string }>(
|
||||||
|
withQuery('/admin/signature-retirement/unreported-signatures', query),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,12 +34,47 @@ describe('Pagination compatibility', () => {
|
|||||||
|
|
||||||
it('derives total pages from the opted-in size when totalPages is omitted', () => {
|
it('derives total pages from the opted-in size when totalPages is omitted', () => {
|
||||||
const changePage = vi.fn();
|
const changePage = vi.fn();
|
||||||
render(<Pagination total={60} pageSize={25} onPageSizeChange={vi.fn()} onPageChange={changePage} />);
|
const changePageSize = vi.fn();
|
||||||
|
render(<Pagination total={60} pageSize={25} onPageSizeChange={changePageSize} onPageChange={changePage} />);
|
||||||
|
|
||||||
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('25 条/页');
|
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('25 条/页');
|
||||||
|
expect(screen.getByLabelText(/^每页数量/)).toHaveAccessibleName(/^每页数量/);
|
||||||
|
expect(screen.getByText('每页数量')).toHaveClass('sr-only');
|
||||||
expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
|
expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
|
||||||
fireEvent.click(screen.getByRole('button', { name: '末页' }));
|
fireEvent.click(screen.getByRole('button', { name: '末页' }));
|
||||||
expect(changePage).toHaveBeenLastCalledWith(3);
|
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(
|
||||||
|
<>
|
||||||
|
<Pagination pageSize={25} onPageSizeChange={firstChange} />
|
||||||
|
<Pagination pageSize={50} onPageSizeChange={secondChange} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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', () => {
|
it('resets only the jump draft when the page changes and does not restore an older draft', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useId, useState, type ReactNode } from 'react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Select } from '@/components/ui/Select';
|
import { Select } from '@/components/ui/Select';
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ export function Pagination({
|
|||||||
pageSizeOptions = [10, 25, 50, 100],
|
pageSizeOptions = [10, 25, 50, 100],
|
||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
}: PaginationProps) {
|
}: PaginationProps) {
|
||||||
|
const pageSizeId = useId();
|
||||||
const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / (pageSize ?? 10)) : page));
|
const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / (pageSize ?? 10)) : page));
|
||||||
|
|
||||||
function changePage(nextPage: number) {
|
function changePage(nextPage: number) {
|
||||||
@@ -91,12 +92,17 @@ export function Pagination({
|
|||||||
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
||||||
<div>
|
<div>
|
||||||
{typeof pageSize === 'number' && onPageSizeChange ? (
|
{typeof pageSize === 'number' && onPageSizeChange ? (
|
||||||
<Select
|
<>
|
||||||
label="每页数量"
|
<label className="sr-only" htmlFor={pageSizeId}>
|
||||||
value={String(pageSize)}
|
每页数量
|
||||||
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
|
</label>
|
||||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
<Select
|
||||||
/>
|
id={pageSizeId}
|
||||||
|
value={String(pageSize)}
|
||||||
|
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
|
||||||
|
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">
|
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">
|
||||||
上一页
|
上一页
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { beforeEach, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { readSession, writeSession } from '@/api/session';
|
||||||
import { AdminLayout } from './AdminLayout';
|
import { AdminLayout } from './AdminLayout';
|
||||||
import { AppShell } from './AppShell';
|
import { AppShell } from './AppShell';
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ const api = vi.hoisted(() => ({
|
|||||||
getSecurityNotificationSummary: vi.fn(),
|
getSecurityNotificationSummary: vi.fn(),
|
||||||
getInfrastructureMonitoringNotificationSummary: vi.fn(),
|
getInfrastructureMonitoringNotificationSummary: vi.fn(),
|
||||||
current: vi.fn(),
|
current: vi.fn(),
|
||||||
|
request: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/api/adminApi', () => ({ adminApi: api, portalSessionApi: { current: api.current } }));
|
vi.mock('@/api/adminApi', () => ({ adminApi: api, portalSessionApi: { current: api.current } }));
|
||||||
|
vi.mock('@/api/core/httpClient', () => ({ request: api.request }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -37,15 +40,18 @@ beforeEach(() => {
|
|||||||
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 7 });
|
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 7 });
|
||||||
api.getSecurityNotificationSummary.mockResolvedValue({ count: 2, criticalCount: 1 });
|
api.getSecurityNotificationSummary.mockResolvedValue({ count: 2, criticalCount: 1 });
|
||||||
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 5, criticalCount: 0 });
|
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 5, criticalCount: 0 });
|
||||||
|
api.request.mockResolvedValue({ count: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
function LocationProbe() {
|
function LocationProbe() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return <p data-testid="route">{location.pathname + location.search}</p>;
|
return <p data-testid="route">{location.pathname + location.search}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAdmin() {
|
function renderAdminShell() {
|
||||||
render(
|
return render(
|
||||||
<MemoryRouter initialEntries={['/admin']}>
|
<MemoryRouter initialEntries={['/admin']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AdminLayout />}>
|
<Route element={<AdminLayout />}>
|
||||||
@@ -54,6 +60,10 @@ async function renderAdmin() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAdmin() {
|
||||||
|
renderAdminShell();
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('7'), {
|
await waitFor(() => expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('7'), {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
@@ -156,3 +166,164 @@ it('does not add notification controls to a client shell without notification it
|
|||||||
expect(screen.queryByRole('button', { name: '报备任务提醒' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: '报备任务提醒' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: '预警通知' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: '预警通知' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const advancePolling = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||||
|
|
||||||
|
function openNotificationMenu(button: string) {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: button }));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('retains all six known counts on failure and clears unavailable descriptions after a successful retry', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
api.request.mockImplementation((path: string) =>
|
||||||
|
Promise.resolve({ count: path.includes('sending-monitor') ? 13 : 11 }),
|
||||||
|
);
|
||||||
|
const { unmount } = renderAdminShell();
|
||||||
|
await act(() => Promise.resolve());
|
||||||
|
await advancePolling(0);
|
||||||
|
expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('18');
|
||||||
|
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('20');
|
||||||
|
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
|
||||||
|
|
||||||
|
for (const endpoint of [
|
||||||
|
api.getPendingAudits,
|
||||||
|
api.getSignatureRetirementUnreadCount,
|
||||||
|
api.getSecurityNotificationSummary,
|
||||||
|
api.getInfrastructureMonitoringNotificationSummary,
|
||||||
|
api.request,
|
||||||
|
]) {
|
||||||
|
endpoint.mockRejectedValue(new Error('network unavailable'));
|
||||||
|
}
|
||||||
|
await act(async () => fireEvent(window, new Event('cmpp-retirement-count-refresh')));
|
||||||
|
openNotificationMenu('报备任务提醒');
|
||||||
|
const reporting = screen.getByRole('menu', { name: '报备任务提醒' });
|
||||||
|
expect(within(reporting).getAllByText('计数暂不可用,显示上次结果')).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
within(reporting)
|
||||||
|
.getByRole('menuitem', { name: /签名清退预警/ })
|
||||||
|
.querySelector('strong'),
|
||||||
|
).toHaveTextContent('7');
|
||||||
|
expect(
|
||||||
|
within(reporting)
|
||||||
|
.getByRole('menuitem', { name: /报备状态变化通知/ })
|
||||||
|
.querySelector('strong'),
|
||||||
|
).toHaveTextContent('11');
|
||||||
|
openNotificationMenu('预警通知');
|
||||||
|
const alerts = screen.getByRole('menu', { name: '预警中心' });
|
||||||
|
expect(within(alerts).getAllByText('计数暂不可用,显示上次结果')).toHaveLength(3);
|
||||||
|
for (const [label, count] of [
|
||||||
|
['发送质量告警', '13'],
|
||||||
|
['安全检测与封禁', '2'],
|
||||||
|
['系统监控告警', '5'],
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
within(alerts)
|
||||||
|
.getByRole('menuitem', { name: new RegExp(label) })
|
||||||
|
.querySelector('strong'),
|
||||||
|
).toHaveTextContent(count);
|
||||||
|
}
|
||||||
|
openNotificationMenu('通知');
|
||||||
|
expect(screen.getAllByRole('menuitem', { name: /计数暂不可用/ })).toHaveLength(6);
|
||||||
|
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
|
||||||
|
expect(screen.getByRole('menuitem', { name: /签名导入待审/ })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/admin/signatures?tab=import',
|
||||||
|
);
|
||||||
|
|
||||||
|
api.getPendingAudits.mockResolvedValue({
|
||||||
|
enterpriseCertifications: 0,
|
||||||
|
smsAudits: 0,
|
||||||
|
templates: 0,
|
||||||
|
signatures: 0,
|
||||||
|
signatureImports: 2,
|
||||||
|
drainageInfos: 0,
|
||||||
|
total: 2,
|
||||||
|
});
|
||||||
|
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 3 });
|
||||||
|
api.getSecurityNotificationSummary.mockResolvedValue({ count: 1, criticalCount: 0 });
|
||||||
|
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 0, criticalCount: 0 });
|
||||||
|
api.request.mockResolvedValue({ count: 0 });
|
||||||
|
await advancePolling(30000);
|
||||||
|
expect(screen.queryByRole('menuitem', { name: /计数暂不可用/ })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('2');
|
||||||
|
expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('3');
|
||||||
|
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('1');
|
||||||
|
openNotificationMenu('报备任务提醒');
|
||||||
|
expect(screen.getByText('今日未读且未抑制')).toBeVisible();
|
||||||
|
expect(screen.queryByText(/计数暂不可用/)).not.toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks unknown counts as loading then unavailable, while retaining successful domains at timeout', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let resolveStalled!: (value: { count: number }) => void;
|
||||||
|
api.getSignatureRetirementUnreadCount.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<{ count: number }>((resolve) => {
|
||||||
|
resolveStalled = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { unmount } = renderAdminShell();
|
||||||
|
await act(() => Promise.resolve());
|
||||||
|
await advancePolling(0);
|
||||||
|
openNotificationMenu('报备任务提醒');
|
||||||
|
expect(screen.getAllByText('计数加载中')).toHaveLength(2);
|
||||||
|
openNotificationMenu('通知');
|
||||||
|
expect(screen.getAllByRole('menuitem', { name: /计数加载中/ })).toHaveLength(6);
|
||||||
|
const signal = api.getSignatureRetirementUnreadCount.mock.calls[0][0] as AbortSignal;
|
||||||
|
expect(api.getPendingAudits.mock.calls[0]).toEqual([undefined, signal]);
|
||||||
|
expect(api.getSecurityNotificationSummary.mock.calls[0]).toEqual([signal]);
|
||||||
|
expect(api.getInfrastructureMonitoringNotificationSummary.mock.calls[0]).toEqual([signal]);
|
||||||
|
expect(api.request.mock.calls.slice(0, 2).every(([, options]) => options.signal === signal)).toBe(true);
|
||||||
|
await advancePolling(15000);
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
|
||||||
|
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('7');
|
||||||
|
openNotificationMenu('报备任务提醒');
|
||||||
|
const retirement = screen.getByRole('menuitem', { name: /签名清退预警/ });
|
||||||
|
expect(retirement).toHaveTextContent('计数暂不可用,尚未取得结果');
|
||||||
|
expect(screen.getByText('按企业与小时汇总的未读消息')).toBeVisible();
|
||||||
|
await act(async () => resolveStalled({ count: 999 }));
|
||||||
|
expect(retirement.querySelector('strong')).toHaveTextContent('0');
|
||||||
|
expect(screen.getByRole('button', { name: '报备任务提醒' })).not.toHaveTextContent('999');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the last monitor count when its successful HTTP response says unavailable', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
api.request.mockResolvedValue({ count: 8 });
|
||||||
|
const { unmount } = renderAdminShell();
|
||||||
|
await act(() => Promise.resolve());
|
||||||
|
await advancePolling(0);
|
||||||
|
api.request.mockResolvedValue({ count: 0, unavailable: true });
|
||||||
|
await act(async () => fireEvent(window, new Event('cmpp-monitor-alert-refresh')));
|
||||||
|
openNotificationMenu('预警通知');
|
||||||
|
const monitor = screen.getByRole('menuitem', { name: /发送质量告警/ });
|
||||||
|
expect(monitor.querySelector('strong')).toHaveTextContent('8');
|
||||||
|
expect(monitor).toHaveTextContent('计数暂不可用,显示上次结果');
|
||||||
|
expect(screen.getByText('1 条严重告警待处置')).toBeVisible();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply or repeat an old account request after the local session identity changes', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let resolveStalled!: (value: { count: number }) => void;
|
||||||
|
api.getSignatureRetirementUnreadCount.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<{ count: number }>((resolve) => {
|
||||||
|
resolveStalled = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { unmount } = renderAdminShell();
|
||||||
|
await act(() => Promise.resolve());
|
||||||
|
await advancePolling(0);
|
||||||
|
const session = readSession('admin')!;
|
||||||
|
writeSession({ ...session, user: { ...session.user, id: 'another-admin' } });
|
||||||
|
await act(async () => resolveStalled({ count: 999 }));
|
||||||
|
expect(screen.getByRole('button', { name: '报备任务提醒' })).not.toHaveTextContent('999');
|
||||||
|
fireEvent(window, new Event('focus'));
|
||||||
|
fireEvent(window, new Event('cmpp-audit-count-refresh'));
|
||||||
|
await advancePolling(60000);
|
||||||
|
expect(api.getPendingAudits).toHaveBeenCalledTimes(1);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|||||||
+108
-96
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -35,6 +35,7 @@ import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/ses
|
|||||||
import { AppShell } from '@/layouts/AppShell';
|
import { AppShell } from '@/layouts/AppShell';
|
||||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||||
import { request } from '@/api/core/httpClient';
|
import { request } from '@/api/core/httpClient';
|
||||||
|
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
|
||||||
|
|
||||||
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||||
enterpriseCertifications: 0,
|
enterpriseCertifications: 0,
|
||||||
@@ -44,92 +45,95 @@ const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
|||||||
drainageInfos: 0,
|
drainageInfos: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NotificationValue<T> = { value: T; status: 'loading' | 'ready' | 'unavailable'; hasValue: boolean };
|
||||||
|
|
||||||
|
function initialNotification<T>(value: T): NotificationValue<T> {
|
||||||
|
return { value, status: 'loading', hasValue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNotification<T>(
|
||||||
|
previous: NotificationValue<T>,
|
||||||
|
result: PromiseSettledResult<T>,
|
||||||
|
available = true,
|
||||||
|
): NotificationValue<T> {
|
||||||
|
return result.status === 'fulfilled' && available
|
||||||
|
? { value: result.value, status: 'ready', hasValue: true }
|
||||||
|
: { ...previous, status: 'unavailable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationDescription<T>(notification: NotificationValue<T>, description: string) {
|
||||||
|
if (notification.status === 'loading') return '计数加载中';
|
||||||
|
if (notification.status === 'unavailable') {
|
||||||
|
return notification.hasValue ? '计数暂不可用,显示上次结果' : '计数暂不可用,尚未取得结果';
|
||||||
|
}
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPollNotifications(userId: string) {
|
||||||
|
const session = readSession('admin');
|
||||||
|
return Boolean(
|
||||||
|
session &&
|
||||||
|
session.user.id === userId &&
|
||||||
|
!session.locked &&
|
||||||
|
Date.now() - getLastUserActivityAt() < session.idleTimeoutSeconds * 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
return (
|
return (
|
||||||
<PortalSessionBoundary portal="admin">
|
<PortalSessionBoundary portal="admin">
|
||||||
{(session) => <AdminAuthenticatedLayout session={session} />}
|
{(session) => <AdminAuthenticatedLayout key={session.user.id} session={session} />}
|
||||||
</PortalSessionBoundary>
|
</PortalSessionBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
const [pendingAudits, setPendingAudits] = useState(() => initialNotification(EMPTY_PENDING_AUDITS));
|
||||||
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
|
const [retirementSummary, setRetirementSummary] = useState(() => initialNotification({ count: 0 }));
|
||||||
const [reportSummary, setReportSummary] = useState({ count: 0, unavailable: false });
|
const [reportSummary, setReportSummary] = useState(() => initialNotification({ count: 0 }));
|
||||||
const [monitorSummary, setMonitorSummary] = useState({ count: 0, unavailable: false });
|
const [monitorSummary, setMonitorSummary] = useState(() => initialNotification({ count: 0 }));
|
||||||
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
const [securityAlertSummary, setSecurityAlertSummary] = useState(() =>
|
||||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
initialNotification({ count: 0, criticalCount: 0 }),
|
||||||
|
);
|
||||||
|
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState(() =>
|
||||||
|
initialNotification({ count: 0, criticalCount: 0 }),
|
||||||
|
);
|
||||||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||||||
const loadPendingAuditCount = useCallback(() => {
|
const canPoll = useCallback(() => canPollNotifications(session.user.id), [session.user.id]);
|
||||||
const currentSession = readSession('admin');
|
const loadPendingAuditCount = useCallback(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||||||
if (
|
|
||||||
!currentSession ||
|
|
||||||
currentSession.locked ||
|
|
||||||
Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
||||||
Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
adminApi.getPendingAudits(),
|
settleNotificationRequest(adminApi.getPendingAudits(undefined, signal), signal),
|
||||||
adminApi.getSignatureRetirementUnreadCount(),
|
settleNotificationRequest(adminApi.getSignatureRetirementUnreadCount(signal), signal),
|
||||||
adminApi.getSecurityNotificationSummary(),
|
settleNotificationRequest(adminApi.getSecurityNotificationSummary(signal), signal),
|
||||||
adminApi.getInfrastructureMonitoringNotificationSummary(),
|
settleNotificationRequest(adminApi.getInfrastructureMonitoringNotificationSummary(signal), signal),
|
||||||
request<{ count: number }>('/admin/report-notifications/summary'),
|
settleNotificationRequest(request<{ count: number }>('/admin/report-notifications/summary', { signal }), signal),
|
||||||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary'),
|
settleNotificationRequest(
|
||||||
])
|
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary', { signal }),
|
||||||
.then(([audits, retirement, security, infrastructure, reporting, monitor]) => {
|
signal,
|
||||||
setMonitorSummary((previous) =>
|
),
|
||||||
monitor.status === 'fulfilled'
|
]);
|
||||||
? { count: monitor.value.count, unavailable: Boolean(monitor.value.unavailable) }
|
if (!isCurrent()) return false;
|
||||||
: { ...previous, unavailable: true },
|
const [audits, retirement, security, infrastructure, reporting, monitor] = results;
|
||||||
);
|
const monitorAvailable = monitor.status === 'fulfilled' && !monitor.value.unavailable;
|
||||||
setReportSummary((previous) =>
|
setPendingAudits((previous) => updateNotification(previous, audits));
|
||||||
reporting.status === 'fulfilled'
|
setRetirementSummary((previous) => updateNotification(previous, retirement));
|
||||||
? { count: reporting.value.count, unavailable: false }
|
setSecurityAlertSummary((previous) => updateNotification(previous, security));
|
||||||
: { ...previous, unavailable: true },
|
setInfrastructureAlertSummary((previous) => updateNotification(previous, infrastructure));
|
||||||
);
|
setReportSummary((previous) => updateNotification(previous, reporting));
|
||||||
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
|
setMonitorSummary((previous) => updateNotification(previous, monitor, monitorAvailable));
|
||||||
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
|
return results.every((result) => result.status === 'fulfilled') && monitorAvailable;
|
||||||
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
|
|
||||||
setInfrastructureAlertSummary(
|
|
||||||
infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 },
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setPendingAudits(EMPTY_PENDING_AUDITS);
|
|
||||||
setRetirementUnreadCount(0);
|
|
||||||
setSecurityAlertSummary({ count: 0, criticalCount: 0 });
|
|
||||||
setInfrastructureAlertSummary({ count: 0, criticalCount: 0 });
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useNotificationPolling({
|
||||||
if (session.portal !== 'admin' || sessionLocked) {
|
enabled: session.portal === 'admin' && !sessionLocked,
|
||||||
return;
|
canPoll,
|
||||||
}
|
poll: loadPendingAuditCount,
|
||||||
loadPendingAuditCount();
|
});
|
||||||
const timer = window.setInterval(loadPendingAuditCount, 30000);
|
const auditLabel = (label: string) =>
|
||||||
const onFocus = () => loadPendingAuditCount();
|
pendingAudits.status === 'ready'
|
||||||
const onAuditRefresh = () => loadPendingAuditCount();
|
? label
|
||||||
window.addEventListener('focus', onFocus);
|
: `${label}(${pendingAudits.status === 'loading' ? '计数加载中' : '计数暂不可用'})`;
|
||||||
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
|
||||||
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
|
||||||
window.addEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
|
||||||
window.addEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
|
||||||
window.addEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
|
||||||
window.addEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
window.removeEventListener('focus', onFocus);
|
|
||||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
|
||||||
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
|
||||||
window.removeEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
|
||||||
window.removeEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
|
||||||
window.removeEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
|
||||||
window.removeEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
|
||||||
};
|
|
||||||
}, [loadPendingAuditCount, session.portal, sessionLocked]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -144,14 +148,14 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
reportingNotifications={[
|
reportingNotifications={[
|
||||||
{
|
{
|
||||||
label: '报备状态变化通知',
|
label: '报备状态变化通知',
|
||||||
count: reportSummary.count,
|
count: reportSummary.value.count,
|
||||||
description: reportSummary.unavailable ? '消息计数暂不可用,请重试' : '按企业与小时汇总的未读消息',
|
description: notificationDescription(reportSummary, '按企业与小时汇总的未读消息'),
|
||||||
to: '/admin/report-records?tab=readiness',
|
to: '/admin/report-records?tab=readiness',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '签名清退预警',
|
label: '签名清退预警',
|
||||||
count: retirementUnreadCount,
|
count: retirementSummary.value.count,
|
||||||
description: '今日未读且未抑制',
|
description: notificationDescription(retirementSummary, '今日未读且未抑制'),
|
||||||
to: '/admin/signature-retirement',
|
to: '/admin/signature-retirement',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -163,40 +167,48 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
alertNotifications={[
|
alertNotifications={[
|
||||||
{
|
{
|
||||||
label: '发送质量告警',
|
label: '发送质量告警',
|
||||||
count: monitorSummary.count,
|
count: monitorSummary.value.count,
|
||||||
description: monitorSummary.unavailable ? '告警计数暂不可用,请重试' : '未读活动发送质量告警',
|
description: notificationDescription(monitorSummary, '未读活动发送质量告警'),
|
||||||
to: '/admin/monitor?tab=alerts',
|
to: '/admin/monitor?tab=alerts',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '安全检测与封禁',
|
label: '安全检测与封禁',
|
||||||
count: securityAlertSummary.count,
|
count: securityAlertSummary.value.count,
|
||||||
description:
|
description: notificationDescription(
|
||||||
securityAlertSummary.criticalCount > 0
|
securityAlertSummary,
|
||||||
? `${securityAlertSummary.criticalCount} 条严重告警待处置`
|
securityAlertSummary.value.criticalCount > 0
|
||||||
|
? `${securityAlertSummary.value.criticalCount} 条严重告警待处置`
|
||||||
: '待处置安全告警',
|
: '待处置安全告警',
|
||||||
|
),
|
||||||
to: '/admin/security-detection',
|
to: '/admin/security-detection',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '系统监控告警',
|
label: '系统监控告警',
|
||||||
count: infrastructureAlertSummary.count,
|
count: infrastructureAlertSummary.value.count,
|
||||||
description:
|
description: notificationDescription(
|
||||||
infrastructureAlertSummary.criticalCount > 0
|
infrastructureAlertSummary,
|
||||||
? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警`
|
infrastructureAlertSummary.value.criticalCount > 0
|
||||||
|
? `${infrastructureAlertSummary.value.criticalCount} 条 Prometheus 严重告警`
|
||||||
: 'Prometheus 活动告警',
|
: 'Prometheus 活动告警',
|
||||||
|
),
|
||||||
to: '/admin/system-monitoring#active-alerts',
|
to: '/admin/system-monitoring#active-alerts',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
auditNotifications={[
|
auditNotifications={[
|
||||||
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
|
|
||||||
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
|
|
||||||
{ label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' },
|
|
||||||
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/signatures' },
|
|
||||||
{
|
{
|
||||||
label: '签名导入待审',
|
label: auditLabel('企业认证待审'),
|
||||||
count: pendingAudits.signatureImports ?? 0,
|
count: pendingAudits.value.enterpriseCertifications,
|
||||||
|
to: '/admin/enterprise-audit',
|
||||||
|
},
|
||||||
|
{ label: auditLabel('短信审核待审'), count: pendingAudits.value.smsAudits, to: '/admin/sms-audit' },
|
||||||
|
{ label: auditLabel('模板待审'), count: pendingAudits.value.templates, to: '/admin/templates' },
|
||||||
|
{ label: auditLabel('签名待审'), count: pendingAudits.value.signatures, to: '/admin/signatures' },
|
||||||
|
{
|
||||||
|
label: auditLabel('签名导入待审'),
|
||||||
|
count: pendingAudits.value.signatureImports ?? 0,
|
||||||
to: '/admin/signatures?tab=import',
|
to: '/admin/signatures?tab=import',
|
||||||
},
|
},
|
||||||
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
|
{ label: auditLabel('引流信息待审'), count: pendingAudits.value.drainageInfos, to: '/admin/drainage-audits' },
|
||||||
]}
|
]}
|
||||||
navSections={[
|
navSections={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { adminOperationsApi } from '@/api/admin/operations.api';
|
||||||
|
import { adminSignatureRetirementApi } from '@/api/admin/signature-retirement.api';
|
||||||
|
import { adminSecurityDetectionApi } from '@/api/admin/security-detection.api';
|
||||||
|
import { adminInfrastructureMonitoringApi } from '@/api/admin/infrastructure-monitoring.api';
|
||||||
|
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPoll = () => true;
|
||||||
|
const refresh = () => window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
|
||||||
|
const focus = () => window.dispatchEvent(new Event('focus'));
|
||||||
|
const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.spyOn(document, 'hidden', 'get').mockReturnValue(false);
|
||||||
|
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a single batch active and coalesces business events into one follow-up, without focus bursts', async () => {
|
||||||
|
const first = deferred<boolean>();
|
||||||
|
const second = deferred<boolean>();
|
||||||
|
const poll = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise).mockResolvedValue(true);
|
||||||
|
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||||
|
await advance(0);
|
||||||
|
act(() => {
|
||||||
|
refresh();
|
||||||
|
refresh();
|
||||||
|
window.dispatchEvent(new Event('cmpp-security-alert-count-refresh'));
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
expect(poll).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => first.resolve(true));
|
||||||
|
expect(poll).toHaveBeenCalledTimes(2);
|
||||||
|
act(() => {
|
||||||
|
focus();
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
await act(async () => second.resolve(true));
|
||||||
|
await advance(29999);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(2);
|
||||||
|
await advance(1);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(3);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts stalled requests at 15 seconds and retries at 30, 60, then at most 120 seconds despite focus or refresh', async () => {
|
||||||
|
const stalled = deferred<boolean>();
|
||||||
|
const signals: AbortSignal[] = [];
|
||||||
|
const poll = vi.fn((signal: AbortSignal) => {
|
||||||
|
signals.push(signal);
|
||||||
|
return settleNotificationRequest(stalled.promise, signal);
|
||||||
|
});
|
||||||
|
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||||
|
await advance(0);
|
||||||
|
for (const [attempt, delay] of [30000, 60000, 120000, 120000].entries()) {
|
||||||
|
await advance(14999);
|
||||||
|
expect(signals[attempt].aborted).toBe(false);
|
||||||
|
await advance(1);
|
||||||
|
expect(signals[attempt].aborted).toBe(true);
|
||||||
|
expect(signals[attempt].reason.name).toBe('TimeoutError');
|
||||||
|
act(() => {
|
||||||
|
focus();
|
||||||
|
refresh();
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
await advance(delay - 1);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(attempt + 1);
|
||||||
|
await advance(1);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(attempt + 2);
|
||||||
|
}
|
||||||
|
unmount();
|
||||||
|
await act(async () => stalled.resolve(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets backoff after recovery and allows an explicit business refresh again', async () => {
|
||||||
|
const poll = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true);
|
||||||
|
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||||
|
await advance(0);
|
||||||
|
await advance(30000);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(2);
|
||||||
|
await advance(60000);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(3);
|
||||||
|
await advance(30000);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(4);
|
||||||
|
await act(async () => refresh());
|
||||||
|
expect(poll).toHaveBeenCalledTimes(5);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['hidden', 'offline'] as const)(
|
||||||
|
'cancels while %s, ignores a late result, and resumes only one batch',
|
||||||
|
async (state) => {
|
||||||
|
const first = deferred<number>();
|
||||||
|
const values: number[] = [];
|
||||||
|
const signals: AbortSignal[] = [];
|
||||||
|
const poll = vi.fn(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||||||
|
signals.push(signal);
|
||||||
|
const value = await settleNotificationRequest(first.promise, signal);
|
||||||
|
if (isCurrent()) values.push(value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||||
|
await advance(0);
|
||||||
|
const setAvailable = (available: boolean) => {
|
||||||
|
if (state === 'hidden') {
|
||||||
|
vi.spyOn(document, 'hidden', 'get').mockReturnValue(!available);
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
} else {
|
||||||
|
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(available);
|
||||||
|
window.dispatchEvent(new Event(available ? 'online' : 'offline'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await act(async () => setAvailable(false));
|
||||||
|
expect(signals[0].aborted).toBe(true);
|
||||||
|
await act(async () => first.resolve(9));
|
||||||
|
await advance(120000);
|
||||||
|
act(() => {
|
||||||
|
focus();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
expect(values).toEqual([]);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => {
|
||||||
|
setAvailable(true);
|
||||||
|
setAvailable(true);
|
||||||
|
});
|
||||||
|
await advance(0);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(2);
|
||||||
|
expect(values).toEqual([9]);
|
||||||
|
unmount();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('preserves a failed retry deadline across hidden, offline, and locked transitions', async () => {
|
||||||
|
const poll = vi.fn().mockResolvedValue(false);
|
||||||
|
const { rerender, unmount } = renderHook(({ enabled }) => useNotificationPolling({ enabled, canPoll, poll }), {
|
||||||
|
initialProps: { enabled: true },
|
||||||
|
});
|
||||||
|
await advance(0);
|
||||||
|
await advance(30000);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(2);
|
||||||
|
rerender({ enabled: false });
|
||||||
|
await advance(1000);
|
||||||
|
rerender({ enabled: true });
|
||||||
|
act(() => {
|
||||||
|
focus();
|
||||||
|
refresh();
|
||||||
|
window.dispatchEvent(new Event('online'));
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
});
|
||||||
|
await advance(58999);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(2);
|
||||||
|
await advance(1);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(3);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates an old batch on lock or unmount even if its source ignores cancellation', async () => {
|
||||||
|
const requests = [deferred<number>(), deferred<number>()];
|
||||||
|
const applied: number[] = [];
|
||||||
|
const signals: AbortSignal[] = [];
|
||||||
|
const poll = vi.fn(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||||||
|
const request = requests[signals.length];
|
||||||
|
signals.push(signal);
|
||||||
|
const value = await request.promise;
|
||||||
|
if (isCurrent()) applied.push(value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const { rerender, unmount } = renderHook(({ enabled }) => useNotificationPolling({ enabled, canPoll, poll }), {
|
||||||
|
initialProps: { enabled: true },
|
||||||
|
});
|
||||||
|
await advance(0);
|
||||||
|
rerender({ enabled: false });
|
||||||
|
expect(signals[0].aborted).toBe(true);
|
||||||
|
rerender({ enabled: true });
|
||||||
|
await advance(0);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(2);
|
||||||
|
await act(async () => requests[0].resolve(100));
|
||||||
|
expect(applied).toEqual([]);
|
||||||
|
unmount();
|
||||||
|
expect(signals[1].aborted).toBe(true);
|
||||||
|
await act(async () => requests[1].resolve(200));
|
||||||
|
expect(applied).toEqual([]);
|
||||||
|
await advance(120000);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts only one batch under StrictMode and none when discarded before setup completes', async () => {
|
||||||
|
const poll = vi.fn().mockResolvedValue(true);
|
||||||
|
const { unmount } = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }), {
|
||||||
|
wrapper: StrictMode,
|
||||||
|
});
|
||||||
|
expect(poll).not.toHaveBeenCalled();
|
||||||
|
await advance(0);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(1);
|
||||||
|
unmount();
|
||||||
|
const discarded = renderHook(() => useNotificationPolling({ enabled: true, canPoll, poll }));
|
||||||
|
discarded.unmount();
|
||||||
|
await advance(0);
|
||||||
|
expect(poll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels all four notification API fetches and preserves the existing tenant filter argument', async () => {
|
||||||
|
const calls: { url: string; options: RequestInit | undefined }[] = [];
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation((url, options) => {
|
||||||
|
calls.push({ url: String(url), options });
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { signal } = controller;
|
||||||
|
const pending = Promise.allSettled([
|
||||||
|
adminOperationsApi.getPendingAudits('tenant with spaces', signal),
|
||||||
|
adminSignatureRetirementApi.getSignatureRetirementUnreadCount(signal),
|
||||||
|
adminSecurityDetectionApi.getSecurityNotificationSummary(signal),
|
||||||
|
adminInfrastructureMonitoringApi.getInfrastructureMonitoringNotificationSummary(signal),
|
||||||
|
]);
|
||||||
|
expect(calls).toHaveLength(4);
|
||||||
|
expect(new URL(calls[0].url, 'http://localhost').searchParams.get('tenantId')).toBe('tenant with spaces');
|
||||||
|
expect(calls.every(({ options }) => options?.signal === signal && (options.method ?? 'GET') === 'GET')).toBe(true);
|
||||||
|
controller.abort();
|
||||||
|
const results = await pending;
|
||||||
|
expect(results.every((result) => result.status === 'rejected' && result.reason.name === 'AbortError')).toBe(true);
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ total: 4 }), { status: 200 }));
|
||||||
|
await expect(adminOperationsApi.getPendingAudits('existing-tenant')).resolves.toEqual({ total: 4 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const REFRESH_EVENTS = [
|
||||||
|
'cmpp-audit-count-refresh',
|
||||||
|
'cmpp-retirement-count-refresh',
|
||||||
|
'cmpp-report-notification-refresh',
|
||||||
|
'cmpp-monitor-alert-refresh',
|
||||||
|
'cmpp-security-alert-count-refresh',
|
||||||
|
'cmpp-infrastructure-alert-count-refresh',
|
||||||
|
];
|
||||||
|
const POLL_INTERVAL_MS = 30000;
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
// Also settles an aborted request if it is waiting outside fetch (for example, for authentication).
|
||||||
|
export function settleNotificationRequest<T>(request: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const abort = () => reject(signal.reason);
|
||||||
|
signal.addEventListener('abort', abort, { once: true });
|
||||||
|
request.then(
|
||||||
|
(value) => {
|
||||||
|
signal.removeEventListener('abort', abort);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
signal.removeEventListener('abort', abort);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (signal.aborted) {
|
||||||
|
signal.removeEventListener('abort', abort);
|
||||||
|
abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationPollingOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
canPoll: () => boolean;
|
||||||
|
poll: (signal: AbortSignal, isCurrent: () => boolean) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useNotificationPolling({ enabled, canPoll, poll }: NotificationPollingOptions) {
|
||||||
|
const retry = useRef({ failures: 0, nextEligibleAt: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let generation = 0;
|
||||||
|
let active: AbortController | undefined;
|
||||||
|
let timer: number | undefined;
|
||||||
|
let timeout: number | undefined;
|
||||||
|
let refreshPending = false;
|
||||||
|
const isAvailable = () => !document.hidden && navigator.onLine && canPoll();
|
||||||
|
const clearTimer = () => window.clearTimeout(timer);
|
||||||
|
const schedule = () => {
|
||||||
|
clearTimer();
|
||||||
|
if (disposed || active || !isAvailable()) return;
|
||||||
|
timer = window.setTimeout(() => start(false), Math.max(0, retry.current.nextEligibleAt - Date.now()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = (businessRefresh: boolean) => {
|
||||||
|
if (disposed || !isAvailable()) return;
|
||||||
|
if (active) {
|
||||||
|
refreshPending ||= businessRefresh;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() < retry.current.nextEligibleAt && (!businessRefresh || retry.current.failures > 0)) {
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimer();
|
||||||
|
refreshPending = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
active = controller;
|
||||||
|
const batchGeneration = generation;
|
||||||
|
const isCurrent = () => !disposed && generation === batchGeneration && isAvailable();
|
||||||
|
timeout = window.setTimeout(
|
||||||
|
() => controller.abort(new DOMException('通知计数请求超时', 'TimeoutError')),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
void poll(controller.signal, isCurrent)
|
||||||
|
.catch(() => false)
|
||||||
|
.then((successful) => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
active = undefined;
|
||||||
|
if (disposed) return;
|
||||||
|
if (!isCurrent()) {
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
retry.current.failures = successful ? 0 : Math.min(retry.current.failures + 1, 3);
|
||||||
|
const delay = successful ? POLL_INTERVAL_MS : POLL_INTERVAL_MS * 2 ** (retry.current.failures - 1);
|
||||||
|
retry.current.nextEligibleAt = Date.now() + delay;
|
||||||
|
if (refreshPending && successful) {
|
||||||
|
start(true);
|
||||||
|
} else {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => start(true);
|
||||||
|
const focus = () => start(false);
|
||||||
|
const availabilityChanged = () => {
|
||||||
|
if (!isAvailable()) {
|
||||||
|
clearTimer();
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
generation += 1;
|
||||||
|
refreshPending = false;
|
||||||
|
active?.abort();
|
||||||
|
} else {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', focus);
|
||||||
|
window.addEventListener('online', availabilityChanged);
|
||||||
|
window.addEventListener('offline', availabilityChanged);
|
||||||
|
document.addEventListener('visibilitychange', availabilityChanged);
|
||||||
|
REFRESH_EVENTS.forEach((event) => window.addEventListener(event, refresh));
|
||||||
|
// StrictMode's discarded setup must not dispatch a second HTTP batch.
|
||||||
|
schedule();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
generation += 1;
|
||||||
|
clearTimer();
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
active?.abort();
|
||||||
|
window.removeEventListener('focus', focus);
|
||||||
|
window.removeEventListener('online', availabilityChanged);
|
||||||
|
window.removeEventListener('offline', availabilityChanged);
|
||||||
|
document.removeEventListener('visibilitychange', availabilityChanged);
|
||||||
|
REFRESH_EVENTS.forEach((event) => window.removeEventListener(event, refresh));
|
||||||
|
};
|
||||||
|
}, [enabled, canPoll, poll]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user