From 049f8257fb14171ce0e869ff2169b33040451218 Mon Sep 17 00:00:00 2001 From: luo Date: Sat, 24 Oct 2026 16:22:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yaml | 3 +- docs/权限.md | 24 + .../design.md | 41 + .../proposal.md | 32 + .../gateway-rate-limit-management/spec.md | 98 +++ .../tasks.md | 29 + package.json | 2 +- scripts/tests/gateway.test.mjs | 726 ++++++++++++++++++ scripts/tests/gatewayBrowserFixtures.mjs | 245 ++++++ scripts/tests/verifyBuild.mjs | 6 +- src/api/modules/gateway.ts | 97 +++ src/api/modules/index.ts | 1 + .../business/gateway/GatewayDynamicData.vue | 65 ++ .../business/gateway/GatewayOptionsNotice.vue | 22 + .../business/gateway/GatewayRuleFields.vue | 41 + .../business/gateway/StopResumeRecords.vue | 268 +++++++ src/composables/useGateway.ts | 183 +++++ src/config/constants/gateway.ts | 12 + src/router/routes/asyncRoutes.ts | 52 ++ src/router/routesAlias.ts | 5 + src/types/api/gateway.ts | 129 ++++ src/types/api/index.ts | 1 + src/types/components.d.ts | 4 + src/utils/business/gateway.ts | 122 +++ .../components/BasicInfoCard.vue | 28 + .../asset-information/index.vue | 54 +- .../error-classifications/index.vue | 425 ++++++++++ .../gateway-stats/index.vue | 226 ++++++ src/views/polling-management/gateway.scss | 65 ++ .../rate-limit-groups/detail.vue | 127 +++ .../rate-limit-groups/index.vue | 429 +++++++++++ .../stop-resume-records/index.vue | 11 + 32 files changed, 3569 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/add-gateway-rate-limit-management/design.md create mode 100644 openspec/changes/add-gateway-rate-limit-management/proposal.md create mode 100644 openspec/changes/add-gateway-rate-limit-management/specs/gateway-rate-limit-management/spec.md create mode 100644 openspec/changes/add-gateway-rate-limit-management/tasks.md create mode 100644 scripts/tests/gateway.test.mjs create mode 100644 scripts/tests/gatewayBrowserFixtures.mjs create mode 100644 src/api/modules/gateway.ts create mode 100644 src/components/business/gateway/GatewayDynamicData.vue create mode 100644 src/components/business/gateway/GatewayOptionsNotice.vue create mode 100644 src/components/business/gateway/GatewayRuleFields.vue create mode 100644 src/components/business/gateway/StopResumeRecords.vue create mode 100644 src/composables/useGateway.ts create mode 100644 src/config/constants/gateway.ts create mode 100644 src/types/api/gateway.ts create mode 100644 src/utils/business/gateway.ts create mode 100644 src/views/polling-management/error-classifications/index.vue create mode 100644 src/views/polling-management/gateway-stats/index.vue create mode 100644 src/views/polling-management/gateway.scss create mode 100644 src/views/polling-management/rate-limit-groups/detail.vue create mode 100644 src/views/polling-management/rate-limit-groups/index.vue create mode 100644 src/views/polling-management/stop-resume-records/index.vue diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 1617fe7..d4462ce 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -3,6 +3,7 @@ name: 构建并部署前端到测试环境 on: push: branches: + - feature/gateway-rate-limit - iteration/august - dev - test @@ -40,7 +41,7 @@ jobs: fi - name: 部署到测试环境 - if: github.ref == 'refs/heads/iteration/august' + if: github.ref == 'refs/heads/feature/gateway-rate-limit' run: | mkdir -p "${{ env.DEPLOY_DIR }}" cp -v docker-compose.prod.yml "${{ env.DEPLOY_DIR }}/" diff --git a/docs/权限.md b/docs/权限.md index fb3a41a..56b3fbc 100644 --- a/docs/权限.md +++ b/docs/权限.md @@ -234,3 +234,27 @@ - 各场景下载权限:`export_task:_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`;这些前端编码不替代后端独立鉴权。 diff --git a/openspec/changes/add-gateway-rate-limit-management/design.md b/openspec/changes/add-gateway-rate-limit-management/design.md new file mode 100644 index 0000000..4c2b781 --- /dev/null +++ b/openspec/changes/add-gateway-rate-limit-management/design.md @@ -0,0 +1,41 @@ +## Context + +项目使用 Vue 3、TypeScript、Element Plus 和 BaseService。后端控制菜单模式下,超级管理员直接使用全部静态路由,普通用户使用授权菜单;按钮控制复用现有 `useAuth`,身份复用 `userStore.isSuperAdmin`。 + +## Goals / Non-Goals + +- Goals: 完整接入文档声明的 API、可操作的管理页面、可靠表单校验、清晰权限配置交付。 +- Non-Goals: 不实现后端限流,不改变旧轮询/停复机行为,不部署,不猜测统计指标字段。 + +## Decisions + +- 四页统一归属「轮询管理」,不新增一级菜单: + +| 页面 | URL | 父菜单 | +| --- | --- | --- | +| 停复机记录 | `/polling-management/stop-resume-records` | 轮询管理 `/polling-management` | +| 上游错误分类 | `/polling-management/error-classifications` | 轮询管理 `/polling-management` | +| 网关限流组 | `/polling-management/rate-limit-groups` | 轮询管理 `/polling-management` | +| 网关调用统计 | `/polling-management/gateway-stats` | 轮询管理 `/polling-management` | +| 网关限流组详情(隐藏路由) | `/polling-management/rate-limit-groups/detail/:id` | 轮询管理 `/polling-management` | + +- 页面权限分别为 `card_stop_resume_record:page`、`gateway_error_classification:page`、`gateway_rate_limit_group:page`、`gateway_call_stats:page`,隐藏详情路由使用 `gateway_rate_limit_group:detail_page`。普通操作权限为 `gateway_error_classification:test`、`gateway_rate_limit_group:detail`、`gateway_call_stats:trend`;单卡入口按资产类型使用 `iot_info:stop_resume_records` / `device_info:stop_resume_records`。这些是前端配置约定,不声称来自后端接口文档;详情页与按钮使用独立编码,避免菜单与按钮编码冲突。 +- 超级管理员维护入口不定义供普通账号分配的增删改权限;UI 与事件处理双重身份检查,后端继续独立鉴权。 +- 提取专用新模块承载网关契约,避免覆盖旧 `/api/cards/{iccid}/suspend-resume-records` 接口及其不同 DTO。 +- 枚举请求失败显式展示错误和重试,不把不可用枚举误报为空;未知值在只读展示中保留原值。 +- 时间统一转换 RFC3339;全局记录查询必须有完整区间,单卡可使用后端默认;分页最大 100。 +- 统计内部字段使用 `unknown`/记录值承载,只展示实际返回字段,空值不转成业务零;不从不明确字段推导图表或指标。 +- 界面复用项目现有表格、搜索区、分页、弹窗、主题变量及状态标签;参考 UI/UX skill 的加载反馈、可访问标签、键盘焦点与响应式原则,不更换全站字体、颜色或新增表单依赖。 +- 用户补充要求:列表严格复用店铺列表的 ArtSearchBar / ArtTableHeader / ArtTable / ArtTableFullScreen;详情使用店铺详情的返回、标题和双列描述表。时间展示复用同一个 `formatDateTime`,不改变请求与数据源;展开按钮仅在搜索条件数 >= 4 时出现。 +- 运营商账户一律名称搜索下拉,复用 `CarrierService.getCarriers({ page: 1, page_size: 20, carrier_name })`,保留原账户名称回显,选中后仍提交 ID;趋势额外保留「无法解析账户的卡」的值 0。 + +## Risks / Trade-offs + +- 统计契约缺字段 → 接口和数据可查看,但正式业务趋势图需后端样例或完整 DTO 后再确定。 +- 前端权限编码未在 API 文档定义 → 交付明确为菜单按钮配置编码,不替代后端鉴权。 +- 31 天日期窗口和不同时区 → 按实际时间戳差值校验,测试边界与非法值。 +- 用户已有未提交改动 → 不碰部署文件及 package 配置,不引入新依赖。 + +## Validation + +服务契约测试覆盖所有 17 个接口;业务测试覆盖时间范围、QPS、优先级操作、超级管理员门禁、枚举值和统计未知字段。执行 `vue-tsc --noEmit`、Vite 构建、针对性 lint/格式/编码检查。没有真实后端响应时,明确区分本地 mock 验证与线上联调。 diff --git a/openspec/changes/add-gateway-rate-limit-management/proposal.md b/openspec/changes/add-gateway-rate-limit-management/proposal.md new file mode 100644 index 0000000..ede144a --- /dev/null +++ b/openspec/changes/add-gateway-rate-limit-management/proposal.md @@ -0,0 +1,32 @@ +# Change: 网关限流管理前端接入 + +## Why + +`docs/网关限流.md` 新增 17 个管理端接口,当前前端缺少网关限流组、上游错误分类、全局停复机记录及按运营商统计的入口。需要完整接入已定义契约,并在交付时提供普通账号需要配置的菜单与按钮权限,不列出仅超级管理员的操作。 + +## What Changes + +- 在现有「轮询管理」下新增「停复机记录」「上游错误分类」「网关限流组」「网关调用统计」四个页面。 +- 接入全部 17 个接口及类型;枚举通过 `GET /api/admin/polling-options` 获取,不把可配置枚举仅写死在前端。 +- 停复机记录支持文档内筛选、分页、必填时间范围和 31 天跨度校验;单卡信息及设备绑定卡提供单卡历史入口,使用卡 ID 请求新接口。 +- 分类规则支持分页筛选、测试现有规则和草稿;限流组支持分页筛选和详情。新建、修改、启停、删除均仅向超级管理员开放,并在操作函数中再次检查身份。 +- 限流组表单校验 QPS、预留额度与高优先级操作的关系,删除和启停提供确认与防重复提交。 +- 网关统计支持账户汇总及账户趋势查询,校验各接口时间参数。 +- 接入路由别名、静态路由和现有后端菜单模式;使用 `xxx:xxx` 格式前端权限常量控制普通用户操作。 +- 新增契约、参数校验、权限分支和交互回归测试,运行类型检查、构建及编码检查。 +- 完工后输出菜单配置清单(名称、URL、父菜单)与按钮权限编码;清单仅保留非超级管理员专属项。 + +## Impact + +- Affected specs: `gateway-rate-limit-management`(新增)。当前 `openspec list --specs` 无已归档规范;现有 `add-polling-priority-queue` 已完成,与本次新增页面并列,不修改其行为。 +- Affected code: `src/types/api/`、`src/api/modules/`、`src/config/constants/`、`src/views/polling-management/`、`src/views/asset-management/asset-information/`、`src/router/routesAlias.ts`、`src/router/routes/asyncRoutes.ts`、`scripts/tests/`。 +- 不修改网关后端限流算法、既有轮询调度、停复机执行动作;不自动创建数据库菜单,不推送 Git,不执行生产部署。 +- 保留用户在 `.gitea/workflows/deploy.yaml` 与 `package.json` 的现有修改。 + +## Contract boundary + +文档未提供统计接口 `carriers[]`、`points[]` 内部字段,仓库当前也没有 `docs/admin-openapi.yaml` 可补全。实现不得假定次数、失败率、时间戳等字段:先按实际响应提供可查看的动态字段表格/原始数据,并清楚提示契约未展开;有真实字段样例后才绘制业务趋势图。不得用虚构字段或零值冒充统计结果。 + +权限编码是前端配置约定,并非接口文档声明的后端权限;实际授权仍由后端控制。仅超级管理员限制必须依据现有 `userStore.isSuperAdmin`,不能通过配置普通按钮权限绕过。 + +用户于 2026-10-24 回复「直接执行」,已批准按此范围实施。 diff --git a/openspec/changes/add-gateway-rate-limit-management/specs/gateway-rate-limit-management/spec.md b/openspec/changes/add-gateway-rate-limit-management/specs/gateway-rate-limit-management/spec.md new file mode 100644 index 0000000..2cdc2f9 --- /dev/null +++ b/openspec/changes/add-gateway-rate-limit-management/specs/gateway-rate-limit-management/spec.md @@ -0,0 +1,98 @@ +## ADDED Requirements + +### Requirement: 停复机记录查询 + +The frontend SHALL provide global stop/resume records and single-card history using the two documented admin APIs. Global queries MUST include RFC3339 `start_time` and `end_time` with an ordered range no greater than 31 days, all documented optional filters and pagination of at most 100 rows. Single-card history MUST use the card ID rather than ICCID or device ID. + +#### Scenario: 查询全局记录 + +- **WHEN** 用户进入页面或修改筛选、页码 +- **THEN** 前端 MUST 请求 `/api/admin/card-stop-resume-records`,展示记录字段及 `items/page/size/total` +- **AND** 不合法或缺失的时间区间 MUST 阻止请求并显示明确提示。 + +#### Scenario: 查看单卡历史 + +- **WHEN** 获授权用户从单卡或设备绑定卡打开历史 +- **THEN** 前端 MUST 请求 `/api/admin/iot-cards/{id}/stop-resume-records` +- **AND** 无卡 ID 或无入口权限时 MUST 不请求接口,切换卡后不得残留另一张卡的结果。 + +### Requirement: 上游错误分类规则 + +The frontend SHALL implement rule listing, creation, modification, status change, deletion and non-persistent testing according to the documented fields. Rule maintenance MUST be restricted to super administrators. Testing SHALL support the current rule set and an optional valid draft, and display `matched/rule_id/category/category_name/keyword/maybe_accepted`. + +#### Scenario: 普通账号测试分类 + +- **WHEN** 具有测试权限的用户提交操作与上游原始信息 +- **THEN** 前端 MUST 调用 `/api/admin/gateway-error-classifications/test` 并展示实际命中结果 +- **AND** 空信息或超过 1000 字符 MUST 不得提交,不通过增删改接口测试草稿。 + +#### Scenario: 超级管理员维护 + +- **WHEN** 超级管理员提交合法规则或确认启停、删除 +- **THEN** 前端 MUST 使用对应 CRUD 或 status 接口,成功后刷新列表 +- **AND** 普通账号 MUST 不显示维护入口且无法从处理函数发起维护请求。 + +### Requirement: 网关限流组 + +The frontend SHALL implement group listing, details, creation, modification, status change and deletion, including carrier/global and status filters. Maintenance MUST be restricted to super administrators. `max_qps` MUST be an integer in 1–10000, `reserved_qps` MUST be a nonnegative integer less than `max_qps`, operations MUST be nonempty, high-priority operations MUST be a subset and MUST be nonempty when reserved QPS is positive. + +#### Scenario: 维护限流组 + +- **WHEN** 超级管理员提交限流组 +- **THEN** 前端 MUST 校验文档的必填、长度、范围及操作关联约束后调用 POST 或 PUT +- **AND** 全局组 MUST 使用空账户值,不得错误提交为具体账户。 + +#### Scenario: 查询限流组 + +- **WHEN** 获授权用户查询列表或详情 +- **THEN** 前端 MUST 分别调用 `/api/admin/polling-rate-limit-groups` 或 `/{id}` 并展示真实字段 +- **AND** 普通账号 MUST 不能通过详情页进入维护操作。 + +### Requirement: 动态枚举与统计 + +The frontend SHALL load all seven enum collections from `/api/admin/polling-options`, and SHALL implement carrier summary and trend requests with documented parameter bounds. Undefined inner fields in `carriers` and `points` MUST be presented transparently from actual response data without invented metrics, silently substituted zeros or assumed chart series. + +#### Scenario: 加载枚举 + +- **WHEN** 页面需要筛选、编辑或显示网关枚举 +- **THEN** 前端 MUST 使用接口的 label/value,失败时提供错误反馈及重试 +- **AND** 未知只读枚举值 MUST 保留原始值,不被显示为另一个已知状态。 + +#### Scenario: 查询汇总与趋势 + +- **WHEN** 用户选择合法的统计区间、运营商和聚合粒度 +- **THEN** 前端 MUST 请求对应 `/api/admin/polling-stats/carriers` 或 `/{carrier_id}/trend` +- **AND** 汇总 minutes 为 1–60,趋势 minutes 不超过 10080,粒度为有效正整数,账户 0 MUST 可用 +- **AND** 契约缺失的内部字段 MUST 以实际字段或原始数据展示并说明限制。 + +### Requirement: 菜单权限与交付 + +The frontend SHALL register four pages under existing polling management and honor the existing static/superadmin and backend-menu routing modes. Normal actions MUST use explicit `xxx:xxx` permissions. The handoff SHALL list menu name, URL, parent menu and button permission codes only for non-superadmin-exclusive configuration. + +#### Scenario: 普通账号访问 + +- **WHEN** 普通账号获得相应菜单和按钮权限 +- **THEN** 对应页面和普通查询、测试操作 MUST 可用 +- **AND** 未授权按钮和请求 MUST 不可用,超级管理员专属维护能力 MUST 不被普通按钮权限开启。 + +#### Scenario: 交付配置清单 + +- **WHEN** 实现和验证结束 +- **THEN** 交付 MUST 列出实际实现的普通菜单与按钮权限,不列出仅超级管理员专属配置 +- **AND** MUST 披露未进行真实后端联调或统计字段不完整等验证限制。 + +### Requirement: 店铺页面一致性 + +The frontend SHALL reuse the shop list search, table header, column settings, fullscreen and pagination components, and the shop detail back button, title and responsive description layout. All displayed business timestamps MUST use the same `formatDateTime` formatter as the shop list. Search expansion MUST be enabled if and only if there are at least four search fields. Carrier selection MUST be a remote dropdown searched by carrier name using `/api/admin/carriers?page=1&page_size=20&carrier_name=...` while requests keep numeric IDs. + +#### Scenario: 展开与时间显示 + +- **WHEN** 页面有少于 4 个搜索条件 +- **THEN** `show-expand` MUST 为 false;4 个及以上 MUST 为 true +- **AND** 已知列表、详情及统计数据中的业务时间 MUST 展示为店铺列表相同格式,不直接显示 RFC3339 字符串。 + +#### Scenario: 按名称搜索运营商 + +- **WHEN** 用户在运营商账户下拉输入名称 +- **THEN** 前端 MUST 请求 carriers 列表并携带 page=1、page_size=20、carrier_name +- **AND** 编辑回显 MUST 保留原账户名称,筛选/保存 MUST 提交所选 numeric carrier_id,趋势未解析账户 MUST 仍可选择值 0。 diff --git a/openspec/changes/add-gateway-rate-limit-management/tasks.md b/openspec/changes/add-gateway-rate-limit-management/tasks.md new file mode 100644 index 0000000..82cc951 --- /dev/null +++ b/openspec/changes/add-gateway-rate-limit-management/tasks.md @@ -0,0 +1,29 @@ +## 1. 契约和公共能力 + +- [x] 1.1 确认提案并记录审批。 +- [x] 1.2 新增全部接口请求、响应类型和服务方法,统一导出,复用现有认证及错误处理。 +- [x] 1.3 接入动态枚举、权限常量、分页与日期/限流参数校验工具。 + +## 2. 页面与入口 + +- [x] 2.1 新增停复机记录列表,支持全部筛选字段、分页、默认最近时间段和 31 天校验。 +- [x] 2.2 单卡及设备绑定卡新增停复机历史入口,使用真实卡 ID,处理权限及卡切换状态。 +- [x] 2.3 新增上游错误分类页,支持列表筛选、超级管理员维护及现有规则/草稿测试结果展示。 +- [x] 2.4 新增网关限流组页,支持列表、详情、超级管理员维护及关联字段校验。 +- [x] 2.5 新增网关调用统计页,支持汇总、趋势参数和未定义内部字段的透明展示。 +- [x] 2.6 注册路由别名、四个菜单路由及隐藏详情路由、页面权限;复用现有后端菜单解析模式。 + +## 3. 验证与交付 + +- [x] 3.1 覆盖全部服务 URL、HTTP 方法、参数和响应的契约测试。 +- [x] 3.2 覆盖时间、QPS、高优先级操作子集、权限和动态枚举回归测试。 +- [x] 3.3 检查加载、错误、空数据、确认、重复提交和响应式显示。 +- [x] 3.4 执行类型检查、构建、相关回归测试、格式与编码检查,记录实际结果。 +- [x] 3.5 交付菜单名称/URL/父菜单和按钮权限编码,仅列非超级管理员专属配置。 + +## 验证结果 + +- 全量 Node 回归 153/153 通过,其中网关新增 23 项;`vue-tsc --noEmit`、生产构建、针对性 ESLint/Stylelint、格式和 UTF-8 检查通过。 +- 本地 mock 浏览器验证:列表与详情、规则测试、按运营商名称搜索、账户 0 趋势、普通账号无维护按钮及授权隐藏详情路由;375px / 768px / 1024px / 1440px 检查与局部截图复核。 +- 菜单与按钮清单写入 `docs/权限.md`,本地详细接入说明位于 `docs/网关限流-前端接入说明.md`(仓库默认忽略其他 docs 文件)。 +- 未进行真实后端联调、Git 推送或生产部署;统计内部字段缺失时按实际响应展示。构建仍有项目既有常量绑定、循环依赖和大 chunk 警告。 diff --git a/package.json b/package.json index d8f106a..e23ff1a 100644 --- a/package.json +++ b/package.json @@ -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 iteration/august", + "deploy:frontend": "node scripts/deploy-frontend.mjs --branch feature/gateway-rate-limit", "deploy:frontend:branch": "node scripts/deploy-frontend.mjs --branch", "lint": "eslint", "fix": "eslint --fix", diff --git a/scripts/tests/gateway.test.mjs b/scripts/tests/gateway.test.mjs new file mode 100644 index 0000000..8cd3ba4 --- /dev/null +++ b/scripts/tests/gateway.test.mjs @@ -0,0 +1,726 @@ +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: {} }, + './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) +}) diff --git a/scripts/tests/gatewayBrowserFixtures.mjs b/scripts/tests/gatewayBrowserFixtures.mjs new file mode 100644 index 0000000..70da263 --- /dev/null +++ b/scripts/tests/gatewayBrowserFixtures.mjs @@ -0,0 +1,245 @@ +// 本地浏览器验收专用;所有 /api 请求在这里终止,不会代理到真实后端。 +const time = '2026-10-24T10:00:00+08:00' +const option = (value, label) => ({ value, label }) +const options = { + error_categories: [ + option('rate_limited_upstream', '上游频率限制'), + option('business', '业务错误') + ], + exempt_sources: [option('manual', '人工')], + gateway_operations: [ + option('query_flow', '查流量'), + option('query_status', '查状态'), + option('query_realname', '查实名'), + option('stop', '停机'), + option('start', '复机') + ], + match_types: [option('contains', '包含'), option('prefix', '前缀'), option('regex', '正则')], + rate_limit_modes: [option('group', '限流组')], + stop_resume_actions: [option('stop', '停机'), option('start', '复机')], + stop_resume_results: [ + option('success', '成功'), + option('failed', '失败'), + option('unknown', '未知'), + option('already_target', '已是目标状态'), + option('skipped', '已跳过') + ] +} +const rules = [ + { + id: 1, + category: 'rate_limited_upstream', + category_name: '上游频率限制', + keyword: '请求过于频繁', + match_type: 'contains', + operations: ['stop'], + priority: 10, + maybe_accepted: false, + status: 1, + remark: '本地模拟规则', + created_at: time, + updated_at: time + } +] +const groups = [ + { + id: 1, + carrier_id: null, + carrier_name: '', + group_code: 'default', + group_name: '全局默认组', + operations: ['query_flow', 'stop', 'start'], + max_qps: 20, + reserved_qps: 5, + high_priority_operations: ['stop', 'start'], + status: 1, + status_name: '启用', + created_at: time, + updated_at: time + } +] +const records = [ + { + id: 1, + card_id: 42, + carrier_id: 8, + iccid: 'MOCK-ICCID-42', + action: 'stop', + action_name: '停机', + result: 'success', + error_category: '', + error_category_name: '', + provider_message: '操作成功', + provider_code: '0', + attempt: 1, + command_id: 'MOCK-CMD', + source: 'manual', + scene: 'manual_stop', + reason: '本地验收', + duration_ms: 42, + created_at: time + } +] +const page = (items, url) => ({ + items, + total: items.length, + page: Number(url.searchParams.get('page') || 1), + size: Number(url.searchParams.get('page_size') || 20) +}) + +export const mockApiPlugin = { + name: 'gateway-local-mock-api', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url, 'http://127.0.0.1') + const path = url.pathname + if (!path.startsWith('/api/')) return next() + let body = '' + for await (const chunk of req) body += chunk + const input = body ? JSON.parse(body) : {} + let data = page([], url) + let code = 0 + let msg = 'local mock only' + if (path === '/api/auth/me' || path === '/api/auth/login') { + const viewer = req.headers.authorization?.includes('local-gateway-viewer') + const pagePermissions = [ + 'card_stop_resume_record:page', + 'gateway_error_classification:page', + 'gateway_rate_limit_group:page', + 'gateway_rate_limit_group:detail_page', + 'gateway_call_stats:page' + ] + const menuEntries = [ + ['停复机记录', 'stop-resume-records', pagePermissions[0]], + ['上游错误分类', 'error-classifications', pagePermissions[1]], + ['网关限流组', 'rate-limit-groups', pagePermissions[2]], + ['网关限流组详情', 'rate-limit-groups/detail/:id', pagePermissions[3]], + ['网关调用统计', 'gateway-stats', pagePermissions[4]] + ] + data = { + access_token: viewer ? 'local-gateway-viewer' : 'local-gateway-mock-only', + refresh_token: '', + expires_in: 3600, + user: { + id: viewer ? 2 : 1, + user_type: viewer ? 2 : 1, + username: viewer ? '本地只读账号' : '本地模拟管理员', + roles: [viewer ? 'R_ADMIN' : 'R_SUPER'] + }, + permissions: viewer ? pagePermissions : [], + buttons: viewer + ? [ + 'gateway_error_classification:test', + 'gateway_rate_limit_group:detail', + 'gateway_call_stats:trend' + ] + : [], + menus: viewer + ? [ + { + url: '/polling-management', + name: '轮询管理', + children: menuEntries.map(([name, segment, perm_code]) => ({ + name, + url: `/polling-management/${segment}`, + perm_code + })) + } + ] + : [] + } + } else if (path === '/api/admin/polling-options') data = options + else if (path === '/api/admin/carriers') + data = page( + [ + { + id: 8, + carrier_name: '模拟运营商账户', + carrier_type: 'CMCC', + status: 1, + created_at: time, + updated_at: time + } + ].filter((carrier) => + carrier.carrier_name.includes(url.searchParams.get('carrier_name') || '') + ), + url + ) + else if ( + path === '/api/admin/card-stop-resume-records' || + /\/iot-cards\/\d+\/stop-resume-records$/.test(path) + ) + data = page(records, url) + else if (path === '/api/admin/gateway-error-classifications/test') { + data = { + matched: input.message?.includes(input.draft?.keyword || '请求过于频繁') || false, + rule_id: input.draft ? 0 : 1, + category: input.draft?.category || rules[0].category, + category_name: '上游频率限制', + keyword: input.draft?.keyword || rules[0].keyword, + maybe_accepted: false + } + } else if ( + /^\/api\/admin\/(gateway-error-classifications|polling-rate-limit-groups)(\/\d+)?(\/status)?$/.test( + path + ) + ) { + const list = path.includes('gateway-error-classifications') ? rules : groups + const id = Number(path.split('/')[4]) + const row = list.find((item) => item.id === id) + if (req.method === 'GET') data = id ? row : page(list, url) + else if (req.method === 'POST') { + data = { + ...input, + id: Math.max(...list.map((item) => item.id), 0) + 1, + created_at: time, + updated_at: time + } + list.push(data) + } else if (req.method === 'PUT' && row) { + Object.assign(row, input) + if ('group_name' in row) row.status_name = row.status === 1 ? '启用' : '停用' + data = row + } else if (req.method === 'DELETE' && row) { + list.splice(list.indexOf(row), 1) + data = {} + } + } else if (path === '/api/admin/polling-stats/carriers') { + data = { + minutes: Number(url.searchParams.get('minutes') || 15), + mode: '本地模拟模式', + carriers: [ + { + carrier_id: 8, + carrier_name: '模拟运营商', + example_calls: 100, + example_upstream_failures: 2 + } + ] + } + } else if (/\/polling-stats\/carriers\/\d+\/trend$/.test(path)) { + data = { + carrier_id: Number(path.split('/')[5]), + points: [{ example_time: time, example_calls: 10 }] + } + } else if (path.startsWith('/api/admin/assets/resolve/')) { + data = { + asset_type: 'card', + asset_id: 42, + identifier: 'MOCK-ICCID-42', + iccid: 'MOCK-ICCID-42', + status: 1, + network_status: 1, + real_name_status: 1, + carrier_name: '模拟运营商', + cards: [] + } + } else if (req.method !== 'GET') { + code = 400 + msg = '此本地验收接口未定义,已拦截写请求' + } + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify({ code, msg, data })) + }) + } +} diff --git a/scripts/tests/verifyBuild.mjs b/scripts/tests/verifyBuild.mjs index 69b0f94..010b283 100644 --- a/scripts/tests/verifyBuild.mjs +++ b/scripts/tests/verifyBuild.mjs @@ -40,7 +40,11 @@ plugins.push( }) ) if (serve) { - const { mockApiPlugin } = await import('./octoberBrowserFixtures.mjs') + const { mockApiPlugin } = await import( + process.argv.includes('--gateway') + ? './gatewayBrowserFixtures.mjs' + : './octoberBrowserFixtures.mjs' + ) const server = await createServer({ ...loaded.config, configFile: false, diff --git a/src/api/modules/gateway.ts b/src/api/modules/gateway.ts new file mode 100644 index 0000000..9c1208e --- /dev/null +++ b/src/api/modules/gateway.ts @@ -0,0 +1,97 @@ +import { BaseService } from '../BaseService' +import type { BaseResponse, PaginationResponse } from '@/types/api/common' +import type { + GatewayOptions, + StopResumeRecord, + StopResumeQuery, + CardStopResumeQuery, + GatewayRule, + GatewayRuleQuery, + GatewayRuleInput, + GatewayRuleTestInput, + GatewayRuleTestResult, + GatewayGroup, + GatewayGroupQuery, + GatewayGroupInput, + GatewayCarrierStats, + GatewayCarrierTrend +} from '@/types/api/gateway' + +const rulesUrl = '/api/admin/gateway-error-classifications' +const groupsUrl = '/api/admin/polling-rate-limit-groups' + +export class GatewayService extends BaseService { + static getRecords(params: StopResumeQuery): Promise> { + return this.get('/api/admin/card-stop-resume-records', params) + } + + static getCardRecords( + id: number, + params?: CardStopResumeQuery + ): Promise> { + return this.get(`/api/admin/iot-cards/${id}/stop-resume-records`, params) + } + + static getRules(params?: GatewayRuleQuery): Promise> { + return this.get(rulesUrl, params) + } + + static createRule(data: GatewayRuleInput): Promise> { + return this.post(rulesUrl, data) + } + + static updateRule(id: number, data: GatewayRuleInput): Promise> { + return this.put(`${rulesUrl}/${id}`, data) + } + + static setRuleStatus(id: number, status: number): Promise> { + return this.put(`${rulesUrl}/${id}/status`, { status }) + } + + static deleteRule(id: number): Promise> { + return this.delete(`${rulesUrl}/${id}`) + } + + static testRule(data: GatewayRuleTestInput): Promise> { + return this.post(`${rulesUrl}/test`, data) + } + + static getOptions(): Promise> { + return this.get('/api/admin/polling-options') + } + + static getGroups(params?: GatewayGroupQuery): Promise> { + return this.get(groupsUrl, params) + } + + static createGroup(data: GatewayGroupInput): Promise> { + return this.post(groupsUrl, data) + } + + static getGroup(id: number): Promise> { + return this.get(`${groupsUrl}/${id}`) + } + + static updateGroup(id: number, data: GatewayGroupInput): Promise> { + return this.put(`${groupsUrl}/${id}`, data) + } + + static setGroupStatus(id: number, status: number): Promise> { + return this.put(`${groupsUrl}/${id}/status`, { status }) + } + + static deleteGroup(id: number): Promise> { + return this.delete(`${groupsUrl}/${id}`) + } + + static getCarrierStats(minutes = 15): Promise> { + return this.get('/api/admin/polling-stats/carriers', { minutes }) + } + + static getCarrierTrend( + carrierId: number, + params: { minutes?: number; bucket_minutes?: number } = {} + ): Promise> { + return this.get(`/api/admin/polling-stats/carriers/${carrierId}/trend`, params) + } +} diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index 54b5778..4b9c9b8 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -60,3 +60,4 @@ export { PollingPriorityQueueService } from './pollingPriorityQueue' export { AssetWalletService } from './assetWallet' export { OperationsReportsService } from './operationsReports' +export { GatewayService } from './gateway' diff --git a/src/components/business/gateway/GatewayDynamicData.vue b/src/components/business/gateway/GatewayDynamicData.vue new file mode 100644 index 0000000..a2147f6 --- /dev/null +++ b/src/components/business/gateway/GatewayDynamicData.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/components/business/gateway/GatewayOptionsNotice.vue b/src/components/business/gateway/GatewayOptionsNotice.vue new file mode 100644 index 0000000..6c75b19 --- /dev/null +++ b/src/components/business/gateway/GatewayOptionsNotice.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/business/gateway/GatewayRuleFields.vue b/src/components/business/gateway/GatewayRuleFields.vue new file mode 100644 index 0000000..ea6d4dd --- /dev/null +++ b/src/components/business/gateway/GatewayRuleFields.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/components/business/gateway/StopResumeRecords.vue b/src/components/business/gateway/StopResumeRecords.vue new file mode 100644 index 0000000..8a80745 --- /dev/null +++ b/src/components/business/gateway/StopResumeRecords.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/src/composables/useGateway.ts b/src/composables/useGateway.ts new file mode 100644 index 0000000..0c98105 --- /dev/null +++ b/src/composables/useGateway.ts @@ -0,0 +1,183 @@ +import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue' +import { GatewayService } from '@/api/modules/gateway' +import { CarrierService } from '@/api/modules/carrier' +import type { GatewayOptions } from '@/types/api/gateway' +import type { PaginationParams, PaginationResponse } from '@/types/api/common' +import { + gatewayData, + gatewayError, + gatewayLabel, + requireGatewayAccess +} from '@/utils/business/gateway' + +export function useGatewayOptions() { + const options = ref({ + 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( + request: (pagination: PaginationParams) => Promise>, + allowed: () => boolean +) { + const rows = shallowRef([]) + 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(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 } +} diff --git a/src/config/constants/gateway.ts b/src/config/constants/gateway.ts new file mode 100644 index 0000000..f0c5a19 --- /dev/null +++ b/src/config/constants/gateway.ts @@ -0,0 +1,12 @@ +export const GATEWAY_PERMISSIONS = { + records: { page: 'card_stop_resume_record:page' }, + rules: { page: 'gateway_error_classification:page', test: 'gateway_error_classification:test' }, + groups: { + page: 'gateway_rate_limit_group:page', + detailPage: 'gateway_rate_limit_group:detail_page', + detail: 'gateway_rate_limit_group:detail' + }, + stats: { page: 'gateway_call_stats:page', trend: 'gateway_call_stats:trend' }, + cardHistory: 'iot_info:stop_resume_records', + deviceCardHistory: 'device_info:stop_resume_records' +} as const diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index a654525..c7cea7e 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -4,6 +4,7 @@ import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' import { AUDIT_PERMISSIONS } from '@/config/constants/audit' import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration' +import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway' import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools' import { EXPORT_TASK_DETAIL_PAGE_PERMISSION, @@ -1347,6 +1348,57 @@ export const asyncRoutes: AppRouteRecord[] = [ keepAlive: true, permissions: [AUGUST_PERMISSIONS.pollingPriorityQueue.page] } + }, + { + path: 'stop-resume-records', + name: 'GatewayStopResumeRecords', + component: RoutesAlias.GatewayStopResumeRecords, + meta: { + title: '停复机记录', + keepAlive: true, + permissions: [GATEWAY_PERMISSIONS.records.page] + } + }, + { + path: 'error-classifications', + name: 'GatewayErrorClassifications', + component: RoutesAlias.GatewayErrorClassifications, + meta: { + title: '上游错误分类', + keepAlive: true, + permissions: [GATEWAY_PERMISSIONS.rules.page] + } + }, + { + path: 'rate-limit-groups', + name: 'GatewayRateLimitGroups', + component: RoutesAlias.GatewayRateLimitGroups, + meta: { + title: '网关限流组', + keepAlive: true, + permissions: [GATEWAY_PERMISSIONS.groups.page] + } + }, + { + path: 'gateway-stats', + name: 'GatewayCallStats', + component: RoutesAlias.GatewayCallStats, + meta: { + title: '网关调用统计', + keepAlive: true, + permissions: [GATEWAY_PERMISSIONS.stats.page] + } + }, + { + path: 'rate-limit-groups/detail/:id', + name: 'GatewayRateLimitGroupDetail', + component: RoutesAlias.GatewayRateLimitGroupDetail, + meta: { + title: '网关限流组详情', + isHide: true, + keepAlive: false, + permissions: [GATEWAY_PERMISSIONS.groups.detailPage] + } } ] }, diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index 44f4f51..a4bdb39 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -144,6 +144,11 @@ export enum RoutesAlias { ManualTrigger = '/polling-management/manual-trigger', // 手动触发 PollingMonitor = '/polling-management/monitor', // 轮询监控 PollingPriorityQueue = '/polling-management/priority-queue', // 优先队列 + GatewayStopResumeRecords = '/polling-management/stop-resume-records', + GatewayErrorClassifications = '/polling-management/error-classifications', + GatewayRateLimitGroups = '/polling-management/rate-limit-groups', + GatewayRateLimitGroupDetail = '/polling-management/rate-limit-groups/detail', + GatewayCallStats = '/polling-management/gateway-stats', // 公共组件 Success = '/result/success', // 成功 diff --git a/src/types/api/gateway.ts b/src/types/api/gateway.ts new file mode 100644 index 0000000..2391cec --- /dev/null +++ b/src/types/api/gateway.ts @@ -0,0 +1,129 @@ +import type { PaginationParams } from './common' + +export interface GatewayOption { + label: string + value: string +} + +export interface GatewayOptions { + error_categories: GatewayOption[] + exempt_sources: GatewayOption[] + gateway_operations: GatewayOption[] + match_types: GatewayOption[] + rate_limit_modes: GatewayOption[] + stop_resume_actions: GatewayOption[] + stop_resume_results: GatewayOption[] +} + +export interface StopResumeRecord { + id: number + card_id: number + carrier_id: number + iccid: string + action: string + action_name?: string + attempt: number + command_id: string + created_at: string + duration_ms: number + error_category: string + error_category_name?: string + provider_code: string + provider_message: string + reason: string + result: string + scene: string + source: string +} + +export interface CardStopResumeQuery extends PaginationParams { + start_time?: string + end_time?: string +} + +export interface StopResumeQuery extends CardStopResumeQuery { + start_time: string + end_time: string + iccid?: string + carrier_id?: number + action?: string + source?: string + result?: string + error_category?: string +} + +export interface GatewayRuleInput { + category: string + keyword: string + match_type: string + maybe_accepted?: boolean + operations?: string[] + priority?: number + remark?: string + status?: number +} + +export interface GatewayRule extends GatewayRuleInput { + id: number + category_name?: string + created_at: string + updated_at: string +} + +export interface GatewayRuleQuery extends PaginationParams { + category?: string + status?: number + keyword?: string +} + +export interface GatewayRuleTestInput { + operation: string + message: string + draft?: GatewayRuleInput +} + +export interface GatewayRuleTestResult { + matched: boolean + rule_id: number + category: string + category_name: string + keyword: string + maybe_accepted: boolean +} + +export interface GatewayGroupInput { + carrier_id?: number | null + group_code: string + group_name: string + max_qps: number + operations: string[] + high_priority_operations?: string[] + reserved_qps?: number + status?: number +} + +export interface GatewayGroup extends GatewayGroupInput { + id: number + carrier_name?: string + status_name?: string + created_at: string + updated_at: string +} + +export interface GatewayGroupQuery extends PaginationParams { + carrier_id?: number + only_global?: boolean + status?: number +} + +// 文档没有定义数组内部字段,保留真实结构,不假定指标名称。 +export interface GatewayCarrierStats { + minutes: number + mode: string + carriers: unknown[] +} + +export interface GatewayCarrierTrend { + carrier_id: number + points: unknown[] +} diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 11cdac5..8461387 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -158,3 +158,4 @@ export * from './assetWallet' // 运营报表相关 export * from './operationsReport' +export * from './gateway' diff --git a/src/types/components.d.ts b/src/types/components.d.ts index ad2f7d2..98a8a1e 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -148,6 +148,9 @@ declare module 'vue' { ElUpload: typeof import('element-plus/es')['ElUpload'] ElWatermark: typeof import('element-plus/es')['ElWatermark'] ExportTaskCreateDialog: typeof import('./../components/business/ExportTaskCreateDialog.vue')['default'] + GatewayDynamicData: typeof import('./../components/business/gateway/GatewayDynamicData.vue')['default'] + GatewayOptionsNotice: typeof import('./../components/business/gateway/GatewayOptionsNotice.vue')['default'] + GatewayRuleFields: typeof import('./../components/business/gateway/GatewayRuleFields.vue')['default'] HorizontalSubmenu: typeof import('./../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default'] ImportDialog: typeof import('./../components/business/ImportDialog.vue')['default'] LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default'] @@ -169,6 +172,7 @@ declare module 'vue' { ShopCreditLimitDialog: typeof import('./../components/business/ShopCreditLimitDialog.vue')['default'] SidebarSubmenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default'] SpeedLimitDialog: typeof import('./../components/device/SpeedLimitDialog.vue')['default'] + StopResumeRecords: typeof import('./../components/business/gateway/StopResumeRecords.vue')['default'] SwitchCardDialog: typeof import('./../components/device/SwitchCardDialog.vue')['default'] TableContextMenuHint: typeof import('./../components/core/others/TableContextMenuHint.vue')['default'] ThemeSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default'] diff --git a/src/utils/business/gateway.ts b/src/utils/business/gateway.ts new file mode 100644 index 0000000..264347e --- /dev/null +++ b/src/utils/business/gateway.ts @@ -0,0 +1,122 @@ +import type { BaseResponse } from '@/types/api/common' +import type { GatewayGroupInput, GatewayRuleInput, GatewayOption } from '@/types/api/gateway' +import { formatDateTime } from './format' + +export function gatewayData(response: BaseResponse): T { + if (response.code !== 0) throw new Error(response.msg || response.message || '请求失败') + return response.data +} + +export function gatewayError(error: unknown): string { + return error instanceof Error ? error.message : '请求失败,请重试' +} + +export function requireGatewayAccess(allowed: boolean): void { + if (!allowed) throw new Error('无权执行此操作') +} + +export function compactGatewayQuery(query: T): T { + return Object.fromEntries( + Object.entries(query).filter( + ([, value]) => value !== '' && value !== null && value !== undefined + ) + ) as T +} + +export function validateGatewayInteger(value: unknown, min: number, max: number, label: string) { + if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) { + throw new Error(`${label}须为 ${min}–${max} 的整数`) + } +} + +export function gatewayOptionalId(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const id = Number(value) + validateGatewayInteger(id, 0, Number.MAX_SAFE_INTEGER, '运营商账户 ID') + return id +} + +export function gatewayTimeRange(range: unknown, required = true) { + if ((!Array.isArray(range) || range.length === 0) && !required) return {} + if (!Array.isArray(range) || range.length !== 2 || range.some((value) => !value)) { + throw new Error('请选择完整的开始和结束时间') + } + const start = new Date(range[0]).getTime() + const end = new Date(range[1]).getTime() + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) { + throw new Error('时间范围无效,结束时间不得早于开始时间') + } + if (end - start > 31 * 86400000) throw new Error('时间跨度不能超过 31 天') + return { start_time: new Date(start).toISOString(), end_time: new Date(end).toISOString() } +} + +export function recentGatewayRange(): [Date, Date] { + const end = new Date() + return [new Date(end.getTime() - 7 * 86400000), end] +} + +function requiredText(value: string, max: number, label: string) { + if (!value?.trim() || value.length > max) throw new Error(`${label}必填且不能超过 ${max} 字符`) +} + +export function validateGatewayRule(data: GatewayRuleInput) { + requiredText(data.category, 100, '错误分类') + requiredText(data.keyword, 200, '关键字或正则') + requiredText(data.match_type, 100, '匹配方式') + if (data.priority !== undefined) validateGatewayInteger(data.priority, 1, 10000, '优先级') + if ((data.remark?.length || 0) > 500) throw new Error('备注不能超过 500 字符') +} + +export function validateGatewayGroup(data: GatewayGroupInput) { + requiredText(data.group_code, 50, '组标识') + requiredText(data.group_name, 100, '组名称') + validateGatewayInteger(data.max_qps, 1, 10000, '每秒请求上限') + const reserved = data.reserved_qps ?? 0 + validateGatewayInteger(reserved, 0, data.max_qps - 1, '预留额度') + if (!data.operations.length) throw new Error('请至少选择一个组内操作') + const high = data.high_priority_operations || [] + if (high.some((operation) => !data.operations.includes(operation))) { + throw new Error('高优先级操作必须属于组内操作') + } + if (reserved > 0 && !high.length) throw new Error('设置预留额度时必须选择高优先级操作') + if (data.carrier_id != null) { + validateGatewayInteger(data.carrier_id, 0, Number.MAX_SAFE_INTEGER, '运营商账户 ID') + } +} + +export function gatewayLabel(options: GatewayOption[] = [], value?: string | null): string { + return options.find((option) => option.value === value)?.label || value || '-' +} + +export function gatewayValue(value: unknown): string { + if (value === undefined || value === null || value === '') return '-' + return typeof value === 'object' ? JSON.stringify(value) : String(value) +} + +export function gatewayRows(values: unknown[]): Record[] { + return values.map((value) => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : { value } + ) +} + +/** 与店铺列表统一:展示 YYYY-MM-DD HH:mm:ss,原始响应与请求不改写。 */ +export function gatewayDisplayValue(value: unknown, key = ''): string { + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value)) { + return formatDateTime(value) + } + if (typeof value === 'number' && /(?:^|_)(?:at|time|timestamp)$/.test(key)) { + return formatDateTime(value) + } + if (value !== null && typeof value === 'object') { + return JSON.stringify(value, (field, nested) => + typeof nested === 'string' && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(nested) + ? formatDateTime(nested) + : typeof nested === 'number' && /(?:^|_)(?:at|time|timestamp)$/.test(field) + ? formatDateTime(nested) + : nested + ) + } + return gatewayValue(value) +} diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index 820692a..299910c 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -9,6 +9,22 @@
+ 停复机记录 {{ isSubjectActivityView ? '活动记录' : '审计记录' }} @@ -485,6 +501,17 @@
+ 停复机记录 + + + (null) + const stopResumePermission = computed(() => + cardInfo.value?.asset_type === 'card' + ? GATEWAY_PERMISSIONS.cardHistory + : GATEWAY_PERMISSIONS.deviceCardHistory + ) + function showStopResumeRecords(target: { cardId: number; iccid: string }) { + if ( + !hasAuth(stopResumePermission.value) || + !Number.isInteger(target.cardId) || + target.cardId < 0 + ) + return + const asset = cardInfo.value + const belongsToAsset = + asset?.asset_type === 'card' + ? target.cardId === asset.asset_id + : asset?.cards?.some( + (card: { card_id: number; iccid: string }) => + card.card_id === target.cardId && card.iccid === target.iccid + ) + if (!belongsToAsset) return + stopResumeTarget.value = { ...target } + stopResumeVisible.value = true + } + watch( + () => + `${cardInfo.value?.asset_type}:${cardInfo.value?.asset_id}:${cardInfo.value?.identifier}:${JSON.stringify(cardInfo.value?.cards?.map((card: { card_id?: number; iccid: string }) => [card.card_id, card.iccid]))}`, + () => { + stopResumeVisible.value = false + stopResumeTarget.value = null + } + ) const realnamePolicyDialogVisible = ref(false) const updateRealnameStatusDialogVisible = ref(false) const selectedPackageUsageId = ref(null) @@ -598,7 +648,9 @@ */ const handleBindingCardRealnameStatusSuccess = async () => { if (bindingCardRealnameStatusAsset && cardInfo.value === bindingCardRealnameStatusAsset) { - await fetchAssetDetail(bindingCardRealnameStatusAsset.identifier, { preservePackageList: true }) + await fetchAssetDetail(bindingCardRealnameStatusAsset.identifier, { + preservePackageList: true + }) } } diff --git a/src/views/polling-management/error-classifications/index.vue b/src/views/polling-management/error-classifications/index.vue new file mode 100644 index 0000000..d25f206 --- /dev/null +++ b/src/views/polling-management/error-classifications/index.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/src/views/polling-management/gateway-stats/index.vue b/src/views/polling-management/gateway-stats/index.vue new file mode 100644 index 0000000..beee8e0 --- /dev/null +++ b/src/views/polling-management/gateway-stats/index.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/src/views/polling-management/gateway.scss b/src/views/polling-management/gateway.scss new file mode 100644 index 0000000..ead9d50 --- /dev/null +++ b/src/views/polling-management/gateway.scss @@ -0,0 +1,65 @@ +.gateway-page { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; + + .gateway-toolbar, + .gateway-pagination { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin: 12px 0; + } + + .gateway-pagination { + justify-content: flex-end; + overflow-x: auto; + } + + .el-select { + min-width: 160px; + } + + .gateway-filters .el-input { + width: 200px; + } + + .gateway-filters .el-date-editor { + max-width: 100%; + } + + .gateway-help { + font-size: 13px; + line-height: 1.6; + color: var(--el-text-color-secondary); + } + + .gateway-data { + max-height: 400px; + overflow: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; + } + + .gateway-error { + margin-bottom: 12px; + } + + @media (width <= 600px) { + .gateway-filters :deep(.el-form-item) { + display: flex; + margin-right: 0; + } + + .gateway-filters .el-input, + .gateway-filters .el-select { + width: 100%; + } + + .gateway-pagination { + justify-content: flex-start; + } + } +} diff --git a/src/views/polling-management/rate-limit-groups/detail.vue b/src/views/polling-management/rate-limit-groups/detail.vue new file mode 100644 index 0000000..70a2aab --- /dev/null +++ b/src/views/polling-management/rate-limit-groups/detail.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/views/polling-management/rate-limit-groups/index.vue b/src/views/polling-management/rate-limit-groups/index.vue new file mode 100644 index 0000000..ecc2708 --- /dev/null +++ b/src/views/polling-management/rate-limit-groups/index.vue @@ -0,0 +1,429 @@ + + + + + diff --git a/src/views/polling-management/stop-resume-records/index.vue b/src/views/polling-management/stop-resume-records/index.vue new file mode 100644 index 0000000..1b85143 --- /dev/null +++ b/src/views/polling-management/stop-resume-records/index.vue @@ -0,0 +1,11 @@ + + +