fix: some
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled

This commit is contained in:
luo
2026-09-17 18:37:20 +08:00
parent 068d0c4ac2
commit 68d8f769d1
21 changed files with 1281 additions and 85 deletions

View File

@@ -0,0 +1,39 @@
# Change: 新增资产钱包自动续费配置(后台查询/保存 + 自动续费失败通知适配)
## Why
根据 `docs/产品迭代8月份/资产钱包.md`。后端测试环境(`https://cmp-api.boss160.cn``Authorization: Bearer <token>`)已提供资产钱包自动续费全局配置能力,仅有 2 个后台接口H5 侧零新增:
- 查询资产钱包自动续费配置:`GET /api/admin/asset-auto-renewal-config`
- 保存资产钱包自动续费配置:`PUT /api/admin/asset-auto-renewal-config`
后台需要提供配置页面承载总开关、适用范围、指定主套餐、到期前天数等配置;同时新增通知类型 `asset.auto_renewal.failed` 会复用既有通知接口产生新数据,需要适配展示与跳转。
## What Changes
- 新增能力 `asset-wallet-auto-renewal`
- 配置读取:`GET /api/admin/asset-auto-renewal-config`,返回唯一一份全局配置的 `enabled`+`enabled_name``scope`+`scope_name``package_ids``days_before_expiry``config_version``updater``updated_at`
- 配置保存:`PUT /api/admin/asset-auto-renewal-config`
- 请求体 `{ enabled(0/1必填), scope(all|specified必填), package_ids(uint[],仅 specified 时非空且只能选当前可售主套餐), days_before_expiry(190必填) }`
- 语义:单行配置、无新增/删除;保存即递增 `config_version`,记录操作者与前后值快照;配置变更只影响后续扫描,历史记录不重算。
- 权限:仅超级管理员与平台账号可访问;其他身份(代理/企业/个人客户)`403`,提示「无权限操作该资源或资源不存在」,无权限与不存在不区分。
- 通知适配(复用既有接口,不算新接口):
- 后台站内通知:新增通知类型 `asset.auto_renewal.failed`(类别 `expiry`、级别 `warning`),接收人是业务员/店铺账号。走既有 `GET /api/admin/notifications` + `GET /api/admin/notifications/{id}/target`;确认 `target_type``iot_card` / `device` 在导航白名单;`available=false` 时只显示正文不跳转;计入未读数。
- H5 个人客户通知:同一类型已由后端加入客户可见白名单,出现在既有 `GET /api/c/v1/notifications``unread-count`,客户侧只有文案(资产标识、套餐、原因、到期日),无跳转接口、无需新页面,本仓库无需改动。该类型属 `expiry` 类别,展示期以业务到期时间为准,套餐到期后从列表消失(既有类别行为)。
- 明确不做(避免前端空等):无自动续费记录页/异常记录页接口、无尝试记录查询接口、无「有余额就不停机」开关、无 H5 客户侧新接口。
## Impact
- Affected specs:
- `asset-wallet-auto-renewal` — 新增能力
- Affected code:
- `src/types/api/assetWallet.ts`(新增)
- `src/types/api/index.ts`
- `src/api/modules/assetWallet.ts`(新增)
- `src/api/modules/index.ts`
- `src/views/settings/asset-wallet-auto-renewal/index.vue`(新增)
- `src/router/routesAlias.ts`
- `src/router/routes/asyncRoutes.ts`
- `src/utils/business/notificationNavigation.ts`(确认/补充 `iot_card``device` 白名单)
- `src/components/core/layouts/art-notification/index.vue`(确认 `expiry` 分类覆盖新类型)
- `src/locales/langs/zh.json``src/locales/langs/en.json`

View File

@@ -0,0 +1,94 @@
## ADDED Requirements
### Requirement: 自动续费配置查询
The admin frontend SHALL load the single global asset wallet auto-renewal configuration through `GET /api/admin/asset-auto-renewal-config`. 响应字段 SHALL 包含并展示 `enabled`+`enabled_name``scope`+`scope_name``package_ids``days_before_expiry``config_version``updater``updated_at`
#### Scenario: 读取全局自动续费配置
- **WHEN** 用户进入资产钱包自动续费配置页面
- **THEN** 前端 MUST 调用 `GET /api/admin/asset-auto-renewal-config`
- **AND** 页面 MUST 展示总开关及中文名称、适用范围及中文名称、指定主套餐集合、统一到期前天数、配置版本、最近保存操作者与最近保存时间
### Requirement: 自动续费配置保存
The admin frontend SHALL save the configuration through `PUT /api/admin/asset-auto-renewal-config` with request body `{ enabled, scope, package_ids, days_before_expiry }``enabled` SHALL 取值 0/1 且必填;`scope` SHALL 取值 `all`(全部主套餐)或 `specified`(指定主套餐)且必填;`package_ids` 仅在 `scope=specified` 时必填非空且只能选择当前可售主套餐;`days_before_expiry` SHALL 取值 1 至 90 且必填。配置为单行、无新增/删除;保存 SHALL 递增 `config_version` 并记录操作者与前后值快照;配置变更 SHALL 只影响后续扫描,历史记录不重算。
#### Scenario: 保存全部主套餐范围配置
- **WHEN** 用户设置适用范围为「全部主套餐」并提交
- **THEN** 前端 MUST 调用 `PUT /api/admin/asset-auto-renewal-config`
- **AND** 请求体携带 `enabled``scope=all``days_before_expiry`
- **AND** `package_ids` 以空数组提交
- **AND** 保存成功后页面 MUST 以响应中的 `config_version``updater``updated_at` 刷新展示
#### Scenario: 保存指定主套餐范围配置
- **WHEN** 用户设置适用范围为「指定主套餐」并选择若干当前可售主套餐后提交
- **THEN** `package_ids` MUST 非空且只包含当前可售主套餐
- **AND** 前端 MUST 携带 `scope=specified` 与非空 `package_ids` 提交
#### Scenario: 参数校验
- **WHEN** `enabled` 缺失、`scope` 非法、`scope=specified``package_ids` 为空或包含不可售套餐,或 `days_before_expiry` 超出 190
- **THEN** 前端 MUST 阻止提交并给出校验提示
#### Scenario: 仅影响后续扫描
- **WHEN** 用户修改并保存配置
- **THEN** 历史自动续费记录 MUST NOT 被重算
- **AND** 新配置 MUST 只对保存后的扫描生效
### Requirement: 自动续费配置访问权限
Only super admin and platform accounts SHALL be allowed to query or save the asset wallet auto-renewal configuration. 代理、企业与个人客户等身份访问时 SHALL 返回 `403` 并提示「无权限操作该资源或资源不存在」;无权限与不存在的表现 MUST NOT 可区分。
#### Scenario: 授权访问
- **WHEN** 超级管理员或平台账号访问配置接口
- **THEN** 页面 MUST 正常查询与保存配置
#### Scenario: 无权限访问
- **WHEN** 代理、企业或个人客户账号访问配置接口
- **THEN** 页面 MUST 按 `403` 无权限处理并展示统一提示
- **AND** 前端 MUST NOT 通过响应区分无权限与资源不存在
### Requirement: 自动续费失败后台通知
The admin notification center SHALL display the new notification type `asset.auto_renewal.failed`(类别 `expiry`、级别 `warning`through the existing notification APIs接收人为业务员/店铺账号。目标 `target_type``iot_card``device` SHALL 位于通知导航白名单;当目标 `available=false` 时页面 SHALL 只展示正文且不跳转;该类型通知 SHALL 计入未读数。
#### Scenario: 展示并跳转自动续费失败通知
- **WHEN** 通知列表返回 `asset.auto_renewal.failed` 类型且目标可用
- **THEN** 通知中心 MUST 将该通知归入 `expiry` 类别展示
- **AND** 用户点击后 MUST 通过既有目标接口按 `iot_card` / `device` 跳转到对应资产详情
#### Scenario: 目标不可用
- **WHEN** 目标接口返回 `available=false`
- **THEN** 页面 MUST 只展示通知正文且不发起跳转
### Requirement: H5 客户侧通知边界
This capability SHALL NOT 新增或修改 H5 客户侧接口与页面。`asset.auto_renewal.failed` 类型已由后端加入客户可见白名单,通过既有 `GET /api/c/v1/notifications``unread-count` 返回;客户侧仅展示文案(资产标识、套餐、原因、到期日),无跳转接口、无需新页面。该类型属 `expiry` 类别,展示期以业务到期时间为准,套餐到期后从列表消失(既有类别行为)。
#### Scenario: H5 复用既有接口
- **WHEN** 客户侧通知接口返回该类型
- **THEN** 客户侧 MUST 仅展示文案且不提供跳转
- **AND** 本次变更 MUST NOT 新增 H5 客户侧接口或页面
#### Scenario: 到期展示期
- **WHEN** 对应套餐业务到期
- **THEN** 该通知按 `expiry` 类别既有行为从客户侧列表消失
### Requirement: 功能范围边界
This capability SHALL NOT 包含以下内容:自动续费记录页/异常记录页、尝试记录查询、「有余额就不停机」开关、H5 客户侧新接口。
#### Scenario: 明确不提供的功能
- **WHEN** 前端对接本次资产钱包自动续费能力
- **THEN** 页面 MUST NOT 提供或依赖自动续费记录页、异常记录页、尝试记录查询接口与「有余额就不停机」开关

View File

@@ -0,0 +1,27 @@
# Tasks: 资产钱包自动续费配置
## 1. 类型与 API
- [ ] 1.1 新增 `src/types/api/assetWallet.ts``AssetAutoRenewalConfig``enabled`/`enabled_name`/`scope`/`scope_name`/`package_ids`/`days_before_expiry`/`config_version`/`updater`/`updated_at`)、`UpdateAssetAutoRenewalConfigRequest``enabled`/`scope`/`package_ids`/`days_before_expiry`)等类型
- [ ] 1.2 `src/types/api/index.ts` 导出新类型
- [ ] 1.3 新增 `src/api/modules/assetWallet.ts`
- `getAutoRenewalConfig``GET /api/admin/asset-auto-renewal-config`
- `saveAutoRenewalConfig``PUT /api/admin/asset-auto-renewal-config`
- [ ] 1.4 `src/api/modules/index.ts` 导出新服务
## 2. 配置页面
- [ ] 2.1 `src/router/routesAlias.ts` 新增资产钱包自动续费配置路由别名,`src/router/routes/asyncRoutes.ts` 在设置下注册路由与菜单(仅超级管理员与平台账号可见)
- [ ] 2.2 新增 `src/views/settings/asset-wallet-auto-renewal/index.vue`:读取配置并展示总开关、适用范围、指定主套餐、统一到期前天数、配置版本、最近保存操作者与时间
- [ ] 2.3 实现保存表单总开关0/1、适用范围all/specified、到期前天数190 校验);指定范围时主套餐多选仅支持当前可售主套餐且非空
- [ ] 2.4 保存成功后使用响应刷新 `config_version``updater``updated_at`,不做新增/删除记录操作
## 3. 通知适配
- [ ] 3.1 确认 `src/utils/business/notificationNavigation.ts` 白名单包含 `iot_card``device`,目标可用时跳转资产详情
- [ ] 3.2 确认 `src/components/core/layouts/art-notification/index.vue``expiry` 分类可展示 `asset.auto_renewal.failed``available=false` 时只显示正文不跳转
- [ ] 3.3 确认该类型通知计入未读数与分类汇总
## 4. Verification
- [ ] 4.1 验证 GET/PUT 字段传递与参数校验enabled、scope、package_ids、days_before_expiry
- [ ] 4.2 验证指定范围时 package_ids 非空且仅可售主套餐
- [ ] 4.3 验证通知类型展示、目标跳转与不可用目标处理
- [ ] 4.4 验证非超级管理员/平台账号访问的 403 处理
- [ ] 4.5 运行 lint、类型检查与构建

View File

@@ -0,0 +1,53 @@
# Change: 新增轮询管理 - 优先队列 API 前端对接(列表 + 人工优先入队 + 单项详情)
## Why
根据 `docs/产品迭代8月份/优先队列.md`。后端测试环境(`https://cmp-api.boss160.cn``Authorization: Bearer <token>`)已提供「轮询管理 - 优先队列」能力:
- 查询优先轮询项列表:`GET /api/admin/polling-priority-items`
- 人工优先入队:`POST /api/admin/polling-priority-items`
- 查询优先轮询项详情:`GET /api/admin/polling-priority-items/{id}`
该能力服务于:
1. 查询优先轮询项:分页查询卡轮询优先项,按创建时间倒序,支持卡 `card_id`、任务类型 `task_type`、状态 `status`、触发类型 `trigger_type` 筛选;数据范围按卡所属店铺快照下推:代理只能看到自身及下级店铺资产,平台卡不可见。
2. 人工优先入队:为该卡的全部纳入轮询任务类型(实名/流量/套餐/卡状态)建立或合并优先轮询项,原因必填(最长 500 字符)。`不受` 既有人工触发的每日次数上限与 24 小时去重约束;`不修改` 调度优先级,也 `不绕过` 既有并发上限。
3. 查询优先轮询项详情:查询单条卡轮询优先项,越权与不存在返回同一响应,不产生可枚举差异,`不提供` 优先级分级、有效期与人工重触发入口。
## What Changes
- 新增能力 `polling-priority-queue`,提供:
- 优先轮询项列表:
- 分页:`GET /api/admin/polling-priority-items`,按创建时间倒序;
- 筛选:卡 `card_id`、任务类型 `task_type`、状态 `status``pending/processing/completed/failed`)、触发类型 `trigger_type`(含 `manual_trigger` 人工入队);
- 列表项字段:`id`/`card_id`/`iccid`/`task_type(_name)`/`status(_name)`/`trigger_type(_name)`/`trigger_types`/`trigger_count`/`attempt_count`/`result(_name)`/`failure_reason`/`shop_id_snapshot`/`source_order_id`/`source_package_usage_id`/`manual_operator_id(_name)`/`manual_reason`/`claimed_at`/`last_triggered_at`/`created_at`/`updated_at`
- 人工优先入队:`POST /api/admin/polling-priority-items`
- 请求 `{card_id, reason}``reason` 必填、最长 500 字符;
- 语义:为该卡的全部纳入轮询任务类型(实名/流量/套餐/卡状态)建立或合并优先轮询项;
- 约束:同一卡同一任务类型至多一条活动项,重复入队合并 `trigger_count` 与来源集合;
- 边界:`不受` 既有人工触发的每日次数上限与 24 小时去重约束;
- 边界:`不修改` 调度优先级,也 `不绕过` 既有并发上限;
- 响应:`{card_id, created_count, merged_count, task_types, task_type_names, items[{item_id, task_type(_name), status(_name), trigger_count, created, last_triggered_at}]`
- 优先轮询项详情:`GET /api/admin/polling-priority-items/{id}`
- 越权与不存在返回同一响应,不产生可枚举差异;
- `不提供` 优先级分级、有效期与人工重触发入口。
- 任务类型 `task_type``polling:realname` 实名检查 / `polling:carddata` 流量检查 / `polling:package` 套餐检查 / `polling:card_status` 卡状态检查。
- 状态 `status``pending` 待执行 / `processing` 执行中 / `completed` 已完成 / `failed` 失败出队。
- 触发类型 `trigger_type``purchase_activated` 主套餐购买后立即生效 / `renewal_activated` 续购套餐生效 / `queue_activated` 排队主套餐顺延生效 / `addon_activated` 加油包生效 / `no_valid_package` 资产无有效套餐 / `manual_trigger` 人工入队。
- 执行结果 `result``success` 成功 / `failed` 失败 / 空值表示未出结果。
- 错误响应:`400` 请求参数错误 / `401` 未认证或认证已过期 / `403` 无权访问 / `500` 服务器内部错误。
## Impact
- Affected specs:
- `polling-priority-queue` — 新增能力
- Affected code:
- `src/api/modules/pollingPriorityQueue.ts`(新增)
- `src/api/modules/index.ts`
- `src/types/api/pollingPriorityQueue.ts`(新增)
- `src/types/api/index.ts`
- `src/config/constants/augustIteration.ts`
- `src/views/polling-management/priority-queue/index.vue`(新增)
- `src/router/routesAlias.ts`
- `src/router/routes/asyncRoutes.ts`
- `src/locales/langs/zh.json`
- `src/locales/langs/en.json`

View File

@@ -0,0 +1,79 @@
## ADDED Requirements
### Requirement: 优先轮询项列表
The admin frontend SHALL provide a paginated list of card polling priority items through `GET /api/admin/polling-priority-items`. The query SHALL support `page`(默认 1最小 1`page_size`(默认 20最大 100以及筛选参数 `card_id``task_type``status``trigger_type`,并按创建时间倒序返回。
#### Scenario: 分页查询优先轮询项
- **WHEN** 用户进入优先队列页面或提交筛选条件
- **THEN** 前端 MUST 调用 `GET /api/admin/polling-priority-items`
- **AND** 携带 `page``page_size` 及所选筛选参数
- **AND** 页面 MUST 使用响应中的 `data.items``data.page``data.size``data.total` 渲染列表
#### Scenario: 数据范围下推
- **WHEN** 当前登录账号为代理
- **THEN** 列表 MUST 只返回自身及下级店铺资产对应的优先轮询项
- **AND** 平台卡对应的优先轮询项 MUST NOT 出现在列表中
### Requirement: 人工优先入队
The admin frontend SHALL enqueue a card for priority polling through `POST /api/admin/polling-priority-items` with request body `{ card_id, reason }`. The `reason` SHALL be required and no longer than 500 characters. The operation SHALL create or merge priority items for all in-scope polling task types实名/流量/套餐/卡状态of the card. The frontend SHALL NOT apply any existing manual daily quota or 24-hour deduplication constraints, SHALL NOT modify scheduling priority, and SHALL NOT bypass existing concurrency limits.
#### Scenario: 人工入队成功
- **WHEN** 用户填写卡号与原因并提交人工入队
- **THEN** 前端 MUST 调用 `POST /api/admin/polling-priority-items`
- **AND** 请求体为 `{ "card_id": 卡ID, "reason": "加急原因" }`
- **AND** 页面 MUST 展示响应中的 `created_count``merged_count``task_types``task_type_names``items`
#### Scenario: 重复入队合并
- **WHEN** 同一卡同一任务类型已存在活动优先轮询项且再次入队
- **THEN** 该卡该任务类型至多保持一条活动项
- **AND** 响应 MUST 返回合并后的项并将该次入队计入 `trigger_count`
#### Scenario: 原因校验
- **WHEN** `reason` 为空或超过 500 字符
- **THEN** 前端 MUST 阻止提交并给出校验提示
### Requirement: 优先轮询项详情
The admin frontend SHALL query a single priority polling item through `GET /api/admin/polling-priority-items/{id}`. 越权与不存在的响应 MUST 保持一致,不产生可枚举差异。该能力 SHALL NOT 提供优先级分级、有效期与人工重触发入口。
#### Scenario: 查询单项详情
- **WHEN** 用户查看某条优先轮询项详情
- **THEN** 前端 MUST 调用 `GET /api/admin/polling-priority-items/{id}`
- **AND** 页面 MUST 展示卡、任务类型、状态、触发类型、执行结果、失败原因、操作者、触发与尝试次数及相关时间字段
#### Scenario: 越权与不存在不区分
- **WHEN** 当前账号无权访问该记录或该记录不存在
- **THEN** 前端 MUST 按同一错误处理,不得通过响应差异区分越权与不存在
### Requirement: 枚举与状态映射
The admin frontend SHALL map and display the backend enums for task type, status, trigger type and result with their Chinese labels.
#### Scenario: 任务类型展示
- **WHEN** 列表返回 `task_type``polling:realname` / `polling:carddata` / `polling:package` / `polling:card_status`
- **THEN** 页面 MUST 分别展示为 实名检查 / 流量检查 / 套餐检查 / 卡状态检查
#### Scenario: 状态与触发类型展示
- **WHEN** 列表返回 `status``pending`/`processing`/`completed`/`failed`)、`trigger_type``purchase_activated`/`renewal_activated`/`queue_activated`/`addon_activated`/`no_valid_package`/`manual_trigger`)与 `result``success`/`failed`/空)
- **THEN** 页面 MUST 展示对应的中文名称与枚举值
- **AND** `result` 为空时 MUST 展示为未出结果
### Requirement: 错误响应处理
The admin frontend SHALL handle the documented error responses for the priority queue APIs: `400` 请求参数错误、`401` 未认证或认证已过期、`403` 无权访问、`500` 服务器内部错误。
#### Scenario: 权限与参数错误提示
- **WHEN** 接口返回 `401``403` 或参数校验错误
- **THEN** 页面 MUST 展示对应错误提示且不产生前端异常

View File

@@ -0,0 +1,30 @@
# Tasks: 轮询管理 - 优先队列
## 1. 类型
- [ ] 1.1 新增 `src/types/api/pollingPriorityQueue.ts`:优先轮询项 `PollingPriorityItem``id` / `card_id` / `iccid` / `task_type` / `task_type_name` / `status` / `status_name` / `trigger_type` / `trigger_type_name` / `trigger_types` / `trigger_count` / `attempt_count` / `result` / `result_name` / `failure_reason` / `shop_id_snapshot` / `source_order_id` / `source_package_usage_id` / `manual_operator_id` / `manual_operator_name` / `manual_reason` / `claimed_at` / `last_triggered_at` / `created_at` / `updated_at`
- [ ] 1.2 查询参数、分页响应 `PollingPriorityItemPageResult`、人工入队请求/响应、入队项 `PriorityItemResult` 类型
- [ ] 1.3 `src/types/api/index.ts` 导出新类型
## 2. API
- [ ] 2.1 新增 `src/api/modules/pollingPriorityQueue.ts`
- `getPriorityItems``GET /api/admin/polling-priority-items`,分页查询卡轮询优先项,按创建时间倒序,支持卡 `card_id`、任务类型 `task_type`、状态 `status`、触发类型 `trigger_type` 筛选
- `createPriorityItems``POST /api/admin/polling-priority-items`,人工优先入队,请求 `{ card_id, reason }`
- `getPriorityItemDetail``GET /api/admin/polling-priority-items/{id}`,查询单项详情
- [ ] 2.2 `src/api/modules/index.ts` 导出新服务
## 3. 页面
- [ ] 3.1 `src/router/routesAlias.ts` 新增优先队列路由别名,`src/router/routes/asyncRoutes.ts` 在轮询管理下注册路由与菜单
- [ ] 3.2 新增 `src/views/polling-management/priority-queue/index.vue`:筛选(卡/任务类型/状态/触发类型)、分页、表格展示列表项字段
- [ ] 3.3 实现任务类型、状态、触发类型、执行结果枚举映射与中文展示
- [ ] 3.4 实现人工优先入队弹窗:选择卡、原因必填校验(最长 500 字符),提交后展示 created_count / merged_count / items
- [ ] 3.5 实现单项详情查看(页面或抽屉),越权与不存在按同一错误处理
## 4. 常量与文案
- [ ] 4.1 在 `src/config/constants/augustIteration.ts`(或新增常量文件)登记任务类型/状态/触发类型/结果枚举与中文名称
- [ ] 4.2 `src/locales/langs/zh.json``src/locales/langs/en.json` 补充菜单与页面文案
## 5. Verification
- [ ] 5.1 验证筛选与分页参数正确传递、列表按创建时间倒序
- [ ] 5.2 验证人工入队成功/合并/原因校验(空与超长)
- [ ] 5.3 验证详情接口与越权/不存在不区分
- [ ] 5.4 运行 lint、类型检查与构建

View File

@@ -0,0 +1,27 @@
import { BaseService } from '../BaseService'
import type {
BaseResponse,
AssetAutoRenewalConfig,
UpdateAssetAutoRenewalConfigRequest
} from '@/types/api'
/**
* 资产钱包相关服务
*/
export class AssetWalletService extends BaseService {
/**
* 查询自动续费配置
*/
static getAutoRenewalConfig(): Promise<BaseResponse<AssetAutoRenewalConfig>> {
return this.get<BaseResponse<AssetAutoRenewalConfig>>('/api/admin/asset-auto-renewal-config')
}
/**
* 保存自动续费配置
*/
static saveAutoRenewalConfig(
data: UpdateAssetAutoRenewalConfigRequest
): Promise<BaseResponse<AssetAutoRenewalConfig>> {
return this.put<BaseResponse<AssetAutoRenewalConfig>>('/api/admin/asset-auto-renewal-config', data)
}
}

View File

@@ -53,3 +53,7 @@ export { H5PopupConfigurationService } from './h5PopupConfiguration'
// export { SettingService } from './setting' // export { SettingService } from './setting'
export { PackageTrafficAlertService } from './packageTrafficAlert' export { PackageTrafficAlertService } from './packageTrafficAlert'
export { PollingPriorityQueueService } from './pollingPriorityQueue'
export { AssetWalletService } from './assetWallet'

View File

@@ -0,0 +1,53 @@
/**
* 轮询优先队列 API
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
PollingPriorityItem,
PollingPriorityItemPageResult,
PollingPriorityItemQueryParams,
CreatePollingPriorityItemRequest,
CreatePollingPriorityItemResponse
} from '@/types/api'
/**
* 轮询优先队列服务
*/
export class PollingPriorityQueueService extends BaseService {
/**
* 获取优先轮询项列表
* GET /api/admin/polling-priority-items
* 按创建时间倒序返回支持卡ID、任务类型、状态、触发类型筛选
*/
static getPriorityItems(
params?: PollingPriorityItemQueryParams
): Promise<BaseResponse<PollingPriorityItemPageResult>> {
return this.get<BaseResponse<PollingPriorityItemPageResult>>(
'/api/admin/polling-priority-items',
params
)
}
/**
* 人工优先入队
* POST /api/admin/polling-priority-items
* 请求体 { card_id, reason },为卡片全部范围内的轮询任务类型创建或合并优先项
*/
static createPriorityItems(
data: CreatePollingPriorityItemRequest
): Promise<BaseResponse<CreatePollingPriorityItemResponse>> {
return this.post<BaseResponse<CreatePollingPriorityItemResponse>>(
'/api/admin/polling-priority-items',
data
)
}
/**
* 获取优先轮询项详情
* GET /api/admin/polling-priority-items/{id}
*/
static getPriorityItemDetail(id: number): Promise<BaseResponse<PollingPriorityItem>> {
return this.get<BaseResponse<PollingPriorityItem>>(`/api/admin/polling-priority-items/${id}`)
}
}

View File

@@ -43,3 +43,6 @@ export * from './packageTrafficAlert'
export * from './bulkPurchase' export * from './bulkPurchase'
export * from './julyIteration' export * from './julyIteration'
export * from './augustIteration' export * from './augustIteration'
// 轮询优先队列相关
export * from './pollingPriorityQueue'

View File

@@ -0,0 +1,71 @@
/**
* 轮询优先队列相关常量
* 对应 docs/产品迭代8月份/优先队列.md
*/
import type {
PollingPriorityTaskType,
PollingPriorityItemStatus,
PollingPriorityTriggerType,
PollingPriorityResult
} from '@/types/api'
/** 任务类型选项 */
export const POLLING_PRIORITY_TASK_TYPE_OPTIONS: Array<{
label: string
value: PollingPriorityTaskType
}> = [
{ label: '实名检查', value: 'polling:realname' },
{ label: '流量检查', value: 'polling:carddata' },
{ label: '套餐检查', value: 'polling:package' },
{ label: '卡状态检查', value: 'polling:card_status' }
]
export const getPollingPriorityTaskTypeName = (
taskType?: PollingPriorityTaskType | string | null
) =>
POLLING_PRIORITY_TASK_TYPE_OPTIONS.find((item) => item.value === taskType)?.label ||
String(taskType || '-')
/** 状态选项 */
export const POLLING_PRIORITY_STATUS_OPTIONS: Array<{
label: string
value: PollingPriorityItemStatus
}> = [
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'processing' },
{ label: '已完成', value: 'completed' },
{ label: '失败', value: 'failed' }
]
export const getPollingPriorityStatusName = (
status?: PollingPriorityItemStatus | string | null
) =>
POLLING_PRIORITY_STATUS_OPTIONS.find((item) => item.value === status)?.label ||
String(status || '-')
/** 触发类型选项 */
export const POLLING_PRIORITY_TRIGGER_TYPE_OPTIONS: Array<{
label: string
value: PollingPriorityTriggerType
}> = [
{ label: '购买激活', value: 'purchase_activated' },
{ label: '续费激活', value: 'renewal_activated' },
{ label: '队列激活', value: 'queue_activated' },
{ label: '附加套餐激活', value: 'addon_activated' },
{ label: '无有效套餐', value: 'no_valid_package' },
{ label: '人工触发', value: 'manual_trigger' }
]
export const getPollingPriorityTriggerTypeName = (
triggerType?: PollingPriorityTriggerType | string | null
) =>
POLLING_PRIORITY_TRIGGER_TYPE_OPTIONS.find((item) => item.value === triggerType)?.label ||
String(triggerType || '-')
/** 执行结果名称result 为空时展示为未出结果) */
export const getPollingPriorityResultName = (result?: PollingPriorityResult | string | null) => {
if (result === 'success') return '成功'
if (result === 'failed') return '失败'
return '未出结果'
}

View File

@@ -1216,6 +1216,16 @@ export const asyncRoutes: AppRouteRecord[] = [
title: '轮询监控', title: '轮询监控',
keepAlive: true keepAlive: true
} }
},
// 优先队列
{
path: 'priority-queue',
name: 'PollingPriorityQueue',
component: RoutesAlias.PollingPriorityQueue,
meta: {
title: '优先队列',
keepAlive: true
}
} }
] ]
} }

View File

@@ -133,6 +133,7 @@ export enum RoutesAlias {
PollingConfig = '/polling-management/config', // 轮询配置 PollingConfig = '/polling-management/config', // 轮询配置
ManualTrigger = '/polling-management/manual-trigger', // 手动触发 ManualTrigger = '/polling-management/manual-trigger', // 手动触发
PollingMonitor = '/polling-management/monitor', // 轮询监控 PollingMonitor = '/polling-management/monitor', // 轮询监控
PollingPriorityQueue = '/polling-management/priority-queue', // 优先队列
// 公共组件 // 公共组件
Success = '/result/success', // 成功 Success = '/result/success', // 成功

View File

@@ -0,0 +1,46 @@
/**
* 资产钱包相关类型定义
*/
/**
* 自动续费适用范围
*/
export type AssetAutoRenewalScope = 'all' | 'specified'
/**
* 资产钱包自动续费配置对象
*/
export interface AssetAutoRenewalConfig {
/** 总开关1:开启0:关闭) */
enabled: 0 | 1
/** 总开关名称 */
enabled_name: string
/** 适用范围all:全部主套餐specified:指定主套餐) */
scope: AssetAutoRenewalScope
/** 适用范围名称 */
scope_name: string
/** 指定主套餐ID列表scope=specified 时非空) */
package_ids: number[]
/** 统一到期前天数1-90 */
days_before_expiry: number
/** 配置版本 */
config_version: number
/** 最近保存操作者 */
updater: string
/** 最近保存时间 */
updated_at: string
}
/**
* 更新资产钱包自动续费配置请求参数
*/
export interface UpdateAssetAutoRenewalConfigRequest {
/** 总开关0/1必填 */
enabled: 0 | 1
/** 适用范围all/specified必填 */
scope: AssetAutoRenewalScope
/** 指定主套餐ID列表scope=specified 时必填非空且仅当前可售主套餐) */
package_ids: number[]
/** 统一到期前天数1-90必填 */
days_before_expiry: number
}

View File

@@ -147,3 +147,9 @@ export * from './h5PopupConfiguration'
// 套餐真流量预警相关 // 套餐真流量预警相关
export * from './packageTrafficAlert' export * from './packageTrafficAlert'
// 轮询优先队列相关
export * from './pollingPriorityQueue'
// 资产钱包自动续费相关
export * from './assetWallet'

View File

@@ -0,0 +1,171 @@
/**
* 轮询优先队列相关类型定义
*/
/**
* 优先轮询任务类型
*/
export type PollingPriorityTaskType =
| 'polling:realname'
| 'polling:carddata'
| 'polling:package'
| 'polling:card_status'
/**
* 优先轮询项状态
*/
export type PollingPriorityItemStatus = 'pending' | 'processing' | 'completed' | 'failed'
/**
* 优先轮询项触发类型
*/
export type PollingPriorityTriggerType =
| 'purchase_activated'
| 'renewal_activated'
| 'queue_activated'
| 'addon_activated'
| 'no_valid_package'
| 'manual_trigger'
/**
* 优先轮询执行结果
*/
export type PollingPriorityResult = 'success' | 'failed' | ''
/**
* 优先轮询项对象
*/
export interface PollingPriorityItem {
/** 优先轮询项ID */
id: number
/** 卡ID */
card_id: number
/** 卡ICCID */
iccid: string
/** 任务类型 */
task_type: PollingPriorityTaskType
/** 任务类型名称 */
task_type_name: string
/** 状态 */
status: PollingPriorityItemStatus
/** 状态名称 */
status_name: string
/** 触发类型 */
trigger_type: PollingPriorityTriggerType
/** 触发类型名称 */
trigger_type_name: string
/** 触发类型列表 */
trigger_types: PollingPriorityTriggerType[]
/** 触发次数 */
trigger_count: number
/** 尝试次数 */
attempt_count: number
/** 执行结果 */
result: PollingPriorityResult
/** 执行结果名称 */
result_name: string
/** 失败原因 */
failure_reason: string | null
/** 店铺快照ID */
shop_id_snapshot: number | null
/** 来源订单ID */
source_order_id: number | null
/** 来源套餐使用记录ID */
source_package_usage_id: number | null
/** 人工操作者ID */
manual_operator_id: number | null
/** 人工操作者名称 */
manual_operator_name: string | null
/** 人工入队原因 */
manual_reason: string | null
/** 认领时间 */
claimed_at: string | null
/** 最后触发时间 */
last_triggered_at: string | null
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/**
* 优先轮询项查询参数
*/
export interface PollingPriorityItemQueryParams {
/** 页码默认1最小1 */
page?: number
/** 每页数量默认20最大100 */
page_size?: number
/** 卡ID筛选 */
card_id?: number
/** 任务类型筛选 */
task_type?: PollingPriorityTaskType
/** 状态筛选 */
status?: PollingPriorityItemStatus
/** 触发类型筛选 */
trigger_type?: PollingPriorityTriggerType
}
/**
* 优先轮询项分页响应
*/
export interface PollingPriorityItemPageResult {
/** 优先轮询项列表 */
items: PollingPriorityItem[]
/** 当前页 */
page: number
/** 每页数量 */
size: number
/** 总数 */
total: number
}
/**
* 人工优先入队请求参数
*/
export interface CreatePollingPriorityItemRequest {
/** 卡ID */
card_id: number
/** 入队原因必填最长500字符 */
reason: string
}
/**
* 人工入队结果项
*/
export interface PriorityItemResult {
/** 优先轮询项ID */
id: number
/** 卡ID */
card_id: number
/** 卡ICCID */
iccid: string
/** 任务类型 */
task_type: PollingPriorityTaskType
/** 任务类型名称 */
task_type_name: string
/** 状态 */
status: PollingPriorityItemStatus
/** 状态名称 */
status_name: string
/** 触发次数 */
trigger_count: number
/** 创建时间 */
created_at: string
}
/**
* 人工优先入队响应
*/
export interface CreatePollingPriorityItemResponse {
/** 新建数量 */
created_count: number
/** 合并数量 */
merged_count: number
/** 涉及任务类型列表 */
task_types: PollingPriorityTaskType[]
/** 涉及任务类型名称列表 */
task_type_names: string[]
/** 入队结果项列表 */
items: PriorityItemResult[]
}

View File

@@ -4,7 +4,6 @@
<ArtSearchBar <ArtSearchBar
v-model:filter="searchForm" v-model:filter="searchForm"
:items="searchFormItems" :items="searchFormItems"
label-width="110"
:show-expand="false" :show-expand="false"
@reset="handleReset" @reset="handleReset"
@search="handleSearch" @search="handleSearch"

View File

@@ -4,7 +4,6 @@
<ArtSearchBar <ArtSearchBar
v-model:filter="searchForm" v-model:filter="searchForm"
:items="searchFormItems" :items="searchFormItems"
label-width="110"
:show-expand="false" :show-expand="false"
@reset="handleReset" @reset="handleReset"
@search="handleSearch" @search="handleSearch"

View File

@@ -300,7 +300,7 @@
<ElFormItem label="主体类型" prop="subject_type"> <ElFormItem label="主体类型" prop="subject_type">
<ElRadioGroup v-model="qualificationForm.subject_type"> <ElRadioGroup v-model="qualificationForm.subject_type">
<ElRadio label="enterprise">企业</ElRadio> <ElRadio label="enterprise">企业</ElRadio>
<ElRadio label="individual">个人</ElRadio> <ElRadio label="personal">个人</ElRadio>
</ElRadioGroup> </ElRadioGroup>
</ElFormItem> </ElFormItem>
<ElFormItem label="主体证件号" prop="subject_code"> <ElFormItem label="主体证件号" prop="subject_code">
@@ -367,11 +367,7 @@
</ElFormItem> </ElFormItem>
<div class="qualification-row"> <div class="qualification-row">
<ElFormItem label="发票抬头"> <ElFormItem label="发票抬头">
<ElInput <ElInput v-model="qualificationForm.invoice_title" placeholder="选填" maxlength="100" />
v-model="qualificationForm.invoice_title"
placeholder="选填"
maxlength="100"
/>
</ElFormItem> </ElFormItem>
<ElFormItem label="发票主体证件号"> <ElFormItem label="发票主体证件号">
<ElInput <ElInput
@@ -399,18 +395,28 @@
<div v-loading="detailLoading" class="withdrawal-detail"> <div v-loading="detailLoading" class="withdrawal-detail">
<template v-if="withdrawalDetail"> <template v-if="withdrawalDetail">
<ElDescriptions :column="1" border> <ElDescriptions :column="1" border>
<ElDescriptionsItem label="提现单号">{{ withdrawalDetail.withdrawal_no }}</ElDescriptionsItem> <ElDescriptionsItem label="提现单号">{{
withdrawalDetail.withdrawal_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">{{ withdrawalDetail.status_name }}</ElDescriptionsItem> <ElDescriptionsItem label="状态">{{ withdrawalDetail.status_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="提现金额">{{ formatMoney(withdrawalDetail.amount) }}</ElDescriptionsItem> <ElDescriptionsItem label="提现金额">{{
<ElDescriptionsItem label="实际到账">{{ formatMoney(withdrawalDetail.actual_amount) }}</ElDescriptionsItem> formatMoney(withdrawalDetail.amount)
<ElDescriptionsItem label="手续费">{{ formatMoney(withdrawalDetail.fee) }}</ElDescriptionsItem> }}</ElDescriptionsItem>
<ElDescriptionsItem label="实际到账">{{
formatMoney(withdrawalDetail.actual_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="手续费">{{
formatMoney(withdrawalDetail.fee)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="提现方式"> <ElDescriptionsItem label="提现方式">
{{ withdrawalMethodLabel(withdrawalDetail.withdrawal_method) }} {{ withdrawalMethodLabel(withdrawalDetail.withdrawal_method) }}
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="收款账户"> <ElDescriptionsItem label="收款账户">
{{ withdrawalDetail.account_name }} / {{ withdrawalDetail.account_number }} {{ withdrawalDetail.account_name }} / {{ withdrawalDetail.account_number }}
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="驳回原因">{{ withdrawalDetail.reject_reason || '-' }}</ElDescriptionsItem> <ElDescriptionsItem label="驳回原因">{{
withdrawalDetail.reject_reason || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="异常标记"> <ElDescriptionsItem label="异常标记">
<ElTag :type="withdrawalDetail.anomaly_flag ? 'danger' : 'success'"> <ElTag :type="withdrawalDetail.anomaly_flag ? 'danger' : 'success'">
{{ withdrawalDetail.anomaly_name }} {{ withdrawalDetail.anomaly_name }}
@@ -442,12 +448,7 @@
</ElDrawer> </ElDrawer>
<!-- 重新提交被驳回的提现 --> <!-- 重新提交被驳回的提现 -->
<ElDialog <ElDialog v-model="resubmitDialogVisible" title="重新提交提现" width="560px" destroy-on-close>
v-model="resubmitDialogVisible"
title="重新提交提现"
width="560px"
destroy-on-close
>
<ElForm <ElForm
ref="resubmitFormRef" ref="resubmitFormRef"
:model="resubmitForm" :model="resubmitForm"
@@ -503,11 +504,7 @@
</ElForm> </ElForm>
<template #footer> <template #footer>
<ElButton @click="resubmitDialogVisible = false">取消</ElButton> <ElButton @click="resubmitDialogVisible = false">取消</ElButton>
<ElButton <ElButton type="primary" :loading="resubmitSubmitting" @click="handleSubmitResubmit">
type="primary"
:loading="resubmitSubmitting"
@click="handleSubmitResubmit"
>
提交 提交
</ElButton> </ElButton>
</template> </template>
@@ -529,7 +526,6 @@
confirm-permission="commission_record:export" confirm-permission="commission_record:export"
title="导出佣金记录" title="导出佣金记录"
/> />
</div> </div>
</template> </template>
@@ -549,7 +545,7 @@
WithdrawalQualificationSubmitParams, WithdrawalQualificationSubmitParams,
WithdrawalRequestDetail, WithdrawalRequestDetail,
ResubmitWithdrawalRequestParams, ResubmitWithdrawalRequestParams,
ShopCommissionRecordSource, ShopCommissionRecordSource
} from '@/types/api/commission' } from '@/types/api/commission'
import { WithdrawalMethod, WithdrawalStatus } from '@/types/api/commission' import { WithdrawalMethod, WithdrawalStatus } from '@/types/api/commission'
import type { Order, OrderQueryParams } from '@/types/api/order' import type { Order, OrderQueryParams } from '@/types/api/order'
@@ -1336,7 +1332,6 @@
} }
}) })
// ==================== 提现资料资格 ==================== // ==================== 提现资料资格 ====================
const qualificationLoading = ref(false) const qualificationLoading = ref(false)
@@ -1371,54 +1366,52 @@
return labels.length > 0 ? labels.join('、') : '-' return labels.length > 0 ? labels.join('、') : '-'
} }
const { const { columnChecks: qualificationColumnChecks, columns: qualificationColumns } =
columnChecks: qualificationColumnChecks, useCheckedColumns(() => [
columns: qualificationColumns { prop: 'subject_type_name', label: '主体类型', width: 90 },
} = useCheckedColumns(() => [ { prop: 'subject_code_masked', label: '主体证件号', minWidth: 160 },
{ prop: 'subject_type_name', label: '主体类型', width: 90 }, { prop: 'legal_person_id_card_masked', label: '法人身份证', minWidth: 170 },
{ prop: 'subject_code_masked', label: '主体证件号', minWidth: 160 }, {
{ prop: 'legal_person_id_card_masked', label: '法人身份证', minWidth: 170 }, prop: 'status_name',
{ label: '状态',
prop: 'status_name', width: 100,
label: '状态', formatter: (row: WithdrawalQualificationItem) => {
width: 100, const tagType = row.status === 0 ? 'warning' : row.status === 1 ? 'success' : 'danger'
formatter: (row: WithdrawalQualificationItem) => { return h(ElTag, { type: tagType }, () => row.status_name || String(row.status))
const tagType = row.status === 0 ? 'warning' : row.status === 1 ? 'success' : 'danger' }
return h(ElTag, { type: tagType }, () => row.status_name || String(row.status)) },
{
prop: 'approval_status_name',
label: '审批状态',
width: 100,
formatter: (row: WithdrawalQualificationItem) => row.approval_status_name || '-'
},
{
prop: 'attachments',
label: '附件',
minWidth: 200,
formatter: (row: WithdrawalQualificationItem) => qualificationAttachmentLabels(row)
},
{
prop: 'invalid_reason',
label: '作废原因',
minWidth: 160,
formatter: (row: WithdrawalQualificationItem) => row.invalid_reason || '-'
},
{
prop: 'invalidated_at',
label: '作废时间',
width: 170,
formatter: (row: WithdrawalQualificationItem) =>
row.invalidated_at ? formatDateTime(row.invalidated_at) : '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 170,
formatter: (row: WithdrawalQualificationItem) => formatDateTime(row.created_at)
} }
}, ])
{
prop: 'approval_status_name',
label: '审批状态',
width: 100,
formatter: (row: WithdrawalQualificationItem) => row.approval_status_name || '-'
},
{
prop: 'attachments',
label: '附件',
minWidth: 200,
formatter: (row: WithdrawalQualificationItem) => qualificationAttachmentLabels(row)
},
{
prop: 'invalid_reason',
label: '作废原因',
minWidth: 160,
formatter: (row: WithdrawalQualificationItem) => row.invalid_reason || '-'
},
{
prop: 'invalidated_at',
label: '作废时间',
width: 170,
formatter: (row: WithdrawalQualificationItem) =>
row.invalidated_at ? formatDateTime(row.invalidated_at) : '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 170,
formatter: (row: WithdrawalQualificationItem) => formatDateTime(row.created_at)
}
])
const getQualificationList = async () => { const getQualificationList = async () => {
if (!currentShopId.value) return if (!currentShopId.value) return
@@ -1562,8 +1555,7 @@
confirmButtonText: '确认作废', confirmButtonText: '确认作废',
cancelButtonText: '取消', cancelButtonText: '取消',
inputPlaceholder: '作废原因(必填)', inputPlaceholder: '作废原因(必填)',
inputValidator: (reason: string) => inputValidator: (reason: string) => (reason && reason.trim() ? true : '作废原因必填'),
reason && reason.trim() ? true : '作废原因必填',
type: 'warning' type: 'warning'
}) })
await CommissionService.voidWithdrawalQualification(row.id, { reason: value.trim() }) await CommissionService.voidWithdrawalQualification(row.id, { reason: value.trim() })
@@ -1571,8 +1563,8 @@
getQualificationList() getQualificationList()
} catch { } catch {
// 取消或请求失败 // 取消或请求失败
} }
} }
// ==================== 提现详情与重提 ==================== // ==================== 提现详情与重提 ====================
@@ -1591,10 +1583,7 @@
detailLoading.value = true detailLoading.value = true
withdrawalDetail.value = null withdrawalDetail.value = null
try { try {
const res = await CommissionService.getWithdrawalRequestDetail( const res = await CommissionService.getWithdrawalRequestDetail(currentShopId.value, row.id)
currentShopId.value,
row.id
)
if (res.code === 0) { if (res.code === 0) {
withdrawalDetail.value = res.data withdrawalDetail.value = res.data
} }
@@ -1606,7 +1595,11 @@
} }
const getWithdrawalActions = (row: WithdrawalRequestItem) => { const getWithdrawalActions = (row: WithdrawalRequestItem) => {
const actions: Array<{ label: string; handler: (row: any) => void; type?: 'primary' | 'danger' }> = [ const actions: Array<{
label: string
handler: (row: any) => void
type?: 'primary' | 'danger'
}> = [
{ {
label: '详情', label: '详情',
handler: () => openWithdrawalDetail(row), handler: () => openWithdrawalDetail(row),
@@ -1755,7 +1748,6 @@
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.qualification-row { .qualification-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View File

@@ -273,7 +273,7 @@
] ]
} }
if (isActing.value) { if (isActing.value) {
base.acting_reason = [{ required: true, message: '请填写代办原因', trigger: 'blur' }] base.acting_reason = [{ required: false, message: '请填写代办原因', trigger: 'blur' }]
} }
return base return base
}) })

View File

@@ -0,0 +1,492 @@
<template>
<ArtTableFullScreen>
<div class="priority-queue-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<el-card shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<el-button type="primary" @click="showCreateDialog">人工优先入队</el-button>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<el-table-column v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</el-card>
</div>
<!-- 人工优先入队 -->
<el-dialog
v-model="createDialogVisible"
title="人工优先入队"
width="520px"
:close-on-click-modal="false"
>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<el-form-item label="卡ICCID" prop="card_id">
<el-select
v-model="createForm.card_id"
placeholder="请输入ICCID搜索"
filterable
remote
:remote-method="remoteSearchCard"
clearable
no-data-text="暂无卡数据请输入ICCID搜索"
style="width: 100%"
>
<el-option
v-for="card in cardList"
:key="card.id"
:label="card.iccid"
:value="card.id"
/>
</el-select>
</el-form-item>
<el-form-item label="入队原因" prop="reason">
<el-input
v-model="createForm.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入入队原因最多500字"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="handleCreateSubmit">
确定
</el-button>
</template>
</el-dialog>
<!-- 入队结果 -->
<el-dialog v-model="resultDialogVisible" title="入队结果" width="620px">
<div class="result-summary">
<el-tag type="success" class="result-item">新建 {{ createResult?.created_count ?? 0 }} </el-tag>
<el-tag type="warning" class="result-item">合并 {{ createResult?.merged_count ?? 0 }} </el-tag>
<span class="result-item">
任务类型{{ (createResult?.task_type_names || []).join('、') || '-' }}
</span>
</div>
<el-table :data="createResult?.items || []" border stripe size="small" max-height="360">
<el-table-column prop="iccid" label="卡ICCID" min-width="160" show-overflow-tooltip />
<el-table-column prop="task_type_name" label="任务类型" min-width="120" />
<el-table-column prop="status_name" label="状态" width="100" />
<el-table-column prop="trigger_count" label="触发次数" width="100" />
</el-table>
</el-dialog>
<!-- 优先轮询项详情 -->
<el-dialog
v-model="detailDialogVisible"
title="优先轮询项详情"
width="640px"
:close-on-click-modal="false"
>
<el-descriptions v-if="detailData" :column="2" border>
<el-descriptions-item label="ID">{{ detailData.id }}</el-descriptions-item>
<el-descriptions-item label="卡ICCID">{{ detailData.iccid }}</el-descriptions-item>
<el-descriptions-item label="任务类型">
{{ detailData.task_type_name }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTagType(detailData.status)">{{
detailData.status_name
}}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="触发类型">
{{ detailData.trigger_type_name }}
</el-descriptions-item>
<el-descriptions-item label="触发次数">{{ detailData.trigger_count }}</el-descriptions-item>
<el-descriptions-item label="尝试次数">{{ detailData.attempt_count }}</el-descriptions-item>
<el-descriptions-item label="执行结果">
{{ detailData.result_name }}
</el-descriptions-item>
<el-descriptions-item label="失败原因" :span="2">
{{ detailData.failure_reason || '-' }}
</el-descriptions-item>
<el-descriptions-item label="人工操作者">
{{ detailData.manual_operator_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="入队原因">
{{ detailData.manual_reason || '-' }}
</el-descriptions-item>
<el-descriptions-item label="认领时间">
{{ formatDateTime(detailData.claimed_at) }}
</el-descriptions-item>
<el-descriptions-item label="最后触发时间">
{{ formatDateTime(detailData.last_triggered_at) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(detailData.created_at) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ formatDateTime(detailData.updated_at) }}
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { ElMessage, ElTag, type FormInstance, type FormRules } from 'element-plus'
import { PollingPriorityQueueService, CardService } from '@/api/modules'
import type {
PollingPriorityItem,
PollingPriorityItemQueryParams,
CreatePollingPriorityItemResponse
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { formatDateTime } from '@/utils/business/format'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import {
POLLING_PRIORITY_TASK_TYPE_OPTIONS,
POLLING_PRIORITY_STATUS_OPTIONS,
POLLING_PRIORITY_TRIGGER_TYPE_OPTIONS,
getPollingPriorityResultName
} from '@/config/constants'
defineOptions({ name: 'PollingPriorityQueue' })
const loading = ref(false)
const tableData = ref<PollingPriorityItem[]>([])
const tableRef = ref()
// 分页数据
const pagination = reactive({
page: 1,
page_size: 20,
total: 0
})
// 搜索表单
const searchForm = reactive({
card_id: undefined as number | undefined,
task_type: undefined as string | undefined,
status: undefined as string | undefined,
trigger_type: undefined as string | undefined
})
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: '卡ID',
prop: 'card_id',
type: 'input',
config: { clearable: true, placeholder: '请输入卡ID' }
},
{
label: '任务类型',
prop: 'task_type',
type: 'select',
options: POLLING_PRIORITY_TASK_TYPE_OPTIONS,
config: { clearable: true, placeholder: '请选择任务类型' }
},
{
label: '状态',
prop: 'status',
type: 'select',
options: POLLING_PRIORITY_STATUS_OPTIONS,
config: { clearable: true, placeholder: '请选择状态' }
},
{
label: '触发类型',
prop: 'trigger_type',
type: 'select',
options: POLLING_PRIORITY_TRIGGER_TYPE_OPTIONS,
config: { clearable: true, placeholder: '请选择触发类型' }
}
]
const getStatusTagType = (status: string) => {
const types: Record<string, 'success' | 'info' | 'warning' | 'danger'> = {
pending: 'info',
processing: 'warning',
completed: 'success',
failed: 'danger'
}
return types[status] || 'info'
}
const getResultTagType = (result: string) => {
if (result === 'success') return 'success'
if (result === 'failed') return 'danger'
return 'info'
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: 'ID',
width: 80
},
{
prop: 'iccid',
label: '卡ICCID',
minWidth: 170,
showOverflowTooltip: true
},
{
prop: 'task_type_name',
label: '任务类型',
width: 130,
formatter: (row: PollingPriorityItem) => row.task_type_name
},
{
prop: 'status',
label: '状态',
width: 100,
slots: {
default: ({ row }: any) =>
h(ElTag, { type: getStatusTagType(row.status) }, () => row.status_name)
}
},
{
prop: 'trigger_type_name',
label: '触发类型',
width: 130,
formatter: (row: PollingPriorityItem) => row.trigger_type_name
},
{
prop: 'trigger_count',
label: '触发次数',
width: 100
},
{
prop: 'attempt_count',
label: '尝试次数',
width: 100
},
{
prop: 'result',
label: '执行结果',
width: 110,
slots: {
default: ({ row }: any) =>
h(ElTag, { type: getResultTagType(row.result) }, () =>
getPollingPriorityResultName(row.result)
)
}
},
{
prop: 'manual_operator_name',
label: '操作者',
width: 120,
formatter: (row: PollingPriorityItem) => row.manual_operator_name || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: PollingPriorityItem) => formatDateTime(row.created_at)
}
])
const columnOptions = computed(() =>
columns.value.map((col) => ({
label: col.label,
prop: col.prop || col.label,
visible: true
}))
)
const getActions = () => [{ label: '详情', handler: showDetail, type: 'primary' as const }]
const loadData = async () => {
loading.value = true
try {
const queryParams: PollingPriorityItemQueryParams = {
page: pagination.page,
page_size: pagination.page_size,
card_id: searchForm.card_id,
task_type: searchForm.task_type as never,
status: searchForm.status as never,
trigger_type: searchForm.trigger_type as never
}
const { data } = await PollingPriorityQueueService.getPriorityItems(queryParams)
tableData.value = data.items || []
pagination.total = data.total || 0
} catch (error) {
console.error('加载优先轮询项列表失败', error)
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadData()
}
const handleReset = () => {
Object.assign(searchForm, {
card_id: undefined,
task_type: undefined,
status: undefined,
trigger_type: undefined
})
pagination.page = 1
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
loadData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadData()
}
// 人工优先入队
const createDialogVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref<FormInstance>()
const createResult = ref<CreatePollingPriorityItemResponse | null>(null)
const resultDialogVisible = ref(false)
const cardList = ref<Array<{ id: number; iccid: string }>>([])
const createForm = reactive({
card_id: undefined as number | undefined,
reason: ''
})
const createRules: FormRules = {
card_id: [{ required: true, message: '请选择卡', trigger: 'change' }],
reason: [
{ required: true, message: '请输入入队原因', trigger: 'blur' },
{ max: 500, message: '入队原因不能超过500个字符', trigger: 'blur' }
]
}
const showCreateDialog = () => {
createForm.card_id = undefined
createForm.reason = ''
createResult.value = null
createDialogVisible.value = true
cardList.value = []
loadCards()
}
const loadCards = async (iccid?: string) => {
try {
const { data } = await CardService.getStandaloneIotCards({
page: 1,
page_size: 20,
iccid
})
cardList.value = data.items || []
} catch (error) {
console.error('加载卡列表失败', error)
}
}
const remoteSearchCard = (query: string) => {
loadCards(query)
}
const handleCreateSubmit = async () => {
if (!createFormRef.value) return
const valid = await createFormRef.value.validate().catch(() => false)
if (!valid) return
if (createForm.card_id === undefined) {
ElMessage.warning('请选择卡')
return
}
createLoading.value = true
try {
const { data } = await PollingPriorityQueueService.createPriorityItems({
card_id: createForm.card_id,
reason: createForm.reason.trim()
})
createResult.value = data
createDialogVisible.value = false
resultDialogVisible.value = true
await loadData()
} catch (error) {
console.error('人工优先入队失败', error)
} finally {
createLoading.value = false
}
}
// 详情
const detailDialogVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<PollingPriorityItem | null>(null)
const showDetail = async (row: PollingPriorityItem) => {
detailData.value = null
detailDialogVisible.value = true
detailLoading.value = true
try {
const { data } = await PollingPriorityQueueService.getPriorityItemDetail(row.id)
detailData.value = data
} catch (error) {
console.error('加载优先轮询项详情失败', error)
} finally {
detailLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.priority-queue-page {
display: flex;
flex-direction: column;
height: 100%;
.result-summary {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
.result-item {
font-size: 13px;
}
}
}
</style>