5 Commits

Author SHA1 Message Date
luo
2f4eed917e fix: 白屏
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 2m0s
2026-10-31 13:02:18 +08:00
luo
1a1ec485a5 fix: some
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 45s
2026-10-29 17:17:55 +08:00
luo
f2614d8efb fix: some
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 54s
2026-10-28 15:18:44 +08:00
luo
06684e1fc0 fix: 轮询按运营商账户限流
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 55s
2026-10-26 14:31:15 +08:00
luo
049f8257fb feat: 新增网关限流
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 56s
2026-10-24 16:22:28 +08:00
179 changed files with 22994 additions and 566 deletions

View File

@@ -3,6 +3,7 @@ name: 构建并部署前端到测试环境
on: on:
push: push:
branches: branches:
- feature/gateway-rate-limit
- iteration/august - iteration/august
- dev - dev
- test - test
@@ -40,7 +41,7 @@ jobs:
fi fi
- name: 部署到测试环境 - name: 部署到测试环境
if: github.ref == 'refs/heads/iteration/august' if: github.ref == 'refs/heads/feature/gateway-rate-limit'
run: | run: |
mkdir -p "${{ env.DEPLOY_DIR }}" mkdir -p "${{ env.DEPLOY_DIR }}"
cp -v docker-compose.prod.yml "${{ env.DEPLOY_DIR }}/" cp -v docker-compose.prod.yml "${{ env.DEPLOY_DIR }}/"

4
.gitignore vendored
View File

@@ -5,6 +5,10 @@ docs
!docs/ !docs/
docs/* docs/*
!docs/权限.md !docs/权限.md
!docs/api/
docs/api/*
!docs/api/package-silence.openapi.json
!docs/2026-10-26-轮询账户限流-后台接入交付.md
dist-ssr dist-ssr
*.local *.local
.cursorrules .cursorrules

View File

@@ -0,0 +1,128 @@
# 轮询账户限流:后台接入交付
本次仅修改本后台前端,未修改独立 C 端、未推送 Git 或部署生产、未配置数据库菜单。真实接口权限和调度行为仍需测试环境联调。本地写操作全部通过 mock 验证。
## 后台配置与使用
新增菜单、隐藏详情、按钮编码和平台账号限制见 [权限增量清单](./权限.md#轮询管理运营商账户限流与调度2026-10-26-增量)。原有网关、轮询配置预览/统计、停复机记录 URL 和权限保留。仅超级管理员的维护项不列入普通配置清单。
UI 沿用店铺列表表头、可选列、分页和店铺详情返回/标题/两列布局;窄屏详情单列。时间统一店铺格式,运营商账户按名称远程搜索,搜索条件 >=4 才出现展开。
新增配置字段现可保存,旧文档“仅预览”已失效。四项间隔:空继承、0 禁止且不再继承、正数执行。编辑清空使用 clear_fields,保存 warnings 单独展示;不再发送保护期间隔。
## C 端需要同步哪些
**必须修改(文档明确要求)**
1. C 端资产详情调用 `GET /api/c/v1/asset/info` 后,不再展示保护期状态(`device_protect_status`),包括标签、说明、倒计时和相关状态分支(如有)。
2. 废弃字段可能仍返回固定 `none`,也可能缺失;兼容解析,但不能再依赖它决定页面展示或是否允许操作。不要把 `none` 当成新的异常状态。
**随新后端回归验证(不是要求 C 端增加后台功能)**
- 订单/套餐生效、有效期变化后,重新读取资产实际 `enable_polling`;后台可能自动恢复轮询,不要仅凭旧前端缓存维持“已关闭”。
- 指令队列启用时,业务受理不等于上游已完成停复机。若 C 端有即时完成提示,改为区分“已受理/处理中”和实际状态,并在合理刷新后确认。
- 资产详情读入口的请求检查、延迟确认及冷却由后端场景配置控制,C 端不要自行复制调度逻辑或高频循环触发。
C 端不需要新增本次后台菜单、告警/调度参数/导入接口;本仓库没有修改任何 C 端代码。若 C 端本来没有保护期 UI 或逻辑,第一项只需核查删除残留引用。
## 接口覆盖
文档 46 个新增操作中,已有 GatewayService 17 个、PollingConfigService 7 个,本次 PollingSchedulerService 补 22 个。自动测试逐条解析原文附录的 46 个方法/路径,与实际服务调用对照,不以数量代替路径验收。
下表路径省略 `/api/admin`。精确 URL、Query/Body 测试位于 `gateway.test.mjs`、`pollingConfigExtensions.test.mjs`、`pollingScheduler.test.mjs`。
| HTTP 操作 | 服务方法 | 后台入口 |
| --- | --- | --- |
| `DELETE /gateway-error-classifications/{id}` | `GatewayService.deleteRule` | 上游错误分类 |
| `DELETE /polling-carrier-params/{carrier_id}` | `PollingSchedulerService.resetCarrierParam` | 账户参数维护 |
| `DELETE /polling-rate-limit-groups/{id}` | `GatewayService.deleteGroup` | 网关限流组维护 |
| `GET /card-polling-pauses` | `PollingSchedulerService.getPauses` | 不轮询清单 |
| `GET /card-stop-resume-records` | `GatewayService.getRecords` | 停复机记录 |
| `GET /gateway-error-classifications` | `GatewayService.getRules` | 上游错误分类 |
| `GET /iot-cards/{id}/polling-state` | `PollingSchedulerService.getCardState` | 单卡/设备绑定卡/告警详情 |
| `GET /iot-cards/{id}/stop-resume-records` | `GatewayService.getCardRecords` | 单卡/告警详情 |
| `GET /polling-carrier-params` | `PollingSchedulerService.getCarrierParams` | 运营商账户调度参数 |
| `GET /polling-carrier-params/{carrier_id}` | `PollingSchedulerService.getCarrierParam` | 账户参数详情 |
| `GET /polling-check-scenes` | `PollingSchedulerService.getScenes` | 请求检查场景 |
| `GET /polling-configs/match-preview` | `PollingConfigService.previewMatch` | 配置匹配预览 |
| `GET /polling-configs/recalculate-due/{task_id}` | `PollingConfigService.getRecalculateTask` | 重算进度 |
| `GET /polling-failure-alerts` | `PollingSchedulerService.getAlerts` | 告警普通/停机被拒 |
| `GET /polling-failure-alerts/summary` | `PollingSchedulerService.getAlertSummary` | 告警账户汇总 |
| `GET /polling-failure-alerts/{id}` | `PollingSchedulerService.getAlert` | 告警详情 |
| `GET /polling-options` | `GatewayService.getOptions` | 网关各页枚举 |
| `GET /polling-rate-limit-groups` | `GatewayService.getGroups` | 网关限流组 |
| `GET /polling-rate-limit-groups/{id}` | `GatewayService.getGroup` | 网关限流组详情 |
| `GET /polling-stats/carrier-queues` | `PollingSchedulerService.getQueues` | 账户队列监控 |
| `GET /polling-stats/carriers` | `GatewayService.getCarrierStats` | 网关汇总/限流模式 |
| `GET /polling-stats/carriers/{carrier_id}/trend` | `GatewayService.getCarrierTrend` | 账户调用趋势 |
| `GET /polling-stats/pre-reset` | `PollingConfigService.getPreResetStats` | 重置日前统计 |
| `GET /polling-stats/tiers` | `PollingConfigService.getTierStats` | 分层统计 |
| `GET /polling-status-imports` | `PollingSchedulerService.getImports` | 导入任务列表 |
| `GET /polling-status-imports/{id}` | `PollingSchedulerService.getImport` | 导入逐行结果 |
| `GET /polling-status/impact` | `PollingSchedulerService.getImpact` | 单项/批量/导入确认 |
| `GET /stop-resume-rule/dry-run` | `PollingSchedulerService.dryRun` | 规则演练(只读) |
| `POST /devices/batch-update-polling-status` | `PollingSchedulerService.toggleDevices` | 设备批量/清单恢复 |
| `POST /gateway-error-classifications` | `GatewayService.createRule` | 上游错误分类 |
| `POST /gateway-error-classifications/test` | `GatewayService.testRule` | 错误分类测试 |
| `POST /iot-cards/batch-update-polling-status` | `PollingSchedulerService.toggleCards` | 卡批量/清单恢复 |
| `POST /polling-configs/match-preview` | `PollingConfigService.previewDraft` | 配置草稿预览 |
| `POST /polling-configs/recalculate-due` | `PollingConfigService.recalculateDue` | 重算到期 |
| `POST /polling-failure-alerts/close-polling` | `PollingSchedulerService.closeAlertPolling` | 告警批量关闭卡轮询 |
| `POST /polling-rate-limit-groups` | `GatewayService.createGroup` | 网关限流组维护 |
| `POST /polling-status-imports` | `PollingSchedulerService.createImport` | 创建导入 |
| `POST /stop-resume-rule/dry-run/export` | `PollingSchedulerService.exportDryRun` | 演练导出 |
| `PUT /gateway-error-classifications/{id}` | `GatewayService.updateRule` | 上游错误分类 |
| `PUT /gateway-error-classifications/{id}/status` | `GatewayService.setRuleStatus` | 上游错误分类 |
| `PUT /polling-carrier-params/{carrier_id}` | `PollingSchedulerService.saveCarrierParam` | 账户参数维护 |
| `PUT /polling-check-scenes/{scene}` | `PollingSchedulerService.saveScene` | 请求检查场景维护 |
| `PUT /polling-configs/priorities` | `PollingConfigService.updatePriorities` | 整体优先级(拖动/上移下移) |
| `PUT /polling-failure-alerts/{id}/status` | `PollingSchedulerService.handleAlert` | 告警处理 |
| `PUT /polling-rate-limit-groups/{id}` | `GatewayService.updateGroup` | 网关限流组维护 |
| `PUT /polling-rate-limit-groups/{id}/status` | `GatewayService.setGroupStatus` | 网关限流组维护 |
### 16 个既有接口改动的处理
| 既有接口 | 接入结果 |
| --- | --- |
| GET /polling-configs | 列表新增资产/套餐条件、重置日前窗口和间隔列 |
| GET /polling-configs/:id | 编辑读入全部新增字段,废弃保护间隔不再参与 |
| POST /polling-configs | 白名单保存新增匹配及窗口字段、三态间隔,展示 warnings |
| PUT /polling-configs/:id | clear_fields 显式清空,0 保留,展示 warnings |
| GET /assets/resolve/:identifier | 不再映射废弃保护期展示;其余资产身份不变 |
| GET /assets/:identifier/realtime-status | 不再更新保护期 UI 字段 |
| POST /assets/:identifier/refresh | 沿用刷新读取,界面无保护期展示 |
| GET /api/c/v1/asset/info | 独立 C 端同步项,本仓库不改 |
| PUT /carriers/:id | 0→1 首次不带确认;展示后端实际影响后确认重提 |
| GET /system-configs | 新增 polling 模块,复用 string/int/bool/json 编辑与校验 |
| GET /iot-cards/standalone | 三态是否轮询筛选,保留 false |
| GET /devices | 新增筛选与是否轮询列 |
| PATCH /assets/:identifier/polling-status | 先读 impact 确认,拒绝/取消恢复 UI,不提供强制参数 |
| POST /storage/upload-url | purpose=polling_status_import;校验 key 前缀后上传,成功才建任务 |
| POST /export-tasks | 三个新增场景;卡/设备保留 enable_polling 筛选 |
| GET /export-tasks | 三个场景列表、共用详情与下载权限;历史文件不做客户端改写 |
`PUT /system-configs/:key` 继续走原编辑能力,新增键来自服务器;本次不会自动修改 scheduler、rate_limit_mode、queue_enabled。
## 验证与限制
本地复现:
```powershell
$tests = @(rg --files scripts/tests -g '*.test.mjs')
node --import ./scripts/tests/register-esbuild.mjs --test $tests
.\node_modules\.bin\vue-tsc.cmd --noEmit
node scripts/tests/verifyBuild.mjs
node scripts/tests/verifyBuild.mjs --serve --scheduler
```
最后一项在 `http://127.0.0.1:5190` 启动本地 mock,所有 /api 被拦截;可使用 local-scheduler-platform/admin/agent/enterprise 模拟 token。该结果不等于线上后端已联调。
已覆盖:46 路径映射、22 新方法请求结构、null/0/false、清空与废弃字段、权限/身份、500 条和 CSV 50000 行边界、引号换行、部分成功、影响说明失败/取消、上传失败、迟到请求/账号范围切换/卸载、模板编译。旧实名管控回归改成后端实际文案确认。
生产构建的既有循环依赖、常量 v-model 和大 chunk 警告仍存在;未在本次跨范围重构。后端若将实名确认提示升级为独立错误码,可替换当前的语义识别,避免依赖提示中的“实名/确认”关键词。当前未知网络错误或不相关错误不会触发确认重试。
2026-10-26 最终回归:全量 191 项通过(本次调度接入新增 17 项,保留并升级原有回归)。类型检查、生产构建、针对性 ESLint / Prettier / 新增组件样式检查、全仓 UTF-8 编码检查通过。
浏览器使用本地 mock 验证了平台账号入口、特殊告警视图、批量 impact 确认与四类逐项结果、CSV 上传建任务/详情、演练汇总与明细、时间格式、网关 80% 成功率及 p95 超桶显示;检查了浅/深色与 375/390/768/1024/1440 宽度布局。开发热更新仍可能遇到项目既有 API 循环依赖,刷新后进行验收,不把热更新错误当作真实接口结果。
真实后端账号、数据范围、错误码、异步终态和导出文件内容需由部署后的联调确认;本次未进行线上写入、Git 推送或部署。

File diff suppressed because it is too large Load Diff

View File

@@ -234,3 +234,204 @@
- 各场景下载权限:`export_task:<scene>_download` - 各场景下载权限:`export_task:<scene>_download`
所有权限编码必须保持唯一;页面 URL 权限与按钮权限应分别定义。 所有权限编码必须保持唯一;页面 URL 权限与按钮权限应分别定义。
## 轮询管理:网关限流
以下仅列普通账号需要配置的项。父菜单为已有「轮询管理」`/polling-management`,无需新增一级菜单。
| 菜单名称 | URL | 父菜单 | 菜单权限编码 |
| --- | --- | --- | --- |
| 停复机记录 | `/polling-management/stop-resume-records` | 轮询管理 | `card_stop_resume_record:page` |
| 上游错误分类 | `/polling-management/error-classifications` | 轮询管理 | `gateway_error_classification:page` |
| 网关限流组 | `/polling-management/rate-limit-groups` | 轮询管理 | `gateway_rate_limit_group:page` |
| 网关调用统计 | `/polling-management/gateway-stats` | 轮询管理 | `gateway_call_stats:page` |
| 网关限流组详情(隐藏) | `/polling-management/rate-limit-groups/detail/:id` | 轮询管理 | `gateway_rate_limit_group:detail_page` |
详情为轮询管理下的同级隐藏路由,不挂在列表组件下。`:id` 保留路由参数形式;普通账号需同时获得该隐藏菜单与「查看详情」按钮授权。
| 按钮名称 | 所属菜单/位置 | 按钮权限编码 |
| --- | --- | --- |
| 测试分类 / 草稿测试 | 上游错误分类 | `gateway_error_classification:test` |
| 查看详情 | 网关限流组 | `gateway_rate_limit_group:detail` |
| 查询趋势 | 网关调用统计 | `gateway_call_stats:trend` |
| 停复机记录 | 资产管理 → 单卡信息,卡信息头部 | `iot_info:stop_resume_records` |
| 停复机记录 | 资产管理 → 单卡信息,设备绑定卡操作区 | `device_info:stop_resume_records` |
查询、重置、分页与刷新使用页面权限,无需另配按钮。运营商名称搜索下拉需要当前账号能读取 `/api/admin/carriers`;这些前端编码不替代后端独立鉴权。
## 轮询管理:轮询配置预览与统计
本次只需新增一个菜单;已有「轮询配置」`/polling-management/config` 保留,不改旧 CRUD 权限。以下仅列普通账号需要配置的项。
| 菜单名称 | URL | 父菜单 | 菜单权限编码 |
| --- | --- | --- | --- |
| 轮询配置统计 | `/polling-management/config-stats` | 轮询管理 `/polling-management` | `polling_config_stats:page` |
| 按钮名称 | 所属菜单 | 按钮权限编码 |
| --- | --- | --- |
| 匹配预览 / 草稿预览 / 预览未保存修改 | 轮询配置 `/polling-management/config` | `polling_config:match_preview` |
| 查询重算进度 | 轮询配置 `/polling-management/config` | `polling_config:recalculate_progress` |
| 查看重置日前加密查流量效果统计 | 轮询配置统计 | `polling_config_stats:pre_reset` |
| 查看分层轮询统计 | 轮询配置统计 | `polling_config_stats:tiers` |
统计菜单权限和区域权限需同时授权;若仅授权其中一个统计区域,另一区域不会请求接口。进度查询权限只允许查询已有任务,不授予创建任务的能力。上述编码是前端配置约定,后端独立鉴权。
### 接入说明
- 当前配置预览使用 GET;草稿预览使用 POST,并在 Query 和 Body 同时传入同一 ICCID。多条草稿可覆盖已有配置或以 id=0 新增,预览不调用持久化 CRUD。
- 草稿中实名、流量、套餐、卡状态检查区分继承(null)、禁止且不再继承(0)、指定秒数(正数)。保护期已下线,表单和请求均移除。重置日前加密需明确开启,窗口和窗口内流量间隔成对设置。
- 以 2026-10-26 完整接口文档为准:资产状态、套餐状态、重置日前窗口字段现已支持创建/编辑保存;编辑清空通过 `clear_fields`,保存成功展示 `warnings`。
- 时间使用与店铺列表相同的 `formatDateTime`。运营商账户按名称远程搜索,仍提交数字 ID;搜索条件 >=4 时才显示展开按钮。
- 任务进度每 3 秒查询一次,终态、失败、关闭、路由离开和卸载后停止;保留真实 task_id、任务状态及 string 类型的响应账户 ID。
- 统计周期为 null 时显示「无周期数据」,真实 0 保留。分层统计标明 5 分钟缓存,未匹配配置的在库卡和已销售无套餐卡突出告警;刷新不宣称清除服务器缓存。
### 本地验证
```powershell
node --test scripts/tests/pollingConfigExtensions.test.mjs
.\node_modules\.bin\vue-tsc.cmd --noEmit
node scripts/tests/verifyBuild.mjs
node scripts/tests/verifyBuild.mjs --serve --polling
```
最后一条命令启动 `http://127.0.0.1:5190` 的本地模拟接口页面;所有 `/api` 请求被拦截,不会对真实后端保存配置或创建任务。当前未进行真实后端联调、菜单数据写入、Git 推送或生产部署。
2026-10-26 验证:新增轮询配置测试 20 项、全量回归 174 项通过;类型、生产构建、针对性 lint / 格式 / 编码检查通过。浏览器验证了当前预览、草稿控件、异步进度、空周期/零值统计、普通账号入口及小屏布局。生产构建仍有项目既有循环依赖、其他页面常量绑定与大 chunk 警告。
## 轮询管理:运营商账户限流与调度(2026-10-26 增量)
本节在前两次配置的基础上新增,旧菜单和旧权限不必重建。仅列非超级管理员专属的菜单/按钮;标注“平台”的项只允许 user_type=1/2,权限码不能突破身份限制。前端编码是本项目约定,后端仍需独立授权。
### 新增可见菜单
| 菜单名称 | URL | 父菜单 | 菜单权限 |
| --- | --- | --- | --- |
| 运营商账户调度参数 | `/polling-management/carrier-params` | 轮询管理 `/polling-management` | `polling_carrier_params:page` |
| 请求检查场景 | `/polling-management/check-scenes` | 轮询管理 | `polling_check_scene:page` |
| 失败告警 | `/polling-management/failure-alerts` | 轮询管理 | `polling_failure_alert:page` |
| 账户队列监控 | `/polling-management/carrier-queues` | 轮询管理 | `polling_carrier_queue:page` |
| 停复机规则演练(平台) | `/polling-management/stop-resume-dry-run` | 轮询管理 | `stop_resume_dry_run:page` |
| 不轮询清单(平台) | `/polling-management/polling-pauses` | 轮询管理 | `polling_pause:page` |
| 轮询开关导入(平台) | `/polling-management/polling-status-imports` | 轮询管理 | `polling_status_import:page` |
| 导出不轮询清单(平台) | `/asset-management/export-task-management/export-polling-pause` | 导出管理 `/asset-management/export-task-management` | `export_task:polling_pause_page` |
| 导出停机被拒告警(平台) | `/asset-management/export-task-management/export-polling-stop-refused-alert` | 导出管理 | `export_task:polling_stop_refused_alert_page` |
| 导出停复机规则演练(平台) | `/asset-management/export-task-management/export-stop-resume-rule-dry-run` | 导出管理 | `export_task:stop_resume_rule_dry_run_page` |
### 新增隐藏详情菜单
这些是独立页面,不是按钮编码,需配置隐藏路由;URL 参数保留,不替换为具体 ID。
| 菜单名称 | URL | 父菜单 | 菜单权限 |
| --- | --- | --- | --- |
| 运营商账户调度参数详情 | `/polling-management/carrier-params/detail/:carrier_id` | 轮询管理 | `polling_carrier_params:detail_page` |
| 失败告警详情 | `/polling-management/failure-alerts/detail/:id` | 轮询管理 | `polling_failure_alert:detail_page` |
| 轮询开关导入详情(平台) | `/polling-management/polling-status-imports/detail/:id` | 轮询管理 | `polling_status_import:detail_page` |
三个导出场景共用已有隐藏详情 `/asset-management/export-task-management/export-task-detail?id=任务ID`,沿用 `export_task:detail_page`,不再新增详情路由。
### 新增按钮权限编码
| 所属页面 | 按钮/能力 | 编码 |
| --- | --- | --- |
| 运营商账户调度参数 | 查看详情 | `polling_carrier_params:detail` |
| 失败告警 | 查看详情 | `polling_failure_alert:detail` |
| 失败告警 | 账户汇总 | `polling_failure_alert:summary` |
| 失败告警 | 标记已处理(仅未处理状态) | `polling_failure_alert:handle` |
| 失败告警 | 批量关闭卡轮询(平台) | `polling_failure_alert:close_polling` |
| 失败告警 | 导出停机被拒(平台) | `polling_failure_alert:export` |
| 停复机规则演练 | 导出演练明细(平台) | `stop_resume_dry_run:export` |
| 不轮询清单 | 批量开启轮询(平台) | `polling_pause:restore` |
| 不轮询清单 | 创建导出任务(平台) | `polling_pause:export` |
| 轮询开关导入 | 上传并创建任务(平台) | `polling_status_import:create` |
| 轮询开关导入 | 查看任务与逐行结果(平台) | `polling_status_import:detail` |
| IoT 卡列表 | 批量开关轮询(平台/代理) | `iot_card:batch_polling` |
| 设备列表 | 批量开关轮询(平台/代理) | `device:batch_polling` |
| 单卡资产详情 / 卡级告警详情 | 查询单卡轮询状态 | `iot_info:polling_state` |
| 设备资产详情的绑定卡 | 查询该绑定卡轮询状态 | `device_info:polling_state` |
| 导出不轮询清单 | 查看详情 / 下载 | `export_task:polling_pause_detail` / `export_task:polling_pause_download` |
| 导出停机被拒告警 | 查看详情 / 下载 | `export_task:polling_stop_refused_alert_detail` / `export_task:polling_stop_refused_alert_download` |
| 导出停复机规则演练 | 查看详情 / 下载 | `export_task:stop_resume_rule_dry_run_detail` / `export_task:stop_resume_rule_dry_run_download` |
“详情”按钮及对应隐藏详情菜单需同时授权。告警详情内查看停复机记录复用已有 `iot_info:stop_resume_records`;资产单项轮询开关复用 `iot_info:auto_polling` / `device_info:auto_polling`,无需新编码。请求检查场景、队列监控无新增普通写按钮。系统配置沿用旧菜单与权限,仅新增 polling 模块选项。
### 操作边界
- 批量只提交当前勾选 ID,最多 500 条,不扩展成全量筛选;原因必填,先读 impact 再确认。四类逐项结果完整保留,部分成功不回滚。
- 企业账号不开放批量;代理资产范围由后端校验。风险停机、已销户不提供“强制开启”。
- 账户级告警 card_id=0 不请求单卡接口、不允许勾选关闭卡轮询;停机被拒视图隐藏并剔除不支持的筛选。
- 导入仅 CSV,首行为表头,首列保留文本,最多 50000 数据行;上传成功后才创建任务。任务终态/请求失败停止自动刷新。
- 演练仅判断不执行;明细 result 不改变汇总,导出也不带 result;演练允许每页 500,其他列表最大 100。
- 调度、限流、指令队列开关不会因本次前端接入自动修改。旧并发配置仅 legacy 模式有效。
C 端同步项和接口覆盖/验证见 [本次接入交付说明](./2026-10-26-轮询账户限流-后台接入交付.md)。
本次验证:191 项回归通过;类型、生产构建、代码/样式/格式与 UTF-8 检查通过。仅本地 mock 验收,没有写入菜单数据、推送或部署;生产构建仍有项目既有循环依赖与大 chunk 警告。
## 订单分类与沉默期(2026-10-28)
后台实现已按 Apifox 定义补齐。以下为实际路由和前端权限约定;后端仍需创建真实菜单/按钮记录并独立校验权限与数据范围。未推送、部署或写入服务器菜单,真实支付/审批/退款/导出需测试环境联调。准确范围见 [执行记录](../openspec/changes/add-order-segmentation-and-package-silence/implementation.md)。
### 新增可见菜单
| 菜单名称 | URL | 父菜单 | 菜单权限 |
| --- | --- | --- | --- |
| 平台订单 | `/order-management/platform-orders` | 订单管理 `/order-management` | `orders:platform_page` |
| 代理商订单 | `/order-management/agent-orders` | 订单管理 | `orders:agent_page` |
| 在线支付订单 | `/order-management/online-orders` | 订单管理 | `orders:online_page` |
| 延期订单 | `/package-management/silence-orders` | 套餐管理 `/package-management` | `package_silence:orders_page` |
| 延期批次与异常 | `/package-management/silence-batches` | 套餐管理 | `package_silence:batches_page` |
| 沉默期临期/过期套餐 | `/package-management/silence-usages` | 套餐管理 | `package_silence:usages_page` |
| 延期政策 | `/package-management/silence-policies` | 套餐管理 | `package_silence:policies_page`(并需 `package_silence:policy`) |
| 导出平台订单 | `/asset-management/export-task-management/export-order-platform` | 导出管理 `/asset-management/export-task-management` | `export_task:order_platform_page` |
| 导出代理商订单 | `/asset-management/export-task-management/export-order-agent` | 导出管理 | `export_task:order_agent_page` |
| 导出在线支付订单 | `/asset-management/export-task-management/export-order-online` | 导出管理 | `export_task:order_online_page` |
| 导出延期订单 | `/asset-management/export-task-management/export-silence-order` | 导出管理 | `export_task:package_silence_order_page` |
| 导出沉默期套餐 | `/asset-management/export-task-management/export-silence-usage` | 导出管理 | `export_task:package_silence_usage_page` |
代理账号仅可配置平台/代理商普通订单及其导出;在线普通订单及导出只允许 user_type=1/2。企业不得配置普通订单菜单。旧普通订单列表/详情保留。沉默期模块没有额外虚构身份范围,按后端真实授权及数据范围工作。
### 新增隐藏详情菜单
| 菜单名称 | URL | 父菜单 | 菜单权限 |
| --- | --- | --- | --- |
| 延期订单详情 | `/package-management/silence-orders/detail/:id` | 套餐管理 | `package_silence:order_detail_page` |
| 延期批次详情 | `/package-management/silence-batches/detail/:id` | 套餐管理 | `package_silence:batch_detail_page` |
直接访问详情同时要求对应列表页权限、隐藏详情菜单权限和下表详情按钮权限;不能只配隐藏详情菜单。导出详情沿用已有隐藏 `export_task:detail_page` 与已有导出详情 URL。仅超管专属配置不列入普通菜单清单。
### 新增及沿用按钮权限
| 操作 | 权限编码 |
| --- | --- |
| 下单时调整本次沉默期(两个单项购买入口) | `package_silence:override` |
| 扫码付费延期、单项特殊延期 | `package_silence:extend` |
| 批量特殊延期(含 CSV 导入) | 同时需要 `package_silence:extend`、`package_silence:extend_batch` |
| 登记线下付费延期 | `package_silence:offline` |
| 异常明细重试/关闭 | `package_silence:exception` |
| 政策查询/新增/编辑/启停/匹配 | `package_silence:policy` |
| 查看延期订单详情 | `package_silence:order_detail` |
| 查看延期批次详情 | `package_silence:batch_detail` |
| 创建延期订单导出 | `package_silence:order_export` |
| 创建沉默期套餐清单导出 | `package_silence:usage_export` |
| 创建普通订单/查看详情/导出 | 沿用 `orders:add` / `orders:view_detail` / `orders:export` |
| 平台普通订单导出详情/下载 | `export_task:order_platform_detail` / `export_task:order_platform_download` |
| 代理商普通订单导出详情/下载 | `export_task:order_agent_detail` / `export_task:order_agent_download` |
| 在线普通订单导出详情/下载 | `export_task:order_online_detail` / `export_task:order_online_download` |
| 延期订单导出详情/下载 | `export_task:package_silence_order_detail` / `export_task:package_silence_order_download` |
| 沉默期套餐导出详情/下载 | `export_task:package_silence_usage_detail` / `export_task:package_silence_usage_download` |
沉默期流水、首次购买记录没有在后端文档声明独立按钮权限,继承所属资产/列表页的读取范围,不虚构按钮编码。角色配置继续使用后端真实权限树,需后端提供权限记录和 ID,前端不伪造记录。
### 操作边界与验证
- 默认商品沉默期 1–90 天,购买覆盖天数仅最小 1;付费延期天数/金额由资格和试算决定,不复制算法。
- 特殊延期 CSV 为前端解析:表头 `package_usage_id,days`,每行两个整数,转换为文档 items[];不假设后端导入文件格式。
- 政策免审批完整枚举未在 OpenAPI 给出;已有值原样保留,提供明确的 offline_paid/batch,其他值需填写后端精确编码。政策接口没有 DELETE,不提供删除。
- 二维码/线下同一快照重试复用幂等键;特殊请求结果不明时提示核对批次并阻止重复提交。待审批、已支付和已完成严格区分。
- 异常关闭必须填写原因并确认,提示在线订单可能原路退款;不直接修改套餐截止时间。
- 企业资产五项限制保持;首次购买为只读事实,不回填绑定卡或换货资产;对象缺失隐藏沉默期入口。
- 236 项测试、类型与生产构建通过;仅本地 mock 验收,没有真实支付、审批、退款、开关切换、导出、推送或部署。
### C 端同步(本仓库未修改)
独立 C 端需接入可选 silence、状态/格式化截止日及 extendable 入口;`/api/c/v1/package-silence` 下的资格、报价、自助下单/qr_content、订单状态刷新、我的延期订单/详情、流水。对象缺失或总开关关闭时隐藏入口;金额单位分、结果取后端,不能复制计费/审批算法,不调用后台 URL。资产归属由后端校验。

View File

@@ -0,0 +1,41 @@
## Context
项目使用 Vue 3、TypeScript、Element Plus 和 BaseService。后端控制菜单模式下,超级管理员直接使用全部静态路由,普通用户使用授权菜单;按钮控制复用现有 `useAuth`,身份复用 `userStore.isSuperAdmin`。
## Goals / Non-Goals
- Goals: 完整接入文档声明的 API、可操作的管理页面、可靠表单校验、清晰权限配置交付。
- Non-Goals: 不实现后端限流,不改变旧轮询/停复机行为,不部署,不猜测统计指标字段。
## Decisions
- 四页统一归属「轮询管理」,不新增一级菜单:
| 页面 | URL | 父菜单 |
| --- | --- | --- |
| 停复机记录 | `/polling-management/stop-resume-records` | 轮询管理 `/polling-management` |
| 上游错误分类 | `/polling-management/error-classifications` | 轮询管理 `/polling-management` |
| 网关限流组 | `/polling-management/rate-limit-groups` | 轮询管理 `/polling-management` |
| 网关调用统计 | `/polling-management/gateway-stats` | 轮询管理 `/polling-management` |
| 网关限流组详情(隐藏路由) | `/polling-management/rate-limit-groups/detail/:id` | 轮询管理 `/polling-management` |
- 页面权限分别为 `card_stop_resume_record:page`、`gateway_error_classification:page`、`gateway_rate_limit_group:page`、`gateway_call_stats:page`,隐藏详情路由使用 `gateway_rate_limit_group:detail_page`。普通操作权限为 `gateway_error_classification:test`、`gateway_rate_limit_group:detail`、`gateway_call_stats:trend`;单卡入口按资产类型使用 `iot_info:stop_resume_records` / `device_info:stop_resume_records`。这些是前端配置约定,不声称来自后端接口文档;详情页与按钮使用独立编码,避免菜单与按钮编码冲突。
- 超级管理员维护入口不定义供普通账号分配的增删改权限;UI 与事件处理双重身份检查,后端继续独立鉴权。
- 提取专用新模块承载网关契约,避免覆盖旧 `/api/cards/{iccid}/suspend-resume-records` 接口及其不同 DTO。
- 枚举请求失败显式展示错误和重试,不把不可用枚举误报为空;未知值在只读展示中保留原值。
- 时间统一转换 RFC3339;全局记录查询必须有完整区间,单卡可使用后端默认;分页最大 100。
- 统计内部字段使用 `unknown`/记录值承载,只展示实际返回字段,空值不转成业务零;不从不明确字段推导图表或指标。
- 界面复用项目现有表格、搜索区、分页、弹窗、主题变量及状态标签;参考 UI/UX skill 的加载反馈、可访问标签、键盘焦点与响应式原则,不更换全站字体、颜色或新增表单依赖。
- 用户补充要求:列表严格复用店铺列表的 ArtSearchBar / ArtTableHeader / ArtTable / ArtTableFullScreen;详情使用店铺详情的返回、标题和双列描述表。时间展示复用同一个 `formatDateTime`,不改变请求与数据源;展开按钮仅在搜索条件数 >= 4 时出现。
- 运营商账户一律名称搜索下拉,复用 `CarrierService.getCarriers({ page: 1, page_size: 20, carrier_name })`,保留原账户名称回显,选中后仍提交 ID;趋势额外保留「无法解析账户的卡」的值 0。
## Risks / Trade-offs
- 统计契约缺字段 → 接口和数据可查看,但正式业务趋势图需后端样例或完整 DTO 后再确定。
- 前端权限编码未在 API 文档定义 → 交付明确为菜单按钮配置编码,不替代后端鉴权。
- 31 天日期窗口和不同时区 → 按实际时间戳差值校验,测试边界与非法值。
- 用户已有未提交改动 → 不碰部署文件及 package 配置,不引入新依赖。
## Validation
服务契约测试覆盖所有 17 个接口;业务测试覆盖时间范围、QPS、优先级操作、超级管理员门禁、枚举值和统计未知字段。执行 `vue-tsc --noEmit`、Vite 构建、针对性 lint/格式/编码检查。没有真实后端响应时,明确区分本地 mock 验证与线上联调。

View File

@@ -0,0 +1,32 @@
# Change: 网关限流管理前端接入
## Why
`docs/网关限流.md` 新增 17 个管理端接口,当前前端缺少网关限流组、上游错误分类、全局停复机记录及按运营商统计的入口。需要完整接入已定义契约,并在交付时提供普通账号需要配置的菜单与按钮权限,不列出仅超级管理员的操作。
## What Changes
- 在现有「轮询管理」下新增「停复机记录」「上游错误分类」「网关限流组」「网关调用统计」四个页面。
- 接入全部 17 个接口及类型;枚举通过 `GET /api/admin/polling-options` 获取,不把可配置枚举仅写死在前端。
- 停复机记录支持文档内筛选、分页、必填时间范围和 31 天跨度校验;单卡信息及设备绑定卡提供单卡历史入口,使用卡 ID 请求新接口。
- 分类规则支持分页筛选、测试现有规则和草稿;限流组支持分页筛选和详情。新建、修改、启停、删除均仅向超级管理员开放,并在操作函数中再次检查身份。
- 限流组表单校验 QPS、预留额度与高优先级操作的关系,删除和启停提供确认与防重复提交。
- 网关统计支持账户汇总及账户趋势查询,校验各接口时间参数。
- 接入路由别名、静态路由和现有后端菜单模式;使用 `xxx:xxx` 格式前端权限常量控制普通用户操作。
- 新增契约、参数校验、权限分支和交互回归测试,运行类型检查、构建及编码检查。
- 完工后输出菜单配置清单(名称、URL、父菜单)与按钮权限编码;清单仅保留非超级管理员专属项。
## Impact
- Affected specs: `gateway-rate-limit-management`(新增)。当前 `openspec list --specs` 无已归档规范;现有 `add-polling-priority-queue` 已完成,与本次新增页面并列,不修改其行为。
- Affected code: `src/types/api/`、`src/api/modules/`、`src/config/constants/`、`src/views/polling-management/`、`src/views/asset-management/asset-information/`、`src/router/routesAlias.ts`、`src/router/routes/asyncRoutes.ts`、`scripts/tests/`。
- 不修改网关后端限流算法、既有轮询调度、停复机执行动作;不自动创建数据库菜单,不推送 Git,不执行生产部署。
- 保留用户在 `.gitea/workflows/deploy.yaml` 与 `package.json` 的现有修改。
## Contract boundary
文档未提供统计接口 `carriers[]`、`points[]` 内部字段,仓库当前也没有 `docs/admin-openapi.yaml` 可补全。实现不得假定次数、失败率、时间戳等字段:先按实际响应提供可查看的动态字段表格/原始数据,并清楚提示契约未展开;有真实字段样例后才绘制业务趋势图。不得用虚构字段或零值冒充统计结果。
权限编码是前端配置约定,并非接口文档声明的后端权限;实际授权仍由后端控制。仅超级管理员限制必须依据现有 `userStore.isSuperAdmin`,不能通过配置普通按钮权限绕过。
用户于 2026-10-24 回复「直接执行」,已批准按此范围实施。

View File

@@ -0,0 +1,98 @@
## ADDED Requirements
### Requirement: 停复机记录查询
The frontend SHALL provide global stop/resume records and single-card history using the two documented admin APIs. Global queries MUST include RFC3339 `start_time` and `end_time` with an ordered range no greater than 31 days, all documented optional filters and pagination of at most 100 rows. Single-card history MUST use the card ID rather than ICCID or device ID.
#### Scenario: 查询全局记录
- **WHEN** 用户进入页面或修改筛选、页码
- **THEN** 前端 MUST 请求 `/api/admin/card-stop-resume-records`,展示记录字段及 `items/page/size/total`
- **AND** 不合法或缺失的时间区间 MUST 阻止请求并显示明确提示。
#### Scenario: 查看单卡历史
- **WHEN** 获授权用户从单卡或设备绑定卡打开历史
- **THEN** 前端 MUST 请求 `/api/admin/iot-cards/{id}/stop-resume-records`
- **AND** 无卡 ID 或无入口权限时 MUST 不请求接口,切换卡后不得残留另一张卡的结果。
### Requirement: 上游错误分类规则
The frontend SHALL implement rule listing, creation, modification, status change, deletion and non-persistent testing according to the documented fields. Rule maintenance MUST be restricted to super administrators. Testing SHALL support the current rule set and an optional valid draft, and display `matched/rule_id/category/category_name/keyword/maybe_accepted`.
#### Scenario: 普通账号测试分类
- **WHEN** 具有测试权限的用户提交操作与上游原始信息
- **THEN** 前端 MUST 调用 `/api/admin/gateway-error-classifications/test` 并展示实际命中结果
- **AND** 空信息或超过 1000 字符 MUST 不得提交,不通过增删改接口测试草稿。
#### Scenario: 超级管理员维护
- **WHEN** 超级管理员提交合法规则或确认启停、删除
- **THEN** 前端 MUST 使用对应 CRUD 或 status 接口,成功后刷新列表
- **AND** 普通账号 MUST 不显示维护入口且无法从处理函数发起维护请求。
### Requirement: 网关限流组
The frontend SHALL implement group listing, details, creation, modification, status change and deletion, including carrier/global and status filters. Maintenance MUST be restricted to super administrators. `max_qps` MUST be an integer in 1–10000, `reserved_qps` MUST be a nonnegative integer less than `max_qps`, operations MUST be nonempty, high-priority operations MUST be a subset and MUST be nonempty when reserved QPS is positive.
#### Scenario: 维护限流组
- **WHEN** 超级管理员提交限流组
- **THEN** 前端 MUST 校验文档的必填、长度、范围及操作关联约束后调用 POST 或 PUT
- **AND** 全局组 MUST 使用空账户值,不得错误提交为具体账户。
#### Scenario: 查询限流组
- **WHEN** 获授权用户查询列表或详情
- **THEN** 前端 MUST 分别调用 `/api/admin/polling-rate-limit-groups` 或 `/{id}` 并展示真实字段
- **AND** 普通账号 MUST 不能通过详情页进入维护操作。
### Requirement: 动态枚举与统计
The frontend SHALL load all seven enum collections from `/api/admin/polling-options`, and SHALL implement carrier summary and trend requests with documented parameter bounds. Undefined inner fields in `carriers` and `points` MUST be presented transparently from actual response data without invented metrics, silently substituted zeros or assumed chart series.
#### Scenario: 加载枚举
- **WHEN** 页面需要筛选、编辑或显示网关枚举
- **THEN** 前端 MUST 使用接口的 label/value,失败时提供错误反馈及重试
- **AND** 未知只读枚举值 MUST 保留原始值,不被显示为另一个已知状态。
#### Scenario: 查询汇总与趋势
- **WHEN** 用户选择合法的统计区间、运营商和聚合粒度
- **THEN** 前端 MUST 请求对应 `/api/admin/polling-stats/carriers` 或 `/{carrier_id}/trend`
- **AND** 汇总 minutes 为 1–60,趋势 minutes 不超过 10080,粒度为有效正整数,账户 0 MUST 可用
- **AND** 契约缺失的内部字段 MUST 以实际字段或原始数据展示并说明限制。
### Requirement: 菜单权限与交付
The frontend SHALL register four pages under existing polling management and honor the existing static/superadmin and backend-menu routing modes. Normal actions MUST use explicit `xxx:xxx` permissions. The handoff SHALL list menu name, URL, parent menu and button permission codes only for non-superadmin-exclusive configuration.
#### Scenario: 普通账号访问
- **WHEN** 普通账号获得相应菜单和按钮权限
- **THEN** 对应页面和普通查询、测试操作 MUST 可用
- **AND** 未授权按钮和请求 MUST 不可用,超级管理员专属维护能力 MUST 不被普通按钮权限开启。
#### Scenario: 交付配置清单
- **WHEN** 实现和验证结束
- **THEN** 交付 MUST 列出实际实现的普通菜单与按钮权限,不列出仅超级管理员专属配置
- **AND** MUST 披露未进行真实后端联调或统计字段不完整等验证限制。
### Requirement: 店铺页面一致性
The frontend SHALL reuse the shop list search, table header, column settings, fullscreen and pagination components, and the shop detail back button, title and responsive description layout. All displayed business timestamps MUST use the same `formatDateTime` formatter as the shop list. Search expansion MUST be enabled if and only if there are at least four search fields. Carrier selection MUST be a remote dropdown searched by carrier name using `/api/admin/carriers?page=1&page_size=20&carrier_name=...` while requests keep numeric IDs.
#### Scenario: 展开与时间显示
- **WHEN** 页面有少于 4 个搜索条件
- **THEN** `show-expand` MUST 为 false;4 个及以上 MUST 为 true
- **AND** 已知列表、详情及统计数据中的业务时间 MUST 展示为店铺列表相同格式,不直接显示 RFC3339 字符串。
#### Scenario: 按名称搜索运营商
- **WHEN** 用户在运营商账户下拉输入名称
- **THEN** 前端 MUST 请求 carriers 列表并携带 page=1、page_size=20、carrier_name
- **AND** 编辑回显 MUST 保留原账户名称,筛选/保存 MUST 提交所选 numeric carrier_id,趋势未解析账户 MUST 仍可选择值 0。

View File

@@ -0,0 +1,29 @@
## 1. 契约和公共能力
- [x] 1.1 确认提案并记录审批。
- [x] 1.2 新增全部接口请求、响应类型和服务方法,统一导出,复用现有认证及错误处理。
- [x] 1.3 接入动态枚举、权限常量、分页与日期/限流参数校验工具。
## 2. 页面与入口
- [x] 2.1 新增停复机记录列表,支持全部筛选字段、分页、默认最近时间段和 31 天校验。
- [x] 2.2 单卡及设备绑定卡新增停复机历史入口,使用真实卡 ID,处理权限及卡切换状态。
- [x] 2.3 新增上游错误分类页,支持列表筛选、超级管理员维护及现有规则/草稿测试结果展示。
- [x] 2.4 新增网关限流组页,支持列表、详情、超级管理员维护及关联字段校验。
- [x] 2.5 新增网关调用统计页,支持汇总、趋势参数和未定义内部字段的透明展示。
- [x] 2.6 注册路由别名、四个菜单路由及隐藏详情路由、页面权限;复用现有后端菜单解析模式。
## 3. 验证与交付
- [x] 3.1 覆盖全部服务 URL、HTTP 方法、参数和响应的契约测试。
- [x] 3.2 覆盖时间、QPS、高优先级操作子集、权限和动态枚举回归测试。
- [x] 3.3 检查加载、错误、空数据、确认、重复提交和响应式显示。
- [x] 3.4 执行类型检查、构建、相关回归测试、格式与编码检查,记录实际结果。
- [x] 3.5 交付菜单名称/URL/父菜单和按钮权限编码,仅列非超级管理员专属配置。
## 验证结果
- 全量 Node 回归 153/153 通过,其中网关新增 23 项;`vue-tsc --noEmit`、生产构建、针对性 ESLint/Stylelint、格式和 UTF-8 检查通过。
- 本地 mock 浏览器验证:列表与详情、规则测试、按运营商名称搜索、账户 0 趋势、普通账号无维护按钮及授权隐藏详情路由;375px / 768px / 1024px / 1440px 检查与局部截图复核。
- 菜单与按钮清单写入 `docs/权限.md`,本地详细接入说明位于 `docs/网关限流-前端接入说明.md`(仓库默认忽略其他 docs 文件)。
- 未进行真实后端联调、Git 推送或生产部署;统计内部字段缺失时按实际响应展示。构建仍有项目既有常量绑定、循环依赖和大 chunk 警告。

View File

@@ -0,0 +1,26 @@
## Context
Vue 3 / Element Plus 后台已有订单列表与详情、两处单项购买、套餐创建/分配/系列授权、资产当前/历史套餐、统一导出、企微审批与角色权限树。本次复用现有能力,延续店铺列表/详情标准、时间格式、条件 >=4 才展开和响应式。
## Decisions
- 原订单列表接收受控分类 props,三个薄路由入口使用独立名称缓存,按分类选择服务与导出场景,不在 query 注入 category。旧列表继续 all;企业路由门禁与请求/处理函数都限制,代理 online 严格禁止,后端继续鉴权。
- 企业操作限制使用公共账号类型判断;不能仅改一个 v-if:覆盖基础信息、绑定卡、当前套餐、历史套餐及父页处理函数,保留 start/stop、历史与只读数据。
- 套餐商品默认天数、单次购买覆盖天数、延期天数分别校验,不能共享错误的 90 天上限;null/空与 0 区分;加油包白名单剔除沉默期字段。原实名生效条件保存为只读基线,禁止转换为购买生效。
- 下单前冻结目标、表单字段和幂等标识;权限取消或资产/套餐切换不发送旧覆盖值。后端决定实际是否适用及是否审批,不靠前端成功提示推定立即生效。
- silence 可选且不可从其他套餐复用。当前/历史套餐分别展示各自响应;后端不返回对象时不猜测未启用与不适用的区别,不请求资格接口。
- 首次购买显示使用 formatDateTime;查询与导出共享同一范围转换,ICCID/IMEI 保持字符串。世代与结束原因由服务端返回,记录 DTO 不明时仅完成字段/筛选,记录弹窗保持待补契约。
- 使用现有预签名上传、凭证、二维码与审批组件,不引入依赖。延期付款金额必须取 quote,不能在浏览器自行按 30 天计费;未知的金额单位与 evidence_refs 结构是实现前置条件。
- 对文档只列 URL、未列 DTO 的延期页面,先列覆盖清单与契约缺口,不擅自制作可写空壳或推断财务模型。
## Validation
2026-10-28 契约补齐:保存 Apifox 的 21 个后台操作为 `docs/api/package-silence.openapi.json`;全部金额为整数分,前端元输入精确转换。首次购买记录使用 `/api/admin/assets/{asset_type}/{id}/first-purchase-records`,流水/首次购买记录为数组;政策列表也为数组但支持 page/page_size,因此显示上一页/下一页而不伪造总数。政策适用系列/套餐使用既有名称搜索接口。
特殊延期 CSV 在前端按 `package_usage_id,days` 两列解析,再以明确的 items[] 提交,不猜后端 import_file_key 的未说明文件格式。二维码/线下订单同一快照重试复用 request_id;特殊接口不定义幂等字段,结果不确定时阻止重复提交并提示核对批次。免审批场景保留真实返回的字符串,提供文档明确的 offline_paid/batch;其他编码需后端提供,不猜完整枚举。
先记录当前 198 项基线与 dirty worktree;新增精确请求/角色/字段校验及组件行为测试,覆盖企业 5 项操作但保留停复机、三分类服务、导出 false/0、套餐天数边界、购买覆盖与原因、无权限不发字段、加油包剔除、首次购买时间与空值、未知错误和 0 元退款。运行全量 Node 测试、vue-tsc、受影响文件 lint/format/encoding、生产构建与针对性本地 mock;不把不完整 DTO 的自造响应作为后端验收。
## Rollout and risks
无数据库迁移,不自动开沉默期总开关,旧订单路由/导出与现有菜单配置保留。新菜单是否可见仍需后端菜单数据授权;清单在实施后按实际 URL/权限交付。真实支付与审批、资产范围和文件内容需测试环境联调。C 端为独立工程,仅提供同步清单。

View File

@@ -0,0 +1,61 @@
# 执行记录:订单分类与沉默期
## 当前结论
本后台代码已按汇总文档及用户授权的 Apifox 定义补齐,原先因缺少 DTO 挂起的延期办理、政策、批次异常和记录弹窗已实现。未修改独立 C 端,未推送、部署或调用真实业务写入;不能把本地 mock 验证等同于线上验收。
## 契约来源与差异
21 个后台操作读取自用户授权的 Apifox 分享文档,留存无密码快照 [package-silence.openapi.json](../../../docs/api/package-silence.openapi.json)。本会话通过官方 Apifox MCP Server 0.0.18 的 --oas 模式验证文档读取,不代表直接实时连接整个私有项目;线上变更后需重新读取快照。
- 所有延期金额、单价均为整数分;元输入做精确整数转换,不能使用浮点金额直接提交。
- 首次购买记录真实路径为 `GET /api/admin/assets/{asset_type}/{id}/first-purchase-records`,不是汇总里带 package-silence 前缀的路径。
- 首次购买记录、沉默期流水为数组;政策列表也是数组且有 page/page_size,因此政策页不伪造 total。
- 更新政策与启停请求含 id;特殊收款时间是 offline_paid_at,线下订单是 paid_at。
- 两个沉默期导出场景依据 orders/usages 各自文档启用,filters 与列表同范围;通用导出接口对 query 的类型为字符串值,沿用 JSON 字符串 filters。之前三类普通订单仍保留汇总明确要求的对象 filters。
## 实现覆盖
| 范围 | 已实现 |
| --- | --- |
| 分类订单 | 平台/代理/在线三页、独立精确路径与 scene、旧列表详情及历史导出保留;代理禁止 online、企业禁止普通订单 |
| 企业资产限制 | 有效期、已用量、停用、轮询、实名状态五类入口和处理函数限制;授权资产停复机保持;未找到仍为普通未找到 |
| 套餐与购买 | 商品默认 1–90 天、加油包剔除、生效条件限制;单项/批量/系列分配写前校验;两处下单覆盖无 90 天上限并有权限及原因校验 |
| 资产沉默期 | 按可选 silence 展示真实状态/截止/累计;真实 usage ID 打开延期与流水;对象缺失隐藏 |
| 扫码延期 | 资格、后端报价、wechat/alipay 二维码、确认后创建、订单刷新;终态/错误/关闭停止刷新;不复制计价算法 |
| 线下登记 | 实收元转分、与报价一致、收款方式字典、交易单号、RFC3339 付款时间、1–5 张凭证、申请依据;待审批不显示已生效 |
| 特殊与批量 | 五类特殊延期、原因/依据、优惠价收款;CSV 两列转 items[],校验表头/整数/重复 ID;批量需 extend 和 extend_batch;展示真实批次与错误行 |
| 订单与批次 | 列表/详情/筛选、订单导出;批次详情逐行异常;重试/关闭需原因及确认,关闭提示可能原路退款 |
| 临期与过期 | expiring/expired/all 视图、默认 7 天、资产/店铺/套餐/截止范围及导出;延期与流水入口 |
| 延期政策 | 列表、详情加载、新增/全量编辑、启停、匹配、重叠错误;系列/套餐名称搜索;无 DELETE 不提供删除 |
| 首次购买 | 卡/设备时间列、RFC3339 筛选/导出、资产详情;列表记录入口和只读世代历史,展示来源/订单/结束原因;不伪造绑定卡或换货时间 |
| 配置与兼容 | 专用总开关/precheck/failed_items、通用配置只读、企微审批类型、六项真实权限识别、1260–1275 提示及零元退款 |
同一二维码/线下请求快照重试使用相同 request_id,不自动重发写请求。特殊延期没有文档定义的幂等参数,传输结果不确定时阻止再次提交并提示先核对批次;不得直接修改截止时间替代异常处理。
CSV 为本地解析的 `package_usage_id,days` 两列格式,经文档 items[] 提交,未猜测后端 import_file_key 的文件格式。政策免审批场景完整枚举未在 OpenAPI 列出:提供明确的 offline_paid、batch,并保留服务端现有值;其他编码由后端提供后填写,不根据中文自行造值。批次及明细未知状态原样显示,不推断“已完成”。
## 验证
当前全量 **236 项测试通过**(保留原 198 项,上一阶段 18 项,本阶段 20 项)。覆盖 21 个精确 HTTP 操作、金额精度与 0、CSV、报价失效、权限撤销/确认取消、幂等重试、迟到响应、线下凭证与审批、特殊结果不确定防重复、政策完整提交、异常 ID/原因、导出白名单与模板编译。
类型检查、生产构建、受影响代码 ESLint/Prettier/Stylelint、UTF-8 检查通过。生产构建仍有项目既有常量 v-model、API 循环依赖及大 chunk 警告,未在本轮扩大重构。
本地浏览器使用 `127.0.0.1:5190` 全 /api 拦截 mock,核验临期状态与格式化截止时间、31 天后端试算为 60 天、提交确认及订单刷新、政策编辑与 390px 窄屏、批次异常原因确认。上一阶段三类订单/角色门禁/套餐配置/总开关 mock 检查仍保留。没有请求真实支付或审批接口。
```powershell
$tests = @(rg --files scripts/tests -g '*.test.mjs')
node --import ./scripts/tests/register-esbuild.mjs --test --test-reporter=spec $tests
.\node_modules\.bin\vue-tsc.cmd --noEmit
node scripts/tests/verifyBuild.mjs
node scripts/tests/verifyBuild.mjs --serve --order-silence
openspec validate add-order-segmentation-and-package-silence --strict
```
## 菜单、权限和 C 端
完整菜单/URL/父菜单与普通按钮编码见 [权限清单](../../../docs/权限.md#订单分类与沉默期2026-10-28)。不列仅超管专属设置,不写入服务器菜单或伪造权限 ID。
C 端需要另行接入:可选 silence 状态/截止与 extendable 入口;资格、后端报价、自助订单/qr_content、订单状态刷新、我的延期订单和流水;总开关关闭或对象缺失隐藏入口;金额为分、不能复制计费/审批规则。对应 `/api/c/v1/package-silence`,不复用后台 URL;资产归属鉴权交给后端。本仓库未修改 C 端代码。
真实测试环境仍需验收:数据范围与权限、实际商户支付及查单、企微通过/驳回、异常关闭退款、上传文件和导出下载内容。全部检查只证明前端实现及本地契约行为,不证明后端已部署兼容。

View File

@@ -0,0 +1,43 @@
# Change: 订单分类拆页与套餐沉默期后台接入
## Why
依据 `docs/2026-10-27-订单拆分至沉默期-前端修改汇总(1).md`,后台尚未接入订单分类列表、企业账号操作限制、套餐沉默期及延期、首次购买套餐时间。需要保留既有订单详情、付款/审批、套餐分配与资产功能,并区分前端实现和真实后端联调。
用户于 2026-10-28 明确要求“根据文档来写提案,然后执行”,本提案完成严格校验后按该授权实施。延续只修改本后台仓库的范围,不修改独立 H5/C 端,不推送或部署。工作区已有四个轮询修复文件必须保留。
## What Changes
- 订单管理新增平台、代理商、在线支付三页,复用原列表及详情、筛选、创建能力;各自请求 `/orders/platform|agent|online`,不提交类别参数,导出分别使用 `order_platform|order_agent|order_online`。保留旧列表及历史 `order` 导出。代理隐藏在线支付页,企业隐藏订单菜单并阻止页面请求;不改变后端数据范围。
- 企业账号隐藏并在处理函数阻止资产改有效期、改已用量、停用、开关轮询、改实名状态五项操作,保留授权资产的停复机及读取。未找到不改写为无权限。
- 正式套餐新增默认沉默期(1–90 天)与付费延期开关;实名即生效必填,新建默认 30 天;购买即生效可选;加油包不显示、不提交。新增未配置筛选与导出参数、详情展示;明确生效条件不可从实名改成购买,购买改实名需同请求带天数。
- 后台两处单项下单入口增加有权限才可填写的 `silence_period_days`、`silence_reason`;购买覆盖天数仅最小 1、不套用套餐配置 90 天上限,天数存在时原因必填。保留后端审批结果,不将待审批说成已延期。
- 资产当前套餐、历史套餐按可选 `silence` 对象展示状态、截止日、累计与审批信息;对象缺失时隐藏,入口按 `extendable` 与权限控制。流水使用真实 usage ID。
- 卡/设备新增首次购买时间列、RFC3339 范围筛选、导出参数、详情与只读记录弹窗;绑定卡不伪造首次购买时间,换货历史以服务端世代与结束原因展示,不在前端推算或回填。
- 新增沉默期专用开关页(仅超管),展示 `precheck` 与 1275 的 `failed_items`;通用系统配置不写此开关。增加沉默期审批业务类型与六个权限点的前端识别;角色权限树沿用服务器数据,不伪造权限 ID。
- 完整目标包含延期资格/试算/二维码、线下付款、特殊单项/批量延期、延期订单/批次异常、临期/过期清单、延期政策以及两个沉默期导出场景;金额、审批与状态均以服务端事实为准,不复制后端计费规则。
- 错误码 1260–1275 保留服务端具体原因、限制与异常行;零元退款允许提交,后台线下购买未实名套餐仍按后端待生效状态展示。
## Contract gaps and execution boundary
首次执行时,`docs/admin-openapi.yaml`、被引用的沉默期技术方案与后端规范未提供,因此对应可写业务未实施。2026-10-28 用户补充并授权带密码的 Apifox 分享文档访问后,已取得 21 个后台操作,保存为 `docs/api/package-silence.openapi.json`(不含密码)。下面四组缺口现已按该权威定义补齐,任务第 3 节已执行;真实环境支付/审批/导出仍需联调验收。
初次记录的缺口(现已由 Apifox 补齐):
1. 延期政策 POST/PUT 字段、字段类型、级别/范围/重叠规则、列表/详情响应与状态枚举。
2. 资格 options、quote、二维码及线下订单响应的字段结构、金额单位、订单 ID/状态/支付状态;超时重试的 request_id 复用约定。
3. 特殊延期优惠收款字段名、amount 与 paid_amount 单位、evidence_refs 元素结构、批量部分成功/异常响应完整结构。
4. 流水、延期订单、批次/明细、usage 清单、首次购买记录的分页结构、筛选、时间与状态字段;未定义处不得擅自命名。
执行顺序:先完成本文可确定的订单、企业限制、套餐/下单字段、首次购买字段/筛选、审批类型和错误提示;契约缺口独立保持未完成。若执行期间补齐规范,再继续完成相应延期业务。不得以全部接口存在或本地测试通过代替完整业务验收。
## Impact
- Affected specs: 新增 `order-segmentation-and-package-silence` 接入约束;当前无已发布 specs,以 ADDED 记录,不伪造 MODIFIED 基线。
- Affected code: order、packageManage、shopPackageAllocation、asset、card、device、wecom、systemConfig、exportTask 的类型/服务/界面、路由与权限、相关工具及回归测试。
- 与 `update-october-batch-one-admin-integration` 的订单支付信息、退款审批状态,以及 `update-package-allocation-expiry-base-override` 的分配覆盖保持兼容;不重置此前轮询修复。
- 不产生真实支付、审批、资产写入、导出、开关切换或 CSV 导入;写操作验证仅本地 mock。
## Delivery
交付提案、实际改动与测试证据;菜单清单给出名称、URL、父菜单、普通按钮编码,不重复列仅超管专属项;单列 C 端同步要求和确实因契约缺失未完成的工作。

View File

@@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: 订单分类与角色边界
The admin frontend SHALL expose independent platform, agent and online order pages using the corresponding documented endpoints and export scenes without category query parameters. It MUST preserve legacy order history and the shared detail contract. Enterprise accounts MUST not query or mutate orders, and agents MUST not access online orders.
#### Scenario: 分类查询和导出
- **WHEN** 平台账号打开某类订单或创建该类导出
- **THEN** 请求 MUST 使用对应路径和 scene,保留原筛选,不改变订单详情接口。
#### Scenario: 不允许的账号
- **WHEN** 企业账号尝试进入订单页,或代理进入在线订单
- **THEN** 菜单和页面请求 MUST 被限制,不以按钮授权覆盖身份限制。
### Requirement: 企业资产操作限制
The frontend SHALL hide and block expiry adjustment, used-data adjustment, deactivation, polling toggle and realname-status adjustment for enterprise accounts across all existing asset entrypoints while preserving authorized start/stop and reads. A not-found asset response MUST remain ordinary not-found feedback.
#### Scenario: 已授权企业资产
- **WHEN** 企业账号查看已授权卡或设备
- **THEN** 五项禁止操作 MUST 不可见且处理函数不能提交,停复机仍按原权限可用。
### Requirement: 套餐默认沉默期与购买覆盖
The frontend SHALL distinguish package defaults (1–90 days), purchase overrides (minimum 1, no documented upper bound) and backend-priced extensions. Formal activation-based packages MUST require defaults with 30 days prefilled only for creation; addons MUST omit silence fields. Forbidden expiry-base transitions MUST be prevented where source data is known and backend rejection preserved otherwise.
#### Scenario: 默认配置
- **WHEN** 新建实名即生效正式套餐或将购买生效改为实名生效
- **THEN** 保存 MUST 携带有效 silence_period_days,不能从实名改为购买生效。
#### Scenario: 单次购买覆盖
- **WHEN** 有 override 权限的用户填写本次沉默期
- **THEN** 请求 MUST 同时包含有效天数与原因;无权限或未填写时不发覆盖字段,等待审批不能显示为已延期。
### Requirement: 可选沉默期与首次购买事实展示
The frontend SHALL show silence status and timestamps only when the server returns the applicable silence object. First-purchase timestamps SHALL remain read-only server facts with shared range filtering/export and generation history, never synthesized for bound cards or exchanges.
#### Scenario: 总开关关闭
- **WHEN** 套餐响应不包含 silence
- **THEN** 沉默期状态及延期入口 MUST 隐藏,不推算默认截止时间。
#### Scenario: 首次购买范围
- **WHEN** 用户按首次购买时间查询卡或设备并导出
- **THEN** 列表和导出 MUST 使用相同 RFC3339 范围,空时间仍显示为空且不开放修改。
### Requirement: 延期与审批使用真实后端契约
The frontend SHALL use documented eligibility, quote, payment, special extension, policy, batch and history contracts. Amounts and state transitions MUST remain backend facts. Undefined DTO fields or money units MUST be reported as missing prerequisites rather than implemented through guessed writes or arbitrary JSON forms.
#### Scenario: 缺少财务契约
- **WHEN** 汇总未定义政策保存字段、金额单位或收款结构且权威文档不可用
- **THEN** 对应实现项 MUST 保持未完成,交付说明准确列出需要补充的契约。
#### Scenario: 专用总开关与错误反馈
- **WHEN** 超管切换总开关或后端拒绝延期
- **THEN** 前端 MUST 走专用开关接口并展示 precheck/failed_items 或具体错误;不得用通用系统配置绕过检查。
### Requirement: 交付与验证边界
The change SHALL preserve existing routes, permissions, worktree edits and successful payment/approval behaviors. It MUST include targeted regression tests and an actual menu/permission handoff, excluding superadmin-only configuration from ordinary-user setup. H5/C-end work and real-backend validation MUST be disclosed separately.
#### Scenario: 执行交付
- **WHEN** 本轮工作停止或完成
- **THEN** 交付 MUST 区分已实现、已本地验证、真实联调待办与缺契约未实现项,不以 mock 或服务数量宣称全部完成。

View File

@@ -0,0 +1,33 @@
## 1. 提案与契约
- [x] 1.1 完整核对汇总文档、现有代码、未归档提案和四个轮询 dirty 文件。
- [x] 1.2 严格校验本提案,按用户明确的先提案后执行授权实施。
- [x] 1.3 通过用户授权的 Apifox 分享文档收齐 21 个后台操作及金额单位,保留无密码的 OpenAPI 快照;首次购买记录以真实 `/api/admin/assets/{asset_type}/{id}/first-purchase-records` 为准。
## 2. 文档明确的后台改动
- [x] 2.1 三类订单服务/独立路由/复用列表/独立导出场景,保留旧列表与详情。
- [x] 2.2 代理隐藏 online、企业隐藏订单/禁止请求;企业五项资产操作所有入口和处理函数限制,保留停复机。
- [x] 2.3a 套餐默认沉默期与延期开关、未配置筛选/导出、详情、加油包剔除与商品自身生效条件转换校验。
- [x] 2.3b 分配/批量/系列覆盖写入前复用套餐详情校验,不猜候选 metadata;覆盖不合法不发写请求,保留后端 1262 约束及原 Body。
- [x] 2.4 两处后台单项下单覆盖天数/原因、权限、正确边界与请求白名单。
- [x] 2.5 资产当前/历史套餐按可选 silence 展示状态、截止日、累计和审批状态,不伪造延期完成。
- [x] 2.6 卡/设备首次购买时间列/筛选/导出/详情,保持只读,不在绑定卡填充。
- [x] 2.7 沉默期开关专用接口/超管页/检查失败展示,通用系统配置只读。
- [x] 2.8 企微沉默期审批类型、六项权限识别、错误码提示、零元退款校验与等待实名文案核查。
## 3. 完整契约补齐后实施
- [x] 3.1 资格/后端报价与二维码延期、支付状态刷新、同快照幂等键复用与迟到响应隔离。
- [x] 3.2 线下付款登记、元转整数分、金额一致性、1~5 张凭证与真实审批状态反馈。
- [x] 3.3 特殊单项/批量 CSV 转换为文档 items[]、原因依据、优惠收款、逐行失败与异常状态;无幂等接口的超时不自动重发。
- [x] 3.4 延期订单/详情/导出、批次/明细/原因确认后重试关闭、临期/过期 usage 清单/导出。
- [x] 3.5 延期政策列表/新增/编辑/匹配/启停/重叠反馈;后端未提供 DELETE,不虚构删除。
- [x] 3.6 沉默期流水与卡/设备列表、资产详情的首次购买记录弹窗。
## 4. 验证与交付
- [x] 4.1 精确请求与角色、金额/天数边界、0/false/null、异步生命周期、审批状态与错误行回归。
- [x] 4.2 类型、全量 236 项测试、构建、lint/样式/格式/编码,保留用户已有修改和声明;执行证据见 implementation.md。
- [x] 4.3 已实施范围的本地 mock 页面与窄屏验收;真实后端未验证内容单列。
- [x] 4.4 实际菜单/URL/父菜单/普通按钮权限、C 端同步清单、接口差异与真实联调待办交付,已写入 docs/权限.md 与 implementation.md;未部署,不归档为线上已验收。

View File

@@ -0,0 +1,45 @@
# Change: 接入轮询配置匹配预览、调序、重算和统计
## Why
`docs/轮询配置管理_API简化版.md` 新增 7 个接口。当前 `PollingConfigService` 仅有配置 CRUD、启停和启用列表,轮询配置页面没有匹配预览、优先级整体调整、异步重算及统计入口。需要按已明确的契约补齐,并延续之前菜单、权限和界面交付标准。
## What Changes
- 为全部 7 个新增接口补充服务方法、请求/响应类型及契约测试。
- 在既有「轮询配置」`/polling-management/config` 内增加匹配预览入口:支持当前配置 GET 预览,以及携带多条未保存修改的 POST 预览。支持新增草稿(id=0)、覆盖配置和模拟停用,展示卡信息、命中配置和最终任务间隔;预览不写数据。
- 草稿表单支持资产状态、套餐状态、五项检查间隔及重置日前窗口字段,准确区分前四项间隔的 null 继承、0 禁用和正数间隔;保护期 null 不检查。新增字段仅进入文档明确的 overrides,不擅自扩展旧 CRUD 保存契约。
- 为超级管理员接入优先级排序提交和确认后的异步重算,界面和事件双重身份检查,不创建普通用户可分配的专属操作权限。
- 在既有配置页面提供按 task_id 查询重算进度入口;启动后可自动查询,关闭、卸载和终态清理轮询,失败保留任务 ID 与错误信息。
- 在「轮询管理」下新增「轮询配置统计」`/polling-management/config-stats`,包含重置日前加密查流量统计、分层统计两个独立授权区域;不额外增加一级菜单或详情路由。
- 界面沿用店铺列表及店铺详情标准;业务时间使用 `formatDateTime`;搜索展开仅在条件数 >=4 时启用;运营商账户复用按名称搜索的远程下拉。
- 完成后在 `docs/权限.md` 与最终回复列明实际新增菜单名称、URL、父菜单、页面权限和按钮权限,仅列普通账号配置。
## Impact
- Affected specs: `polling-config-extensions`(新增);仓库当前没有已归档 specs,已完成的 `add-gateway-rate-limit-management` 提供可复用账户搜索和通用交互能力,不更改它的接口与权限。
- Affected code: `src/api/modules/pollingConfig.ts`、`src/types/api/pollingConfig.ts`、类型统一导出、新权限常量、`src/views/polling-management/config/`、新增统计页、路由别名和静态路由、`scripts/tests/`、`docs/权限.md`。
- 保留当前工作区全部既有修改,包括网关限流、停复机时间控件、用户部署文件和 `package.json`。
- 不执行真实配置修改、优先级提交、重算任务创建、Git 推送或线上部署;写接口只用本地 mock 验证。
## Contract boundaries
- POST 匹配预览同时在 Query 与 JSON Body 传同一 ICCID;Body 包含必填 iccid,不能只传其中一处。
- 优先级接口没有明确成功 data DTO,不读取虚构的返回列表;成功后主动刷新配置列表。
- 重算 carrier_id 请求为 number/null,响应为 string,任务状态和计数按接口原值展示;不把重算完成误解为停复机执行或强制推后轮询。
- 两个统计接口提供明确 DTO,按该结构展示;周期 null 显示无数据,0 保留为真实零。分层统计注明 5 分钟缓存,未匹配项突出告警;统计刷新不宣称清除后端缓存。
- 新文档只明确 overrides 支持额外配置字段,未给出新版持久化 CRUD 契约。因此保留原保存行为,用独立草稿表单承载新增预览字段;待后端提供明确保存契约后再扩展原保存表单。
## Proposed ordinary-user configuration
| 项目 | URL / 编码 | 父菜单 / 位置 |
| --- | --- | --- |
| 轮询配置统计 | `/polling-management/config-stats`,页面权限 `polling_config_stats:page` | 轮询管理 |
| 匹配预览(当前配置/草稿) | `polling_config:match_preview` | 既有轮询配置 |
| 查询重算进度 | `polling_config:recalculate_progress` | 既有轮询配置 |
| 查看重置日前统计 | `polling_config_stats:pre_reset` | 轮询配置统计 |
| 查看分层统计 | `polling_config_stats:tiers` | 轮询配置统计 |
以上是前端配置约定,后端独立鉴权。既有轮询配置菜单和旧 CRUD 权限不因本次接入强制迁移。
用户于 2026-10-24 回复「执行」,并于 2026-10-26 回复「继续」,已批准按本提案实施。

View File

@@ -0,0 +1,87 @@
## ADDED Requirements
### Requirement: 当前及未保存配置匹配预览
The frontend SHALL implement GET and POST `/api/admin/polling-configs/match-preview`. ICCID MUST be nonempty and at most 30 characters. POST MUST send the identical ICCID in query and JSON body, with optional overrides. Results MUST show the card, all matched configurations and final task intervals, preserving interval/disabled/unmatched modes, inherited and draft indicators. Preview MUST NOT call create/update/delete/status APIs.
#### Scenario: 当前配置预览
- **WHEN** 获匹配预览权限的用户输入 ICCID 查询当前配置
- **THEN** 前端 MUST 发起 GET 查询并展示 card、matched、intervals,不擅自推导匹配顺序或最终间隔。
#### Scenario: 草稿覆盖预览
- **WHEN** 用户添加 id=0 的新草稿或覆盖已有配置并提交
- **THEN** 前端 MUST 发起 POST,Query 与 Body 的 iccid 一致,overrides 保留原有顺序、id 和 disabled
- **AND** 编辑未保存字段 MUST 不触发真实写接口,旧预览结果 MUST 不污染新的卡或草稿。
### Requirement: 草稿字段与检查语义
The draft form SHALL support every documented override field. The four ordinary task intervals MUST distinguish null/omitted inheritance, zero disable-without-inheritance and positive intervals subject to documented minima. Protect interval null MUST mean no check and non-null values MUST be integers of at least 30. Pre-reset window fields MUST use documented minima and clearly explain that both are required for effect. New preview-only fields MUST NOT be silently added to undocumented persisted CRUD bodies.
#### Scenario: 禁用与继承不同
- **WHEN** 用户将实名、流量、套餐或卡状态检查设为禁用
- **THEN** 对应值 MUST 为 0,不得变成 null 或被过滤掉
- **AND** 继承模式 MUST 使用 null/省略值,正数模式 MUST 校验对应 30 或 60 秒下限。
#### Scenario: 新字段隔离
- **WHEN** 草稿设置资产状态、套餐状态或重置日前窗口
- **THEN** 字段 MUST 仅进入已定义的 overrides 契约,不得因预览自动保存配置。
### Requirement: 优先级调整与异步重算
The frontend SHALL implement PUT `/api/admin/polling-configs/priorities` and POST `/api/admin/polling-configs/recalculate-due` with superadmin-only UI and event guards, explicit confirmation and duplicate-submit prevention. Ordered IDs MUST be nonempty, unique and valid; no undefined success DTO SHALL be assumed. Recalculation MUST be described as advance-only asynchronous work, not immediate completion of all card actions.
#### Scenario: 保存优先级顺序
- **WHEN** 超级管理员确认有效顺序
- **THEN** 前端 MUST 提交 ordered_ids 并在成功后重新获取列表
- **AND** 普通账号 MUST 无法通过 UI 或处理函数发起调序或重算启动。
#### Scenario: 启动重算
- **WHEN** 超级管理员选择运营商账户或全部并确认
- **THEN** 前端 MUST 提交 number/null carrier_id、保留 task_id 并展示真实任务状态,不在 POST 返回时直接宣称重算完成。
### Requirement: 任务进度查询
The frontend SHALL expose GET `/api/admin/polling-configs/recalculate-due/{task_id}` under a normal-user progress permission. It MUST show task ID, status, total/processed/advanced, response string carrier ID, errors and formatted timestamps. Automatic polling MUST cease on terminal status, closure, unmount or scope change, and late responses MUST be discarded.
#### Scenario: 查询已知任务
- **WHEN** 获进度权限的用户输入非空 task_id
- **THEN** 前端 MUST 对路径参数编码后查询并展示实际数据
- **AND** 查询权限 MUST 不授予创建重算任务的能力。
#### Scenario: 终态及关闭
- **WHEN** 任务 completed/failed 或对话框关闭、组件卸载
- **THEN** 自动查询 MUST 停止,不得后台无限轮询或由旧结果覆盖新任务。
### Requirement: 轮询配置统计
The frontend SHALL provide a new config statistics page under polling management for GET `/api/admin/polling-stats/pre-reset` and GET `/api/admin/polling-stats/tiers`, gated by page and separate ordinary view permissions. It MUST preserve null versus zero, label approximate counts, highlight unmatched-card warnings and indicate the five-minute tiers cache without promising forced cache invalidation.
#### Scenario: 重置日前统计
- **WHEN** 用户查看有数据或 null 的 previous/upcoming 周期
- **THEN** 前端 MUST 展示运营商、重置时间、套餐卡数、窗口检查/未检查及接口给定的分钟桶
- **AND** null MUST 显示无周期数据,真实 0 MUST 保留,不将桶擅自转换成未定义的差分或精确比例。
#### Scenario: 分层统计
- **WHEN** 用户打开分层统计
- **THEN** 前端 MUST 展示 by_config、by_package_state、computed_at、total_cards 和两个未匹配告警计数
- **AND** config_id=0 MUST 显示未匹配,不假定该配置存在。
### Requirement: 界面一致性与配置交付
The frontend SHALL reuse shop-list/detail conventions, `formatDateTime`, carrier-name remote search and search expansion only for at least four fields. Handoff MUST list the actual new menu name, URL, parent, page codes and ordinary action codes, excluding superadmin-exclusive configuration and distinguishing local mock verification from live integration.
#### Scenario: 菜单权限配置
- **WHEN** 本次实现交付
- **THEN** MUST 提供轮询管理下统计页及现有配置页新增按钮的准确配置
- **AND** 既有 CRUD 权限 MUST 不因未声明迁移被移除,不创建新的一级菜单。

View File

@@ -0,0 +1,30 @@
## 1. 契约与权限
- [x] 1.1 用户确认提案并记录审批。
- [x] 1.2 为 7 个接口补充类型及服务,区分预览 overrides 与既有 CRUD 请求。
- [x] 1.3 新增普通功能权限常量,复用超级管理员身份门禁与账户名称搜索。
## 2. 页面接入
- [x] 2.1 在轮询配置中接入当前配置 GET 预览和草稿 POST 预览,包含多条覆盖、停用模拟及完整结果。
- [x] 2.2 校验 ICCID、优先级、字段长度、null/0/正数间隔语义和成对生效的重置日前窗口。
- [x] 2.3 接入超级管理员调序界面、顺序与 ID 校验、确认和防重复提交。
- [x] 2.4 接入超级管理员异步重算启动、普通任务进度查询、终态与生命周期清理。
- [x] 2.5 新增轮询配置统计页,对接 pre-reset / tiers,准确展示空值、真实零、告警与缓存时间。
- [x] 2.6 注册一个统计菜单路由,沿用店铺页面、时间格式、条件数决定展开及运营商名称搜索标准。
## 3. 验证与交付
- [x] 3.1 覆盖全部 7 个新增接口 URL、方法、Query / Body、task_id 编码和返回契约。
- [x] 3.2 覆盖草稿语义、预览不写数据、权限、确认、防重、进度终态/卸载和迟到响应测试。
- [x] 3.3 检查列表、预览、统计、空状态、失败重试及响应式布局。
- [x] 3.4 运行相关和全量回归、类型检查、生产构建、针对性 lint / 格式 / 编码检查。
- [x] 3.5 在 `docs/权限.md` 与最终回复交付普通菜单和按钮配置及真实验证范围。
## 验证记录(2026-10-26)
- 新增轮询配置测试 20/20,全量 Node 回归 174/174;类型检查、生产构建、针对性 ESLint/Stylelint、格式与 UTF-8 检查通过。
- 本地 mock 浏览器复核配置列表、当前配置预览、草稿控件、确认后重算任务与自动终态停止、两种统计以及普通账号进度入口。375px 统计/进度无页面横向溢出,1440px 预览和统计截图已复核,1024px 普通账号入口已检查。
- 补正原配置列表检查间隔列的无效 slots 渲染,并使用唯一列 prop;原保存字段不变。
- 所有模拟 API 在本地拦截,未进行真实后端联调、菜单数据库写入、Git 推送或生产部署。生产构建仍提示项目既有 API 循环依赖、其他页面常量绑定及大 chunk 警告。
- 菜单与普通按钮编码已写入 `docs/权限.md`;不输出仅超级管理员专属配置。

View File

@@ -0,0 +1,41 @@
## Context
项目已经有网关管理、轮询配置预览与统计、单卡历史、统一导出任务、预签名 CSV 上传、运营商账户编辑和系统配置。最新文档把这些基础能力扩展为按账户调度与停复机指令队列,必须做契约升级,而不是另建同名模块。
## Decisions
- 为新增调度/告警/轮询开关契约建立独立 API 和类型模块,已存在的接口在原服务扩展。分页、枚举、账户搜索、时间格式、权限门禁和异步生命周期优先复用已验证能力。
- 仅平台账号使用现有平台身份判断(user_type=1/2),仅超级管理员使用 isSuperAdmin。每个写操作同时在入口和处理函数检查,后端继续独立鉴权;禁止用按钮权限绕过身份。
- 详情遵循店铺详情的独立隐藏兄弟路由,普通页与详情按钮使用不同权限编码;需要数据库菜单配置的隐藏详情与导出任务菜单一并交付。单卡轮询状态通过已有资产信息弹窗/页签进入,卡 ID 来自授权资产详情,绑定卡不使用设备 ID 代替。
- 展示 effective 与 configured 参数的区别;configured 为空并非零值。执行上限与 consume_capacity 的关系在可判断时前置校验,无法获知的停复机进程容量不凭空假设,由服务端验证。
- 批量、告警关闭和导入共享接口影响说明及逐项结果展示。每次确认使用目标状态对应的 impact;获取失败不展示硬编码替代文案,更不绕过确认。跨筛选、视图、资产或账号变更时清理勾选和迟到结果。
- 系统配置修改沿用现有权限,增加 polling 模块、枚举选择与 JSON 合法性提示;不因前端接入自动切换 scheduler、rate_limit_mode 或 queue_enabled。
- CSV 文件保留标识符原始文本,客户端只做文档明确的格式/数量校验;后端仍负责逐行资产范围、风险状态和权限判定。
- 模式与范围明确:observe 不宣称已拦截,队列前 500 条的 pending 计数标注近似范围;近似 QPS/p95 保留单位和接口定义。未知枚举保留原值。
## Proposed route organization
在已有「轮询管理」`/polling-management` 下新增以下可见菜单,均非仅超级管理员专属页面;其中平台限定页面还需身份控制:
| 菜单 | URL | 账号边界 |
| --- | --- | --- |
| 运营商账户调度参数 | `/polling-management/carrier-params` | 查看按后端权限;维护仅超级管理员 |
| 请求检查场景 | `/polling-management/check-scenes` | 查看按后端权限;维护仅超级管理员 |
| 失败告警 | `/polling-management/failure-alerts` | 查看、处理按权限;批量关闭仅平台 |
| 账户队列监控 | `/polling-management/carrier-queues` | 按后端权限 |
| 停复机规则演练 | `/polling-management/stop-resume-dry-run` | 仅平台 |
| 不轮询清单 | `/polling-management/polling-pauses` | 仅平台 |
| 轮询开关导入 | `/polling-management/polling-status-imports` | 仅平台 |
隐藏详情拟用 `/polling-management/carrier-params/detail/:carrier_id`、`/polling-management/failure-alerts/detail/:id`、`/polling-management/polling-status-imports/detail/:id`。导出管理沿用现有 `/asset-management/export-task-management/` 下同场景命名规则,不新建一级菜单。最终以实际注册路由和权限交付为准。
## Risks / Trade-offs
- 本次跨多个既有业务页,必须保留已有 dirty worktree:使用定点修改和基于当前状态的回归,禁止重置前一轮实现。
- C 端不在本仓库,明确外部待办;不得越出命名项目搜索或修改其他工程。
- 文档 46/16 是后端相对旧版本的统计,并非当前代码差异;以接口覆盖矩阵逐项闭环。
- 分页读取过程中数据可能变化,不能凭前端缓存自动扩大写操作目标。导入、批量操作发生部分成功后明确不可回滚,不提供虚构的整批恢复。
## Validation
覆盖所有新增/变更接口方法与 Query/Body、true/false/null/0、clear_fields、warnings、保护期删除、账号类型 1/2/3/4、五百条上限、CSV 流程、部分成功、账户级告警、特殊视图、导出场景以及异步终态/关闭/卸载。运行类型、生产构建、全量 Node 回归和针对性 lint/编码检查;浏览器使用本地 mock,明确区别于真实后端联调。

View File

@@ -0,0 +1,43 @@
# Change: 按最新运营商账户限流契约补齐调度、告警与轮询开关管理
## Why
`docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md` 是前两轮简化文档之后的完整变更契约,包含 46 个新增接口与 16 个改动接口。当前前端已实现其中的网关管理和轮询配置扩展基础,但仍缺少账户调度、失败告警、演练、单卡排查、批量开关及导入清单,并存在必须升级的持久化配置和已废弃保护期字段。
本次以该日期文档为准做增量接入,不重复建设前两轮已有页面,不把文档中所有“新增”理解为当前项目均未实现。
## What Changes
1. 轮询配置保存升级:创建/编辑/列表/详情支持 asset_status、package_state、pre_reset_window、pre_reset_carddata_interval;四项检查间隔支持 null 继承、0 禁用、正数间隔。编辑清空显式使用 clear_fields,保存成功展示 warnings;移除 protect_check_interval。更新草稿、匹配预览和拖拽优先级,保留既有预览、进度及统计权限。
2. 保护期退场:移除后台相关输入与展示,不再请求/保存保护期间隔;兼容后端仍返回但废弃的 device_protect_status,不影响其他资产功能。
3. 现有网关页面增量:错误分类支持 terminal 类别专属 auto_disable_polling 与测试结果;限流组展示真实 observe/enforce 模式;网关调用统计按已补全 DTO 展示账户/操作汇总、失败分类、豁免来源和趋势,不再保留“字段未定义”的临时限制。成功率按 0–1 显示,p95=-1 显示超出最大桶,不伪造精确耗时。
4. 轮询管理新增「运营商账户调度参数」「请求检查场景」「失败告警」「账户队列监控」「停复机规则演练」「不轮询清单」「轮询开关导入」。失败告警使用普通列表、账户汇总、停机被拒三个视图;单卡轮询状态放入现有单卡及设备绑定卡入口,不新建一级菜单。
5. 调度参数、场景修改严格仅超级管理员;规则演练、文件导入、不轮询清单、告警批量关闭及指定导出严格仅平台账号(user_type=1/2)。普通权限不得越过账号身份边界。
6. 卡/设备列表补齐 enable_polling 筛选、列和最多 500 项的批量开关。批量提交前取 impact 文案并确认、要求原因;展示成功/跳过/失败/无权限逐项结果,部分成功不回滚。设备级联语义保持后端决定;风险停机/已销户不提供强制开启。
7. 单资产开关改为展示接口 impact 确认文案并处理拒绝响应;订单或有效期变动后读取后端实际状态,不在前端模拟自动恢复。
8. 导入复用预签名上传:purpose=polling_status_import、CSV 表头与首列标识符、最多 50000 行、file_key 前缀校验,提交后列表与逐行结果按真实状态显示。长 ICCID 保持字符串,不转数字;上传失败不得创建任务,异步轮询要清理。
9. 导出复用统一任务,增加 polling_pause、polling_stop_refused_alert、stop_resume_rule_dry_run 场景、列表/详情/下载权限和必要菜单路由;卡/设备导出携带 enable_polling,客户端不重写旧历史文件。
10. 系统配置支持 polling 模块和文档中的 string/int/bool/json 配置,以服务器返回校验规则和错误为准;轮询并发页提示仅 legacy 调度生效,并链接账户参数。运营商实名管控 0→1 改为展示后端返回的实际影响说明,确认后带 realname_control_confirm 重提,不先擅自确认。
11. 延续 UI 标准:店铺列表和详情组件、统一 formatDateTime、运营商账户名称搜索、日期范围按临期资产样式、搜索条件 >=4 才显示展开。普通账号交付清单只列实际新增/变更菜单、URL、父菜单及 xxx:xxx 权限;保留旧配置、不列仅超级管理员专属项。
## Impact
- Affected specs: `carrier-polling-scheduler-integration`(新增综合接入规范)。当前无已归档 specs;前两轮 `add-gateway-rate-limit-management`、`add-polling-config-preview-and-statistics` 已完成,不更改其既有路由 URL 与权限编码。
- Affected code: pollingConfig / gateway / asset / card / device / carrier / storage / exportTask / systemConfig 的 API、类型与界面,相关路由、权限、上传组件、导出场景配置及测试。
- 不执行后端限流调度算法或数据迁移,不在真实环境触发批量操作、导入、调序、重算、配置修改、Git 推送或生产部署。测试写操作只使用本地 mock。
- 保留工作区已有未提交的轮询配置实现与其他用户修改。
## Scope boundaries and contract risks
- 文档要求的 C 端资产详情不在当前后台仓库:检索未发现独立 C 端工程。本次只落实本仓库可定位的后台部分,C 端保护期下线在交付中明确列为外部同步项,不声称已经修改。
- 本文 46 个新增接口中,前两轮已经建立 24 个方法,预计补齐剩余 22 个;实施前按路径与方法逐项核对,不以数量替代验收。附录已给出多数新 DTO,旧接口的完整 OpenAPI 未随本文提供时,不猜测未定义字段或清空规则。
- 网关统计内部字段已经补全,覆盖前一提案的“未知字段动态展示”临时约定。轮询配置新持久化字段已经明确,覆盖上一提案“新增字段仅用于预览”的暂定边界。旧说明与回归测试需同步升级。
- warnings 未定义固定结构时以实际字符串/对象安全展示,不假设固定业务键;clear_fields 仅使用文档支持的可空字段,不任意清除配置。
- 演练 page_size 特例最大 500,其余文档分页最大 100;演练结果筛选只影响明细不影响汇总,导出接口没有 result 参数时不擅自加入。假设实名管控 on/off 账户集合不得重叠。
- 停机被拒视图仅发送文档支持的五类筛选;切换视图清除忽略项及旧勾选。账户级告警 card_id=0 不得当作有效单卡 ID 请求历史或轮询状态。
## Delivery
先实现配置/保护期和公共契约,再接调度与排查,随后接批量/导入/导出,最后完成权限矩阵、mock 浏览器和回归验证。普通菜单与按钮配置写入 `docs/权限.md` 并在最终回复给出增量清单;现有菜单和权限不重复要求用户新增。
用户于 2026-10-26 批准执行,并明确仅修改后台前端;交付时另列 C 端同步事项,不修改 C 端代码。

View File

@@ -0,0 +1,98 @@
## ADDED Requirements
### Requirement: 最新轮询配置保存契约
The admin frontend SHALL align create/update/list/detail and preview with the latest configuration fields, including asset/package matching, pre-reset window, four tri-state intervals, clear_fields and save warnings. It MUST remove protect_check_interval and must not confuse zero with null or silently discard successful-save warnings. Existing routes and permissions MUST be preserved.
#### Scenario: 保存及清空
- **WHEN** 用户保存新增条件、禁用间隔或清空可空字段
- **THEN** 前端 MUST 使用明确的更新字段和 clear_fields,保留 0、false,展示实际 warnings
- **AND** 不得继续发送保护期间隔,预览必须与同一次编辑值一致。
### Requirement: 保护期移除与兼容
The frontend SHALL remove deprecated protection-period input and UI in this repository while tolerating device_protect_status in old responses. It MUST disclose that the independently deployed C-end code is outside this repository rather than claim to modify it.
#### Scenario: 旧响应兼容
- **WHEN** 后台详情仍返回固定 none 的保护期字段
- **THEN** 页面 MUST 不显示保护期,不因废弃字段缺失影响资产查询或其他功能。
### Requirement: 账户调度、场景与状态排查
The frontend SHALL provide carrier scheduling parameter list/detail, request-check scenes, carrier queue monitoring and single-card polling state with the documented effective/configured values, queue states, confirmations and stop/resume commands. Superadmin-only writes MUST be guarded both in UI and handlers. Null defaults and real zeros MUST remain distinct.
#### Scenario: 调度参数维护
- **WHEN** 超级管理员修改参数或恢复默认
- **THEN** 前端 MUST 校验可确定的容量、比例和递增延迟约束,提交对应接口并展示服务端拒绝原因
- **AND** 普通按钮权限 MUST 不能启用专属写操作。
#### Scenario: 单卡状态排查
- **WHEN** 获授权用户从单卡或设备绑定卡查看状态
- **THEN** 前端 MUST 使用真实 card_id 请求 polling-state,展示各检查类型及 stop_resume_command
- **AND** 账户级告警 card_id=0 MUST 不得作为有效卡身份进入该入口。
### Requirement: 失败告警与停机被拒视图
The frontend SHALL provide ordinary, carrier-summary and stop-refused alert views, details, handled status and platform-only close-polling actions. Only unresolved alerts may be marked handled. The special view MUST hide and omit check_type/error_category/alert_level. Bulk results MUST distinguish success, skipped, failed and unauthorized/nonexistent without suggesting rollback.
#### Scenario: 切换特殊视图
- **WHEN** 用户切换到停机被拒
- **THEN** 请求 MUST 只带该视图支持的筛选,清除旧选择,展示额外设备/停机/套餐/轮询字段
- **AND** 导出 MUST 使用独立场景与对应权限。
#### Scenario: 批量关闭
- **WHEN** 有权限的平台账号确认关闭告警对应卡的轮询
- **THEN** 前端 MUST 提交合法 alert_ids 和原因,逐项展示结果并刷新
- **AND** 不能把账户级告警当设备级操作,不能自动重试整批已部分成功的请求。
### Requirement: 平台规则演练与实名确认
The frontend SHALL provide platform-only dry-run summary, paginated details, hypothetical carrier controls and export without executing gateway actions. Hypothetical on/off carrier sets MUST not overlap. Real carrier control changes from off to on MUST display the backend impact message and only resubmit with realname_control_confirm after explicit confirmation.
#### Scenario: 只读演练
- **WHEN** 用户运行演练或按结果筛选明细
- **THEN** 前端 MUST 仅请求 dry-run,汇总保留后端定义的范围,不调用真实停复机或修改管控接口
- **AND** 明细 result 筛选不得被描述为已改变汇总。
### Requirement: 轮询开关、清单与 CSV 导入
The frontend SHALL support card/device polling filters, batch toggles, platform pause-history and CSV import workflows. It MUST load impact descriptions from the API before confirmation, require reasons, enforce unique 1–500 asset IDs, preserve false filters, maintain string identifiers and show all item outcomes. Enterprise accounts MUST not invoke batch toggles. Upload purpose and returned key prefix MUST match the documented polling-status-import contract; file limits and async cleanup MUST be enforced.
#### Scenario: 确认并批量开关
- **WHEN** 有权限账号选择资产开启或关闭
- **THEN** 前端 MUST 展示对应 impact 后确认、提交、展示部分成功结果
- **AND** 风险停机或已销户卡不得获得强制开启选项,403 必须回滚前端开关并保留原状态。
#### Scenario: 导入任务
- **WHEN** 平台账号上传带表头的合法 CSV 并确认
- **THEN** 前端 MUST 先获取预签名、上传成功后再提交任务,保留 ICCID 原文,展示任务与逐行错误
- **AND** 上传失败不得创建任务,终态、关闭和卸载 MUST 停止自动查询。
### Requirement: 系统模式与监控指标
The frontend SHALL add the polling system-config module, support documented JSON editing, display actual rate-limit/scheduler mode, and explain legacy-only concurrency behavior. Gateway monitoring SHALL use the now-defined operation DTOs, show success rates in the correct scale, identify approximate p95/queue-sample limits and not invent missing metrics.
#### Scenario: 观察模式
- **WHEN** 接口返回 observe
- **THEN** 页面 MUST 说明只统计不拦截,区分 would_limit 与真正 rate_limited
- **AND** 不得因前端接入自动修改生产调度或限流开关。
### Requirement: 导出、路由与权限交付
The frontend SHALL integrate the three new export scenes and preserve enable_polling filters for existing card/device exports. New pages, hidden details and export-task routes MUST follow existing menu conventions. Final handoff MUST list actual incremental menu names, URLs, parents and ordinary xxx:xxx button codes, excluding superadmin-exclusive configuration and preserving previous menu codes.
#### Scenario: 配置交付
- **WHEN** 实现完成
- **THEN** 前端 MUST 同步 docs/权限.md 和最终回复中的增量菜单与按钮清单
- **AND** MUST 区分平台身份限制、普通按钮权限、真实后端验证与本地 mock,以及本仓库外的 C 端同步事项。

View File

@@ -0,0 +1,38 @@
## 1. 契约与基线
- [x] 1.1 用户批准提案,记录当前 dirty worktree 和前两轮回归基线。
- [x] 1.2 建立文档接口到现有/新增方法、页面、权限及测试的覆盖矩阵,核对 46 新增与 16 改动的实际差异。
- [x] 1.3 新增账户调度、场景、告警、队列、单卡状态、批量开关、导入/清单、演练及 impact 类型和服务。
## 2. 既有页面升级
- [x] 2.1 升级轮询配置持久化 DTO、表单、列表、clear_fields、warnings、三态间隔、保存前预览和优先级拖拽。
- [x] 2.2 清除本仓库保护期输入/展示与请求字段,保留后端废弃响应兼容;记录外部 C 端同步项。
- [x] 2.3 增加 terminal auto_disable_polling,升级错误分类列表/表单/测试。
- [x] 2.4 依完整 DTO 改造网关汇总、趋势和 observe/enforce 模式展示。
- [x] 2.5 增加 polling 系统模块与 JSON 编辑校验、旧并发页说明;运营商实名管控改用服务端影响说明二次确认。
## 3. 调度、排查与演练
- [x] 3.1 完成账户调度参数列表/详情/超级管理员维护和容量边界。
- [x] 3.2 完成请求检查场景列表及超级管理员维护、严格递增确认延迟校验。
- [x] 3.3 完成账户队列和停复机指令监控、初始化状态、真实指标及异步生命周期。
- [x] 3.4 在单卡及设备绑定卡接入轮询状态与停复机命令,校验真实卡 ID 和账号权限。
- [x] 3.5 完成失败告警普通/汇总/停机被拒视图、详情、处理、记录联动、平台批量关闭。
- [x] 3.6 完成平台规则演练、假设管控条件、汇总/分页明细、只读保障与导出。
## 4. 开关、清单与文件任务
- [x] 4.1 卡/设备列表新增轮询筛选和列,导出保留 false 筛选,接入批量开关原因、影响确认和逐项结果。
- [x] 4.2 单资产开关使用 impact 文案,处理风险/已销户拒绝,刷新后端实际状态。
- [x] 4.3 平台不轮询清单支持全部筛选、历史/系统操作者、批量开启及导出。
- [x] 4.4 平台 CSV 导入支持预签名、purpose、file_key、行数校验、创建和任务列表/逐行详情。
- [x] 4.5 接入三个导出场景及任务列表、详情、下载权限,维持历史文件兼容。
## 5. 验证与交付
- [x] 5.1 注册新可见/隐藏路由和权限,保留前两轮 URL、时间/账户搜索/展开标准。
- [x] 5.2 完成接口覆盖矩阵测试、权限与部分成功/视图切换/上传/轮询清理回归。
- [x] 5.3 验证类型、构建、全量测试、lint、格式、编码和本地 mock 浏览器交互/响应式。
- [x] 5.4 同步旧文档和 `docs/权限.md`,交付新增菜单名称/URL/父菜单、普通按钮编码;排除仅超级管理员专属项。
- [x] 5.5 明确真实联调范围、尚缺后端契约和外部 C 端同步项,不将 mock 结果当作线上验证。

View File

@@ -15,7 +15,7 @@
"np": "vite build --mode production", "np": "vite build --mode production",
"nd": "vite build --mode development", "nd": "vite build --mode development",
"serve": "vite preview", "serve": "vite preview",
"deploy:frontend": "node scripts/deploy-frontend.mjs --branch iteration/august", "deploy:frontend": "node scripts/deploy-frontend.mjs --branch feature/gateway-rate-limit",
"deploy:frontend:branch": "node scripts/deploy-frontend.mjs --branch", "deploy:frontend:branch": "node scripts/deploy-frontend.mjs --branch",
"lint": "eslint", "lint": "eslint",
"fix": "eslint --fix", "fix": "eslint --fix",

View File

@@ -87,11 +87,17 @@ function mountBasicInfo(t, userType, permissions = []) {
}, },
'../composables/useAssetFormatters': formatters, '../composables/useAssetFormatters': formatters,
'@/composables/useAuth': { useAuth: () => ({ hasAuth }) }, '@/composables/useAuth': { useAuth: () => ({ hasAuth }) },
'@/components/business/package-silence/SilenceRecordsDialog.vue': {
default: vue.defineComponent(() => () => null)
},
'@/store/modules/user': { useUserStore: () => user }, '@/store/modules/user': { useUserStore: () => user },
'@/config/constants': { '@/config/constants': {
JULY_PERMISSIONS: { speedTier: { view: 'speed:view', update: 'speed:update' } } JULY_PERMISSIONS: { speedTier: { view: 'speed:view', update: 'speed:update' } }
}, },
'@/utils/business/format': { formatDateTime: (value) => String(value || '') }, '@/utils/business/format': { formatDateTime: (value) => String(value || '') },
'@/utils/business/packageSilence': evaluate(
readFileSync('src/utils/business/packageSilence.ts', 'utf8')
),
'@/utils/business/expiryEstimate': { '@/utils/business/expiryEstimate': {
getExpiryEstimateText: () => '-', getExpiryEstimateText: () => '-',
getExpiryEstimateTooltip: () => '', getExpiryEstimateTooltip: () => '',
@@ -179,6 +185,29 @@ function mountBasicInfo(t, userType, permissions = []) {
const textOf = (node) => const textOf = (node) =>
node.tag === '#comment' ? '' : node.text + node.children.map(textOf).join('') node.tag === '#comment' ? '' : node.text + node.children.map(textOf).join('')
test('enterprise account hides polling and realname writes while authorized start/stop remain', async (t) => {
const { root, asset, user } = mountBasicInfo(t, 4)
user.permissions = [
'device_info:auto_polling',
'iot_info:auto_polling',
'iot_info:update_realname_status',
'bound_card:update_realname_status',
'iot_info:start',
'iot_info:stop'
]
await vue.nextTick()
assert.doesNotMatch(textOf(root), /自动轮询|更新实名状态/)
assert.equal(nodesWithClass(root, 'binding-card').length, 2)
asset.asset_type = 'card'
asset.network_status = 0
await vue.nextTick()
assert.match(textOf(root), /启用此卡/)
assert.doesNotMatch(textOf(root), /自动轮询|更新实名状态/)
asset.network_status = 1
await vue.nextTick()
assert.match(textOf(root), /停用此卡/)
})
const nodesWithClass = (node, className) => [ const nodesWithClass = (node, className) => [
...(String(node.props.class || '') ...(String(node.props.class || '')
.split(' ') .split(' ')

View File

@@ -557,6 +557,8 @@ function pageHandlers(state) {
visit(source) visit(source)
assert.equal(declarations.length, wanted.size) assert.equal(declarations.length, wanted.size)
const bindings = { const bindings = {
useUserStore: () => ({ info: { user_type: 1 } }),
canEditRestrictedAsset: (type) => [1, 2, 3].includes(Number(type)),
cardInfo: state.cardInfo, cardInfo: state.cardInfo,
fetchAssetDetail: state.fetchAssetDetail, fetchAssetDetail: state.fetchAssetDetail,
loadPackageList: state.loadPackageList, loadPackageList: state.loadPackageList,

View File

@@ -0,0 +1,341 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
import * as vue from 'vue'
const read = (path) => readFileSync(path, 'utf8')
function evaluate(source, imports = {}) {
const code = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', code)(
(name) => {
if (name.endsWith('.vue')) return { default: {} }
assert.ok(imports[name], `Unexpected import ${name}`)
return imports[name]
},
module,
module.exports
)
return module.exports
}
const format = evaluate(read('src/utils/business/format.ts'))
const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format })
const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
const response = (data) => ({ code: 0, data })
const deferred = () => {
let resolve, reject
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
const tick = async () => {
await vue.nextTick()
await Promise.resolve()
await vue.nextTick()
}
function serviceHarness(overrides = {}) {
const calls = []
const service = new Proxy(
{},
{
get:
(_, method) =>
async (...args) => {
calls.push({ method, args })
return overrides[method] ? overrides[method](...args) : response({ items: [], total: 0 })
}
}
)
const modules = {
'./packageManage': { PackageManageService: service },
'./packageSeries': { PackageSeriesService: service },
'./shop': { ShopService: service },
'./card': { CardService: service },
'./device': { DeviceService: service },
'./account': { AccountService: service },
'./packageSilence': { PackageSilenceService: service },
'@/utils/business/gateway': gateway
}
return { ...evaluate(read('src/api/modules/businessOptions.ts'), modules), calls }
}
test('entity search uses documented keyword/name fields and returns labels, never database IDs as labels', async () => {
const h = serviceHarness({
getPackages: () => response({ items: [{ id: 8, package_name: '实名套餐' }], total: 41 }),
getPackageSeries: () => response({ items: [{ id: 9, series_name: '流量系列' }], total: 1 }),
getShops: () => response({ items: [{ id: 10, shop_name: '一号代理' }], total: 1 }),
getAccounts: () =>
response({ items: [{ id: 11, username: '张三', shop_name: '一号代理' }], total: 1 }),
getStandaloneIotCards: () =>
response({ items: [{ id: 12, iccid: '89860001', virtual_no: 'V-01' }], total: 1 }),
getDevices: () =>
response({ items: [{ id: 13, virtual_no: 'D-01', device_name: '设备甲' }], total: 1 })
})
for (const [kind, method, field] of [
['package', 'getPackages', 'package_name'],
['series', 'getPackageSeries', 'series_name'],
['shop', 'getShops', 'shop_name'],
['account', 'getAccounts', 'username'],
['card', 'getStandaloneIotCards', 'keyword']
]) {
const result = await h.searchBusinessOptions(kind, ' 关键字 ', 2)
assert.ok(result.options.length)
assert.equal(typeof result.options[0].value, 'number')
assert.notEqual(result.options[0].label, String(result.options[0].value))
const call = h.calls.at(-1)
assert.equal(call.method, method)
assert.deepEqual(call.args[0], { page: 2, page_size: 20, [field]: '关键字' })
assert.equal(result.hasMore, kind === 'package')
}
const assets = await h.searchBusinessOptions('asset', '01')
assert.deepEqual(
assets.options.map((x) => x.value),
['89860001', 'D-01']
)
assert.match(assets.options[0].label, /V-01/)
assert.match(assets.options[1].label, /设备甲/)
})
test('package record selection uses real usage IDs with asset/package context and readable order labels', async () => {
const h = serviceHarness({
usages: () =>
response({
items: [
{
package_usage_id: 99,
package_name: '实名套餐',
asset_identifier: '8986',
order_no: 'ORD-01'
}
],
total: 1
})
})
const result = await h.searchBusinessOptions('usage', '', 1, {
asset_identifier: '8986',
package_id: 8
})
assert.deepEqual(h.calls[0].args[0], {
page: 1,
page_size: 20,
view: 'all',
asset_identifier: '8986',
package_id: 8
})
assert.deepEqual(result.options, [{ value: 99, label: '实名套餐 · 8986 · ORD-01' }])
await h.searchBusinessOptions('usage', '8987', 1, { asset_identifier: '8986' })
assert.equal(h.calls.at(-1).args[0].asset_identifier, '8987')
})
test('asset lookup keeps permitted card results when the device lookup fails and reports the partial failure', async () => {
const h = serviceHarness({
getStandaloneIotCards: () => response({ items: [{ id: 12, iccid: '89860001' }], total: 1 }),
getDevices: () => ({ code: 403, msg: '无设备查看权限' })
})
const result = await h.searchBusinessOptions('asset', '')
assert.equal(result.options[0].value, '89860001')
assert.match(result.warning, /设备.*无设备查看权限/)
})
test('policy selection resolves existing package/series names through detail endpoints and propagates business errors', async () => {
const h = serviceHarness({
getPackageDetail: () => response({ package_name: '已有套餐' }),
getPackageSeriesDetail: () => response({ series_name: '已有系列' }),
getShops: () => ({ code: 403, msg: '无数据权限' })
})
assert.deepEqual(await h.resolveBusinessOption('package', 8), { value: 8, label: '已有套餐' })
assert.deepEqual(await h.resolveBusinessOption('series', 9), { value: 9, label: '已有系列' })
await assert.rejects(h.searchBusinessOptions('shop', ''), /无数据权限/)
})
function componentHarness({ entity = 'shop', modelValue, search, resolve, allowed = true } = {}) {
const hooks = [],
calls = [],
events = []
const user = vue.reactive({ info: { id: 1, user_type: 2, shop_id: 1 }, isLogin: true })
const props = vue.reactive({ entity, modelValue, allowed, clearable: true })
const runtime = {
...vue,
onBeforeUnmount: (fn) => hooks.push(fn),
onDeactivated: (fn) => hooks.push(fn),
onActivated() {}
}
const { descriptor } = parse(read('src/components/business/BusinessEntitySelect.vue'))
const component = evaluate(compileScript(descriptor, { id: 'entity-select' }).content, {
vue: runtime,
'@/store/modules/user': { useUserStore: () => user },
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': polling,
'@/api/modules/businessOptions': {
BUSINESS_OPTION_LABELS: {
shop: '店铺',
package: '套餐',
series: '系列',
asset: '资产',
account: '操作人'
},
searchBusinessOptions: async (...args) => {
calls.push(args)
return search ? search(...args) : { options: [], hasMore: false }
},
resolveBusinessOption: resolve || (async (_, value) => ({ value, label: '已有名称' }))
}
}).default
const scope = vue.effectScope()
const state = scope.run(() =>
component.setup(props, {
expose() {},
emit: (event, value) => {
events.push([event, value])
props.modelValue = value
}
})
)
return {
state,
props,
user,
calls,
events,
dispose: () => {
hooks.forEach((fn) => fn())
scope.stop()
}
}
}
test('rapid keywords ignore late responses, selection retains its label through later searches and clear emits no ID', async () => {
const old = deferred()
const h = componentHarness({
search: (_, keyword) =>
keyword === 'old'
? old.promise
: {
options: [
{ value: keyword === 'new' ? 2 : 3, label: keyword === 'new' ? '新店铺' : '其他店铺' }
],
hasMore: false
}
})
const pending = h.state.search('old')
await h.state.search('new')
old.resolve({ options: [{ value: 1, label: '旧店铺' }], hasMore: false })
await pending
assert.deepEqual(h.state.options.value, [{ value: 2, label: '新店铺' }])
h.state.selection.value = 2
await h.state.search('other')
assert.deepEqual(
h.state.options.value.map((x) => x.label),
['新店铺', '其他店铺']
)
assert.deepEqual(h.events[0], ['update:modelValue', 2])
h.state.selection.value = undefined
assert.deepEqual(
h.state.options.value.map((x) => x.label),
['其他店铺']
)
h.dispose()
})
test('load more appends options and request failures are retryable rather than fabricated empty results', async () => {
let fail = true
const h = componentHarness({
search: (_, keyword, page) => {
if (keyword === 'fail' && fail) throw new Error('网络失败')
return { options: [{ value: page, label: `店铺${page}` }], hasMore: page < 2 }
}
})
await h.state.search('')
assert.equal(h.state.hasMore.value, true)
await h.state.load(2)
assert.deepEqual(
h.state.options.value.map((x) => x.value),
[1, 2]
)
assert.equal(h.state.hasMore.value, false)
await h.state.search('fail')
assert.equal(h.state.error.value, '网络失败')
fail = false
await h.state.load(1)
assert.equal(h.state.error.value, '')
assert.equal(h.calls.at(-1)[1], 'fail')
h.dispose()
})
test('permission/account/context changes and unmount discard in-flight results without new unauthorized requests', async () => {
for (const action of ['permission', 'account', 'context', 'unmount']) {
const pending = deferred(),
h = componentHarness({ search: () => pending.promise })
const loading = h.state.search('a')
if (action === 'permission') h.props.allowed = false
if (action === 'account') h.user.info.id = 2
if (action === 'context') h.props.context = { package_id: 8 }
if (action === 'unmount') h.dispose()
await tick()
pending.resolve({ options: [{ value: 8, label: '已失效数据' }], hasMore: true })
await loading
assert.deepEqual(h.state.options.value, [], action)
assert.equal(h.state.loading.value, false)
if (action === 'permission' || action === 'unmount') {
await h.state.search('b')
assert.equal(h.calls.length, 1)
}
if (action !== 'unmount') h.dispose()
}
})
test('existing multi-selection hydrates readable labels and system operator preserves numeric zero', async () => {
const h = componentHarness({
entity: 'package',
modelValue: [7, 8],
resolve: async (_, value) => ({ value, label: `套餐名${value}` })
})
await tick()
assert.deepEqual(
h.state.options.value.map((x) => x.label),
['套餐名7', '套餐名8']
)
h.props.modelValue = [8]
await tick()
assert.deepEqual(
h.state.options.value.map((x) => x.label),
['套餐名8']
)
h.dispose()
const account = componentHarness({ entity: 'account' })
account.props.includeSystem = true
account.state.selection.value = 0
assert.deepEqual(account.events.at(-1), ['update:modelValue', 0])
assert.deepEqual(account.state.options.value, [{ value: 0, label: '系统' }])
account.dispose()
})
test('all affected templates compile and raw-ID search controls are replaced throughout the shared pages', () => {
const files = [
'src/components/business/BusinessEntitySelect.vue',
'src/components/core/forms/art-search-bar/widget/art-search-business-select/index.vue',
'src/components/core/forms/art-search-bar/index.vue',
'src/components/business/package-silence/SilenceManagement.vue',
'src/components/business/package-silence/SilencePolicyDialog.vue',
'src/views/order-management/order-list/index.vue',
'src/views/finance/refund/index.vue',
'src/views/polling-management/priority-queue/index.vue',
'src/views/polling-management/polling-pauses/index.vue',
'src/views/asset-management/phone-asset-association/index.vue',
'src/views/asset-management/record-management/asset-assign/index.vue'
]
for (const file of files) {
const { descriptor, errors } = parse(read(file))
assert.deepEqual(errors, [], file)
assert.deepEqual(
compileTemplate({ source: descriptor.template.content, filename: file, id: file }).errors,
[],
file
)
}
const management = read(files[3])
assert.match(read(files[2]), /v-show="index < visibleItemCount"/)
for (const field of ['asset_identifier', 'package_id', 'shop_id', 'package_usage_id'])
assert.match(management, new RegExp(`entity\\('${field}'`))
assert.doesNotMatch(management, /请输入套餐 ID|number\('(?:package_id|shop_id|package_usage_id)'/)
assert.doesNotMatch(read(files[4]), /label: `ID/)
assert.doesNotMatch(read(files[7]), /请输入卡ID/)
})

View File

@@ -56,6 +56,8 @@ const carrier = (enabled) => ({
function harness(userType, responseCarrier = carrier(1)) { function harness(userType, responseCarrier = carrier(1)) {
const writes = [] const writes = []
const attempts = []
let list
const confirmations = [] const confirmations = []
const user = vue.reactive({ info: { user_type: userType }, permissions: [], buttons: [] }) const user = vue.reactive({ info: { user_type: userType }, permissions: [], buttons: [] })
const permission = evaluate(read('src/composables/usePermission.ts'), { const permission = evaluate(read('src/composables/usePermission.ts'), {
@@ -66,7 +68,17 @@ function harness(userType, responseCarrier = carrier(1)) {
getCarriers: async () => ({ code: 0, data: { items: [responseCarrier], total: 1 } }), getCarriers: async () => ({ code: 0, data: { items: [responseCarrier], total: 1 } }),
getCarrierDetail: async () => ({ code: 0, data: responseCarrier }), getCarrierDetail: async () => ({ code: 0, data: responseCarrier }),
createCarrier: async (data) => writes.push({ method: 'create', data: { ...data } }), createCarrier: async (data) => writes.push({ method: 'create', data: { ...data } }),
updateCarrier: async (id, data) => writes.push({ method: 'update', id, data: { ...data } }) updateCarrier: async (id, data) => {
attempts.push({ id, data: { ...data } })
if (
list?.originalRealnameControlEnabled.value === 0 &&
data.realname_control_enabled === 1 &&
!data.realname_control_confirm
)
throw new Error('开启实名管控将停机 12 张卡,请确认后重试')
writes.push({ method: 'update', id, data: { ...data } })
return { code: 0, data: responseCarrier }
}
} }
const runtimeVue = { ...vue, onMounted: () => {} } const runtimeVue = { ...vue, onMounted: () => {} }
const imports = { const imports = {
@@ -75,7 +87,7 @@ function harness(userType, responseCarrier = carrier(1)) {
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }, '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) },
'@/api/modules': { CarrierService: service }, '@/api/modules': { CarrierService: service },
'element-plus': { 'element-plus': {
ElMessage: { success() {}, warning() {} }, ElMessage: { success() {}, warning() {}, error() {} },
ElMessageBox: { ElMessageBox: {
confirm: async (...args) => { confirm: async (...args) => {
confirmations.push(args) confirmations.push(args)
@@ -102,8 +114,10 @@ function harness(userType, responseCarrier = carrier(1)) {
} }
const setup = (name) => const setup = (name) =>
evaluate(components[name].script, imports, runtimeVue).default.setup({}, { expose() {} }) evaluate(components[name].script, imports, runtimeVue).default.setup({}, { expose() {} })
list = setup('index')
return { return {
list: setup('index'), list,
attempts,
detail: setup('detail'), detail: setup('detail'),
writes, writes,
confirmations, confirmations,
@@ -189,8 +203,8 @@ test('editing then creating resets control state; default creation sends numeric
}) })
for (const userType of [1, 2]) { for (const userType of [1, 2]) {
test(`user_type=${userType}: enabling control requires confirmation before PUT and sends true`, async () => { test(`user_type=${userType}: initial PUT obtains actual impact before confirmed retry`, async () => {
const { list, writes, confirmations, messageBox } = harness(userType) const { list, writes, confirmations, messageBox, attempts } = harness(userType)
list.showDialog('edit', carrier(0)) list.showDialog('edit', carrier(0))
list.form.realname_control_enabled = 1 list.form.realname_control_enabled = 1
let confirm let confirm
@@ -201,8 +215,11 @@ for (const userType of [1, 2]) {
}) })
} }
const pending = list.handleSubmit(validForm) const pending = list.handleSubmit(validForm)
await new Promise((resolve) => setImmediate(resolve))
assert.equal(confirmations.length, 1) assert.equal(confirmations.length, 1)
assert.match(confirmations[0][0], /未实名的开机卡会在下一次评估时被停机/) assert.equal(confirmations[0][0], '开启实名管控将停机 12 张卡,请确认后重试')
assert.equal(attempts.length, 1)
assert.equal(attempts[0].data.realname_control_confirm, undefined)
assert.deepEqual(writes, []) assert.deepEqual(writes, [])
assert.equal(list.submitLoading.value, true) assert.equal(list.submitLoading.value, true)
await list.handleSubmit(validForm) await list.handleSubmit(validForm)
@@ -275,7 +292,7 @@ test('opening another carrier resets the original control state after a cancelle
assert.equal(Object.hasOwn(writes[0].data, 'realname_control_confirm'), false) assert.equal(Object.hasOwn(writes[0].data, 'realname_control_confirm'), false)
}) })
test('a failed PUT requires a fresh confirmation on retry', async (t) => { test('an unrelated PUT error never triggers confirmation or automatic retry', async (t) => {
const { list, writes, confirmations, service } = harness(2) const { list, writes, confirmations, service } = harness(2)
t.mock.method(console, 'error', () => {}) t.mock.method(console, 'error', () => {})
list.showDialog('edit', carrier(0)) list.showDialog('edit', carrier(0))
@@ -285,13 +302,13 @@ test('a failed PUT requires a fresh confirmation on retry', async (t) => {
throw new Error('PUT failed') throw new Error('PUT failed')
} }
await list.handleSubmit(validForm) await list.handleSubmit(validForm)
assert.equal(confirmations.length, 1) assert.equal(confirmations.length, 0)
assert.deepEqual(writes, []) assert.deepEqual(writes, [])
assert.equal(list.dialogVisible.value, true) assert.equal(list.dialogVisible.value, true)
assert.equal(list.submitLoading.value, false) assert.equal(list.submitLoading.value, false)
service.updateCarrier = updateCarrier service.updateCarrier = updateCarrier
await list.handleSubmit(validForm) await list.handleSubmit(validForm)
assert.equal(confirmations.length, 2) assert.equal(confirmations.length, 1)
assert.equal(writes[0].data.realname_control_confirm, true) assert.equal(writes[0].data.realname_control_confirm, true)
}) })
@@ -353,6 +370,9 @@ test('carrier endpoints preserve the field in list/detail responses and create/u
calls.push({ method: 'PUT', url, data }) calls.push({ method: 'PUT', url, data })
return Promise.resolve({ code: 0, data }) return Promise.resolve({ code: 0, data })
} }
static put(url, data) {
return this.update(url, data)
}
} }
const { CarrierService } = evaluate(read('src/api/modules/carrier.ts'), { const { CarrierService } = evaluate(read('src/api/modules/carrier.ts'), {
'../BaseService': { BaseService } '../BaseService': { BaseService }

View File

@@ -0,0 +1,5 @@
iccid,note
89860426012345678901,local mock
89860426012345678902,skip
89860426012345678903,fail
89860426012345678904,denied
1 iccid note
2 89860426012345678901 local mock
3 89860426012345678902 skip
4 89860426012345678903 fail
5 89860426012345678904 denied

View File

@@ -0,0 +1,48 @@
[
"DELETE /gateway-error-classifications/{id}",
"DELETE /polling-carrier-params/{carrier_id}",
"DELETE /polling-rate-limit-groups/{id}",
"GET /card-polling-pauses",
"GET /card-stop-resume-records",
"GET /gateway-error-classifications",
"GET /iot-cards/{id}/polling-state",
"GET /iot-cards/{id}/stop-resume-records",
"GET /polling-carrier-params",
"GET /polling-carrier-params/{carrier_id}",
"GET /polling-check-scenes",
"GET /polling-configs/match-preview",
"GET /polling-configs/recalculate-due/{task_id}",
"GET /polling-failure-alerts",
"GET /polling-failure-alerts/summary",
"GET /polling-failure-alerts/{id}",
"GET /polling-options",
"GET /polling-rate-limit-groups",
"GET /polling-rate-limit-groups/{id}",
"GET /polling-stats/carrier-queues",
"GET /polling-stats/carriers",
"GET /polling-stats/carriers/{carrier_id}/trend",
"GET /polling-stats/pre-reset",
"GET /polling-stats/tiers",
"GET /polling-status-imports",
"GET /polling-status-imports/{id}",
"GET /polling-status/impact",
"GET /stop-resume-rule/dry-run",
"POST /devices/batch-update-polling-status",
"POST /gateway-error-classifications",
"POST /gateway-error-classifications/test",
"POST /iot-cards/batch-update-polling-status",
"POST /polling-configs/match-preview",
"POST /polling-configs/recalculate-due",
"POST /polling-failure-alerts/close-polling",
"POST /polling-rate-limit-groups",
"POST /polling-status-imports",
"POST /stop-resume-rule/dry-run/export",
"PUT /gateway-error-classifications/{id}",
"PUT /gateway-error-classifications/{id}/status",
"PUT /polling-carrier-params/{carrier_id}",
"PUT /polling-check-scenes/{scene}",
"PUT /polling-configs/priorities",
"PUT /polling-failure-alerts/{id}/status",
"PUT /polling-rate-limit-groups/{id}",
"PUT /polling-rate-limit-groups/{id}/status"
]

View File

@@ -0,0 +1,727 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import { compileScript, parse } from '@vue/compiler-sfc'
function evaluate(source, imports = {}) {
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)(
(name) => {
assert.ok(imports[name], `Unexpected dependency: ${name}`)
return imports[name]
},
module,
module.exports
)
return module.exports
}
const source = (path) => readFileSync(path, 'utf8')
const format = evaluate(source('src/utils/business/format.ts'))
const utils = evaluate(source('src/utils/business/gateway.ts'), { './format': format })
const constants = evaluate(source('src/config/constants/gateway.ts'))
const permissions = constants.GATEWAY_PERMISSIONS
const rule = {
category: 'business',
keyword: 'failed',
match_type: 'contains',
priority: 10,
status: 1,
operations: ['stop']
}
const group = {
group_code: 'default',
group_name: '默认组',
max_qps: 20,
reserved_qps: 5,
operations: ['stop', 'start'],
high_priority_operations: ['stop'],
status: 1
}
const option = (value) => ({ value, label: `动态-${value}` })
const options = {
error_categories: ['business', 'terminal'].map(option),
exempt_sources: ['manual'].map(option),
gateway_operations: ['stop', 'start', 'query_flow', 'query_status', 'query_realname'].map(option),
match_types: ['contains', 'prefix', 'regex'].map(option),
rate_limit_modes: ['default'].map(option),
stop_resume_actions: ['stop', 'start'].map(option),
stop_resume_results: ['success', 'failed', 'unknown', 'already_target', 'skipped'].map(option)
}
const success = (data) => ({ code: 0, data, msg: 'ok' })
const page = (items) => success({ items, total: items.length, page: 1, size: 20 })
const deferred = () => {
let resolve
const promise = new Promise((done) => {
resolve = done
})
return { promise, resolve }
}
async function flush() {
await new Promise((done) => setImmediate(done))
await vue.nextTick()
}
test('all 17 gateway service contracts preserve methods, paths, params and response', async () => {
const calls = []
const response = success({ marker: true })
const http = Object.fromEntries(
['get', 'post', 'put', 'del'].map((method) => [
method,
(args) => {
calls.push({ method, ...args })
return Promise.resolve(response)
}
])
)
const base = evaluate(source('src/api/BaseService.ts'), { '@/utils/http': { default: http } })
const { GatewayService: service } = evaluate(source('src/api/modules/gateway.ts'), {
'../BaseService': base
})
const recordsQuery = {
page: 2,
page_size: 100,
carrier_id: 0,
start_time: '2026-10-01T00:00:00Z',
end_time: '2026-10-24T00:00:00Z'
}
const contracts = [
['getRecords', [recordsQuery], 'get', '/card-stop-resume-records', 'params', recordsQuery],
[
'getCardRecords',
[42, { page: 1 }],
'get',
'/iot-cards/42/stop-resume-records',
'params',
{ page: 1 }
],
['getRules', [{ status: 0 }], 'get', '/gateway-error-classifications', 'params', { status: 0 }],
['createRule', [rule], 'post', '/gateway-error-classifications', 'data', rule],
['updateRule', [7, rule], 'put', '/gateway-error-classifications/7', 'data', rule],
[
'setRuleStatus',
[7, 0],
'put',
'/gateway-error-classifications/7/status',
'data',
{ status: 0 }
],
['deleteRule', [7], 'del', '/gateway-error-classifications/7', 'params', undefined],
[
'testRule',
[{ operation: 'stop', message: 'failed', draft: rule }],
'post',
'/gateway-error-classifications/test',
'data',
{ operation: 'stop', message: 'failed', draft: rule }
],
['getOptions', [], 'get', '/polling-options', 'params', undefined],
[
'getGroups',
[{ only_global: false, carrier_id: 0, status: 0 }],
'get',
'/polling-rate-limit-groups',
'params',
{ only_global: false, carrier_id: 0, status: 0 }
],
['createGroup', [group], 'post', '/polling-rate-limit-groups', 'data', group],
['getGroup', [4], 'get', '/polling-rate-limit-groups/4', 'params', undefined],
['updateGroup', [4, group], 'put', '/polling-rate-limit-groups/4', 'data', group],
['setGroupStatus', [4, 1], 'put', '/polling-rate-limit-groups/4/status', 'data', { status: 1 }],
['deleteGroup', [4], 'del', '/polling-rate-limit-groups/4', 'params', undefined],
['getCarrierStats', [60], 'get', '/polling-stats/carriers', 'params', { minutes: 60 }],
[
'getCarrierTrend',
[0, { minutes: 10080, bucket_minutes: 60 }],
'get',
'/polling-stats/carriers/0/trend',
'params',
{ minutes: 10080, bucket_minutes: 60 }
]
]
for (const [method, args, verb, url, field, payload] of contracts) {
assert.equal(await service[method](...args), response)
assert.deepEqual(calls.at(-1), { method: verb, url: `/api/admin${url}`, [field]: payload })
}
assert.equal(calls.length, 17)
})
test('record dates are RFC3339, ordered and bounded to 31 days, optional only for cards', () => {
assert.deepEqual(utils.gatewayTimeRange(['2026-10-01T08:00:00+08:00', '2026-11-01T00:00:00Z']), {
start_time: '2026-10-01T00:00:00.000Z',
end_time: '2026-11-01T00:00:00.000Z'
})
assert.deepEqual(utils.gatewayTimeRange(null, false), {})
for (const range of [
null,
[],
[''],
['invalid', 'invalid'],
['2026-10-02', '2026-10-01'],
['2026-10-01', '2026-11-01T00:00:00.001Z']
])
assert.throws(() => utils.gatewayTimeRange(range))
const [start, end] = utils.recentGatewayRange()
assert.equal(end - start, 7 * 86400000)
})
test('limit groups validate QPS, operation subset, carrier zero and text limits', () => {
assert.doesNotThrow(() => utils.validateGatewayGroup({ ...group, carrier_id: 0 }))
assert.doesNotThrow(() =>
utils.validateGatewayGroup({ ...group, reserved_qps: 0, high_priority_operations: [] })
)
for (const patch of [
{ max_qps: 0 },
{ max_qps: 10001 },
{ max_qps: 1.5 },
{ reserved_qps: -1 },
{ reserved_qps: 20 },
{ reserved_qps: 0.5 },
{ operations: [] },
{ high_priority_operations: [] },
{ high_priority_operations: ['query_flow'] },
{ group_code: ' ' },
{ group_name: 'x'.repeat(101) },
{ carrier_id: -1 }
])
assert.throws(() => utils.validateGatewayGroup({ ...group, ...patch }))
})
test('rules validate required fields, priority and lengths, including draft rules', () => {
assert.doesNotThrow(() => utils.validateGatewayRule(rule))
for (const patch of [
{ keyword: ' ' },
{ keyword: 'x'.repeat(201) },
{ category: '' },
{ match_type: '' },
{ priority: 0 },
{ priority: 10001 },
{ priority: 1.2 },
{ remark: 'x'.repeat(501) }
])
assert.throws(() => utils.validateGatewayRule({ ...rule, ...patch }))
})
test('unknown enums and statistic fields remain visible, without fabricated zeroes', () => {
assert.equal(utils.gatewayLabel(options.gateway_operations, 'stop'), '动态-stop')
assert.equal(
utils.gatewayLabel(options.gateway_operations, 'future_operation'),
'future_operation'
)
assert.equal(utils.gatewayValue(0), '0')
assert.equal(utils.gatewayValue(false), 'false')
assert.equal(utils.gatewayValue(null), '-')
assert.deepEqual(utils.gatewayRows([{ future_metric: 12 }, 0, null]), [
{ future_metric: 12 },
{ value: 0 },
{ value: null }
])
assert.deepEqual(
utils.compactGatewayQuery({
carrier_id: 0,
status: 0,
only_global: false,
blank: '',
missing: undefined
}),
{ carrier_id: 0, status: 0, only_global: false }
)
assert.throws(() => utils.gatewayData({ code: 403, msg: '没有权限' }), /没有权限/)
})
function mountComponent(
t,
path,
{ superAdmin = false, perms = [], overrides = {}, props = {} } = {}
) {
const calls = []
const user = vue.reactive({ isSuperAdmin: superAdmin, perms })
const service = Object.fromEntries(
['getRecords', 'getCardRecords', 'getRules', 'getGroups'].map((name) => [
name,
async (...args) => {
calls.push({ name, args })
return page([])
}
])
)
Object.assign(service, { getOptions: async () => success(options) })
for (const name of [
'createRule',
'updateRule',
'setRuleStatus',
'deleteRule',
'testRule',
'createGroup',
'updateGroup',
'setGroupStatus',
'deleteGroup',
'getGroup',
'getCarrierStats',
'getCarrierTrend'
])
service[name] = async (...args) => {
calls.push({ name, args })
return success({
...group,
id: 4,
matched: true,
rule_id: 0,
category: 'business',
category_name: '业务错误',
keyword: 'failed',
maybe_accepted: false,
carriers: [],
points: [],
carrier_id: args[0]
})
}
for (const [name, handler] of Object.entries(overrides))
service[name] = async (...args) => {
calls.push({ name, args })
return handler(...args)
}
const imports = {
vue,
'vue-router': {
useRouter: () => ({
push: async (url) => {
calls.push({ name: 'navigate', args: [url] })
},
back() {}
}),
useRoute: () => ({ params: { id: '4' } })
},
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) },
'@element-plus/icons-vue': { ArrowLeft: {} },
'@/router/routesAlias': {
RoutesAlias: { GatewayRateLimitGroupDetail: '/polling-management/rate-limit-groups/detail' }
},
'@/utils/business/format': format,
'@/api/modules/carrier': {
CarrierService: {
getCarriers: async (...args) => {
calls.push({ name: 'getCarriers', args })
if (overrides.getCarriers) return overrides.getCarriers(...args)
return page([{ id: 8, carrier_name: '模拟运营商' }])
}
}
},
'element-plus': {
ElMessage: { success() {}, error() {} },
ElMessageBox: { confirm: async () => {} }
},
'@/api/modules/gateway': { GatewayService: service },
'@/store/modules/user': { useUserStore: () => user },
'@/composables/useAuth': {
useAuth: () => ({ hasAuth: (perm) => user.isSuperAdmin || user.perms.includes(perm) })
},
'@/config/constants/gateway': constants,
'@/utils/business/gateway': utils,
'@/components/business/gateway/GatewayOptionsNotice.vue': { default: {} },
'@/components/business/gateway/GatewayRuleFields.vue': { default: {} },
'@/components/business/gateway/GatewayDynamicData.vue': { default: {} },
'@/components/business/gateway/GatewayOperationTable.vue': { default: {} },
'./GatewayOptionsNotice.vue': { default: {} }
}
imports['@/composables/useGateway'] = evaluate(source('src/composables/useGateway.ts'), imports)
const { descriptor } = parse(source(path))
const component = evaluate(
compileScript(descriptor, { id: 'gateway-test' }).content,
imports
).default
let state
const reactiveProps = vue.reactive(props)
const renderer = vue.createRenderer({
createComment: () => ({}),
insert() {},
remove() {},
parentNode: () => null,
nextSibling: () => null
})
const app = renderer.createApp({
setup() {
state = component.setup(reactiveProps, { expose() {}, emit() {} })
return () => null
}
})
app.mount({})
t.after(() => app.unmount())
return { state, calls, user, props: reactiveProps, service }
}
const rulePage = 'src/views/polling-management/error-classifications/index.vue'
const groupPage = 'src/views/polling-management/rate-limit-groups/index.vue'
const statsPage = 'src/views/polling-management/gateway-stats/index.vue'
const recordComponent = 'src/components/business/gateway/StopResumeRecords.vue'
test('ordinary users cannot maintain either rules or groups, even if handlers are called', async (t) => {
for (const [path, area, save, mutate, input] of [
[rulePage, 'rules', 'saveRule', 'changeRule', rule],
[groupPage, 'groups', 'saveGroup', 'changeGroup', group]
]) {
const { state, calls } = mountComponent(t, path, { perms: [permissions[area].page] })
await flush()
state.form.value = input
await state[save]()
await state[mutate]({ ...input, id: 1 }, true)
assert.equal(
calls.filter((call) => /create|update|delete|set.*Status/.test(call.name)).length,
0
)
assert.match(state.editorError.value, /无权/)
}
})
test('superadmin saves use write DTOs without record ids or timestamps', async (t) => {
for (const [path, save, input, method] of [
[rulePage, 'saveRule', rule, 'createRule'],
[groupPage, 'saveGroup', group, 'createGroup']
]) {
const { state, calls } = mountComponent(t, path, { superAdmin: true })
await flush()
state.form.value = { ...input }
await state[save]()
const call = calls.find((call) => call.name === method)
assert.ok(call)
assert.equal(call.args[0].id, undefined)
if (method === 'createGroup') assert.equal(call.args[0].carrier_id, null)
assert.equal(state.editorError.value, '')
}
})
test('ordinary rule tests require page + button, validate drafts, and never write rules', async (t) => {
const { state, calls, user } = mountComponent(t, rulePage, {
perms: [permissions.rules.page, permissions.rules.test]
})
await flush()
state.openTest()
state.testForm.operation = 'stop'
state.testForm.message = 'failed'
state.includeDraft.value = true
await state.runTest()
assert.equal(calls.filter((call) => call.name === 'testRule').length, 0)
state.draft.value = { ...rule }
await state.runTest()
assert.deepEqual(calls.find((call) => call.name === 'testRule').args[0], {
operation: 'stop',
message: 'failed',
draft: rule
})
assert.equal(state.testResult.value.matched, true)
user.perms = [permissions.rules.test]
await state.runTest()
assert.match(state.testError.value, /无权/)
assert.equal(calls.filter((call) => call.name === 'testRule').length, 1)
})
test('test close/reopen discards a pending result', async (t) => {
const pending = deferred()
const { state } = mountComponent(t, rulePage, {
superAdmin: true,
overrides: { testRule: () => pending.promise }
})
await flush()
state.openTest()
Object.assign(state.testForm, { operation: 'stop', message: 'failed' })
const running = state.runTest()
state.testVisible.value = false
await vue.nextTick()
state.openTest()
pending.resolve(success({ matched: true }))
await running
assert.equal(state.testResult.value, null)
})
test('enum failure is visible, blocks editing and can be retried with dynamic labels', async (t) => {
let failed = true
const { state } = mountComponent(t, rulePage, {
superAdmin: true,
overrides: {
getOptions: () => (failed ? Promise.reject(new Error('枚举不可用')) : success(options))
}
})
await flush()
assert.equal(state.optionsReady.value, false)
assert.match(state.optionsError.value, /枚举不可用/)
state.openEditor()
assert.equal(state.editorVisible.value, false)
failed = false
await state.loadOptions()
assert.equal(state.optionsReady.value, true)
assert.equal(state.label('gateway_operations', 'stop'), '动态-stop')
})
test('record queries validate time, preserve zero filters and isolate cards by real ID', async (t) => {
const { state, calls, props } = mountComponent(t, recordComponent, {
perms: [permissions.cardHistory],
props: { cardId: 42, permission: permissions.cardHistory }
})
await flush()
assert.equal(calls[0].name, 'getCardRecords')
assert.equal(calls[0].args[0], 42)
state.range.value = null
await state.search()
assert.deepEqual(calls.at(-1).args, [42, { page: 1, page_size: 20 }])
props.cardId = 43
await flush()
assert.equal(calls.at(-1).args[0], 43)
props.cardId = undefined
props.permission = permissions.records.page
await flush()
assert.match(state.error.value, /无权/)
})
test('invalid global date ranges never request records; zero carrier survives', async (t) => {
const { state, calls } = mountComponent(t, recordComponent, {
perms: [permissions.records.page],
props: { permission: permissions.records.page }
})
await flush()
calls.length = 0
state.range.value = null
await state.search()
assert.equal(calls.length, 0)
state.range.value = [new Date('2026-10-01'), new Date('2026-10-24')]
state.filters.carrier_id = 0
await state.search()
assert.equal(calls[0].args[0].carrier_id, 0)
})
test('list latest request wins and errors clear previous records', async (t) => {
const first = deferred()
const second = deferred()
let count = 0
const { state } = mountComponent(t, groupPage, {
superAdmin: true,
overrides: { getGroups: () => (++count === 1 ? first.promise : second.promise) }
})
const latest = state.load()
second.resolve(page([{ ...group, id: 2 }]))
await latest
first.resolve(page([{ ...group, id: 1 }]))
await flush()
assert.equal(state.rows.value[0].id, 2)
})
test('group details require permissions before navigation to shop-style detail page', async (t) => {
const { state, calls, user } = mountComponent(t, groupPage, { perms: [permissions.groups.page] })
await flush()
await state.openDetail(4)
assert.equal(
calls.some((call) => call.name === 'navigate'),
false
)
user.perms.push(permissions.groups.detail)
await state.openDetail(4)
assert.equal(
calls.find((call) => call.name === 'navigate').args[0],
'/polling-management/rate-limit-groups/detail/4'
)
})
test('statistics support account 0, reject bad bounds and retain raw unknown metrics', async (t) => {
const { state, calls } = mountComponent(t, statsPage, {
perms: [permissions.stats.page, permissions.stats.trend],
overrides: {
getCarrierTrend: (id) =>
success({ carrier_id: id, points: [{ new_metric: 9, future_time: '2026-10-24' }] })
}
})
await flush()
state.carrierId.value = 0
await state.loadTrend()
assert.equal(state.trend.value.carrier_id, 0)
assert.equal(state.trend.value.points[0].new_metric, 9)
state.trendMinutes.value = 10081
await state.loadTrend()
assert.equal(calls.filter((call) => call.name === 'getCarrierTrend').length, 1)
state.summaryMinutes.value = 61
await state.loadSummary()
assert.equal(calls.filter((call) => call.name === 'getCarrierStats').length, 1)
assert.equal(state.trend.value, null)
})
test('route aliases match all four pages and ordinary permissions use colon codes', () => {
const routes = source('src/router/routes/asyncRoutes.ts')
const aliases = source('src/router/routesAlias.ts')
for (const [name, segment] of [
['GatewayStopResumeRecords', 'stop-resume-records'],
['GatewayErrorClassifications', 'error-classifications'],
['GatewayRateLimitGroups', 'rate-limit-groups'],
['GatewayCallStats', 'gateway-stats']
]) {
assert.ok(routes.includes(`name: '${name}'`))
assert.ok(aliases.includes(`'/polling-management/${segment}'`))
assert.ok(source(`src/views/polling-management/${segment}/index.vue`))
}
const flattened = Object.values(permissions).flatMap((value) =>
typeof value === 'string' ? [value] : Object.values(value)
)
assert.equal(flattened.length, 10)
for (const code of flattened) assert.match(code, /^[a-z_]+:[a-z_]+$/)
})
test('detail page checks permission and formats dates using the shop formatter', async (t) => {
const path = 'src/views/polling-management/rate-limit-groups/detail.vue'
const denied = mountComponent(t, path)
await flush()
assert.equal(
denied.calls.some((call) => call.name === 'getGroup'),
false
)
const allowed = mountComponent(t, path, { perms: [permissions.groups.detailPage] })
await flush()
assert.equal(allowed.state.detail.value.id, 4)
assert.equal(allowed.state.formatDateTime('2026-10-24T10:00:00+08:00'), '2026-10-24 10:00:00')
})
test('gateway search expand toggles follow the >=4 rule', () => {
for (const path of [recordComponent, rulePage, groupPage, statsPage]) {
const matches = [...source(path).matchAll(/:show-expand="([^"]+)"/g)]
assert.ok(matches.length)
for (const match of matches) assert.match(match[1], /\.length >= 4$/)
}
})
test('record time picker matches expiring assets estimated-expiry range configuration', async (t) => {
const { descriptor } = parse(source('src/views/asset-management/expiring-assets/index.vue'))
const script = ts.createSourceFile(
'expiring-assets.ts',
descriptor.scriptSetup.content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
)
let reference
function visit(node) {
if (
ts.isObjectLiteralExpression(node) &&
node.properties.some(
(property) =>
ts.isPropertyAssignment(property) &&
property.name.getText(script) === 'label' &&
ts.isStringLiteral(property.initializer) &&
property.initializer.text === '预计到期起止'
)
) {
reference = evaluate(`export const item = ${node.getText(script)}`).item
}
ts.forEachChild(node, visit)
}
visit(script)
assert.ok(reference)
const { state } = mountComponent(t, recordComponent, {
perms: [permissions.records.page],
props: { permission: permissions.records.page }
})
await flush()
const item = state.searchItems.value.find((item) => item.prop === 'range')
assert.equal(item.type, reference.type)
assert.equal(item.elColSpan, reference.elColSpan)
assert.deepEqual(item.config, reference.config)
assert.equal(item.label, '记录时间')
})
test('business timestamps match shop list formatting and IDs retain zero', () => {
const timestamp = '2026-10-24T10:00:00+08:00'
assert.equal(utils.gatewayDisplayValue(timestamp, 'created_at'), format.formatDateTime(timestamp))
assert.equal(
utils.gatewayDisplayValue({ created_at: timestamp }),
'{"created_at":"2026-10-24 10:00:00"}'
)
assert.equal(
utils.gatewayDisplayValue(1792807200, 'timestamp'),
format.formatDateTime(1792807200)
)
assert.equal(utils.gatewayDisplayValue(0, 'duration_ms'), '0')
assert.equal(utils.gatewayOptionalId('0'), 0)
assert.equal(utils.gatewayOptionalId(''), undefined)
assert.throws(() => utils.gatewayOptionalId('-1'))
})
test('carrier name search uses exact backend parameters and remembers edit labels', async (t) => {
const { state, calls } = mountComponent(t, groupPage, { superAdmin: true })
await flush()
await state.searchCarriers('运营商名称')
assert.deepEqual(calls.at(-1), {
name: 'getCarriers',
args: [{ page: 1, page_size: 20, carrier_name: '运营商名称' }]
})
state.openEditor({ ...group, id: 7, carrier_id: 99, carrier_name: '原账户' })
assert.equal(state.carrierOptions.value.find((option) => option.value === 99).label, '原账户')
assert.equal(state.form.value.carrier_id, 99)
assert.equal(state.searchItems.value[0].label, '运营商账户')
assert.equal(state.searchItems.value[0].type, 'select')
})
test('carrier search ignores a late old response and retains selected account labels', async (t) => {
const older = deferred()
const { state } = mountComponent(t, groupPage, {
superAdmin: true,
overrides: {
getCarriers: ({ carrier_name }) =>
carrier_name === 'old' ? older.promise : page([{ id: 8, carrier_name: '新账户' }])
}
})
await flush()
const first = state.searchCarriers('old')
await state.searchCarriers('new')
state.selectCarrier(8)
older.resolve(page([{ id: 9, carrier_name: '旧账户' }]))
await first
assert.equal(
state.carrierOptions.value.some((option) => option.value === 9),
false
)
assert.equal(state.carrierOptions.value.find((option) => option.value === 8).label, '新账户')
})
test('duplicate maintenance submits cannot send a second write while saving', async (t) => {
const pending = deferred()
const { state, calls } = mountComponent(t, rulePage, {
superAdmin: true,
overrides: { createRule: () => pending.promise }
})
await flush()
state.form.value = { ...rule }
const first = state.saveRule()
await state.saveRule()
assert.equal(calls.filter((call) => call.name === 'createRule').length, 1)
pending.resolve(success(rule))
await first
})
test('single-card history entry rejects missing IDs, other assets and denied permissions', () => {
const text = source('src/views/asset-management/asset-information/index.vue')
const functionText = text.match(/ {2}function showStopResumeRecords[\s\S]*?\n {2}}/)[0]
const { create } = evaluate(
`export const create = (cardInfo, hasAuth, stopResumePermission, stopResumeTarget, stopResumeVisible) => { ${functionText}; return showStopResumeRecords }`
)
const asset = vue.ref({
asset_type: 'device',
asset_id: 100,
cards: [{ card_id: 42, iccid: 'CARD' }]
})
const target = vue.ref(null)
const visible = vue.ref(false)
let allowed = false
const open = create(asset, () => allowed, vue.ref(permissions.deviceCardHistory), target, visible)
open({ cardId: 42, iccid: 'CARD' })
assert.equal(visible.value, false)
allowed = true
open({ cardId: 100, iccid: 'CARD' })
assert.equal(visible.value, false)
open({ cardId: 42, iccid: 'CARD' })
assert.equal(target.value.cardId, 42)
assert.equal(visible.value, true)
visible.value = false
asset.value = { asset_type: 'card', asset_id: 7, iccid: 'CARD-7' }
open({ cardId: 42, iccid: 'CARD' })
assert.equal(visible.value, false)
open({ cardId: 7, iccid: 'CARD-7' })
assert.equal(target.value.cardId, 7)
})

View File

@@ -0,0 +1,245 @@
// 本地浏览器验收专用;所有 /api 请求在这里终止,不会代理到真实后端。
const time = '2026-10-24T10:00:00+08:00'
const option = (value, label) => ({ value, label })
const options = {
error_categories: [
option('rate_limited_upstream', '上游频率限制'),
option('business', '业务错误')
],
exempt_sources: [option('manual', '人工')],
gateway_operations: [
option('query_flow', '查流量'),
option('query_status', '查状态'),
option('query_realname', '查实名'),
option('stop', '停机'),
option('start', '复机')
],
match_types: [option('contains', '包含'), option('prefix', '前缀'), option('regex', '正则')],
rate_limit_modes: [option('group', '限流组')],
stop_resume_actions: [option('stop', '停机'), option('start', '复机')],
stop_resume_results: [
option('success', '成功'),
option('failed', '失败'),
option('unknown', '未知'),
option('already_target', '已是目标状态'),
option('skipped', '已跳过')
]
}
const rules = [
{
id: 1,
category: 'rate_limited_upstream',
category_name: '上游频率限制',
keyword: '请求过于频繁',
match_type: 'contains',
operations: ['stop'],
priority: 10,
maybe_accepted: false,
status: 1,
remark: '本地模拟规则',
created_at: time,
updated_at: time
}
]
const groups = [
{
id: 1,
carrier_id: null,
carrier_name: '',
group_code: 'default',
group_name: '全局默认组',
operations: ['query_flow', 'stop', 'start'],
max_qps: 20,
reserved_qps: 5,
high_priority_operations: ['stop', 'start'],
status: 1,
status_name: '启用',
created_at: time,
updated_at: time
}
]
const records = [
{
id: 1,
card_id: 42,
carrier_id: 8,
iccid: 'MOCK-ICCID-42',
action: 'stop',
action_name: '停机',
result: 'success',
error_category: '',
error_category_name: '',
provider_message: '操作成功',
provider_code: '0',
attempt: 1,
command_id: 'MOCK-CMD',
source: 'manual',
scene: 'manual_stop',
reason: '本地验收',
duration_ms: 42,
created_at: time
}
]
const page = (items, url) => ({
items,
total: items.length,
page: Number(url.searchParams.get('page') || 1),
size: Number(url.searchParams.get('page_size') || 20)
})
export const mockApiPlugin = {
name: 'gateway-local-mock-api',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url, 'http://127.0.0.1')
const path = url.pathname
if (!path.startsWith('/api/')) return next()
let body = ''
for await (const chunk of req) body += chunk
const input = body ? JSON.parse(body) : {}
let data = page([], url)
let code = 0
let msg = 'local mock only'
if (path === '/api/auth/me' || path === '/api/auth/login') {
const viewer = req.headers.authorization?.includes('local-gateway-viewer')
const pagePermissions = [
'card_stop_resume_record:page',
'gateway_error_classification:page',
'gateway_rate_limit_group:page',
'gateway_rate_limit_group:detail_page',
'gateway_call_stats:page'
]
const menuEntries = [
['停复机记录', 'stop-resume-records', pagePermissions[0]],
['上游错误分类', 'error-classifications', pagePermissions[1]],
['网关限流组', 'rate-limit-groups', pagePermissions[2]],
['网关限流组详情', 'rate-limit-groups/detail/:id', pagePermissions[3]],
['网关调用统计', 'gateway-stats', pagePermissions[4]]
]
data = {
access_token: viewer ? 'local-gateway-viewer' : 'local-gateway-mock-only',
refresh_token: '',
expires_in: 3600,
user: {
id: viewer ? 2 : 1,
user_type: viewer ? 2 : 1,
username: viewer ? '本地只读账号' : '本地模拟管理员',
roles: [viewer ? 'R_ADMIN' : 'R_SUPER']
},
permissions: viewer ? pagePermissions : [],
buttons: viewer
? [
'gateway_error_classification:test',
'gateway_rate_limit_group:detail',
'gateway_call_stats:trend'
]
: [],
menus: viewer
? [
{
url: '/polling-management',
name: '轮询管理',
children: menuEntries.map(([name, segment, perm_code]) => ({
name,
url: `/polling-management/${segment}`,
perm_code
}))
}
]
: []
}
} else if (path === '/api/admin/polling-options') data = options
else if (path === '/api/admin/carriers')
data = page(
[
{
id: 8,
carrier_name: '模拟运营商账户',
carrier_type: 'CMCC',
status: 1,
created_at: time,
updated_at: time
}
].filter((carrier) =>
carrier.carrier_name.includes(url.searchParams.get('carrier_name') || '')
),
url
)
else if (
path === '/api/admin/card-stop-resume-records' ||
/\/iot-cards\/\d+\/stop-resume-records$/.test(path)
)
data = page(records, url)
else if (path === '/api/admin/gateway-error-classifications/test') {
data = {
matched: input.message?.includes(input.draft?.keyword || '请求过于频繁') || false,
rule_id: input.draft ? 0 : 1,
category: input.draft?.category || rules[0].category,
category_name: '上游频率限制',
keyword: input.draft?.keyword || rules[0].keyword,
maybe_accepted: false
}
} else if (
/^\/api\/admin\/(gateway-error-classifications|polling-rate-limit-groups)(\/\d+)?(\/status)?$/.test(
path
)
) {
const list = path.includes('gateway-error-classifications') ? rules : groups
const id = Number(path.split('/')[4])
const row = list.find((item) => item.id === id)
if (req.method === 'GET') data = id ? row : page(list, url)
else if (req.method === 'POST') {
data = {
...input,
id: Math.max(...list.map((item) => item.id), 0) + 1,
created_at: time,
updated_at: time
}
list.push(data)
} else if (req.method === 'PUT' && row) {
Object.assign(row, input)
if ('group_name' in row) row.status_name = row.status === 1 ? '启用' : '停用'
data = row
} else if (req.method === 'DELETE' && row) {
list.splice(list.indexOf(row), 1)
data = {}
}
} else if (path === '/api/admin/polling-stats/carriers') {
data = {
minutes: Number(url.searchParams.get('minutes') || 15),
mode: '本地模拟模式',
carriers: [
{
carrier_id: 8,
carrier_name: '模拟运营商',
example_calls: 100,
example_upstream_failures: 2
}
]
}
} else if (/\/polling-stats\/carriers\/\d+\/trend$/.test(path)) {
data = {
carrier_id: Number(path.split('/')[5]),
points: [{ example_time: time, example_calls: 10 }]
}
} else if (path.startsWith('/api/admin/assets/resolve/')) {
data = {
asset_type: 'card',
asset_id: 42,
identifier: 'MOCK-ICCID-42',
iccid: 'MOCK-ICCID-42',
status: 1,
network_status: 1,
real_name_status: 1,
carrier_name: '模拟运营商',
cards: []
}
} else if (req.method !== 'GET') {
code = 400
msg = '此本地验收接口未定义,已拦截写请求'
}
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ code, msg, data }))
})
}
}

View File

@@ -0,0 +1,491 @@
// 全 API 本地拦截;沉默期响应依据 docs/api/package-silence.openapi.json。
import { mockApiPlugin as fallback } from './pollingSchedulerBrowserFixtures.mjs'
const time = '2026-10-28T10:00:00+08:00'
const log = []
let localPaymentStatus = 'pending_payment'
const silence = {
silence_status: 'expired',
silence_status_name: '沉默期已过期',
silence_deadline_at: '2026-10-27T23:59:59+08:00',
silence_period_days: 30,
silence_extended_days: 0,
silence_paid_extended_days: 0,
silence_extension_count: 0,
silence_free_extension_used: 0,
silence_adjust_pending: false,
extendable: true
}
const silenceOrder = () => ({
id: 7,
order_no: 'LOCAL-SILENCE-7',
status: localPaymentStatus,
status_name: localPaymentStatus === 'completed' ? '已完成' : '待支付',
package_usage_id: 4,
package_id: 8,
package_name: '本地实名套餐',
asset_identifier: '89860426012345678901',
asset_type: 'iot_card',
asset_id: 2,
source: 'admin',
source_name: '后台',
amount: 100,
paid_amount: null,
payment_method: 'wechat',
input_days: 31,
extension_days: 60,
qr_content: localPaymentStatus === 'pending_payment' ? 'https://local.invalid/payment/7' : '',
expire_at: '2026-10-28T23:59:59+08:00',
before_deadline_at: '2026-10-27T23:59:59+08:00',
expected_deadline_at: '2026-12-26T23:59:59+08:00',
created_at: time,
approval_required: false
})
const silencePolicy = {
id: 1,
name: '本地全部套餐政策',
scope_type: 'all',
scope_type_name: '全部套餐',
scope_ids: [],
unit_price: 100,
free_extension_limit: 1,
paid_max_months: 12,
single_max_months: null,
effective_from: time,
effective_to: null,
approval_exempt_types: [],
status: 1,
created_at: time,
updated_at: time
}
const silenceBatch = {
id: 3,
batch_no: 'LOCAL-BATCH-3',
path: 'special',
extension_type: 'free',
status: 'pending_approval',
approval_required: true,
approval_status: 'pending',
requested_count: 1,
succeeded_count: 0,
failed_count: 0,
reason: '本地原因',
basis: '本地依据',
amount: 0,
requested_at: time,
evidence_refs: [],
items: [
{
id: 9,
row_no: 1,
package_usage_id: 4,
asset_identifier: '89860426012345678901',
package_name: '本地实名套餐',
extension_days: 30,
status: 'exception',
failure_code: 'local_exception',
failure_summary: '仅本地模拟执行异常',
amount: 0,
before_deadline_at: time
}
]
}
const pkg = {
id: 8,
package_name: '本地实名套餐',
package_code: 'LOCAL',
package_type: 'formal',
series_id: 1,
series_name: '本地系列',
expiry_base: 'from_activation',
silence_period_days: 30,
silence_extension_enabled: true,
silence_config_required: true,
duration_months: 1,
calendar_type: 'natural_month',
status: 1,
shelf_status: 1,
cost_price: 100,
suggested_retail_price: 200,
real_data_mb: 1024,
is_gift: false,
created_at: time,
updated_at: time
}
const entries = [
['平台订单', '/order-management/platform-orders', 'orders:platform_page'],
['代理商订单', '/order-management/agent-orders', 'orders:agent_page'],
['在线支付订单', '/order-management/online-orders', 'orders:online_page'],
['订单列表', '/order-management/order-list', ''],
['订单详情', '/order-management/order-list/detail/:id', '']
]
const page = (items, url) => ({
items,
total: items.length,
page: Number(url.searchParams.get('page') || 1),
page_size: Number(url.searchParams.get('page_size') || 20),
size: Number(url.searchParams.get('page_size') || 20),
total_pages: 1
})
const ok = (res, data, code = 0, msg = 'local mock only') => {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ code, msg, data }))
}
export const mockApiPlugin = {
name: 'order-silence-known-contract-mock',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url, 'http://127.0.0.1'),
path = url.pathname
if (path === '/__order-silence-log') return ok(res, log)
const handled =
['/api/auth/me', '/api/auth/login', '/api/auth/logout'].includes(path) ||
/^\/api\/admin\/(orders|packages|package-series|package-silence|employee-collection-payment-methods|assets|system-configs|iot-cards|devices|shops|accounts)(\/|$)/.test(
path
)
if (!handled) return next()
let raw = ''
for await (const part of req) raw += part
const input = raw ? JSON.parse(raw) : {},
token = String(req.headers.authorization || ''),
type = token.includes('enterprise')
? 4
: token.includes('agent')
? 3
: token.includes('super')
? 1
: 2
log.push({
path,
method: req.method,
body: input,
query: Object.fromEntries(url.searchParams)
})
const matches = (items, field, keyword) =>
items.filter((item) => !keyword || String(item[field]).includes(keyword))
if (path === '/api/admin/iot-cards/standalone')
return ok(
res,
page(
matches(
[
{
id: 2,
iccid: '89860426012345678901',
virtual_no: 'LOCAL-CARD',
shop_name: '本地店铺'
}
],
'iccid',
url.searchParams.get('keyword')
),
url
)
)
if (path === '/api/admin/devices')
return ok(
res,
page(
matches(
[
{
id: 3,
virtual_no: 'LOCAL-DEVICE',
device_name: '本地设备',
imei: '860000000000001',
shop_name: '本地店铺'
}
],
'virtual_no',
url.searchParams.get('keyword')
),
url
)
)
if (path === '/api/admin/shops')
return ok(
res,
page(
matches(
[{ id: 6, shop_name: '本地店铺' }],
'shop_name',
url.searchParams.get('shop_name')
),
url
)
)
if (path === '/api/admin/accounts')
return ok(
res,
page(
matches(
[{ id: 2, username: '本地操作员', shop_name: '本地店铺' }],
'username',
url.searchParams.get('username')
),
url
)
)
if (path.startsWith('/api/auth/'))
return ok(res, {
access_token: `local-silence-${type === 1 ? 'super' : type === 3 ? 'agent' : type === 4 ? 'enterprise' : 'platform'}`,
refresh_token: '',
expires_in: 3600,
user: {
id: type,
user_type: type,
username: '本地订单沉默期验收',
roles: [type === 1 ? 'R_SUPER' : 'R_ADMIN']
},
permissions: [
'orders:platform_page',
'orders:agent_page',
'orders:online_page',
'package_silence:orders_page',
'package_silence:order_detail_page',
'package_silence:batches_page',
'package_silence:batch_detail_page',
'package_silence:usages_page',
'package_silence:policies_page'
],
buttons: [
'orders:add',
'orders:export',
'orders:view_detail',
'package:add',
'package:edit',
'package:detail',
'package_silence:override',
'package_silence:extend',
'package_silence:extend_batch',
'package_silence:offline',
'package_silence:exception',
'package_silence:policy',
'package_silence:order_detail',
'package_silence:batch_detail',
'package_silence:order_export',
'package_silence:usage_export'
],
menus: [
{
name: '订单管理',
url: '/order-management',
children: entries.map(([name, url, perm_code]) => ({ name, url, perm_code }))
},
{
name: '套餐管理',
url: '/package-management',
children: [
{ name: '套餐列表', url: '/package-management/package-list' },
{ name: '套餐详情', url: '/package-management/package-list/detail/:id' },
...['orders', 'batches', 'usages', 'policies'].map((kind, index) => ({
name: ['延期订单', '延期批次与异常', '临期/过期套餐', '延期政策'][index],
url: `/package-management/silence-${kind}`,
perm_code: `package_silence:${kind}_page`
})),
{
name: '延期订单详情',
url: '/package-management/silence-orders/detail/:id',
perm_code: 'package_silence:order_detail_page',
is_hidden: true
},
{
name: '延期批次详情',
url: '/package-management/silence-batches/detail/:id',
perm_code: 'package_silence:batch_detail_page',
is_hidden: true
}
]
},
{
name: '设置',
url: '/settings',
children: [{ name: '沉默期总开关', url: '/settings/package-silence-switch' }]
}
]
})
if (path.startsWith('/api/admin/orders')) {
if (type === 4) return ok(res, null, 403, '企业账号无权访问订单')
if (path.endsWith('/online') && type === 3) return ok(res, page([], url))
const order = {
id: 1,
order_no: `LOCAL-${path.split('/').at(-1)}`,
order_type: 'single_card',
buyer_type: 'personal',
buyer_id: 1,
payment_status: 2,
payment_status_text: '已支付',
payment_method: 'offline',
total_amount: 200,
actual_paid_amount: 200,
paid_at: time,
items: [
{
id: 1,
package_id: 8,
package_name: pkg.package_name,
quantity: 1,
unit_price: 200,
amount: 200
}
],
asset_identifier: '89860426012345678901',
asset_type: 'card',
created_at: time,
updated_at: time,
commission_status: 2,
commission_config_version: 1,
payments: []
}
return ok(
res,
/\/orders\/\d+$/.test(path) || req.method === 'POST' ? order : page([order], url)
)
}
if (path === '/api/admin/package-silence/switch') {
if (type !== 1) return ok(res, null, 403, '仅超级管理员可操作')
const precheck = [
{ code: 'package', name: '套餐沉默期配置', passed: false, count: 2 },
{ code: 'policy', name: '延期政策', passed: true, count: 1 },
{ code: 'template', name: '企微审批模板', passed: true, count: 1 },
{ code: 'member', name: '默认审批人', passed: true, count: 1 }
]
return req.method === 'PUT'
? ok(res, { failed_items: [precheck[0]] }, 1275, '检查未通过,存在未配置沉默期套餐')
: ok(res, { enabled: false, precheck })
}
if (path === '/api/admin/employee-collection-payment-methods')
return ok(res, page([{ id: 2, name: '本地银行收款', enabled: true }], url))
if (path.startsWith('/api/admin/assets/') && path.endsWith('/first-purchase-records'))
return ok(res, [
{
id: 1,
generation: 1,
asset_identifier: 'LOCAL-ASSET',
first_purchase_at: time,
source: 'order',
source_name: '首次购买套餐',
order_no: 'LOCAL-ORDER',
end_reason: 'exchange_return',
ended_at: time,
created_at: time
}
])
if (path.startsWith('/api/admin/package-silence/')) {
if (path.endsWith('/eligibility'))
return ok(res, {
package_usage_id: 4,
can_extend: true,
special_extension_available: true,
free_extension_available: true,
min_days: 1,
max_days: 360,
min_months: 1,
max_months: 12,
unit_price: 100,
options: [{ amount: 100, billed_days: 30, months: 1, new_deadline: '2026-11-26' }],
original_deadline: '2026-10-27',
reason: '',
policy_id: 1,
policy_name: '本地全部套餐政策'
})
if (path.endsWith('/quote'))
return ok(res, {
amount: 200,
billed_days: 60,
input_days: Number(url.searchParams.get('days')),
months: 2,
new_deadline: '2026-12-26',
valid: true
})
if (path.endsWith('/orders/qrcode')) {
localPaymentStatus = 'pending_payment'
return ok(res, silenceOrder())
}
if (path.endsWith('/orders/offline'))
return ok(res, {
...silenceOrder(),
status: 'pending_approval',
status_name: '待审批',
approval_required: true,
qr_content: '',
payment_method: 'offline'
})
if (path.endsWith('/orders')) return ok(res, page([silenceOrder()], url))
if (/\/orders\/\d+$/.test(path)) {
const order = silenceOrder()
localPaymentStatus = 'completed'
return ok(res, order)
}
if (path.endsWith('/special')) return ok(res, silenceBatch)
if (path.endsWith('/batches')) return ok(res, page([silenceBatch], url))
if (/\/batches\/\d+$/.test(path)) return ok(res, silenceBatch)
if (path.endsWith('/handle'))
return ok(res, {
...silenceBatch.items[0],
status: input.action === 'close' ? 'closed' : 'completed',
failure_summary: ''
})
if (path.endsWith('/usages'))
return ok(
res,
page(
[
{
package_usage_id: 4,
package_id: 8,
package_name: pkg.package_name,
asset_id: 2,
asset_identifier: '89860426012345678901',
asset_type: 'iot_card',
shop_id: 1,
shop_name: '本地店铺',
created_at: time,
order_no: 'LOCAL-ORDER',
silence
}
],
url
)
)
if (path.endsWith('/records'))
return ok(res, [
{
id: 1,
event_type: 'set',
event_name: '设置沉默期',
before_status: null,
after_status: 'waiting',
after_deadline_at: time,
change_days: 30,
operator_id: 1,
operator_type: 'admin',
reason: '本地记录',
created_at: time
}
])
if (path.endsWith('/policies/match'))
return ok(res, { matched: true, package_id: 8, policy: silencePolicy })
if (path.endsWith('/policies'))
return ok(res, req.method === 'GET' ? [silencePolicy] : { ...silencePolicy, ...input })
if (/\/policies\/\d+(\/status)?$/.test(path)) return ok(res, { ...silencePolicy, ...input })
return ok(res, null, 400, '本地未定义的沉默期接口已阻止')
}
if (path.startsWith('/api/admin/packages'))
return ok(
res,
path === '/api/admin/packages' && req.method === 'GET'
? page([pkg], url)
: { ...pkg, ...input }
)
if (path.startsWith('/api/admin/package-series'))
return ok(
res,
page([{ id: 1, series_name: '本地系列', series_code: 'LOCAL', status: 1 }], url)
)
if (path.startsWith('/api/admin/system-configs'))
return ok(res, { list: [], total: 0, page: 1, page_size: 20 })
return ok(res, null, 400, '本地未定义,已阻止请求')
})
fallback.configureServer(server)
}
}

View File

@@ -0,0 +1,517 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
const read = (path) => readFileSync(path, 'utf8')
function evaluate(source, imports = {}, globals = {}) {
const code = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const m = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(
(name) => {
if (name.endsWith('.vue')) return { default: {} }
assert.ok(imports[name], `Unexpected dependency: ${name}`)
return imports[name]
},
m,
m.exports,
...Object.values(globals)
)
return m.exports
}
const helpers = evaluate(read('src/utils/business/packageSilence.ts'))
const constants = evaluate(read('src/config/constants/packageSilence.ts'))
const format = evaluate(read('src/utils/business/format.ts'))
const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format })
const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
const exportConfig = evaluate(read('src/config/constants/exportTask.ts'))
test('silence business errors never count as successful orders, package saves or refunds', () => {
for (const code of [1260, 1261, 1262, 1268, 1275])
assert.throws(() => helpers.assertSilenceResponse({ code, data: null }), {
message: helpers.SILENCE_ERROR_MESSAGES[code]
})
assert.throws(
() => helpers.assertSilenceResponse({ code: 1266, msg: 'min_days=30,max_days=90' }),
{ message: 'min_days=30,max_days=90' }
)
const response = { code: 0, data: { warnings: ['saved'] } }
assert.equal(helpers.assertSilenceResponse(response), response)
for (const path of [
'src/views/order-management/order-list/index.vue',
'src/views/package-management/package-list/index.vue',
'src/views/asset-management/asset-information/components/dialogs/PackageRechargeDialog.vue',
'src/components/business/CreateRefundDialog.vue'
])
assert.ok(read(path).includes('assertSilenceResponse'))
})
function setup(path, { type = 2, props = {}, permissions = [], overrides = {} } = {}) {
const calls = [],
unmount = []
const user = vue.reactive({
info: { id: 1, user_type: type },
get isSuperAdmin() {
return Number(this.info.user_type) === 1
},
get getUserInfo() {
return this.info
}
})
const services = new Proxy(
{},
{
get:
(_, name) =>
async (...args) => {
calls.push({ name, args })
return overrides[name]
? overrides[name](...args)
: { code: 0, data: { items: [], total: 0 } }
}
}
)
const runtime = {
...vue,
onMounted() {},
onActivated() {},
onDeactivated() {},
onBeforeUnmount: (fn) => unmount.push(fn)
}
const store = { useUserStore: () => user }
const auth = {
useAuth: () => ({ hasAuth: (code) => user.isSuperAdmin || permissions.includes(code) })
}
const scheduler = evaluate(read('src/composables/useSchedulerRequest.ts'), {
vue: runtime,
'@/store/modules/user': store,
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': polling
})
const checked = evaluate(read('src/composables/useCheckedColumns.ts'), { vue: runtime })
const imports = {
vue: runtime,
pinia: { storeToRefs: vue.toRefs },
'vue-router': { useRouter: () => ({ push() {}, back() {} }) },
'element-plus': {
ElMessage: { success() {}, warning() {}, error() {} },
ElMessageBox: { confirm: async () => {} },
ElTag: {}
},
'@/api/modules': {
OrderService: services,
ExportTaskService: services,
CardService: services,
DeviceService: services,
PackageManageService: services,
ShopService: services,
AssetService: services
},
'@/api/modules/packageSilence': { PackageSilenceService: services },
'@/store/modules/user': store,
'@/composables/useAuth': auth,
'@/composables/useCheckedColumns': checked,
'@/composables/useSchedulerRequest': scheduler,
'@/utils/business/format': format,
'@/utils/business/packageSilence': helpers,
'@/utils/business/pollingScheduler': polling,
'@/utils/business/gateway': gateway,
'@/utils/business/orderPayment': {},
'@/utils/business': { hasVoucherKeys: (v) => !!v?.length, toVoucherKeyList: (v) => v },
'@/router/routesAlias': { RoutesAlias: {} },
'@/config/constants/audit': { AUDIT_PERMISSIONS: {} },
'@/utils/business/auditNavigation': {},
'@/components/business/audit/investigationController': {},
'@/config/constants/bulkPurchase': { BULK_PURCHASE_PERMISSIONS: {} },
'@/config/constants/packageSilence': constants,
'@/config/constants': exportConfig,
'@/config/constants/exportTask': exportConfig
}
const { descriptor } = parse(read(path)),
scope = vue.effectScope()
const component = evaluate(
compileScript(descriptor, { id: 'silence-integration' }).content,
imports,
runtime
).default
const state = scope.run(() => component.setup(props, { expose() {}, emit() {} }))
return {
state,
calls,
user,
dispose() {
unmount.forEach((fn) => fn())
scope.stop()
}
}
}
test('order category role matrix and export scene boundaries match the document', () => {
for (const type of [1, 2, 3, 4, 0, undefined])
for (const category of ['all', 'platform', 'agent', 'online']) {
const allowed = (category === 'online' ? [1, 2] : [1, 2, 3]).includes(type)
assert.equal(helpers.canAccessOrderCategory(type, category), allowed)
assert.equal(
exportConfig.canAccessExportTaskScene(
constants.ORDER_CATEGORY_CONFIG[category].scene,
type
),
allowed
)
}
assert.equal(helpers.canEditRestrictedAsset(4), false)
assert.equal(helpers.canEditRestrictedAsset(3), true)
})
test('three category endpoints preserve query and shared legacy detail contract', async () => {
const calls = []
class BaseService {}
for (const name of ['get', 'getOne'])
BaseService[name] = (url, params) => {
calls.push({ url, params })
return Promise.resolve({ code: 0 })
}
const { OrderService: S } = evaluate(read('src/api/modules/order.ts'), {
'../BaseService': { BaseService }
})
const query = { page: 1, page_size: 20, is_expired: false, seller_shop_id: 8 }
await S.getPlatformOrders(query)
await S.getAgentOrders(query)
await S.getOnlineOrders(query)
await S.getOrders(query)
await S.getOrderById(1)
assert.deepEqual(
calls.map((x) => x.url),
[
'/api/admin/orders/platform',
'/api/admin/orders/agent',
'/api/admin/orders/online',
'/api/admin/orders',
'/api/admin/orders/1'
]
)
for (const call of calls.slice(0, 4)) {
assert.deepEqual(call.params, query)
assert.equal('category' in call.params, false)
}
})
for (const [category, method] of [
['platform', 'getPlatformOrders'],
['agent', 'getAgentOrders'],
['online', 'getOnlineOrders'],
['all', 'getOrders']
])
test(`order view ${category} uses exact service and scene, blocks disallowed identities`, async () => {
for (const type of [1, 2, 3, 4]) {
const h = setup('src/views/order-management/order-list/index.vue', {
type,
props: { category }
})
h.state.searchForm.is_expired = 0
await h.state.getTableData()
const allowed = helpers.canAccessOrderCategory(type, category)
assert.equal(h.calls.length, allowed ? 1 : 0)
if (allowed) {
assert.equal(h.calls[0].name, method)
assert.equal(h.calls[0].args[0].is_expired, false)
assert.equal('category' in h.calls[0].args[0], false)
}
assert.equal(
h.state.categoryConfig.value.scene,
constants.ORDER_CATEGORY_CONFIG[category].scene
)
h.dispose()
}
})
test('package defaults enforce 1..90, nullable purchase defaults, addon omission and immutable activation base', () => {
for (const days of [0, 91, 1.5, NaN])
assert.throws(() =>
helpers.packageSilenceFields({
package_type: 'formal',
expiry_base: 'from_activation',
silence_period_days: days
})
)
for (const days of [1, 30, 90])
assert.equal(
helpers.packageSilenceFields({
package_type: 'formal',
expiry_base: 'from_activation',
silence_period_days: days
}).silence_period_days,
days
)
assert.throws(() =>
helpers.packageSilenceFields({
package_type: 'formal',
expiry_base: 'from_activation',
silence_period_days: null
})
)
assert.equal(
helpers.packageSilenceFields({
package_type: 'formal',
expiry_base: 'from_purchase',
silence_period_days: null,
silence_extension_enabled: false
}).silence_extension_enabled,
false
)
assert.deepEqual(
helpers.packageSilenceFields({ package_type: 'addon', silence_period_days: 999 }),
{}
)
assert.throws(() =>
helpers.packageSilenceFields(
{ package_type: 'formal', expiry_base: 'from_purchase' },
'from_activation'
)
)
})
test('allocation overrides enforce source defaults before single, batch and series writes', async () => {
const packageData = {
package_type: 'formal',
expiry_base: 'from_purchase',
silence_period_days: null
}
let reads = 0
const validation = evaluate(read('src/api/modules/packageSilenceValidation.ts'), {
'./packageManage': {
PackageManageService: {
getPackageDetail: async () => {
reads++
return { code: 0, data: { ...packageData } }
}
}
},
'@/utils/business/packageSilence': helpers
})
const writes = []
class BaseService {}
for (const method of ['post', 'put', 'patch', 'create'])
BaseService[method] = (url, data) => {
writes.push({ url, data })
return Promise.resolve({ code: 0, data })
}
const imports = { '../BaseService': { BaseService }, './packageSilenceValidation': validation }
const S = evaluate(
read('src/api/modules/shopPackageAllocation.ts'),
imports
).ShopPackageAllocationService
const G = evaluate(read('src/api/modules/shopSeriesGrant.ts'), imports).ShopSeriesGrantService
const calls = [
() => S.createShopPackageAllocation({ package_id: 8, expiry_base_override: 'from_activation' }),
() =>
S.createShopPackageBatchAllocations({
package_ids: [8, 9],
expiry_base_override: 'from_activation'
}),
() =>
S.updateShopPackageAllocationExpiryBase(1, { expiry_base_override: 'from_activation' }, 8),
() =>
G.createShopSeriesGrant({
packages: [{ package_id: 8 }],
expiry_base_override: 'from_activation'
}),
() =>
G.manageGrantPackages(1, {
packages: [{ package_id: 8 }],
expiry_base_override: 'from_activation'
})
]
for (const call of calls) await assert.rejects(call, /先配置默认沉默期/)
assert.equal(writes.length, 0)
packageData.silence_period_days = 30
for (const call of calls) await call()
assert.equal(writes.length, 5)
packageData.expiry_base = 'from_activation'
await assert.rejects(
() => S.updateShopPackageAllocationExpiryBase(1, { expiry_base_override: 'from_purchase' }, 8),
/不允许覆盖/
)
const previousReads = reads
await S.createShopPackageAllocation({ package_id: 8, expiry_base_override: null })
await G.manageGrantPackages(1, {
packages: [{ package_id: 8, remove: true }],
expiry_base_override: 'from_purchase'
})
assert.equal(reads, previousReads)
})
test('purchase overrides do not impose a 90-day maximum and never leak unauthorized fields', () => {
assert.deepEqual(helpers.silencePurchaseFields(false, 91, 'why'), {})
assert.deepEqual(helpers.silencePurchaseFields(true, null, 'why'), {})
assert.deepEqual(helpers.silencePurchaseFields(true, 365, ' why '), {
silence_period_days: 365,
silence_reason: 'why'
})
for (const days of [0, -1, 1.5, NaN])
assert.throws(() => helpers.silencePurchaseFields(true, days, 'why'))
assert.throws(() => helpers.silencePurchaseFields(true, 30, ' '))
})
test('purchase override remains null until explicitly enabled and disabling clears the reason', async () => {
const path = 'src/components/business/package-silence/SilencePurchaseFields.vue'
const { descriptor } = parse(read(path))
const definition = evaluate(compileScript(descriptor, { id: 'optional-override' }).content, {
vue
}).default
let state
const days = vue.ref(null),
reason = vue.ref('')
const component = {
...definition,
setup(props, context) {
state = definition.setup(props, context)
return () => null
}
}
const renderer = vue.createRenderer({
createComment: () => ({}),
insert() {},
remove() {},
parentNode: () => null,
nextSibling: () => null
})
const app = renderer.createApp({
render: () =>
vue.h(component, {
days: days.value,
reason: reason.value,
'onUpdate:days': (v) => (days.value = v),
'onUpdate:reason': (v) => (reason.value = v)
})
})
app.mount({})
assert.equal(state.overrideEnabled.value, false)
assert.equal(days.value, null)
state.overrideEnabled.value = true
await vue.nextTick()
assert.equal(days.value, 1)
days.value = 365
reason.value = 'why'
await vue.nextTick()
assert.equal(state.overrideEnabled.value, true)
state.overrideEnabled.value = false
await vue.nextTick()
assert.equal(days.value, null)
assert.equal(reason.value, '')
assert.match(descriptor.template.content, /<ElInputNumber\s+v-if="overrideEnabled"/)
app.unmount()
})
test('first purchase range is shared RFC3339 and rejects invalid or reversed input', () => {
const range = ['2026-10-01T00:00:00+08:00', '2026-10-28T23:59:59+08:00']
assert.deepEqual(helpers.firstPurchaseRange(range), {
first_purchase_start: '2026-09-30T16:00:00Z',
first_purchase_end: '2026-10-28T15:59:59Z'
})
assert.deepEqual(helpers.firstPurchaseRange([]), {})
for (const value of [['bad', 'date'], range.toReversed(), [range[0]]])
assert.throws(() => helpers.firstPurchaseRange(value))
})
test('new order exports keep query.filters as an object, preserving zero/false and legacy serialization', () => {
for (const scene of ['order_platform', 'order_agent', 'order_online', 'order']) {
const filters = {
payment_status: 2,
is_expired: false,
seller_shop_id: 0,
start_time: '2026-10-01T00:00:00+08:00'
}
const h = setup('src/components/business/ExportTaskCreateDialog.vue', {
props: { scene, query: { filters }, modelValue: true, defaultFormat: 'xlsx' }
})
const query = h.state.getNormalizedQuery()
assert.deepEqual(query.filters, scene === 'order' ? JSON.stringify(filters) : filters)
h.dispose()
}
})
test('silence switch uses dedicated endpoints and boolean state, never generic system config', async () => {
const calls = []
class BaseService {}
for (const method of ['get', 'put'])
BaseService[method] = (url, data) => {
calls.push({ method, url, data })
return Promise.resolve({ code: 0 })
}
const { PackageSilenceService: S } = evaluate(read('src/api/modules/packageSilence.ts'), {
'../BaseService': { BaseService }
})
await S.getSwitch()
await S.setSwitch(false)
assert.deepEqual(calls, [
{ method: 'get', url: '/api/admin/package-silence/switch', data: undefined },
{ method: 'put', url: '/api/admin/package-silence/switch', data: { enabled: false } }
])
})
test('silence switch refuses ordinary accounts and exposes failed prechecks from business errors', async () => {
for (const type of [2, 3, 4]) {
const h = setup('src/views/settings/package-silence-switch/index.vue', { type })
await h.state.load()
await h.state.toggle()
assert.equal(h.calls.length, 0)
h.dispose()
}
const failed = [{ code: 'unconfigured', name: 'missing config', passed: false, count: 3 }]
const h = setup('src/views/settings/package-silence-switch/index.vue', {
type: 1,
overrides: {
getSwitch: () => ({ code: 0, data: { enabled: false, precheck: failed } }),
setSwitch: () => ({ code: 1275, msg: 'check failed', data: { failed_items: failed } })
}
})
await h.state.load()
await h.state.toggle()
assert.equal(h.state.data.value.enabled, false)
assert.deepEqual(h.state.failed.value, failed)
assert.equal(h.state.error.value, 'check failed')
await h.state.load()
assert.deepEqual(h.state.failed.value, [])
assert.equal(h.state.error.value, '')
h.dispose()
})
test('all new and changed business templates compile, including conditional root order view', () => {
const paths = [
'src/views/order-management/order-list/index.vue',
'src/views/package-management/package-list/index.vue',
'src/views/settings/package-silence-switch/index.vue',
'src/views/asset-management/asset-information/components/PackageListCard.vue',
'src/views/asset-management/asset-information/components/CurrentPackageCard.vue',
'src/components/business/package-silence/SilenceSummary.vue',
'src/components/business/package-silence/SilencePurchaseFields.vue',
'src/components/business/package-silence/PackageSilenceFields.vue'
]
for (const path of paths) {
const { descriptor, errors } = parse(read(path))
assert.deepEqual(errors, [], path)
const result = compileTemplate({
source: descriptor.template.content,
filename: path,
id: path,
compilerOptions: { expressionPlugins: ['typescript'] }
})
assert.deepEqual(result.errors, [], path)
}
})
test('198 base regressions remain compatible while metadata and zero refunds follow the new contract', () => {
assert.equal(Object.keys(helpers.SILENCE_ERROR_MESSAGES).length, 16)
for (const type of ['override', 'extend', 'extend_batch', 'offline', 'exception', 'policy'])
assert.ok(
Object.values(constants.PACKAGE_SILENCE_PERMISSIONS).includes(`package_silence:${type}`)
)
assert.match(read('src/types/api/wecom.ts'), /package_silence_approval/)
assert.match(read('src/views/settings/wecom/scenes/index.vue'), /沉默期审批/)
assert.match(read('src/views/finance/refund/index.vue'), /requested_refund_amount"\s+:min="0"/)
assert.match(
read('src/views/finance/refund/index.vue'),
/row.requested_refund_amount == null \? undefined : fenToYuan/
)
for (const path of [
'src/views/asset-management/iot-card-management/index.vue',
'src/views/asset-management/device-list/index.vue'
])
assert.ok(read(path).includes('firstPurchaseRange'))
})

View File

@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { compileTemplate, parse } from '@vue/compiler-sfc'
const pages = [
['asset-information', 'div', 'PollingStateDialog', 'pollingStateTarget'],
['device-list', 'ArtTableFullScreen', 'PollingBatchDialog', 'pollingBatchIds'],
['iot-card-management', 'ArtTableFullScreen', 'PollingBatchDialog', 'pollingBatchIds']
]
for (const [page, rootTag, dialogTag, dialogState] of pages) {
test(`${page} keeps its polling dialog inside one transition-compatible page root`, () => {
const filename = `src/views/asset-management/${page}/index.vue`
const { descriptor, errors } = parse(readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [])
const roots = descriptor.template.ast.children.filter((node) => node.type === 1)
assert.equal(roots.length, 1, 'sibling polling dialogs turn the page into a Fragment')
assert.equal(roots[0].tag, rootTag, 'preserve the existing page/layout root')
const dialog = roots[0].children.find((node) => node.type === 1 && node.tag === dialogTag)
assert.ok(dialog, 'the polling dialog must remain available inside the page root')
const condition = dialog.props.find((prop) => prop.type === 7 && prop.name === 'if')
assert.equal(condition?.exp.content, dialogState)
const close = dialog.props.find(
(prop) => prop.type === 7 && prop.name === 'on' && prop.arg?.content === 'close'
)
assert.equal(close?.exp.content, `${dialogState} = null`)
// In production, a Fragment root never completes Transition's out-in leave,
// leaving KeepAlive on a comment placeholder for every subsequent route.
const compiled = compileTemplate({
source: descriptor.template.content,
filename,
id: page,
isProd: true,
compilerOptions: { expressionPlugins: ['typescript'] }
})
assert.deepEqual(compiled.errors, [])
assert.equal(compiled.ast.codegenNode.type, 13, 'the page must compile to a single VNode')
assert.equal(typeof compiled.ast.codegenNode.tag, 'string', 'never compile a Fragment root')
})
}
test('the table page layout provides a native element root for route transitions', () => {
const filename = 'src/components/core/tables/ArtTableFullScreen.vue'
const { descriptor } = parse(readFileSync(filename, 'utf8'), { filename })
const roots = descriptor.template.ast.children.filter((node) => node.type === 1)
assert.equal(roots.length, 1)
assert.equal(roots[0].tag, 'div')
})

View File

@@ -0,0 +1,277 @@
import { mockApiPlugin as gatewayFixtures } from './gatewayBrowserFixtures.mjs'
const time = '2026-10-26T10:00:00+08:00'
const configs = [1, 2, 3].map((id) => ({
id,
config_name: `模拟配置 ${id}`,
priority: id,
description: '本地模拟,不代表真实配置',
status: id === 3 ? 0 : 1,
carrier_id: 8,
carrier_name: '模拟运营商账户',
card_category: 'normal',
card_condition: 'activated',
asset_status: 2,
package_state: 'active',
realname_check_interval: null,
carddata_check_interval: 300,
package_check_interval: 600,
card_status_check_interval: 0,
pre_reset_window: 3600,
pre_reset_carddata_interval: 60,
created_at: time,
updated_at: time
}))
const tasks = new Map()
const page = (items, url) => {
const current = Number(url.searchParams.get('page') || 1)
const size = Number(url.searchParams.get('page_size') || 20)
return {
items: items.slice((current - 1) * size, current * size),
page: current,
size,
page_size: size,
total: items.length,
total_pages: Math.ceil(items.length / size)
}
}
// 本地测试专用:所有 /api 都被当前或 gateway fixture 消费,无真实后端代理。
export const mockApiPlugin = {
name: 'polling-config-local-mock-api',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url, 'http://127.0.0.1')
const path = url.pathname
const auth = ['/api/auth/me', '/api/auth/login', '/api/auth/logout'].includes(path)
if (
!auth &&
!path.startsWith('/api/admin/polling-configs') &&
!['/api/admin/polling-stats/pre-reset', '/api/admin/polling-stats/tiers'].includes(path)
)
return next()
let body = ''
for await (const chunk of req) body += chunk
const input = body ? JSON.parse(body) : {}
const viewer = req.headers.authorization?.includes('local-polling-viewer')
const denied = req.headers.authorization?.includes('local-polling-denied')
const admin = !viewer && !denied
let data = {}
let code = 0
let msg = 'local mock only'
if (auth) {
const menus = [
{ url: '/polling-management/config', name: '轮询配置' },
{
url: '/polling-management/config-stats',
name: '轮询配置统计',
perm_code: 'polling_config_stats:page'
}
]
data = {
access_token: viewer
? 'local-polling-viewer'
: denied
? 'local-polling-denied'
: 'local-polling-admin',
refresh_token: '',
expires_in: 3600,
user: {
id: admin ? 1 : 2,
user_type: admin ? 1 : 2,
username: admin ? '本地模拟管理员' : '本地普通账号',
roles: [admin ? 'R_SUPER' : 'R_ADMIN']
},
permissions: denied ? [] : ['polling_config_stats:page'],
buttons: denied
? []
: [
'polling_config:match_preview',
'polling_config:recalculate_progress',
'polling_config_stats:pre_reset',
'polling_config_stats:tiers'
],
menus: [{ url: '/polling-management', name: '轮询管理', children: menus }]
}
} else if (path === '/api/admin/polling-configs/match-preview') {
const iccid = url.searchParams.get('iccid')
if (!iccid || (req.method === 'POST' && input.iccid !== iccid)) {
code = 1001
msg = 'Query 和 Body 的 ICCID 必须一致'
} else
data = {
card: {
iccid,
card_id: 42,
carrier_id: 8,
enable_polling: true,
asset_status: 2,
card_category: 'normal',
card_condition: 'activated',
package_state: 'active',
package_state_name: '有生效套餐'
},
matched:
req.method === 'POST' && input.overrides?.length
? input.overrides
.filter((item) => !item.disabled)
.map((item) => ({
id: item.id,
config_name: item.config_name,
priority: item.priority,
draft: true
}))
: [{ id: 1, config_name: configs[0].config_name, priority: 1, draft: false }],
intervals: [
{
config_id: 1,
config_name: configs[0].config_name,
inherited: false,
interval: 300,
mode: 'interval',
task_type: 'polling:carddata',
task_type_name: '流量'
},
{
config_id: 1,
config_name: configs[0].config_name,
inherited: true,
interval: 0,
mode: 'disabled',
task_type: 'polling:card_status',
task_type_name: '卡状态'
},
{
config_id: 0,
config_name: '',
inherited: false,
interval: null,
mode: 'unmatched',
task_type: 'polling:realname',
task_type_name: '实名'
}
]
}
} else if (path === '/api/admin/polling-configs/priorities') {
if (!admin) {
code = 403
msg = '仅管理员可操作'
} else {
input.ordered_ids.forEach((id, index) => {
const row = configs.find((item) => item.id === id)
if (row) row.priority = index + 1
})
data = null
}
} else if (path === '/api/admin/polling-configs/recalculate-due' && req.method === 'POST') {
if (!admin) {
code = 403
msg = '仅管理员可操作'
} else {
const id = `local-task-${tasks.size + 1}`
data = {
task_id: id,
status: 'pending',
carrier_id: input.carrier_id == null ? '' : String(input.carrier_id),
total: 100,
processed: 0,
advanced: 0,
error: '',
created_at: time,
started_at: '',
finished_at: ''
}
tasks.set(id, { data, polls: 0 })
}
} else if (path.startsWith('/api/admin/polling-configs/recalculate-due/')) {
const id = decodeURIComponent(
path.slice('/api/admin/polling-configs/recalculate-due/'.length)
)
if (!tasks.has(id))
tasks.set(id, {
data: {
task_id: id,
carrier_id: '8',
total: 100,
processed: 0,
advanced: 0,
error: '',
created_at: time,
started_at: '',
finished_at: ''
},
polls: 0
})
const entry = tasks.get(id)
entry.polls++
data = {
...entry.data,
status: entry.polls > 2 ? 'completed' : 'running',
processed: Math.min(100, entry.polls * 40),
advanced: entry.polls * 10,
started_at: time,
finished_at: entry.polls > 2 ? time : ''
}
} else if (path === '/api/admin/polling-stats/pre-reset') {
data = {
carriers: [
{
carrier_id: 8,
carrier_name: '模拟运营商账户',
previous: {
active_package_cards: 100,
checked_in_window: 80,
not_checked_in_window: 20,
reset_at: time,
last_offset_buckets: { le_5: 0, le_15: 20, le_30: 15, le_60: 25, gt_60: 10 }
},
upcoming: null
}
]
}
} else if (path === '/api/admin/polling-stats/tiers') {
data = {
by_config: [
{ config_id: 1, config_name: configs[0].config_name, cards: 95 },
{ config_id: 0, config_name: '', cards: 5 }
],
by_package_state: { active: 80, depleted: 10, pending: 5, none: 3, unknown: 2 },
computed_at: time,
total_cards: 100,
unmatched_in_stock: 2,
unmatched_sold_no_package: 3
}
} else if (path === '/api/admin/polling-configs' && req.method === 'GET') {
const query = url.searchParams.get('config_name') || ''
data = page(
configs.filter((item) => item.config_name.includes(query)),
url
)
} else if (path === '/api/admin/polling-configs' && req.method === 'POST') {
data = {
...input,
id: configs.length + 1,
status: 1,
created_at: time,
updated_at: time,
warnings: ['本地模拟:保存提示']
}
configs.push(data)
} else if (/^\/api\/admin\/polling-configs\/\d+$/.test(path) && req.method === 'PUT') {
const row = configs.find((item) => item.id === Number(path.split('/').at(-1)))
Object.assign(row, input)
for (const key of input.clear_fields || []) row[key] = null
delete row.clear_fields
data = { ...row, warnings: ['本地模拟:保存提示'] }
} else if (/^\/api\/admin\/polling-configs\/\d+$/.test(path) && req.method === 'GET')
data = configs.find((item) => item.id === Number(path.split('/').at(-1)))
else {
code = 400
msg = '此本地验收接口未定义,已拦截写请求'
}
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ code, msg, data }))
})
gatewayFixtures.configureServer(server)
}
}

View File

@@ -0,0 +1,737 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import { compileScript, parse } from '@vue/compiler-sfc'
const source = (path) => readFileSync(path, 'utf8')
function evaluate(text, imports = {}, globals = {}) {
const compiled = ts.transpileModule(text, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(
(name) => {
assert.ok(imports[name], `Unexpected dependency: ${name}`)
return imports[name]
},
module,
module.exports,
...Object.values(globals)
)
return module.exports
}
const format = evaluate(source('src/utils/business/format.ts'))
const gateway = evaluate(source('src/utils/business/gateway.ts'), { './format': format })
const constants = evaluate(source('src/config/constants/pollingConfig.ts'))
const helpers = evaluate(source('src/utils/business/pollingConfig.ts'), {
'@/config/constants/pollingConfig': constants,
'./gateway': gateway
})
const perms = constants.POLLING_CONFIG_PERMISSIONS
const ok = (data) => ({ code: 0, data, msg: 'ok' })
const page = (items) => ok({ items, page: 1, page_size: 20, total: items.length })
const draft = () => ({ ...helpers.newPollingDraft(), config_name: '测试草稿' })
const task = (id = 'task-1', status = 'running') => ({
task_id: id,
status,
total: 100,
processed: 20,
advanced: 10,
carrier_id: '8',
error: '',
created_at: '2026-10-26T10:00:00+08:00',
started_at: '',
finished_at: ''
})
const preview = {
card: { iccid: 'CARD', asset_status: 2, package_state: 'active' },
matched: [],
intervals: [{ task_type: 'polling:carddata', mode: 'disabled', interval: 0 }]
}
const preReset = {
carriers: [
{
carrier_id: 8,
carrier_name: '账户',
previous: {
active_package_cards: 0,
checked_in_window: 0,
not_checked_in_window: 0,
reset_at: '2026-10-26T00:00:00+08:00',
last_offset_buckets: { le_5: 0, le_15: 0, le_30: 0, le_60: 0, gt_60: 0 }
},
upcoming: null
}
]
}
const tiers = {
total_cards: 0,
by_config: [{ config_id: 0, config_name: '', cards: 0 }],
by_package_state: { active: 0, unknown: 0 },
computed_at: '2026-10-26T00:00:00Z',
unmatched_in_stock: 0,
unmatched_sold_no_package: 0
}
function deferred() {
let resolve
let reject
const promise = new Promise((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
async function flush() {
await new Promise((done) => setImmediate(done))
await vue.nextTick()
}
test('all seven new API methods use exact URLs, methods, query and body contracts', async () => {
const calls = []
const response = ok(preview)
const http = Object.fromEntries(
['get', 'post', 'put', 'del'].map((method) => [
method,
(args) => {
calls.push({ method, ...args })
return Promise.resolve(response)
}
])
)
const base = evaluate(source('src/api/BaseService.ts'), { '@/utils/http': { default: http } })
const { PollingConfigService: service } = evaluate(source('src/api/modules/pollingConfig.ts'), {
'../BaseService': base
})
const body = { iccid: 'CARD/1', overrides: [draft()] }
const specs = [
[
'previewMatch',
['CARD/1'],
{
method: 'get',
url: '/api/admin/polling-configs/match-preview',
params: { iccid: 'CARD/1' }
}
],
[
'previewDraft',
[body],
{
method: 'post',
url: '/api/admin/polling-configs/match-preview',
params: { iccid: body.iccid },
data: body
}
],
[
'updatePriorities',
[[12, 3, 8]],
{
method: 'put',
url: '/api/admin/polling-configs/priorities',
data: { ordered_ids: [12, 3, 8] }
}
],
[
'recalculateDue',
[null],
{
method: 'post',
url: '/api/admin/polling-configs/recalculate-due',
data: { carrier_id: null }
}
],
[
'getRecalculateTask',
['task/a b'],
{
method: 'get',
url: '/api/admin/polling-configs/recalculate-due/task%2Fa%20b',
params: undefined
}
],
[
'getPreResetStats',
[],
{ method: 'get', url: '/api/admin/polling-stats/pre-reset', params: undefined }
],
[
'getTierStats',
[],
{ method: 'get', url: '/api/admin/polling-stats/tiers', params: undefined }
]
]
for (const [name, args, expected] of specs) {
assert.equal(await service[name](...args), response)
assert.deepEqual(calls.at(-1), expected)
}
})
test('drafts preserve null inheritance, zero disable, numeric intervals and body whitelist', () => {
const item = {
...draft(),
carrier_id: 0,
realname_check_interval: null,
carddata_check_interval: 0,
package_check_interval: 60,
status: 0,
key: 'ui',
created_at: 'unexpected'
}
const [body] = helpers.pollingDraftBody([item])
assert.equal(body.carrier_id, 0)
assert.equal(body.realname_check_interval, null)
assert.equal(body.carddata_check_interval, 0)
assert.equal(body.package_check_interval, 60)
for (const key of ['status', 'key', 'created_at']) assert.equal(key in body, false)
assert.equal(item.status, 0)
assert.equal(helpers.pollingDraftBody([draft(), draft()]).length, 2)
assert.throws(() =>
helpers.pollingDraftBody([
{ ...draft(), id: 1 },
{ ...draft(), id: 1 }
])
)
})
test('draft validation enforces documented interval minima, priority and paired windows', () => {
for (const patch of [
{ config_name: ' ' },
{ config_name: 'a'.repeat(101) },
{ description: 'x'.repeat(501) },
{ priority: 0 },
{ priority: 1001 },
{ priority: 1.2 },
{ asset_status: 5 },
{ carrier_id: -1 },
{ realname_check_interval: 29 },
{ carddata_check_interval: 59 },
{ package_check_interval: -1 },
{ card_status_check_interval: 0.1 },
{ pre_reset_window: 60 },
{ pre_reset_window: 59, pre_reset_carddata_interval: 60 }
])
assert.throws(() => helpers.pollingDraftBody([{ ...draft(), ...patch }]))
assert.doesNotThrow(() =>
helpers.pollingDraftBody([
{
...draft(),
protect_check_interval: 30,
pre_reset_window: 60,
pre_reset_carddata_interval: 60
}
])
)
assert.equal(helpers.pollingIccid(' CARD '), 'CARD')
assert.throws(() => helpers.pollingIccid(''))
assert.throws(() => helpers.pollingIccid('x'.repeat(31)))
for (const ids of [[], [1, 1], [-1], [0], [1.5]])
assert.throws(() => helpers.validatePollingOrder(ids))
assert.doesNotThrow(() => helpers.validatePollingOrder([3, 2, 1]))
})
function mount(
t,
path,
{ superAdmin = false, permissions = [], overrides = {}, props = {}, globals = {} } = {}
) {
const calls = []
const events = []
const user = vue.reactive({ isSuperAdmin: superAdmin, permissions })
const config = {
id: 1,
config_name: '默认配置',
priority: 1,
status: 1,
carrier_id: 8,
realname_check_interval: null
}
const defaults = {
getPollingConfigs: () => page([config]),
getPollingConfigById: () => ok(config),
previewMatch: () => ok(preview),
previewDraft: () => ok(preview),
updatePriorities: () => undefined,
recalculateDue: () => ok(task()),
getRecalculateTask: (id) => ok(task(id)),
getPreResetStats: () => ok(preReset),
getTierStats: () => ok(tiers)
}
const service = Object.fromEntries(
Object.entries(defaults).map(([name, handler]) => [
name,
async (...args) => {
calls.push({ name, args })
return (overrides[name] || handler)(...args)
}
])
)
const hasAuth = (value) => user.isSuperAdmin || user.permissions.includes(value)
const imports = {
vue,
'element-plus': {
ElMessageBox: { confirm: overrides.confirm || (async () => {}) },
ElMessage: { success() {} }
},
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) },
'@/api/modules/pollingConfig': { PollingConfigService: service },
'@/store/modules/user': { useUserStore: () => user },
'@/composables/useAuth': { useAuth: () => ({ hasAuth }) },
'@/config/constants/pollingConfig': constants,
'@/utils/business/pollingConfig': helpers,
'@/utils/business/gateway': gateway,
'@/utils/business/format': format,
'@/composables/useGateway': {
useGatewayCarriers: () => ({
options: vue.ref([]),
loading: vue.ref(false),
error: vue.ref(''),
search() {},
select() {},
label: String
}),
useGatewayColumns: (definitions) => ({
columns: vue.computed(() => definitions),
columnChecks: vue.ref(definitions)
})
},
'./PollingDraftFields.vue': { default: {} }
}
imports['@/composables/usePollingConfigExtensions'] = evaluate(
source('src/composables/usePollingConfigExtensions.ts'),
imports,
globals
)
let component
if (path === 'progress')
component = {
setup: () =>
imports['@/composables/usePollingConfigExtensions'].usePollingRecalculateProgress(() =>
hasAuth(perms.progress)
)
}
else {
const { descriptor } = parse(source(path))
component = evaluate(
compileScript(descriptor, { id: 'polling-config-test' }).content,
imports,
globals
).default
}
let state
const renderer = vue.createRenderer({
createComment: () => ({}),
insert() {},
remove() {},
parentNode: () => null,
nextSibling: () => null
})
const reactiveProps = vue.reactive(props)
const app = renderer.createApp({
setup() {
state = component.setup(reactiveProps, { expose() {}, emit: (...args) => events.push(args) })
return () => null
}
})
app.mount({})
t.after(() => app.unmount())
return { state, calls, events, user, unmount: () => app.unmount() }
}
const previewPath = 'src/views/polling-management/config/components/PollingPreviewDialog.vue'
const priorityPath = 'src/views/polling-management/config/components/PollingPriorityDialog.vue'
const recalculatePath =
'src/views/polling-management/config/components/PollingRecalculateDialog.vue'
const statsPath = 'src/views/polling-management/config-stats/index.vue'
const writes = (calls) =>
calls.filter((call) =>
['updatePriorities', 'recalculateDue', 'createPollingConfig', 'updatePollingConfig'].includes(
call.name
)
)
test('preview permissions and ICCID validation block unauthorized or invalid requests', async (t) => {
const { state, calls, user } = mount(t, previewPath)
await flush()
state.iccid.value = 'CARD'
await state.runPreview()
assert.match(state.error.value, /无权/)
user.permissions.push(perms.preview)
state.iccid.value = 'x'.repeat(31)
await state.runPreview()
assert.match(state.error.value, /ICCID/)
assert.equal(
calls.some((call) => call.name === 'previewMatch'),
false
)
})
test('GET and multi-override POST previews never save configurations', async (t) => {
const { state, calls } = mount(t, previewPath, { permissions: [perms.preview] })
state.iccid.value = 'CARD'
await state.runPreview()
assert.equal(state.result.value.intervals[0].interval, 0)
state.useDraft.value = true
state.addDraft(draft())
state.addDraft({ ...draft(), id: 2, disabled: true })
await state.runPreview()
const body = calls.find((call) => call.name === 'previewDraft').args[0]
assert.equal(body.iccid, 'CARD')
assert.equal(body.overrides.length, 2)
assert.equal(body.overrides[1].disabled, true)
assert.equal(writes(calls).length, 0)
})
test('a stale preview cannot overwrite a changed card or unmounted dialog', async (t) => {
const pending = deferred()
const { state, unmount } = mount(t, previewPath, {
permissions: [perms.preview],
overrides: { previewMatch: () => pending.promise }
})
state.iccid.value = 'OLD'
const running = state.runPreview()
state.iccid.value = 'NEW'
pending.resolve(ok(preview))
await running
assert.equal(state.result.value, null)
unmount()
})
test('ordinary users cannot submit priority updates or start recalculation', async (t) => {
for (const [path, action] of [
[priorityPath, 'save'],
[recalculatePath, 'start']
]) {
const { state, calls } = mount(t, path, { permissions: [perms.progress] })
await flush()
await state[action]()
assert.equal(writes(calls).length, 0)
}
})
test('priority updates load all pages, preserve chosen order, confirm and accept empty success DTO', async (t) => {
const { state, calls, events } = mount(t, priorityPath, {
superAdmin: true,
overrides: {
getPollingConfigs: ({ page: current }) =>
ok({ items: [{ id: current, config_name: `配置${current}`, priority: current }], total: 2 })
}
})
await flush()
assert.equal(state.rows.value.length, 2)
state.move(1, -1)
await state.save()
assert.deepEqual(calls.find((call) => call.name === 'updatePriorities').args[0], [2, 1])
assert.ok(events.some((event) => event[0] === 'saved'))
})
test('priority cancellation or role revocation after confirmation sends no write', async (t) => {
for (const cancel of [true, false]) {
const confirmation = deferred()
const { state, calls, user } = mount(t, priorityPath, {
superAdmin: true,
overrides: { confirm: () => confirmation.promise }
})
await flush()
const saving = state.save()
if (cancel) confirmation.reject('cancel')
else {
user.isSuperAdmin = false
confirmation.resolve('ok')
}
await saving
assert.equal(writes(calls).length, 0)
}
})
test('recalculation uses numeric/null carrier IDs and only reports actual task status', async (t) => {
const { state, calls } = mount(t, recalculatePath, { superAdmin: true })
await state.start()
assert.equal(calls.find((call) => call.name === 'recalculateDue').args[0], null)
assert.equal(state.task.value.status, 'running')
state.allCarriers.value = false
state.carrierId.value = 8
await state.start()
assert.equal(calls.filter((call) => call.name === 'recalculateDue').at(-1).args[0], 8)
})
function fakeTimers() {
let key = 0
const timers = new Map()
return {
timers,
globals: {
setTimeout: (fn) => {
timers.set(++key, fn)
return key
},
clearTimeout: (id) => timers.delete(id)
},
async tick() {
const next = timers.entries().next().value
if (next) {
timers.delete(next[0])
next[1]()
await flush()
}
}
}
}
test('progress polling stops at terminal state and cleanup removes pending timers', async (t) => {
const clock = fakeTimers()
let count = 0
const { state, unmount } = mount(t, 'progress', {
permissions: [perms.progress],
globals: clock.globals,
overrides: { getRecalculateTask: (id) => ok(task(id, ++count === 1 ? 'running' : 'completed')) }
})
state.taskId.value = 'task-1'
state.autoRefresh.value = true
await state.query()
assert.equal(clock.timers.size, 1)
await clock.tick()
assert.equal(state.task.value.status, 'completed')
assert.equal(clock.timers.size, 0)
state.adopt(task())
assert.equal(clock.timers.size, 1)
unmount()
assert.equal(clock.timers.size, 0)
})
test('changing task ID, closing, errors or loss of permission discard old progress', async (t) => {
const clock = fakeTimers()
const pending = deferred()
const { state, user } = mount(t, 'progress', {
permissions: [perms.progress],
globals: clock.globals,
overrides: { getRecalculateTask: () => pending.promise }
})
state.taskId.value = 'OLD'
const request = state.query()
state.taskId.value = 'NEW'
pending.resolve(ok(task('OLD')))
await request
assert.equal(state.task.value, null)
state.adopt(task('NEW'))
state.autoRefresh.value = true
user.permissions = []
await flush()
assert.equal(clock.timers.size, 0)
assert.equal(state.task.value, null)
})
test('statistics retain null periods, zero counts, raw bucket values and unknown package states', async (t) => {
const { state } = mount(t, statsPath, {
permissions: [perms.statsPage, perms.preReset, perms.tiers]
})
await flush()
assert.equal(state.preRows.value.length, 2)
assert.equal(state.preValue(state.preRows.value[0], 'checked_in_window'), 0)
assert.equal(state.preValue(state.preRows.value[0], 'le_5'), 0)
assert.equal(state.preValue(state.preRows.value[1], 'reset_at'), '无周期数据')
assert.equal(state.preValue(state.preRows.value[1], 'checked_in_window'), '-')
assert.equal(state.tiers.value.by_config[0].config_id, 0)
assert.equal(state.packageRows.value.find((row) => row.state === 'unknown').cards, 0)
})
test('statistics page and separate area permissions are both required', async (t) => {
for (const permissions of [[], [perms.preReset, perms.tiers], [perms.statsPage]]) {
const { state, calls } = mount(t, statsPath, { permissions })
await state.loadPreReset()
await state.loadTiers()
assert.equal(
calls.filter((call) => ['getPreResetStats', 'getTierStats'].includes(call.name)).length,
0
)
}
})
test('new fields persist through whitelisted CRUD and menu/button contracts use distinct codes', () => {
const pageSource = source('src/views/polling-management/config/index.vue')
const body = helpers.pollingSaveBody(
{
...draft(),
asset_status: 2,
package_state: 'active',
pre_reset_window: 60,
pre_reset_carddata_interval: 60
},
false
)
for (const field of [
'asset_status',
'package_state',
'pre_reset_window',
'pre_reset_carddata_interval'
])
assert.ok(Object.hasOwn(body, field))
assert.match(pageSource, /pollingSaveBody\(formData/)
assert.match(pageSource, /:show-expand="searchFormItems.length >= 4"/)
assert.match(
source('src/router/routesAlias.ts'),
/PollingConfigStats = '\/polling-management\/config-stats'/
)
assert.equal(new Set(Object.values(perms)).size, 5)
for (const code of Object.values(perms)) assert.match(code, /^[a-z_]+:[a-z_]+$/)
})
test('interval field UI emits distinct null, zero and positive values', async (t) => {
const { descriptor } = parse(
source('src/views/polling-management/config/components/PollingIntervalField.vue')
)
const definition = evaluate(compileScript(descriptor, { id: 'polling-interval-test' }).content, {
vue
}).default
const value = vue.ref(null)
let state
const component = {
...definition,
setup(props, context) {
state = definition.setup(props, context)
return () => null
}
}
const renderer = vue.createRenderer({
createComment: () => ({}),
insert() {},
remove() {},
parentNode: () => null,
nextSibling: () => null
})
const app = renderer.createApp({
render: () =>
vue.h(component, {
min: 30,
modelValue: value.value,
'onUpdate:modelValue': (next) => {
value.value = next
}
})
})
app.mount({})
t.after(() => app.unmount())
assert.equal(state.mode.value, 'inherit')
state.mode.value = 'disabled'
await vue.nextTick()
assert.equal(value.value, 0)
state.mode.value = 'interval'
await vue.nextTick()
assert.equal(value.value, 30)
state.mode.value = 'inherit'
await vue.nextTick()
assert.equal(value.value, null)
})
test('progress errors stop automatic polling and do not create a new task', async (t) => {
const clock = fakeTimers()
const { state, calls } = mount(t, 'progress', {
permissions: [perms.progress],
globals: clock.globals,
overrides: {
getRecalculateTask: () => {
throw new Error('任务查询失败')
}
}
})
state.taskId.value = 'task-1'
state.autoRefresh.value = true
await state.query()
assert.match(state.error.value, /任务查询失败/)
assert.equal(clock.timers.size, 0)
assert.equal(writes(calls).length, 0)
})
test('partial or duplicate configuration pages cannot be submitted as a complete priority order', async (t) => {
const { state, calls } = mount(t, priorityPath, {
superAdmin: true,
overrides: {
getPollingConfigs: () =>
ok({ items: [{ id: 1, config_name: '重复配置', priority: 1 }], total: 2 })
}
})
await flush()
assert.equal(state.loaded.value, false)
assert.match(state.error.value, /不完整或存在重复/)
await state.save()
assert.equal(writes(calls).length, 0)
})
test('pre-reset draft controls require an explicit enable and preserve null when disabled', async (t) => {
const { descriptor } = parse(
source('src/views/polling-management/config/components/PollingDraftFields.vue')
)
const definition = evaluate(compileScript(descriptor, { id: 'polling-window-test' }).content, {
vue,
'@/config/constants/pollingConfig': constants,
'@/composables/useGateway': {
useGatewayCarriers: () => ({
options: vue.ref([]),
loading: vue.ref(false),
error: vue.ref(''),
search() {},
remember() {},
select() {}
})
},
'./PollingIntervalField.vue': { default: {} }
}).default
const value = vue.ref(draft())
let state
const component = {
...definition,
setup(props, context) {
state = definition.setup(props, context)
return () => null
}
}
const renderer = vue.createRenderer({
createComment: () => ({}),
insert() {},
remove() {},
parentNode: () => null,
nextSibling: () => null
})
const app = renderer.createApp({
render: () =>
vue.h(component, {
modelValue: value.value,
'onUpdate:modelValue': (next) => {
value.value = next
}
})
})
app.mount({})
t.after(() => app.unmount())
assert.equal(state.preResetEnabled.value, false)
assert.equal(value.value.pre_reset_window, null)
state.preResetEnabled.value = true
await vue.nextTick()
assert.equal(value.value.pre_reset_window, 60)
assert.equal(value.value.pre_reset_carddata_interval, 60)
state.preResetEnabled.value = false
await vue.nextTick()
assert.equal(value.value.pre_reset_window, null)
assert.equal(value.value.pre_reset_carddata_interval, null)
})
test('closing a preview or progress query while pending discards its response', async (t) => {
for (const kind of ['preview', 'progress']) {
const pending = deferred()
const { state, unmount } = mount(t, kind === 'preview' ? previewPath : 'progress', {
permissions: [perms.preview, perms.progress],
overrides:
kind === 'preview'
? { previewMatch: () => pending.promise }
: { getRecalculateTask: () => pending.promise }
})
if (kind === 'preview') state.iccid.value = 'CARD'
else state.taskId.value = 'task-1'
const query = kind === 'preview' ? state.runPreview() : state.query()
unmount()
pending.resolve(ok(kind === 'preview' ? preview : task()))
await query
assert.equal(kind === 'preview' ? state.result.value : state.task.value, null)
}
})

View File

@@ -0,0 +1,747 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
const source = (path) => readFileSync(path, 'utf8')
function evaluate(text, imports = {}, globals = {}) {
const compiled = ts.transpileModule(text, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(
(name) => {
assert.ok(imports[name], `Unexpected dependency: ${name}`)
return imports[name]
},
module,
module.exports,
...Object.values(globals)
)
return module.exports
}
const gateway = evaluate(source('src/utils/business/gateway.ts'), {
'./format': evaluate(source('src/utils/business/format.ts'))
})
const helpers = evaluate(source('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
const constants = evaluate(source('src/config/constants/pollingScheduler.ts'))
const configHelpers = evaluate(source('src/utils/business/pollingConfig.ts'), {
'./gateway': gateway,
'@/config/constants/pollingConfig': evaluate(source('src/config/constants/pollingConfig.ts'))
})
const ok = (data) => ({ code: 0, data })
const flush = async () => {
await new Promise((done) => setImmediate(done))
await vue.nextTick()
}
const batchPath = 'src/components/business/polling/PollingBatchDialog.vue'
test('gateway metrics flatten into row arrays without old raw-data attribute overriding the table', () => {
const path = 'src/components/business/gateway/GatewayOperationTable.vue'
const { descriptor } = parse(source(path))
const definition = evaluate(compileScript(descriptor, { id: 'metric-table' }).content, {
vue,
'@/components/business/polling/SchedulerTable.vue': { default: {} },
'@/utils/business/gateway': gateway
}).default
const props = {
values: [
{
carrier_id: 8,
carrier_name: 'carrier',
operations: [
{ operation: 'stop', operation_name: 'stop', success_rate: 0.8, p95_duration_ms: -1 }
]
}
]
}
const state = definition.setup(props, { expose() {}, emit() {} })
assert.equal(state.rows.value.length, 1)
assert.equal(state.format(state.rows.value[0], 'success_rate'), '80.00%')
assert.equal(state.format(state.rows.value[0], 'p95_duration_ms'), '超过最大分桶')
assert.equal(definition.inheritAttrs, false)
assert.doesNotMatch(
source('src/views/polling-management/gateway-stats/index.vue'),
/<GatewayOperationTable[^>]*:data=/s
)
})
test('every one of the 46 documented added HTTP operations has a service method', async () => {
const calls = []
class BaseService {}
for (const [method, verb] of [
['get', 'GET'],
['post', 'POST'],
['put', 'PUT'],
['delete', 'DELETE']
])
BaseService[method] = (url) => {
calls.push(`${verb} ${url}`)
return ok({})
}
for (const [file, name] of [
['gateway', 'GatewayService'],
['pollingConfig', 'PollingConfigService'],
['pollingScheduler', 'PollingSchedulerService']
]) {
const service = evaluate(source(`src/api/modules/${file}.ts`), {
'../BaseService': { BaseService }
})[name]
for (const key of Object.getOwnPropertyNames(service))
if (typeof service[key] === 'function' && !['length', 'name', 'prototype'].includes(key))
await service[key](1, {}, '')
}
const normalize = (value) =>
value
.replace('/api/admin', '')
.replace(/\{[^}]+\}/g, '{}')
.replace(/\/1(?=\/|$)/g, '/{}')
// 原始后端文档在本仓库被忽略,固定路径快照保证全新检出也可运行;本地有原文时额外校对。
const snapshot = JSON.parse(source('scripts/tests/fixtures/pollingSchedulerEndpoints.json'))
const document = 'docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md'
if (existsSync(document))
assert.deepEqual(
[...source(document).matchAll(/^#### `(GET|POST|PUT|DELETE) ([^`]+)`/gm)].map(
(match) => `${match[1]} ${match[2]}`
),
snapshot
)
const expected = snapshot.map(normalize)
assert.equal(expected.length, 46)
const actual = new Set(calls.map(normalize))
for (const endpoint of expected) assert.ok(actual.has(endpoint), endpoint)
})
test('scheduler request rejects stale responses after reload, permission change, scope switch and unmount', async () => {
const user = vue.reactive({ info: { id: 1, user_type: 2 }, isLogin: true }),
permission = vue.ref(true),
stops = [],
deactivations = [],
activations = []
const runtime = {
...vue,
onBeforeUnmount: (fn) => stops.push(fn),
onDeactivated: (fn) => deactivations.push(fn),
onActivated: (fn) => activations.push(fn)
}
const { useSchedulerRequest } = evaluate(source('src/composables/useSchedulerRequest.ts'), {
vue: runtime,
'@/store/modules/user': { useUserStore: () => user },
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': helpers
})
const pending = []
const scope = vue.effectScope()
const state = scope.run(() =>
useSchedulerRequest(
() => new Promise((resolve) => pending.push(resolve)),
() => permission.value
)
)
const a = state.load(),
b = state.load()
pending[1]('new')
await b
pending[0]('old')
await a
assert.equal(state.data.value, 'new')
const c = state.load()
user.info.id = 2
pending[2]('old account')
await c
assert.equal(state.data.value, null)
permission.value = false
await state.load()
assert.equal(pending.length, 3)
permission.value = true
const d = state.load()
stops.forEach((fn) => fn())
pending[3]('unmounted')
await d
assert.equal(state.data.value, null)
scope.stop()
})
function mount(
path,
{
userType = 2,
permissions = ['test'],
props = {},
services = {},
storage = {},
confirm = async () => 'confirm'
} = {}
) {
const user = vue.reactive({ info: { id: 1, user_type: userType }, isLogin: true })
const events = [],
calls = [],
beforeUnmount = [],
deactivated = []
const api = new Proxy(services, {
get(target, key) {
return async (...args) => {
calls.push({ name: key, args })
return key in target
? target[key](...args)
: ok({ enable: ['Enable impact from server'], disable: ['Disable impact from server'] })
}
}
})
const runtime = {
...vue,
onMounted: () => {},
onBeforeUnmount: (fn) => beforeUnmount.push(fn),
onDeactivated: (fn) => deactivated.push(fn),
onActivated: () => {}
}
const { descriptor } = parse(source(path))
const script = compileScript(descriptor, { id: 'scheduler-test' }).content
const store = { useUserStore: () => user }
const schedulerRequest = evaluate(source('src/composables/useSchedulerRequest.ts'), {
vue: runtime,
'@/store/modules/user': store,
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': helpers
})
const imports = {
vue: runtime,
'vue-router': {
useRoute: () => ({ params: { id: '1', carrier_id: '8' }, fullPath: '/local/detail/1' }),
useRouter: () => ({ back() {} })
},
'element-plus': { ElMessageBox: { confirm }, ElMessage: { success() {} } },
'@/api/modules/pollingScheduler': { PollingSchedulerService: api },
'@/api/modules/storage': { StorageService: storage },
'@/store/modules/user': store,
'@/composables/useAuth': {
useAuth: () => ({ hasAuth: (permission) => permissions.includes(permission) })
},
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': helpers,
'@/config/constants/pollingScheduler': constants,
'@/config/constants/gateway': evaluate(source('src/config/constants/gateway.ts')),
'@/composables/useSchedulerRequest': schedulerRequest,
'@/composables/useGateway': {
useGatewayCarriers: () => ({ options: vue.ref([]), search() {}, loading: vue.ref(false) })
},
'@/components/business/polling/SchedulerTable.vue': { default: {} },
'@/components/business/gateway/StopResumeRecords.vue': { default: {} },
'./PollingStateDialog.vue': { default: {} },
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }
}
const scope = vue.effectScope()
const state = scope.run(() =>
evaluate(script, imports, runtime).default.setup(props, {
expose() {},
emit: (...args) => events.push(args)
})
)
return {
state,
calls,
user,
events,
dispose: () => {
beforeUnmount.forEach((fn) => fn())
scope.stop()
},
deactivate: () => deactivated.forEach((fn) => fn())
}
}
const dryRunPath = 'src/views/polling-management/stop-resume-dry-run/index.vue'
const schedulerDetailPath = 'src/components/business/polling/SchedulerDetail.vue'
const dryRunResult = {
items: [{ card_id: 42, iccid: '89860426012345678901' }],
total: 1,
page: 3,
size: 20,
summary: [{ carrier_id: 8, count: 1 }],
totals: { total: 1, will_stop: 1, will_resume: 0, no_action: 0 }
}
test('dry-run reset clears filters, old summary/details and errors without starting an unfiltered scan', async () => {
const h = mount(dryRunPath, {
permissions: [constants.SCHEDULER_PERMISSIONS.dryRun.page],
services: { dryRun: () => ok(dryRunResult) }
})
Object.assign(h.state.filters, {
carrier_id: 8,
stop_reason: 'manual',
result: 'will_stop',
assume_control_on: [8],
assume_control_off: [9]
})
h.state.page.value = 3
await h.state.load()
assert.equal(h.state.data.value.totals.total, 1)
assert.equal(h.state.data.value.summary.length, 1)
assert.equal(h.state.data.value.items.length, 1)
h.state.error.value = 'old export error'
h.state.reset()
assert.deepEqual(
{ ...h.state.filters },
{
carrier_id: undefined,
stop_reason: '',
result: '',
assume_control_on: [],
assume_control_off: []
}
)
assert.equal(h.state.page.value, 1)
assert.equal(h.state.data.value, null)
assert.equal(h.state.error.value, '')
assert.equal(h.state.loading.value, false)
assert.equal(h.calls.length, 1)
h.dispose()
})
test('dry-run reset invalidates pending success/error and allows a fresh explicit query', async () => {
for (const fail of [false, true]) {
let resolve, reject
const pendingResponse = new Promise((done, failed) => {
resolve = done
reject = failed
})
let requests = 0
const h = mount(dryRunPath, {
permissions: [constants.SCHEDULER_PERMISSIONS.dryRun.page],
services: { dryRun: () => (++requests === 1 ? pendingResponse : ok(dryRunResult)) }
})
h.state.filters.stop_reason = 'manual'
const pending = h.state.load()
assert.equal(h.state.loading.value, true)
h.state.reset()
if (fail) reject(new Error('stale request failed'))
else resolve(ok(dryRunResult))
await pending
assert.equal(h.state.data.value, null)
assert.equal(h.state.error.value, '')
assert.equal(h.state.loading.value, false)
assert.equal(requests, 1)
h.state.filters.carrier_id = 9
await h.state.load()
assert.equal(h.state.data.value.totals.total, 1)
assert.equal(h.calls.at(-1).args[0].carrier_id, 9)
assert.equal(h.calls.at(-1).args[0].stop_reason, undefined)
h.dispose()
}
})
for (const [kind, method] of [
['params', 'getCarrierParam'],
['alerts', 'getAlert'],
['imports', 'getImport']
])
test(`${kind} direct detail requires both page and detail permissions and retains the role boundary`, async () => {
const P = constants.SCHEDULER_PERMISSIONS[kind]
for (const permissions of [
[],
[P.page],
[P.detailPage],
[P.detail],
[P.detailPage, P.detail]
]) {
const allowed = permissions.includes(P.detailPage) && permissions.includes(P.detail)
const h = mount(schedulerDetailPath, {
props: { kind },
permissions,
services: { [method]: () => ok({ id: 1, carrier_id: 8, card_id: 0, status: 3 }) }
})
await h.state.load()
assert.equal(h.calls.length, allowed ? 1 : 0, permissions.join(','))
assert.equal(!!h.state.data.value, allowed)
h.dispose()
}
for (const userType of [1, 3, 4]) {
const allowed = kind !== 'imports' || userType === 1
const h = mount(schedulerDetailPath, {
props: { kind },
userType,
permissions: [P.detailPage, P.detail],
services: { [method]: () => ok({ id: 1, status: 3 }) }
})
await h.state.load()
assert.equal(h.calls.length, allowed ? 1 : 0)
assert.equal(!!h.state.data.value, allowed)
h.dispose()
}
})
test('revoking either detail permission clears displayed data and blocks subsequent refresh', async () => {
for (const kind of ['params', 'alerts', 'imports']) {
const P = constants.SCHEDULER_PERMISSIONS[kind]
for (const revoked of [P.detailPage, P.detail]) {
const permissions = vue.reactive([P.detailPage, P.detail])
const h = mount(schedulerDetailPath, { props: { kind }, permissions })
await h.state.load()
assert.ok(h.state.data.value)
permissions.splice(permissions.indexOf(revoked), 1)
assert.equal(h.state.data.value, null)
await h.state.load()
assert.equal(h.calls.length, 1)
h.dispose()
}
}
})
test('pending detail responses cannot appear after the detail permission is revoked', async () => {
for (const [kind, method] of [
['params', 'getCarrierParam'],
['alerts', 'getAlert'],
['imports', 'getImport']
]) {
const P = constants.SCHEDULER_PERMISSIONS[kind]
const permissions = vue.reactive([P.detailPage, P.detail])
let resolve
const response = new Promise((done) => {
resolve = done
})
const h = mount(schedulerDetailPath, {
props: { kind },
permissions,
services: { [method]: () => response }
})
const pending = h.state.load()
permissions.splice(permissions.indexOf(P.detail), 1)
resolve(ok({ id: 1, status: 3 }))
await pending
assert.equal(h.state.data.value, null)
assert.equal(h.state.loading.value, false)
assert.equal(h.calls.length, 1)
h.dispose()
}
})
test('22 scheduler methods preserve exact HTTP paths, bodies, null and false', async () => {
const calls = []
class BaseService {}
for (const method of ['get', 'put', 'post', 'delete'])
BaseService[method] = (url, data) => {
calls.push({ method, url, data })
return ok(data)
}
const { PollingSchedulerService: service } = evaluate(
source('src/api/modules/pollingScheduler.ts'),
{ '../BaseService': { BaseService } }
)
const toggle = { asset_ids: [1, 2], enable_polling: false, reason: 'test' },
input = { inflight_limit: null },
query = { page: 1, page_size: 20, status: 1 }
const specs = [
['getCarrierParams', [], 'get', '/polling-carrier-params'],
['getCarrierParam', [8], 'get', '/polling-carrier-params/8'],
['saveCarrierParam', [8, input], 'put', '/polling-carrier-params/8', input],
['resetCarrierParam', [8], 'delete', '/polling-carrier-params/8'],
['getScenes', [], 'get', '/polling-check-scenes'],
['saveScene', ['scene/a', input], 'put', '/polling-check-scenes/scene%2Fa', input],
['getAlerts', [query], 'get', '/polling-failure-alerts', query],
['getAlertSummary', [], 'get', '/polling-failure-alerts/summary'],
['getAlert', [1], 'get', '/polling-failure-alerts/1'],
[
'handleAlert',
[1, 'note'],
'put',
'/polling-failure-alerts/1/status',
{ status: 4, remark: 'note' }
],
[
'closeAlertPolling',
[[1, 2], 'note'],
'post',
'/polling-failure-alerts/close-polling',
{ alert_ids: [1, 2], reason: 'note' }
],
['getQueues', [], 'get', '/polling-stats/carrier-queues'],
['getCardState', [42], 'get', '/iot-cards/42/polling-state'],
['getImpact', [], 'get', '/polling-status/impact'],
['toggleCards', [toggle], 'post', '/iot-cards/batch-update-polling-status', toggle],
['toggleDevices', [toggle], 'post', '/devices/batch-update-polling-status', toggle],
['getPauses', [query], 'get', '/card-polling-pauses', query],
['getImports', [query], 'get', '/polling-status-imports', query],
['getImport', [3], 'get', '/polling-status-imports/3'],
['createImport', [toggle], 'post', '/polling-status-imports', toggle],
['dryRun', [query], 'get', '/stop-resume-rule/dry-run', query],
[
'exportDryRun',
[{ carrier_id: 8, format: 'csv' }],
'post',
'/stop-resume-rule/dry-run/export',
{ carrier_id: 8, format: 'csv' }
]
]
for (const [name, args, method, path, data] of specs) {
await service[name](...args)
assert.deepEqual(calls.at(-1), { method, url: `/api/admin${path}`, data })
}
assert.equal(specs.length, 22)
})
test('role boundaries and unique 1..500 ids are enforced', () => {
for (const type of [1, 2, 3, 4, 0, undefined]) {
assert.equal(helpers.isPollingPlatform(type), [1, 2].includes(type))
assert.equal(helpers.canPollingBatch(type), [1, 2, 3].includes(type))
}
for (const ids of [[], [1, 1], [0], [-1], [1.5], Array.from({ length: 501 }, (_, i) => i + 1)])
assert.throws(() => helpers.validatePollingIds(ids))
assert.doesNotThrow(() =>
helpers.validatePollingIds(Array.from({ length: 500 }, (_, i) => i + 1))
)
assert.throws(() => helpers.pollingReason(' '))
assert.equal(helpers.pollingReason(' reason '), 'reason')
})
test('special alert view only sends documented query filters; zero status is preserved', () => {
const query = {
page: 1,
page_size: 20,
carrier_id: 8,
status: 0,
view: 'stop_refused',
check_type: 'stop_resume',
error_category: 'terminal',
alert_level: 'card',
iccid: 'x'
}
assert.deepEqual(helpers.failureQuery(query), {
page: 1,
page_size: 20,
carrier_id: 8,
status: 0,
iccid: 'x',
view: 'stop_refused'
})
assert.equal(helpers.failureQuery({ ...query, view: undefined }).check_type, 'stop_resume')
assert.deepEqual(
helpers.schedulerTime(['2026-10-26T00:00:00+08:00', '2026-10-27T00:00:00+08:00']),
{ start_time: '2026-10-25T16:00:00Z', end_time: '2026-10-26T16:00:00Z' }
)
assert.throws(() => helpers.schedulerTime(['bad', 'date']))
})
test('parameter, capacity, nullable cooldown and strictly increasing delays validate', () => {
assert.doesNotThrow(() => helpers.validateCarrierParams({ inflight_limit: null }, 0))
assert.throws(() => helpers.validateCarrierParams({ inflight_limit: 11 }, 10))
assert.throws(() => helpers.validateCarrierParams({ priority_max_ratio: 0 }, 10))
for (const delays of [[30, 30], [60, 30], [0]])
assert.throws(() =>
helpers.validateScene({ check_types: ['carddata'], confirm_delays: delays, cooldown: null })
)
assert.doesNotThrow(() =>
helpers.validateScene({ check_types: ['realname'], confirm_delays: [30, 60], cooldown: 0 })
)
assert.throws(() =>
helpers.validateScene({ check_types: ['package'], confirm_delays: [], cooldown: null })
)
assert.throws(() => helpers.validateDryAssumptions([1, 2], [2]))
assert.doesNotThrow(() => helpers.validateDryAssumptions([1], [2]))
})
test('CSV header, quoted newlines, escaped quotes, empty lines, 50000 limit and string ids', () => {
const id = '89860426012345678901'
assert.equal(
helpers.validatePollingCsv(
'file.CSV',
`\uFEFFiccid,note\r\n${id},"line\nline"\r\n\r\n${id},"a""b"`
),
2
)
assert.equal(id, '89860426012345678901')
for (const [name, text] of [
['f.txt', 'h\nx'],
['f.csv', 'header'],
['f.csv', 'h\n"x'],
['f.csv', `h\n${'x\n'.repeat(50001)}`]
])
assert.throws(() => helpers.validatePollingCsv(name, text))
assert.equal(helpers.validatePollingCsv('f.csv', `h\n${'x\n'.repeat(50000)}`), 50000)
})
test('config save persists new fields, removes protection, uses clear_fields without losing zero', () => {
const draft = {
...configHelpers.newPollingDraft(),
config_name: 'x',
asset_status: 2,
package_state: 'active',
carddata_check_interval: 0,
protect_check_interval: 30
}
const body = configHelpers.pollingSaveBody(draft, true)
assert.equal(body.asset_status, 2)
assert.equal(body.package_state, 'active')
assert.equal(body.carddata_check_interval, 0)
assert.ok(body.clear_fields.includes('realname_check_interval'))
assert.equal('realname_check_interval' in body, false)
assert.equal('protect_check_interval' in body, false)
assert.equal('id' in body, false)
assert.equal('disabled' in body, false)
assert.deepEqual(
configHelpers.pollingDraftBody([{ id: 3, disabled: true, priority: -1, config_name: '' }]),
[{ id: 3, disabled: true }]
)
assert.throws(() =>
gateway.validateGatewayRule({
category: 'business',
auto_disable_polling: true,
keyword: 'a',
match_type: 'contains'
})
)
})
for (const [target, type, allowed] of [
['iot_card', 3, true],
['device', 4, false],
['alert', 3, false],
['alert', 2, true]
])
test(`batch ${target} type${type} validates identity and shows partial results`, async () => {
const result = {
success_count: 1,
skip_count: 1,
fail_count: 2,
impact: ['impact'],
items: [1, 2, 3, 4].map((status) => ({
asset_id: status,
identifier: String(status),
status,
status_name: String(status),
reason: 'reason'
}))
}
const h = mount(batchPath, {
userType: type,
props: { target, ids: [1, 2, 3, 4], permission: 'test' },
services: {
toggleCards: () => ok(result),
toggleDevices: () => ok(result),
closeAlertPolling: () => ok(result)
}
})
await h.state.loadImpact()
h.state.reason.value = ' reason '
await h.state.submit()
assert.equal(
h.calls.some((call) =>
['toggleCards', 'toggleDevices', 'closeAlertPolling'].includes(call.name)
),
allowed
)
if (allowed) {
assert.deepEqual(h.state.result.value, result)
assert.equal(h.state.resultRows.value.length, 4)
await h.state.submit()
assert.equal(h.calls.length, 2)
}
h.dispose()
})
test('batch missing impact, cancel, duplicate ids, unmount, and changed scope never write', async () => {
for (const variant of ['impact', 'cancel', 'duplicate', 'unmount', 'scope']) {
const props = {
target: 'iot_card',
ids: variant === 'duplicate' ? [1, 1] : [1],
permission: 'test'
}
let h
h = mount(batchPath, {
props,
services:
variant === 'impact'
? {
getImpact: () => {
throw Error('offline')
}
}
: {},
confirm: async () => {
if (variant === 'cancel') throw 'cancel'
if (variant === 'unmount') h.dispose()
if (variant === 'scope') h.user.info.id = 2
}
})
await h.state.loadImpact()
h.state.reason.value = 'reason'
await h.state.submit()
assert.equal(
h.calls.some((call) => call.name === 'toggleCards'),
false,
variant
)
h.dispose()
}
})
test('import validates purpose/prefix and never creates after upload failure', async () => {
for (const fail of [false, true]) {
const uploads = []
const h = mount('src/components/business/polling/PollingImportDialog.vue', {
permissions: [constants.SCHEDULER_PERMISSIONS.imports.create],
storage: {
getUploadUrl: async (data) => {
uploads.push(data)
return ok({ upload_url: 'local-upload', file_key: 'polling-status-imports/test.csv' })
},
uploadFile: async () => {
if (fail) throw Error('upload failed')
}
},
services: { createImport: () => ok({ id: 3 }) }
})
h.state.file.value = { name: 'file.csv', text: async () => 'iccid\n89860426012345678901' }
h.state.reason.value = 'reason'
await h.state.submit()
assert.equal(uploads[0].purpose, 'polling_status_import')
assert.equal(
h.calls.some((call) => call.name === 'createImport'),
!fail
)
h.dispose()
}
})
test('all new Vue templates compile, including multi-statement event handlers', () => {
const dirs = [
'src/components/business/polling',
'src/views/polling-management/carrier-params',
'src/views/polling-management/check-scenes',
'src/views/polling-management/carrier-queues',
'src/views/polling-management/failure-alerts',
'src/views/polling-management/polling-pauses',
'src/views/polling-management/polling-status-imports',
'src/views/polling-management/stop-resume-dry-run'
]
for (const dir of dirs)
for (const file of readdirSync(dir).filter((file) => file.endsWith('.vue'))) {
const path = `${dir}/${file}`,
{ descriptor, errors } = parse(source(path))
assert.deepEqual(errors, [], path)
const result = compileTemplate({
source: descriptor.template.content,
filename: path,
id: path,
compilerOptions: { expressionPlugins: ['typescript'] }
})
assert.deepEqual(result.errors, [], path)
}
})
test('protection is absent from admin forms and polling pause/alert/dry-run export scenes are registered', () => {
for (const path of [
'src/views/polling-management/config/index.vue',
'src/views/polling-management/config/components/PollingDraftFields.vue',
'src/views/asset-management/asset-information/components/BasicInfoCard.vue'
])
assert.equal(/protect_check_interval|device_protect_status/.test(source(path)), false, path)
for (const scene of ['polling_pause', 'polling_stop_refused_alert', 'stop_resume_rule_dry_run'])
assert.ok(source('src/config/constants/exportTask.ts').includes(`scene: '${scene}'`))
for (const kind of [
'carrier-params',
'check-scenes',
'failure-alerts',
'carrier-queues',
'stop-resume-dry-run',
'polling-pauses',
'polling-status-imports'
])
assert.ok(source('src/router/routes/asyncRoutes.ts').includes(`path: '${kind}'`))
assert.match(
source('src/views/polling-management/stop-resume-dry-run/index.vue'),
/assume_control_off/
)
})

View File

@@ -0,0 +1,414 @@
// 本地验收专用。所有 API 由此模块或下游 mock 拦截,禁止代理真实服务。
import { mockApiPlugin as polling } from './pollingConfigBrowserFixtures.mjs'
const time = '2026-10-26T10:00:00+08:00'
const menus = [
['运营商账户调度参数', 'carrier-params', 'polling_carrier_params:page'],
['请求检查场景', 'check-scenes', 'polling_check_scene:page'],
['失败告警', 'failure-alerts', 'polling_failure_alert:page'],
['账户队列监控', 'carrier-queues', 'polling_carrier_queue:page'],
['停复机规则演练', 'stop-resume-dry-run', 'stop_resume_dry_run:page'],
['不轮询清单', 'polling-pauses', 'polling_pause:page'],
['轮询开关导入', 'polling-status-imports', 'polling_status_import:page'],
['轮询配置', 'config'],
['网关调用统计', 'gateway-stats', 'gateway_call_stats:page']
]
const permissions = [
'polling_config_create',
'polling_carrier_params:detail',
'polling_carrier_params:detail_page',
'polling_failure_alert:detail',
'polling_failure_alert:detail_page',
'polling_failure_alert:summary',
'polling_failure_alert:handle',
'polling_failure_alert:close_polling',
'polling_failure_alert:export',
'polling_pause:restore',
'polling_pause:export',
'polling_status_import:create',
'polling_status_import:detail',
'polling_status_import:detail_page',
'stop_resume_dry_run:export',
'iot_info:polling_state',
'device_info:polling_state',
'iot_card:batch_polling',
'device:batch_polling',
'gateway_call_stats:trend',
'iot_info:stop_resume_records',
'polling_config:match_preview'
]
const params = {
carrier_id: 8,
carrier_name: '模拟运营商账户',
is_default: true,
card_max_in_flight: 10,
inflight_limit: 20,
configured_card_max_in_flight: null,
configured_inflight_limit: null,
consume_capacity: 30,
priority_max_ratio: null,
sr_inflight_limit: null,
stop_resume_max_in_flight: null,
stop_resume_min_interval: null,
stop_resume_confirm_delay: null
}
const alert = {
id: 1,
alert_level: 'card',
card_id: 42,
carrier_id: 8,
carrier_name: params.carrier_name,
check_type: 'stop_resume',
check_type_name: '停复机',
created_at: time,
iccid: '89860426012345678901',
status: 1,
status_name: '未处理',
error_category: 'terminal',
fail_count: 3,
first_failed_at: time,
last_failed_at: time,
last_error: '模拟停机被拒',
handled_by: 0,
handle_remark: '',
handled_at: '',
recovered_at: '',
network_status: 1,
stop_reason: 'manual',
enable_polling: true,
has_package: false
}
const pause = {
id: 1,
asset_id: 42,
asset_type: 'iot_card',
identifier: alert.iccid,
carrier_id: 8,
carrier_name: params.carrier_name,
enable_polling: false,
gateway_extend: '',
status: 1,
status_name: '已关闭',
close_source: 'batch',
close_source_name: '勾选批量',
close_reason: '模拟关闭',
closed_by: 0,
closed_by_name: '',
closed_at: time,
restore_source: '',
restore_reason: '',
restored_by: 0,
restored_at: '',
import_task_id: 0
}
const task = {
id: 1,
task_no: 'MOCK-IMPORT-1',
asset_type: 'iot_card',
enable_polling: false,
file_name: 'mock.csv',
reason: '模拟导入',
status: 3,
status_name: '已完成',
creator_name: '模拟平台',
total_count: 4,
success_count: 1,
skip_count: 1,
fail_count: 2,
error_message: '',
created_at: time,
started_at: time,
completed_at: time,
items: [1, 2, 3, 4].map((status, index) => ({
line: index + 2,
identifier: `8986042601234567890${index}`,
asset_id: index + 42,
status,
status_name: ['', '成功', '跳过', '失败', '无权限或不存在'][status],
reason: status > 1 ? '模拟逐项原因' : ''
}))
}
const operation = {
operation: 'stop',
operation_name: '停机',
attempts: 10,
success: 8,
success_rate: 0.8,
avg_qps: 0.2,
peak_minute_qps: 0.3,
avg_duration_ms: 230,
p95_duration_ms: -1,
rate_limited: 0,
would_limit: 2,
failures: { 'upstream:terminal': 2 },
exempt: { manual_click: 1 }
}
const log = []
const page = (items, url) => ({
items,
total: items.length,
page: Number(url.searchParams.get('page') || 1),
size: Number(url.searchParams.get('page_size') || 20)
})
export const mockApiPlugin = {
name: 'polling-scheduler-local-mock-api',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url, 'http://127.0.0.1'),
path = url.pathname
if (path === '/__scheduler-upload') {
for await (const _ of req) {
}
res.end('')
return
}
if (path === '/__scheduler-log') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(log))
return
}
const handled =
/^\/api\/admin\/(polling-carrier-params|polling-check-scenes|polling-failure-alerts|card-polling-pauses|polling-status-imports|polling-status\/impact|stop-resume-rule\/dry-run|polling-stats\/carrier|storage\/upload-url)/.test(
path
) ||
/\/polling-state$|\/batch-update-polling-status$/.test(path) ||
['/api/auth/me', '/api/auth/login'].includes(path)
if (!handled) return next()
let raw = ''
for await (const chunk of req) raw += chunk
const input = raw ? JSON.parse(raw) : {},
token = String(req.headers.authorization || ''),
type = token.includes('agent')
? 3
: token.includes('enterprise')
? 4
: token.includes('admin')
? 1
: 2
log.push({
path,
method: req.method,
query: Object.fromEntries(url.searchParams),
body: input
})
let data = {},
code = 0
if (path.startsWith('/api/auth/'))
data = {
access_token: `local-scheduler-${type === 1 ? 'admin' : type === 3 ? 'agent' : type === 4 ? 'enterprise' : 'platform'}`,
refresh_token: '',
expires_in: 3600,
user: {
id: type,
user_type: type,
username: '本地模拟账号',
roles: [type === 1 ? 'R_SUPER' : 'R_ADMIN']
},
permissions: menus.map((row) => row[2]).filter(Boolean),
buttons: permissions,
menus: [
{
url: '/polling-management',
name: '轮询管理',
children: [
...menus,
[
'运营商账户调度参数详情',
'carrier-params/detail/:carrier_id',
'polling_carrier_params:detail_page'
],
['失败告警详情', 'failure-alerts/detail/:id', 'polling_failure_alert:detail_page'],
[
'导入任务详情',
'polling-status-imports/detail/:id',
'polling_status_import:detail_page'
]
].map(([name, path, perm_code]) => ({
name,
url: `/polling-management/${path}`,
perm_code
}))
}
]
}
else if (path === '/api/admin/polling-carrier-params')
data = {
default_card_max_in_flight: 10,
default_inflight_limit: 20,
subscriber_processes: 3,
items: [params]
}
else if (path.startsWith('/api/admin/polling-carrier-params/')) data = { ...params, ...input }
else if (path.startsWith('/api/admin/polling-check-scenes'))
data = [
{
scene: 'order_paid',
scene_name: '订单支付',
check_types: ['carddata', 'card_status'],
confirm_delays: [30, 60],
cooldown: null,
updated_at: time
}
]
else if (path === '/api/admin/polling-stats/carrier-queues')
data = {
scheduler_mode: 'carrier',
init_state: 'completed',
items: [
{
carrier_id: 8,
carrier_name: params.carrier_name,
queue_length: 10,
priority_length: 1,
due_count: 2,
running: 1,
inflight_limit: 20,
qps_last_minute: 0.2,
rate_limit_waits: 1,
rate_limited: 0,
expired_claims_1h: 0,
open_alerts: 2,
open_alerts_by_category: { terminal: 2 },
stop_resume_queue_length: 5,
stop_resume_due: 1,
stop_resume_running: 1,
stop_resume_pending: 2,
stop_resume_success_1h: 10,
stop_resume_failed_1h: 2,
stop_resume_retry_1h: 3,
stop_resume_unknown_1h: 2
}
]
}
else if (path === '/api/admin/polling-stats/carriers')
data = {
minutes: 15,
mode: 'observe',
carriers: [{ carrier_id: 8, carrier_name: params.carrier_name, operations: [operation] }]
}
else if (path.endsWith('/trend'))
data = { carrier_id: 8, points: [{ time: '2026-10-26 10:00', operations: [operation] }] }
else if (path === '/api/admin/polling-failure-alerts/summary')
data = [
{
carrier_id: 8,
carrier_name: params.carrier_name,
error_category: 'terminal',
open_count: 2
}
]
else if (path === '/api/admin/polling-failure-alerts')
data = page(
[
alert,
{ ...alert, id: 2, alert_level: 'carrier', card_id: 0, iccid: '', check_type: 'all' }
],
url
)
else if (path.endsWith('/close-polling') || path.endsWith('/batch-update-polling-status'))
data = {
success_count: 1,
skip_count: 1,
fail_count: 2,
impact: ['本地模拟影响'],
items: task.items
}
else if (path.startsWith('/api/admin/polling-failure-alerts/')) data = alert
else if (path === '/api/admin/card-polling-pauses') data = page([pause], url)
else if (path === '/api/admin/polling-status/impact')
data = {
enable: ['本地模拟:按配置查询并自动停复机。'],
disable: ['本地模拟:关闭查询及部分自动停复机;部分业务仍生效。']
}
else if (path === '/api/admin/polling-status-imports')
data =
req.method === 'POST' ? { ...task, status: 1, status_name: '待处理' } : page([task], url)
else if (path.startsWith('/api/admin/polling-status-imports/')) data = task
else if (path === '/api/admin/storage/upload-url')
data = {
upload_url: 'http://127.0.0.1:5190/__scheduler-upload',
file_key: 'polling-status-imports/mock.csv'
}
else if (path.endsWith('/polling-state'))
data = {
card_id: 42,
carrier_id: 8,
iccid: alert.iccid,
enable_polling: true,
scheduler_mode: 'carrier',
queue: 'priority',
queue_score_at: time,
running: false,
priority_mark: false,
claim_expire_at: '',
stop_resume_queue_enabled: true,
last_stop_resume_call_at: time,
stop_resume_command: {
action: 'stop',
reason: 'manual',
scenes: ['order_paid'],
source: 'order',
version: 1,
fail_count: 0,
last_error: '',
pending: true,
unknown: 1,
written_at: time,
next_at: time
},
types: [
{
check_type: 'carddata',
check_type_name: '流量',
next_due_at: time,
last_success_at: time,
fail_count: 0,
first_failed_at: '',
last_error: '',
pending_confirm: [30, 60]
}
]
}
else if (path === '/api/admin/stop-resume-rule/dry-run')
data = {
...page(
[
{
card_id: 42,
carrier_id: 8,
carrier_name: params.carrier_name,
iccid: alert.iccid,
device_id: 0,
network_status: 1,
card_category: 'normal',
realname_ok: false,
stop_reason: 'none',
stop_reason_name: '无',
result: 'will_stop',
result_name: '将停机',
reason: 'not_realname',
reason_name: '未实名'
}
],
url
),
totals: { total: 100, will_stop: 20, will_resume: 10, no_action: 70 },
summary: [
{
carrier_id: 8,
carrier_name: params.carrier_name,
stop_reason_name: '无',
result_name: '将停机',
reason_name: '未实名',
count: 20
}
]
}
else if (path.endsWith('/export'))
data = { task_id: 1, task_no: 'MOCK-EXPORT-1', status: 1, status_name: '待处理' }
else code = 400
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ code, msg: 'local mock only', data }))
})
polling.configureServer(server)
}
}

View File

@@ -0,0 +1,701 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
const read = (path) => readFileSync(path, 'utf8')
function evaluate(source, imports = {}, globals = {}) {
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(
(name) => {
if (name.endsWith('.vue')) return { default: {} }
assert.ok(imports[name], `Unexpected dependency ${name}`)
return imports[name]
},
module,
module.exports,
...Object.values(globals)
)
return module.exports
}
const format = evaluate(read('src/utils/business/format.ts'))
const legacy = evaluate(read('src/utils/business/packageSilence.ts'))
const helpers = evaluate(read('src/utils/business/silenceExtension.ts'), {
'./format': format,
'./packageSilence': legacy
})
const gateway = evaluate(read('src/utils/business/gateway.ts'), { './format': format })
const polling = evaluate(read('src/utils/business/pollingScheduler.ts'), { './gateway': gateway })
const constants = evaluate(read('src/config/constants/packageSilence.ts'))
const response = (data) => ({ code: 0, data, msg: 'ok' })
const flush = async () => {
await vue.nextTick()
await Promise.resolve()
await vue.nextTick()
}
const deferred = () => {
let resolve, reject
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { resolve, reject, promise }
}
function setup(
name,
{
props = {},
permissions = [],
overrides = {},
confirm = async () => {},
prompt = async () => ({ value: 'reason' })
} = {}
) {
const calls = [],
hooks = [],
timers = new Set(),
events = [],
mounted = []
const user = vue.reactive({ info: { id: 1, user_type: 2, shop_id: 1 }, isLogin: true })
const grants = vue.reactive(new Set(permissions))
const route = vue.reactive({
params: { id: '3' },
fullPath: '/package-management/silence-orders/detail/3'
})
const hasAuth = (code) => grants.has(code)
const services = new Proxy(
{},
{
get:
(_, method) =>
async (...args) => {
calls.push({ method, args })
return overrides[method]
? overrides[method](...args)
: response(
method === 'eligibility'
? {
can_extend: true,
special_extension_available: true,
min_days: 1,
max_days: 720
}
: method === 'quote'
? {
valid: true,
amount: 100,
billed_days: 30,
input_days: args[1],
new_deadline: '2026-11-30'
}
: method === 'orders'
? { items: [], total: 0 }
: []
)
}
}
)
const runtime = {
...vue,
onMounted: (fn) => mounted.push(fn),
onActivated() {},
onDeactivated: (fn) => hooks.push(fn),
onBeforeUnmount: (fn) => hooks.push(fn)
}
const store = { useUserStore: () => user },
auth = { useAuth: () => ({ hasAuth }) }
const scheduler = evaluate(read('src/composables/useSchedulerRequest.ts'), {
vue: runtime,
'@/store/modules/user': store,
'@/utils/business/gateway': gateway,
'@/utils/business/pollingScheduler': polling
})
const imports = {
vue: runtime,
'vue-router': { useRoute: () => route, useRouter: () => ({ push() {}, back() {} }) },
'@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1000) }) },
'qrcode.vue': { default: {} },
'element-plus': { ElMessageBox: { confirm, prompt } },
'@/api/modules/packageSilence': { PackageSilenceService: services },
'@/api/modules/employeeCollection': { EmployeeCollectionService: services },
'@/api/modules/packageManage': { PackageManageService: services },
'@/api/modules/packageSeries': { PackageSeriesService: services },
'@/composables/useAuth': auth,
'@/store/modules/user': store,
'@/composables/useSchedulerRequest': scheduler,
'@/utils/business/pollingScheduler': polling,
'@/utils/business/silenceExtension': helpers,
'@/utils/business/gateway': gateway,
'@/utils/business/format': format,
'@/config/constants/packageSilence': constants
}
const { descriptor } = parse(read(`src/components/business/package-silence/${name}.vue`))
const component = evaluate(
compileScript(descriptor, { id: 'silence-extension' }).content,
imports,
{
...runtime,
setTimeout: (fn) => {
timers.add(fn)
return fn
},
clearTimeout: (fn) => timers.delete(fn)
}
).default
const scope = vue.effectScope(),
reactiveProps = vue.reactive(props)
const state = scope.run(() =>
component.setup(reactiveProps, { expose() {}, emit: (...args) => events.push(args) })
)
return {
state,
calls,
user,
grants,
route,
props: reactiveProps,
timers,
events,
mount: async () => {
for (const fn of mounted) await fn()
},
dispose() {
hooks.forEach((fn) => fn())
scope.stop()
}
}
}
const extendPermission = constants.PACKAGE_SILENCE_PERMISSIONS.extend
async function ready(ctx) {
await ctx.state.loadEligibility()
ctx.state.days.value = 31
await flush()
await ctx.state.loadQuote()
assert.equal(ctx.state.canSubmit.value, true)
}
test('all 21 authoritative Apifox operations have exact service methods, including corrected first-purchase path and body IDs', async () => {
const calls = []
class BaseService {}
for (const method of ['get', 'post', 'put'])
BaseService[method] = async (url, body) => {
calls.push({ method, url, body })
return response({})
}
const { PackageSilenceService: S } = evaluate(read('src/api/modules/packageSilence.ts'), {
'../BaseService': { BaseService }
})
await S.firstPurchase('card', 9)
await S.batches({ has_exception: false })
await S.batch(3)
await S.eligibility(4)
await S.handle(5, { id: 5, action: 'close', reason: 'r' })
await S.orders({ payment_method: 'offline' })
await S.order(6)
await S.createOffline({ days: 1 })
await S.createQrcode({ days: 31 })
await S.quote(4, 31)
await S.special({ items: [] })
await S.policies({ status: 0 })
await S.createPolicy({ name: 'n' })
await S.policy(7)
await S.updatePolicy(7, { id: 99, name: 'edit' })
await S.policyStatus(7, 0)
await S.match(4)
await S.records(4)
await S.getSwitch()
await S.setSwitch(false)
await S.usages({ view: 'expired' })
const spec = JSON.parse(read('docs/api/package-silence.openapi.json'))
for (const [path, methods] of Object.entries(spec.paths))
for (const method of Object.keys(methods)) {
const regex = new RegExp('^' + path.replace(/\{[^}]+\}/g, '[^/]+') + '$')
assert.ok(
calls.some((call) => call.method === method && regex.test(call.url)),
`${method} ${path}`
)
}
assert.equal(calls.length, 21)
assert.equal(calls[0].url, '/api/admin/assets/card/9/first-purchase-records')
assert.deepEqual(calls[1].body, { has_exception: false })
assert.deepEqual(calls[14].body, { id: 7, name: 'edit' })
assert.deepEqual(calls[15].body, { id: 7, status: 0 })
})
test('money conversion is exact integer fen, keeps zero, rejects precision loss and never guesses absent values', () => {
for (const [value, expected] of [
['0', 0],
['0.01', 1],
['0.29', 29],
['1.10', 110],
['100', 10000]
])
assert.equal(helpers.silenceFen(value), expected)
for (const value of ['', null, '-1', '1.001', '1e2', 'NaN', '9007199254740992'])
assert.throws(() => helpers.silenceFen(value))
assert.equal(helpers.silenceAmount(0), '¥0.00')
assert.equal(helpers.silenceAmount(undefined), '-')
assert.equal(helpers.silenceDisplay({ end_reason: 'exchange_return' }, 'end_reason'), '换货回库')
})
test('CSV import uses only two documented item fields and rejects bad headers, duplicate and unsafe IDs', () => {
assert.deepEqual(helpers.parseSilenceCsv('\uFEFFpackage_usage_id,days\r\n11,31\r\n12,181'), [
{ package_usage_id: 11, days: 31 },
{ package_usage_id: 12, days: 181 }
])
for (const input of [
'id,days\n1,30',
'package_usage_id,days',
'package_usage_id,days\n1e4,30',
'package_usage_id,days\n1,0',
'package_usage_id,days\n1,30\n1,60'
])
assert.throws(() => helpers.parseSilenceCsv(input))
})
test('business failures retain per-row details, while pending approval is not an error or fabricated completion', () => {
const rows = [{ row_no: 2, reason: 'expired' }]
try {
helpers.silenceData({ code: 1267, data: { rows }, msg: '免费次数已用完' })
} catch (e) {
assert.deepEqual(helpers.silenceRowsError(e), rows)
assert.equal(e.message, '免费次数已用完')
}
assert.deepEqual(helpers.silenceData(response({ status: 'pending_approval' })), {
status: 'pending_approval'
})
})
test('current quote is required and changing days invalidates it before any write', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission]
})
try {
await ready(ctx)
ctx.state.days.value = 32
await flush()
assert.equal(ctx.state.canSubmit.value, false)
await ctx.state.submit()
assert.equal(
ctx.calls.some((x) => x.method === 'createQrcode'),
false
)
} finally {
ctx.dispose()
}
})
test('online order sends days not client-calculated amount; QR status polling stops when closed', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission],
overrides: {
createQrcode: () => response({ id: 7, status: 'pending_payment', qr_content: 'local' }),
order: () => response({ id: 7, status: 'closed' })
}
})
try {
await ready(ctx)
await ctx.state.submit()
const call = ctx.calls.find((x) => x.method === 'createQrcode')
assert.equal(call.args[0].days, 31)
assert.equal('amount' in call.args[0], false)
assert.equal(typeof call.args[0].request_id, 'string')
assert.equal(ctx.timers.size, 1)
await ctx.state.refreshOrder()
assert.equal(ctx.timers.size, 0)
} finally {
ctx.dispose()
}
})
test('retry after a transport failure reuses the same idempotency key instead of silently creating a new payment', async () => {
let count = 0
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission],
overrides: {
createQrcode: () => {
if (!count++) throw new Error('network')
return response({ id: 7, status: 'pending_approval' })
}
}
})
try {
await ready(ctx)
await ctx.state.submit()
await ctx.state.submit()
const calls = ctx.calls.filter((x) => x.method === 'createQrcode')
assert.equal(calls.length, 2)
assert.equal(calls[0].args[0].request_id, calls[1].args[0].request_id)
assert.equal(ctx.timers.size, 0)
} finally {
ctx.dispose()
}
})
test('permission loss while confirmation is open prevents the payment write', async () => {
const confirm = deferred(),
ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission],
confirm: () => confirm.promise
})
try {
await ready(ctx)
const saving = ctx.state.submit()
await flush()
ctx.grants.clear()
await flush()
confirm.resolve()
await saving
assert.equal(
ctx.calls.some((x) => x.method === 'createQrcode'),
false
)
} finally {
ctx.dispose()
}
})
test('canceling confirmation sends no write; offline amount mismatch blocks even with a valid quote', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission, constants.PACKAGE_SILENCE_PERMISSIONS.offline],
confirm: async () => {
throw 'cancel'
}
})
try {
await ready(ctx)
await ctx.state.submit()
assert.equal(
ctx.calls.some((x) => x.method === 'createQrcode'),
false
)
ctx.state.mode.value = 'offline'
await flush()
await ctx.state.loadQuote()
Object.assign(ctx.state, {})
ctx.state.methodId.value = 2
ctx.state.tradeNo.value = 'T'
ctx.state.paidYuan.value = '0.99'
ctx.state.paidAt.value = '2026-10-28T10:00:00+08:00'
ctx.state.vouchers.value = ['a']
await ctx.state.submit()
assert.match(ctx.state.error.value, /实收金额/)
assert.equal(
ctx.calls.some((x) => x.method === 'createOffline'),
false
)
} finally {
ctx.dispose()
}
})
test('late order creation after unmount cannot display a QR code or restart polling', async () => {
const pending = deferred(),
ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [extendPermission],
overrides: { createQrcode: () => pending.promise }
})
await ready(ctx)
const saving = ctx.state.submit()
await flush()
ctx.dispose()
pending.resolve(response({ id: 7, status: 'pending_payment', qr_content: 'late' }))
await saving
assert.equal(ctx.state.order.value, undefined)
assert.equal(ctx.timers.size, 0)
})
test('batch special extension requires both permissions and submits documented parsed items with zero amount', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { batchMode: true },
permissions: [extendPermission],
overrides: { special: () => response({ id: 2, status: 'pending_approval', items: [] }) }
})
try {
ctx.state.batchItems.value = [
{ package_usage_id: 1, days: 121 },
{ package_usage_id: 2, days: 181 }
]
ctx.state.reason.value = '原因'
ctx.state.basis.value = '依据'
await ctx.state.submit()
assert.equal(
ctx.calls.some((x) => x.method === 'special'),
false
)
ctx.grants.add(constants.PACKAGE_SILENCE_PERMISSIONS.batch)
await flush()
await ctx.state.submit()
const call = ctx.calls.find((x) => x.method === 'special')
assert.deepEqual(call.args[0].items, [
{ package_usage_id: 1, days: 121 },
{ package_usage_id: 2, days: 181 }
])
assert.equal(call.args[0].amount, 0)
assert.equal('request_id' in call.args[0], false)
assert.equal(ctx.state.batch.value.status, 'pending_approval')
} finally {
ctx.dispose()
}
})
test('read-only record dialogs use array responses and the exact corrected first-purchase URL contract', async () => {
const ctx = setup('SilenceRecordsDialog', {
props: { assetType: 'device', assetId: 2 },
overrides: { firstPurchase: () => response([{ generation: 1, source: 'order' }]) }
})
try {
await ctx.state.load()
assert.deepEqual(ctx.calls[0], { method: 'firstPurchase', args: ['device', 2] })
assert.equal(ctx.state.data.value[0].generation, 1)
} finally {
ctx.dispose()
}
})
test('direct order and batch details require page, hidden detail page and button permission together', async () => {
for (const kind of ['orders', 'batches']) {
const codes = constants.SILENCE_PAGES[kind]
for (const removed of ['page', 'detailPage', 'detail']) {
const ctx = setup('SilenceDetail', {
props: { kind },
permissions: Object.entries(codes)
.filter(([key]) => key !== removed)
.map(([, v]) => v)
})
try {
await ctx.state.load()
assert.equal(ctx.calls.length, 0)
assert.equal(ctx.state.allowed.value, false)
} finally {
ctx.dispose()
}
}
}
})
test('order and usage exports freeze whitelisted list filters without pagination; policy zero status survives', async () => {
for (const kind of ['orders', 'usages', 'policies']) {
const codes = constants.SILENCE_PAGES[kind],
ctx = setup('SilenceManagement', {
props: { kind },
permissions: [codes.page, codes.export, constants.PACKAGE_SILENCE_PERMISSIONS.policy]
})
try {
ctx.state.filters.injected = 'never'
ctx.state.filters.status = 0
if (kind === 'orders')
ctx.state.filters.time_range = ['2026-10-01T00:00:00+08:00', '2026-10-28T23:59:59+08:00']
await ctx.state.load()
assert.equal('injected' in ctx.calls[0].args[0], false)
if (kind === 'policies') assert.equal(ctx.calls[0].args[0].status, 0)
else {
ctx.state.openExport()
assert.equal('page' in ctx.state.exportQuery.value.filters, false)
if (kind === 'orders')
assert.equal(ctx.state.exportQuery.value.filters.paid_start, '2026-09-30T16:00:00Z')
}
} finally {
ctx.dispose()
}
}
})
test('searchable silence filters keep real IDs for list/export, clear stale record dependencies and reset all selections', async () => {
const ctx = setup('SilenceManagement', {
props: { kind: 'orders' },
permissions: [constants.SILENCE_PAGES.orders.page, constants.SILENCE_PAGES.orders.export]
})
try {
ctx.state.filters.asset_identifier = '89860001'
ctx.state.filters.package_id = 8
ctx.state.filters.package_usage_id = 99
await ctx.state.load()
assert.equal(ctx.calls.at(-1).args[0].package_id, 8)
assert.equal(ctx.calls.at(-1).args[0].package_usage_id, 99)
ctx.state.openExport()
assert.equal(ctx.state.exportQuery.value.filters.package_usage_id, 99)
ctx.state.filters.package_id = 9
assert.equal(ctx.state.filters.package_usage_id, undefined)
ctx.state.filters.package_usage_id = 100
ctx.state.filters.asset_identifier = '89860002'
assert.equal(ctx.state.filters.package_usage_id, undefined)
ctx.state.reset()
assert.deepEqual(ctx.state.filters, {})
} finally {
ctx.dispose()
}
})
test('policy matching uses the selected package and rejects a late result after the selection dialog closes', async () => {
const pending = deferred()
const ctx = setup('SilenceManagement', {
props: { kind: 'policies' },
permissions: [
constants.SILENCE_PAGES.policies.page,
constants.PACKAGE_SILENCE_PERMISSIONS.policy
],
overrides: { match: () => pending.promise }
})
try {
await ctx.state.matchPolicy()
assert.equal(ctx.calls.length, 0)
ctx.state.matchVisible.value = true
ctx.state.matchPackage.value = 8
const matching = ctx.state.matchPolicy()
assert.deepEqual(ctx.calls[0], { method: 'match', args: [8] })
ctx.state.closeMatch()
pending.resolve(response({ matched: true, policy: { name: '不应弹出的旧结果' } }))
await matching
assert.equal(ctx.state.matchResult.value, undefined)
assert.equal(ctx.state.matching.value, false)
} finally {
ctx.dispose()
}
})
test('policy validation retains nullable limits and rejects missing scope and inverted dates', () => {
const value = {
name: '政策',
scope_type: 'all',
scope_ids: [],
effective_from: '2026-10-28T00:00:00+08:00',
effective_to: null,
unit_price: 0,
single_max_months: null
}
assert.doesNotThrow(() => helpers.validateSilencePolicy(value))
assert.throws(() => helpers.validateSilencePolicy({ ...value, scope_type: 'package' }))
assert.throws(() =>
helpers.validateSilencePolicy({ ...value, effective_to: '2026-10-27T00:00:00+08:00' })
)
})
test('offline successful registration sends fen and receipt fields, and pending approval never starts QR polling', async () => {
const permission = constants.PACKAGE_SILENCE_PERMISSIONS.offline
const ctx = setup('SilenceExtendDialog', {
props: { usageId: 4 },
permissions: [permission],
overrides: {
createOffline: () => response({ id: 7, status: 'pending_approval', approval_required: true })
}
})
try {
await ready(ctx)
ctx.state.methodId.value = 2
ctx.state.tradeNo.value = 'LOCAL-TRADE'
ctx.state.paidYuan.value = '1.00'
ctx.state.paidAt.value = '2026-10-28T10:00:00+08:00'
ctx.state.vouchers.value = ['receipts/local.png']
await ctx.state.submit()
const body = ctx.calls.find((x) => x.method === 'createOffline').args[0]
assert.equal(body.paid_amount, 100)
assert.equal(body.paid_at, '2026-10-28T02:00:00Z')
assert.equal(body.offline_payment_method_id, 2)
assert.deepEqual(body.payment_voucher_keys, ['receipts/local.png'])
assert.equal(ctx.state.order.value.status, 'pending_approval')
assert.equal(ctx.timers.size, 0)
} finally {
ctx.dispose()
}
})
test('special transport uncertainty prevents duplicate non-idempotent submissions', async () => {
const ctx = setup('SilenceExtendDialog', {
props: { batchMode: true },
permissions: [extendPermission, constants.PACKAGE_SILENCE_PERMISSIONS.batch],
overrides: {
special: () => {
throw new Error('timeout')
}
}
})
try {
ctx.state.batchItems.value = [{ package_usage_id: 4, days: 31 }]
ctx.state.reason.value = 'reason'
ctx.state.basis.value = 'basis'
await ctx.state.submit()
await ctx.state.submit()
assert.equal(ctx.calls.filter((x) => x.method === 'special').length, 1)
assert.equal(ctx.state.specialUncertain.value, true)
assert.match(ctx.state.error.value, /批次列表|无权/)
} finally {
ctx.dispose()
}
})
test('policy create and edit use full typed fields, fen, IDs and explicit nullable limits', async () => {
for (const id of [undefined, 4]) {
const ctx = setup('SilencePolicyDialog', {
props: { id, allowed: true },
overrides: {
policy: () =>
response({
id: 4,
name: 'old',
scope_type: 'all',
scope_ids: [],
unit_price: 0,
status: 0,
effective_from: '2026-10-28T00:00:00+08:00',
single_max_months: null
})
}
})
try {
await ctx.mount()
ctx.state.form.name = 'new'
ctx.state.form.effective_from = '2026-10-28T00:00:00+08:00'
ctx.state.unitYuan.value = '0.29'
ctx.state.singleLimited.value = false
await ctx.state.submit()
const call = ctx.calls.find((x) => x.method === (id ? 'updatePolicy' : 'createPolicy'))
assert.ok(call)
const body = call.args[id ? 1 : 0]
assert.equal(body.unit_price, 29)
assert.equal(body.single_max_months, null)
assert.equal(body.effective_to, null)
assert.deepEqual(body.scope_ids, [])
if (id) {
assert.equal(body.id, 4)
assert.equal(body.status, 0)
}
} finally {
ctx.dispose()
}
}
})
test('exception close uses the extension detail ID, requires a reason and both page/detail permissions', async () => {
const p = constants.SILENCE_PAGES.batches,
ctx = setup('SilenceDetail', {
props: { kind: 'batches' },
permissions: [
p.page,
p.detailPage,
p.detail,
constants.PACKAGE_SILENCE_PERMISSIONS.exception
],
prompt: async () => ({ value: '关闭原因' })
})
try {
await ctx.state.handle({ id: 99 }, 'close')
const call = ctx.calls.find((x) => x.method === 'handle')
assert.deepEqual(call.args, [99, { id: 99, action: 'close', reason: '关闭原因' }])
} finally {
ctx.dispose()
}
})
test('all added Vue templates compile and route components resolve to actual files', () => {
const dir = 'src/components/business/package-silence'
for (const file of readdirSync(dir).filter((x) => x.endsWith('.vue'))) {
const filename = dir + '/' + file,
{ descriptor } = parse(read(filename))
const compiled = compileScript(descriptor, { id: file })
const template = compileTemplate({
source: descriptor.template.content,
filename,
id: file,
compilerOptions: { bindingMetadata: compiled.bindings }
})
assert.deepEqual(template.errors, [], filename)
}
for (const kind of ['orders', 'batches', 'usages', 'policies'])
assert.ok(
read(`src/views/package-management/silence-${kind}/index.vue`).includes(`kind="${kind}"`)
)
})

View File

@@ -40,7 +40,17 @@ plugins.push(
}) })
) )
if (serve) { if (serve) {
const { mockApiPlugin } = await import('./octoberBrowserFixtures.mjs') const { mockApiPlugin } = await import(
process.argv.includes('--order-silence')
? './orderSilenceBrowserFixtures.mjs'
: process.argv.includes('--scheduler')
? './pollingSchedulerBrowserFixtures.mjs'
: process.argv.includes('--polling')
? './pollingConfigBrowserFixtures.mjs'
: process.argv.includes('--gateway')
? './gatewayBrowserFixtures.mjs'
: './octoberBrowserFixtures.mjs'
)
const server = await createServer({ const server = await createServer({
...loaded.config, ...loaded.config,
configFile: false, configFile: false,

View File

@@ -1,5 +1,5 @@
/** /**
* 账号相关 API - 匹配后端实际接口 * 账号相关 API-匹配后端实际接口
*/ */
import { BaseService } from '../BaseService' import { BaseService } from '../BaseService'

View File

@@ -0,0 +1,177 @@
import { PackageManageService } from './packageManage'
import { PackageSeriesService } from './packageSeries'
import { ShopService } from './shop'
import { CardService } from './card'
import { DeviceService } from './device'
import { AccountService } from './account'
import { PackageSilenceService } from './packageSilence'
import { gatewayData, gatewayError } from '@/utils/business/gateway'
export type BusinessOptionKind =
| 'asset'
| 'card'
| 'package'
| 'series'
| 'shop'
| 'usage'
| 'account'
export interface BusinessOption {
value: string | number
label: string
}
export interface BusinessOptionPage {
options: BusinessOption[]
hasMore: boolean
warning?: string
}
export const BUSINESS_OPTION_LABELS: Record<BusinessOptionKind, string> = {
asset: '资产',
card: '网卡',
package: '套餐',
series: '套餐系列',
shop: '店铺',
usage: '套餐记录',
account: '操作人'
}
export async function searchBusinessOptions(
kind: BusinessOptionKind,
keyword: string,
page = 1,
context: { asset_identifier?: string; package_id?: number } = {}
): Promise<BusinessOptionPage> {
const pagination = { page, page_size: 20 },
name = keyword.trim()
const result = (
data: { items?: unknown[] | null; total?: number },
options: BusinessOption[]
) => ({
options,
hasMore:
typeof data.total === 'number'
? page * pagination.page_size < data.total
: (data.items?.length || 0) >= pagination.page_size
})
if (kind === 'package') {
const data = gatewayData(
await PackageManageService.getPackages({ ...pagination, package_name: name })
)
return result(
data,
(data.items || []).map((x) => ({ value: x.id, label: x.package_name }))
)
}
if (kind === 'series') {
const data = gatewayData(
await PackageSeriesService.getPackageSeries({ ...pagination, series_name: name })
)
return result(
data,
(data.items || []).map((x) => ({ value: x.id, label: x.series_name }))
)
}
if (kind === 'shop') {
const data = gatewayData(await ShopService.getShops({ ...pagination, shop_name: name }))
return result(
data,
(data.items || []).map((x) => ({ value: x.id, label: x.shop_name }))
)
}
if (kind === 'account') {
const data = gatewayData(await AccountService.getAccounts({ ...pagination, username: name }))
return result(
data,
(data.items || []).flatMap((x) => {
const value = x.id ?? x.ID
return value == null
? []
: [{ value, label: [x.username, x.shop_name].filter(Boolean).join(' · ') }]
})
)
}
if (kind === 'usage') {
const data = gatewayData(
await PackageSilenceService.usages({
...pagination,
view: 'all',
...context,
asset_identifier: name || context.asset_identifier
})
)
return result(
data,
(data.items || []).flatMap((x) =>
x.package_usage_id == null
? []
: [
{
value: x.package_usage_id,
label: [
x.package_name || '未命名套餐',
x.asset_identifier,
x.order_no || x.created_at
]
.filter(Boolean)
.join(' · ')
}
]
)
)
}
const cards = async () => {
const data = gatewayData(
await CardService.getStandaloneIotCards({ ...pagination, keyword: name })
)
return result(
data,
(data.items || [])
.filter((x) => x.iccid)
.map((x) => ({
value: kind === 'card' ? x.id : x.iccid,
label: [x.iccid, x.virtual_no, x.shop_name].filter(Boolean).join(' · ')
}))
)
}
if (kind === 'card') return cards()
const devices = async () => {
const data = gatewayData(await DeviceService.getDevices({ ...pagination, keyword: name }))
return result(
data,
(data.items || [])
.filter((x) => x.virtual_no)
.map((x) => ({
value: x.virtual_no,
label: [x.virtual_no, x.device_name, x.imei, x.shop_name].filter(Boolean).join(' · ')
}))
)
}
const results = await Promise.allSettled([cards(), devices()])
const pages = results.flatMap((x) => (x.status === 'fulfilled' ? [x.value] : []))
const failures = results.flatMap((x, index) =>
x.status === 'rejected'
? [`${index === 0 ? '网卡' : '设备'}选项加载失败:${gatewayError(x.reason)}`]
: []
)
if (!pages.length) throw new Error(failures.join(';'))
return {
options: pages.flatMap((x) => x.options),
hasMore: pages.some((x) => x.hasMore),
...(failures.length ? { warning: failures.join(';') } : {})
}
}
// 编辑已有政策时按详情补回名称,不用裸 ID 充当选项文案。
export async function resolveBusinessOption(
kind: BusinessOptionKind,
value: number
): Promise<BusinessOption> {
if (kind === 'package') {
const data = gatewayData(await PackageManageService.getPackageDetail(value))
return { value, label: data.package_name }
}
if (kind === 'series') {
const data = gatewayData(await PackageSeriesService.getPackageSeriesDetail(value))
return { value, label: data.series_name }
}
throw new Error('不支持补全此选项')
}

View File

@@ -39,7 +39,9 @@ export class CarrierService extends BaseService {
* @param data 运营商数据 * @param data 运营商数据
*/ */
static updateCarrier(id: number, data: UpdateCarrierParams): Promise<BaseResponse<Carrier>> { static updateCarrier(id: number, data: UpdateCarrierParams): Promise<BaseResponse<Carrier>> {
return this.update<Carrier>(`/api/admin/carriers/${id}`, data) return this.put(`/api/admin/carriers/${id}`, data, {
requestOptions: { errorMessageMode: 'none' }
})
} }
/** /**

View File

@@ -0,0 +1,97 @@
import { BaseService } from '../BaseService'
import type { BaseResponse, PaginationResponse } from '@/types/api/common'
import type {
GatewayOptions,
StopResumeRecord,
StopResumeQuery,
CardStopResumeQuery,
GatewayRule,
GatewayRuleQuery,
GatewayRuleInput,
GatewayRuleTestInput,
GatewayRuleTestResult,
GatewayGroup,
GatewayGroupQuery,
GatewayGroupInput,
GatewayCarrierStats,
GatewayCarrierTrend
} from '@/types/api/gateway'
const rulesUrl = '/api/admin/gateway-error-classifications'
const groupsUrl = '/api/admin/polling-rate-limit-groups'
export class GatewayService extends BaseService {
static getRecords(params: StopResumeQuery): Promise<PaginationResponse<StopResumeRecord>> {
return this.get('/api/admin/card-stop-resume-records', params)
}
static getCardRecords(
id: number,
params?: CardStopResumeQuery
): Promise<PaginationResponse<StopResumeRecord>> {
return this.get(`/api/admin/iot-cards/${id}/stop-resume-records`, params)
}
static getRules(params?: GatewayRuleQuery): Promise<PaginationResponse<GatewayRule>> {
return this.get(rulesUrl, params)
}
static createRule(data: GatewayRuleInput): Promise<BaseResponse<GatewayRule>> {
return this.post(rulesUrl, data)
}
static updateRule(id: number, data: GatewayRuleInput): Promise<BaseResponse<GatewayRule>> {
return this.put(`${rulesUrl}/${id}`, data)
}
static setRuleStatus(id: number, status: number): Promise<BaseResponse<unknown>> {
return this.put(`${rulesUrl}/${id}/status`, { status })
}
static deleteRule(id: number): Promise<BaseResponse<unknown>> {
return this.delete(`${rulesUrl}/${id}`)
}
static testRule(data: GatewayRuleTestInput): Promise<BaseResponse<GatewayRuleTestResult>> {
return this.post(`${rulesUrl}/test`, data)
}
static getOptions(): Promise<BaseResponse<GatewayOptions>> {
return this.get('/api/admin/polling-options')
}
static getGroups(params?: GatewayGroupQuery): Promise<PaginationResponse<GatewayGroup>> {
return this.get(groupsUrl, params)
}
static createGroup(data: GatewayGroupInput): Promise<BaseResponse<GatewayGroup>> {
return this.post(groupsUrl, data)
}
static getGroup(id: number): Promise<BaseResponse<GatewayGroup>> {
return this.get(`${groupsUrl}/${id}`)
}
static updateGroup(id: number, data: GatewayGroupInput): Promise<BaseResponse<GatewayGroup>> {
return this.put(`${groupsUrl}/${id}`, data)
}
static setGroupStatus(id: number, status: number): Promise<BaseResponse<unknown>> {
return this.put(`${groupsUrl}/${id}/status`, { status })
}
static deleteGroup(id: number): Promise<BaseResponse<unknown>> {
return this.delete(`${groupsUrl}/${id}`)
}
static getCarrierStats(minutes = 15): Promise<BaseResponse<GatewayCarrierStats>> {
return this.get('/api/admin/polling-stats/carriers', { minutes })
}
static getCarrierTrend(
carrierId: number,
params: { minutes?: number; bucket_minutes?: number } = {}
): Promise<BaseResponse<GatewayCarrierTrend>> {
return this.get(`/api/admin/polling-stats/carriers/${carrierId}/trend`, params)
}
}

View File

@@ -60,3 +60,5 @@ export { PollingPriorityQueueService } from './pollingPriorityQueue'
export { AssetWalletService } from './assetWallet' export { AssetWalletService } from './assetWallet'
export { OperationsReportsService } from './operationsReports' export { OperationsReportsService } from './operationsReports'
export { GatewayService } from './gateway'
export { PollingSchedulerService } from './pollingScheduler'

View File

@@ -13,6 +13,15 @@ import type {
} from '@/types/api' } from '@/types/api'
export class OrderService extends BaseService { export class OrderService extends BaseService {
static getPlatformOrders(params?: OrderQueryParams): Promise<BaseResponse<OrderListResponse>> {
return this.get('/api/admin/orders/platform', params)
}
static getAgentOrders(params?: OrderQueryParams): Promise<BaseResponse<OrderListResponse>> {
return this.get('/api/admin/orders/agent', params)
}
static getOnlineOrders(params?: OrderQueryParams): Promise<BaseResponse<OrderListResponse>> {
return this.get('/api/admin/orders/online', params)
}
/** /**
* 获取订单列表 * 获取订单列表
* @param params 查询参数 * @param params 查询参数

View File

@@ -0,0 +1,116 @@
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api/common'
import type { SilenceSwitchState } from '@/types/api/packageSilence'
import type {
AssetFirstPurchaseRecordResponse,
SilenceBatchPageResult,
SilenceBatchResponse,
SilenceEligibilityResponse,
SilenceExceptionHandleParams,
SilenceExtensionResponse,
SilenceOrderPageResult,
SilenceExtensionOrderResponse,
SilenceOfflineOrderRequest,
SilenceOnlineOrderRequest,
SilenceQuoteResponse,
SilenceSpecialRequest,
SilencePolicyResponse,
SilencePolicyRequest,
SilencePolicyUpdateParams,
SilencePolicyMatchResponse,
SilenceRecordResponse,
SilenceUsagePageResult,
SilenceOrderQuery,
SilenceBatchQuery,
SilenceUsageQuery,
SilencePolicyQuery
} from '@/types/api/packageSilenceContract'
const root = '/api/admin/package-silence'
const quiet = { requestOptions: { errorMessageMode: 'none' } }
export class PackageSilenceService extends BaseService {
static eligibility(package_usage_id: number): Promise<BaseResponse<SilenceEligibilityResponse>> {
return this.get(`${root}/eligibility`, { package_usage_id }, quiet)
}
static quote(
package_usage_id: number,
days: number
): Promise<BaseResponse<SilenceQuoteResponse>> {
return this.get(`${root}/quote`, { package_usage_id, days }, quiet)
}
static createQrcode(
data: SilenceOnlineOrderRequest
): Promise<BaseResponse<SilenceExtensionOrderResponse>> {
return this.post(`${root}/orders/qrcode`, data, quiet)
}
static createOffline(
data: SilenceOfflineOrderRequest
): Promise<BaseResponse<SilenceExtensionOrderResponse>> {
return this.post(`${root}/orders/offline`, data, quiet)
}
static special(data: SilenceSpecialRequest): Promise<BaseResponse<SilenceBatchResponse>> {
return this.post(`${root}/special`, data, quiet)
}
static orders(params: SilenceOrderQuery): Promise<BaseResponse<SilenceOrderPageResult>> {
return this.get(`${root}/orders`, params, quiet)
}
static order(id: number): Promise<BaseResponse<SilenceExtensionOrderResponse>> {
return this.get(`${root}/orders/${id}`, undefined, quiet)
}
static batches(params: SilenceBatchQuery): Promise<BaseResponse<SilenceBatchPageResult>> {
return this.get(`${root}/batches`, params, quiet)
}
static batch(id: number): Promise<BaseResponse<SilenceBatchResponse>> {
return this.get(`${root}/batches/${id}`, undefined, quiet)
}
static handle(
id: number,
data: SilenceExceptionHandleParams
): Promise<BaseResponse<SilenceExtensionResponse>> {
return this.post(`${root}/extensions/${id}/handle`, data, quiet)
}
static usages(params: SilenceUsageQuery): Promise<BaseResponse<SilenceUsagePageResult>> {
return this.get(`${root}/usages`, params, quiet)
}
static policies(params: SilencePolicyQuery = {}): Promise<BaseResponse<SilencePolicyResponse[]>> {
return this.get(`${root}/policies`, params, quiet)
}
static policy(id: number): Promise<BaseResponse<SilencePolicyResponse>> {
return this.get(`${root}/policies/${id}`, undefined, quiet)
}
static createPolicy(data: SilencePolicyRequest): Promise<BaseResponse<SilencePolicyResponse>> {
return this.post(`${root}/policies`, data, quiet)
}
static updatePolicy(
id: number,
data: SilencePolicyUpdateParams
): Promise<BaseResponse<SilencePolicyResponse>> {
return this.put(`${root}/policies/${id}`, { ...data, id }, quiet)
}
static policyStatus(id: number, status: number): Promise<BaseResponse<SilencePolicyResponse>> {
return this.put(`${root}/policies/${id}/status`, { id, status }, quiet)
}
static match(package_id: number): Promise<BaseResponse<SilencePolicyMatchResponse>> {
return this.get(`${root}/policies/match`, { package_id }, quiet)
}
static records(package_usage_id: number): Promise<BaseResponse<SilenceRecordResponse[]>> {
return this.get(`${root}/records`, { package_usage_id }, quiet)
}
static firstPurchase(
assetType: 'card' | 'device',
id: number
): Promise<BaseResponse<AssetFirstPurchaseRecordResponse[]>> {
return this.get(`/api/admin/assets/${assetType}/${id}/first-purchase-records`, undefined, quiet)
}
static getSwitch(): Promise<BaseResponse<SilenceSwitchState>> {
return this.get('/api/admin/package-silence/switch')
}
static setSwitch(enabled: boolean): Promise<BaseResponse<unknown>> {
return this.put(
'/api/admin/package-silence/switch',
{ enabled },
{ requestOptions: { errorMessageMode: 'none' } }
)
}
}

View File

@@ -0,0 +1,32 @@
import { PackageManageService } from './packageManage'
import {
assertSilenceResponse,
validateSilenceExpiryOverride
} from '@/utils/business/packageSilence'
// 复用既有套餐详情,不假设授权候选已携带新字段;全部校验完成后才写。
export async function ensurePackageSilenceOverrides(
items: { packageId: number | undefined; override?: string | null }[]
) {
const checks = items.filter((item) => item.override && item.override !== 'default')
const packages = new Map<
number,
Awaited<ReturnType<typeof PackageManageService.getPackageDetail>>['data']
>()
for (let index = 0; index < checks.length; index += 4) {
await Promise.all(
checks.slice(index, index + 4).map(async (item) => {
if (!Number.isSafeInteger(item.packageId) || Number(item.packageId) < 1)
throw new Error('缺少套餐 ID,无法校验生效条件')
const id = item.packageId!
let pkg = packages.get(id)
if (!pkg) {
const response = assertSilenceResponse(await PackageManageService.getPackageDetail(id))
pkg = response.data
packages.set(id, pkg)
}
validateSilenceExpiryOverride(pkg, item.override)
})
)
}
}

View File

@@ -3,6 +3,13 @@
*/ */
import { BaseService } from '../BaseService' import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api' import type { BaseResponse } from '@/types/api'
import type {
PollingMatchPreview,
PollingPreviewRequest,
PollingRecalculateTask,
PollingPreResetStats,
PollingTierStats
} from '@/types/api/pollingConfigExtensions'
import type { import type {
PollingConfig, PollingConfig,
PollingConfigQueryParams, PollingConfigQueryParams,
@@ -16,6 +23,37 @@ import type {
* 轮询配置管理服务 * 轮询配置管理服务
*/ */
export class PollingConfigService extends BaseService { export class PollingConfigService extends BaseService {
static previewMatch(iccid: string): Promise<BaseResponse<PollingMatchPreview>> {
return this.get('/api/admin/polling-configs/match-preview', { iccid })
}
static previewDraft(data: PollingPreviewRequest): Promise<BaseResponse<PollingMatchPreview>> {
return this.post('/api/admin/polling-configs/match-preview', data, {
params: { iccid: data.iccid }
})
}
// 文档未定义成功 data,不假定返回新的配置列表。
static updatePriorities(orderedIds: number[]): Promise<BaseResponse<unknown> | void> {
return this.put('/api/admin/polling-configs/priorities', { ordered_ids: orderedIds })
}
static recalculateDue(carrierId: number | null): Promise<BaseResponse<PollingRecalculateTask>> {
return this.post('/api/admin/polling-configs/recalculate-due', { carrier_id: carrierId })
}
static getRecalculateTask(taskId: string): Promise<BaseResponse<PollingRecalculateTask>> {
return this.get(`/api/admin/polling-configs/recalculate-due/${encodeURIComponent(taskId)}`)
}
static getPreResetStats(): Promise<BaseResponse<PollingPreResetStats>> {
return this.get('/api/admin/polling-stats/pre-reset')
}
static getTierStats(): Promise<BaseResponse<PollingTierStats>> {
return this.get('/api/admin/polling-stats/tiers')
}
/** /**
* 获取轮询配置列表 * 获取轮询配置列表
* @param params 查询参数 * @param params 查询参数

View File

@@ -0,0 +1,109 @@
import { BaseService } from '../BaseService'
import type { BaseResponse, PaginationParams } from '@/types/api/common'
import type {
CarrierParamsInput,
CarrierParamsList,
CarrierPollingParams,
PollingCheckScene,
CheckSceneInput,
PollingFailureQuery,
PollingFailureAlert,
PollingFailureSummary,
SchedulerPage,
CarrierQueues,
CardPollingState,
PollingImpact,
PollingBatchResult,
PollingToggleInput,
PollingPauseQuery,
PollingPause,
PollingImportInput,
PollingImportTask,
StopResumeDryQuery,
StopResumeDryRun
} from '@/types/api/pollingScheduler'
import type { CreateExportTaskResponse } from '@/types/api/exportTask'
export class PollingSchedulerService extends BaseService {
static getCarrierParams(): Promise<BaseResponse<CarrierParamsList>> {
return this.get('/api/admin/polling-carrier-params')
}
static getCarrierParam(id: number): Promise<BaseResponse<CarrierPollingParams>> {
return this.get(`/api/admin/polling-carrier-params/${id}`)
}
static saveCarrierParam(
id: number,
data: CarrierParamsInput
): Promise<BaseResponse<CarrierPollingParams>> {
return this.put(`/api/admin/polling-carrier-params/${id}`, data)
}
static resetCarrierParam(id: number): Promise<BaseResponse<unknown>> {
return this.delete(`/api/admin/polling-carrier-params/${id}`)
}
static getScenes(): Promise<BaseResponse<PollingCheckScene[]>> {
return this.get('/api/admin/polling-check-scenes')
}
static saveScene(scene: string, data: CheckSceneInput): Promise<BaseResponse<PollingCheckScene>> {
return this.put(`/api/admin/polling-check-scenes/${encodeURIComponent(scene)}`, data)
}
static getAlerts(
params: PollingFailureQuery
): Promise<BaseResponse<SchedulerPage<PollingFailureAlert>>> {
return this.get('/api/admin/polling-failure-alerts', params)
}
static getAlertSummary(): Promise<BaseResponse<PollingFailureSummary[]>> {
return this.get('/api/admin/polling-failure-alerts/summary')
}
static getAlert(id: number): Promise<BaseResponse<PollingFailureAlert>> {
return this.get(`/api/admin/polling-failure-alerts/${id}`)
}
static handleAlert(id: number, remark: string): Promise<BaseResponse<unknown>> {
return this.put(`/api/admin/polling-failure-alerts/${id}/status`, { status: 4, remark })
}
static closeAlertPolling(
alertIds: number[],
reason: string
): Promise<BaseResponse<PollingBatchResult>> {
return this.post('/api/admin/polling-failure-alerts/close-polling', {
alert_ids: alertIds,
reason
})
}
static getQueues(): Promise<BaseResponse<CarrierQueues>> {
return this.get('/api/admin/polling-stats/carrier-queues')
}
static getCardState(id: number): Promise<BaseResponse<CardPollingState>> {
return this.get(`/api/admin/iot-cards/${id}/polling-state`)
}
static getImpact(): Promise<BaseResponse<PollingImpact>> {
return this.get('/api/admin/polling-status/impact')
}
static toggleCards(data: PollingToggleInput): Promise<BaseResponse<PollingBatchResult>> {
return this.post('/api/admin/iot-cards/batch-update-polling-status', data)
}
static toggleDevices(data: PollingToggleInput): Promise<BaseResponse<PollingBatchResult>> {
return this.post('/api/admin/devices/batch-update-polling-status', data)
}
static getPauses(params: PollingPauseQuery): Promise<BaseResponse<SchedulerPage<PollingPause>>> {
return this.get('/api/admin/card-polling-pauses', params)
}
static getImports(
params: PaginationParams & { status?: number }
): Promise<BaseResponse<SchedulerPage<PollingImportTask>>> {
return this.get('/api/admin/polling-status-imports', params)
}
static getImport(id: number): Promise<BaseResponse<PollingImportTask>> {
return this.get(`/api/admin/polling-status-imports/${id}`)
}
static createImport(data: PollingImportInput): Promise<BaseResponse<PollingImportTask>> {
return this.post('/api/admin/polling-status-imports', data)
}
static dryRun(params: StopResumeDryQuery): Promise<BaseResponse<StopResumeDryRun>> {
return this.get('/api/admin/stop-resume-rule/dry-run', params)
}
static exportDryRun(
data: Omit<StopResumeDryQuery, 'page' | 'page_size' | 'result'> & { format: 'csv' | 'xlsx' }
): Promise<BaseResponse<CreateExportTaskResponse>> {
return this.post('/api/admin/stop-resume-rule/dry-run/export', data)
}
}

View File

@@ -3,6 +3,7 @@
*/ */
import { BaseService } from '../BaseService' import { BaseService } from '../BaseService'
import { ensurePackageSilenceOverrides } from './packageSilenceValidation'
import type { import type {
BaseResponse, BaseResponse,
CreateShopPackageBatchAllocationsRequest, CreateShopPackageBatchAllocationsRequest,
@@ -17,9 +18,12 @@ export class ShopPackageAllocationService extends BaseService {
* 创建店铺套餐分配 * 创建店铺套餐分配
* POST /api/admin/shop-package-allocations * POST /api/admin/shop-package-allocations
*/ */
static createShopPackageAllocation( static async createShopPackageAllocation(
data: CreateShopPackageAllocationRequest data: CreateShopPackageAllocationRequest
): Promise<BaseResponse<ShopPackageAllocationResponse>> { ): Promise<BaseResponse<ShopPackageAllocationResponse>> {
await ensurePackageSilenceOverrides([
{ packageId: data.package_id, override: data.expiry_base_override }
])
return this.post<BaseResponse<ShopPackageAllocationResponse>>( return this.post<BaseResponse<ShopPackageAllocationResponse>>(
'/api/admin/shop-package-allocations', '/api/admin/shop-package-allocations',
data data
@@ -30,9 +34,12 @@ export class ShopPackageAllocationService extends BaseService {
* 批量创建店铺套餐分配 * 批量创建店铺套餐分配
* POST /api/admin/shop-package-batch-allocations * POST /api/admin/shop-package-batch-allocations
*/ */
static createShopPackageBatchAllocations( static async createShopPackageBatchAllocations(
data: CreateShopPackageBatchAllocationsRequest data: CreateShopPackageBatchAllocationsRequest
): Promise<BaseResponse<ShopPackageBatchAllocationsResponse>> { ): Promise<BaseResponse<ShopPackageBatchAllocationsResponse>> {
await ensurePackageSilenceOverrides(
data.package_ids.map((packageId) => ({ packageId, override: data.expiry_base_override }))
)
return this.post<BaseResponse<ShopPackageBatchAllocationsResponse>>( return this.post<BaseResponse<ShopPackageBatchAllocationsResponse>>(
'/api/admin/shop-package-batch-allocations', '/api/admin/shop-package-batch-allocations',
data data
@@ -43,10 +50,12 @@ export class ShopPackageAllocationService extends BaseService {
* 更新店铺套餐分配生效条件 * 更新店铺套餐分配生效条件
* PATCH /api/admin/shop-package-allocations/{id}/expiry-base * PATCH /api/admin/shop-package-allocations/{id}/expiry-base
*/ */
static updateShopPackageAllocationExpiryBase( static async updateShopPackageAllocationExpiryBase(
id: number, id: number,
data: UpdateShopPackageAllocationExpiryBaseRequest data: UpdateShopPackageAllocationExpiryBaseRequest,
packageId?: number
): Promise<BaseResponse<ShopPackageAllocationResponse>> { ): Promise<BaseResponse<ShopPackageAllocationResponse>> {
await ensurePackageSilenceOverrides([{ packageId, override: data.expiry_base_override }])
return this.patch<BaseResponse<ShopPackageAllocationResponse>>( return this.patch<BaseResponse<ShopPackageAllocationResponse>>(
`/api/admin/shop-package-allocations/${id}/expiry-base`, `/api/admin/shop-package-allocations/${id}/expiry-base`,
data data

View File

@@ -3,6 +3,7 @@
*/ */
import { BaseService } from '../BaseService' import { BaseService } from '../BaseService'
import { ensurePackageSilenceOverrides } from './packageSilenceValidation'
import type { import type {
ShopSeriesGrantResponse, ShopSeriesGrantResponse,
ShopSeriesGrantQueryParams, ShopSeriesGrantQueryParams,
@@ -31,9 +32,20 @@ export class ShopSeriesGrantService extends BaseService {
* POST /api/admin/shop-series-grants * POST /api/admin/shop-series-grants
* @param data 授权数据 * @param data 授权数据
*/ */
static createShopSeriesGrant( static async createShopSeriesGrant(
data: CreateShopSeriesGrantRequest data: CreateShopSeriesGrantRequest
): Promise<BaseResponse<ShopSeriesGrantResponse>> { ): Promise<BaseResponse<ShopSeriesGrantResponse>> {
await ensurePackageSilenceOverrides(
data.packages
.filter((item) => !item.remove)
.map((item) => ({
packageId: item.package_id,
override:
item.expiry_base_override === undefined
? data.expiry_base_override
: item.expiry_base_override
}))
)
return this.create<ShopSeriesGrantResponse>('/api/admin/shop-series-grants', data) return this.create<ShopSeriesGrantResponse>('/api/admin/shop-series-grants', data)
} }
@@ -88,10 +100,21 @@ export class ShopSeriesGrantService extends BaseService {
* @param id 授权ID * @param id 授权ID
* @param data 套餐管理数据 * @param data 套餐管理数据
*/ */
static manageGrantPackages( static async manageGrantPackages(
id: number, id: number,
data: ManageGrantPackagesRequest data: ManageGrantPackagesRequest
): Promise<BaseResponse<ShopSeriesGrantResponse>> { ): Promise<BaseResponse<ShopSeriesGrantResponse>> {
await ensurePackageSilenceOverrides(
data.packages
.filter((item) => !item.remove)
.map((item) => ({
packageId: item.package_id,
override:
item.expiry_base_override === undefined
? data.expiry_base_override
: item.expiry_base_override
}))
)
return this.put<BaseResponse<ShopSeriesGrantResponse>>( return this.put<BaseResponse<ShopSeriesGrantResponse>>(
`/api/admin/shop-series-grants/${id}/packages`, `/api/admin/shop-series-grants/${id}/packages`,
data data

View File

@@ -24,6 +24,7 @@ const triggerBrowserDownload = (downloadUrl: string, fileName: string) => {
* 文件用途枚举 * 文件用途枚举
*/ */
export type FilePurpose = export type FilePurpose =
| 'polling_status_import'
| 'iot_import' | 'iot_import'
| 'batch_purchase' | 'batch_purchase'
| 'device_batch_allocation' | 'device_batch_allocation'

View File

@@ -0,0 +1,198 @@
<template>
<ElSelect
v-model="selection"
class="business-entity-select"
:multiple="multiple"
:disabled="disabled || !allowed"
:clearable="clearable"
:placeholder="placeholder || `搜索并选择${entityLabel}`"
filterable
remote
remote-show-suffix
:remote-method="search"
:loading="loading"
:no-data-text="error || '暂无匹配结果,请更换关键词'"
@visible-change="(visible) => visible && search('')"
>
<ElOption
v-for="option in options"
:key="option.value"
:value="option.value"
:label="option.label"
/>
<template v-if="hasMore || error" #footer>
<span v-if="error" role="alert">{{ error }}</span>
<ElButton v-if="error" link :loading="loading" @click="load(1)">重试</ElButton>
<ElButton v-else link :loading="loading" @click="load(page + 1)">加载更多</ElButton>
</template>
</ElSelect>
</template>
<script setup lang="ts">
import { computed, ref, watch, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
import {
searchBusinessOptions,
resolveBusinessOption,
BUSINESS_OPTION_LABELS,
type BusinessOptionKind,
type BusinessOption
} from '@/api/modules/businessOptions'
import { useUserStore } from '@/store/modules/user'
import { pollingScope } from '@/utils/business/pollingScheduler'
import { gatewayError } from '@/utils/business/gateway'
const props = withDefaults(
defineProps<{
modelValue?: string | number | (string | number)[] | null
entity: BusinessOptionKind
multiple?: boolean
clearable?: boolean
disabled?: boolean
allowed?: boolean
placeholder?: string
context?: { asset_identifier?: string; package_id?: number }
includeSystem?: boolean
}>(),
{ clearable: true, allowed: true }
)
const emit = defineEmits<{
'update:modelValue': [value: string | number | (string | number)[] | undefined]
}>()
const user = useUserStore(),
candidates = ref<BusinessOption[]>([]),
retained = ref<BusinessOption[]>([])
const loading = ref(false),
error = ref(''),
hasMore = ref(false),
page = ref(1),
keyword = ref('')
const entityLabel = computed(() => BUSINESS_OPTION_LABELS[props.entity])
const selection = computed({
get: () => props.modelValue ?? undefined,
set: (value) => {
const selected = Array.isArray(value) ? value : [value]
retained.value = options.value.filter((x) => selected.includes(x.value))
emit('update:modelValue', value)
}
})
const options = computed(() => {
const selected = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]
const merged = new Map(
[...retained.value.filter((x) => selected.includes(x.value)), ...candidates.value].map(
(x) => [x.value, x]
)
)
if (props.entity === 'account' && props.includeSystem)
merged.set(0, { value: 0, label: '系统' })
for (const value of selected) {
if (value == null || value === '' || merged.has(value)) continue
merged.set(value, {
value,
label:
typeof value === 'string' && props.entity === 'asset'
? value
: `${entityLabel.value}名称待加载`
})
}
return [...merged.values()]
})
let active = true,
sequence = 0,
version = 0
async function search(value: string) {
keyword.value = value
await load(1)
}
async function load(nextPage: number) {
if (!active || !props.allowed || props.disabled) return
const current = ++sequence,
currentVersion = version
loading.value = true
error.value = ''
try {
const result = await searchBusinessOptions(
props.entity,
keyword.value,
nextPage,
props.context
)
if (!active || current !== sequence || currentVersion !== version) return
const selected = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]
retained.value = options.value.filter((x) => selected.includes(x.value))
candidates.value = nextPage === 1 ? result.options : [...candidates.value, ...result.options]
page.value = nextPage
hasMore.value = result.hasMore
error.value = result.warning || ''
} catch (e) {
if (active && current === sequence && currentVersion === version)
error.value = gatewayError(e)
} finally {
if (current === sequence) loading.value = false
}
}
async function hydrate() {
if (!active || !props.allowed || !['package', 'series'].includes(props.entity)) return
const currentVersion = version
const values = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]
await Promise.all(
values.map(async (value) => {
if (typeof value !== 'number' || retained.value.some((x) => x.value === value)) return
let option: BusinessOption
try {
option = await resolveBusinessOption(props.entity, value)
} catch {
option = { value, label: `${entityLabel.value}名称加载失败` }
}
if (active && currentVersion === version) retained.value.push(option)
})
)
}
function invalidate() {
version++
sequence++
candidates.value = []
retained.value = []
loading.value = false
error.value = ''
hasMore.value = false
page.value = 1
keyword.value = ''
}
watch(
() => [
props.entity,
props.allowed,
props.disabled,
pollingScope(user),
JSON.stringify(props.context)
],
() => {
invalidate()
void hydrate()
},
{ flush: 'sync' }
)
watch(
() => props.modelValue,
() => void hydrate(),
{ immediate: true }
)
onDeactivated(() => {
active = false
version++
sequence++
loading.value = false
})
onActivated(() => {
active = true
void hydrate()
})
onBeforeUnmount(() => {
active = false
invalidate()
})
</script>
<style scoped>
.business-entity-select {
width: 100%;
}
</style>

View File

@@ -117,6 +117,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { assertSilenceResponse } from '@/utils/business/packageSilence'
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
@@ -356,7 +357,15 @@
} else if (formData.refund_voucher_key.length) { } else if (formData.refund_voucher_key.length) {
data.refund_voucher_key = formData.refund_voucher_key data.refund_voucher_key = formData.refund_voucher_key
} }
await RefundService.createRefund(data) const response = await RefundService.createRefund(data)
if (response.code !== 0) {
try {
assertSilenceResponse(response)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '退款申请失败')
}
return
}
ElMessage.success('退款申请创建成功') ElMessage.success('退款申请创建成功')
dialogVisible.value = false dialogVisible.value = false
emit('success') emit('success')

View File

@@ -38,6 +38,9 @@
import { ExportTaskService } from '@/api/modules' import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { getExportTaskSceneName } from '@/config/constants' import { getExportTaskSceneName } from '@/config/constants'
import { isPollingPlatformExport, canAccessExportTaskScene } from '@/config/constants/exportTask'
import { useUserStore } from '@/store/modules/user'
import { isPollingPlatform } from '@/utils/business/pollingScheduler'
import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api' import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api'
const props = withDefaults( const props = withDefaults(
@@ -64,6 +67,7 @@
}>() }>()
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const user = useUserStore()
const submitting = ref(false) const submitting = ref(false)
const form = reactive({ const form = reactive({
format: props.defaultFormat format: props.defaultFormat
@@ -85,14 +89,21 @@
} }
) )
const getNormalizedQuery = (): Record<string, string> => { const getNormalizedQuery = (): Record<string, string | Record<string, unknown>> => {
const query: Record<string, string> = {} const query: Record<string, string | Record<string, unknown>> = {}
Object.entries(props.query || {}).forEach(([key, value]) => { Object.entries(props.query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return if (value === undefined || value === null || value === '') return
if (Array.isArray(value) && value.length === 0) return if (Array.isArray(value) && value.length === 0) return
if (typeof value === 'string') { if (
key === 'filters' &&
['order_platform', 'order_agent', 'order_online'].includes(props.scene) &&
typeof value === 'object' &&
!Array.isArray(value)
) {
query[key] = value as Record<string, unknown>
} else if (typeof value === 'string') {
query[key] = value query[key] = value
} else if (typeof value === 'number' || typeof value === 'boolean') { } else if (typeof value === 'number' || typeof value === 'boolean') {
query[key] = String(value) query[key] = String(value)
@@ -107,6 +118,14 @@
} }
const submit = async () => { const submit = async () => {
if (!canAccessExportTaskScene(props.scene, user.info.user_type)) {
ElMessage.warning('当前账号无权导出该类订单')
return
}
if (isPollingPlatformExport(props.scene) && !isPollingPlatform(user.info.user_type)) {
ElMessage.warning('仅平台账号可创建该导出任务')
return
}
if (props.confirmPermission && !hasAuth(props.confirmPermission)) { if (props.confirmPermission && !hasAuth(props.confirmPermission)) {
ElMessage.warning('您没有创建导出任务的权限') ElMessage.warning('您没有创建导出任务的权限')
return return

View File

@@ -31,6 +31,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { canEditRestrictedAsset } from '@/utils/business/packageSilence'
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
@@ -79,10 +81,11 @@
} }
const handleSubmit = async () => { const handleSubmit = async () => {
if (!canEditRestrictedAsset(useUserStore().info.user_type)) return
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate(async (valid) => { await formRef.value.validate(async (valid) => {
if (valid) { if (valid && canEditRestrictedAsset(useUserStore().info.user_type)) {
submitLoading.value = true submitLoading.value = true
try { try {
await AssetService.updateRealnameStatus(props.assetIdentifier, { await AssetService.updateRealnameStatus(props.assetIdentifier, {

View File

@@ -0,0 +1,65 @@
<template>
<div>
<ArtTableHeader v-model:columns="columnChecks" @refresh="$emit('refresh')" />
<ArtTable
:data="rows"
:pagination="false"
empty-text="暂无统计数据"
max-height="460"
:index="false"
>
<ElTableColumn
v-for="key in visibleKeys"
:key="key"
:label="key"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">{{ gatewayDisplayValue(row[key], key) }}</template>
</ElTableColumn>
</ArtTable>
<ElCollapse>
<ElCollapseItem title="查看完整响应数据(时间已格式化)" name="raw">
<pre class="gateway-data">{{ responseText }}</pre>
</ElCollapseItem>
</ElCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { gatewayRows, gatewayDisplayValue } from '@/utils/business/gateway'
defineEmits<{ refresh: [] }>()
const props = defineProps<{ values: unknown[]; data: unknown }>()
const rows = computed(() => gatewayRows(props.values))
const keys = computed(() => [...new Set(rows.value.flatMap((row) => Object.keys(row)))])
const responseText = computed(() => {
const text = gatewayDisplayValue(props.data)
if (props.data == null || typeof props.data !== 'object') return text
return JSON.stringify(JSON.parse(text), null, 2)
})
const columnChecks = ref<{ prop: string; label: string; checked: boolean }[]>([])
watch(
keys,
(values) => {
columnChecks.value = values.map((key) => ({
prop: key,
label: key,
checked: columnChecks.value.find((column) => column.prop === key)?.checked ?? true
}))
},
{ immediate: true }
)
const visibleKeys = computed(() =>
columnChecks.value.filter((column) => column.checked).map((column) => column.prop)
)
</script>
<style scoped>
.gateway-data {
max-height: 400px;
overflow: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<SchedulerTable
:data="rows"
:definitions="fields"
:formatter="format"
row-key="metric_key"
@refresh="$emit('refresh')"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
import { gatewayDisplayValue } from '@/utils/business/gateway'
import type { GatewayOperationStats } from '@/types/api/gateway'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
values: {
carrier_id?: number
carrier_name?: string
time?: string
operations: GatewayOperationStats[]
}[]
}>()
defineEmits<{ refresh: [] }>()
const rows = computed(() =>
props.values.flatMap((group, index) =>
(group.operations || []).map((op) => ({
...op,
carrier_name: group.carrier_id === 0 ? '无法解析账户的卡' : group.carrier_name,
time: group.time,
metric_key: `${index}:${op.operation}`
}))
)
)
const fields = [
['carrier_name', '运营商账户'],
['time', '时间桶起点'],
['operation_name', '操作'],
['attempts', '实际请求'],
['success', '成功'],
['success_rate', '成功率'],
['avg_qps', '平均 QPS'],
['peak_minute_qps', '峰值分钟 QPS'],
['avg_duration_ms', '平均耗时(ms)'],
['p95_duration_ms', 'p95 上界(ms,分桶近似)'],
['rate_limited', '拦截次数'],
['would_limit', '本应限流次数(观察)'],
['failures', '失败分类'],
['exempt', '豁免来源']
]
.filter(([key]) => key !== (props.values[0]?.time ? 'carrier_name' : 'time'))
.map(([prop, label]) => ({ prop, label, width: 180 }))
function format(row: Record<string, any>, key: string) {
if (key === 'success_rate') return row[key] == null ? '-' : `${(row[key] * 100).toFixed(2)}%`
if (key === 'p95_duration_ms' && row[key] === -1) return '超过最大分桶'
return gatewayDisplayValue(row[key], key)
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<ElAlert
v-if="error"
type="error"
:closable="false"
show-icon
:title="title || '网关枚举加载失败'"
>
<template #default>
{{ error }}
<ElButton link type="primary" :loading="loading" @click="$emit('retry')">重新加载</ElButton>
</template>
</ElAlert>
<div v-else-if="loading" role="status">{{
title ? '正在加载运营商账户…' : '正在加载网关枚举…'
}}</div>
</template>
<script setup lang="ts">
defineProps<{ error: string; loading: boolean; title?: string }>()
defineEmits<{ retry: [] }>()
</script>

View File

@@ -0,0 +1,52 @@
<template>
<ElForm label-width="130px" :disabled="disabled">
<ElFormItem label="错误分类" required>
<ElSelect v-model="model.category"
><ElOption v-for="opt in options.error_categories" :key="opt.value" v-bind="opt"
/></ElSelect>
</ElFormItem>
<ElFormItem label="关键字或正则" required
><ElInput v-model="model.keyword" maxlength="200" show-word-limit
/></ElFormItem>
<ElFormItem label="匹配方式" required>
<ElSelect v-model="model.match_type"
><ElOption v-for="opt in options.match_types" :key="opt.value" v-bind="opt"
/></ElSelect>
</ElFormItem>
<ElFormItem label="适用操作">
<ElSelect v-model="model.operations" multiple clearable placeholder="留空表示全部操作"
><ElOption v-for="opt in options.gateway_operations" :key="opt.value" v-bind="opt"
/></ElSelect>
</ElFormItem>
<ElFormItem label="上游可能已受理"><ElSwitch v-model="model.maybe_accepted" /></ElFormItem>
<ElFormItem v-if="model.category === 'terminal'" label="自动关闭轮询"
><ElSwitch v-model="model.auto_disable_polling" /><span>仅终态类别生效</span></ElFormItem
>
<ElFormItem label="匹配优先级"
><ElInputNumber v-model="model.priority" :min="1" :max="10000" :precision="0" /><span
>数值越小越先匹配</span
></ElFormItem
>
<ElFormItem label="状态"
><ElSelect v-model="model.status"
><ElOption label="启用" :value="1" /><ElOption label="停用" :value="0" /></ElSelect
></ElFormItem>
<ElFormItem label="备注"
><ElInput v-model="model.remark" type="textarea" maxlength="500" show-word-limit
/></ElFormItem>
</ElForm>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import type { GatewayRuleInput, GatewayOptions } from '@/types/api/gateway'
const model = defineModel<GatewayRuleInput>({ required: true })
watch(
() => model.value.category,
(value) => {
if (value !== 'terminal' && model.value.auto_disable_polling)
model.value.auto_disable_polling = false
}
)
defineProps<{ options: GatewayOptions; disabled?: boolean }>()
</script>

View File

@@ -0,0 +1,268 @@
<template>
<div class="gateway-page" :id="cardId == null ? 'table-full-screen' : undefined">
<GatewayOptionsNotice :error="optionsError" :loading="optionsLoading" @retry="loadOptions" />
<GatewayOptionsNotice
v-if="cardId == null"
title="运营商账户加载失败"
:error="carrierError"
:loading="carrierLoading"
@retry="searchCarriers()"
/>
<ArtSearchBar
:filter="searchFilters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="100px"
@update:filter="updateFilters"
@search="search"
@reset="reset"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader v-model:columns="columnChecks" @refresh="load">
<template #left
><span class="gateway-help"
>时间跨度不超过 31 天;单卡清空时间时查询最近 31 天。</span
></template
>
</ArtTableHeader>
<ElAlert
v-if="error"
class="gateway-error"
type="error"
:title="error"
:closable="false"
show-icon
/>
<ArtTable
:loading="loading"
:data="rows"
row-key="id"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="total"
:page-sizes="[20, 50, 100]"
:margin-top="10"
empty-text="暂无停复机记录"
@current-change="changePage"
@size-change="changeSize"
>
<ElTableColumn
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:min-width="col.width || 140"
show-overflow-tooltip
>
<template #default="{ row }">{{ recordValue(row, col.prop) }}</template>
</ElTableColumn>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, ref, reactive, watch } from 'vue'
import { GatewayService } from '@/api/modules/gateway'
import { useAuth } from '@/composables/useAuth'
import {
useGatewayList,
useGatewayOptions,
useGatewayColumns,
useGatewayCarriers
} from '@/composables/useGateway'
import type { SearchFormItem } from '@/types'
import type { StopResumeQuery, StopResumeRecord } from '@/types/api/gateway'
import {
compactGatewayQuery,
gatewayTimeRange,
recentGatewayRange,
gatewayDisplayValue,
gatewayOptionalId,
validateGatewayInteger
} from '@/utils/business/gateway'
import GatewayOptionsNotice from './GatewayOptionsNotice.vue'
const props = defineProps<{ cardId?: number; permission: string }>()
const { hasAuth } = useAuth()
const {
options: carrierOptions,
loading: carrierLoading,
error: carrierError,
search: searchCarriers,
label: carrierLabel,
select: selectCarrier
} = useGatewayCarriers(props.cardId == null)
const {
options,
error: optionsError,
loading: optionsLoading,
ready: optionsReady,
load: loadOptions,
label
} = useGatewayOptions()
const range = ref<[Date | string, Date | string] | null>(recentGatewayRange())
const filters = reactive({
iccid: '',
carrier_id: undefined as number | undefined,
action: '',
source: '',
result: '',
error_category: '',
range
})
const { rows, total, loading, error, pagination, load, search, clear } =
useGatewayList<StopResumeRecord>(
(page) => {
const time = gatewayTimeRange(range.value, props.cardId == null)
if (props.cardId != null) {
validateGatewayInteger(props.cardId, 0, Number.MAX_SAFE_INTEGER, '卡 ID')
return GatewayService.getCardRecords(props.cardId, { ...page, ...time })
}
return GatewayService.getRecords(
compactGatewayQuery({
...filters,
range: undefined,
carrier_id: gatewayOptionalId(filters.carrier_id),
...page,
...time
}) as StopResumeQuery
)
},
() => hasAuth(props.permission)
)
const columnDefinitions: { prop: keyof StopResumeRecord; label: string; width?: number }[] = [
{ prop: 'created_at', label: '创建时间', width: 200 },
{ prop: 'iccid', label: 'ICCID', width: 220 },
{ prop: 'action', label: '动作' },
{ prop: 'result', label: '结果' },
{ prop: 'error_category', label: '错误类别' },
{ prop: 'provider_message', label: '上游返回信息', width: 240 },
{ prop: 'reason', label: '原因', width: 200 },
{ prop: 'source', label: '来源' },
{ prop: 'scene', label: '场景' },
{ prop: 'attempt', label: '尝试次数' },
{ prop: 'duration_ms', label: '耗时(毫秒)' },
{ prop: 'provider_code', label: '上游返回码' },
{ prop: 'command_id', label: '命令 ID' },
{ prop: 'card_id', label: '卡 ID' },
{ prop: 'carrier_id', label: '运营商账户' },
{ prop: 'id', label: '记录 ID' }
]
const { columns, columnChecks } = useGatewayColumns(columnDefinitions)
const searchFilters = filters
function updateFilters(value: Record<string, unknown>) {
const { range: time, ...rest } = value
Object.assign(filters, rest)
range.value = time as typeof range.value
}
const searchItems = computed<SearchFormItem[]>(() => [
...(props.cardId == null
? [
{
label: 'ICCID',
prop: 'iccid',
type: 'input' as const,
config: { clearable: true, maxlength: 30, placeholder: '请输入 ICCID' }
},
{
label: '运营商账户',
prop: 'carrier_id',
type: 'select' as const,
options: carrierOptions.value,
onChange: ({ val }: { val: unknown }) => selectCarrier(val),
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: searchCarriers,
loading: carrierLoading.value,
placeholder: '请输入运营商名称搜索'
}
},
{
label: '动作',
prop: 'action',
type: 'select' as const,
options: options.value.stop_resume_actions,
config: { clearable: true, disabled: !optionsReady.value }
},
{
label: '来源',
prop: 'source',
type: 'input' as const,
config: { clearable: true, maxlength: 50 }
},
{
label: '结果',
prop: 'result',
type: 'select' as const,
options: options.value.stop_resume_results,
config: { clearable: true, disabled: !optionsReady.value }
},
{
label: '错误类别',
prop: 'error_category',
type: 'select' as const,
options: options.value.error_categories,
config: { clearable: true, disabled: !optionsReady.value }
}
]
: []),
{
label: '记录时间',
prop: 'range',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
clearable: true
}
}
])
function changePage(page: number) {
pagination.page = page
void load()
}
function changeSize(size: number) {
pagination.page_size = size
pagination.page = 1
void load()
}
function recordValue(row: StopResumeRecord, prop: keyof StopResumeRecord) {
if (prop === 'carrier_id') return carrierLabel(row.carrier_id)
if (prop === 'action') return row.action_name || label('stop_resume_actions', row.action)
if (prop === 'result') return label('stop_resume_results', row.result)
if (prop === 'error_category')
return row.error_category_name || label('error_categories', row.error_category)
return gatewayDisplayValue(row[prop], prop)
}
function reset() {
Object.assign(filters, {
iccid: '',
carrier_id: undefined,
action: '',
source: '',
result: '',
error_category: ''
})
range.value = recentGatewayRange()
void search()
}
watch(
() => [props.cardId, props.permission],
() => {
clear()
pagination.page = 1
void load()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
@use '@/views/polling-management/gateway';
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div
>{{ time ? formatDateTime(time) : '-' }}
<ElButton v-if="assetId" link type="primary" @click="visible = true">记录</ElButton></div
>
<SilenceRecordsDialog
v-if="visible"
:asset-id="assetId"
:asset-type="assetType"
@close="visible = false"
/>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { formatDateTime } from '@/utils/business/format'
import SilenceRecordsDialog from './SilenceRecordsDialog.vue'
const props = defineProps<{
assetId: number
assetType: 'card' | 'device'
time?: string | null
}>(),
visible = ref(false)
watch(
() => [props.assetId, props.assetType],
() => {
visible.value = false
}
)
</script>

View File

@@ -0,0 +1,18 @@
<template>
<template v-if="packageType === 'formal'">
<ElFormItem label="默认沉默期(天)" :required="required || expiryBase === 'from_activation'">
<ElInputNumber
v-model="days"
:precision="0"
:controls="false"
placeholder="1–90 天;购买即生效可留空"
/>
</ElFormItem>
<ElFormItem label="支持付费延期"><ElSwitch v-model="enabled" /></ElFormItem>
</template>
</template>
<script setup lang="ts">
defineProps<{ packageType: string; expiryBase?: string; required?: boolean }>()
const days = defineModel<number | null>('days', { required: true })
const enabled = defineModel<boolean>('enabled', { required: true })
</script>

View File

@@ -0,0 +1,284 @@
<template>
<ElCard shadow="never" class="silence-detail">
<div class="detail-header"
><ElButton @click="router.back()">返回</ElButton
><h2>{{ kind === 'orders' ? '延期订单详情' : '延期批次详情' }}</h2
><ElButton :loading="loading" @click="load">刷新</ElButton></div
>
<ElAlert
v-if="error || actionError"
:title="error || actionError"
type="error"
:closable="false"
/>
<template v-if="data">
<ElDescriptions :column="width < 640 ? 1 : 2" border
><ElDescriptionsItem v-for="field in fields" :key="field.prop" :label="field.label">{{
silenceDisplay(data, field.prop)
}}</ElDescriptionsItem></ElDescriptions
>
<div
v-if="kind === 'orders' && data.status === 'pending_payment' && data.qr_content"
class="silence-qr"
><QrcodeVue :value="data.qr_content" :size="200" /><p
>支付截止:{{ data.expire_at ? formatDateTime(data.expire_at) : '-' }}</p
></div
>
<ElAlert
v-if="kind === 'orders' && data.status === 'pending_approval'"
title="审批中,尚未生效。请以订单完成状态及实际截止日为准。"
type="warning"
:closable="false"
/>
<ElButton v-if="data.package_usage_id" @click="recordsId = data.package_usage_id"
>沉默期流水</ElButton
>
<SilenceTable
v-if="kind === 'batches'"
:rows="data.items || []"
:definitions="itemFields"
@refresh="load"
><ElTableColumn label="异常处理" width="190"
><template #default="{ row }"
><template
v-if="
allowed &&
hasAuth(P.exception) &&
(row.failure_code || row.failure_summary || row.status === 'exception')
"
><ElButton link :disabled="handling" @click="handle(row, 'retry')">重试</ElButton
><ElButton link type="danger" :disabled="handling" @click="handle(row, 'close')"
>关闭</ElButton
></template
></template
></ElTableColumn
></SilenceTable
>
<h3 v-if="data.evidence_refs?.length">申请依据</h3
><PaymentVoucherDialog
v-if="data.evidence_refs?.length"
inline
:file-keys="data.evidence_refs"
/>
<h3 v-if="data.payment_voucher_keys?.length">付款凭证</h3
><PaymentVoucherDialog
v-if="data.payment_voucher_keys?.length"
inline
:file-keys="data.payment_voucher_keys"
/>
</template>
<SilenceRecordsDialog
v-if="recordsId"
:usage-id="recordsId"
:allowed="allowed"
@close="recordsId = undefined"
/>
</ElCard>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, onDeactivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWindowSize } from '@vueuse/core'
import { ElMessageBox } from 'element-plus'
import QrcodeVue from 'qrcode.vue'
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
import {
SILENCE_PAGES,
PACKAGE_SILENCE_PERMISSIONS as P
} from '@/config/constants/packageSilence'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { silenceData, silenceDisplay, silenceId } from '@/utils/business/silenceExtension'
import { pollingScope } from '@/utils/business/pollingScheduler'
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { formatDateTime } from '@/utils/business/format'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import SilenceRecordsDialog from './SilenceRecordsDialog.vue'
import SilenceTable from './SilenceTable.vue'
const props = defineProps<{ kind: 'orders' | 'batches' }>(),
route = useRoute(),
router = useRouter(),
{ width } = useWindowSize(),
{ hasAuth } = useAuth(),
user = useUserStore()
const allowed = computed(() => {
const p = SILENCE_PAGES[props.kind]
return hasAuth(p.page) && hasAuth(p.detailPage) && hasAuth(p.detail)
})
const { data, error, loading, load } = useSchedulerRequest<Record<string, any>>(
async () => {
const id = silenceId(Number(route.params.id))
return props.kind === 'orders'
? silenceData(await Service.order(id))
: silenceData(await Service.batch(id))
},
() => allowed.value
)
const recordsId = ref<number>(),
actionError = ref(''),
handling = ref(false)
const maps = {
orders: [
['order_no', '订单号'],
['status_name', '状态'],
['asset_identifier', '资产'],
['package_name', '套餐'],
['package_usage_id', '套餐记录 ID'],
['source_name', '来源'],
['input_days', '输入天数'],
['extension_days', '实际延长天数'],
['amount', '应付金额'],
['paid_amount', '实收金额'],
['payment_method', '支付方式'],
['payment_merchant_name', '收款商户'],
['third_party_trade_no', '交易流水号'],
['offline_payment_method_name', '线下收款方式'],
['offline_trade_no', '线下交易单号'],
['paid_at', '支付时间'],
['before_deadline_at', '原截止时间'],
['expected_deadline_at', '预计新截止'],
['completed_at', '实际生效时间'],
['policy_name', '政策快照'],
['approval_required', '需要审批'],
['approval_instance_id', '审批实例'],
['exception_code', '异常码'],
['exception_summary', '异常说明'],
['refund_no', '退款单号'],
['refund_amount', '退款金额'],
['refunded_at', '退款时间'],
['created_at', '创建时间']
],
batches: [
['batch_no', '批次号'],
['path', '办理路径'],
['extension_type', '延期类型'],
['status', '批次状态'],
['approval_required', '需要审批'],
['approval_status', '审批状态'],
['approval_instance_id', '审批实例'],
['reason', '原因'],
['basis', '依据'],
['amount', '费用合计'],
['requested_count', '明细数'],
['succeeded_count', '成功数'],
['failed_count', '失败数'],
['requested_by', '发起人'],
['requested_at', '发起时间'],
['completed_at', '完成时间']
]
}
const fields = computed(() => maps[props.kind].map(([prop, label]) => ({ prop, label })))
const itemFields = [
['row_no', '行号'],
['id', '明细 ID'],
['asset_identifier', '资产标识'],
['package_name', '套餐'],
['extension_type', '延期类型'],
['extension_days', '延长天数'],
['amount', '金额'],
['status', '明细状态'],
['before_deadline_at', '原截止'],
['after_deadline_at', '实际新截止'],
['executed_at', '执行时间'],
['failure_code', '异常码'],
['failure_summary', '异常原因']
].map(([prop, label]) => ({ prop, label }))
let timer: ReturnType<typeof setTimeout> | undefined,
active = true
function stop() {
if (timer) clearTimeout(timer)
timer = undefined
}
watch(data, (value) => {
stop()
if (
active &&
props.kind === 'orders' &&
['pending_payment', 'paid'].includes(value?.status) &&
allowed.value
)
timer = setTimeout(() => void load(), 3000)
})
watch(
() => [route.fullPath, allowed.value, pollingScope(user)],
() => {
stop()
recordsId.value = undefined
actionError.value = ''
void load()
}
)
async function handle(row: Record<string, any>, action: 'retry' | 'close') {
if (handling.value) return
const scope = pollingScope(user),
path = route.fullPath,
id = silenceId(row.id)
handling.value = true
try {
requireGatewayAccess(allowed.value && hasAuth(P.exception))
const { value } = await ElMessageBox.prompt(
action === 'close'
? '填写关闭原因。在线订单关闭可能按冻结商户原路退款,请确认后继续。'
: '填写重试原因,将按原快照重新执行。',
'异常处理',
{ inputValidator: (value) => !!value?.trim() || '请填写原因', type: 'warning' }
)
requireGatewayAccess(
active &&
allowed.value &&
hasAuth(P.exception) &&
scope === pollingScope(user) &&
path === route.fullPath
)
silenceData(await Service.handle(id, { id, action, reason: value.trim() }))
if (active && scope === pollingScope(user) && path === route.fullPath) await load()
} catch (e) {
if (active && e !== 'cancel' && e !== 'close') actionError.value = gatewayError(e)
} finally {
handling.value = false
}
}
onMounted(load)
onDeactivated(() => {
active = false
stop()
})
onBeforeUnmount(() => {
active = false
stop()
})
</script>
<style scoped>
.silence-detail {
margin: 16px;
}
.detail-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.detail-header h2 {
margin: 0;
font-size: 20px;
}
.silence-qr {
padding: 16px;
text-align: center;
}
:deep(.el-descriptions__cell) {
overflow-wrap: anywhere;
}
@media (width <= 640px) {
.silence-detail {
margin: 4px;
}
}
</style>

View File

@@ -0,0 +1,595 @@
<template>
<ElDialog
:model-value="true"
title="办理沉默期延期"
width="min(760px, 96vw)"
destroy-on-close
@close="$emit('close')"
>
<ElAlert
title="金额和延期截止时间以服务端试算及处理结果为准;待审批或已支付不代表延期已生效。"
type="info"
:closable="false"
/>
<ElAlert
v-if="error || eligibilityError"
:title="error || eligibilityError"
type="error"
:closable="false"
/>
<ElForm label-width="140px" :disabled="submitting || !!order || !!batch">
<ElFormItem label="办理方式"
><ElSelect v-model="mode"
><ElOption
v-if="hasAuth(P.extend) && usageId"
value="online"
label="扫码付费延期" /><ElOption
v-if="hasAuth(P.offline) && usageId"
value="offline"
label="登记线下付款" /><ElOption
v-if="hasAuth(P.extend)"
value="special"
label="特殊延期" /></ElSelect
></ElFormItem>
<template v-if="usageId">
<ElFormItem label="套餐记录"
>{{ usageId }}
<ElButton link :loading="eligibilityLoading" @click="loadEligibility"
>刷新资格</ElButton
></ElFormItem
>
<ElFormItem v-if="eligibility" label="延期资格"
><span
>{{
eligibility.reason || (eligibility.can_extend ? '可以付费延期' : '不能付费延期')
}};特殊延期:{{
eligibility.special_extension_available ? '可申请' : '不可申请'
}}</span
></ElFormItem
>
<ElFormItem v-if="eligibility?.pending_order_no" label="待处理订单"
>{{ eligibility.pending_order_no }}
<ElButton link :disabled="submitting" @click="resumeOrder">查看订单</ElButton></ElFormItem
>
</template>
<ElFormItem label="延期天数" required
><ElInputNumber v-model="days" :min="1" :precision="0" :step="1" /><span
v-if="mode !== 'special' && eligibility"
>({{ eligibility.min_days ?? '-' }}~{{ eligibility.max_days ?? '-' }} 天)</span
></ElFormItem
>
<template v-if="mode !== 'special'">
<ElFormItem label="后端试算"
><ElButton :loading="quoteLoading" :disabled="!eligibility?.can_extend" @click="loadQuote"
>试算</ElButton
><span v-if="quote"
>实际延长 {{ quote.billed_days ?? '-' }} 天,{{ silenceAmount(quote.amount) }},新截止日
{{ quote.new_deadline || '-' }}(当日 23:59:59)</span
></ElFormItem
>
<ElFormItem v-if="mode === 'online'" label="支付方式"
><ElSelect v-model="paymentMethod"
><ElOption value="wechat" label="微信支付" /><ElOption
value="alipay"
label="支付宝" /></ElSelect
></ElFormItem>
</template>
<template v-else>
<ElFormItem label="延期类型" required
><ElSelect v-model="specialType"
><ElOption v-for="item in specialTypes" :key="item.value" v-bind="item" /></ElSelect
></ElFormItem>
<ElFormItem label="原因" required><ElInput v-model="reason" type="textarea" /></ElFormItem>
<ElFormItem label="依据" required><ElInput v-model="basis" type="textarea" /></ElFormItem>
<ElFormItem v-if="hasAuth(P.batch)" label="批量 CSV"
><ElUpload :auto-upload="false" :show-file-list="false" accept=".csv" :on-change="readCsv"
><ElButton>选择 CSV 明细</ElButton></ElUpload
><p
>表头 package_usage_id,days;每行一笔。选中后将替代当前单项。天数由后端按 30
天向上取整。</p
></ElFormItem
>
<ElFormItem v-if="batchItems.length" label="导入明细"
><span>{{ batchItems.length }} 项</span
><ElButton link @click="batchItems = []">清除</ElButton></ElFormItem
>
<ElFormItem v-if="specialType === 'discount'" label="实收合计(元)" required
><ElInput v-model="paidYuan" inputmode="decimal"
/></ElFormItem>
</template>
<template v-if="requiresReceipt">
<ElFormItem label="线下收款方式" required
><ElSelect
v-model="methodId"
filterable
:loading="methodsLoading"
@visible-change="(value) => value && loadMethods()"
><ElOption
v-for="item in methods"
:key="item.id"
:value="item.id"
:label="item.name" /></ElSelect
></ElFormItem>
<ElFormItem label="交易单号" required><ElInput v-model="tradeNo" /></ElFormItem>
<ElFormItem v-if="mode === 'offline'" label="实收金额(元)" required
><ElInput v-model="paidYuan" inputmode="decimal"
/></ElFormItem>
<ElFormItem label="付款时间" required
><ElDatePicker
v-model="paidAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ssZ"
format="YYYY-MM-DD HH:mm:ss"
/></ElFormItem>
<ElFormItem label="收款凭证" required
><VoucherUpload
v-model="vouchers"
:key="`vouchers-${mode}`"
:max-count="5"
@uploading-change="(value) => (uploadingReceipt = value)"
/></ElFormItem>
</template>
<ElFormItem label="申请记录/截图"
><VoucherUpload
v-model="evidence"
:max-count="5"
@uploading-change="(value) => (uploadingEvidence = value)"
/></ElFormItem>
</ElForm>
<ElAlert
v-if="order"
:title="`订单 ${order.order_no || '-'}:${order.status_name || order.status || '-'};实际生效以完成状态为准`"
type="info"
:closable="false"
/>
<div v-if="order?.status === 'pending_payment' && order.qr_content" class="silence-qr"
><QrcodeVue :value="order.qr_content" :size="200" level="M" /><p
>支付截止:{{ order.expire_at ? formatDateTime(order.expire_at) : '-' }}</p
></div
>
<ElButton v-if="order?.id" :loading="checking" @click="refreshOrder">刷新订单状态</ElButton>
<ElAlert
v-if="batch"
:title="`批次 ${batch.batch_no || '-'}:${batch.status || '-'};审批 ${batch.approval_status || '-'};成功 ${batch.succeeded_count ?? '-'},失败 ${batch.failed_count ?? '-'}`"
:closable="false"
/>
<SilenceTable v-if="batch?.items?.length" :rows="batch.items" :definitions="resultFields" />
<ElTable v-if="rowErrors.length" :data="rowErrors"
><ElTableColumn label="行号"
><template #default="{ row }">{{ row.row_no ?? row.row ?? '-' }}</template></ElTableColumn
><ElTableColumn label="失败原因"
><template #default="{ row }">{{
row.reason || row.message || '-'
}}</template></ElTableColumn
></ElTable
>
<template #footer
><ElButton @click="$emit('close')">关闭</ElButton
><ElButton
v-if="!order && !batch"
type="primary"
:loading="submitting"
:disabled="!canSubmit || uploadingReceipt || uploadingEvidence"
@click="submit"
>确认提交</ElButton
></template
>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, onDeactivated } from 'vue'
import { ElMessageBox } from 'element-plus'
import type { UploadFile } from 'element-plus'
import QrcodeVue from 'qrcode.vue'
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
import { EmployeeCollectionService } from '@/api/modules/employeeCollection'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { pollingScope } from '@/utils/business/pollingScheduler'
import { PACKAGE_SILENCE_PERMISSIONS as P } from '@/config/constants/packageSilence'
import {
silenceData,
silenceId,
silenceDays,
silenceFen,
silenceDate,
silenceAmount,
parseSilenceCsv,
silenceRowsError,
SILENCE_SPECIAL_TYPES,
silenceOptions
} from '@/utils/business/silenceExtension'
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { formatDateTime } from '@/utils/business/format'
import type {
SilenceExtensionOrderResponse,
SilenceBatchResponse,
SilenceSpecialItem
} from '@/types/api/packageSilenceContract'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import SilenceTable from './SilenceTable.vue'
const props = defineProps<{ usageId?: number; allowed?: boolean; batchMode?: boolean }>()
const emit = defineEmits<{ close: []; changed: [] }>()
const { hasAuth } = useAuth(),
user = useUserStore()
const mode = ref<'online' | 'offline' | 'special'>(
props.batchMode ? 'special' : hasAuth(P.extend) ? 'online' : 'offline'
)
const days = ref<number | undefined>(),
paymentMethod = ref('wechat'),
specialType = ref('free'),
reason = ref(''),
basis = ref('')
const methodId = ref<number>(),
tradeNo = ref(''),
paidYuan = ref(''),
paidAt = ref(''),
vouchers = ref<string[]>([]),
evidence = ref<string[]>([])
const batchItems = ref<SilenceSpecialItem[]>([]),
specialUncertain = ref(false),
error = ref(''),
submitting = ref(false),
checking = ref(false)
const uploadingReceipt = ref(false),
uploadingEvidence = ref(false),
methodsLoading = ref(false),
methods = ref<{ id: number; name: string }[]>([])
const order = ref<SilenceExtensionOrderResponse>(),
batch = ref<SilenceBatchResponse>(),
rowErrors = ref<ReturnType<typeof silenceRowsError>>([])
const specialTypes = silenceOptions(SILENCE_SPECIAL_TYPES)
const allowed = () =>
props.allowed !== false &&
(mode.value === 'offline' ? hasAuth(P.offline) : hasAuth(P.extend)) &&
(!props.batchMode || hasAuth(P.batch))
const eligibilityAllowed = computed(() => allowed() && !!props.usageId)
const {
data: eligibility,
loading: eligibilityLoading,
error: eligibilityError,
load: loadEligibility,
clear: clearEligibility
} = useSchedulerRequest(
async () => silenceData(await Service.eligibility(silenceId(props.usageId))),
() => eligibilityAllowed.value
)
const {
data: quote,
loading: quoteLoading,
load: requestQuote,
clear: clearQuote,
error: quoteError
} = useSchedulerRequest(
async () => {
const input = silenceDays(days.value),
usage = silenceId(props.usageId)
return { ...silenceData(await Service.quote(usage, input)), forDays: input, forUsage: usage }
},
() => eligibilityAllowed.value && !!eligibility.value?.can_extend
)
const requiresReceipt = computed(
() =>
mode.value === 'offline' ||
(mode.value === 'special' && specialType.value === 'discount' && Number(paidYuan.value) > 0)
)
const canSubmit = computed(
() =>
allowed() &&
!specialUncertain.value &&
(mode.value === 'special'
? batchItems.value.length
? hasAuth(P.batch)
: !!eligibility.value?.special_extension_available
: !!eligibility.value?.can_extend &&
!!quote.value?.valid &&
quote.value.forDays === days.value &&
quote.value.forUsage === props.usageId &&
Number.isSafeInteger(quote.value.amount))
)
let active = true,
version = 0,
timer: ReturnType<typeof setTimeout> | undefined
const requestIds = new Map<string, string>()
function stop() {
if (timer) clearTimeout(timer)
timer = undefined
}
function invalidate() {
version++
stop()
order.value = undefined
batch.value = undefined
rowErrors.value = []
error.value = ''
clearQuote()
clearEligibility()
vouchers.value = []
evidence.value = []
methods.value = []
methodsLoading.value = false
days.value = undefined
reason.value = ''
basis.value = ''
methodId.value = undefined
tradeNo.value = ''
paidYuan.value = ''
paidAt.value = ''
batchItems.value = []
}
watch(
() => [props.usageId, props.allowed, pollingScope(user)],
() => {
invalidate()
if (props.usageId && allowed()) void loadEligibility()
},
{ flush: 'sync' }
)
watch(
() => [mode.value, days.value],
() => {
clearQuote()
error.value = ''
},
{ flush: 'sync' }
)
watch(
() => allowed(),
(value) => {
if (!value) invalidate()
},
{ flush: 'sync' }
)
watch(quoteError, (value) => {
if (value) error.value = value
})
async function loadQuote() {
await requestQuote()
}
async function loadMethods() {
if (!allowed() || methodsLoading.value) return
const current = version,
scope = pollingScope(user)
methodsLoading.value = true
try {
const result = silenceData(
await EmployeeCollectionService.getPaymentMethods({
page: 1,
page_size: 100,
enabled: true
})
)
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
const values = Array.isArray(result) ? result : result.items || []
methods.value = values.map((item) => ({ id: item.id, name: item.name }))
} catch (e) {
if (current === version) error.value = gatewayError(e)
} finally {
if (current === version) methodsLoading.value = false
}
}
async function readCsv(file: UploadFile) {
try {
requireGatewayAccess(allowed() && hasAuth(P.batch))
if (!file.raw || !file.name.toLowerCase().endsWith('.csv')) throw new Error('请选择 CSV 文件')
const current = version,
items = parseSilenceCsv(await file.raw.text())
if (active && current === version && allowed() && hasAuth(P.batch)) batchItems.value = items
} catch (e) {
error.value = gatewayError(e)
}
}
async function resumeOrder() {
const no = eligibility.value?.pending_order_no,
current = version
if (!no || !allowed()) return
try {
const result = silenceData(await Service.orders({ order_no: no, page: 1, page_size: 20 }))
if (active && current === version && allowed()) {
order.value = result.items?.find((item) => item.order_no === no)
schedule()
}
} catch (e) {
if (current === version) error.value = gatewayError(e)
}
}
function schedule() {
stop()
if (active && allowed() && ['pending_payment', 'paid'].includes(order.value?.status || ''))
timer = setTimeout(() => void refreshOrder(), 3000)
}
async function refreshOrder() {
stop()
const id = order.value?.id,
current = version,
scope = pollingScope(user)
if (!id || checking.value || !allowed()) return
checking.value = true
try {
const result = silenceData(await Service.order(id))
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
order.value = result
if (result.status === 'completed') emit('changed')
schedule()
} catch (e) {
if (current === version) error.value = gatewayError(e)
} finally {
checking.value = false
}
}
async function submit() {
if (submitting.value) return
const current = version,
scope = pollingScope(user),
currentMode = mode.value
submitting.value = true
error.value = ''
rowErrors.value = []
let specialSent = false
try {
requireGatewayAccess(canSubmit.value && !uploadingReceipt.value && !uploadingEvidence.value)
const input =
currentMode === 'special' && batchItems.value.length ? 1 : silenceDays(days.value),
usage =
batchItems.value.length && currentMode === 'special'
? undefined
: silenceId(props.usageId)
const common = { package_usage_id: usage!, days: input, evidence_refs: [...evidence.value] }
const receipt = requiresReceipt.value
? {
offline_payment_method_id: silenceId(methodId.value, '收款方式'),
offline_trade_no: tradeNo.value.trim(),
paid_at: silenceDate(paidAt.value),
payment_voucher_keys: [...vouchers.value]
}
: undefined
if (
receipt &&
(!receipt.offline_trade_no ||
receipt.payment_voucher_keys.length < 1 ||
receipt.payment_voucher_keys.length > 5)
)
throw new Error('请填写交易单号,并上传 1~5 张收款凭证')
const amount =
currentMode === 'offline' || (currentMode === 'special' && specialType.value === 'discount')
? silenceFen(paidYuan.value)
: 0
if (currentMode === 'offline' && amount !== quote.value?.amount)
throw new Error('实收金额必须等于当前后端试算金额')
const items = batchItems.value.length
? batchItems.value.map((item) => ({ ...item }))
: [{ package_usage_id: usage!, days: input }]
if (currentMode === 'special' && (!reason.value.trim() || !basis.value.trim()))
throw new Error('请填写特殊延期原因和依据')
if (currentMode === 'special' && items.length > 1) requireGatewayAccess(hasAuth(P.batch))
const body =
currentMode === 'special'
? {
extension_type: specialType.value,
items,
reason: reason.value.trim(),
basis: basis.value.trim(),
amount,
evidence_refs: [...evidence.value],
...(receipt
? {
offline_payment_method_id: receipt.offline_payment_method_id,
offline_trade_no: receipt.offline_trade_no,
offline_paid_at: receipt.paid_at,
payment_voucher_keys: receipt.payment_voucher_keys
}
: {})
}
: currentMode === 'offline'
? { ...common, ...receipt!, paid_amount: amount }
: { ...common, payment_method: paymentMethod.value }
const fingerprint = JSON.stringify([currentMode, body])
if (!requestIds.has(fingerprint)) requestIds.set(fingerprint, crypto.randomUUID())
await ElMessageBox.confirm(
currentMode === 'special'
? `确认提交 ${items.length} 项特殊延期申请?结果以审批和逐项执行为准。`
: '确认提交延期订单?待支付或待审批并不代表已生效。',
'提交确认',
{ type: 'warning' }
)
requireGatewayAccess(
active &&
current === version &&
scope === pollingScope(user) &&
currentMode === mode.value &&
canSubmit.value
)
// 表单在确认框期间被锁定;同一快照重试复用幂等键,不自动重发写请求。
specialSent = currentMode === 'special'
const result =
currentMode === 'special'
? silenceData(await Service.special(body as Parameters<typeof Service.special>[0]))
: currentMode === 'offline'
? silenceData(
await Service.createOffline({
...body,
request_id: requestIds.get(fingerprint)!
} as Parameters<typeof Service.createOffline>[0])
)
: silenceData(
await Service.createQrcode({
...body,
request_id: requestIds.get(fingerprint)!
} as Parameters<typeof Service.createQrcode>[0])
)
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
if (currentMode === 'special') batch.value = result as SilenceBatchResponse
else {
order.value = result as SilenceExtensionOrderResponse
schedule()
}
emit('changed')
} catch (e) {
if (active && current === version && e !== 'cancel' && e !== 'close') {
error.value = gatewayError(e)
rowErrors.value = silenceRowsError(e)
if (
specialSent &&
!(e as { code?: unknown; response?: unknown })?.code &&
!(e as { response?: unknown })?.response
) {
specialUncertain.value = true
error.value += '。提交结果未确认,请先到延期批次列表核对,不要重复提交。'
}
}
} finally {
submitting.value = false
}
}
const resultFields = [
['row_no', '行号'],
['asset_identifier', '资产'],
['package_name', '套餐'],
['status', '处理状态'],
['after_deadline_at', '延期后截止'],
['failure_summary', '失败原因']
].map(([prop, label]) => ({ prop, label }))
onMounted(() => {
if (props.usageId) void loadEligibility()
})
onDeactivated(() => {
active = false
invalidate()
})
onBeforeUnmount(() => {
active = false
invalidate()
})
</script>
<style scoped>
.silence-qr {
padding: 16px;
text-align: center;
}
.el-alert {
margin-bottom: 16px;
}
.el-form-item p {
margin: 0;
line-height: 1.5;
}
@media (width <= 640px) {
:deep(.el-form-item) {
flex-wrap: wrap;
}
:deep(.el-form-item__label) {
justify-content: flex-start;
width: 100% !important;
}
:deep(.el-form-item__content) {
flex-basis: 100%;
margin-left: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,633 @@
<template>
<ArtTableFullScreen
><div id="table-full-screen">
<ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="100px"
@search="search"
@reset="reset"
/>
<ElCard shadow="never" class="art-table-card">
<ElAlert
v-if="error || actionError"
:title="error || actionError"
type="error"
:closable="false"
/>
<SilenceTable
:rows="displayRows"
:row-key="kind === 'usages' ? 'package_usage_id' : 'id'"
:definitions="fields"
:loading="loading"
:total="kind === 'policies' ? undefined : total"
:page="page"
:size="size"
@refresh="load"
@page="
(value) => {
page = value
load()
}
"
@size="
(value) => {
size = value
search()
}
"
>
<template #toolbar>
<ElButton
v-if="kind === 'policies' && canManagePolicy"
type="primary"
@click="editPolicy()"
>新增政策</ElButton
>
<ElButton v-if="kind === 'policies' && canManagePolicy" @click="matchVisible = true"
>查询套餐匹配政策</ElButton
>
<ElButton
v-if="kind === 'batches' && allowed && hasAuth(P.extend) && hasAuth(P.batch)"
type="primary"
@click="batchVisible = true"
>批量特殊延期</ElButton
>
<ElButton
v-if="exportPermission && allowed && hasAuth(exportPermission)"
@click="openExport"
>导出</ElButton
>
</template>
<ElTableColumn label="操作" fixed="right" min-width="200"
><template #default="{ row }">
<ElButton v-if="canDetail" type="primary" link @click="detail(row)">详情</ElButton>
<template v-if="kind === 'usages' && row.silence">
<ElButton link type="primary" @click="recordsId = row.package_usage_id"
>沉默期流水</ElButton
>
<ElButton
v-if="row.silence.extendable && (hasAuth(P.extend) || hasAuth(P.offline))"
link
type="primary"
@click="extendId = row.package_usage_id"
>延期</ElButton
>
</template>
<template v-if="kind === 'policies' && canManagePolicy"
><ElButton link type="primary" @click="editPolicy(row)">编辑</ElButton
><ElButton link :disabled="changing" @click="togglePolicy(row)">{{
row.status === 1 ? '停用' : '启用'
}}</ElButton></template
>
</template></ElTableColumn
>
</SilenceTable>
<div v-if="kind === 'policies'" class="policy-pager">
<span>第 {{ page }} 页(接口未返回总数)</span>
<ElButton :disabled="page <= 1 || loading" @click="previousPolicyPage">上一页</ElButton>
<ElButton :disabled="rows.length < size || loading" @click="nextPolicyPage"
>下一页</ElButton
>
</div>
</ElCard>
<ElDialog
v-if="matchVisible"
:model-value="true"
title="查询套餐匹配政策"
width="min(600px,96vw)"
@close="closeMatch"
>
<ElForm label-width="80px">
<ElFormItem label="套餐" required>
<BusinessEntitySelect
v-model="matchPackage"
entity="package"
:allowed="canManagePolicy"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeMatch">取消</ElButton>
<ElButton
type="primary"
:disabled="!matchPackage || !canManagePolicy"
:loading="matching"
@click="matchPolicy"
>查询</ElButton
>
</template>
</ElDialog>
<ElDialog
v-if="matchResult"
:model-value="true"
title="当前匹配政策"
width="min(600px,96vw)"
@close="matchResult = undefined"
><ElDescriptions :column="1" border
><ElDescriptionsItem label="是否匹配">{{
matchResult.matched ? '是' : '否'
}}</ElDescriptionsItem
><ElDescriptionsItem label="政策">{{
matchResult.policy?.name || '-'
}}</ElDescriptionsItem
><ElDescriptionsItem label="月单价">{{
silenceAmount(matchResult.policy?.unit_price)
}}</ElDescriptionsItem></ElDescriptions
></ElDialog
>
<SilenceExtendDialog
v-if="extendId || batchVisible"
:usage-id="extendId"
:batch-mode="batchVisible"
:allowed="allowed"
@close="closeExtension"
@changed="load"
/>
<SilenceRecordsDialog
v-if="recordsId"
:usage-id="recordsId"
:allowed="allowed"
@close="recordsId = undefined"
/>
<SilencePolicyDialog
v-if="policyVisible"
:id="policyId"
:allowed="canManagePolicy"
@close="policyVisible = false"
@saved="load"
/>
<ExportTaskCreateDialog
v-if="exportVisible && exportScene"
v-model="exportVisible"
:scene="exportScene"
:query="exportQuery"
:confirm-permission="exportPermission"
/> </div
></ArtTableFullScreen>
</template>
<script setup lang="ts">
import {
ref,
reactive,
computed,
onMounted,
onBeforeUnmount,
onActivated,
onDeactivated,
watch
} from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { pollingScope } from '@/utils/business/pollingScheduler'
import {
silenceData,
silenceRange,
silenceDate,
silenceId,
silenceAmount,
silenceOptions,
SILENCE_ORDER_STATUS,
SILENCE_SPECIAL_TYPES
} from '@/utils/business/silenceExtension'
import { compactGatewayQuery, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import {
SILENCE_PAGES,
PACKAGE_SILENCE_PERMISSIONS as P
} from '@/config/constants/packageSilence'
import type { SearchFormItem } from '@/types'
import type { ExportTaskScene } from '@/types/api/exportTask'
import type { SilencePolicyMatchResponse } from '@/types/api/packageSilenceContract'
import SilenceTable from './SilenceTable.vue'
import SilenceExtendDialog from './SilenceExtendDialog.vue'
import SilenceRecordsDialog from './SilenceRecordsDialog.vue'
import SilencePolicyDialog from './SilencePolicyDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import BusinessEntitySelect from '@/components/business/BusinessEntitySelect.vue'
const props = defineProps<{ kind: 'orders' | 'batches' | 'usages' | 'policies' }>()
const router = useRouter(),
user = useUserStore(),
{ hasAuth } = useAuth(),
page = ref(1),
size = ref(20)
const allowed = computed(
() =>
hasAuth(SILENCE_PAGES[props.kind].page) && (props.kind !== 'policies' || hasAuth(P.policy))
)
const canManagePolicy = computed(() => allowed.value && hasAuth(P.policy))
const canDetail = computed(
() =>
allowed.value &&
(props.kind === 'orders' || props.kind === 'batches') &&
hasAuth(SILENCE_PAGES[props.kind as 'orders' | 'batches'].detail) &&
hasAuth(SILENCE_PAGES[props.kind as 'orders' | 'batches'].detailPage)
)
const filters = reactive<Record<string, any>>({}),
actionError = ref(''),
changing = ref(false)
const recordsId = ref<number>(),
extendId = ref<number>(),
batchVisible = ref(false),
policyVisible = ref(false),
policyId = ref<number>(),
matchResult = ref<SilencePolicyMatchResponse>()
const matchVisible = ref(false),
matchPackage = ref<number>(),
matching = ref(false)
let matchSequence = 0
const exportVisible = ref(false),
exportQuery = ref<Record<string, unknown>>({})
const exportScene = computed<ExportTaskScene | undefined>(() =>
props.kind === 'orders'
? 'package_silence_order'
: props.kind === 'usages'
? 'package_silence_usage'
: undefined
)
const exportPermission = computed(() =>
props.kind === 'orders'
? SILENCE_PAGES.orders.export
: props.kind === 'usages'
? SILENCE_PAGES.usages.export
: ''
)
const queryKeys = {
orders: [
'package_usage_id',
'package_id',
'asset_identifier',
'order_no',
'status',
'source',
'payment_method',
'merchant_name',
'third_party_trade_no'
],
batches: ['batch_no', 'path', 'extension_type', 'status', 'has_exception'],
usages: ['view', 'within_days', 'asset_identifier', 'package_id', 'shop_id'],
policies: ['name', 'scope_type', 'status']
}
function query() {
const value: Record<string, unknown> = {}
for (const key of queryKeys[props.kind]) {
const raw = filters[key]
value[key] =
['package_usage_id', 'package_id', 'shop_id', 'within_days'].includes(key) &&
raw != null &&
raw !== ''
? silenceId(Number(raw), key)
: raw
}
if (props.kind === 'usages')
Object.assign(value, silenceRange(filters.time_range, 'deadline_start', 'deadline_end'))
if (props.kind === 'orders')
Object.assign(value, silenceRange(filters.time_range, 'paid_start', 'paid_end'))
if (props.kind === 'policies' && filters.effective_at)
value.effective_at = silenceDate(filters.effective_at)
return compactGatewayQuery(value)
}
const { data, loading, error, load, clear } = useSchedulerRequest(
async () => {
const params = { ...query(), page: page.value, page_size: size.value }
if (props.kind === 'orders') return silenceData(await Service.orders(params))
if (props.kind === 'batches') return silenceData(await Service.batches(params))
if (props.kind === 'usages') return silenceData(await Service.usages(params))
return silenceData(await Service.policies(params))
},
() => allowed.value
)
const rows = computed(() => (Array.isArray(data.value) ? data.value : data.value?.items || []))
const total = computed(() =>
Array.isArray(data.value) ? data.value.length : data.value?.total || 0
)
const input = (prop: string, label: string): SearchFormItem => ({
prop,
label,
type: 'input',
config: { clearable: true }
})
const select = (prop: string, label: string, map: Record<string, string>): SearchFormItem => ({
prop,
label,
type: 'select',
options: silenceOptions(map),
config: { clearable: true }
})
const number = (prop: string, label: string): SearchFormItem => ({
prop,
label,
type: 'input',
config: { inputmode: 'numeric', clearable: true }
})
const entity = (prop: string, label: string, kind: string): SearchFormItem => ({
prop,
label,
type: 'business-select',
config: {
entity: kind,
allowed: allowed.value,
...(kind === 'usage'
? {
context: {
asset_identifier: filters.asset_identifier || undefined,
package_id: filters.package_id || undefined
}
}
: {})
}
})
const time: SearchFormItem = {
prop: 'time_range',
label: props.kind === 'orders' ? '支付起止时间' : '截止起止时间',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
format: 'YYYY-MM-DD HH:mm:ss',
clearable: true
}
}
const searchItems = computed<SearchFormItem[]>(() =>
props.kind === 'orders'
? [
entity('asset_identifier', '资产', 'asset'),
input('order_no', '订单号'),
select('status', '订单状态', SILENCE_ORDER_STATUS),
select('source', '来源', { client: '客户端', admin: '后台', offline: '线下' }),
select('payment_method', '支付方式', {
wechat: '微信',
alipay: '支付宝',
offline: '线下'
}),
input('merchant_name', '收款商户'),
input('third_party_trade_no', '交易单号'),
entity('package_usage_id', '套餐记录', 'usage'),
entity('package_id', '套餐', 'package'),
time
]
: props.kind === 'batches'
? [
input('batch_no', '批次号'),
select('path', '办理路径', {
online: '在线',
offline: '线下',
special: '特殊延期',
adjust: '购买调整'
}),
select('extension_type', '延期类型', SILENCE_SPECIAL_TYPES),
input('status', '批次状态编码'),
{
prop: 'has_exception',
label: '含异常',
type: 'select',
options: [
{ label: '是', value: true },
{ label: '否', value: false }
],
config: { clearable: true }
}
]
: props.kind === 'usages'
? [
select('view', '视图', { expiring: '即将过期', expired: '已过期', all: '全部' }),
number('within_days', '临期天数'),
entity('asset_identifier', '资产', 'asset'),
entity('package_id', '套餐', 'package'),
entity('shop_id', '店铺', 'shop'),
time
]
: [
input('name', '政策名称'),
select('scope_type', '适用范围', {
all: '全部套餐',
series: '套餐系列',
package: '指定套餐'
}),
{
prop: 'status',
label: '状态',
type: 'select',
options: [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
],
config: { clearable: true }
},
{
prop: 'effective_at',
label: '生效于',
type: 'datetime',
config: { type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm:ssZ', clearable: true }
}
]
)
const definitions = {
orders: [
['order_no', '订单号'],
['asset_identifier', '资产标识'],
['package_name', '套餐'],
['status_name', '状态'],
['source_name', '来源'],
['extension_days', '延长天数'],
['amount', '应付'],
['paid_amount', '实付'],
['payment_method', '支付方式'],
['payment_merchant_name', '收款商户'],
['third_party_trade_no', '交易流水号'],
['paid_at', '支付时间'],
['expected_deadline_at', '预计截止'],
['completed_at', '生效时间'],
['created_at', '创建时间']
],
batches: [
['batch_no', '批次号'],
['path', '办理路径'],
['extension_type', '延期类型'],
['status', '状态'],
['approval_status', '审批状态'],
['requested_count', '总项数'],
['succeeded_count', '成功'],
['failed_count', '失败'],
['amount', '金额'],
['requested_at', '发起时间'],
['completed_at', '完成时间']
],
usages: [
['asset_identifier', '资产标识'],
['asset_type', '资产类型'],
['shop_name', '店铺'],
['package_name', '套餐'],
['package_usage_id', '套餐记录 ID'],
['silence_status_name', '沉默期状态'],
['silence_deadline_at', '截止时间'],
['silence_extended_days', '累计延期天数'],
['order_no', '购买订单'],
['created_at', '购买时间']
],
policies: [
['name', '政策名称'],
['scope_type_name', '适用范围'],
['scope_ids', '范围 ID'],
['unit_price', '月单价'],
['free_extension_limit', '免费次数'],
['paid_max_months', '付费累计最多月数'],
['single_max_months', '单次最多月数'],
['effective_from', '生效时间'],
['effective_to', '失效时间'],
['status', '状态'],
['updated_at', '更新时间']
]
}
const fields = computed(() => definitions[props.kind].map(([prop, label]) => ({ prop, label })))
const displayRows = computed(() =>
rows.value.map((row) =>
props.kind === 'usages' ? { ...row, ...('silence' in row ? row.silence : {}) } : row
)
)
// 用扁平显示行,同时保留原 silence 对象供操作入口判定。
function search() {
page.value = 1
void load()
}
function previousPolicyPage() {
page.value--
void load()
}
function nextPolicyPage() {
page.value++
void load()
}
function closeExtension() {
extendId.value = undefined
batchVisible.value = false
}
function reset() {
clear()
Object.keys(filters).forEach((key) => delete filters[key])
if (props.kind === 'usages') filters.view = 'expiring'
search()
}
function detail(row: Record<string, any>) {
requireGatewayAccess(canDetail.value)
void router.push(`/package-management/silence-${props.kind}/detail/${silenceId(row.id)}`)
}
function editPolicy(row?: Record<string, any>) {
if (!canManagePolicy.value) return
policyId.value = row?.id
policyVisible.value = true
}
function openExport() {
try {
requireGatewayAccess(allowed.value && hasAuth(exportPermission.value))
exportQuery.value = { filters: query() }
exportVisible.value = true
} catch (e) {
actionError.value = gatewayError(e)
}
}
async function matchPolicy() {
if (!canManagePolicy.value || !matchPackage.value || matching.value) return
const scope = pollingScope(user),
current = ++matchSequence,
packageId = matchPackage.value
matching.value = true
try {
requireGatewayAccess(active && canManagePolicy.value && scope === pollingScope(user))
const result = silenceData(await Service.match(silenceId(packageId, '套餐')))
if (
active &&
canManagePolicy.value &&
scope === pollingScope(user) &&
current === matchSequence &&
packageId === matchPackage.value
) {
matchResult.value = result
closeMatch()
}
} catch (e) {
if (active && current === matchSequence && scope === pollingScope(user))
actionError.value = gatewayError(e)
} finally {
if (current === matchSequence) matching.value = false
}
}
function closeMatch() {
matchSequence++
matchVisible.value = false
matchPackage.value = undefined
matching.value = false
}
watch(
() => [filters.asset_identifier, filters.package_id],
() => {
delete filters.package_usage_id
},
{ flush: 'sync' }
)
async function togglePolicy(row: Record<string, any>) {
if (changing.value) return
const scope = pollingScope(user),
id = silenceId(row.id),
status = row.status === 1 ? 0 : 1
changing.value = true
try {
requireGatewayAccess(canManagePolicy.value)
await ElMessageBox.confirm(`确认${status ? '启用' : '停用'} ${row.name}?`, '状态确认', {
type: 'warning'
})
requireGatewayAccess(active && canManagePolicy.value && scope === pollingScope(user))
silenceData(await Service.policyStatus(id, status))
if (active && scope === pollingScope(user)) await load()
} catch (e) {
if (e !== 'cancel' && e !== 'close') actionError.value = gatewayError(e)
} finally {
changing.value = false
}
}
watch(
() => [allowed.value, pollingScope(user)],
() => {
recordsId.value = undefined
extendId.value = undefined
batchVisible.value = false
policyVisible.value = false
exportVisible.value = false
matchResult.value = undefined
closeMatch()
actionError.value = ''
},
{ flush: 'sync' }
)
let active = true
function closeDialogs() {
recordsId.value = undefined
closeExtension()
policyVisible.value = false
exportVisible.value = false
matchResult.value = undefined
closeMatch()
}
onDeactivated(() => {
active = false
closeDialogs()
})
onActivated(() => {
active = true
})
onBeforeUnmount(() => {
active = false
closeDialogs()
})
onMounted(() => {
if (props.kind === 'usages') filters.view = 'expiring'
void load()
})
</script>

View File

@@ -0,0 +1,246 @@
<template>
<ElDialog
:model-value="true"
:title="id ? '编辑延期政策' : '新增延期政策'"
width="min(780px, 96vw)"
destroy-on-close
@close="$emit('close')"
>
<ElAlert
title="同级范围及生效时间不能重叠;变更只影响之后发起的业务,不改变历史订单和审批快照。金额输入单位为元。"
type="info"
:closable="false"
/>
<ElAlert v-if="error" :title="error" type="error" :closable="false" />
<ElForm v-loading="loading" label-width="160px" :disabled="saving || loading">
<ElFormItem label="政策名称" required><ElInput v-model="form.name" /></ElFormItem>
<ElFormItem label="适用范围" required
><ElSelect v-model="form.scope_type" @change="form.scope_ids = []"
><ElOption value="all" label="全部套餐" /><ElOption
value="series"
label="套餐系列" /><ElOption value="package" label="指定套餐" /></ElSelect
></ElFormItem>
<ElFormItem v-if="form.scope_type !== 'all'" label="适用系列/套餐" required
><BusinessEntitySelect
v-model="form.scope_ids"
:entity="form.scope_type === 'package' ? 'package' : 'series'"
:allowed="allowed"
multiple
/>
<p>范围优先级:指定套餐 > 套餐系列 > 全部套餐。</p></ElFormItem
>
<ElFormItem label="月单价(元)" required
><ElInput v-model="unitYuan" inputmode="decimal"
/></ElFormItem>
<ElFormItem label="免费延期次数"
><ElInputNumber v-model="form.free_extension_limit" :min="0" :precision="0"
/></ElFormItem>
<ElFormItem label="付费累计最多月数"
><ElInputNumber v-model="form.paid_max_months" :min="0" :precision="0"
/></ElFormItem>
<ElFormItem label="限制单次月数"><ElSwitch v-model="singleLimited" /></ElFormItem>
<ElFormItem v-if="singleLimited" label="单次最多月数"
><ElInputNumber v-model="form.single_max_months" :min="1" :precision="0"
/></ElFormItem>
<ElFormItem label="生效时间" required
><ElDatePicker
v-model="form.effective_from"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ssZ"
format="YYYY-MM-DD HH:mm:ss"
/></ElFormItem>
<ElFormItem label="失效时间"
><ElDatePicker
v-model="form.effective_to"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ssZ"
format="YYYY-MM-DD HH:mm:ss"
clearable
/><p>不填表示长期。</p></ElFormItem
>
<ElFormItem label="免审批场景"
><ElSelect
v-model="form.approval_exempt_types"
multiple
filterable
allow-create
default-first-option
placeholder="空表示全部需要审批"
><ElOption value="offline_paid" label="线下付费延期" /><ElOption
value="batch"
label="批量特殊延期" /><ElOption
v-for="value in form.approval_exempt_types.filter(
(x) => !['offline_paid', 'batch'].includes(x)
)"
:key="value"
:value="value"
:label="value" /></ElSelect
><p>接口未枚举完整场景;其他场景须填写后端提供的精确编码,不填写显示文案。</p></ElFormItem
>
<ElFormItem label="启用"
><ElSwitch v-model="form.status" :active-value="1" :inactive-value="0"
/></ElFormItem>
<ElFormItem label="备注"><ElInput v-model="form.remark" type="textarea" /></ElFormItem>
</ElForm>
<template #footer
><ElButton @click="$emit('close')">取消</ElButton
><ElButton
type="primary"
:loading="saving"
:disabled="loading || !allowed || !loaded"
@click="submit"
>保存</ElButton
></template
>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch, onBeforeUnmount } from 'vue'
import { ElMessageBox } from 'element-plus'
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
import BusinessEntitySelect from '@/components/business/BusinessEntitySelect.vue'
import {
silenceData,
silenceDate,
silenceFen,
silenceId,
validateSilencePolicy
} from '@/utils/business/silenceExtension'
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { pollingScope } from '@/utils/business/pollingScheduler'
import { useUserStore } from '@/store/modules/user'
import type { SilencePolicyRequest } from '@/types/api/packageSilenceContract'
const props = defineProps<{ id?: number; allowed: boolean }>(),
emit = defineEmits<{ close: []; saved: [] }>(),
user = useUserStore()
const form = reactive({
name: '',
scope_type: 'all',
scope_ids: [] as (number | string)[],
free_extension_limit: 0,
paid_max_months: 0,
single_max_months: 1,
effective_from: '',
effective_to: '' as string | null,
approval_exempt_types: [] as string[],
status: 1,
remark: ''
})
const unitYuan = ref('0.00'),
singleLimited = ref(false),
loading = ref(false),
saving = ref(false),
loaded = ref(!props.id),
error = ref('')
let active = true,
version = 0
watch(
() => [props.id, props.allowed, pollingScope(user)],
() => {
version++
loaded.value = false
error.value = ''
emit('close')
},
{ flush: 'sync' }
)
onMounted(async () => {
if (!props.id || !props.allowed) return
const current = version
loading.value = true
try {
const value = silenceData(await Service.policy(silenceId(props.id)))
if (!active || current !== version || !props.allowed) return
Object.assign(form, {
name: value.name || '',
scope_type: value.scope_type || 'all',
scope_ids: value.scope_ids || [],
free_extension_limit: value.free_extension_limit ?? 0,
paid_max_months: value.paid_max_months ?? 0,
single_max_months: value.single_max_months ?? 1,
effective_from: value.effective_from || '',
effective_to: value.effective_to || null,
approval_exempt_types: value.approval_exempt_types || [],
status: value.status ?? 1,
remark: value.remark || ''
})
unitYuan.value = ((value.unit_price ?? 0) / 100).toFixed(2)
singleLimited.value = value.single_max_months != null
loaded.value = true
} catch (e) {
if (current === version) error.value = gatewayError(e)
} finally {
if (current === version) loading.value = false
}
})
async function submit() {
if (saving.value) return
saving.value = true
const current = version,
scope = pollingScope(user)
try {
requireGatewayAccess(props.allowed && loaded.value)
const body: SilencePolicyRequest = {
...form,
name: form.name.trim(),
scope_ids:
form.scope_type === 'all' ? [] : form.scope_ids.map((value) => silenceId(Number(value))),
unit_price: silenceFen(unitYuan.value),
single_max_months: singleLimited.value ? form.single_max_months : null,
effective_from: silenceDate(form.effective_from),
effective_to: form.effective_to ? silenceDate(form.effective_to) : null,
approval_exempt_types: [...form.approval_exempt_types]
}
validateSilencePolicy(body)
await ElMessageBox.confirm(
'确认保存延期政策?历史订单和已提交审批不受此次修改影响。',
'保存确认',
{ type: 'warning' }
)
requireGatewayAccess(
active && current === version && scope === pollingScope(user) && props.allowed
)
if (props.id) silenceData(await Service.updatePolicy(props.id, { ...body, id: props.id }))
else silenceData(await Service.createPolicy(body))
if (active && current === version && props.allowed) {
emit('saved')
emit('close')
}
} catch (e) {
if (active && current === version && e !== 'cancel' && e !== 'close')
error.value = gatewayError(e)
} finally {
saving.value = false
}
}
onBeforeUnmount(() => {
active = false
version++
})
</script>
<style scoped>
.el-alert {
margin-bottom: 16px;
}
p {
margin: 0;
line-height: 1.5;
}
@media (width <= 640px) {
:deep(.el-form-item) {
flex-wrap: wrap;
}
:deep(.el-form-item__label) {
justify-content: flex-start;
width: 100% !important;
}
:deep(.el-form-item__content) {
flex-basis: 100%;
margin-left: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<ElFormItem label="沉默期(天)"
><ElSwitch
v-model="overrideEnabled"
active-text="调整本次沉默期"
inactive-text="使用默认值"
:disabled="disabled" />
<ElInputNumber
v-if="overrideEnabled"
v-model="days"
:min="1"
:precision="0"
:controls="false"
clearable
:disabled="disabled"
placeholder="留空使用默认值"
/></ElFormItem>
<ElFormItem v-if="overrideEnabled" label="调整原因" :required="days != null"
><ElInput v-model="reason" type="textarea" :disabled="disabled"
/></ElFormItem>
<p class="silence-help"
>超过默认沉默期可能需审批,以后台返回结果为准;本次覆盖不受商品默认值 90 天上限限制。</p
>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const days = defineModel<number | null>('days', { required: true })
const reason = defineModel<string>('reason', { required: true })
const overrideEnabled = computed({
get: () => days.value != null,
set: (enabled: boolean) => {
days.value = enabled ? 1 : null
if (!enabled) reason.value = ''
}
})
defineProps<{ disabled?: boolean }>()
</script>
<style scoped>
.silence-help {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<ElDialog
:model-value="true"
:title="firstPurchase ? '首次购买套餐记录' : '沉默期流水'"
width="min(1100px, 96vw)"
destroy-on-close
@close="$emit('close')"
>
<ElAlert v-if="error" :title="error" type="error" :closable="false" />
<SilenceTable :rows="data || []" :definitions="fields" :loading="loading" @refresh="load" />
</ElDialog>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { silenceData, silenceId } from '@/utils/business/silenceExtension'
import SilenceTable from './SilenceTable.vue'
const props = defineProps<{
usageId?: number
assetId?: number
assetType?: 'card' | 'device'
allowed?: boolean
}>()
defineEmits<{ close: [] }>()
const firstPurchase = computed(() => !!props.assetType)
const { data, error, loading, load } = useSchedulerRequest<object[]>(
async () => {
if (props.assetType)
return (
silenceData(await Service.firstPurchase(props.assetType, silenceId(props.assetId))) || []
)
return silenceData(await Service.records(silenceId(props.usageId))) || []
},
() => props.allowed !== false
)
const fields = computed(() =>
(firstPurchase.value
? [
['generation', '世代'],
['first_purchase_at', '首次购买套餐时间'],
['source_name', '来源'],
['order_no', '订单号'],
['ended_at', '世代结束时间'],
['end_reason', '结束原因'],
['created_at', '记录时间']
]
: [
['created_at', '记录时间'],
['event_name', '操作'],
['before_status', '变更前状态'],
['after_status', '变更后状态'],
['before_deadline_at', '变更前截止'],
['after_deadline_at', '变更后截止'],
['change_days', '变化天数'],
['operator_id', '操作人'],
['operator_type', '操作人类型'],
['reason', '原因'],
['trigger_source', '触发来源'],
['exchange_no', '换货单号']
]
).map(([prop, label]) => ({ prop, label }))
)
watch(() => [props.usageId, props.assetId, props.assetType], load)
onMounted(load)
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div v-if="silence" class="silence-summary">
<ElTag :type="silence.silence_status === 'expired' ? 'danger' : 'info'">{{
silenceStatusLabel(silence)
}}</ElTag>
<span
>截止:{{
silence.silence_deadline_at ? formatDateTime(silence.silence_deadline_at) : '-'
}}</span
>
<span v-if="silence.silence_period_days != null"
>初始 {{ silence.silence_period_days }} 天</span
>
<span v-if="silence.silence_extended_days != null"
>累计延期 {{ silence.silence_extended_days }} 天</span
>
<ElTag v-if="silence.silence_adjust_pending" type="warning">调整审批中</ElTag>
<span v-if="silence.silence_paid_extended_days != null"
>付费延期 {{ silence.silence_paid_extended_days }} 天</span
>
<span v-if="silence.silence_extension_count != null"
>延期 {{ silence.silence_extension_count }} 次</span
>
<span v-if="silence.silence_free_extension_used != null"
>已用免费次数 {{ silence.silence_free_extension_used }}</span
>
<ElButton v-if="usageId" link type="primary" @click="recordsVisible = true"
>沉默期流水</ElButton
>
<ElButton
v-if="usageId && silence.extendable && (hasAuth(P.extend) || hasAuth(P.offline))"
link
type="primary"
@click="extendVisible = true"
>延期</ElButton
>
<SilenceRecordsDialog
v-if="recordsVisible && usageId"
:usage-id="usageId"
@close="recordsVisible = false"
/>
<SilenceExtendDialog
v-if="extendVisible && usageId"
:usage-id="usageId"
@close="closeExtension"
@changed="dirty = true"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { PACKAGE_SILENCE_PERMISSIONS as P } from '@/config/constants/packageSilence'
import SilenceRecordsDialog from './SilenceRecordsDialog.vue'
import SilenceExtendDialog from './SilenceExtendDialog.vue'
import type { PackageSilenceInfo } from '@/types/api/packageSilence'
import { silenceStatusLabel } from '@/utils/business/packageSilence'
import { formatDateTime } from '@/utils/business/format'
const props = defineProps<{ silence?: PackageSilenceInfo | null; usageId?: number }>()
const emit = defineEmits<{ changed: [] }>()
const { hasAuth } = useAuth()
const recordsVisible = ref(false),
extendVisible = ref(false),
dirty = ref(false)
function closeExtension() {
extendVisible.value = false
if (dirty.value) {
dirty.value = false
emit('changed')
}
}
watch(
() => [props.usageId, !!props.silence],
() => {
recordsVisible.value = false
extendVisible.value = false
dirty.value = false
}
)
</script>
<style scoped>
.silence-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<ArtTableHeader v-model:columns="columnChecks" @refresh="$emit('refresh')"
><template #left><slot name="toolbar" /></template
></ArtTableHeader>
<ArtTable
:data="rows"
:loading="loading"
:row-key="rowKey || 'id'"
:pagination="total != null"
:current-page="page || 1"
:page-size="size || 20"
:total="total || 0"
:margin-top="10"
@current-change="(value) => $emit('page', value)"
@size-change="(value) => $emit('size', value)"
>
<ElTableColumn
v-for="column in columns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.width || 170"
show-overflow-tooltip
><template #default="{ row }">{{ silenceDisplay(row, column.prop) }}</template></ElTableColumn
>
<slot />
</ArtTable>
</template>
<script setup lang="ts">
import { useGatewayColumns } from '@/composables/useGateway'
import { silenceDisplay } from '@/utils/business/silenceExtension'
const props = defineProps<{
rows: object[]
rowKey?: string
definitions: { prop: string; label: string; width?: number }[]
loading?: boolean
page?: number
size?: number
total?: number
}>()
defineEmits<{ refresh: []; page: [number]; size: [number] }>()
const { columns, columnChecks } = useGatewayColumns(props.definitions)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="nullable-number">
<ElSwitch
:model-value="model != null"
active-text="指定"
:inactive-text="emptyLabel || '使用默认'"
@update:model-value="(value) => (model = value ? (min ?? 0) : null)"
/>
<ElInputNumber
v-if="model != null"
v-model="model"
:min="min"
:max="max"
:precision="precision ?? 0"
:controls="false"
/>
</div>
</template>
<script setup lang="ts">
const model = defineModel<number | null>({ required: true })
defineProps<{ min?: number; max?: number; precision?: number; emptyLabel?: string }>()
</script>
<style scoped>
.nullable-number {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<ElDialog
:model-value="true"
:title="target === 'alert' ? '关闭告警对应卡的轮询' : '批量开关轮询'"
width="min(900px, 96vw)"
:close-on-click-modal="!saving"
:show-close="!saving"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
>
<template v-if="!result">
<ElForm label-width="100px" :disabled="saving">
<ElFormItem label="目标数量">{{ ids.length }} 项(最多 500 项)</ElFormItem>
<ElFormItem label="目标状态"
><ElSwitch
v-model="enabled"
:disabled="target === 'alert' || fixedEnabled != null"
active-text="开启轮询"
inactive-text="关闭轮询"
/></ElFormItem>
<ElFormItem label="原因" required
><ElInput v-model="reason" type="textarea" :rows="3"
/></ElFormItem>
</ElForm>
<ElAlert type="warning" title="本次操作的影响(来自接口)" :closable="false"
><ul
><li v-for="line in impactLines" :key="line">{{ line }}</li></ul
></ElAlert
>
<ElButton v-if="!impact" :loading="impactLoading" @click="loadImpact">加载影响说明</ElButton>
</template>
<template v-else>
<ElAlert
:type="result.fail_count ? 'warning' : 'success'"
:closable="false"
:title="`成功 ${result.success_count},跳过 ${result.skip_count},失败 ${result.fail_count}。部分成功不回滚,请逐项核对。`"
/>
<ArtTable :data="resultRows" row-key="result_key" :pagination="false" max-height="460"
><ElTableColumn prop="identifier" label="资产标识" min-width="210" /><ElTableColumn
prop="asset_id"
label="资产 ID"
width="110" /><ElTableColumn label="结果" min-width="150"
><template #default="{ row }">{{
row.status_name ||
{ 1: '成功', 2: '跳过', 3: '失败', 4: '无权限或不存在' }[row.status as number]
}}</template></ElTableColumn
><ElTableColumn prop="reason" label="原因" min-width="250"
/></ArtTable>
<ul
><li v-for="line in result.impact" :key="line">{{ line }}</li></ul
>
</template>
<ElAlert v-if="error" type="error" :title="error" :closable="false" />
<template #footer
><ElButton :disabled="saving" @click="emit('close')">关闭</ElButton
><ElButton
v-if="!result"
type="primary"
:disabled="!impact || impactLoading"
:loading="saving"
@click="submit"
>确认提交</ElButton
></template
>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, onDeactivated } from 'vue'
import { ElMessageBox } from 'element-plus'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useUserStore } from '@/store/modules/user'
import { useAuth } from '@/composables/useAuth'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import {
pollingScope,
isPollingPlatform,
canPollingBatch,
validatePollingIds,
pollingReason
} from '@/utils/business/pollingScheduler'
import type { PollingImpact, PollingBatchResult } from '@/types/api/pollingScheduler'
const props = defineProps<{
target: 'iot_card' | 'device' | 'alert'
ids: number[]
permission: string
fixedEnabled?: boolean
}>()
const emit = defineEmits<{ close: []; done: [] }>()
const user = useUserStore()
const { hasAuth } = useAuth()
const scope = pollingScope(user)
const allowed = () =>
scope === pollingScope(user) &&
hasAuth(props.permission) &&
(props.target === 'alert'
? isPollingPlatform(user.info.user_type)
: canPollingBatch(user.info.user_type))
const enabled = ref(props.target === 'alert' ? false : (props.fixedEnabled ?? true))
const reason = ref('')
const impact = ref<PollingImpact | null>(null)
const impactLoading = ref(false)
const saving = ref(false)
const error = ref('')
const result = ref<PollingBatchResult | null>(null)
const impactLines = computed(() => impact.value?.[enabled.value ? 'enable' : 'disable'] || [])
const resultRows = computed(
() =>
result.value?.items.map((row, index) => ({
...row,
result_key: `${row.asset_id}:${index}`
})) || []
)
let active = true
async function loadImpact() {
impactLoading.value = true
error.value = ''
try {
requireGatewayAccess(allowed())
const data = gatewayData(await Service.getImpact())
if (active && allowed()) {
if (!Array.isArray(data.enable) || !Array.isArray(data.disable))
throw new Error('影响说明格式不完整')
impact.value = data
}
} catch (cause) {
if (active) error.value = gatewayError(cause)
} finally {
if (active) impactLoading.value = false
}
}
async function submit() {
if (saving.value || result.value) return
error.value = ''
try {
requireGatewayAccess(allowed())
validatePollingIds(props.ids)
const why = pollingReason(reason.value)
if (!impact.value || !impactLines.value.length) throw new Error('请先加载操作影响说明')
const ids = [...props.ids],
targetEnabled = enabled.value
saving.value = true
await ElMessageBox.confirm(impactLines.value.join('\n'), '确认轮询开关影响', {
type: 'warning'
})
requireGatewayAccess(active && allowed())
const data = gatewayData(
await (props.target === 'alert'
? Service.closeAlertPolling(ids, why)
: props.target === 'device'
? Service.toggleDevices({ asset_ids: ids, enable_polling: targetEnabled, reason: why })
: Service.toggleCards({ asset_ids: ids, enable_polling: targetEnabled, reason: why }))
)
if (active && allowed()) {
result.value = data
emit('done')
}
} catch (cause) {
if (active && cause !== 'cancel' && cause !== 'close') error.value = gatewayError(cause)
} finally {
if (active) saving.value = false
}
}
onMounted(loadImpact)
onDeactivated(() => {
active = false
emit('close')
})
onBeforeUnmount(() => {
active = false
})
</script>

View File

@@ -0,0 +1,125 @@
<template>
<ElDialog
:model-value="true"
title="轮询开关 CSV 导入"
width="min(700px,96vw)"
:show-close="!saving"
:close-on-click-modal="!saving"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
><ElAlert
type="info"
title="CSV 首行为表头、首列为 ICCID(19/20 位)或设备 IMEI/虚拟号;空行忽略,最多 50000 行,标识符保持文本。"
:closable="false"
/><ElForm label-width="110px" :disabled="saving"
><ElFormItem label="资产类型"
><ElSelect v-model="assetType"
><ElOption label="卡(ICCID)" value="iot_card" /><ElOption
label="设备"
value="device" /></ElSelect></ElFormItem
><ElFormItem label="目标状态"
><ElSwitch v-model="enabled" active-text="开启轮询" inactive-text="关闭轮询" /></ElFormItem
><ElFormItem label="原因" required><ElInput v-model="reason" type="textarea" /></ElFormItem
><ElFormItem label="CSV 文件" required
><input
type="file"
accept=".csv"
:disabled="saving"
@change="selectFile" /></ElFormItem></ElForm
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><template #footer
><ElButton :disabled="saving" @click="emit('close')">取消</ElButton
><ElButton type="primary" :loading="saving" @click="submit"
>上传并创建任务</ElButton
></template
></ElDialog
>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, onDeactivated } from 'vue'
import { ElMessageBox } from 'element-plus'
import { StorageService } from '@/api/modules/storage'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useUserStore } from '@/store/modules/user'
import { useAuth } from '@/composables/useAuth'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import {
pollingScope,
isPollingPlatform,
pollingReason,
validatePollingCsv
} from '@/utils/business/pollingScheduler'
const emit = defineEmits<{ close: []; created: [id: number] }>(),
user = useUserStore(),
{ hasAuth } = useAuth(),
assetType = ref<'iot_card' | 'device'>('iot_card'),
enabled = ref(false),
reason = ref(''),
file = ref<File>(),
saving = ref(false),
error = ref('')
let active = true
const scope = pollingScope(user)
const allowed = () =>
active &&
scope === pollingScope(user) &&
isPollingPlatform(user.info.user_type) &&
hasAuth(SCHEDULER_PERMISSIONS.imports.create)
function selectFile(event: Event) {
file.value = (event.target as HTMLInputElement).files?.[0]
}
async function submit() {
if (saving.value) return
error.value = ''
try {
requireGatewayAccess(allowed())
const why = pollingReason(reason.value),
selected = file.value
if (!selected) throw new Error('请选择 CSV 文件')
saving.value = true
validatePollingCsv(selected.name, await selected.text())
const impact = gatewayData(await Service.getImpact()),
lines = impact[enabled.value ? 'enable' : 'disable']
if (!Array.isArray(lines) || !lines.length) throw new Error('未获取到有效影响说明')
await ElMessageBox.confirm(lines.join('\n'), '确认导入影响', { type: 'warning' })
requireGatewayAccess(allowed())
const upload = gatewayData(
await StorageService.getUploadUrl({
file_name: selected.name,
content_type: 'text/csv',
purpose: 'polling_status_import'
})
)
if (!upload.file_key.startsWith('polling-status-imports/'))
throw new Error('上传文件 Key 前缀错误')
await StorageService.uploadFile(upload.upload_url, selected, 'text/csv')
requireGatewayAccess(allowed())
const task = gatewayData(
await Service.createImport({
file_key: upload.file_key,
asset_type: assetType.value,
enable_polling: enabled.value,
reason: why
})
)
if (allowed()) {
emit('created', task.id)
emit('close')
}
} catch (e) {
if (active && e !== 'cancel' && e !== 'close') error.value = gatewayError(e)
} finally {
if (active) saving.value = false
}
}
onDeactivated(() => {
active = false
emit('close')
})
onBeforeUnmount(() => {
active = false
})
</script>

View File

@@ -0,0 +1,147 @@
<template>
<ElDialog
:model-value="true"
title="单卡轮询状态"
width="min(1100px, 96vw)"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
>
<ElButton :loading="loading" @click="load">刷新状态</ElButton>
<ElAlert v-if="error" type="error" :title="error" :closable="false" />
<template v-if="state">
<ElDescriptions :column="width < 640 ? 1 : 2" border class="state-overview"
><ElDescriptionsItem v-for="item in fields" :key="item[0]" :label="item[1]">{{
gatewayDisplayValue(state[item[0] as keyof typeof state], item[0])
}}</ElDescriptionsItem></ElDescriptions
>
<h3>各检查类型状态</h3
><ArtTable :data="state.types" row-key="check_type" :pagination="false"
><ElTableColumn v-for="item in typeFields" :key="item[0]" :label="item[1]" min-width="170"
><template #default="{ row }">{{
gatewayDisplayValue(row[item[0]], item[0])
}}</template></ElTableColumn
></ArtTable
>
<h3>停复机指令</h3
><ElDescriptions v-if="state.stop_resume_command" :column="width < 640 ? 1 : 2" border
><ElDescriptionsItem v-for="item in commandFields" :key="item[0]" :label="item[1]">{{
gatewayDisplayValue(commandValue(item[0]), item[0])
}}</ElDescriptionsItem></ElDescriptions
><ElEmpty v-else description="当前没有停复机指令" />
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onDeactivated, watch } from 'vue'
import { useUserStore } from '@/store/modules/user'
import {
pollingScope,
schedulerDisplayValue as gatewayDisplayValue
} from '@/utils/business/pollingScheduler'
import { useWindowSize } from '@vueuse/core'
import { PollingSchedulerService } from '@/api/modules/pollingScheduler'
import { useAuth } from '@/composables/useAuth'
import {
gatewayData,
gatewayError,
requireGatewayAccess,
validateGatewayInteger
} from '@/utils/business/gateway'
import type { CardPollingState } from '@/types/api/pollingScheduler'
const props = defineProps<{ cardId: number; permission: string }>()
const emit = defineEmits<{ close: [] }>()
const user = useUserStore()
watch(
() => pollingScope(user),
() => {
++sequence
state.value = null
emit('close')
},
{ flush: 'sync' }
)
const { width } = useWindowSize(),
{ hasAuth } = useAuth()
const loading = ref(false),
error = ref(''),
state = ref<CardPollingState | null>(null)
const fields = [
['iccid', 'ICCID'],
['carrier_id', '运营商账户 ID'],
['scheduler_mode', '调度模式'],
['enable_polling', '是否轮询'],
['queue', '所在队列'],
['queue_score_at', '下次可领取时间'],
['running', '执行中'],
['claim_expire_at', '处理中标记到期'],
['priority_mark', '优先标记'],
['stop_resume_queue_enabled', '停复机出口队列'],
['last_stop_resume_call_at', '最近停复机网关调用']
]
const typeFields = [
['check_type_name', '检查类型'],
['next_due_at', '下次到期'],
['last_success_at', '最近成功'],
['fail_count', '连续失败'],
['first_failed_at', '首次失败'],
['last_error', '最近错误'],
['pending_confirm', '待确认延迟(秒)']
]
const commandFields = [
['action', '动作'],
['reason', '原因'],
['scenes', '场景'],
['source', '来源'],
['version', '版本'],
['fail_count', '连续失败'],
['last_error', '最近错误'],
['pending', '待确认'],
['unknown', '连续未知'],
['written_at', '写入时间'],
['next_at', '下次可执行时间']
]
let sequence = 0
const commandValue = (key: string) =>
state.value?.stop_resume_command?.[
key as keyof NonNullable<CardPollingState['stop_resume_command']>
]
async function load() {
const scope = pollingScope(user)
const current = ++sequence
loading.value = true
error.value = ''
state.value = null
try {
requireGatewayAccess(hasAuth(props.permission))
validateGatewayInteger(props.cardId, 1, Number.MAX_SAFE_INTEGER, '卡 ID')
const data = gatewayData(await PollingSchedulerService.getCardState(props.cardId))
if (current === sequence && scope === pollingScope(user) && hasAuth(props.permission))
state.value = data
} catch (cause) {
if (current === sequence) error.value = gatewayError(cause)
} finally {
if (current === sequence) loading.value = false
}
}
onMounted(load)
onDeactivated(() => {
++sequence
emit('close')
})
onBeforeUnmount(() => {
++sequence
})
</script>
<style scoped>
.state-overview {
margin-top: 16px;
}
h3 {
margin: 20px 0 12px;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="scheduler-detail"
><ElCard shadow="never"
><div class="detail-header"
><ElButton @click="router.back()">返回</ElButton><h2>{{ title }}</h2
><ElButton :loading="loading" @click="load">刷新</ElButton></div
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><div
v-loading="loading"
><template v-if="data"
><ElDescriptions :column="width < 640 ? 1 : 2" border
><ElDescriptionsItem v-for="item in fields" :key="item[0]" :label="item[1]">{{
gatewayDisplayValue(data[item[0]], item[0])
}}</ElDescriptionsItem></ElDescriptions
><template v-if="kind === 'imports'"
><p>逐行独立处理,部分成功不回滚。</p
><ArtTable :data="data.items || []" :pagination="false" row-key="line" max-height="500"
><ElTableColumn prop="line" label="数据行号" width="100" /><ElTableColumn
prop="identifier"
label="资产标识"
min-width="230" /><ElTableColumn
prop="asset_id"
label="资产 ID"
width="110" /><ElTableColumn
prop="status_name"
label="结果"
width="140" /><ElTableColumn prop="reason" label="原因" min-width="250" /></ArtTable
><ElSwitch v-model="auto" active-text="自动刷新任务(终态停止)" /></template
><template v-if="kind === 'alerts' && data.card_id > 0"
><ElButton v-if="hasAuth(GATEWAY_PERMISSIONS.cardHistory)" @click="history = !history"
>最近停复机记录</ElButton
><ElButton v-if="hasAuth(SCHEDULER_PERMISSIONS.cardState)" @click="stateVisible = true"
>轮询状态</ElButton
><StopResumeRecords
v-if="history"
:card-id="data.card_id"
:permission="GATEWAY_PERMISSIONS.cardHistory" /><PollingStateDialog
v-if="stateVisible"
:card-id="data.card_id"
:permission="SCHEDULER_PERMISSIONS.cardState"
@close="stateVisible = false" /></template></template></div></ElCard
></div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWindowSize } from '@vueuse/core'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway'
import { gatewayData, validateGatewayInteger } from '@/utils/business/gateway'
import {
isPollingPlatform,
schedulerDisplayValue as gatewayDisplayValue
} from '@/utils/business/pollingScheduler'
import StopResumeRecords from '@/components/business/gateway/StopResumeRecords.vue'
import PollingStateDialog from './PollingStateDialog.vue'
const props = defineProps<{ kind: 'params' | 'alerts' | 'imports' }>(),
route = useRoute(),
router = useRouter(),
{ width } = useWindowSize(),
{ hasAuth } = useAuth(),
user = useUserStore()
const title = computed(
() =>
({ params: '运营商账户调度参数详情', alerts: '失败告警详情', imports: '轮询开关导入详情' })[
props.kind
]
)
const canReadDetail = () => {
const permissions = SCHEDULER_PERMISSIONS[props.kind]
return (
hasAuth(permissions.detailPage) &&
hasAuth(permissions.detail) &&
(props.kind !== 'imports' || isPollingPlatform(user.info.user_type))
)
}
const { data, loading, error, load } = useSchedulerRequest<Record<string, any>>(async () => {
const id = Number(route.params.id ?? route.params.carrier_id)
validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '记录 ID')
if (props.kind === 'params') return gatewayData(await Service.getCarrierParam(id))
if (props.kind === 'alerts') return gatewayData(await Service.getAlert(id))
return gatewayData(await Service.getImport(id))
}, canReadDetail)
const maps = {
params: [
['carrier_name', '运营商账户'],
['is_default', '全部使用全局默认'],
['configured_inflight_limit', '配置执行上限'],
['inflight_limit', '生效执行上限'],
['configured_card_max_in_flight', '配置每进程并发'],
['card_max_in_flight', '生效每进程并发'],
['consume_capacity', '订阅进程容量'],
['priority_max_ratio', '优先队列占比'],
['sr_inflight_limit', '停复机执行上限'],
['stop_resume_max_in_flight', '停复机每进程并发'],
['stop_resume_min_interval', '单卡最小间隔(秒)'],
['stop_resume_confirm_delay', '未知确认延迟(秒)']
],
alerts: [
['id', '告警 ID'],
['carrier_name', '运营商账户'],
['alert_level', '告警级别'],
['iccid', 'ICCID'],
['card_id', '卡 ID'],
['check_type_name', '检查类型'],
['status_name', '状态'],
['error_category', '错误类别'],
['last_error', '上游返回/最近错误'],
['fail_count', '连续失败/窗口告警数'],
['first_failed_at', '首次失败'],
['last_failed_at', '最近失败'],
['created_at', '创建时间'],
['recovered_at', '恢复时间'],
['handle_remark', '处理备注'],
['handled_by_name', '处理人'],
['handled_at', '处理时间'],
['network_status', '网络状态'],
['stop_reason', '停机原因'],
['has_package', '有生效套餐'],
['enable_polling', '是否轮询']
],
imports: [
['task_no', '任务编号'],
['asset_type', '资产类型'],
['file_name', '文件名'],
['enable_polling', '目标轮询状态'],
['reason', '原因'],
['status_name', '任务状态'],
['creator_name', '创建人'],
['total_count', '总行数'],
['success_count', '成功'],
['skip_count', '跳过'],
['fail_count', '失败'],
['error_message', '任务级错误'],
['created_at', '创建时间'],
['started_at', '开始时间'],
['completed_at', '完成时间']
]
}
const fields = computed(() => maps[props.kind]),
history = ref(false),
stateVisible = ref(false),
auto = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
function stop() {
if (timer) clearTimeout(timer)
timer = undefined
}
watch([data, auto], () => {
stop()
if (props.kind === 'imports' && auto.value && [1, 2].includes(data.value?.status))
timer = setTimeout(() => {
void load()
}, 3000)
})
watch(
() => route.fullPath,
() => {
stop()
history.value = false
stateVisible.value = false
void load()
}
)
onMounted(load)
onBeforeUnmount(stop)
</script>
<style scoped>
.scheduler-detail {
padding: 20px;
}
.detail-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
padding-bottom: 16px;
}
.detail-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
:deep(.el-descriptions__cell) {
overflow-wrap: anywhere;
}
@media (width <= 640px) {
.scheduler-detail {
padding: 8px;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<ArtTableHeader v-model:columns="columnChecks" @refresh="$emit('refresh')"
><template #left><slot name="toolbar" /></template
></ArtTableHeader>
<ArtTable
:data="data"
:loading="loading"
:row-key="rowKey || 'id'"
:pagination="total != null"
:current-page="page || 1"
:page-size="size || 20"
:total="total || 0"
:page-sizes="pageSizes || [20, 50, 100]"
:margin-top="10"
@current-change="(value) => $emit('page', value)"
@size-change="(value) => $emit('size', value)"
@selection-change="(value) => $emit('selection', value)"
>
<ElTableColumn v-if="selectable" type="selection" width="55" :selectable="selectableRow" />
<ElTableColumn
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:min-width="col.width || 160"
show-overflow-tooltip
><template #default="{ row }">{{
formatter ? formatter(row, col.prop) : schedulerDisplayValue(row[col.prop], col.prop)
}}</template></ElTableColumn
>
<slot />
</ArtTable>
</template>
<script setup lang="ts">
import { useGatewayColumns } from '@/composables/useGateway'
import { schedulerDisplayValue } from '@/utils/business/pollingScheduler'
const props = defineProps<{
data: Record<string, any>[]
definitions: { prop: string; label: string; width?: number }[]
loading?: boolean
rowKey?: string
selectable?: boolean
selectableRow?: (row: any) => boolean
page?: number
size?: number
total?: number
pageSizes?: number[]
formatter?: (row: any, key: string) => unknown
}>()
defineEmits<{
refresh: []
page: [value: number]
size: [value: number]
selection: [rows: any[]]
}>()
const { columns, columnChecks } = useGatewayColumns(props.definitions)
</script>

View File

@@ -3,8 +3,9 @@
<el-form :model="filter" :label-position="props.labelPosition"> <el-form :model="filter" :label-position="props.labelPosition">
<el-row class="search-form-row" :gutter="props.gutter"> <el-row class="search-form-row" :gutter="props.gutter">
<el-col <el-col
v-for="item in useFormItemArr" v-for="(item, index) in props.items"
:key="item.prop" :key="item.prop"
v-show="index < visibleItemCount"
:xs="24" :xs="24"
:sm="12" :sm="12"
:md="item.elColSpan || props.elColSpan" :md="item.elColSpan || props.elColSpan"
@@ -73,6 +74,7 @@
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import ArtSearchInput from './widget/art-search-input/index.vue' import ArtSearchInput from './widget/art-search-input/index.vue'
import ArtSearchSelect from './widget/art-search-select/index.vue' import ArtSearchSelect from './widget/art-search-select/index.vue'
import ArtSearchBusinessSelect from './widget/art-search-business-select/index.vue'
import ArtSearchRadio from './widget/art-search-radio/index.vue' import ArtSearchRadio from './widget/art-search-radio/index.vue'
import ArtSearchDate from './widget/art-search-date/index.vue' import ArtSearchDate from './widget/art-search-date/index.vue'
import ArtSearchTreeSelect from './widget/art-search-tree-select/index.vue' import ArtSearchTreeSelect from './widget/art-search-tree-select/index.vue'
@@ -81,7 +83,7 @@
const { width } = useWindowSize() const { width } = useWindowSize()
const isMobile = computed(() => width.value < 500) const isMobile = computed(() => width.value < 500)
type FilterVo = string | number | undefined | null | unknown[] type FilterVo = string | number | boolean | undefined | null | unknown[]
interface PropsVO { interface PropsVO {
filter: Record<string, FilterVo> // 查询参数 filter: Record<string, FilterVo> // 查询参数
@@ -113,15 +115,10 @@
const isShow = ref(false) const isShow = ref(false)
const useFormItemArr = computed(() => { // 收起时保留控件实例,避免远程选中项的名称和候选缓存在重新展开后丢失。
const isshowLess = !props.isExpand && !isShow.value const visibleItemCount = computed(() =>
if (isshowLess) { !props.isExpand && !isShow.value ? Math.floor(24 / props.elColSpan) - 1 : props.items.length
const num = Math.floor(24 / props.elColSpan) - 1 )
return props.items.slice(0, num)
} else {
return props.items
}
})
const filter = computed({ const filter = computed({
get: () => props.filter, get: () => props.filter,
@@ -132,6 +129,7 @@
const componentsMap: Record<string, any> = { const componentsMap: Record<string, any> = {
input: ArtSearchInput, input: ArtSearchInput,
select: ArtSearchSelect, select: ArtSearchSelect,
'business-select': ArtSearchBusinessSelect,
'tree-select': ArtSearchTreeSelect, 'tree-select': ArtSearchTreeSelect,
radio: ArtSearchRadio, radio: ArtSearchRadio,
datetime: ArtSearchDate, datetime: ArtSearchDate,

View File

@@ -0,0 +1,21 @@
<template>
<BusinessEntitySelect
v-model="value"
v-bind="item.config"
:entity="entity"
:placeholder="item.placeholder"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BusinessEntitySelect from '@/components/business/BusinessEntitySelect.vue'
import type { BusinessOptionKind } from '@/api/modules/businessOptions'
import type { SearchFormItem } from '@/types'
const props = defineProps<{ value: unknown; item: SearchFormItem }>()
const emit = defineEmits<{ 'update:value': [value: unknown] }>()
const entity = computed(() => props.item.config?.entity as BusinessOptionKind)
const value = computed({
get: () => props.value as string | number | (string | number)[] | null | undefined,
set: (value) => emit('update:value', value)
})
</script>

View File

@@ -0,0 +1,183 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
import { GatewayService } from '@/api/modules/gateway'
import { CarrierService } from '@/api/modules/carrier'
import type { GatewayOptions } from '@/types/api/gateway'
import type { PaginationParams, PaginationResponse } from '@/types/api/common'
import {
gatewayData,
gatewayError,
gatewayLabel,
requireGatewayAccess
} from '@/utils/business/gateway'
export function useGatewayOptions() {
const options = ref<GatewayOptions>({
error_categories: [],
exempt_sources: [],
gateway_operations: [],
match_types: [],
rate_limit_modes: [],
stop_resume_actions: [],
stop_resume_results: []
})
const error = ref('')
const loading = ref(false)
const ready = ref(false)
let active = true
async function load() {
if (loading.value) return
loading.value = true
error.value = ''
try {
const data = gatewayData(await GatewayService.getOptions())
if (!active) return
// 不用空数组掩盖异常契约。
for (const key of Object.keys(options.value) as (keyof GatewayOptions)[]) {
if (!Array.isArray(data[key])) throw new Error(`枚举响应缺少 ${key}`)
}
options.value = data
ready.value = true
} catch (cause) {
if (active) {
error.value = gatewayError(cause)
ready.value = false
}
} finally {
if (active) loading.value = false
}
}
onMounted(load)
onBeforeUnmount(() => {
active = false
})
const label = (key: keyof GatewayOptions, value?: string | null) =>
gatewayLabel(options.value[key], value)
return { options, error, loading, ready, load, label }
}
export function useGatewayList<T>(
request: (pagination: PaginationParams) => Promise<PaginationResponse<T>>,
allowed: () => boolean
) {
const rows = shallowRef<T[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref('')
const pagination = reactive({ page: 1, page_size: 20 })
let sequence = 0
const canRead = computed(allowed)
async function load() {
const current = ++sequence
error.value = ''
loading.value = true
try {
requireGatewayAccess(canRead.value)
const data = gatewayData(await request({ ...pagination }))
if (current !== sequence) return
rows.value = data.items || []
total.value = data.total
pagination.page = data.page
pagination.page_size = data.size
} catch (cause) {
if (current !== sequence) return
rows.value = []
total.value = 0
error.value = gatewayError(cause)
} finally {
if (current === sequence) loading.value = false
}
}
function search() {
pagination.page = 1
return load()
}
function resize() {
pagination.page = 1
return load()
}
function clear() {
++sequence
rows.value = []
total.value = 0
error.value = ''
loading.value = false
}
onBeforeUnmount(clear)
watch(canRead, (allowed) => {
clear()
if (allowed) void load()
else error.value = '无权查看此页面'
})
return { rows, total, pagination, loading, error, load, search, resize, clear, canRead }
}
export function useGatewayColumns<T extends { prop: string; label: string }>(definitions: T[]) {
const columnChecks = ref(definitions.map((column) => ({ ...column, checked: true })))
const showColumn = (prop: string) =>
columnChecks.value.some((column) => column.prop === prop && column.checked)
const columns = computed(() =>
columnChecks.value
.filter((column) => column.checked && column.prop !== 'operation')
.map((column) => definitions.find((definition) => definition.prop === column.prop)!)
)
return { columnChecks, showColumn, columns }
}
/** 所有网关页面共用按运营商名称搜索的账户候选,不把名称提交成 carrier_id。 */
export function useGatewayCarriers(loadOnMount = true) {
const candidates = ref<{ value: number; label: string }[]>([])
const remembered = ref<{ value: number; label: string }[]>([])
const loading = ref(false)
const error = ref('')
let sequence = 0
function remember(id: number | null | undefined, name?: string) {
if (id == null || !name) return
remembered.value = [
...remembered.value.filter((option) => option.value !== id),
{ value: id, label: name }
]
}
function select(value: unknown) {
const id = Number(value)
if (value == null || value === '' || !Number.isSafeInteger(id)) return
const selected = options.value.find((option) => option.value === id)
if (selected) remember(selected.value, selected.label)
}
const options = computed(() => {
const values = new Map(remembered.value.map((option) => [option.value, option]))
candidates.value.forEach((option) => values.set(option.value, option))
return [...values.values()]
})
async function search(query = '') {
const current = ++sequence
loading.value = true
error.value = ''
try {
const data = gatewayData(
await CarrierService.getCarriers({ page: 1, page_size: 20, carrier_name: query.trim() })
)
if (current !== sequence) return
if (!Array.isArray(data.items)) throw new Error('运营商账户响应缺少 items')
candidates.value = data.items.map((carrier) => ({
value: carrier.id,
label: carrier.carrier_name
}))
} catch (cause) {
if (current === sequence) {
error.value = gatewayError(cause)
candidates.value = []
}
} finally {
if (current === sequence) loading.value = false
}
}
const label = (id: number) =>
options.value.find((option) => option.value === id)?.label || `运营商账户(${id})`
onMounted(() => {
if (loadOnMount) void search()
})
onBeforeUnmount(() => {
++sequence
})
return { options, loading, error, search, remember, select, label }
}

View File

@@ -0,0 +1,125 @@
import { ref, onBeforeUnmount, onDeactivated, onMounted, watch } from 'vue'
import { PollingConfigService } from '@/api/modules/pollingConfig'
import type { PollingConfig } from '@/types/api/pollingConfig'
import type { PollingRecalculateTask } from '@/types/api/pollingConfigExtensions'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { pollingTaskActive } from '@/utils/business/pollingConfig'
export function usePollingConfigCandidates() {
const candidates = ref<PollingConfig[]>([])
const loading = ref(false)
const error = ref('')
let sequence = 0
async function search(query = '') {
const current = ++sequence
loading.value = true
error.value = ''
try {
const data = gatewayData(
await PollingConfigService.getPollingConfigs({
page: 1,
page_size: 20,
config_name: query.trim()
})
)
if (current === sequence) candidates.value = data.items || []
} catch (cause) {
if (current === sequence) {
error.value = gatewayError(cause)
candidates.value = []
}
} finally {
if (current === sequence) loading.value = false
}
}
onMounted(() => search())
onBeforeUnmount(() => {
++sequence
})
return { candidates, loading, error, search }
}
/** 单一任务、非重叠查询;失败、终态、关闭、切换任务和卸载都撤销旧请求。 */
export function usePollingRecalculateProgress(allowed: () => boolean) {
const taskId = ref('')
const task = ref<PollingRecalculateTask | null>(null)
const loading = ref(false)
const error = ref('')
const autoRefresh = ref(false)
let sequence = 0
let timer: ReturnType<typeof setTimeout> | undefined
let disposed = false
function stop() {
++sequence
if (timer) clearTimeout(timer)
timer = undefined
loading.value = false
}
async function query() {
if (disposed || loading.value) return
stop()
const current = sequence
const id = taskId.value.trim()
error.value = ''
loading.value = true
try {
requireGatewayAccess(allowed())
if (!id) throw new Error('请输入任务 ID')
const data = gatewayData(await PollingConfigService.getRecalculateTask(id))
if (current !== sequence || disposed) return
requireGatewayAccess(allowed())
if (data.task_id !== id) throw new Error('返回的任务 ID 与查询任务不一致')
task.value = data
if (pollingTaskActive(data.status) && autoRefresh.value)
timer = setTimeout(() => {
void query()
}, 3000)
} catch (cause) {
if (current === sequence) error.value = gatewayError(cause)
} finally {
if (current === sequence) loading.value = false
}
}
function adopt(data: PollingRecalculateTask) {
stop()
taskId.value = data.task_id
task.value = data
error.value = ''
if (allowed() && autoRefresh.value && pollingTaskActive(data.status))
timer = setTimeout(() => {
void query()
}, 3000)
}
// 同步 watch:修改输入的这一刻即使旧网络请求未结束也不再允许其回填。
watch(
taskId,
() => {
stop()
task.value = null
error.value = ''
},
{ flush: 'sync' }
)
watch(
autoRefresh,
(enabled) => {
if (!enabled) stop()
else if (!loading.value && taskId.value.trim() && pollingTaskActive(task.value?.status))
void query()
},
{ flush: 'sync' }
)
watch(allowed, (enabled) => {
if (!enabled) {
stop()
task.value = null
error.value = ''
}
})
onDeactivated(stop)
onBeforeUnmount(() => {
disposed = true
stop()
})
return { taskId, task, loading, error, autoRefresh, query, stop, adopt }
}

View File

@@ -0,0 +1,56 @@
import { ref, shallowRef, onBeforeUnmount, onDeactivated, onActivated, watch } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { pollingScope } from '@/utils/business/pollingScheduler'
export function useSchedulerRequest<T>(request: () => Promise<T>, allowed: () => boolean) {
const user = useUserStore()
const data = shallowRef<T | null>(null),
loading = ref(false),
error = ref('')
let sequence = 0,
active = true
function clear() {
++sequence
data.value = null
loading.value = false
error.value = ''
}
async function load() {
if (!active) return
const current = ++sequence,
scope = pollingScope(user)
loading.value = true
error.value = ''
data.value = null
try {
requireGatewayAccess(allowed())
const result = await request()
if (active && current === sequence && scope === pollingScope(user) && allowed())
data.value = result
} catch (cause) {
if (active && current === sequence) {
data.value = null
error.value = gatewayError(cause)
}
} finally {
if (current === sequence) loading.value = false
}
}
watch(() => [pollingScope(user), allowed()], clear, { flush: 'sync' })
onDeactivated(() => {
active = false
clear()
})
onActivated(() => {
if (!active) {
active = true
void load()
}
})
onBeforeUnmount(() => {
active = false
clear()
})
return { data, loading, error, load, clear }
}

View File

@@ -7,6 +7,15 @@ export interface ExportTaskScenePermissions {
} }
export const EXPORT_TASK_DETAIL_PAGE_PERMISSION = 'export_task:detail_page' export const EXPORT_TASK_DETAIL_PAGE_PERMISSION = 'export_task:detail_page'
export const canAccessExportTaskScene = (scene: ExportTaskScene | undefined, userType: unknown) =>
scene === 'order_online'
? [1, 2].includes(Number(userType))
: ['order', 'order_platform', 'order_agent'].includes(scene || '')
? [1, 2, 3].includes(Number(userType))
: true
export const isPollingPlatformExport = (scene?: ExportTaskScene) =>
!!scene &&
['polling_pause', 'polling_stop_refused_alert', 'stop_resume_rule_dry_run'].includes(scene)
export interface ExportTaskSceneConfig { export interface ExportTaskSceneConfig {
scene: ExportTaskScene scene: ExportTaskScene
@@ -16,6 +25,86 @@ export interface ExportTaskSceneConfig {
} }
export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneConfig> = { export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneConfig> = {
package_silence_order: {
scene: 'package_silence_order',
sceneName: '沉默期延期订单',
pageTitle: '导出延期订单',
permissions: {
page: 'export_task:package_silence_order_page',
detail: 'export_task:package_silence_order_detail',
download: 'export_task:package_silence_order_download'
}
},
package_silence_usage: {
scene: 'package_silence_usage',
sceneName: '沉默期套餐清单',
pageTitle: '导出沉默期套餐',
permissions: {
page: 'export_task:package_silence_usage_page',
detail: 'export_task:package_silence_usage_detail',
download: 'export_task:package_silence_usage_download'
}
},
order_platform: {
scene: 'order_platform',
sceneName: '平台订单',
pageTitle: '导出平台订单',
permissions: {
page: 'export_task:order_platform_page',
detail: 'export_task:order_platform_detail',
download: 'export_task:order_platform_download'
}
},
order_agent: {
scene: 'order_agent',
sceneName: '代理商订单',
pageTitle: '导出代理商订单',
permissions: {
page: 'export_task:order_agent_page',
detail: 'export_task:order_agent_detail',
download: 'export_task:order_agent_download'
}
},
order_online: {
scene: 'order_online',
sceneName: '在线支付订单',
pageTitle: '导出在线支付订单',
permissions: {
page: 'export_task:order_online_page',
detail: 'export_task:order_online_detail',
download: 'export_task:order_online_download'
}
},
polling_pause: {
scene: 'polling_pause',
sceneName: '不轮询清单',
pageTitle: '导出不轮询清单',
permissions: {
page: 'export_task:polling_pause_page',
detail: 'export_task:polling_pause_detail',
download: 'export_task:polling_pause_download'
}
},
polling_stop_refused_alert: {
scene: 'polling_stop_refused_alert',
sceneName: '停机被拒告警',
pageTitle: '导出停机被拒告警',
permissions: {
page: 'export_task:polling_stop_refused_alert_page',
detail: 'export_task:polling_stop_refused_alert_detail',
download: 'export_task:polling_stop_refused_alert_download'
}
},
stop_resume_rule_dry_run: {
scene: 'stop_resume_rule_dry_run',
sceneName: '停复机规则演练',
pageTitle: '导出停复机规则演练',
permissions: {
page: 'export_task:stop_resume_rule_dry_run_page',
detail: 'export_task:stop_resume_rule_dry_run_detail',
download: 'export_task:stop_resume_rule_dry_run_download'
}
},
device: { device: {
scene: 'device', scene: 'device',
sceneName: '设备管理', sceneName: '设备管理',

View File

@@ -0,0 +1,12 @@
export const GATEWAY_PERMISSIONS = {
records: { page: 'card_stop_resume_record:page' },
rules: { page: 'gateway_error_classification:page', test: 'gateway_error_classification:test' },
groups: {
page: 'gateway_rate_limit_group:page',
detailPage: 'gateway_rate_limit_group:detail_page',
detail: 'gateway_rate_limit_group:detail'
},
stats: { page: 'gateway_call_stats:page', trend: 'gateway_call_stats:trend' },
cardHistory: 'iot_info:stop_resume_records',
deviceCardHistory: 'device_info:stop_resume_records'
} as const

View File

@@ -0,0 +1,29 @@
export const PACKAGE_SILENCE_PERMISSIONS = {
override: 'package_silence:override',
extend: 'package_silence:extend',
batch: 'package_silence:extend_batch',
offline: 'package_silence:offline',
exception: 'package_silence:exception',
policy: 'package_silence:policy'
} as const
export const SILENCE_PAGES = {
orders: {
page: 'package_silence:orders_page',
detail: 'package_silence:order_detail',
detailPage: 'package_silence:order_detail_page',
export: 'package_silence:order_export'
},
batches: {
page: 'package_silence:batches_page',
detail: 'package_silence:batch_detail',
detailPage: 'package_silence:batch_detail_page'
},
usages: { page: 'package_silence:usages_page', export: 'package_silence:usage_export' },
policies: { page: 'package_silence:policies_page' }
} as const
export const ORDER_CATEGORY_CONFIG = {
all: { title: '订单列表', scene: 'order', page: '' },
platform: { title: '平台订单', scene: 'order_platform', page: 'orders:platform_page' },
agent: { title: '代理商订单', scene: 'order_agent', page: 'orders:agent_page' },
online: { title: '在线支付订单', scene: 'order_online', page: 'orders:online_page' }
} as const

View File

@@ -0,0 +1,43 @@
export const POLLING_CONFIG_PERMISSIONS = {
preview: 'polling_config:match_preview',
progress: 'polling_config:recalculate_progress',
statsPage: 'polling_config_stats:page',
preReset: 'polling_config_stats:pre_reset',
tiers: 'polling_config_stats:tiers'
} as const
export const POLLING_ASSET_STATUSES = [
{ label: '在库', value: 1 },
{ label: '已销售', value: 2 },
{ label: '已换货', value: 3 },
{ label: '已停用', value: 4 }
]
export const POLLING_PACKAGE_STATES = [
{ label: '有生效套餐', value: 'active' },
{ label: '套餐已用完', value: 'depleted' },
{ label: '只有待生效套餐', value: 'pending' },
{ label: '无套餐', value: 'none' }
]
export const POLLING_CARD_CATEGORIES = [
{ label: '普通卡', value: 'normal' },
{ label: '行业卡', value: 'industry' }
]
export const POLLING_CARD_CONDITIONS = [
{ label: '未实名', value: 'not_real_name' },
{ label: '已实名', value: 'real_name' },
{ label: '已激活', value: 'activated' },
{ label: '已停用', value: 'suspended' }
]
export const POLLING_DRAFT_INTERVALS = [
{ field: 'realname_check_interval', label: '实名检查', min: 30 },
{ field: 'carddata_check_interval', label: '流量检查', min: 60 },
{ field: 'package_check_interval', label: '套餐检查', min: 60 },
{ field: 'card_status_check_interval', label: '卡状态检查', min: 30 }
] as const
export const pollingOptionLabel = (
options: { label: string; value: string | number }[],
value: unknown
) =>
options.find((option) => option.value === value)?.label ||
(value == null || value === '' ? '-' : String(value))

View File

@@ -0,0 +1,53 @@
export const SCHEDULER_PERMISSIONS = {
params: {
page: 'polling_carrier_params:page',
detailPage: 'polling_carrier_params:detail_page',
detail: 'polling_carrier_params:detail'
},
scenes: { page: 'polling_check_scene:page' },
alerts: {
page: 'polling_failure_alert:page',
detailPage: 'polling_failure_alert:detail_page',
detail: 'polling_failure_alert:detail',
summary: 'polling_failure_alert:summary',
handle: 'polling_failure_alert:handle',
close: 'polling_failure_alert:close_polling',
export: 'polling_failure_alert:export'
},
queues: { page: 'polling_carrier_queue:page' },
dryRun: { page: 'stop_resume_dry_run:page', export: 'stop_resume_dry_run:export' },
pauses: {
page: 'polling_pause:page',
restore: 'polling_pause:restore',
export: 'polling_pause:export'
},
imports: {
page: 'polling_status_import:page',
create: 'polling_status_import:create',
detailPage: 'polling_status_import:detail_page',
detail: 'polling_status_import:detail'
},
cardState: 'iot_info:polling_state',
deviceCardState: 'device_info:polling_state',
cardBatch: 'iot_card:batch_polling',
deviceBatch: 'device:batch_polling'
} as const
export const POLLING_CLOSE_SOURCES = [
['single', '单资产接口'],
['batch', '勾选批量'],
['import', '文件导入'],
['risk_auto', '风险停机或已销户自动关闭'],
['cascade', '设备级联'],
['order', '支付或下单生效'],
['expiry_change', '修改有效期'],
['alert', '停机被拒告警处理'],
['history', '历史数据']
].map(([value, label]) => ({ value, label }))
export const POLLING_CHECK_TYPES = [
['card_status', '卡状态'],
['carddata', '流量'],
['realname', '实名'],
['package', '套餐'],
['stop_resume', '停复机'],
['all', '账户级']
].map(([value, label]) => ({ value, label }))

View File

@@ -275,7 +275,11 @@ async function processBackendMenu(router: Router): Promise<void> {
.map((menu) => convertBackendMenuToRoute(menu, routeMap)) .map((menu) => convertBackendMenuToRoute(menu, routeMap))
.filter((route) => route !== null) .filter((route) => route !== null)
const finalMenuList = mergeDefaultMenus(menuList, routeMap) const finalMenuList = filterMenuByRoles(
mergeDefaultMenus(menuList, routeMap),
userStore.info.roles || [],
Number(userStore.info.user_type)
)
await registerAndStoreMenu(router, finalMenuList, closeLoading) await registerAndStoreMenu(router, finalMenuList, closeLoading)
} catch (error) { } catch (error) {
closeLoading() closeLoading()

View File

@@ -4,6 +4,10 @@ import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit' import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration' import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway'
import { POLLING_CONFIG_PERMISSIONS } from '@/config/constants/pollingConfig'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { SILENCE_PAGES, PACKAGE_SILENCE_PERMISSIONS } from '@/config/constants/packageSilence'
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools' import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
import { import {
EXPORT_TASK_DETAIL_PAGE_PERMISSION, EXPORT_TASK_DETAIL_PAGE_PERMISSION,
@@ -162,6 +166,64 @@ export const asyncRoutes: AppRouteRecord[] = [
icon: '&#xe81a;' icon: '&#xe81a;'
}, },
children: [ children: [
{
path: 'silence-orders',
name: 'SilenceOrders',
component: RoutesAlias.SilenceOrders,
meta: { title: '延期订单', keepAlive: true, permissions: [SILENCE_PAGES.orders.page] }
},
{
path: 'silence-orders/detail/:id',
name: 'SilenceOrderDetail',
component: RoutesAlias.SilenceOrderDetail,
meta: {
title: '延期订单详情',
isHide: true,
keepAlive: false,
permissions: [SILENCE_PAGES.orders.detailPage]
}
},
{
path: 'silence-batches',
name: 'SilenceBatches',
component: RoutesAlias.SilenceBatches,
meta: {
title: '延期批次与异常',
keepAlive: true,
permissions: [SILENCE_PAGES.batches.page]
}
},
{
path: 'silence-batches/detail/:id',
name: 'SilenceBatchDetail',
component: RoutesAlias.SilenceBatchDetail,
meta: {
title: '延期批次详情',
isHide: true,
keepAlive: false,
permissions: [SILENCE_PAGES.batches.detailPage]
}
},
{
path: 'silence-usages',
name: 'SilenceUsages',
component: RoutesAlias.SilenceUsages,
meta: {
title: '沉默期临期/过期套餐',
keepAlive: true,
permissions: [SILENCE_PAGES.usages.page]
}
},
{
path: 'silence-policies',
name: 'SilencePolicies',
component: RoutesAlias.SilencePolicies,
meta: {
title: '延期政策',
keepAlive: true,
permissions: [SILENCE_PAGES.policies.page, PACKAGE_SILENCE_PERMISSIONS.policy]
}
},
// 套餐系列 // 套餐系列
{ {
path: 'package-series', path: 'package-series',
@@ -681,6 +743,42 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true keepAlive: true
} }
}, },
{
path: 'export-order-platform',
name: 'ExportPlatformOrder',
component: RoutesAlias.ExportPlatformOrder,
meta: {
title: '导出平台订单',
exportTaskScene: 'order_platform',
keepAlive: true,
allowedUserTypes: [1, 2, 3],
permissions: [EXPORT_TASK_SCENE_CONFIG.order_platform.permissions.page]
}
},
{
path: 'export-order-agent',
name: 'ExportAgentOrder',
component: RoutesAlias.ExportAgentOrder,
meta: {
title: '导出代理商订单',
exportTaskScene: 'order_agent',
keepAlive: true,
allowedUserTypes: [1, 2, 3],
permissions: [EXPORT_TASK_SCENE_CONFIG.order_agent.permissions.page]
}
},
{
path: 'export-order-online',
name: 'ExportOnlineOrder',
component: RoutesAlias.ExportOnlineOrder,
meta: {
title: '导出在线支付订单',
exportTaskScene: 'order_online',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [EXPORT_TASK_SCENE_CONFIG.order_online.permissions.page]
}
},
{ {
path: 'export-order', path: 'export-order',
name: 'ExportOrderTaskList', name: 'ExportOrderTaskList',
@@ -796,6 +894,64 @@ export const asyncRoutes: AppRouteRecord[] = [
permissions: [EXPORT_TASK_SCENE_CONFIG.operations_renewal.permissions.page] permissions: [EXPORT_TASK_SCENE_CONFIG.operations_renewal.permissions.page]
} }
}, },
{
path: 'export-polling-pause',
name: 'ExportPollingPause',
component: RoutesAlias.ExportPollingPause,
meta: {
title: '导出不轮询清单',
exportTaskScene: 'polling_pause',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [EXPORT_TASK_SCENE_CONFIG.polling_pause.permissions.page]
}
},
{
path: 'export-polling-stop-refused-alert',
name: 'ExportPollingStopRefusedAlert',
component: RoutesAlias.ExportPollingStopRefusedAlert,
meta: {
title: '导出停机被拒告警',
exportTaskScene: 'polling_stop_refused_alert',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [EXPORT_TASK_SCENE_CONFIG.polling_stop_refused_alert.permissions.page]
}
},
{
path: 'export-stop-resume-rule-dry-run',
name: 'ExportStopResumeDryRun',
component: RoutesAlias.ExportStopResumeDryRun,
meta: {
title: '导出停复机规则演练',
exportTaskScene: 'stop_resume_rule_dry_run',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [EXPORT_TASK_SCENE_CONFIG.stop_resume_rule_dry_run.permissions.page]
}
},
{
path: 'export-silence-order',
name: 'ExportSilenceOrder',
component: RoutesAlias.ExportSilenceOrder,
meta: {
title: '导出延期订单',
exportTaskScene: 'package_silence_order',
keepAlive: true,
permissions: [EXPORT_TASK_SCENE_CONFIG.package_silence_order.permissions.page]
}
},
{
path: 'export-silence-usage',
name: 'ExportSilenceUsage',
component: RoutesAlias.ExportSilenceUsage,
meta: {
title: '导出沉默期套餐',
exportTaskScene: 'package_silence_usage',
keepAlive: true,
permissions: [EXPORT_TASK_SCENE_CONFIG.package_silence_usage.permissions.page]
}
},
{ {
path: 'export-task-detail', path: 'export-task-detail',
name: 'ExportTaskDetail', name: 'ExportTaskDetail',
@@ -817,9 +973,43 @@ export const asyncRoutes: AppRouteRecord[] = [
component: RoutesAlias.Home, component: RoutesAlias.Home,
meta: { meta: {
title: 'menus.orderManagement.title', title: 'menus.orderManagement.title',
allowedUserTypes: [1, 2, 3],
icon: '&#xe7ae;' icon: '&#xe7ae;'
}, },
children: [ children: [
{
path: 'platform-orders',
name: 'PlatformOrderList',
component: RoutesAlias.PlatformOrderList,
meta: {
title: '平台订单',
keepAlive: true,
allowedUserTypes: [1, 2, 3],
permissions: ['orders:platform_page']
}
},
{
path: 'agent-orders',
name: 'AgentOrderList',
component: RoutesAlias.AgentOrderList,
meta: {
title: '代理商订单',
keepAlive: true,
allowedUserTypes: [1, 2, 3],
permissions: ['orders:agent_page']
}
},
{
path: 'online-orders',
name: 'OnlineOrderList',
component: RoutesAlias.OnlineOrderList,
meta: {
title: '在线支付订单',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: ['orders:online_page']
}
},
// 订单列表 // 订单列表
{ {
path: 'order-list', path: 'order-list',
@@ -827,6 +1017,7 @@ export const asyncRoutes: AppRouteRecord[] = [
component: RoutesAlias.OrderList, component: RoutesAlias.OrderList,
meta: { meta: {
title: 'menus.orderManagement.orderList', title: 'menus.orderManagement.orderList',
allowedUserTypes: [1, 2, 3],
keepAlive: true keepAlive: true
} }
}, },
@@ -837,6 +1028,7 @@ export const asyncRoutes: AppRouteRecord[] = [
component: RoutesAlias.OrderDetail, component: RoutesAlias.OrderDetail,
meta: { meta: {
title: 'menus.orderManagement.orderDetail', title: 'menus.orderManagement.orderDetail',
allowedUserTypes: [1, 2, 3],
isHide: true, isHide: true,
keepAlive: false keepAlive: false
} }
@@ -1163,6 +1355,12 @@ export const asyncRoutes: AppRouteRecord[] = [
roles: ['R_SUPER'] roles: ['R_SUPER']
} }
}, },
{
path: 'package-silence-switch',
name: 'PackageSilenceSwitch',
component: RoutesAlias.PackageSilenceSwitch,
meta: { title: '沉默期总开关', keepAlive: false, allowedUserTypes: [1] }
},
// 系统配置 // 系统配置
{ {
path: 'system-configs', path: 'system-configs',
@@ -1267,6 +1465,113 @@ export const asyncRoutes: AppRouteRecord[] = [
icon: '&#xe68a;' icon: '&#xe68a;'
}, },
children: [ children: [
{
path: 'carrier-params',
name: 'PollingCarrierParams',
component: RoutesAlias.PollingCarrierParams,
meta: {
title: '运营商账户调度参数',
keepAlive: true,
permissions: [SCHEDULER_PERMISSIONS.params.page]
}
},
{
path: 'carrier-params/detail/:carrier_id',
name: 'PollingCarrierParamDetail',
component: RoutesAlias.PollingCarrierParamDetail,
meta: {
title: '运营商账户调度参数详情',
keepAlive: false,
isHide: true,
permissions: [SCHEDULER_PERMISSIONS.params.detailPage]
}
},
{
path: 'check-scenes',
name: 'PollingCheckScenes',
component: RoutesAlias.PollingCheckScenes,
meta: {
title: '请求检查场景',
keepAlive: true,
permissions: [SCHEDULER_PERMISSIONS.scenes.page]
}
},
{
path: 'failure-alerts',
name: 'PollingFailureAlerts',
component: RoutesAlias.PollingFailureAlerts,
meta: {
title: '失败告警',
keepAlive: true,
permissions: [SCHEDULER_PERMISSIONS.alerts.page]
}
},
{
path: 'failure-alerts/detail/:id',
name: 'PollingFailureAlertDetail',
component: RoutesAlias.PollingFailureAlertDetail,
meta: {
title: '失败告警详情',
keepAlive: false,
isHide: true,
permissions: [SCHEDULER_PERMISSIONS.alerts.detailPage]
}
},
{
path: 'carrier-queues',
name: 'PollingCarrierQueues',
component: RoutesAlias.PollingCarrierQueues,
meta: {
title: '账户队列监控',
keepAlive: true,
permissions: [SCHEDULER_PERMISSIONS.queues.page]
}
},
{
path: 'stop-resume-dry-run',
name: 'StopResumeDryRun',
component: RoutesAlias.StopResumeDryRun,
meta: {
title: '停复机规则演练',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [SCHEDULER_PERMISSIONS.dryRun.page]
}
},
{
path: 'polling-pauses',
name: 'PollingPauses',
component: RoutesAlias.PollingPauses,
meta: {
title: '不轮询清单',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [SCHEDULER_PERMISSIONS.pauses.page]
}
},
{
path: 'polling-status-imports',
name: 'PollingStatusImports',
component: RoutesAlias.PollingStatusImports,
meta: {
title: '轮询开关导入',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [SCHEDULER_PERMISSIONS.imports.page]
}
},
{
path: 'polling-status-imports/detail/:id',
name: 'PollingStatusImportDetail',
component: RoutesAlias.PollingStatusImportDetail,
meta: {
title: '轮询开关导入详情',
keepAlive: false,
isHide: true,
allowedUserTypes: [1, 2],
permissions: [SCHEDULER_PERMISSIONS.imports.detailPage]
}
},
// 数据清理 // 数据清理
{ {
path: 'data-cleanup', path: 'data-cleanup',
@@ -1317,7 +1622,17 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true keepAlive: true
} }
}, },
// 手动触发 // 轮询配置统计
{
path: 'config-stats',
name: 'PollingConfigStats',
component: RoutesAlias.PollingConfigStats,
meta: {
title: '轮询配置统计',
keepAlive: true,
permissions: [POLLING_CONFIG_PERMISSIONS.statsPage]
}
},
{ {
path: 'manual-trigger', path: 'manual-trigger',
name: 'ManualTrigger', name: 'ManualTrigger',
@@ -1347,6 +1662,57 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true, keepAlive: true,
permissions: [AUGUST_PERMISSIONS.pollingPriorityQueue.page] permissions: [AUGUST_PERMISSIONS.pollingPriorityQueue.page]
} }
},
{
path: 'stop-resume-records',
name: 'GatewayStopResumeRecords',
component: RoutesAlias.GatewayStopResumeRecords,
meta: {
title: '停复机记录',
keepAlive: true,
permissions: [GATEWAY_PERMISSIONS.records.page]
}
},
{
path: 'error-classifications',
name: 'GatewayErrorClassifications',
component: RoutesAlias.GatewayErrorClassifications,
meta: {
title: '上游错误分类',
keepAlive: true,
permissions: [GATEWAY_PERMISSIONS.rules.page]
}
},
{
path: 'rate-limit-groups',
name: 'GatewayRateLimitGroups',
component: RoutesAlias.GatewayRateLimitGroups,
meta: {
title: '网关限流组',
keepAlive: true,
permissions: [GATEWAY_PERMISSIONS.groups.page]
}
},
{
path: 'gateway-stats',
name: 'GatewayCallStats',
component: RoutesAlias.GatewayCallStats,
meta: {
title: '网关调用统计',
keepAlive: true,
permissions: [GATEWAY_PERMISSIONS.stats.page]
}
},
{
path: 'rate-limit-groups/detail/:id',
name: 'GatewayRateLimitGroupDetail',
component: RoutesAlias.GatewayRateLimitGroupDetail,
meta: {
title: '网关限流组详情',
isHide: true,
keepAlive: false,
permissions: [GATEWAY_PERMISSIONS.groups.detailPage]
}
} }
] ]
}, },

View File

@@ -98,6 +98,21 @@ export enum RoutesAlias {
// 订单管理 // 订单管理
OrderList = '/order-management/order-list', // 订单列表 OrderList = '/order-management/order-list', // 订单列表
PackageSilenceSwitch = '/settings/package-silence-switch',
SilenceOrders = '/package-management/silence-orders',
SilenceOrderDetail = '/package-management/silence-orders/detail',
SilenceBatches = '/package-management/silence-batches',
SilenceBatchDetail = '/package-management/silence-batches/detail',
SilenceUsages = '/package-management/silence-usages',
SilencePolicies = '/package-management/silence-policies',
ExportSilenceOrder = '/asset-management/export-task-management/export-silence-order',
ExportSilenceUsage = '/asset-management/export-task-management/export-silence-usage',
PlatformOrderList = '/order-management/platform-orders',
AgentOrderList = '/order-management/agent-orders',
OnlineOrderList = '/order-management/online-orders',
ExportPlatformOrder = '/asset-management/export-task-management/export-order-platform',
ExportAgentOrder = '/asset-management/export-task-management/export-order-agent',
ExportOnlineOrder = '/asset-management/export-task-management/export-order-online',
OrderDetail = '/order-management/order-list/detail', // 订单详情 OrderDetail = '/order-management/order-list/detail', // 订单详情
// 财务管理 // 财务管理
AgentRecharge = '/finance/agent-recharge', // 代理充值 AgentRecharge = '/finance/agent-recharge', // 代理充值
@@ -141,9 +156,28 @@ export enum RoutesAlias {
AlertHistory = '/polling-management/alert-history', // 告警历史 AlertHistory = '/polling-management/alert-history', // 告警历史
PollingConcurrency = '/polling-management/concurrency', // 并发控制 PollingConcurrency = '/polling-management/concurrency', // 并发控制
PollingConfig = '/polling-management/config', // 轮询配置 PollingConfig = '/polling-management/config', // 轮询配置
PollingConfigStats = '/polling-management/config-stats', // 轮询配置统计
PollingCarrierParams = '/polling-management/carrier-params',
PollingCarrierParamDetail = '/polling-management/carrier-params/detail',
PollingCheckScenes = '/polling-management/check-scenes',
PollingFailureAlerts = '/polling-management/failure-alerts',
PollingFailureAlertDetail = '/polling-management/failure-alerts/detail',
PollingCarrierQueues = '/polling-management/carrier-queues',
StopResumeDryRun = '/polling-management/stop-resume-dry-run',
PollingPauses = '/polling-management/polling-pauses',
PollingStatusImports = '/polling-management/polling-status-imports',
PollingStatusImportDetail = '/polling-management/polling-status-imports/detail',
ExportPollingPause = '/asset-management/export-task-management/export-polling-pause',
ExportPollingStopRefusedAlert = '/asset-management/export-task-management/export-polling-stop-refused-alert',
ExportStopResumeDryRun = '/asset-management/export-task-management/export-stop-resume-rule-dry-run',
ManualTrigger = '/polling-management/manual-trigger', // 手动触发 ManualTrigger = '/polling-management/manual-trigger', // 手动触发
PollingMonitor = '/polling-management/monitor', // 轮询监控 PollingMonitor = '/polling-management/monitor', // 轮询监控
PollingPriorityQueue = '/polling-management/priority-queue', // 优先队列 PollingPriorityQueue = '/polling-management/priority-queue', // 优先队列
GatewayStopResumeRecords = '/polling-management/stop-resume-records',
GatewayErrorClassifications = '/polling-management/error-classifications',
GatewayRateLimitGroups = '/polling-management/rate-limit-groups',
GatewayRateLimitGroupDetail = '/polling-management/rate-limit-groups/detail',
GatewayCallStats = '/polling-management/gateway-stats',
// 公共组件 // 公共组件
Success = '/result/success', // 成功 Success = '/result/success', // 成功

View File

@@ -157,6 +157,7 @@ export interface AssetExchangeTrace {
* 对应接口:GET /api/admin/assets/resolve/:identifier * 对应接口:GET /api/admin/assets/resolve/:identifier
*/ */
export interface AssetResolveResponse { export interface AssetResolveResponse {
first_purchase_at?: string | null
asset_type: AssetType // 资产类型:card 或 device asset_type: AssetType // 资产类型:card 或 device
asset_id: number // 数据库 ID asset_id: number // 数据库 ID
associated_phones?: string[] // 当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组) associated_phones?: string[] // 当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)
@@ -189,7 +190,8 @@ export interface AssetResolveResponse {
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称 expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
is_expiring?: boolean // 是否临期,由后端判断 is_expiring?: boolean // 是否临期,由后端判断
reduction_pct: number // 展示增幅比例(小数,如 0.428571) reduction_pct: number // 展示增幅比例(小数,如 0.428571)
device_protect_status?: DeviceProtectStatus // 保护期状态:none / stop / start(仅 device) /** @deprecated 保护期已下线,固定返回 none;不用于界面或业务判断。 */
device_protect_status?: DeviceProtectStatus
activated_at: string // 激活时间 activated_at: string // 激活时间
created_at: string // 创建时间 created_at: string // 创建时间
updated_at: string // 更新时间 updated_at: string // 更新时间
@@ -300,6 +302,7 @@ export interface DeviceGatewayInfo {
* 对应接口:GET /api/admin/assets/:asset_type/:id/realtime-status * 对应接口:GET /api/admin/assets/:asset_type/:id/realtime-status
*/ */
export interface AssetRealtimeStatusResponse { export interface AssetRealtimeStatusResponse {
enable_polling?: boolean
asset_type: AssetType // 资产类型 asset_type: AssetType // 资产类型
asset_id: number // 资产 ID asset_id: number // 资产 ID
@@ -316,7 +319,8 @@ export interface AssetRealtimeStatusResponse {
last_card_status_check_at?: string | null // 卡状态同步时间 last_card_status_check_at?: string | null // 卡状态同步时间
// ===== 设备专属字段 ===== // ===== 设备专属字段 =====
device_protect_status?: DeviceProtectStatus // 保护期(仅 device) /** @deprecated 固定返回 none,仅兼容旧响应。 */
device_protect_status?: DeviceProtectStatus
cards?: AssetBoundCard[] // 所有绑定卡的状态(仅 device) cards?: AssetBoundCard[] // 所有绑定卡的状态(仅 device)
online_status?: number // 在线状态(仅 device) online_status?: number // 在线状态(仅 device)
last_online_time?: string | null // 最后在线时间(仅 device) last_online_time?: string | null // 最后在线时间(仅 device)
@@ -339,6 +343,7 @@ export type AssetRefreshResponse = AssetRealtimeStatusResponse
* 对应接口:GET /api/admin/assets/:identifier/packages * 对应接口:GET /api/admin/assets/:identifier/packages
*/ */
export interface AssetPackageUsageRecord { export interface AssetPackageUsageRecord {
silence?: import('./packageSilence').PackageSilenceInfo | null
id?: number // 兼容前端当前映射字段 id?: number // 兼容前端当前映射字段
package_usage_id?: number // 套餐使用记录 ID package_usage_id?: number // 套餐使用记录 ID
package_id?: number // 套餐 ID package_id?: number // 套餐 ID

View File

@@ -355,6 +355,9 @@ export enum StandaloneCardStatus {
// 单卡查询参数 // 单卡查询参数
export interface StandaloneCardQueryParams extends PaginationParams { export interface StandaloneCardQueryParams extends PaginationParams {
first_purchase_start?: string
first_purchase_end?: string
enable_polling?: boolean
keyword?: string // 通用关键字搜索 keyword?: string // 通用关键字搜索
has_active_package?: boolean // has active package filter has_active_package?: boolean // has active package filter
real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名 real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名
@@ -381,6 +384,7 @@ export interface StandaloneCardQueryParams extends PaginationParams {
// 单卡信息 // 单卡信息
export interface StandaloneIotCard { export interface StandaloneIotCard {
first_purchase_at?: string | null
gateway_card_imei?: string // 网关侧返回的卡 IMEI(可空) gateway_card_imei?: string // 网关侧返回的卡 IMEI(可空)
id: number // 卡ID id: number // 卡ID
iccid: string // ICCID iccid: string // ICCID

View File

@@ -37,6 +37,8 @@ export type SwitchMode = '0' | '1' // 0=自动, 1=手动
// 设备信息 // 设备信息
export interface Device { export interface Device {
first_purchase_at?: string | null
enable_polling?: boolean
id: number // 设备ID id: number // 设备ID
virtual_no: string // 虚拟号(原 device_no) virtual_no: string // 虚拟号(原 device_no)
associated_phones?: string[] // 当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组) associated_phones?: string[] // 当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)
@@ -83,6 +85,9 @@ export interface Device {
// 设备查询参数 // 设备查询参数
export interface DeviceQueryParams extends PaginationParams { export interface DeviceQueryParams extends PaginationParams {
first_purchase_start?: string
first_purchase_end?: string
enable_polling?: boolean
has_active_package?: boolean // has active package filter has_active_package?: boolean // has active package filter
real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名 real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名
virtual_no?: string // 虚拟号(模糊查询,原 device_no) virtual_no?: string // 虚拟号(模糊查询,原 device_no)

View File

@@ -4,6 +4,11 @@ export type ExportTaskScene =
| 'device' | 'device'
| 'iot_card' | 'iot_card'
| 'order' | 'order'
| 'order_platform'
| 'order_agent'
| 'order_online'
| 'package_silence_order'
| 'package_silence_usage'
| 'package' | 'package'
| 'agent_wallet_transaction' | 'agent_wallet_transaction'
| 'agent_recharge' | 'agent_recharge'
@@ -14,6 +19,9 @@ export type ExportTaskScene =
| 'expiring_asset' | 'expiring_asset'
| 'operations_activation' | 'operations_activation'
| 'operations_renewal' | 'operations_renewal'
| 'polling_pause'
| 'polling_stop_refused_alert'
| 'stop_resume_rule_dry_run'
export type ExportTaskFormat = 'xlsx' | 'csv' export type ExportTaskFormat = 'xlsx' | 'csv'
@@ -74,7 +82,7 @@ export interface ExportTaskListResponse {
export interface CreateExportTaskRequest { export interface CreateExportTaskRequest {
scene: ExportTaskScene scene: ExportTaskScene
format: ExportTaskFormat format: ExportTaskFormat
query?: Record<string, string> query?: Record<string, string | Record<string, unknown>>
} }
export interface CreateExportTaskResponse { export interface CreateExportTaskResponse {

145
src/types/api/gateway.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { PaginationParams } from './common'
export interface GatewayOption {
label: string
value: string
}
export interface GatewayOptions {
error_categories: GatewayOption[]
exempt_sources: GatewayOption[]
gateway_operations: GatewayOption[]
match_types: GatewayOption[]
rate_limit_modes: GatewayOption[]
stop_resume_actions: GatewayOption[]
stop_resume_results: GatewayOption[]
}
export interface StopResumeRecord {
id: number
card_id: number
carrier_id: number
iccid: string
action: string
action_name?: string
attempt: number
command_id: string
created_at: string
duration_ms: number
error_category: string
error_category_name?: string
provider_code: string
provider_message: string
reason: string
result: string
scene: string
source: string
}
export interface CardStopResumeQuery extends PaginationParams {
start_time?: string
end_time?: string
}
export interface StopResumeQuery extends CardStopResumeQuery {
start_time: string
end_time: string
iccid?: string
carrier_id?: number
action?: string
source?: string
result?: string
error_category?: string
}
export interface GatewayRuleInput {
auto_disable_polling?: boolean
category: string
keyword: string
match_type: string
maybe_accepted?: boolean
operations?: string[]
priority?: number
remark?: string
status?: number
}
export interface GatewayRule extends GatewayRuleInput {
id: number
category_name?: string
created_at: string
updated_at: string
}
export interface GatewayRuleQuery extends PaginationParams {
category?: string
status?: number
keyword?: string
}
export interface GatewayRuleTestInput {
operation: string
message: string
draft?: GatewayRuleInput
}
export interface GatewayRuleTestResult {
auto_disable_polling?: boolean
matched: boolean
rule_id: number
category: string
category_name: string
keyword: string
maybe_accepted: boolean
}
export interface GatewayGroupInput {
carrier_id?: number | null
group_code: string
group_name: string
max_qps: number
operations: string[]
high_priority_operations?: string[]
reserved_qps?: number
status?: number
}
export interface GatewayGroup extends GatewayGroupInput {
id: number
carrier_name?: string
status_name?: string
created_at: string
updated_at: string
}
export interface GatewayGroupQuery extends PaginationParams {
carrier_id?: number
only_global?: boolean
status?: number
}
export interface GatewayOperationStats {
operation: string
operation_name: string
attempts: number
success: number
success_rate: number
avg_duration_ms: number
p95_duration_ms: number
avg_qps: number
peak_minute_qps: number
rate_limited: number
would_limit: number
exempt: Record<string, number>
failures: Record<string, number>
}
export interface GatewayCarrierStats {
minutes: number
mode: string
carriers: { carrier_id: number; carrier_name: string; operations: GatewayOperationStats[] }[]
}
export interface GatewayCarrierTrend {
carrier_id: number
points: { time: string; operations: GatewayOperationStats[] }[]
}

View File

@@ -110,6 +110,7 @@ export type {
UpdatePollingConfigRequest, UpdatePollingConfigRequest,
UpdatePollingConfigStatusRequest UpdatePollingConfigStatusRequest
} from './pollingConfig' } from './pollingConfig'
export * from './pollingConfigExtensions'
// 手动触发相关 // 手动触发相关
export * from './manualTrigger' export * from './manualTrigger'
@@ -158,3 +159,5 @@ export * from './assetWallet'
// 运营报表相关 // 运营报表相关
export * from './operationsReport' export * from './operationsReport'
export * from './gateway'
export * from './pollingScheduler'

View File

@@ -155,6 +155,8 @@ export interface OrderListResponse {
// 创建订单请求 // 创建订单请求
export interface CreateOrderRequest { export interface CreateOrderRequest {
silence_period_days?: number
silence_reason?: string
order_type?: OrderType // 订单类型 order_type?: OrderType // 订单类型
iot_card_id?: number | null // IoT卡ID iot_card_id?: number | null // IoT卡ID
device_id?: number | null // 设备ID device_id?: number | null // 设备ID

View File

@@ -152,6 +152,9 @@ export interface ShopPackageBatchAllocationsResponse {
* 套餐响应 * 套餐响应
*/ */
export interface PackageResponse extends PackageAllocationExpiryBaseFields { export interface PackageResponse extends PackageAllocationExpiryBaseFields {
silence_period_days?: number | null
silence_extension_enabled?: boolean
silence_config_required?: boolean
id: number id: number
package_code: string package_code: string
package_name: string package_name: string
@@ -190,6 +193,7 @@ export interface PackageResponse extends PackageAllocationExpiryBaseFields {
* 套餐查询参数 * 套餐查询参数
*/ */
export interface PackageQueryParams extends PaginationParams { export interface PackageQueryParams extends PaginationParams {
silence_unconfigured?: boolean
package_name?: string // 套餐名称(模糊搜索) package_name?: string // 套餐名称(模糊搜索)
series_id?: number // 系列ID series_id?: number // 系列ID
package_type?: string // 套餐类型 package_type?: string // 套餐类型
@@ -201,6 +205,8 @@ export interface PackageQueryParams extends PaginationParams {
* 创建套餐请求 * 创建套餐请求
*/ */
export interface CreatePackageRequest { export interface CreatePackageRequest {
silence_period_days?: number | null
silence_extension_enabled?: boolean
package_code: string // 套餐编码,必填 package_code: string // 套餐编码,必填
package_name: string // 套餐名称,必填 package_name: string // 套餐名称,必填
series_id?: number | null // 所属系列ID,可选 series_id?: number | null // 所属系列ID,可选
@@ -222,6 +228,8 @@ export interface CreatePackageRequest {
* 更新套餐请求 * 更新套餐请求
*/ */
export interface UpdatePackageRequest { export interface UpdatePackageRequest {
silence_period_days?: number | null
silence_extension_enabled?: boolean
package_name?: string | null // 套餐名称,可选 package_name?: string | null // 套餐名称,可选
series_id?: number | null // 所属系列ID,可选 series_id?: number | null // 所属系列ID,可选
package_type?: string | null // 套餐类型,可选 (formal:正式套餐, addon:附加套餐) package_type?: string | null // 套餐类型,可选 (formal:正式套餐, addon:附加套餐)

View File

@@ -0,0 +1,24 @@
export interface PackageSilenceInfo {
silence_status: string
silence_status_name?: string
silence_deadline_at?: string | null
silence_start_at?: string | null
silence_period_days?: number | null
silence_extended_days?: number
silence_paid_extended_days?: number
silence_extension_count?: number
silence_free_extension_used?: number
silence_expired_at?: string | null
silence_adjust_pending?: boolean
extendable: boolean
}
export interface SilenceSwitchCheck {
code: string
name: string
passed: boolean
count: number
}
export interface SilenceSwitchState {
enabled: boolean
precheck: SilenceSwitchCheck[]
}

View File

@@ -0,0 +1,667 @@
// Generated from docs/api/package-silence.openapi.json (Apifox, 2026-10-28).
// Financial values use integer fen. Missing optional response fields are not fabricated.
export interface AssetFirstPurchaseRecordResponse {
/** 资产 ID */
asset_id?: number
/** 资产标识快照 */
asset_identifier?: string
/** 资产类型 */
asset_type?: string
/** 记录时间 */
created_at?: string
/** 结束原因:exchange_return 换货回库 */
end_reason?: string
/** 该世代结束时间 */
ended_at?: string | null
/** 首次购买套餐时间 */
first_purchase_at?: string
/** 世代 */
generation?: number
/** 记录 ID */
id?: number
/** 订单号 */
order_no?: string
/** 来源:order 首次购买套餐 / backfill 历史回填 */
source?: string
/** 来源名称 */
source_name?: string
}
export interface SilenceBatchPageResult {
/** 批次列表 */
items?: Array<SilenceBatchResponse>
/** 页码 */
page?: number
/** 每页数量 */
page_size?: number
/** 总数 */
total?: number
}
export interface SilenceBatchResponse {
/** 费用(分) */
amount?: number
/** 审批实例 ID */
approval_instance_id?: number | null
/** 是否需要审批 */
approval_required?: boolean
/** 审批状态 */
approval_status?: string
/** 依据 */
basis?: string
/** 批次号 */
batch_no?: string
/** 完成时间 */
completed_at?: string | null
/** 附件 */
evidence_refs?: Array<string>
/** 延期类型 */
extension_type?: string
/** 失败数 */
failed_count?: number
/** 批次 ID */
id?: number
/** 明细 */
items?: Array<SilenceExtensionResponse>
/** 路径:online / offline / special / adjust */
path?: string
/** 原因 */
reason?: string
/** 发起时间 */
requested_at?: string
/** 发起人 */
requested_by?: number
/** 明细数 */
requested_count?: number
/** 批次状态 */
status?: string
/** 成功数 */
succeeded_count?: number
}
export interface SilenceExtensionResponse {
/** 延期后截止时间 */
after_deadline_at?: string | null
/** 金额(分) */
amount?: number
/** 资产标识 */
asset_identifier?: string
/** 资产类型 */
asset_type?: string
/** 批次 ID */
batch_id?: number
/** 延期前截止时间 */
before_deadline_at?: string | null
/** 执行时间 */
executed_at?: string | null
/** 延长天数 */
extension_days?: number
/** 延期订单 ID */
extension_order_id?: number | null
/** 延期类型 */
extension_type?: string
/** 失败 / 异常原因码 */
failure_code?: string
/** 失败 / 异常说明 */
failure_summary?: string
/** 明细 ID */
id?: number
/** 输入天数 */
input_days?: number
/** 计费月数 */
months?: number
/** 套餐名称 */
package_name?: string
/** 套餐记录 ID */
package_usage_id?: number
/** 行号 */
row_no?: number
/** 明细状态 */
status?: string
}
export interface SilenceEligibilityResponse {
/** 能否办理付费延期 */
can_extend?: boolean
/** 已累计延期天数 */
extended_days?: number
/** 套餐是否支持付费延期 */
extension_enabled?: boolean
/** 是否还能申请免费延期 */
free_extension_available?: boolean
/** 免费延期次数上限 */
free_extension_limit?: number
/** 已使用免费延期次数 */
free_extension_used?: number
/** 本次付费延期可输入的最多天数 */
max_days?: number
/** 本次付费延期最多月数(特殊延期不受此限) */
max_months?: number
/** 可输入的最少天数(付费与特殊延期都适用) */
min_days?: number
/** 最少延期月数 */
min_months?: number
/** min~max 每档计费天数、金额与新截止日期 */
options?: Array<SilenceExtensionOption>
/** 当前截止日期(YYYY-MM-DD) */
original_deadline?: string
/** 已过期天数 */
overdue_days?: number
/** 套餐记录 ID */
package_usage_id?: number
/** 其中付费延期累计天数 */
paid_extended_days?: number
/** 未结束的延期订单号(有则不能新建,需先完成或关闭) */
pending_order_no?: string
/** 当前匹配的延期政策 ID */
policy_id?: number | null
/** 当前匹配的延期政策名称 */
policy_name?: string
/** 付费延期不能办理的原因:not_expired/no_silence/extension_disabled/no_policy/exceeds_limit/refunded/order_pending/activated/adjust_pending */
reason?: string
/** 沉默期状态(待实名但已到期的先判定为过期) */
silence_status?: string
/** 能否申请特殊延期(只要求套餐已过期) */
special_extension_available?: boolean
/** 每月单价(分),取当前匹配的延期政策 */
unit_price?: number
}
export interface SilenceExtensionOption {
/** 金额(分) */
amount?: number
/** 计费 / 延长天数(月数 × 30) */
billed_days?: number
/** 计费月数 */
months?: number
/** 新截止日期(YYYY-MM-DD,当日 23:59:59 截止) */
new_deadline?: string
}
export interface SilenceExceptionHandleParams {
/** 处理方式:retry 重试执行 / close 关闭(在线订单关闭时原路退款) */
action: string
/** 延期明细 ID */
id: number
/** 处理原因 */
reason: string
}
export interface SilenceOrderPageResult {
/** 订单列表 */
items?: Array<SilenceExtensionOrderResponse>
/** 页码 */
page?: number
/** 每页数量 */
page_size?: number
/** 总数 */
total?: number
}
export interface SilenceExtensionOrderResponse {
/** 应付金额(分) */
amount?: number
/** 审批实例 ID */
approval_instance_id?: number | null
/** 是否需要审批 */
approval_required?: boolean
/** 资产 ID */
asset_id?: number
/** 资产标识快照 */
asset_identifier?: string
/** 资产类型 iot_card / device */
asset_type?: string
/** 批次号 */
batch_no?: string
/** 下单时截止时间 */
before_deadline_at?: string
/** 延期生效时间 */
completed_at?: string | null
/** 创建时间 */
created_at?: string
/** 后台创建人 */
created_by?: number | null
/** H5 下单的客户 */
customer_id?: number | null
/** 申请依据 */
evidence_refs?: Array<string>
/** 异常原因码 */
exception_code?: string
/** 异常说明 */
exception_summary?: string
/** 预计新截止时间 */
expected_deadline_at?: string
/** 支付截止时间 */
expire_at?: string | null
/** 计费 / 延长天数 */
extension_days?: number
/** 订单 ID */
id?: number
/** 输入天数 */
input_days?: number
/** 计费月数 */
months?: number
/** 线下收款方式 */
offline_payment_method_name?: string
/** 线下交易单号 */
offline_trade_no?: string
/** 订单号 */
order_no?: string
/** 套餐 ID */
package_id?: number
/** 套餐名称快照 */
package_name?: string
/** 套餐记录 ID */
package_usage_id?: number
/** 实付金额(分) */
paid_amount?: number | null
/** 支付时间 */
paid_at?: string | null
/** 收款商户名称(取已支付或已退款支付单快照) */
payment_merchant_name?: string
/** 支付方式:wechat/alipay/offline */
payment_method?: string
/** 支付单号 */
payment_no?: string
/** 线下收款凭证 */
payment_voucher_keys?: Array<string>
/** 政策名称快照 */
policy_name?: string
/** 支付内容(待支付时返回,前端渲染为二维码或跳转链接) */
qr_content?: string
/** 退款金额 */
refund_amount?: number | null
/** 原路退款单号(仅执行异常) */
refund_no?: string
/** 退款时间 */
refunded_at?: string | null
/** 下单时资产所属店铺 */
shop_id?: number
/** 来源:client / admin / offline */
source?: string
/** 来源名称 */
source_name?: string
/** 状态:pending_payment/pending_approval/rejected/paid/completed/closed/exception/refunded */
status?: string
/** 状态名称 */
status_name?: string
/** 交易流水号 */
third_party_trade_no?: string
/** 单价(分/月) */
unit_price?: number
}
export interface SilenceOfflineOrderRequest {
/** 输入的延期天数 */
days: number
/** 客户申请依据 */
evidence_refs?: Array<string> | null
/** 线下收款方式(字典 ID) */
offline_payment_method_id: number
/** 线下交易单号(同一收款方式下唯一) */
offline_trade_no: string
/** 套餐记录 ID */
package_usage_id: number
/** 实收金额(分),必须等于系统计算金额 */
paid_amount?: number
/** 付款时间 */
paid_at: string
/** 收款凭证对象存储 Key(1~5 个) */
payment_voucher_keys: Array<string> | null
/** 幂等键 */
request_id: string
}
export interface SilenceOnlineOrderRequest {
/** 输入的延期天数 */
days: number
/** 客户申请记录或截图对象存储 Key(后台创建时填写) */
evidence_refs?: Array<string> | null
/** 套餐记录 ID */
package_usage_id: number
/** 支付方式:wechat / alipay(经商户池路由) */
payment_method: string
/** 下单幂等键 */
request_id: string
}
export interface SilenceQuoteResponse {
/** 金额(分) */
amount?: number
/** 计费 / 延长天数 */
billed_days?: number
/** 输入天数 */
input_days?: number
/** 计费月数 */
months?: number
/** 新截止日期 */
new_deadline?: string
/** 是否在可办理范围内 */
valid?: boolean
}
export interface SilenceSpecialRequest {
/** 费用(分,免费为 0;优惠价为实收合计) */
amount?: number
/** 免费或优惠依据 */
basis?: string
/** 客户申请记录或截图 */
evidence_refs?: Array<string> | null
/** 延期类型:free 免费 / discount 优惠价 / complaint_compensation 客诉补偿 / system_compensation 系统异常补偿 / special_customer 特殊客户政策 */
extension_type: string
/** 批量导入的明细文件 Key */
import_file_key?: string
/** 明细(批量需 package_silence:extend_batch 权限) */
items: Array<SilenceSpecialItem> | null
/** 优惠价有收款时:付款时间 */
offline_paid_at?: string | null
/** 优惠价有收款时:线下收款方式 */
offline_payment_method_id?: number | null
/** 优惠价有收款时:交易单号 */
offline_trade_no?: string
/** 优惠价有收款时:收款凭证 */
payment_voucher_keys?: Array<string> | null
/** 原因 */
reason: string
}
export interface SilenceSpecialItem {
/** 延期天数(按 30 天向上取整) */
days: number
/** 套餐记录 ID */
package_usage_id: number
}
export interface SilencePolicyResponse {
/** 免审批场景 */
approval_exempt_types?: Array<string>
/** 创建时间 */
created_at?: string
/** 创建人 */
creator?: number
/** 生效时间 */
effective_from?: string
/** 失效时间 */
effective_to?: string | null
/** 免费延期次数 */
free_extension_limit?: number
/** 政策 ID */
id?: number
/** 政策名称 */
name?: string
/** 付费延期累计最多月数 */
paid_max_months?: number
/** 备注 */
remark?: string
/** 系列或套餐 ID 列表 */
scope_ids?: Array<number>
/** 适用范围 */
scope_type?: string
/** 适用范围名称 */
scope_type_name?: string
/** 单次付费延期最多月数 */
single_max_months?: number | null
/** 状态:0-停用 1-启用 */
status?: number
/** 每月单价(分) */
unit_price?: number
/** 更新时间 */
updated_at?: string
/** 更新人 */
updater?: number
}
export interface SilencePolicyRequest {
/** 免审批场景(多选,空表示全部需审批) */
approval_exempt_types?: Array<string> | null
/** 生效时间 */
effective_from: string
/** 失效时间(为空表示长期) */
effective_to?: string | null
/** 每笔套餐免费延期次数 */
free_extension_limit?: number
/** 政策名称(唯一) */
name: string
/** 付费延期累计最多月数 */
paid_max_months?: number
/** 备注 */
remark?: string
/** 系列 ID 或套餐 ID 列表(all 时为空) */
scope_ids?: Array<number> | null
/** 适用范围:all-全部套餐 series-套餐系列 package-指定套餐 */
scope_type: string
/** 单次付费延期最多月数(为空表示不单独限制) */
single_max_months?: number | null
/** 状态:0-停用 1-启用(新增默认启用) */
status?: number | null
/** 每月单价(分),1 月 = 30 天 */
unit_price?: number
}
export interface SilencePolicyUpdateParams {
/** 免审批场景(多选,空表示全部需审批) */
approval_exempt_types?: Array<string> | null
/** 生效时间 */
effective_from: string
/** 失效时间(为空表示长期) */
effective_to?: string | null
/** 每笔套餐免费延期次数 */
free_extension_limit?: number
/** 政策 ID */
id: number
/** 政策名称(唯一) */
name: string
/** 付费延期累计最多月数 */
paid_max_months?: number
/** 备注 */
remark?: string
/** 系列 ID 或套餐 ID 列表(all 时为空) */
scope_ids?: Array<number> | null
/** 适用范围:all-全部套餐 series-套餐系列 package-指定套餐 */
scope_type: string
/** 单次付费延期最多月数(为空表示不单独限制) */
single_max_months?: number | null
/** 状态:0-停用 1-启用(新增默认启用) */
status?: number | null
/** 每月单价(分),1 月 = 30 天 */
unit_price?: number
}
export interface SilencePolicyStatusParams {
/** 政策 ID */
id: number
/** 状态:0-停用 1-启用 */
status: number | null
}
export interface SilencePolicyMatchResponse {
/** 是否匹配到政策(未匹配时不能付费延期,特殊延期仍可申请) */
matched?: boolean
/** 套餐 ID */
package_id?: number
policy?: SilencePolicyResponse
}
export interface SilenceRecordResponse {
/** 变更后截止时间 */
after_deadline_at?: string | null
/** 变更后状态 */
after_status?: string | null
/** 审批实例 */
approval_instance_id?: number | null
/** 资产 ID */
asset_id?: number
/** 资产类型 */
asset_type?: string
/** 批次 */
batch_id?: number | null
/** 变更前截止时间 */
before_deadline_at?: string | null
/** 变更前状态 */
before_status?: string | null
/** 变化天数 */
change_days?: number
/** 时间 */
created_at?: string
/** 默认值快照 */
default_days?: number | null
/** 事件名称 */
event_name?: string
/** 事件:set/adjust/expired/ended/extend/exchange/backfill/realname_backfill */
event_type?: string
/** 换货单号 */
exchange_no?: string
/** 延期明细 */
extension_id?: number | null
/** 流水 ID */
id?: number
/** 是否调整 */
is_adjusted?: boolean
/** 操作人 */
operator_id?: number
/** 操作人类型 */
operator_type?: string
/** 套餐记录 ID */
package_usage_id?: number
/** 沉默期天数 */
period_days?: number | null
/** 原因 */
reason?: string
/** 触发来源 */
trigger_source?: string
}
export interface SilenceSwitchResponse {
/** 总开关是否打开 */
enabled?: boolean
/** 打开前检查各项结果 */
precheck?: Array<SilenceSwitchPrecheckItem>
/** 打开前检查是否全部通过 */
precheck_passed?: boolean
}
export interface SilenceSwitchPrecheckItem {
/** 检查项:unconfigured_packages / unbackfilled_usages / default_policy_missing / approval_scene_missing */
code?: string
/** 数量(前两项为未满足的数量,后两项为已具备的数量) */
count?: number
/** 检查项名称 */
name?: string
/** 是否满足 */
passed?: boolean
}
export interface SilenceSwitchRequest {
/** 目标状态:true 打开 / false 关闭(应急关闭不做检查) */
enabled: boolean | null
}
export interface SilenceUsagePageResult {
/** 列表 */
items?: Array<SilenceUsageItem>
/** 页码 */
page?: number
/** 每页数量 */
page_size?: number
/** 总数 */
total?: number
}
export interface SilenceUsageItem {
/** 资产 ID */
asset_id?: number
/** 资产标识 */
asset_identifier?: string
/** 资产类型 */
asset_type?: string
/** 购买时间 */
created_at?: string
/** 订单号 */
order_no?: string
/** 套餐 ID */
package_id?: number
/** 套餐名称 */
package_name?: string
/** 套餐记录 ID */
package_usage_id?: number
/** 资产所属店铺 */
shop_id?: number | null
/** 店铺名称 */
shop_name?: string
silence?: PackageSilenceView
}
export interface PackageSilenceView {
/** 是否显示延期入口(沉默期已过期的待生效套餐) */
extendable?: boolean
/** 购买时调整沉默期的审批是否进行中 */
silence_adjust_pending?: boolean
/** 当前沉默期截止时间(截止日 23:59:59) */
silence_deadline_at?: string | null
/** 最近一次过期时间 */
silence_expired_at?: string | null
/** 累计已延期天数 */
silence_extended_days?: number
/** 累计延期次数 */
silence_extension_count?: number
/** 已使用免费延期次数 */
silence_free_extension_used?: number
/** 其中付费延期累计天数 */
silence_paid_extended_days?: number
/** 沉默期天数 */
silence_period_days?: number | null
/** 沉默期起算时间(购买支付成功时间) */
silence_start_at?: string | null
/** 沉默期状态:waiting-待实名 expired-沉默期已过期 ended-沉默期已结束;待实名但已过截止的按 expired 返回 */
silence_status?: string
/** 沉默期状态名称(待实名 / 延期待实名 / 沉默期已过期 / 沉默期已结束) */
silence_status_name?: string
}
export interface SilenceOrderQuery {
page?: number
page_size?: number
package_usage_id?: number | null
package_id?: number | null
asset_identifier?: string
order_no?: string
status?: string
source?: string
payment_method?: string
merchant_name?: string
third_party_trade_no?: string
paid_start?: string
paid_end?: string
}
export interface SilenceBatchQuery {
page?: number
page_size?: number
batch_no?: string
path?: string
extension_type?: string
status?: string
has_exception?: boolean | null
}
export interface SilenceUsageQuery {
page?: number
page_size?: number
view?: string
within_days?: number
asset_identifier?: string
package_id?: number | null
shop_id?: number | null
deadline_start?: string
deadline_end?: string
}
export interface SilencePolicyQuery {
page?: number
page_size?: number
name?: string
scope_type?: string
status?: number | null
effective_at?: string
}

Some files were not shown because too many files have changed in this diff Show More