1 Commits

211 changed files with 559 additions and 26363 deletions

View File

@@ -3,11 +3,10 @@ name: 构建并部署前端到测试环境
on:
push:
branches:
- iteration/8-11
- dev
- test
# 如果不再需要 main 分支触发构建,可以把下面这行删掉;
# 如果只是想保留构建(不部署),可以留着,反正部署条件已经只认 iteration/8-11
# 如果只是想保留构建(不部署),可以留着,反正部署条件已经只认 iteration/august
- main
env:
@@ -40,7 +39,7 @@ jobs:
fi
- name: 部署到测试环境
if: github.ref == 'refs/heads/iteration/8-11'
if: github.ref == 'refs/heads/iteration/august'
run: |
mkdir -p "${{ env.DEPLOY_DIR }}"
cp -v docker-compose.prod.yml "${{ env.DEPLOY_DIR }}/"

4
.gitignore vendored
View File

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

View File

@@ -1,128 +0,0 @@
# 轮询账户限流:后台接入交付
本次仅修改本后台前端,未修改独立 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,204 +234,3 @@
- 各场景下载权限:`export_task:<scene>_download`
所有权限编码必须保持唯一;页面 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

@@ -1,41 +0,0 @@
## 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

@@ -1,32 +0,0 @@
# 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

@@ -1,98 +0,0 @@
## 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

@@ -1,29 +0,0 @@
## 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

@@ -1,26 +0,0 @@
## 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

@@ -1,61 +0,0 @@
# 执行记录:订单分类与沉默期
## 当前结论
本后台代码已按汇总文档及用户授权的 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

@@ -1,43 +0,0 @@
# 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

@@ -1,75 +0,0 @@
## 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

@@ -1,33 +0,0 @@
## 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

@@ -1,45 +0,0 @@
# 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

@@ -1,87 +0,0 @@
## 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

@@ -1,30 +0,0 @@
## 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

@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-11-07

View File

@@ -1,141 +0,0 @@
## Context
- **后端(8-11,不改)**:接口和字段见 `junhong_cmp_fiber/docs/2026-11-06-测试环境8-11新功能前端对接与权限现状.md` 第 2.9、2.11、2.12 节,以 8-11 生成的 `admin-openapi.yaml` 为准。
- **账号类型限制**(后端只按账号类型校验,没有对应权限码):
- 记录查询接口不传资产标识时查全部,仅平台账号可用。
- 资产详情的采集记录、冲正查询与提交,企业账号无权调用。
- 退款后处理人工重试、尝试记录查询,仅平台账号(含超级管理员)可用。
- 人工重试只接受 `asset_post_status = 3`(需人工处理)且资产后处理未完成的退款单。
- **管理后台现状(`iteration/8-11`)**:
- 菜单为静态路由(`src/router/routes/asyncRoutes.ts`),用 `meta.permissions` 过滤;按钮用 `useAuth().hasAuth()`;超级管理员不受限。
- 列表页通用组件:`ArtSearchBar`、`ArtTableHeader`、`ArtTableFullScreen`。
- 可复用的模式是 `components/business/gateway/StopResumeRecords.vue`:同一个组件既作为独立页(`views/polling-management/stop-resume-records`),也嵌在资产详情里(传入卡 ID)。
- 资产详情:
- `views/asset-management/asset-information` 用 `BasicInfoCard` 触发停复机记录、`PackageListCard` 触发日流量详单(`DailyRecordsDialog`)。
- 同步按钮走 `useAssetInfo.refreshAsset`,目前忽略响应体。
- 退款:`views/finance/refund/index.vue`、`detail.vue`,接口在 `api/modules/refund.ts`,类型在 `types/api/refund.ts`。
- **约束**:基于 `iteration/8-11`,只部署测试环境;不改停复机逻辑;网关只读开关保持开启。
## Goals / Non-Goals
**Goals:**
- 运营能在后台查清楚"一张卡的流量读数从采集到入账"的全过程,并能人工冲正。
- 退款后处理卡住时,运营能看到原因并人工重试。
- 新页面和按钮有明确的权限码,可以分配给非超级管理员角色。
**Non-Goals:**
- 后端接口或权限校验改动。
- 采集记录导出、归档文件下载(后端未提供)。
- 生产发布与生产权限数据。
## Decisions
### D1 记录列表做成可复用组件,页面只是壳
参照 `StopResumeRecords.vue`,在 `src/components/business/traffic-records/` 下新建组件:
- `TrafficCollectionRecords.vue`:成功记录;传 `failures` 时切到失败记录。
- `CardDailyUsages.vue`
- `PackageUsageDailyRecords.vue`
- `PackageTrafficAdjustments.vue`
- `RefundPostProcessAttempts.vue`
每个组件接受可选的固定筛选项,例如 `identifier`、`packageUsageId`、`refundId`。独立页面不传,资产详情和退款详情传入,复用同一套列表与原始数据展开。
**备选**:页面与弹窗各写一套。**拒绝原因**:字段多(约 30 个),两套容易不一致。
### D2 菜单位置
| 页面 | 菜单位置 |
|---|---|
| 流量采集成功记录、流量采集失败记录、卡日流量、套餐日流量详单、套餐流量冲正流水 | 资产管理 → 记录管理(与"资产分配""授权记录"并列) |
| 退款后处理尝试记录 | 财务管理(紧跟"退款管理") |
路由别名在 `routesAlias.ts` 注册,页面目录分别为 `views/asset-management/record-management/<name>/index.vue` 与 `views/finance/refund-post-attempts/index.vue`。
### D3 权限码
后端没有这些权限码,前端新增常量文件 `src/config/constants/trafficRecords.ts`:
| 用途 | 权限码 |
|---|---|
| 采集成功记录页 | `traffic_collection_record:page` |
| 采集失败记录页 | `traffic_collection_failure:page` |
| 卡日流量页 | `card_daily_usage:page` |
| 套餐日流量详单页 | `package_usage_daily_record:page` |
| 冲正流水页 | `package_traffic_adjustment:page` |
| 退款后处理尝试记录页 | `refund_post_process_attempt:page` |
| 资产详情 · 采集记录(卡 / 设备绑定卡) | `iot_info:traffic_collections`、`device_info:traffic_collections` |
| 资产详情 · 冲正记录 | `package_usage:traffic_adjustments` |
| 资产详情 · 人工冲正 | `package_usage:traffic_adjust` |
| 退款 · 人工重试 | `refund:asset_post_retry` |
- 菜单用 `meta.permissions`,按钮用 `hasAuth`。
- 前端还要按账号类型隐藏后端会拒绝的入口:企业账号不显示资产详情的采集记录和冲正;非平台账号不显示人工重试和尝试记录页。
- 清单交后端写入测试库 `tb_permission` 并挂到对应菜单。权限码命名最终以写入测试库的数据为准。
### D4 时间范围与默认值
- 所有列表默认最近 1 天(日期类默认最近 7 天)。
- 选择器限制跨度 ≤ 31 天,提交前再校验一次。
- 时间参数按上海时间 `YYYY-MM-DD HH:mm:ss` 传,后端同时接受 RFC3339。
- 结束时间按"不含"处理,日期类按"含首尾"处理,筛选栏旁用文字说明。
### D5 枚举中文名
在 `src/config/constants/trafficRecords.ts` 统一维护以下中文名,未知值原样显示:
| 枚举 | 中文名 |
|---|---|
| `decision` | written 已写入、baseline 建立基线、queue_not_full 采集队列未满、slope_negative 读数回落、out_of_order 采集乱序、settled_existing 已结算 |
| `result` | ok 正常、failed 失败、rate_limited 限流、invalid_payload 响应无效、no_reading 无读数、invalid_reading 读数无效 |
| `source` | polling 轮询、manual_sync 人工同步、business_event 业务事件 |
| 退款后处理 `step` | load 加载、invalidate 失效套餐、activate 激活、check_main 检查主套餐、stop 停机、mark 标记完成、done 完成 |
| 退款后处理 `trigger` | outbox 自动、manual 人工 |
| `asset_post_status` | 0 未开始、1 待重试、2 已完成、3 需人工处理 |
### D6 原始数据展示
- 表格行展开后,用只读 JSON 块(`<pre>` 格式化)展示 `queue_points`、`raw_request`、`raw_response`。
- `raw_response` 为空、`raw_response_text` 有值时,显示文本并标注"已截断"。
- 每块提供"复制"按钮,方便贴给网关方排查。
### D7 人工冲正交互
- 表单只让运营填正整数加方向,由前端换算 `delta_mb` 的正负,避免运营手输负号出错。
- 确认框展示当前已用量(取套餐行的已用量)、调整量与预计结果。
- 退回不能使结果小于 0。
- 成功后刷新套餐列表,并展示返回的 `before_usage_mb → after_usage_mb`。
- 提示文案说明"提交后系统会按规则重新判断停复机",前端不调用任何停复机接口。
### D8 人工重试交互
- 按钮只在"需人工处理 + 平台账号 + 有权限"时出现。
- 成功时接口返回最新退款详情,直接替换当前行或详情数据。
- 失败时显示后端 `msg`,并重新拉取该退款单,更新尝试次数和最后错误。
### D9 同步结果(可选)
- `AssetService.refreshAsset` 的响应类型增加 `traffic_refresh`。
- `useAssetInfo.refreshAsset` 在成功提示里附加一行入账结果,或用 `ElNotification` 展示。
- 不改同步按钮的限流和刷新逻辑。
## Risks / Trade-offs
- **[权限码未入库,非超级管理员看不到新功能]** → 测试先用超级管理员验证。按 D3 清单补测试库后,再用普通运营账号验证。
- **[人工冲正、人工重试会触发后端停复机判断]** → 测试环境资产已全部在网关只读表,停复机调用会被拦截,不会改上游卡状态。验证只看停复机指令和拦截日志,不看上游卡状态。
- **[采集记录字段多,表格过宽]** → 默认只显示关键列,其余列通过 `ArtTableHeader` 列设置打开;原始数据放展开行。
- **[采集记录只保留当月加最近 2 个整月,资产详情采集记录保留 90 天]** → 筛选栏说明保留期,超出保留期查询为空属正常。
## Migration Plan
1. 在 `iteration/8-11` 实现。本地 `build:development`(接测试后端)通过类型检查与构建。
2. 后端在测试库补 D3 权限码,并分配给测试角色。
3. 推送 `iteration/8-11` 部署测试环境。管理后台 CI 要求推送前在本机构建镜像并推送到 `registry.boss160.cn`,需先 `docker login`;部署时间由你安排。
4. 用超级管理员和普通运营账号分别验证。
5. 回退:回滚对应提交重新部署;新增页面不影响现有页面。
## Open Questions
- 权限码命名(D3)是否沿用此清单,还是由后端统一定名后回填前端常量?默认先用此清单。
- 记录查询菜单是否放在"资产管理 → 记录管理",还是放到"轮询管理"下与停复机记录并列?默认放"记录管理"。

View File

@@ -1,34 +0,0 @@
## Why
后端 8-11 迭代在 11-05 新增了流量采集记录分表与记录查询接口、套餐流量人工冲正、退款资产后处理逐次尝试与重试上限,10-29 起资产刷新接口还会返回本次读数是否入账(`traffic_refresh`)。管理后台目前都没有接上:运营看不到采集成功/失败记录和卡日流量,无法人工冲正套餐流量;退款后处理失败到达重试上限后,后台既看不到状态也不能人工重试。依据 `junhong_cmp_fiber/docs/2026-11-06-测试环境8-11新功能前端对接与权限现状.md` 第 2.9、2.11、2.12、5.2 节补齐管理后台。
本变更基于 `iteration/8-11` 分支,只部署测试环境。
## What Changes
- 新增 6 个记录查询列表页:运营商流量采集成功记录、采集失败记录、卡日流量、套餐日流量详单、套餐流量冲正流水、退款资产后处理尝试记录。
- 资产详情新增"采集记录"(按卡,可包含失败记录)。
- 资产详情套餐列表新增"冲正记录"查看与"人工冲正"(正数补扣、负数退回,原因必填)。
- 退款列表、详情展示资产后处理状态、已尝试次数、最后错误、下次自动重试时间;列表支持按后处理状态筛选;"需人工处理"的退款单提供"人工重试"按钮与尝试记录入口。
- 可选:资产"同步"成功后展示本次读数、读数结果、是否写入、写入判断、增量。
- 新增上述页面与按钮的前端权限码常量,并整理清单供测试库补权限数据。
- 不改停复机逻辑:人工冲正、人工重试会由后端按规则触发停复机判断,前端只调用接口;测试环境资产已全部在网关只读表中,不会真实停复机。
## Capabilities
### New Capabilities
- `traffic-record-query`:采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水 4 类全局查询页,以及资产详情中的卡采集记录。
- `package-traffic-adjustment`:资产详情中按套餐查看冲正流水与提交人工冲正。
- `refund-asset-post-processing`:退款后处理状态展示与筛选、人工重试、后处理尝试记录查询页。
- `asset-refresh-traffic-result`:资产同步后展示 `traffic_refresh` 入账结果(可选)。
### Modified Capabilities
(无。本仓库 `openspec/specs/` 下没有已有规格。)
## Impact
- 接口模块:新增 `src/api/modules/trafficRecords.ts`;`refund.ts` 增加人工重试与尝试记录;`asset.ts` 增加采集记录、冲正查询与提交,`AssetRefreshResponse` 类型增加 `traffic_refresh`。
- 类型:`src/types/api/refund.ts` 的 `Refund` 与 `RefundQueryParams` 增加 `asset_post_*` 字段;新增记录查询类型。
- 页面:新增记录查询页(`src/views/asset-management/record-management/*`、`src/views/finance/refund-post-attempts`);修改 `src/views/finance/refund/index.vue`、`detail.vue`、`src/views/asset-management/asset-information`(采集记录、冲正)。
- 路由与权限:`src/router/routes/asyncRoutes.ts`、`routesAlias.ts`,新增权限常量文件;菜单与按钮权限码需在测试库 `tb_permission` 补数据,否则只有超级管理员可见。
- 后端接口(8-11 已提供,不改):`/api/admin/traffic-collections`、`/traffic-collection-failures`、`/card-daily-usages`、`/package-usage-daily-records`、`/package-traffic-adjustments`、`/refund-post-process-attempts`、`/assets/{identifier}/traffic-collections`、`/assets/{identifier}/packages/{package_usage_id}/traffic-adjustments`(GET/POST)、`POST /refunds/{id}/asset-post-processing/retry`、`POST /assets/{identifier}/refresh`。

View File

@@ -1,13 +0,0 @@
## ADDED Requirements
### Requirement: 同步后展示流量入账结果
资产详情点击"同步"成功后,若响应包含 `traffic_refresh`,系统 SHALL 在成功提示中或提示下方展示本次流量入账结果:读数结果(中文名称)、本次运营商读数 `reading_mb`(为空时显示"未返回读数")、是否写入 `written`、写入判断 `decision`(中文名称)、本次增量 `delta_mb`。响应不含 `traffic_refresh` 时 MUST 保持现有"刷新成功"提示不变。本需求为可选项,不实现不影响现有功能。
#### Scenario: 读数已写入
- **WHEN** 同步返回 `traffic_refresh = { result: ok, reading_mb: 1200, written: true, decision: written, delta_mb: 15 }`
- **THEN** 页面显示"本次读数 1200 MB,已写入,增量 15 MB"
#### Scenario: 读数未写入
- **WHEN** 同步返回 `written = false`、`decision = queue_not_full`
- **THEN** 页面显示"本次读数未写入:采集队列未满"

View File

@@ -1,25 +0,0 @@
## ADDED Requirements
### Requirement: 查看套餐冲正流水
资产详情套餐列表中每笔套餐 SHALL 提供"冲正记录"入口,弹窗调用 `GET /api/admin/assets/{identifier}/packages/{package_usage_id}/traffic-adjustments` 分页展示该套餐的冲正流水:冲正时间、调整量、调整前后已用量、调整前后套餐状态、原因、操作人、关联流量事件 ID 与采集 ID。企业账号 MUST NOT 显示该入口。
#### Scenario: 查看冲正记录
- **WHEN** 运营在资产详情某笔套餐上点击"冲正记录"
- **THEN** 弹窗按时间倒序显示该套餐的冲正流水,无记录时显示空状态
### Requirement: 提交人工冲正
有冲正按钮权限的账号 SHALL 能在资产详情套餐上提交人工冲正,调用 `POST /api/admin/assets/{identifier}/packages/{package_usage_id}/traffic-adjustments`。表单字段:调整方向(补扣 / 退回)、调整量 MB(正整数,提交时按方向转为正数或负数 `delta_mb`)、原因(必填,去首尾空格后不能为空)、关联采集 ID 与关联流量事件 ID(选填)。提交前 SHALL 二次确认,确认框展示当前已用量、调整量与预计调整后已用量,并提示"提交后系统会按规则重新判断停复机"。退回量大于当前已用量时 SHALL 提示调整后不能小于 0 并阻止提交。提交中 MUST 防重复。成功后 SHALL 提示调整前后已用量,并刷新套餐列表与冲正记录。
#### Scenario: 补扣
- **WHEN** 运营对已用 500 MB 的套餐选择补扣 100 MB、填写原因并确认
- **THEN** 系统提交 `delta_mb = 100`,成功后提示已用量 500 MB → 600 MB,套餐列表刷新
#### Scenario: 退回超过已用量
- **WHEN** 运营对已用 50 MB 的套餐选择退回 80 MB
- **THEN** 页面提示调整后已用量不能小于 0,不提交
#### Scenario: 原因为空
- **WHEN** 运营未填写原因直接提交
- **THEN** 表单提示原因必填,不发请求

View File

@@ -1,45 +0,0 @@
## ADDED Requirements
### Requirement: 退款后处理状态展示
退款列表与退款详情 SHALL 展示资产后处理状态 `asset_post_status`(0 未开始、1 待重试、2 已完成、3 需人工处理),列表用标签展示,"需人工处理"用警示色。详情 SHALL 另外展示已尝试次数 `asset_post_attempts`(注明自动重试上限 10 次)、最后一次错误 `asset_post_last_error`、下次自动重试时间 `asset_post_next_at`(仅待重试时有值)。字段缺失时显示"-",不影响现有展示。
#### Scenario: 待重试
- **WHEN** 一笔退款 `asset_post_status = 1`、已尝试 3 次、下次重试时间为 10:30
- **THEN** 详情显示"待重试"、已尝试 3/10 次、最后错误与下次重试时间 10:30
### Requirement: 按后处理状态筛选
退款列表 SHALL 增加"资产后处理状态"筛选项,选中后请求带 `asset_post_status`;清空时不传该参数。
#### Scenario: 筛选需人工处理
- **WHEN** 运营选择"需人工处理"并查询
- **THEN** 请求带 `asset_post_status = 3`,列表只显示需人工处理的退款单
### Requirement: 人工重试后处理
退款单 `asset_post_status = 3` 且当前账号为平台账号或超级管理员、并有人工重试按钮权限时,退款列表操作列与详情页 SHALL 显示"人工重试"按钮;其他状态或账号类型 MUST NOT 显示。点击后 SHALL 二次确认,提示"重试会重新执行退款后的资产处理(可能包括停机),确认继续?"。调用 `POST /api/admin/refunds/{id}/asset-post-processing/retry`,提交中 MUST 防重复。成功时 SHALL 用返回的最新退款详情刷新当前行或详情;失败时 SHALL 显示后端错误信息(如"资产后处理仍未完成:…"),并重新加载该退款单以展示最新尝试次数与错误。
#### Scenario: 重试成功
- **WHEN** 运营对需人工处理的退款单点击"人工重试"并确认,后端返回成功
- **THEN** 页面提示重试成功,该退款单后处理状态显示"已完成",按钮消失
#### Scenario: 重试仍失败
- **WHEN** 后端返回"资产后处理仍未完成:停机失败"
- **THEN** 页面显示该错误,重新加载后仍显示"需人工处理"和最新错误
#### Scenario: 非需人工处理
- **WHEN** 退款单后处理状态为"待重试"
- **THEN** 不显示"人工重试"按钮
### Requirement: 后处理尝试记录查询
系统 SHALL 提供"退款后处理尝试记录"列表页,调用 `GET /api/admin/refund-post-process-attempts`,仅平台账号与超级管理员可用。筛选条件:时间范围(必填,跨度不超过 31 天)、退款单 ID 或单号、资产标识、处理步骤 `step`、结果 `result`、错误码、触发方式 `trigger`(自动 / 人工)。列表 SHALL 展示记录时间、退款单号、资产类型与 ICCID、第几次尝试、步骤、结果、错误码与错误信息、触发方式、耗时;步骤、结果、触发方式显示中文名称。退款详情 SHALL 提供"后处理尝试记录"入口,打开该列表并自动带上退款单 ID。
#### Scenario: 从退款详情查看
- **WHEN** 运营在退款详情点击"后处理尝试记录"
- **THEN** 打开尝试记录列表,已按该退款单 ID 筛选,时间范围为最近 31 天
#### Scenario: 时间范围必填
- **WHEN** 运营清空时间范围后查询
- **THEN** 页面提示请选择时间范围,不发请求

View File

@@ -1,65 +0,0 @@
## ADDED Requirements
### Requirement: 采集成功记录查询
系统 SHALL 提供"流量采集成功记录"列表页,调用 `GET /api/admin/traffic-collections`。筛选条件:资产标识、时间范围(`start_time`~`end_time`,结束时间不含)、运营商账户、写入判断 `decision`、来源 `source`、场景 `scene`、是否有增量 `has_delta`、是否触发重置日防旧值 `stale_guarded`。列表 SHALL 展示采集时间、ICCID 或卡 ID、运营商账户、来源、场景、运营商原始读数、本月累计、本次增量、写入判断、防旧值、网关 code 与 trace_id、网关尝试次数,并可展开查看采集队列 `queue_points`、原始请求 `raw_request`、原始响应 `raw_response` / `raw_response_text`(截断时标注)。写入判断、来源、读数结果 SHALL 显示中文名称。分页每页默认 20、最大 100。
#### Scenario: 默认查询
- **WHEN** 运营打开采集成功记录页
- **THEN** 默认时间范围为最近 1 天,按采集时间倒序展示第一页
#### Scenario: 查看原始数据
- **WHEN** 运营展开一条 `raw_truncated = true` 的记录
- **THEN** 页面显示 `raw_response_text` 并标注"原始响应已截断"
### Requirement: 采集失败记录查询
系统 SHALL 提供"流量采集失败记录"列表页,调用 `GET /api/admin/traffic-collection-failures`。筛选条件:资产标识、时间范围、运营商账户、失败类型 `result`、错误类别 `error_category`、上游返回码 `provider_code`、来源、场景。列表 SHALL 展示采集时间、ICCID、运营商账户、失败类型、错误类别、HTTP 状态、上游返回码与信息、网关 code/msg/trace_id、耗时、尝试次数,并可展开原始请求与响应。
#### Scenario: 按失败类型筛选
- **WHEN** 运营选择失败类型"限流"并查询
- **THEN** 列表只显示 `result = rate_limited` 的记录
### Requirement: 卡日流量查询
系统 SHALL 提供"卡日流量"列表页,调用 `GET /api/admin/card-daily-usages`。筛选条件:资产标识、日期范围(`start_date`~`end_date`,含首尾)、运营商账户、当日用量下限 `min_usage_mb`。列表 SHALL 展示日期、ICCID、运营商账户、当日用量 `settled_mb`、当日结束时本月累计、最后一次运营商原始读数、当天写入次数 `obs_count`、是否触发防旧值、更新时间。
#### Scenario: 排查异常大流量
- **WHEN** 运营输入当日用量下限 1024 MB 并查询
- **THEN** 列表只显示当日用量不低于 1024 MB 的卡日记录
### Requirement: 套餐日流量详单查询
系统 SHALL 提供"套餐日流量详单"列表页,调用 `GET /api/admin/package-usage-daily-records`。筛选条件:资产标识、日期范围、套餐使用记录 ID、套餐 ID、运营商账户。列表 SHALL 展示日期、套餐使用记录 ID、套餐 ID、卡 ID 或设备 ID、当日流量、截止当日累计、更新时间。
#### Scenario: 按套餐使用记录查询
- **WHEN** 运营输入套餐使用记录 ID 并查询
- **THEN** 列表只显示该套餐的日流量
### Requirement: 冲正流水查询
系统 SHALL 提供"套餐流量冲正流水"列表页,调用 `GET /api/admin/package-traffic-adjustments`。筛选条件:资产标识、时间范围、套餐使用记录 ID、操作人账号 ID、调整方向 `direction`(补扣 / 退回)。列表 SHALL 展示冲正时间、套餐使用记录 ID、卡 ID 或设备 ID、调整量(正数补扣、负数退回)、调整前后已用量、调整前后套餐状态、原因、操作人、关联流量事件 ID 与采集 ID。
#### Scenario: 只看退回
- **WHEN** 运营选择调整方向"退回"
- **THEN** 列表只显示调整量为负数的流水
### Requirement: 查询范围与账号限制
所有记录查询页的时间或日期跨度 MUST NOT 超过 31 天,超出时 SHALL 在提交前提示并阻止查询。不填资产标识查询全部只允许平台账号与超级管理员:其他账号类型 SHALL 要求填写资产标识后才能查询。后端返回无权限时 SHALL 显示后端提示。
#### Scenario: 跨度超限
- **WHEN** 运营选择 2026-10-01 至 2026-11-06 并查询
- **THEN** 页面提示时间跨度不能超过 31 天,不发请求
#### Scenario: 代理账号不填资产
- **WHEN** 代理账号不填资产标识直接查询
- **THEN** 页面提示请先输入资产标识
### Requirement: 资产详情采集记录
资产详情 SHALL 为卡(含设备绑定的卡)提供"采集记录"入口,弹窗调用 `GET /api/admin/assets/{identifier}/traffic-collections`,默认最近 1 天、每页 50、不含失败记录;可勾选"包含失败记录"(`include_failures = true`),并可按写入判断、来源、场景筛选。列表 SHALL 用 `record_kind` 区分成功与失败记录,成功记录展示读数、累计、增量、写入判断,失败记录展示失败类型、错误类别与上游返回;均可展开原始数据。企业账号 MUST NOT 显示该入口。
#### Scenario: 包含失败记录
- **WHEN** 运营在卡详情打开采集记录并勾选"包含失败记录"
- **THEN** 列表同时显示成功与失败记录,失败记录有明显标识

View File

@@ -1,46 +0,0 @@
## 1. 类型、接口与常量
- [x] 1.1 新增记录查询类型(采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水、后处理尝试记录、`traffic_refresh`)
- [x] 1.2 `types/api/refund.ts`:`Refund` 增加 `asset_post_status`、`asset_post_attempts`、`asset_post_last_error`、`asset_post_next_at`;`RefundQueryParams` 增加 `asset_post_status`
- [x] 1.3 新增 `api/modules/trafficRecords.ts`(6 个全局查询接口),直接按路径引用(未加入 modules/index.ts)
- [x] 1.4 资产采集记录、套餐冲正查询与提交(放在 `trafficRecords.ts` 的 TrafficRecordsService,未改 asset.ts);`AssetRefreshResponse` 增加 `traffic_refresh`
- [x] 1.5 `api/modules/refund.ts` 增加人工重试接口
- [x] 1.6 新增 `config/constants/trafficRecords.ts`:权限码(design D3)、枚举中文名(D5)、31 天跨度校验工具
## 2. 可复用记录组件
- [x] 2.1 原始数据展开组件:格式化 JSON、截断标注、复制
- [x] 2.2 `TrafficCollectionRecords.vue`(成功 / 失败两种模式,支持固定 identifier、include_failures)
- [x] 2.3 `CardDailyUsages.vue`
- [x] 2.4 `PackageUsageDailyRecords.vue`
- [x] 2.5 `PackageTrafficAdjustments.vue`(支持固定 identifier + package_usage_id)
- [x] 2.6 `RefundPostProcessAttempts.vue`(支持固定 refund_id,时间范围必填)
- [x] 2.7 组件统一处理:默认时间范围、31 天校验、非平台账号必须填资产标识、分页
## 3. 页面与路由
- [x] 3.1 新增 5 个记录查询页(资产管理 → 记录管理)与退款后处理尝试记录页(财务管理),注册 `routesAlias.ts` 与 `asyncRoutes.ts`,配置 `meta.permissions`
- [x] 3.2 菜单多语言文案(`locales`)(新菜单沿用停复机记录等页面的写法,标题直接用中文,未加 locales 键)
## 4. 资产详情
- [x] 4.1 `BasicInfoCard` 为卡与设备绑定卡增加"采集记录"按钮(权限 + 非企业账号),弹窗嵌入 `TrafficCollectionRecords`
- [x] 4.2 `PackageListCard` 每笔套餐增加"冲正记录",弹窗嵌入 `PackageTrafficAdjustments`
- [x] 4.3 新增人工冲正弹窗:方向 + 正整数 MB + 原因必填 + 选填关联 ID;二次确认展示预计结果;退回不得小于 0;提交防重复;成功后刷新套餐列表
- [x] 4.4 (可选)同步成功后展示 `traffic_refresh` 入账结果
## 5. 退款
- [x] 5.1 退款列表增加后处理状态列与筛选项
- [x] 5.2 退款详情增加后处理状态、尝试次数(/10)、最后错误、下次重试时间
- [x] 5.3 列表操作列与详情增加"人工重试"(需人工处理 + 平台账号 + 权限),二次确认、防重复,成功替换数据、失败显示错误并重新加载
- [x] 5.4 退款详情增加"后处理尝试记录"入口,按退款单 ID 打开尝试记录
## 6. 验证与发布
- [x] 6.1 `vue-tsc --noEmit` 与 `build:development` 通过(另新增 scripts/tests/trafficRecords.test.mjs;既有 5 个测试文件在未改动基线上即失败,agentRecharge 4 个缺 esbuild,均与本变更无关)
- [ ] 6.2 整理 D3 权限码清单给后端,在测试库 `tb_permission` 补数据并分配测试角色
- [ ] 6.3 超级管理员验证:6 个列表页筛选与原始数据、资产详情采集记录、冲正记录与人工冲正、退款后处理展示与筛选、人工重试
- [ ] 6.4 普通运营账号验证菜单与按钮权限;代理账号验证必须填资产标识、看不到人工重试
- [ ] 6.5 人工冲正、人工重试后确认停复机调用被网关只读拦截(worker 日志),不以上游卡状态为准
- [ ] 6.6 推送 `iteration/8-11` 部署测试环境(先 `docker login registry.boss160.cn`,时间由你安排)

View File

@@ -1,41 +0,0 @@
## 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

@@ -1,43 +0,0 @@
# 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

@@ -1,98 +0,0 @@
## 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

@@ -1,38 +0,0 @@
## 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",
"nd": "vite build --mode development",
"serve": "vite preview",
"deploy:frontend": "node scripts/deploy-frontend.mjs --branch feature/gateway-rate-limit",
"deploy:frontend": "node scripts/deploy-frontend.mjs --branch iteration/august",
"deploy:frontend:branch": "node scripts/deploy-frontend.mjs --branch",
"lint": "eslint",
"fix": "eslint --fix",

View File

@@ -87,32 +87,17 @@ function mountBasicInfo(t, userType, permissions = []) {
},
'../composables/useAssetFormatters': formatters,
'@/composables/useAuth': { useAuth: () => ({ hasAuth }) },
'@/components/business/package-silence/SilenceRecordsDialog.vue': {
default: vue.defineComponent(() => () => null)
},
'@/store/modules/user': { useUserStore: () => user },
'@/config/constants': {
JULY_PERMISSIONS: { speedTier: { view: 'speed:view', update: 'speed:update' } }
},
'@/utils/business/format': { formatDateTime: (value) => String(value || '') },
'@/utils/business/packageSilence': evaluate(
readFileSync('src/utils/business/packageSilence.ts', 'utf8')
),
'@/utils/business/expiryEstimate': {
getExpiryEstimateText: () => '-',
getExpiryEstimateTooltip: () => '',
getExpiryEstimateClass: () => ''
},
'@/config/constants/audit': { AUDIT_PERMISSIONS: {} },
'@/config/constants/trafficRecords': {
TRAFFIC_RECORD_PERMISSIONS: {
assetCollections: {
card: 'iot_info:traffic_collections',
device: 'device_info:traffic_collections'
}
}
},
'@/utils/business/trafficRecords': { isEnterpriseAccount: (type) => type === 4 },
'@/utils/business/auditNavigation': { resolveAuditResourceTarget: () => null },
'@/components/business/audit/investigationController': { openAuditInvestigation: () => {} }
}).default
@@ -194,29 +179,6 @@ function mountBasicInfo(t, userType, permissions = []) {
const textOf = (node) =>
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) => [
...(String(node.props.class || '')
.split(' ')

View File

@@ -83,8 +83,7 @@ function harness(t, overrides = {}) {
},
'@/api/modules': { AssetService: api },
'@/utils/business/apiRateLimit': { FrontendRateLimitError: class extends Error {} },
'@/store/modules/user': { useUserStore: () => user },
'@/utils/business/trafficRecords': { describeTrafficRefresh: () => '' }
'@/store/modules/user': { useUserStore: () => user }
}
const module = { exports: {} }
new Function('require', 'module', 'exports', compiledSource)(
@@ -558,8 +557,6 @@ function pageHandlers(state) {
visit(source)
assert.equal(declarations.length, wanted.size)
const bindings = {
useUserStore: () => ({ info: { user_type: 1 } }),
canEditRestrictedAsset: (type) => [1, 2, 3].includes(Number(type)),
cardInfo: state.cardInfo,
fetchAssetDetail: state.fetchAssetDetail,
loadPackageList: state.loadPackageList,

View File

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

View File

@@ -1,5 +0,0 @@
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

@@ -1,48 +0,0 @@
[
"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

@@ -1,727 +0,0 @@
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

@@ -1,245 +0,0 @@
// 本地浏览器验收专用;所有 /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

@@ -1,491 +0,0 @@
// 全 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

@@ -1,517 +0,0 @@
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

@@ -1,51 +0,0 @@
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

@@ -1,277 +0,0 @@
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

@@ -1,737 +0,0 @@
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

@@ -1,747 +0,0 @@
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

@@ -1,414 +0,0 @@
// 本地验收专用。所有 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

@@ -1,701 +0,0 @@
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

@@ -1,146 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
function evaluate(path, imports = {}) {
const compiled = ts.transpileModule(readFileSync(path, 'utf8'), {
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 constants = evaluate('src/config/constants/trafficRecords.ts')
const utils = evaluate('src/utils/business/trafficRecords.ts', {
'@/config/constants/trafficRecords': constants
})
test('时间范围:结束时间不含,跨度不超过 31 天', () => {
assert.deepEqual(utils.toTimeQuery(['2026-11-01 00:00:00', '2026-11-02 00:00:00']), {
start_time: '2026-11-01 00:00:00',
end_time: '2026-11-02 00:00:00'
})
assert.doesNotThrow(() => utils.toTimeQuery(['2026-10-01 00:00:00', '2026-11-01 00:00:00']))
assert.throws(
() => utils.toTimeQuery(['2026-10-01 00:00:00', '2026-11-06 00:00:00']),
/时间跨度不能超过 31 天/
)
assert.throws(
() => utils.toTimeQuery(['2026-11-02 00:00:00', '2026-11-01 00:00:00']),
/结束时间必须晚于开始时间/
)
assert.throws(() => utils.toTimeQuery(null), /请选择完整的时间范围/)
assert.deepEqual(utils.toTimeQuery(null, false), {})
})
test('日期范围:含首尾,最多 31 天', () => {
assert.deepEqual(utils.toDateQuery(['2026-10-01', '2026-10-31']), {
start_date: '2026-10-01',
end_date: '2026-10-31'
})
assert.throws(() => utils.toDateQuery(['2026-10-01', '2026-11-01']), /日期跨度不能超过 31 天/)
assert.throws(() => utils.toDateQuery(['2026-10-02', '2026-10-01']), /结束日期不得早于开始日期/)
})
test('非平台账号必须填资产标识', () => {
assert.doesNotThrow(() => utils.requireIdentifierForNonPlatform('', 1))
assert.doesNotThrow(() => utils.requireIdentifierForNonPlatform('', 2))
assert.throws(() => utils.requireIdentifierForNonPlatform(' ', 3), /请先输入资产标识/)
assert.doesNotThrow(() => utils.requireIdentifierForNonPlatform('898600', 3))
assert.equal(utils.isEnterpriseAccount(4), true)
assert.equal(utils.isPlatformAccount(3), false)
})
test('筛选值处理', () => {
assert.deepEqual(utils.compactQuery({ a: '', b: 0, c: null, d: undefined, e: false, f: 'x' }), {
b: 0,
e: false,
f: 'x'
})
assert.equal(utils.optionalPositiveInt('', 'ID'), undefined)
assert.equal(utils.optionalPositiveInt('12', 'ID'), 12)
assert.throws(() => utils.optionalPositiveInt('-1', '运营商账户 ID'), /运营商账户 ID须为正整数/)
assert.equal(utils.optionalBoolean('true'), true)
assert.equal(utils.optionalBoolean('false'), false)
assert.equal(utils.optionalBoolean(''), undefined)
assert.equal(utils.formatMb(12), '12 MB')
assert.equal(utils.formatMb(1.256), '1.26 MB')
assert.equal(utils.formatMb(null), '-')
})
test('资产同步后的流量入账结果文案', () => {
assert.equal(utils.describeTrafficRefresh(undefined), '')
assert.equal(
utils.describeTrafficRefresh({
result: 'ok',
reading_mb: 1200,
written: true,
decision: 'written',
delta_mb: 15
}),
'本次读数 1200 MB,已写入,增量 15 MB'
)
assert.equal(
utils.describeTrafficRefresh({
result: 'ok',
reading_mb: 1200,
written: false,
decision: 'queue_not_full'
}),
'本次读数 1200 MB,未写入:采集队列未满'
)
assert.equal(utils.describeTrafficRefresh({ result: 'rate_limited' }), '流量读数限流,未写入')
})
test('退款后处理状态与权限码常量', () => {
assert.equal(constants.REFUND_ASSET_POST_STATUS.manualRequired, 3)
assert.equal(constants.REFUND_ASSET_POST_STATUS_LABELS[3], '需人工处理')
assert.equal(constants.REFUND_ASSET_POST_MAX_ATTEMPTS, 10)
assert.equal(constants.TRAFFIC_RECORD_PERMISSIONS.refundAssetPostRetry, 'refund:asset_post_retry')
assert.equal(constants.labelOf(constants.TRAFFIC_DECISION_LABELS, 'out_of_order'), '采集乱序')
assert.equal(constants.labelOf(constants.TRAFFIC_DECISION_LABELS, 'new_value'), 'new_value')
assert.equal(constants.labelOf(constants.TRAFFIC_DECISION_LABELS, ''), '-')
})
test('卡 / 设备展示优先用 ICCID、设备号,没有时才显示内部 ID', () => {
assert.equal(
utils.assetDisplay({ iot_card_id: 367218, iccid: '89860624660030813727' }),
'89860624660030813727'
)
assert.equal(
utils.assetDisplay({
device_id: 49323,
device_virtual_no: '60003081372',
device_imei: '869555083099518'
}),
'60003081372(IMEI 869555083099518)'
)
assert.equal(
utils.assetDisplay({
device_id: 1,
device_virtual_no: '862639078983683',
device_imei: '862639078983683'
}),
'862639078983683'
)
assert.equal(
utils.assetDisplay({ device_id: 1, device_imei: '869555083099518' }),
'869555083099518'
)
assert.equal(utils.assetDisplay({ iot_card_id: 367218 }), '卡ID 367218')
assert.equal(utils.assetDisplay({ device_id: 49323 }), '设备ID 49323')
assert.equal(utils.assetDisplay({}), '-')
assert.equal(utils.nameOrId('移动账户A', 3), '移动账户A')
assert.equal(utils.nameOrId('', 3), 'ID 3')
assert.equal(utils.nameOrId('', 7, '账号ID'), '账号ID 7')
assert.equal(utils.nameOrId(undefined, 0), '-')
})

View File

@@ -40,17 +40,7 @@ plugins.push(
})
)
if (serve) {
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 { mockApiPlugin } = await import('./octoberBrowserFixtures.mjs')
const server = await createServer({
...loaded.config,
configFile: false,

View File

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

View File

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

View File

@@ -1,97 +0,0 @@
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,5 +60,3 @@ export { PollingPriorityQueueService } from './pollingPriorityQueue'
export { AssetWalletService } from './assetWallet'
export { OperationsReportsService } from './operationsReports'
export { GatewayService } from './gateway'
export { PollingSchedulerService } from './pollingScheduler'

View File

@@ -13,15 +13,6 @@ import type {
} from '@/types/api'
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 查询参数

View File

@@ -1,123 +0,0 @@
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 closeOrder(
id: number,
reason?: string
): Promise<BaseResponse<SilenceExtensionOrderResponse>> {
return this.post(`${root}/orders/${id}/close`, { reason }, 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

@@ -1,32 +0,0 @@
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,13 +3,6 @@
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
PollingMatchPreview,
PollingPreviewRequest,
PollingRecalculateTask,
PollingPreResetStats,
PollingTierStats
} from '@/types/api/pollingConfigExtensions'
import type {
PollingConfig,
PollingConfigQueryParams,
@@ -23,37 +16,6 @@ import type {
* 轮询配置管理服务
*/
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 查询参数

View File

@@ -1,109 +0,0 @@
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

@@ -94,13 +94,4 @@ export class RefundService extends BaseService {
static recoverApproval(id: number): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/recover-approval`)
}
/**
* 人工重试退款资产后处理(仅平台账号;仅"需人工处理"的退款单)
* 成功返回最新退款详情;仍失败时后端返回错误并保留"需人工处理"
* @param id 退款申请ID
*/
static retryAssetPostProcessing(id: number): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/asset-post-processing/retry`)
}
}

View File

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

View File

@@ -3,7 +3,6 @@
*/
import { BaseService } from '../BaseService'
import { ensurePackageSilenceOverrides } from './packageSilenceValidation'
import type {
ShopSeriesGrantResponse,
ShopSeriesGrantQueryParams,
@@ -32,20 +31,9 @@ export class ShopSeriesGrantService extends BaseService {
* POST /api/admin/shop-series-grants
* @param data 授权数据
*/
static async createShopSeriesGrant(
static createShopSeriesGrant(
data: CreateShopSeriesGrantRequest
): 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)
}
@@ -100,21 +88,10 @@ export class ShopSeriesGrantService extends BaseService {
* @param id 授权ID
* @param data 套餐管理数据
*/
static async manageGrantPackages(
static manageGrantPackages(
id: number,
data: ManageGrantPackagesRequest
): 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>>(
`/api/admin/shop-series-grants/${id}/packages`,
data

View File

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

View File

@@ -1,100 +0,0 @@
/**
* 流量记录查询、套餐人工冲正、退款资产后处理尝试记录
* 后端 Iteration/8-11:记录查询接口不传资产标识时查全部,仅平台账号;资产详情接口企业账号无权调用。
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api/common'
import type {
RecordPage,
TrafficCollectionQuery,
TrafficCollectionFailureQuery,
TrafficCollectionRecord,
AssetTrafficCollectionQuery,
CardDailyUsageQuery,
CardDailyUsage,
PackageUsageDailyRecordQuery,
PackageUsageDailyRecord,
PackageTrafficAdjustmentQuery,
PackageTrafficAdjustment,
AdjustPackageTrafficRequest,
RefundPostProcessAttemptQuery,
RefundPostProcessAttempt,
RecordTimeQuery
} from '@/types/api/trafficRecords'
type Page<T> = Promise<BaseResponse<RecordPage<T>>>
const assetPath = (identifier: string) => `/api/admin/assets/${encodeURIComponent(identifier)}`
export class TrafficRecordsService extends BaseService {
/** 运营商流量采集成功记录 */
static getCollections(params: TrafficCollectionQuery): Page<TrafficCollectionRecord> {
return this.get('/api/admin/traffic-collections', params)
}
/** 运营商流量采集失败记录 */
static getCollectionFailures(
params: TrafficCollectionFailureQuery
): Page<TrafficCollectionRecord> {
return this.get('/api/admin/traffic-collection-failures', params)
}
/** 卡日流量记录(卡日快照) */
static getCardDailyUsages(params: CardDailyUsageQuery): Page<CardDailyUsage> {
return this.get('/api/admin/card-daily-usages', params)
}
/** 套餐日流量详单 */
static getPackageUsageDailyRecords(
params: PackageUsageDailyRecordQuery
): Page<PackageUsageDailyRecord> {
return this.get('/api/admin/package-usage-daily-records', params)
}
/** 套餐流量冲正流水 */
static getTrafficAdjustments(
params: PackageTrafficAdjustmentQuery
): Page<PackageTrafficAdjustment> {
return this.get('/api/admin/package-traffic-adjustments', params)
}
/** 退款资产后处理尝试记录(仅平台账号,时间范围必填) */
static getRefundPostProcessAttempts(
params: RefundPostProcessAttemptQuery
): Page<RefundPostProcessAttempt> {
return this.get('/api/admin/refund-post-process-attempts', params)
}
/** 资产详情:卡流量采集记录 */
static getAssetCollections(
identifier: string,
params: AssetTrafficCollectionQuery
): Page<TrafficCollectionRecord> {
return this.get(`${assetPath(identifier)}/traffic-collections`, params)
}
/** 资产详情:某笔套餐的冲正流水 */
static getAssetPackageAdjustments(
identifier: string,
packageUsageId: number,
params: RecordTimeQuery
): Page<PackageTrafficAdjustment> {
return this.get(
`${assetPath(identifier)}/packages/${packageUsageId}/traffic-adjustments`,
params
)
}
/** 资产详情:人工冲正套餐流量(正数补扣、负数退回) */
static adjustAssetPackageTraffic(
identifier: string,
packageUsageId: number,
data: AdjustPackageTrafficRequest
): Promise<BaseResponse<PackageTrafficAdjustment>> {
return this.post(
`${assetPath(identifier)}/packages/${packageUsageId}/traffic-adjustments`,
data
)
}
}

View File

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

View File

@@ -38,9 +38,6 @@
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
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'
const props = withDefaults(
@@ -67,7 +64,6 @@
}>()
const { hasAuth } = useAuth()
const user = useUserStore()
const submitting = ref(false)
const form = reactive({
format: props.defaultFormat
@@ -89,21 +85,14 @@
}
)
const getNormalizedQuery = (): Record<string, string | Record<string, unknown>> => {
const query: Record<string, string | Record<string, unknown>> = {}
const getNormalizedQuery = (): Record<string, string> => {
const query: Record<string, string> = {}
Object.entries(props.query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value) && value.length === 0) return
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') {
if (typeof value === 'string') {
query[key] = value
} else if (typeof value === 'number' || typeof value === 'boolean') {
query[key] = String(value)
@@ -118,14 +107,6 @@
}
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)) {
ElMessage.warning('您没有创建导出任务的权限')
return

View File

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

View File

@@ -1,65 +0,0 @@
<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

@@ -1,58 +0,0 @@
<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

@@ -1,22 +0,0 @@
<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

@@ -1,52 +0,0 @@
<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

@@ -1,268 +0,0 @@
<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

@@ -1,29 +0,0 @@
<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

@@ -1,18 +0,0 @@
<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

@@ -1,341 +0,0 @@
<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
>
<div v-if="canCloseOrder" class="silence-order-actions">
<ElButton type="danger" plain :loading="handling" @click="closeOrder">作废订单</ElButton>
<span class="silence-order-tip">作废后同一套餐可以重新下单;已支付的订单不能作废。</span>
</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()
}
)
// 作废待支付订单:需要发起延期的权限(与生成二维码相同)
const canCloseOrder = computed(
() =>
props.kind === 'orders' &&
data.value?.status === 'pending_payment' &&
data.value?.payment_method !== 'offline' &&
allowed.value &&
hasAuth(P.extend)
)
async function closeOrder() {
if (handling.value || !data.value) return
const scope = pollingScope(user),
path = route.fullPath,
id = silenceId(Number(data.value.id))
handling.value = true
try {
requireGatewayAccess(canCloseOrder.value)
const { value } = await ElMessageBox.prompt(
'作废后该订单不能再支付,同一套餐可以重新下单。作废前会先向支付渠道确认,已支付的会按支付成功办理延期。可填写作废原因(选填)。',
'作废延期订单',
{
type: 'warning',
confirmButtonText: '确认作废',
cancelButtonText: '取消',
inputPlaceholder: '作废原因(选填)'
}
)
requireGatewayAccess(
active && canCloseOrder.value && scope === pollingScope(user) && path === route.fullPath
)
silenceData(await Service.closeOrder(id, value?.trim() || undefined))
if (active && scope === pollingScope(user) && path === route.fullPath) await load()
} catch (e) {
if (active && e !== 'cancel' && e !== 'close') {
actionError.value = gatewayError(e)
await load()
}
} finally {
handling.value = false
}
}
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;
}
}
.silence-order-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin: 12px 0;
}
.silence-order-tip {
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,737 +0,0 @@
<template>
<ElDialog
:model-value="true"
append-to-body
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>
<ElFormItem v-if="mode === 'offline'" label="收款凭证" required
><div class="silence-receipt"
><VoucherUpload
v-model="vouchers"
key="vouchers-offline"
:max-count="5"
@uploading-change="(value) => (uploadingReceipt = value)"
/><p class="silence-ocr-tip"
>先上传收款凭证,系统会自动识别交易单号,可在下方核对修改。</p
></div
></ElFormItem
>
<template v-if="usageId">
<ElFormItem label="套餐"
>{{ packageLabel }}
<ElButton link :loading="eligibilityLoading" @click="loadEligibility"
>刷新资格</ElButton
></ElFormItem
>
<ElFormItem v-if="eligibility" label="订购时间">{{
eligibility.purchased_at ? formatDateTime(eligibility.purchased_at) : '-'
}}</ElFormItem>
<ElFormItem v-if="eligibility" label="到期时间">{{
eligibility.expires_at
? formatDateTime(eligibility.expires_at)
: '未生效(实名激活后开始计算)'
}}</ElFormItem>
<ElFormItem v-if="eligibility" label="沉默期截止"
><span class="silence-deadline">{{
eligibility.silence_deadline_at
? formatDateTime(eligibility.silence_deadline_at)
: eligibility.original_deadline || '-'
}}</span
><span v-if="eligibility.overdue_days" class="silence-overdue"
>(已过期 {{ eligibility.overdue_days }} 天)</span
></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"
:placeholder="ocrLoading ? '正在识别凭证中的交易单号…' : '请输入或核对交易单号'"
@input="tradeNoTouched = true"
/><span v-if="ocrMessage" :class="['silence-ocr-tip', ocrTone]">{{
ocrMessage
}}</span></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 v-if="mode !== 'offline'" 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 { AgentRechargeService } from '@/api/modules/agentRecharge'
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'
// allowed 未传时视为允许:Vue 会把未传的布尔 prop 当成 false,不设默认值时资产详情入口一律报"无权执行此操作"
const props = withDefaults(
defineProps<{
usageId?: number
allowed?: boolean
batchMode?: boolean
packageName?: string
assetIdentifier?: string
}>(),
{ allowed: true, batchMode: false }
)
const emit = defineEmits<{ close: []; changed: [] }>()
// 展示套餐名称(及资产),不展示内部套餐记录 ID;调用方未传名称时才退回显示 ID
const packageLabel = computed(() => {
const name = eligibility.value?.package_name?.trim() || props.packageName?.trim()
const asset = eligibility.value?.asset_identifier || props.assetIdentifier
if (!name) return props.usageId ? `套餐记录 ${props.usageId}` : '-'
return asset ? `${name}(${asset})` : name
})
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))
)
// 线下登记:上传第一张收款凭证后自动识别交易单号(复用代理充值的凭证识别,只预填、需人工核对)
const ocrLoading = ref(false),
ocrMessage = ref(''),
ocrTone = ref<'info' | 'success' | 'warning'>('info'),
tradeNoTouched = ref(false)
let ocrSequence = 0,
ocrKey = ''
async function recognizeTradeNo(key: string) {
if (!key || key === ocrKey) return
ocrKey = key
const current = ++ocrSequence
ocrLoading.value = true
ocrTone.value = 'info'
ocrMessage.value = '正在识别交易单号…'
try {
const res = await AgentRechargeService.recognizePaymentVoucher({ payment_voucher_key: key })
if (current !== ocrSequence) return
const transactionNo = res.code === 0 ? res.data?.external_transaction_no?.trim() : ''
if (!transactionNo) {
ocrTone.value = 'warning'
ocrMessage.value = '未识别到交易单号,请手动填写'
return
}
if (!tradeNoTouched.value || !tradeNo.value.trim()) {
tradeNo.value = transactionNo
tradeNoTouched.value = false
}
ocrTone.value = 'success'
ocrMessage.value = `已识别交易单号 ${transactionNo},请对照凭证核对`
} catch {
if (current !== ocrSequence) return
ocrTone.value = 'warning'
ocrMessage.value = '识别失败,请手动填写交易单号'
} finally {
if (current === ocrSequence) ocrLoading.value = false
}
}
watch(
() => [mode.value, vouchers.value[0]] as const,
([currentMode, firstKey]) => {
if (currentMode === 'offline' && firstKey) void recognizeTradeNo(firstKey)
if (!firstKey) {
ocrKey = ''
ocrMessage.value = ''
}
}
)
// 线下登记的实收金额必须等于试算金额:试算后自动带入,仍可修改
watch(
() => quote.value?.amount,
(amount) => {
if (mode.value === 'offline' && Number.isSafeInteger(amount) && !paidYuan.value) {
paidYuan.value = (Number(amount) / 100).toFixed(2)
}
}
)
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 = ''
tradeNoTouched.value = false
ocrSequence++
ocrKey = ''
ocrLoading.value = false
ocrMessage.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;
}
}
.silence-receipt {
width: 100%;
}
.silence-ocr-tip {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
&.success {
color: var(--el-color-success);
}
&.warning {
color: var(--el-color-warning);
}
}
.silence-overdue {
margin-left: 4px;
color: var(--el-color-danger);
}
</style>

View File

@@ -1,640 +0,0 @@
<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="openExtension(row)"
>延期</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"
:package-name="extendRow?.package_name"
:asset-identifier="extendRow?.asset_identifier"
: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>(),
extendRow = ref<Record<string, any>>(),
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)
}
}
function openExtension(row: Record<string, any>) {
extendRow.value = row
extendId.value = row.package_usage_id
}
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

@@ -1,247 +0,0 @@
<template>
<ElDialog
append-to-body
: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

@@ -1,43 +0,0 @@
<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

@@ -1,71 +0,0 @@
<template>
<ElDialog
:model-value="true"
append-to-body
: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'
// allowed 未传时视为允许(Vue 会把未传的布尔 prop 当成 false)
const props = withDefaults(
defineProps<{
usageId?: number
assetId?: number
assetType?: 'card' | 'device'
allowed?: boolean
}>(),
{ allowed: true }
)
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

@@ -1,96 +0,0 @@
<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"
:package-name="packageName"
:asset-identifier="assetIdentifier"
@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
packageName?: string
assetIdentifier?: string
}>()
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

@@ -1,43 +0,0 @@
<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

@@ -1,30 +0,0 @@
<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

@@ -1,174 +0,0 @@
<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

@@ -1,125 +0,0 @@
<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

@@ -1,147 +0,0 @@
<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

@@ -1,199 +0,0 @@
<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

@@ -1,57 +0,0 @@
<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

@@ -1,144 +0,0 @@
<template>
<div class="traffic-records">
<ArtSearchBar
:filter="filters"
:items="searchItems"
label-width="110px"
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
@search="search"
@reset="reset"
/>
<ElCard shadow="never">
<RecordTableShell
:error="error"
hint="数据来自卡日快照(每卡每天一条);日期含首尾,跨度不超过 31 天。不填资产标识查全部仅平台账号可用。"
:page="pagination.page"
:page-size="pagination.page_size"
:total="total"
@page-change="changePage"
@size-change="changeSize"
>
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无卡日流量">
<ElTableColumn label="日期" prop="date" width="120" />
<ElTableColumn label="ICCID" min-width="190">
<template #default="{ row }">{{ assetDisplay(row) }}</template>
</ElTableColumn>
<ElTableColumn label="运营商账户" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ nameOrId(row.carrier_name, row.carrier_id) }}</template>
</ElTableColumn>
<ElTableColumn label="当日用量" min-width="110">
<template #default="{ row }">{{ formatMb(row.settled_mb) }}</template>
</ElTableColumn>
<ElTableColumn label="当日结束本月累计" min-width="140">
<template #default="{ row }">{{ formatMb(row.month_used_mb) }}</template>
</ElTableColumn>
<ElTableColumn label="最后一次运营商读数" min-width="150">
<template #default="{ row }">{{ formatMb(row.vendor_used_mb) }}</template>
</ElTableColumn>
<ElTableColumn label="写入次数" prop="obs_count" width="90" />
<ElTableColumn label="防旧值" width="80">
<template #default="{ row }">{{ row.stale_guarded ? '是' : '否' }}</template>
</ElTableColumn>
<ElTableColumn label="更新时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</ElTableColumn>
</ElTable>
</RecordTableShell>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { storeToRefs } from 'pinia'
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
import { useCarrierFilterItem, useRecordList } from '@/composables/useRecordList'
import { useUserStore } from '@/store/modules/user'
import type { SearchFormItem } from '@/types'
import type { CardDailyUsage } from '@/types/api/trafficRecords'
import { formatDateTime } from '@/utils/business/format'
import {
compactQuery,
assetDisplay,
formatMb,
nameOrId,
recentDateRange,
requireIdentifierForNonPlatform,
toDateQuery
} from '@/utils/business/trafficRecords'
import RecordTableShell from './RecordTableShell.vue'
const { info: userInfo } = storeToRefs(useUserStore())
const emptyFilters = () => ({
identifier: '',
carrier_id: undefined as number | undefined,
min_usage_mb: '',
range: recentDateRange(7) as [string, string] | null
})
const filters = reactive(emptyFilters())
const { rows, total, loading, error, pagination, load, search, changePage, changeSize } =
useRecordList<CardDailyUsage>((page) => {
const identifier = filters.identifier.trim()
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
const minUsage = filters.min_usage_mb === '' ? undefined : Number(filters.min_usage_mb)
if (minUsage !== undefined && (!Number.isFinite(minUsage) || minUsage < 0)) {
throw new Error('当日用量下限须为非负数')
}
return TrafficRecordsService.getCardDailyUsages(
compactQuery({
...page,
...toDateQuery(filters.range),
identifier,
carrier_id: filters.carrier_id,
min_usage_mb: minUsage
})
)
})
const { item: carrierItem } = useCarrierFilterItem()
const searchItems = computed<SearchFormItem[]>(() => [
{
label: '资产标识',
prop: 'identifier',
type: 'input',
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
},
{
label: '日期',
prop: 'range',
type: 'daterange',
config: {
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD',
clearable: false
}
},
{
label: '当日用量≥(MB)',
prop: 'min_usage_mb',
type: 'input',
config: { clearable: true, placeholder: '排查异常大流量' }
},
carrierItem.value
])
function reset() {
Object.assign(filters, emptyFilters())
void search()
}
void load()
</script>
<style scoped lang="scss">
.traffic-records {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
</style>

View File

@@ -1,226 +0,0 @@
<template>
<div class="traffic-records">
<ArtSearchBar
v-if="!isFixed"
:filter="filters"
:items="searchItems"
:show-expand="true"
label-width="120px"
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
@search="search"
@reset="reset"
/>
<component :is="isFixed ? 'div' : ElCard" shadow="never">
<RecordTableShell
:error="error"
:hint="hint"
:page="pagination.page"
:page-size="pagination.page_size"
:total="total"
@page-change="changePage"
@size-change="changeSize"
>
<ElTable
v-loading="loading"
:data="rows"
row-key="id"
border
:max-height="isFixed ? 460 : undefined"
empty-text="暂无冲正记录"
>
<ElTableColumn label="冲正时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn
v-if="!isFixed"
label="ICCID / 设备号"
min-width="200"
show-overflow-tooltip
>
<template #default="{ row }">{{ assetDisplay(row) }}</template>
</ElTableColumn>
<ElTableColumn v-if="!isFixed" label="套餐" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.package_name || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="调整量" min-width="110">
<template #default="{ row }">
<span :class="row.delta_mb > 0 ? 'delta-deduct' : 'delta-refund'">
{{ row.delta_mb > 0 ? '补扣' : '退回' }} {{ formatMb(Math.abs(row.delta_mb)) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="已用量变化" min-width="170">
<template #default="{ row }">
{{ formatMb(row.before_usage_mb) }} → {{ formatMb(row.after_usage_mb) }}
</template>
</ElTableColumn>
<ElTableColumn label="套餐状态变化" min-width="140">
<template #default="{ row }">
{{ statusName(row.before_status) }} → {{ statusName(row.after_status) }}
</template>
</ElTableColumn>
<ElTableColumn label="原因" prop="reason" min-width="200" show-overflow-tooltip />
<ElTableColumn label="操作人" min-width="120">
<template #default="{ row }">{{
nameOrId(row.operator_name, row.operator_id, '账号ID')
}}</template>
</ElTableColumn>
<ElTableColumn
label="关联采集 ID"
prop="ref_observation_id"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn
label="关联流量事件 ID"
prop="ref_event_id"
min-width="180"
show-overflow-tooltip
/>
</ElTable>
</RecordTableShell>
</component>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { ElCard } from 'element-plus'
import { storeToRefs } from 'pinia'
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
import { useRecordList } from '@/composables/useRecordList'
import { useUserStore } from '@/store/modules/user'
import type { SearchFormItem } from '@/types'
import type { PackageTrafficAdjustment } from '@/types/api/trafficRecords'
import { formatDateTime } from '@/utils/business/format'
import { ADJUSTMENT_DIRECTION_OPTIONS } from '@/config/constants/trafficRecords'
import {
compactQuery,
assetDisplay,
formatMb,
nameOrId,
recentTimeRange,
requireIdentifierForNonPlatform,
toTimeQuery
} from '@/utils/business/trafficRecords'
import RecordTableShell from './RecordTableShell.vue'
/** 传入 identifier + packageUsageId 时为资产详情里的单笔套餐冲正记录 */
const props = defineProps<{ identifier?: string; packageUsageId?: number }>()
const isFixed = computed(() => !!props.identifier && !!props.packageUsageId)
const hint = computed(() =>
isFixed.value
? '按时间倒序展示该套餐的全部冲正记录。'
: '时间跨度不超过 31 天,结束时间不含。补扣为正数、退回为负数。不填资产标识查全部仅平台账号可用。'
)
const PACKAGE_STATUS_NAMES: Record<number, string> = {
0: '待生效',
1: '生效中',
2: '已用完',
3: '已过期',
4: '已失效'
}
const statusName = (status?: number) =>
status === undefined || status === null ? '-' : PACKAGE_STATUS_NAMES[status] || String(status)
const { info: userInfo } = storeToRefs(useUserStore())
const emptyFilters = () => ({
identifier: '',
operator_id: undefined as number | undefined,
direction: '',
range: recentTimeRange(7) as [string, string] | null
})
const filters = reactive(emptyFilters())
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
useRecordList<PackageTrafficAdjustment>((page) => {
if (isFixed.value) {
return TrafficRecordsService.getAssetPackageAdjustments(
props.identifier!,
props.packageUsageId!,
page
)
}
const identifier = filters.identifier.trim()
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
return TrafficRecordsService.getTrafficAdjustments(
compactQuery({
...page,
...toTimeQuery(filters.range),
identifier,
operator_id: filters.operator_id || undefined,
direction: filters.direction as 'deduct' | 'refund' | ''
})
)
})
const searchItems: SearchFormItem[] = [
{
label: '资产标识',
prop: 'identifier',
type: 'input',
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
},
{
label: '冲正时间',
prop: 'range',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
clearable: false
}
},
{
label: '操作人',
prop: 'operator_id',
type: 'business-select',
config: { entity: 'account', placeholder: '输入账号用户名搜索' }
},
{
label: '调整方向',
prop: 'direction',
type: 'select',
options: ADJUSTMENT_DIRECTION_OPTIONS,
config: { clearable: true }
}
]
function reset() {
Object.assign(filters, emptyFilters())
void search()
}
watch(
() => [props.identifier, props.packageUsageId],
() => {
clear()
pagination.page = 1
void load()
},
{ immediate: true }
)
defineExpose({ reload: load })
</script>
<style scoped lang="scss">
.traffic-records {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.delta-deduct {
color: var(--el-color-danger);
}
.delta-refund {
color: var(--el-color-success);
}
</style>

View File

@@ -1,133 +0,0 @@
<template>
<div class="traffic-records">
<ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="true"
label-width="120px"
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
@search="search"
@reset="reset"
/>
<ElCard shadow="never">
<RecordTableShell
:error="error"
hint="日期含首尾,跨度不超过 31 天;设备标识同时匹配设备套餐与其绑定卡的套餐。不填资产标识查全部仅平台账号可用。"
:page="pagination.page"
:page-size="pagination.page_size"
:total="total"
@page-change="changePage"
@size-change="changeSize"
>
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无套餐日流量">
<ElTableColumn label="日期" prop="date" width="120" />
<ElTableColumn label="ICCID / 设备号" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ assetDisplay(row) }}</template>
</ElTableColumn>
<ElTableColumn label="套餐" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ row.package_name || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="当日流量" min-width="110">
<template #default="{ row }">{{ formatMb(row.daily_usage_mb) }}</template>
</ElTableColumn>
<ElTableColumn label="截止当日累计" min-width="120">
<template #default="{ row }">{{ formatMb(row.cumulative_usage_mb) }}</template>
</ElTableColumn>
<ElTableColumn label="更新时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</ElTableColumn>
</ElTable>
</RecordTableShell>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { storeToRefs } from 'pinia'
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
import { useCarrierFilterItem, useRecordList } from '@/composables/useRecordList'
import { useUserStore } from '@/store/modules/user'
import type { SearchFormItem } from '@/types'
import type { PackageUsageDailyRecord } from '@/types/api/trafficRecords'
import { formatDateTime } from '@/utils/business/format'
import {
compactQuery,
assetDisplay,
formatMb,
recentDateRange,
requireIdentifierForNonPlatform,
toDateQuery
} from '@/utils/business/trafficRecords'
import RecordTableShell from './RecordTableShell.vue'
const { info: userInfo } = storeToRefs(useUserStore())
const emptyFilters = () => ({
identifier: '',
package_id: undefined as number | undefined,
carrier_id: undefined as number | undefined,
range: recentDateRange(7) as [string, string] | null
})
const filters = reactive(emptyFilters())
const { rows, total, loading, error, pagination, load, search, changePage, changeSize } =
useRecordList<PackageUsageDailyRecord>((page) => {
const identifier = filters.identifier.trim()
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
return TrafficRecordsService.getPackageUsageDailyRecords(
compactQuery({
...page,
...toDateQuery(filters.range),
identifier,
package_id: filters.package_id || undefined,
carrier_id: filters.carrier_id
})
)
})
const { item: carrierItem } = useCarrierFilterItem()
const searchItems = computed<SearchFormItem[]>(() => [
{
label: '资产标识',
prop: 'identifier',
type: 'input',
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
},
{
label: '日期',
prop: 'range',
type: 'daterange',
config: {
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD',
clearable: false
}
},
{
label: '套餐',
prop: 'package_id',
type: 'business-select',
config: { entity: 'package', placeholder: '输入套餐名称搜索' }
},
carrierItem.value
])
function reset() {
Object.assign(filters, emptyFilters())
void search()
}
void load()
</script>
<style scoped lang="scss">
.traffic-records {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
</style>

View File

@@ -1,78 +0,0 @@
<template>
<div class="raw-data-panel">
<div v-for="block in visibleBlocks" :key="block.title" class="raw-data-block">
<div class="raw-data-title">
<span>{{ block.title }}</span>
<ElTag v-if="block.truncated" size="small" type="warning">已截断</ElTag>
<ElButton link type="primary" size="small" @click="copy(block.text)">复制</ElButton>
</div>
<pre class="raw-data-content">{{ block.text }}</pre>
</div>
<div v-if="!visibleBlocks.length" class="raw-data-empty">无原始数据</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import type { RawDataBlock } from '@/types/api/trafficRecords'
const props = defineProps<{ blocks: RawDataBlock[] }>()
const toText = (value: unknown) => {
if (typeof value === 'string') return value
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
const visibleBlocks = computed(() =>
props.blocks
.filter((block) => block.value !== undefined && block.value !== null && block.value !== '')
.map((block) => ({ ...block, text: toText(block.value) }))
)
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败,请手动选择复制')
}
}
</script>
<style scoped lang="scss">
.raw-data-panel {
display: grid;
gap: 12px;
padding: 8px 16px;
}
.raw-data-title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
font-weight: 600;
}
.raw-data-content {
max-height: 320px;
padding: 8px 12px;
margin: 0;
overflow: auto;
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
white-space: pre-wrap;
background: var(--el-fill-color-light);
border-radius: 4px;
}
.raw-data-empty {
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,65 +0,0 @@
<template>
<div class="record-shell">
<div v-if="hint" class="record-hint">{{ hint }}</div>
<ElAlert
v-if="error"
class="record-error"
type="error"
:title="error"
:closable="false"
show-icon
/>
<slot />
<div class="record-pagination">
<ElPagination
:current-page="page"
:page-size="pageSize"
:total="total"
:page-sizes="pageSizes"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="(value: number) => emit('page-change', value)"
@size-change="(value: number) => emit('size-change', value)"
/>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
error?: string
hint?: string
page: number
pageSize: number
total: number
pageSizes?: number[]
}>(),
{ error: '', hint: '', pageSizes: () => [20, 50, 100] }
)
const emit = defineEmits<{
(e: 'page-change', page: number): void
(e: 'size-change', size: number): void
}>()
</script>
<style scoped lang="scss">
.record-shell {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.record-hint {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.record-pagination {
display: flex;
justify-content: flex-end;
overflow-x: auto;
}
</style>

View File

@@ -1,204 +0,0 @@
<template>
<div class="traffic-records">
<ElAlert
v-if="!allowed"
type="warning"
title="退款资产后处理尝试记录仅平台账号可查看"
:closable="false"
show-icon
/>
<template v-else>
<ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="true"
label-width="110px"
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
@search="search"
@reset="reset"
/>
<ElCard shadow="never">
<RecordTableShell
:error="error"
hint="时间范围必填,跨度不超过 31 天,结束时间不含;按时间倒序。"
:page="pagination.page"
:page-size="pagination.page_size"
:total="total"
@page-change="changePage"
@size-change="changeSize"
>
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无尝试记录">
<ElTableColumn label="记录时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn label="退款单号" min-width="180">
<template #default="{ row }">{{ row.refund_no || row.refund_id }}</template>
</ElTableColumn>
<ElTableColumn label="资产" min-width="200">
<template #default="{ row }">
{{ row.asset_type === 'device' ? '设备' : '单卡' }}
{{
row.asset_type === 'device'
? assetDisplay({
device_id: row.asset_id,
device_virtual_no: row.device_virtual_no,
device_imei: row.device_imei
})
: assetDisplay({ iot_card_id: row.asset_id, iccid: row.iccid })
}}
</template>
</ElTableColumn>
<ElTableColumn label="第几次" prop="attempt" width="80" />
<ElTableColumn label="步骤" width="110">
<template #default="{ row }">{{
labelOf(REFUND_POST_STEP_LABELS, row.step)
}}</template>
</ElTableColumn>
<ElTableColumn label="结果" width="80">
<template #default="{ row }">
<ElTag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
{{ labelOf(REFUND_POST_RESULT_LABELS, row.result) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="错误码" prop="error_code" min-width="120" show-overflow-tooltip />
<ElTableColumn
label="错误信息"
prop="error_message"
min-width="220"
show-overflow-tooltip
/>
<ElTableColumn label="触发方式" width="90">
<template #default="{ row }">{{
labelOf(REFUND_POST_TRIGGER_LABELS, row.trigger)
}}</template>
</ElTableColumn>
<ElTableColumn label="耗时(ms)" prop="duration_ms" width="90" />
</ElTable>
</RecordTableShell>
</ElCard>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
import { useRecordList } from '@/composables/useRecordList'
import { useUserStore } from '@/store/modules/user'
import type { SearchFormItem } from '@/types'
import type { RefundPostProcessAttempt } from '@/types/api/trafficRecords'
import { formatDateTime } from '@/utils/business/format'
import {
REFUND_POST_RESULT_LABELS,
REFUND_POST_RESULT_OPTIONS,
REFUND_POST_STEP_LABELS,
REFUND_POST_STEP_OPTIONS,
REFUND_POST_TRIGGER_LABELS,
REFUND_POST_TRIGGER_OPTIONS,
RECORD_MAX_SPAN_DAYS,
labelOf
} from '@/config/constants/trafficRecords'
import {
compactQuery,
assetDisplay,
isPlatformAccount,
recentTimeRange,
toTimeQuery
} from '@/utils/business/trafficRecords'
import RecordTableShell from './RecordTableShell.vue'
/** refundId 来自退款详情入口:自动按该退款单筛选,时间范围取最近 31 天 */
const props = defineProps<{ refundId?: number }>()
const { info: userInfo } = storeToRefs(useUserStore())
const allowed = computed(() => isPlatformAccount(userInfo.value.user_type))
const emptyFilters = () => ({
refund_no: '',
identifier: '',
step: '',
result: '',
error_code: '',
trigger: '',
range: recentTimeRange(props.refundId ? RECORD_MAX_SPAN_DAYS : 7) as [string, string] | null
})
const filters = reactive(emptyFilters())
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
useRecordList<RefundPostProcessAttempt>((page) =>
TrafficRecordsService.getRefundPostProcessAttempts(
compactQuery({
...page,
...toTimeQuery(filters.range, true),
// 从退款详情进入时固定按该退款单筛选,不让用户手填内部 ID
refund_id: props.refundId || undefined,
refund_no: filters.refund_no.trim(),
identifier: filters.identifier.trim(),
step: filters.step,
result: filters.result,
error_code: filters.error_code.trim(),
trigger: filters.trigger
})
)
)
const select = (label: string, prop: string, options: SearchFormItem['options']) => ({
label,
prop,
type: 'select' as const,
options,
config: { clearable: true }
})
const searchItems: SearchFormItem[] = [
{
label: '记录时间',
prop: 'range',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
clearable: true
}
},
{ label: '退款单号', prop: 'refund_no', type: 'input', config: { clearable: true } },
{
label: '资产标识',
prop: 'identifier',
type: 'input',
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / IMEI / SN' }
},
select('处理步骤', 'step', REFUND_POST_STEP_OPTIONS),
select('结果', 'result', REFUND_POST_RESULT_OPTIONS),
{ label: '错误码', prop: 'error_code', type: 'input', config: { clearable: true } },
select('触发方式', 'trigger', REFUND_POST_TRIGGER_OPTIONS)
]
function reset() {
Object.assign(filters, emptyFilters())
void search()
}
watch(
() => [props.refundId, allowed.value],
() => {
clear()
Object.assign(filters, emptyFilters())
pagination.page = 1
if (allowed.value) void load()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.traffic-records {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
</style>

View File

@@ -1,192 +0,0 @@
<template>
<ElDialog
:model-value="modelValue"
title="人工冲正"
width="min(560px, 96vw)"
destroy-on-close
@update:model-value="(value: boolean) => emit('update:modelValue', value)"
@closed="resetForm"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px">
<ElFormItem label="套餐名称">
<span>{{ packageName || '-' }}</span>
</ElFormItem>
<ElFormItem label="当前已用量">
<span>{{ formatMb(currentUsageMb) }}</span>
</ElFormItem>
<ElFormItem label="调整方向" prop="direction">
<ElRadioGroup v-model="form.direction">
<ElRadio value="deduct">补扣(增加已用量)</ElRadio>
<ElRadio value="refund">退回(减少已用量)</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="调整量(MB)" prop="amount">
<ElInputNumber
v-model="form.amount"
:min="1"
:max="form.direction === 'refund' ? Math.max(currentUsageMb, 1) : undefined"
:precision="0"
:step="1"
step-strictly
placeholder="正整数"
/>
</ElFormItem>
<ElFormItem label="调整后已用量">
<span :class="{ 'adjust-invalid': expectedAfter !== null && expectedAfter < 0 }">
{{ expectedAfter === null ? '-' : formatMb(expectedAfter) }}
</span>
</ElFormItem>
<ElFormItem label="原因" prop="reason">
<ElInput
v-model="form.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="必填,说明冲正依据"
/>
</ElFormItem>
<ElFormItem label="关联采集 ID" prop="ref_observation_id">
<ElInput v-model="form.ref_observation_id" maxlength="128" clearable placeholder="选填" />
</ElFormItem>
<ElFormItem label="关联流量事件 ID" prop="ref_event_id">
<ElInput v-model="form.ref_event_id" maxlength="128" clearable placeholder="选填" />
</ElFormItem>
</ElForm>
<ElAlert
type="info"
:closable="false"
show-icon
title="提交后系统会按规则重新判断停复机,前端不会直接停机或复机。"
/>
<template #footer>
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="submit">提交冲正</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
import { errorMessage, formatMb } from '@/utils/business/trafficRecords'
const props = defineProps<{
modelValue: boolean
identifier: string
packageUsageId?: number
packageName?: string
currentUsageMb: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const emptyForm = () => ({
direction: 'deduct' as 'deduct' | 'refund',
amount: undefined as number | undefined,
reason: '',
ref_observation_id: '',
ref_event_id: ''
})
const form = reactive(emptyForm())
const deltaMb = computed(() => {
if (!form.amount) return null
return form.direction === 'deduct' ? form.amount : -form.amount
})
const expectedAfter = computed(() =>
deltaMb.value === null ? null : props.currentUsageMb + deltaMb.value
)
const rules: FormRules = {
direction: [{ required: true, message: '请选择调整方向', trigger: 'change' }],
amount: [
{ required: true, message: '请输入调整量', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (!Number.isInteger(value) || value <= 0) return callback(new Error('调整量须为正整数'))
if (form.direction === 'refund' && value > props.currentUsageMb) {
return callback(new Error('调整后已用量不能小于 0'))
}
callback()
},
trigger: 'change'
}
],
reason: [
{
validator: (_rule, value: string, callback) =>
value?.trim() ? callback() : callback(new Error('原因必填')),
trigger: 'blur'
}
]
}
function resetForm() {
Object.assign(form, emptyForm())
formRef.value?.clearValidate()
}
async function submit() {
if (submitting.value || !formRef.value) return
if (!props.identifier || !props.packageUsageId) {
ElMessage.error('缺少资产标识或套餐使用记录ID')
return
}
const valid = await formRef.value.validate().catch(() => false)
if (!valid || deltaMb.value === null || expectedAfter.value === null) return
if (expectedAfter.value < 0) {
ElMessage.error('调整后已用量不能小于 0')
return
}
try {
await ElMessageBox.confirm(
`当前已用量 ${formatMb(props.currentUsageMb)},${
form.direction === 'deduct' ? '补扣' : '退回'
} ${formatMb(form.amount)},调整后 ${formatMb(expectedAfter.value)}。提交后系统会按规则重新判断停复机,确认提交?`,
'确认人工冲正',
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' }
)
} catch {
return
}
submitting.value = true
try {
const response = await TrafficRecordsService.adjustAssetPackageTraffic(
props.identifier,
props.packageUsageId,
{
delta_mb: deltaMb.value,
reason: form.reason.trim(),
ref_observation_id: form.ref_observation_id.trim() || undefined,
ref_event_id: form.ref_event_id.trim() || undefined
}
)
const result = response.data
ElMessage.success(
result
? `冲正成功:已用量 ${formatMb(result.before_usage_mb)} → ${formatMb(result.after_usage_mb)}`
: '冲正成功'
)
emit('update:modelValue', false)
emit('success')
} catch (cause) {
// 请求层已弹出后端错误;这里只兜底非 HTTP 错误
if (!(cause as { response?: unknown })?.response) ElMessage.error(errorMessage(cause))
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.adjust-invalid {
color: var(--el-color-danger);
}
</style>

View File

@@ -1,371 +0,0 @@
<template>
<div class="traffic-records">
<ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="110px"
@update:filter="updateFilters"
@search="search"
@reset="reset"
/>
<ElCard shadow="never">
<RecordTableShell
:error="error"
:hint="hint"
:page="pagination.page"
:page-size="pagination.page_size"
:total="total"
:page-sizes="pageSizes"
@page-change="changePage"
@size-change="changeSize"
>
<ElTable
v-loading="loading"
:data="rows"
row-key="id"
border
:max-height="isAsset ? 520 : undefined"
:empty-text="emptyText"
>
<ElTableColumn type="expand" width="44">
<template #default="{ row }">
<RawDataPanel :blocks="rawBlocks(row)" />
</template>
</ElTableColumn>
<ElTableColumn label="采集时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.observed_at) }}</template>
</ElTableColumn>
<ElTableColumn v-if="isAsset" label="类型" width="80">
<template #default="{ row }">
<ElTag :type="row.record_kind === 'failure' ? 'danger' : 'success'" size="small">
{{ row.record_kind === 'failure' ? '失败' : '成功' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn v-if="!isAsset" label="ICCID" min-width="190">
<template #default="{ row }">{{ assetDisplay(row) }}</template>
</ElTableColumn>
<ElTableColumn v-if="!isAsset" label="运营商账户" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ nameOrId(row.carrier_name, row.carrier_id) }}</template>
</ElTableColumn>
<ElTableColumn label="来源" width="90">
<template #default="{ row }">{{ labelOf(TRAFFIC_SOURCE_LABELS, row.source) }}</template>
</ElTableColumn>
<ElTableColumn label="场景" prop="scene" min-width="120" show-overflow-tooltip />
<template v-if="mode !== 'failure'">
<ElTableColumn label="运营商读数" min-width="110">
<template #default="{ row }">{{
successValue(row, formatMb(row.vendor_used_mb))
}}</template>
</ElTableColumn>
<ElTableColumn label="本月累计" min-width="110">
<template #default="{ row }">{{
successValue(row, formatMb(row.month_used_mb))
}}</template>
</ElTableColumn>
<ElTableColumn label="本次增量" min-width="100">
<template #default="{ row }">{{
successValue(row, formatMb(row.delta_mb))
}}</template>
</ElTableColumn>
<ElTableColumn label="写入判断" min-width="110">
<template #default="{ row }">
{{ successValue(row, labelOf(TRAFFIC_DECISION_LABELS, row.decision)) }}
</template>
</ElTableColumn>
<ElTableColumn label="防旧值" width="80">
<template #default="{ row }">{{
successValue(row, row.stale_guarded ? '是' : '否')
}}</template>
</ElTableColumn>
</template>
<template v-if="mode !== 'success'">
<ElTableColumn label="失败类型" min-width="100">
<template #default="{ row }">{{
failureValue(row, labelOf(TRAFFIC_RESULT_LABELS, row.result))
}}</template>
</ElTableColumn>
<ElTableColumn label="错误类别" min-width="100">
<template #default="{ row }">
{{ failureValue(row, labelOf(TRAFFIC_ERROR_CATEGORY_LABELS, row.error_category)) }}
</template>
</ElTableColumn>
<ElTableColumn label="上游返回" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ failureValue(row, joinText(row.provider_code, row.provider_message)) }}
</template>
</ElTableColumn>
<ElTableColumn label="HTTP 状态" width="90">
<template #default="{ row }">{{
failureValue(row, display(row.http_status))
}}</template>
</ElTableColumn>
<ElTableColumn label="耗时(ms)" width="90">
<template #default="{ row }">{{
failureValue(row, display(row.duration_ms))
}}</template>
</ElTableColumn>
</template>
<ElTableColumn label="网关 code / msg" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{
joinText(row.gateway_code, row.gateway_msg)
}}</template>
</ElTableColumn>
<ElTableColumn
label="网关 trace_id"
prop="gateway_trace_id"
min-width="160"
show-overflow-tooltip
/>
<ElTableColumn label="网关尝试" width="80">
<template #default="{ row }">{{ display(row.gateway_attempts) }}</template>
</ElTableColumn>
<ElTableColumn
label="采集 ID"
prop="observation_id"
min-width="200"
show-overflow-tooltip
/>
</ElTable>
</RecordTableShell>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
import { useCarrierFilterItem, useRecordList } from '@/composables/useRecordList'
import { useUserStore } from '@/store/modules/user'
import type { SearchFormItem } from '@/types'
import type { RawDataBlock, TrafficCollectionRecord } from '@/types/api/trafficRecords'
import { formatDateTime } from '@/utils/business/format'
import {
TRAFFIC_DECISION_LABELS,
TRAFFIC_DECISION_OPTIONS,
TRAFFIC_ERROR_CATEGORY_LABELS,
TRAFFIC_ERROR_CATEGORY_OPTIONS,
TRAFFIC_FAILURE_RESULT_OPTIONS,
TRAFFIC_RESULT_LABELS,
TRAFFIC_SOURCE_LABELS,
TRAFFIC_SOURCE_OPTIONS,
BOOLEAN_FILTER_OPTIONS,
labelOf
} from '@/config/constants/trafficRecords'
import {
compactQuery,
assetDisplay,
formatMb,
nameOrId,
optionalBoolean,
recentTimeRange,
requireIdentifierForNonPlatform,
toTimeQuery
} from '@/utils/business/trafficRecords'
import RawDataPanel from './RawDataPanel.vue'
import RecordTableShell from './RecordTableShell.vue'
/**
* success:全局采集成功记录;failure:全局采集失败记录;
* asset:资产详情的卡采集记录(固定 identifier,可包含失败记录)。
*/
const props = withDefaults(
defineProps<{ mode?: 'success' | 'failure' | 'asset'; identifier?: string }>(),
{ mode: 'success', identifier: '' }
)
const isAsset = computed(() => props.mode === 'asset')
const { item: carrierItem } = useCarrierFilterItem(props.mode !== 'asset')
const { info: userInfo } = storeToRefs(useUserStore())
const pageSizes = computed(() => (isAsset.value ? [50, 100] : [20, 50, 100]))
const emptyText = computed(() => (props.mode === 'failure' ? '暂无采集失败记录' : '暂无采集记录'))
const hint = computed(() =>
isAsset.value
? '仅采集时刻有套餐的卡有记录,保留 90 天;时间跨度不超过 31 天,结束时间不含。'
: '时间跨度不超过 31 天,结束时间不含;在线保留当月加最近 2 个整月。不填资产标识查全部仅平台账号可用。'
)
const emptyFilters = () => ({
identifier: '',
carrier_id: undefined as number | undefined,
decision: '',
source: '',
scene: '',
has_delta: '',
stale_guarded: '',
result: '',
error_category: '',
provider_code: '',
include_failures: '',
range: recentTimeRange(1) as [string, string] | null
})
const filters = reactive(emptyFilters())
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
useRecordList<TrafficCollectionRecord>(
(page) => {
const time = toTimeQuery(filters.range)
if (isAsset.value) {
return TrafficRecordsService.getAssetCollections(
props.identifier,
compactQuery({
...page,
...time,
decision: filters.decision,
source: filters.source,
scene: filters.scene.trim(),
include_failures: optionalBoolean(filters.include_failures)
})
)
}
const identifier = filters.identifier.trim()
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
const common = {
...page,
...time,
identifier,
carrier_id: filters.carrier_id,
source: filters.source,
scene: filters.scene.trim()
}
if (props.mode === 'failure') {
return TrafficRecordsService.getCollectionFailures(
compactQuery({
...common,
result: filters.result,
error_category: filters.error_category,
provider_code: filters.provider_code.trim()
})
)
}
return TrafficRecordsService.getCollections(
compactQuery({
...common,
decision: filters.decision,
has_delta: optionalBoolean(filters.has_delta),
stale_guarded: optionalBoolean(filters.stale_guarded)
})
)
},
isAsset.value ? 50 : 20
)
const rangeItem: SearchFormItem = {
label: '采集时间',
prop: 'range',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
clearable: false
}
}
const select = (label: string, prop: string, options: unknown[]): SearchFormItem => ({
label,
prop,
type: 'select',
options: options as SearchFormItem['options'],
config: { clearable: true }
})
const input = (label: string, prop: string, placeholder = ''): SearchFormItem => ({
label,
prop,
type: 'input',
config: { clearable: true, maxlength: 64, placeholder }
})
const searchItems = computed<SearchFormItem[]>(() => {
if (isAsset.value) {
return [
rangeItem,
select('写入判断', 'decision', TRAFFIC_DECISION_OPTIONS),
select('来源', 'source', TRAFFIC_SOURCE_OPTIONS),
input('场景', 'scene'),
select('包含失败记录', 'include_failures', BOOLEAN_FILTER_OPTIONS)
]
}
const common = [
input('资产标识', 'identifier', 'ICCID / 虚拟号 / MSISDN / IMEI / SN'),
rangeItem,
carrierItem.value,
select('来源', 'source', TRAFFIC_SOURCE_OPTIONS),
input('场景', 'scene')
]
if (props.mode === 'failure') {
return [
...common,
select('失败类型', 'result', TRAFFIC_FAILURE_RESULT_OPTIONS),
select('错误类别', 'error_category', TRAFFIC_ERROR_CATEGORY_OPTIONS),
input('上游返回码', 'provider_code')
]
}
return [
...common,
select('写入判断', 'decision', TRAFFIC_DECISION_OPTIONS),
select('有增量', 'has_delta', BOOLEAN_FILTER_OPTIONS),
select('触发防旧值', 'stale_guarded', BOOLEAN_FILTER_OPTIONS)
]
})
function updateFilters(value: Record<string, unknown>) {
Object.assign(filters, value)
}
function reset() {
Object.assign(filters, emptyFilters())
void search()
}
const isFailure = (row: TrafficCollectionRecord) =>
props.mode === 'failure' || row.record_kind === 'failure'
const successValue = (row: TrafficCollectionRecord, text: string) => (isFailure(row) ? '-' : text)
const failureValue = (row: TrafficCollectionRecord, text: string) => (isFailure(row) ? text : '-')
const display = (value: unknown) =>
value === undefined || value === null || value === '' ? '-' : String(value)
const joinText = (...values: unknown[]) =>
values.filter((value) => value !== undefined && value !== null && value !== '').join(' / ') ||
'-'
function rawBlocks(row: TrafficCollectionRecord): RawDataBlock[] {
const response: RawDataBlock =
row.raw_response !== undefined && row.raw_response !== null
? { title: '原始响应', value: row.raw_response, truncated: row.raw_truncated }
: { title: '原始响应(文本)', value: row.raw_response_text, truncated: row.raw_truncated }
return [
{ title: '采集队列', value: row.queue_points },
{ title: '原始请求', value: row.raw_request },
response,
{ title: '流量事件 ID', value: row.event_id }
]
}
watch(
() => [props.mode, props.identifier],
() => {
clear()
Object.assign(filters, emptyFilters())
pagination.page = 1
pagination.page_size = isAsset.value ? 50 : 20
if (isAsset.value && !props.identifier) return
void load()
},
{ immediate: true }
)
defineExpose({ reload: load })
</script>
<style scoped lang="scss">
.traffic-records {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
</style>

View File

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

View File

@@ -1,21 +0,0 @@
<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

@@ -1,183 +0,0 @@
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

@@ -1,125 +0,0 @@
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 }
}

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