Compare commits
2
Commits
ebb185b22b
...
809175b544
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
809175b544 | ||
|
|
6816f56178 |
@@ -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“浏览器异常与通知刷新修复”,不改业务端口、后端计数口径或短信链路。
|
||||
|
||||
@@ -34,3 +34,36 @@
|
||||
- 通道报备明细首次进入/刷新默认25,改变容量回到第一页、清空当页选择,保留已应用筛选及URL范围;总数与页面内容来自同一个真实分页接口。增加请求序号和卸载失效保护,过期成功与失败都不能覆盖最新列表或错误;当前请求失败继续真实展示。
|
||||
- 实际新增容量消费者只有AdminAnalyticsPage的四个模块与AdminReportTasksPage;不改CSS、后端、数据库、权限、详情/状态修改/导出逻辑或其他列表的容量设置。共享Pagination的原消费者通过兼容单测和前端全量回归验证。
|
||||
- 验收覆盖四档请求、翻页后改容量、空结果、独立日期/筛选、乱序响应、跳转输入草稿、三视口和刷新/路由切换。执行结果见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)。
|
||||
|
||||
|
||||
## 通知取消复核与前端构建模式补充(2026-09-08 22:35)
|
||||
|
||||
第三轮真实测试浏览器诊断确认一个独立发布缺陷:标准 prepare 的 NODE_ENV=test 使 Vite/React 插件选择开发运行时,实际线上 index-TLGANFcU.js 包含 19 处 react_stack_bottom_frame、2 处 jsx-dev-runtime,SHA-256 为 a8db74bfdb24ef0892ca77fee891297d492d07b979c8653d5fd7c86c186e080c。先前仅依据生产构建命令推断线上运行生产 React 的判断撤回,以实际下载产物为准。
|
||||
|
||||
18 次通知原生 signal abort 均在可见且在线文档中发生,耗时约 3.1~14ms、reason=AbortError,全部落在 JS 225:176295 的通知 effect cleanup;React 146:26427/25089 映射为删除树 passive unmount 清理及其递归遍历,不是 15 秒超时。已确认取消来自布局清理链,但不能进一步把每次清理都写成 StrictMode 双 effect,不能据此宣称另一台电脑历史 ERR_CONNECTION_CLOSED 已定位或根除。原六类通知 GET 的保值、退避、取消及会话设计保持。
|
||||
|
||||
最小修复为发布工具仅给前端 npm run build 显式 production 环境;新增 Vite build/configResolved 的 isProduction 守卫,在错误模式下拒绝产出发布包,不改变 serve/Vitest配置或后端接口。真实 test/development 模式两个构建负例已拒绝;Windows 工具 37 项中 35 通过、2 平台跳过。默认构建/lint 正在执行、Linux 新 37 项待返回,不提前记录通过。须以重新生成的正式候选及真实三尺寸、刷新/跨路由、通知六 GET 验收关闭本问题,不能修改浏览器错误过滤来代替正式包复测。
|
||||
|
||||
最终检查结果补记(2026-09-08 22:36):默认 npm run build 成功,npm run lint 退出码 0;两个真实 test/development 构建负例准确拒绝。Linux 新版工具 37/37 通过、0 失败/0 跳过,耗时 0.192s,日志 SHA-256 为 305e7df43ff9d6506a755fd8d5e0adb6926e5ee89eb719d8baa015c231a4060b,位于主 TEMP/release-linux-unit-buildmode-2026-09-08T14-34-37-205Z/linux-unittest.log。本结果补齐上文执行中的本地检查;正式候选及真实部署页面仍待验收。
|
||||
|
||||
@@ -310,3 +310,29 @@ Windows内置tar解压含中文路径的完整归档曾报Invalid empty pathname
|
||||
本次独立备份为测试/var/backups/cmpp-platform/night-risk-20260907T230742和预生产/opt/cmpp-platform-backups/night-risk-20260907T231021;前一轮失败备份目录不算恢复资产。旧API依赖及dist仍在各自/opt/cmpp-night-previous-时间戳,历史logs依赖继续保留。新审核记录和预留状态需结合业务恢复,不能把应用回退当作数据回退。
|
||||
|
||||
验收需同时读journald与应用append日志;本轮预生产journal无warning不能掩盖API文件内既有日报事务超时。在线回执可在备份与验收之间自动退款,遇到余额摘要变化应核对实际业务流水、消息和服务时间,不回写余额以强行制造摘要一致。测试安全代理缺少运行目录、预生产日报刷新超时作为独立遗留问题登记。
|
||||
|
||||
|
||||
## 2026-09-08 前端候选安全门禁依赖修复
|
||||
|
||||
标准 prepare 的 security:verify 使用 createRequire(api/package.json) 读取 API 本地依赖及 brace-expansion 兼容适配器。仅安装根 node_modules 的前端候选可能向上解析到根依赖,导致安全门禁阻断。现已调整为:impact.frontend 或 impact.api 任一成立时,在独立候选依次执行 npm ci --include=dev、npm --prefix api ci --include=dev,再进入 security:verify 与 deploy:verify;不跳过安全检查,不在运行目录手工安装依赖。
|
||||
|
||||
npm run prisma:generate、API 生产构建,以及线上 api/dist、api/node_modules 的切换仍仅由 impact.api 控制。前端变更为安全检查安装的候选 API 依赖不进入线上 API;不因此停止或重启 API/Worker。依赖安装或门禁失败不生成可部署的 candidateHash,后续 deploy 拒绝进入备份和切换。
|
||||
|
||||
原测试发布编号 20260908T134432-6816f5617819-b92bbb04 已由标准 status 核验停在 prepare,backup=null,尚未备份、停服或切换。工具修复改变 toolHash,须重新生成绑定新摘要的计划并执行 fresh preflight,不修改旧计划或复用失效现场检查;原失败候选和日志保留。
|
||||
|
||||
本次补充 4 项发布工具隔离回归:Windows 共 35 项,33 通过、2 项因符号链接权限与 Linux flock 平台限制跳过;Linux 35/35 通过且临时测试夹具残留 0。Linux 日志 SHA-256 为 77106f006cf22ea33c3b744c9c7cbf43d3885b32f4c6d71d8d00b49b1578e900;日志和补丁见本机 %TEMP%/cmpp-starttime-pagination-20260908。隔离测试使用真实临时归档、文件摘要和目录切换,外部命令受控替身仅用于测试,不替代真实发布验收。
|
||||
|
||||
授权变更记录:用户随后明确授权先完成测试环境验收,再推送本轮提交并发布预生产;此前“仅测试部署、不推送、不预生产”的表述保留为历史阶段边界。本节记录时业务提交 6816f5617819546850b1188508eda37866f4ca3c 已本地提交、尚未推送;修复工具仍属受保护的未跟踪文件,不据此纳入整套既有发布工具或其他脏文件。后续两环境执行与验收分别追加。
|
||||
|
||||
|
||||
## 2026-09-08 22:35 前端正式构建模式修复
|
||||
|
||||
测试环境实际下载的 /assets/index-TLGANFcU.js(SHA-256:a8db74bfdb24ef0892ca77fee891297d492d07b979c8653d5fd7c86c186e080c)含 react_stack_bottom_frame 与 jsx-dev-runtime。标准 prepare 把全部命令 NODE_ENV 设为 test;Vite 以 NODE_ENV===production 判断 isProduction,React 插件沿用该值,因此此前名为 build 的发布产物实际包含开发版 React。必须核验最终产物,不能只凭执行 vite build 推断生产运行时。
|
||||
|
||||
本次工具最小修复仅为前端 npm run build 复制独立命令环境并显式设置 NODE_ENV=production;安装、安全/部署门禁、API 的生成/构建仍保持原环境,既有依赖前置与组件切换范围不变。vite.config.ts 同时增加 apply: build 的 configResolved 守卫:isProduction 为假立即拒绝构建,防止其他构建入口再次产出开发版发布包;serve 与 Vitest 不应用该 build 守卫。
|
||||
|
||||
真实观测的 18 次通知信号取消发生于可见、在线页面,elapsed 约 3.1~14ms,均为 AbortError 且栈落在 225:176295 的通知 effect cleanup,不是 15 秒 TimeoutError。146:26427 对应 React 的 commitPassiveUnmountEffectsInsideOfDeletedTree_begin,146:25089 对应递归 passive unmount;这是删除树清理路径,不能直接认定所有取消都是 StrictMode 双 effect,也不是另一台电脑 ERR_CONNECTION_CLOSED 的已证实根因。修正正式包后须重新验证资源模式和真实刷新/切页/通知行为。
|
||||
|
||||
截至本节,NODE_ENV=test、development 两个真实负例均被新守卫拒绝;默认构建和 lint 正在执行,尚未记为通过。工具 Windows 共 37 项,35 通过、2 项平台限制跳过;新增版本 Linux 37 项结果待返回。新工具摘要和新增 Vite 配置要求重新生成绑定精确提交的计划、执行对应证据与 fresh preflight;本节不表示新版本已部署或业务已验收。证据位于 %TEMP%/cmpp-starttime-pagination-20260908 的 live-react-mode-evidence.json、release-build-mode-fix.patch、build-mode-test.log、build-mode-development.log 及第三轮 browser 目录。
|
||||
|
||||
最终检查结果补记(2026-09-08 22:36):默认 npm run build 成功,npm run lint 退出码 0;两个真实 test/development 构建负例准确拒绝。Linux 新版工具 37/37 通过、0 失败/0 跳过,耗时 0.192s,日志 SHA-256 为 305e7df43ff9d6506a755fd8d5e0adb6926e5ee89eb719d8baa015c231a4060b,位于主 TEMP/release-linux-unit-buildmode-2026-09-08T14-34-37-205Z/linux-unittest.log。本结果补齐上文执行中的本地检查;正式候选及真实部署页面仍待验收。
|
||||
|
||||
@@ -5271,3 +5271,44 @@ 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或修改业务配置 |
|
||||
|
||||
|
||||
### 标准发布前端候选安全依赖回归(2026-09-08)
|
||||
|
||||
本组验证候选依赖前置条件和既有组件切换边界;隔离命令替身仅用于故障测试,不代替真实安全门禁、现场 preflight 或业务验收。
|
||||
|
||||
| 编号 | 场景/步骤 | 预期 |
|
||||
| --- | --- | --- |
|
||||
| REL-PREP0908-01 | 仅前端变更,从真实临时归档 prepare;安全检查要求候选 API 本地依赖,再执行受控 deploy | 根 npm ci、API npm ci 先于 security:verify/deploy:verify;不显式生成 Prisma、不构建 API。前端产物更新,线上 api/dist/api/node_modules 摘要不变,无 API 目录切换或服务重启命令 |
|
||||
| REL-PREP0908-02 | 仅 API 变更,记录候选依赖、生成、门禁与构建顺序 | 根与 API npm ci 各执行一次,之后显式 Prisma 生成、security:verify、deploy:verify、API build;没有遗漏安全门禁或误跑前端构建 |
|
||||
| REL-PREP0908-03 | 前端候选的 API npm ci 注入失败,随后尝试 deploy | prepare 记录失败,未进入 security:verify,不产生 candidateHash,线上应用摘要不变;deploy 在备份/现场切换前拒绝 |
|
||||
| REL-PREP0908-04 | 前端候选依赖安装成功,security:verify 注入失败,随后尝试 deploy | 不执行后续 deploy:verify 或构建,不产生 candidateHash,线上应用摘要不变;deploy 拒绝进入备份、停服和切换,不绕过安全门禁 |
|
||||
|
||||
本次执行:上述 4 项均通过;发布工具全量 Windows 35 项中 33 通过、2 项平台限制跳过,Linux 35/35 通过,Linux 日志 SHA-256 为 77106f006cf22ea33c3b744c9c7cbf43d3885b32f4c6d71d8d00b49b1578e900。原测试发布 20260908T134432-6816f5617819-b92bbb04 仅 prepare 失败、backup=null,未切换。用户后续授权的“测试验收后推送、再发布预生产”须分别完成真实验收,不能依据本组隔离测试直接判定发布完成。
|
||||
|
||||
|
||||
### 正式前端构建模式与取消归因回归(2026-09-08 22:35)
|
||||
|
||||
| 编号 | 场景/步骤 | 预期与当前执行状态 |
|
||||
| --- | --- | --- |
|
||||
| REL-BUILD0908-01 | prepare 分别处理前端/API 影响,逐条记录命令 NODE_ENV,父进程预设 development | 仅前端 npm run build 为 production;安装、门禁与 API 生成/构建仍为 test,父进程保持 development。工具新增隔离回归已通过 |
|
||||
| REL-BUILD0908-02 | 前端构建命令注入失败,随后尝试 deploy | prepare 失败,不进入后续 style 等门禁,不产生 candidateHash,deploy 在备份/切换前拒绝;工具新增回归已通过 |
|
||||
| REL-BUILD0908-03 | 真实 Vite build 分别设置 NODE_ENV=test、development,再运行默认正式构建 | 两个错误模式被 apply: build/configResolved isProduction 守卫拒绝,错误清楚且不放宽门禁;两个真实负例已拒绝,默认构建与 lint 仍在执行,待追加结果。守卫不应用 serve/Vitest |
|
||||
| OPS-NOTIFY0908-05 | 下载实际发布 JS 核验模式,再采集快速切页/刷新时的 signal.reason、耗时、文档和调用栈 | 生产包不得包含开发 React/JSX 运行时;区分 15 秒超时、布局清理和浏览器导航取消,不把 ERR_ABORTED 一律当网络故障或一律忽略。旧包已确认 18 次可见在线的 3.1~14ms AbortError 均落 effect cleanup;正式包尚待真实复测 |
|
||||
|
||||
旧包 SHA-256:a8db74bfdb24ef0892ca77fee891297d492d07b979c8653d5fd7c86c186e080c,cleanup 位置 225:176295;React 146:26427/25089 为删除树 passive unmount 链,不等同于确证全部 StrictMode 双 effect,也不证明历史 ERR_CONNECTION_CLOSED 根因。工具本次 Windows 37 项中 35 通过、2 平台限制跳过;Linux 新 37 项待回报。
|
||||
|
||||
最终检查结果补记(2026-09-08 22:36):默认 npm run build 成功,npm run lint 退出码 0;两个真实 test/development 构建负例准确拒绝。Linux 新版工具 37/37 通过、0 失败/0 跳过,耗时 0.192s,日志 SHA-256 为 305e7df43ff9d6506a755fd8d5e0adb6926e5ee89eb719d8baa015c231a4060b,位于主 TEMP/release-linux-unit-buildmode-2026-09-08T14-34-37-205Z/linux-unittest.log。本结果补齐上文执行中的本地检查;正式候选及真实部署页面仍待验收。
|
||||
|
||||
@@ -4764,3 +4764,41 @@ 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目录。
|
||||
|
||||
|
||||
## 2026-09-08 22:03 标准 prepare 前端候选依赖修复与授权更新
|
||||
|
||||
- 现场阻断:测试发布 20260908T134432-6816f5617819-b92bbb04 的前端候选只执行根 npm ci,security:verify 通过 createRequire(api/package.json) 校验 API brace-expansion 适配器时缺少本地依赖而向上解析错误版本。主线程先用标准 status 取得 phase=prepare、backup=null;没有备份、停服或切换,原失败候选保留。
|
||||
- 最小修复:tools/release/remote.py 仅将候选 API npm ci 移入 frontend 或 api 条件;显式 Prisma 生成、API 构建和线上 API 产物/依赖切换仍只在 api 影响范围内执行。没有修改 security:verify、跳过门禁、手工修补线上依赖或替换标准编排。tools/release/test_release.py 仅追加 Preparation 的 4 项回归,两工具文件保留原未跟踪状态,未暂存、未提交整套既有第一期工具。
|
||||
- 先失败后修复:新增测试在旧实现上复现前端候选缺 API 依赖及安装失败场景断言失败;修复后 Windows 标准 self-test 共 35 项,33 通过、2 项分别因符号链接权限与 Linux flock 限制跳过。测试以真实临时 tar、文件摘要和目录切换核验前端部署不改变线上 API 目录,不调用服务命令;受控命令替身只用于隔离失败场景。
|
||||
- Linux 隔离验证于 22:00:38 返回 exitCode=0,35/35 通过,临时夹具残留 0;Linux 日志 SHA-256 已回读核验为 77106f006cf22ea33c3b744c9c7cbf43d3885b32f4c6d71d8d00b49b1578e900。本地 dependency mitigation、security deployment、deploy verifier 通过;两 Python 文件解析和尾随空白检查通过。未修改 JS/Shell,因此未把未执行的对应语法或格式检查记为通过。
|
||||
- 证据:%TEMP%/cmpp-starttime-pagination-20260908/release-prepare-before.log、release-prepare-after.log、release-prepare-fix.patch、release-linux-unit/result.json、release-linux-unit/linux-unittest.log。用例 REL-PREP0908-01~04 已追加,候选安装与线上切换边界同步部署/工具设计文档。工具摘要变化使旧计划失效,后续使用新计划、匹配精确提交的已执行验证证据和 fresh preflight;发布与业务验收另记,不以隔离测试冒充上线。
|
||||
- 最新授权:用户已明确允许测试环境验收通过后推送本轮提交并部署预生产,取代本轮早先仅本地提交与测试部署的后续执行限制;此前文字继续保留为历史事实。记录时 6816f5617819546850b1188508eda37866f4ca3c 仅本地提交、未推送,预生产尚未执行本轮发布。授权不包含其他既有脏文件、短信发送/补发/重投/重新入队、余额/客户/通道配置或恢复管理员。
|
||||
|
||||
|
||||
## 2026-09-08 22:35 测试前端开发运行时实证与构建模式修复
|
||||
|
||||
- 新证据纠正此前推断:只读下载测试实际 /assets/index-TLGANFcU.js,SHA-256 a8db74bfdb24ef0892ca77fee891297d492d07b979c8653d5fd7c86c186e080c,包含 19 处 react_stack_bottom_frame 和 2 处 jsx-dev-runtime。remote.prepare 统一 NODE_ENV=test;本地对应 Vite 以 NODE_ENV===production 决定 isProduction,React 插件沿用该值,确认发布过程实际产出了开发版 React,不能用“执行过 build”替代资源模式核验。
|
||||
- 第三轮 browser/run-2026-09-08T14-27-36-420Z/fetch-diagnostics.ndjson 中 18 次 fetch-signal-abort 均可见在线,耗时约 3.1~14ms、reason=AbortError,全部经过 JS 225:176295 的通知 effect cleanup。146:26427 对应 commitPassiveUnmountEffectsInsideOfDeletedTree_begin,146:25089 对应递归 passive unmount 遍历;不是 15 秒 TimeoutError,不是已逐项证实的 StrictMode 双 effect。确认存在开发包与布局卸载取消链,不宣称它解释另一台电脑历史 ERR_CONNECTION_CLOSED;正式包行为须重测。
|
||||
- 工具最小修复:仅前端 npm run build 使用复制的 NODE_ENV=production 命令环境,其他安装/门禁/API 生成构建保留 test,父进程不污染;原 API 安全依赖补丁保留。主线程新增 vite.config.ts 的 cmpp-production-build-guard,apply: build、configResolved 检查 isProduction,非 production 明确失败,不应用 serve/Vitest。没有修改通知业务计数口径、全局错误过滤或后端端口。
|
||||
- 已执行:环境回归在旧实现明确失败(test != production),修复后 Windows 标准 self-test 37 项中 35 通过、2 项因符号链接权限与 Linux flock 限制跳过;包含新环境隔离与前端构建失败阻断回归。真实 NODE_ENV=test、development 两个 Vite 负例均由新守卫拒绝。默认构建、lint 当前正在执行,Linux 新 37 项结果待其他执行任务返回;本节不提前记录通过,也不复用前一版 Linux 35/35 充当本版结果。
|
||||
- 证据:主 TEMP/cmpp-starttime-pagination-20260908 下 index-TLGANFcU.live.js、live-react-mode-evidence.json、release-build-mode-fix.patch、release-build-mode-before.log/after.log、build-mode-test.log、build-mode-development.log,以及第三轮 fetch-diagnostics.ndjson。两 Python 文件语法与尾随空白检查已通过。工具仍未跟踪、未暂存;Vite 配置及最终版本/测试/标准发布结果由主线程后续精确提交与追加。
|
||||
- 待验收:新候选生产运行时、真实分页/三尺寸/刷新/切页/六通知请求与故障恢复,及授权顺序内的测试验收后推送、预生产独立发布。新工具摘要和配置改变后使用匹配精确提交的新计划及 fresh preflight,不把旧包只读观测或本地负例视为正式包业务完成。
|
||||
|
||||
最终检查结果补记(2026-09-08 22:36):默认 npm run build 成功,npm run lint 退出码 0;两个真实 test/development 构建负例准确拒绝。Linux 新版工具 37/37 通过、0 失败/0 跳过,耗时 0.192s,日志 SHA-256 为 305e7df43ff9d6506a755fd8d5e0adb6926e5ee89eb719d8baa015c231a4060b,位于主 TEMP/release-linux-unit-buildmode-2026-09-08T14-34-37-205Z/linux-unittest.log。本结果补齐上文执行中的本地检查;正式候选及真实部署页面仍待验收。
|
||||
|
||||
@@ -137,3 +137,8 @@
|
||||
- 弹窗:使用 `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 type { InfrastructureAlertSettings, InfrastructureAlertThresholds, InfrastructureMonitoringOverview, InfrastructureMonitoringRange } from '../types';
|
||||
import type {
|
||||
InfrastructureAlertSettings,
|
||||
InfrastructureAlertThresholds,
|
||||
InfrastructureMonitoringOverview,
|
||||
InfrastructureMonitoringRange,
|
||||
} from '../types';
|
||||
|
||||
export const adminInfrastructureMonitoringApi = {
|
||||
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
|
||||
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
|
||||
getInfrastructureMonitoringNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary'),
|
||||
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 }) }),
|
||||
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
|
||||
request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary', {
|
||||
signal,
|
||||
}),
|
||||
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 = {
|
||||
getDashboard: (tenantId?: string) =>
|
||||
request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
getPendingAudits: (tenantId?: string) =>
|
||||
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
|
||||
getPendingAudits: (tenantId?: string, signal?: AbortSignal) =>
|
||||
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }),
|
||||
getSendQuality: (date?: string) =>
|
||||
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||
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';
|
||||
|
||||
export const adminSecurityDetectionApi = {
|
||||
getSecurityOverview: (range = '24h') => request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
|
||||
getSecurityNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary'),
|
||||
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)),
|
||||
getSecurityOverview: (range = '24h') =>
|
||||
request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
|
||||
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'),
|
||||
updateSecurityRule: (id: string, body: Partial<SecurityRule>) => request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
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 }) }),
|
||||
updateSecurityRule: (id: string, body: Partial<SecurityRule>) =>
|
||||
request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
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'),
|
||||
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'),
|
||||
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';
|
||||
|
||||
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<SignatureRetirementRule>('/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<SignatureRetirementRule>('/admin/signature-retirement/rules', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) =>
|
||||
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteSignatureRetirementWebhook: (id: string) => request<SignatureRetirementWebhook>(`/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<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
|
||||
getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'),
|
||||
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'),
|
||||
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteSignatureRetirementWebhook: (id: string) =>
|
||||
request<SignatureRetirementWebhook>(`/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<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) =>
|
||||
request<SignatureRetirementSuppression>(`/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<SignatureRetirementSuppression>(`/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<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', () => {
|
||||
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(/^每页数量/)).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(
|
||||
<>
|
||||
<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', () => {
|
||||
|
||||
@@ -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' ? <span>显示 {total} 条记录</span> : <span />}
|
||||
<div>
|
||||
{typeof pageSize === 'number' && onPageSizeChange ? (
|
||||
<Select
|
||||
label="每页数量"
|
||||
value={String(pageSize)}
|
||||
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
/>
|
||||
<>
|
||||
<label className="sr-only" htmlFor={pageSizeId}>
|
||||
每页数量
|
||||
</label>
|
||||
<Select
|
||||
id={pageSizeId}
|
||||
value={String(pageSize)}
|
||||
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">
|
||||
上一页
|
||||
|
||||
@@ -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 { 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 { AppShell } from './AppShell';
|
||||
|
||||
@@ -11,9 +12,11 @@ const api = vi.hoisted(() => ({
|
||||
getSecurityNotificationSummary: vi.fn(),
|
||||
getInfrastructureMonitoringNotificationSummary: vi.fn(),
|
||||
current: vi.fn(),
|
||||
request: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi: api, portalSessionApi: { current: api.current } }));
|
||||
vi.mock('@/api/core/httpClient', () => ({ request: api.request }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -37,15 +40,18 @@ beforeEach(() => {
|
||||
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 7 });
|
||||
api.getSecurityNotificationSummary.mockResolvedValue({ count: 2, criticalCount: 1 });
|
||||
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 5, criticalCount: 0 });
|
||||
api.request.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <p data-testid="route">{location.pathname + location.search}</p>;
|
||||
}
|
||||
|
||||
async function renderAdmin() {
|
||||
render(
|
||||
function renderAdminShell() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/admin']}>
|
||||
<Routes>
|
||||
<Route element={<AdminLayout />}>
|
||||
@@ -54,6 +60,10 @@ async function renderAdmin() {
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
async function renderAdmin() {
|
||||
renderAdminShell();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('7'), {
|
||||
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();
|
||||
});
|
||||
|
||||
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 {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
@@ -35,6 +35,7 @@ import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/ses
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
import { request } from '@/api/core/httpClient';
|
||||
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
|
||||
|
||||
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||
enterpriseCertifications: 0,
|
||||
@@ -44,92 +45,95 @@ const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||
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() {
|
||||
return (
|
||||
<PortalSessionBoundary portal="admin">
|
||||
{(session) => <AdminAuthenticatedLayout session={session} />}
|
||||
{(session) => <AdminAuthenticatedLayout key={session.user.id} session={session} />}
|
||||
</PortalSessionBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
||||
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
|
||||
const [reportSummary, setReportSummary] = useState({ count: 0, unavailable: false });
|
||||
const [monitorSummary, setMonitorSummary] = useState({ count: 0, unavailable: false });
|
||||
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||
const [pendingAudits, setPendingAudits] = useState(() => initialNotification(EMPTY_PENDING_AUDITS));
|
||||
const [retirementSummary, setRetirementSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [reportSummary, setReportSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [monitorSummary, setMonitorSummary] = useState(() => initialNotification({ count: 0 }));
|
||||
const [securityAlertSummary, setSecurityAlertSummary] = useState(() =>
|
||||
initialNotification({ count: 0, criticalCount: 0 }),
|
||||
);
|
||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState(() =>
|
||||
initialNotification({ count: 0, criticalCount: 0 }),
|
||||
);
|
||||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
const currentSession = readSession('admin');
|
||||
if (
|
||||
!currentSession ||
|
||||
currentSession.locked ||
|
||||
Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const canPoll = useCallback(() => canPollNotifications(session.user.id), [session.user.id]);
|
||||
const loadPendingAuditCount = useCallback(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||||
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
||||
Promise.allSettled([
|
||||
adminApi.getPendingAudits(),
|
||||
adminApi.getSignatureRetirementUnreadCount(),
|
||||
adminApi.getSecurityNotificationSummary(),
|
||||
adminApi.getInfrastructureMonitoringNotificationSummary(),
|
||||
request<{ count: number }>('/admin/report-notifications/summary'),
|
||||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary'),
|
||||
])
|
||||
.then(([audits, retirement, security, infrastructure, reporting, monitor]) => {
|
||||
setMonitorSummary((previous) =>
|
||||
monitor.status === 'fulfilled'
|
||||
? { count: monitor.value.count, unavailable: Boolean(monitor.value.unavailable) }
|
||||
: { ...previous, unavailable: true },
|
||||
);
|
||||
setReportSummary((previous) =>
|
||||
reporting.status === 'fulfilled'
|
||||
? { count: reporting.value.count, unavailable: false }
|
||||
: { ...previous, unavailable: true },
|
||||
);
|
||||
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
|
||||
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
|
||||
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 });
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
settleNotificationRequest(adminApi.getPendingAudits(undefined, signal), signal),
|
||||
settleNotificationRequest(adminApi.getSignatureRetirementUnreadCount(signal), signal),
|
||||
settleNotificationRequest(adminApi.getSecurityNotificationSummary(signal), signal),
|
||||
settleNotificationRequest(adminApi.getInfrastructureMonitoringNotificationSummary(signal), signal),
|
||||
settleNotificationRequest(request<{ count: number }>('/admin/report-notifications/summary', { signal }), signal),
|
||||
settleNotificationRequest(
|
||||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary', { signal }),
|
||||
signal,
|
||||
),
|
||||
]);
|
||||
if (!isCurrent()) return false;
|
||||
const [audits, retirement, security, infrastructure, reporting, monitor] = results;
|
||||
const monitorAvailable = monitor.status === 'fulfilled' && !monitor.value.unavailable;
|
||||
setPendingAudits((previous) => updateNotification(previous, audits));
|
||||
setRetirementSummary((previous) => updateNotification(previous, retirement));
|
||||
setSecurityAlertSummary((previous) => updateNotification(previous, security));
|
||||
setInfrastructureAlertSummary((previous) => updateNotification(previous, infrastructure));
|
||||
setReportSummary((previous) => updateNotification(previous, reporting));
|
||||
setMonitorSummary((previous) => updateNotification(previous, monitor, monitorAvailable));
|
||||
return results.every((result) => result.status === 'fulfilled') && monitorAvailable;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.portal !== 'admin' || sessionLocked) {
|
||||
return;
|
||||
}
|
||||
loadPendingAuditCount();
|
||||
const timer = window.setInterval(loadPendingAuditCount, 30000);
|
||||
const onFocus = () => loadPendingAuditCount();
|
||||
const onAuditRefresh = () => loadPendingAuditCount();
|
||||
window.addEventListener('focus', onFocus);
|
||||
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]);
|
||||
useNotificationPolling({
|
||||
enabled: session.portal === 'admin' && !sessionLocked,
|
||||
canPoll,
|
||||
poll: loadPendingAuditCount,
|
||||
});
|
||||
const auditLabel = (label: string) =>
|
||||
pendingAudits.status === 'ready'
|
||||
? label
|
||||
: `${label}(${pendingAudits.status === 'loading' ? '计数加载中' : '计数暂不可用'})`;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -144,14 +148,14 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
reportingNotifications={[
|
||||
{
|
||||
label: '报备状态变化通知',
|
||||
count: reportSummary.count,
|
||||
description: reportSummary.unavailable ? '消息计数暂不可用,请重试' : '按企业与小时汇总的未读消息',
|
||||
count: reportSummary.value.count,
|
||||
description: notificationDescription(reportSummary, '按企业与小时汇总的未读消息'),
|
||||
to: '/admin/report-records?tab=readiness',
|
||||
},
|
||||
{
|
||||
label: '签名清退预警',
|
||||
count: retirementUnreadCount,
|
||||
description: '今日未读且未抑制',
|
||||
count: retirementSummary.value.count,
|
||||
description: notificationDescription(retirementSummary, '今日未读且未抑制'),
|
||||
to: '/admin/signature-retirement',
|
||||
},
|
||||
{
|
||||
@@ -163,40 +167,48 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
alertNotifications={[
|
||||
{
|
||||
label: '发送质量告警',
|
||||
count: monitorSummary.count,
|
||||
description: monitorSummary.unavailable ? '告警计数暂不可用,请重试' : '未读活动发送质量告警',
|
||||
count: monitorSummary.value.count,
|
||||
description: notificationDescription(monitorSummary, '未读活动发送质量告警'),
|
||||
to: '/admin/monitor?tab=alerts',
|
||||
},
|
||||
{
|
||||
label: '安全检测与封禁',
|
||||
count: securityAlertSummary.count,
|
||||
description:
|
||||
securityAlertSummary.criticalCount > 0
|
||||
? `${securityAlertSummary.criticalCount} 条严重告警待处置`
|
||||
count: securityAlertSummary.value.count,
|
||||
description: notificationDescription(
|
||||
securityAlertSummary,
|
||||
securityAlertSummary.value.criticalCount > 0
|
||||
? `${securityAlertSummary.value.criticalCount} 条严重告警待处置`
|
||||
: '待处置安全告警',
|
||||
),
|
||||
to: '/admin/security-detection',
|
||||
},
|
||||
{
|
||||
label: '系统监控告警',
|
||||
count: infrastructureAlertSummary.count,
|
||||
description:
|
||||
infrastructureAlertSummary.criticalCount > 0
|
||||
? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警`
|
||||
count: infrastructureAlertSummary.value.count,
|
||||
description: notificationDescription(
|
||||
infrastructureAlertSummary,
|
||||
infrastructureAlertSummary.value.criticalCount > 0
|
||||
? `${infrastructureAlertSummary.value.criticalCount} 条 Prometheus 严重告警`
|
||||
: 'Prometheus 活动告警',
|
||||
),
|
||||
to: '/admin/system-monitoring#active-alerts',
|
||||
},
|
||||
]}
|
||||
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: '签名导入待审',
|
||||
count: pendingAudits.signatureImports ?? 0,
|
||||
label: auditLabel('企业认证待审'),
|
||||
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',
|
||||
},
|
||||
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
|
||||
{ label: auditLabel('引流信息待审'), count: pendingAudits.value.drainageInfos, to: '/admin/drainage-audits' },
|
||||
]}
|
||||
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));
|
||||
// Immediate setup/cleanup cycles can cancel this timer before network work starts.
|
||||
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]);
|
||||
}
|
||||
+12
-1
@@ -5,7 +5,18 @@ import path from 'path';
|
||||
const apiProxyTarget = process.env.API_PROXY_TARGET ?? 'http://localhost:3000';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'cmpp-production-build-guard',
|
||||
apply: 'build',
|
||||
configResolved(config) {
|
||||
if (!config.isProduction) {
|
||||
throw new Error('CMPP frontend builds require NODE_ENV=production; refusing a development React release.');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
cacheDir: 'node_modules/.vite-cmpp',
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user