Compare commits

..

10 Commits

Author SHA1 Message Date
luo
2c8524485b fix: 资产信息字段显示隐藏根据角色
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m24s
2026-07-13 16:40:35 +08:00
luo
4c0207c6e6 fix: 将微信配置改成支付配置
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m41s
2026-07-10 18:09:19 +08:00
luo
5b8d6610ab fix: 将套餐系列的套餐加滚动条 2026-07-09 17:38:58 +08:00
luo
a036b10641 fix: 将权限管理里面的分页去掉
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m45s
2026-07-09 15:59:39 +08:00
luo
c1a7118d35 fix: 将导出列表分成三个模块
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m40s
2026-07-09 15:20:28 +08:00
luo
f27bbacb1e fix: 设备信息新增的两个流量放到同一行
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m13s
2026-07-06 17:52:08 +08:00
luo
acda0d82c8 feat: 新增全部已使用流量和全部总流量字段
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m30s
2026-07-03 12:03:52 +08:00
99de03604d 驳回
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m1s
2026-06-26 18:14:37 +08:00
1baa6d87cf 驳回 2026-06-26 18:14:26 +08:00
f604791c69 倒计时没有覆盖复机停机的问题 2026-06-25 10:30:49 +08:00
63 changed files with 1782 additions and 354 deletions

3
.gitignore vendored
View File

@@ -27,3 +27,6 @@ npminstall-debug.log
# ai
.agents
#build
one-pipe-system-dist.zip

View File

@@ -13,3 +13,9 @@ This context captures the business language used by the admin system for orders,
**Operational batch task**: An asynchronous admin task created from an uploaded batch import file and tracked through task status, counts, operator identity, and failure details. _Avoid_: Inline order operation, synchronous import
**Operational remark**: A note captured when an admin creates a financial or operational record. Operational remarks are retained for later review and are not edited after creation. _Avoid_: Editable comment, processing note
**Recharge order**: A C-side asset recharge order created when a user recharges an IoT card or device asset wallet. It is identified by `recharge_order_no`, is associated with `resource_type` and `resource_id`, and may be rejected while pending payment. _Avoid_: Agent recharge order
**Rejected recharge order**: A recharge order that an admin has rejected before payment because the recharge request should not proceed. Rejection is an irreversible terminal state and does not involve refunds. _Avoid_: Refunded recharge, closed recharge
**Agent recharge order**: A financial recharge record for an agent/shop wallet in the admin finance module. It uses a different API contract and status model from C-side recharge orders. _Avoid_: Recharge order

View File

@@ -0,0 +1,37 @@
# Change: 更新资产解析流量汇总展示
## Why
后台“资产信息”页需要展示当前世代下所有套餐的虚流量已用量和剩余量,方便运营人员在查看资产基础信息时快速判断整体流量消耗情况。
后端通过 `GET /api/admin/assets/resolve/:identifier` 新增可选查询参数控制是否返回汇总字段;前端需要明确请求契约、类型契约和展示位置,避免默认查询缺失汇总数据。
## What Changes
- 更新资产解析接口请求契约:
- `GET /api/admin/assets/resolve/:identifier` 支持可选 Query 参数 `include_usage_summary: boolean`
- 参数含义为是否返回当前世代流量汇总字段 `total_virtual_used_mb` / `total_virtual_remaining_mb`
- 后端未传时默认不返回汇总数据;后台“资产信息”页发起解析请求时默认传 `include_usage_summary=true`
- 更新资产解析接口响应契约:
- 新增 `total_virtual_used_mb?: number | null`,表示所有套餐已用量(MB),未请求时为 `null`
- 新增 `total_virtual_remaining_mb?: number | null`,表示所有套餐剩余量(MB),未请求时为 `null`
- 更新后台“资产信息”基础信息展示:
- 卡资产在“运营商状态”字段后新增“所有套餐已用量”和“所有套餐剩余量”
- 设备资产在“SN”字段后新增“所有套餐已用量”和“所有套餐剩余量”
- 字段值使用上述两个响应字段,并按流量大小格式化展示
- 字段为空或未请求时显示稳定占位内容
## Impact
- Affected specs:
- `asset-information`
- Affected code:
- `src/api/modules/asset.ts`
- `src/types/api/asset.ts`
- `src/views/asset-management/asset-information/types.ts`
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
- Dependencies:
- `GET /api/admin/assets/resolve/:identifier` 后端支持 `include_usage_summary` Query 参数及响应字段
- Breaking changes:
- 无;新增请求参数和响应字段均为可选,前端资产信息页仅主动请求并展示新增数据

View File

@@ -0,0 +1,60 @@
## ADDED Requirements
### Requirement: Admin Asset Resolve Usage Summary Contract
The admin asset information integration SHALL request usage summary data from `GET /api/admin/assets/resolve/:identifier` by default and preserve the returned current-generation virtual usage summary fields.
#### Scenario: Asset information requests usage summary by default
- **GIVEN** 用户在后台“资产信息”页查询资产标识符
- **WHEN** 前端调用 `GET /api/admin/assets/resolve/:identifier`
- **THEN** 请求 Query MUST include `include_usage_summary=true`
- **AND** `include_usage_summary` MUST be treated as a boolean query parameter
#### Scenario: Preserve usage summary fields from resolve response
- **GIVEN** `GET /api/admin/assets/resolve/:identifier?include_usage_summary=true` 返回 `total_virtual_used_mb``total_virtual_remaining_mb`
- **WHEN** 前端解析资产详情响应
- **THEN** 页面状态 MUST preserve `total_virtual_used_mb` as a `number | null` field
- **AND** 页面状态 MUST preserve `total_virtual_remaining_mb` as a `number | null` field
- **AND** `total_virtual_used_mb` MUST represent 所有套餐已用量(MB)
- **AND** `total_virtual_remaining_mb` MUST represent 所有套餐剩余量(MB)
#### Scenario: Usage summary not requested
- **GIVEN** a caller invokes `GET /api/admin/assets/resolve/:identifier` without `include_usage_summary=true`
- **WHEN** the response is parsed
- **THEN** `total_virtual_used_mb` MAY be absent or `null`
- **AND** `total_virtual_remaining_mb` MAY be absent or `null`
### Requirement: Admin Asset Usage Summary Display
The admin asset information view SHALL display current-generation virtual usage summary fields in both card and device asset information sections using asset-type-specific placement.
#### Scenario: Show card usage summary after carrier status
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前资产详情状态中存在 `total_virtual_used_mb``total_virtual_remaining_mb`
- **WHEN** 页面渲染“卡信息”区域
- **THEN** 页面 MUST keep `运营商状态` visible
- **AND** 页面 MUST display `所有套餐已用量` immediately after `运营商状态`, using `total_virtual_used_mb`
- **AND** 页面 MUST display `所有套餐剩余量` immediately after `所有套餐已用量`, using `total_virtual_remaining_mb`
- **AND** both values MUST be formatted as traffic sizes based on MB values
#### Scenario: Show device usage summary after SN
- **GIVEN** 用户正在查看后台“资产信息”中的设备资产详情
- **AND** 当前资产详情状态中存在 `total_virtual_used_mb``total_virtual_remaining_mb`
- **WHEN** 页面渲染“设备信息”区域
- **THEN** 页面 MUST keep `SN` visible
- **AND** 页面 MUST display `所有套餐已用量` immediately after `SN`, using `total_virtual_used_mb`
- **AND** 页面 MUST display `所有套餐剩余量` immediately after `所有套餐已用量`, using `total_virtual_remaining_mb`
- **AND** both values MUST be formatted as traffic sizes based on MB values
#### Scenario: Show placeholder when usage summary is unavailable
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情或设备资产详情
- **AND** `total_virtual_used_mb` or `total_virtual_remaining_mb` is `null` or absent
- **WHEN** 页面渲染对应汇总字段
- **THEN** 页面 MUST display `-` for the unavailable field
- **AND** 页面 MUST NOT display `0` solely because the field is unavailable

View File

@@ -0,0 +1,19 @@
## 1. Contract
- [x] 1.1 扩展资产解析接口方法,支持传入 `include_usage_summary` Query 参数。
- [x] 1.2 扩展资产解析响应类型,新增 `total_virtual_used_mb?: number | null``total_virtual_remaining_mb?: number | null`
- [x] 1.3 确保后台“资产信息”页调用 `GET /api/admin/assets/resolve/:identifier` 时默认传 `include_usage_summary=true`
## 2. State And Display
- [x] 2.1 在资产信息页面状态中保留 `total_virtual_used_mb``total_virtual_remaining_mb`
- [x] 2.2 在卡资产“卡信息”区域的“运营商状态”后新增“所有套餐已用量”和“所有套餐剩余量”。
- [x] 2.3 在设备资产“设备信息”区域的“SN”后新增“所有套餐已用量”和“所有套餐剩余量”。
- [x] 2.4 新增字段按流量大小格式化展示;当响应字段为 `null``undefined` 或未请求时显示 `-`
## 3. Verification
- [ ] 3.1 验证资产信息页请求解析接口时 URL 包含 `include_usage_summary=true`
- [ ] 3.2 验证解析接口返回两个汇总字段时,卡资产详情在“运营商状态”后正确展示。
- [ ] 3.3 验证解析接口返回两个汇总字段时设备资产详情在“SN”后正确展示。
- [ ] 3.4 验证两个汇总字段为 `null` 或缺失时,页面显示 `-` 且不报错。

View File

@@ -0,0 +1,44 @@
# Change: 更新资产信息账号类型字段可见性与运营商账户展示
## Why
资产信息页当前在卡/设备基础信息区域存在两类与最新业务口径不一致的问题:
- 卡资产“卡信息”中的 `实名认证策略` 仍会对 `user_type=3`(代理账号)和 `user_type=4`(企业账号)显示。
- 设备资产“设备信息”中的 `实名认证策略``制造商` 仍会对 `user_type=3/4` 显示。
- 卡资产“运营商账户”当前展示的是 `carrier_name`,而最新要求需要改为展示 `carrier_type`,并按运营商管理中的映射规则转成中文,例如 `CTCC -> 中国电信`
如果不统一这些展示规则,不同账号在资产信息页会继续看到不应暴露的字段,同时“运营商账户”的显示口径也会与系统内其他模块不一致。
## What Changes
- 更新卡资产“卡信息”区域:
- 当当前登录账号 `user_type``3``4` 时,隐藏 `实名认证策略` 字段。
- 当当前登录账号 `user_type``3``4` 时,`运营商账户` 字段改为展示 `carrier_type` 的中文映射值,而不是 `carrier_name`
- 当当前登录账号 `user_type` 不是 `3` 且不是 `4` 时,`运营商账户` 字段继续保持当前 `carrier_name` 展示口径。
- 已知运营商类型映射沿用运营商管理口径:`CMCC -> 中国移动``CUCC -> 中国联通``CTCC -> 中国电信``CBN -> 中国广电`
-`carrier_type` 为空时显示 `-`;若返回未知编码,则保留原编码展示,避免吞掉上游信息。
- 更新设备资产“设备信息”区域:
- 当当前登录账号 `user_type``3``4` 时,隐藏 `实名认证策略` 字段。
- 当当前登录账号 `user_type``3``4` 时,隐藏 `制造商` 字段。
- 范围约束:
- 本提案仅调整资产信息基础描述区字段展示。
- 本提案不改动实名认证策略相关操作按钮的权限和可见性。
- 本提案不改动设备详情“绑定卡列表”中的列展示规则,除非后续另行提出变更。
## Impact
- Affected specs:
- `asset-information`
- Affected code:
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
- `src/views/asset-management/asset-information/composables/useAssetFormatters.ts`
- `src/views/asset-management/asset-information/types.ts`
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
- `src/config/constants/carrierTypes.ts`
- Dependencies:
- 依赖当前登录账号 `user_type` 可从用户状态中稳定读取
- 依赖资产详情接口继续返回 `carrier_type`
- 运营商类型中文映射应与运营商管理模块保持一致
- Breaking changes:
- 无外部接口破坏性变更;本次仅调整后台页面展示契约和字段可见性

View File

@@ -0,0 +1,123 @@
## ADDED Requirements
### Requirement: Admin Card Asset Carrier Type Contract
The admin asset information integration SHALL accept and preserve `carrier_type` from `GET /api/admin/assets/resolve/:identifier` for card assets so the card information section can render the carrier account value from the carrier type code.
#### Scenario: Card asset detail preserves carrier_type from resolve response
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** `GET /api/admin/assets/resolve/:identifier` 返回了 `carrier_type`
- **WHEN** 前端解析卡资产详情响应
- **THEN** 页面状态 MUST 保留该 `carrier_type` 原始值
- **AND** 后续“卡信息”区域的 `运营商账户` 展示 MUST 基于该状态值进行格式化
#### Scenario: Missing carrier_type does not break card asset detail parsing
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** `GET /api/admin/assets/resolve/:identifier` 未返回 `carrier_type` 或该字段为空
- **WHEN** 前端解析卡资产详情响应
- **THEN** 页面 MUST 继续保留其他卡资产字段的解析行为
- **AND** MUST NOT 因 `carrier_type` 缺失导致“卡信息”区域渲染失败
### Requirement: Admin Card Asset Carrier Account Display
The admin asset information view SHALL render the card information field `运营商账户` according to the current logged-in account type.
For logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号), the field MUST use `carrier_type` and the same carrier-type-to-Chinese mapping used by carrier management.
Known mappings MUST include `CMCC -> 中国移动`, `CUCC -> 中国联通`, `CTCC -> 中国电信`, and `CBN -> 中国广电`.
For other logged-in account types, the field MUST keep the existing `carrier_name` display behavior.
If `carrier_type` is empty or missing, the page MUST display `-`. If `carrier_type` contains an unknown code, the page MUST preserve and display the original code.
#### Scenario: Show China Telecom label for CTCC in card information for agent or enterprise account
- **GIVEN** 当前登录账号的 `user_type``3``4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前卡资产详情状态中的 `carrier_type``CTCC`
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
- **THEN** 页面 MUST 显示 `中国电信`
#### Scenario: Show placeholder when carrier_type is empty for agent or enterprise account
- **GIVEN** 当前登录账号的 `user_type``3``4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前卡资产详情状态中的 `carrier_type` 为空、缺失或仅包含空白字符
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
- **THEN** 页面 MUST 显示 `-`
#### Scenario: Preserve unknown carrier_type code for agent or enterprise account
- **GIVEN** 当前登录账号的 `user_type``3``4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前卡资产详情状态中的 `carrier_type` 为未收录的编码
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
- **THEN** 页面 MUST 直接显示该原始编码
- **AND** MUST NOT 错误映射为其他运营商中文名称
#### Scenario: Keep carrier_name display for non-agent non-enterprise account
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前卡资产详情状态中的 `carrier_name` 为有效值
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
- **THEN** 页面 MUST 显示该 `carrier_name`
### Requirement: Admin Card Realname Policy Visibility By Account Type
The admin asset information view SHALL hide the card information field `实名认证策略` for logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号).
For other logged-in account types, the page SHALL keep the existing card information field visibility behavior for `实名认证策略`.
#### Scenario: Hide card realname policy for agent account
- **GIVEN** 当前登录账号的 `user_type``3`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **WHEN** 页面渲染“卡信息”区域
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
#### Scenario: Hide card realname policy for enterprise account
- **GIVEN** 当前登录账号的 `user_type``4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **WHEN** 页面渲染“卡信息”区域
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
#### Scenario: Keep card realname policy visible for non-agent non-enterprise account
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **WHEN** 页面渲染“卡信息”区域
- **THEN** 页面 MUST 继续按现有规则渲染 `实名认证策略` 字段
### Requirement: Admin Device Sensitive Field Visibility By Account Type
The admin asset information view SHALL hide the device information fields `实名认证策略` and `制造商` for logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号).
For other logged-in account types, the page SHALL keep the existing device information field visibility behavior for both fields.
#### Scenario: Hide device realname policy and manufacturer for agent account
- **GIVEN** 当前登录账号的 `user_type``3`
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
- **WHEN** 页面渲染“设备信息”区域
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
- **AND** 页面 MUST NOT 显示 `制造商` 字段
#### Scenario: Hide device realname policy and manufacturer for enterprise account
- **GIVEN** 当前登录账号的 `user_type``4`
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
- **WHEN** 页面渲染“设备信息”区域
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
- **AND** 页面 MUST NOT 显示 `制造商` 字段
#### Scenario: Keep device realname policy and manufacturer visible for non-agent non-enterprise account
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
- **WHEN** 页面渲染“设备信息”区域
- **THEN** 页面 MUST 继续按现有规则渲染 `实名认证策略` 字段
- **AND** 页面 MUST 继续按现有规则渲染 `制造商` 字段

View File

@@ -0,0 +1,20 @@
## 1. Contract And Formatting
- [x] 1.1 补齐资产信息页面本地类型/状态中的 `carrier_type` 字段,确保卡资产详情可稳定保留该值。
- [x] 1.2 复用运营商管理中的运营商类型映射规则,为资产信息页提供 `carrier_type` 到中文文案的格式化能力。
## 2. UI Visibility
- [x] 2.1 调整卡资产“卡信息”区域的 `运营商账户` 字段:`user_type=3/4` 展示 `carrier_type` 中文映射,其他账号继续展示原来的 `carrier_name`
- [x] 2.2 当当前登录账号 `user_type``3``4` 时,隐藏卡资产“卡信息”区域的 `实名认证策略` 字段。
- [x] 2.3 当当前登录账号 `user_type``3``4` 时,隐藏设备资产“设备信息”区域的 `实名认证策略``制造商` 字段。
- [x] 2.4 确认本次变更仅影响基础信息描述区,不改动实名认证策略操作按钮和设备绑定卡列表列。
## 3. Verification
- [ ] 3.1 验证 `user_type=3/4` 查看卡资产且返回 `carrier_type=CTCC` 时,“运营商账户”显示为 `中国电信`
- [ ] 3.2 验证 `user_type=3/4` 查看卡资产且返回 `carrier_type=CMCC/CUCC/CBN` 时,“运营商账户”分别显示与运营商管理一致的中文文案;空值显示 `-`
- [ ] 3.3 验证 `user_type=3/4` 查看卡资产且返回未知 `carrier_type` 编码时,“运营商账户”保留原编码展示。
- [ ] 3.4 验证 `user_type=3``user_type=4` 查看卡资产时,不显示 `实名认证策略` 字段。
- [ ] 3.5 验证 `user_type=3``user_type=4` 查看设备资产时,不显示 `实名认证策略``制造商` 字段。
- [ ] 3.6 验证其他账号类型查看卡资产时,“运营商账户”继续显示原来的 `carrier_name`;其余上述字段继续按现有规则显示。

View File

@@ -0,0 +1,39 @@
# Change: 允许企业账号登录后台管理系统
## Why
当前前端登录流程会在登录接口成功返回后,基于 `user_type=4` 对企业账号做前端拦截,并提示“企业账号无法登录后台管理系统,请使用平台/代理账号登录”。这与最新业务要求不一致,导致企业账号即使后端已返回有效 token、菜单和权限也无法进入后台。
如果只保留现状,企业账号无法访问后台中已经按权限和菜单控制开放给它们的页面,也会与近期多项针对企业账号后台可见性和资产能力的变更方向相冲突。
## What Changes
- 移除前端登录流程中对 `user_type=4` 企业账号的后台登录拦截。
- 企业账号登录成功后,前端应与其他账号类型一致地:
- 保存 token
- 保存用户信息
- 保存权限、菜单和按钮数据
- 在存在可用后台菜单和权限时,继续进入后台默认首页/重定向目标页
- 当登录接口成功但 `menus` 为空或 `permissions` 为空时:
- 前端仅提示“当前账号暂无任何权限,请联系管理员分配角色和权限”
- 不继续进入后台首页或动态路由注册流程
- 不作为这条提示的后续动作调用退出登录接口
- 登录成功后的页面访问边界继续由现有菜单、路由权限、按钮权限和后端接口权限共同控制。
- 本提案不要求本次同步放开所有企业账号受限接口或页面;仅调整“企业账号能否进入后台”的前端准入逻辑。
- 本提案不修改登录接口路径、请求参数或返回结构。
## Impact
- Affected specs:
- `admin-authentication`
- Affected code:
- `src/composables/useLogin.ts`
- `src/router/guards/beforeEach.ts`
- `src/router/guards/permission.ts`
- `src/App.vue`
- 其他依赖当前登录态、菜单和权限进行页面准入的后台入口逻辑
- Dependencies:
- 后端登录接口继续允许企业账号返回有效 `access_token``user``permissions``menus``buttons`
- 企业账号需要至少具备一部分后台菜单或权限,才能进入可访问页面
- Breaking changes:
- 无外部接口破坏性变更;本次为前端登录准入行为放宽

View File

@@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Enterprise Account Admin Login Admission
The admin login flow SHALL allow enterprise accounts whose `user_type` is `4` to sign in to the admin system when the login API returns a successful response with valid authentication data.
The frontend MUST NOT reject admin login solely because `user_type` equals `4`.
#### Scenario: Enterprise account is allowed to complete admin login
- **GIVEN** 用户使用企业账号凭证登录后台管理系统
- **AND** 登录接口返回成功状态
- **AND** 返回数据包含有效 `access_token`
- **AND** 返回数据中的 `user.user_type``4`
- **WHEN** 前端处理登录成功响应
- **THEN** 前端 MUST 继续执行后台登录成功流程
- **AND** MUST NOT 弹出“企业账号无法登录后台管理系统,请使用平台/代理账号登录”提示
#### Scenario: Enterprise account login still fails when login API itself fails
- **GIVEN** 用户使用企业账号凭证登录后台管理系统
- **AND** 登录接口返回失败状态,或未返回有效 `access_token`
- **WHEN** 前端处理登录响应
- **THEN** 前端 MUST 继续按现有登录失败流程处理
- **AND** MUST NOT 将“允许企业账号登录后台”解释为绕过登录接口失败
### Requirement: Enterprise Account Login State Persistence
The admin login flow SHALL persist enterprise-account login state using the same token, user, permissions, menus, and buttons handling used for other successful admin logins only when the login response provides usable admin-access data.
#### Scenario: Enterprise account saves auth state after successful login with usable admin access data
- **GIVEN** 用户使用企业账号成功登录后台管理系统
- **AND** 登录返回中包含 `access_token``refresh_token``user``permissions``menus``buttons`
- **AND** `permissions` 非空
- **AND** `menus` 非空
- **WHEN** 前端写入登录态
- **THEN** 前端 MUST 保存 token
- **AND** MUST 保存用户信息
- **AND** MUST 保存权限、菜单和按钮数据
#### Scenario: Enterprise account shows no-permission warning without logout follow-up
- **GIVEN** 用户使用企业账号成功登录后台管理系统
- **AND** 登录返回中的 `permissions` 为空,或 `menus` 为空
- **WHEN** 前端处理登录成功响应
- **THEN** 前端 MUST 继续显示现有“当前账号暂无任何权限,请联系管理员分配角色和权限”提示
- **AND** MUST NOT 因为空权限或空菜单回退为企业账号登录拦截
- **AND** MUST NOT 继续进入后台首页或动态路由注册流程
- **AND** MUST NOT 因这条无权限提示而调用退出登录接口
### Requirement: Enterprise Account Admin Entry Uses Existing Authorization Boundaries
Allowing enterprise accounts to sign in to the admin system SHALL NOT by itself grant additional page or API access beyond the existing menu, route-permission, button-permission, and backend authorization boundaries.
#### Scenario: Enterprise account enters admin and follows existing route registration flow
- **GIVEN** 用户使用企业账号成功登录后台管理系统
- **AND** 登录返回中的 `permissions` 非空
- **AND** 登录返回中的 `menus` 非空
- **WHEN** 前端进入登录后的路由注册和首页跳转流程
- **THEN** 前端 MUST 继续按现有菜单和动态路由注册逻辑处理
- **AND** MUST NOT 因 `user_type=4` 在该阶段额外拒绝用户进入后台
#### Scenario: Enterprise account page access remains permission-controlled
- **GIVEN** 用户已使用企业账号登录后台管理系统
- **WHEN** 用户尝试访问某个后台页面或触发某个后台操作
- **THEN** 页面访问和操作可用性 MUST 继续由现有菜单、路由权限、按钮权限和后端接口鉴权决定
- **AND** 允许登录后台本身 MUST NOT 被解释为默认拥有全部后台页面和操作权限

View File

@@ -0,0 +1,17 @@
## 1. Login Flow
- [x] 1.1 移除登录成功后对 `user_type=4` 企业账号的前端拒绝逻辑和错误提示。
- [x] 1.2 确保企业账号在登录成功后与其他账号一样完成 token、用户信息、权限、菜单、按钮的保存流程。
- [x] 1.3 当登录成功但 `menus``permissions` 为空时,仅提示“当前账号暂无任何权限,请联系管理员分配角色和权限”并停止后续登录流程,不调用退出登录接口。
## 2. Navigation And Access
- [x] 2.1 确保企业账号在具备可用后台菜单和权限时,继续走现有后台首页跳转和路由注册流程,不因账号类型被额外拦截。
- [x] 2.2 确认企业账号后台访问范围仍由现有菜单、路由权限和后端接口权限控制,而不是在登录阶段一刀切拒绝。
## 3. Verification
- [ ] 3.1 验证企业账号登录成功后不再出现“企业账号无法登录后台管理系统,请使用平台/代理账号登录”提示。
- [ ] 3.2 验证企业账号在具备可用后台菜单和权限时,能正常保存登录态并进入后台首页或重定向目标页。
- [ ] 3.3 验证企业账号在菜单为空或权限为空时,仅提示“暂无任何权限”,且不会继续调用退出登录接口。
- [ ] 3.4 验证平台账号和代理账号登录流程不受影响。

View File

@@ -0,0 +1,67 @@
## Context
导出任务能力已经具备统一 API、通用列表页面、详情页面和创建弹窗。现有列表页面通过 `scene` 搜索项区分设备、IOT 卡和订单任务,但新的产品方案要求用三个固定场景页面替代该筛选方式,并让三个页面按钮权限相互独立。
## Goals / Non-Goals
- Goals: 在导出管理下提供“导出设备”“导出IOT卡”“导出订单”三个页面。
- Goals: 三个页面固定传入各自 `scene`,去掉页面中的“导出场景”筛选。
- Goals: 三个页面及其按钮/操作使用场景级独立权限编码。
- Goals: 尽量复用现有导出任务列表和详情实现,避免复制三份完整页面逻辑。
- Non-Goals: 不新增导出任务后端 API。
- Non-Goals: 不改变创建导出任务的 `scene` 值和 `query` 快照语义。
- Non-Goals: 不调整导出任务状态流转、文件生成或下载地址生成逻辑。
## Decisions
- Decision: 使用一个可复用导出任务列表实现,通过路由配置或组件 props 注入固定场景、页面标题和权限映射。
- Rationale: 三个页面除固定 `scene` 和权限外,分页、状态筛选、时间筛选、下载、取消逻辑一致,复用可以减少重复代码和后续维护成本。
- Decision: 页面查询参数由页面固定注入 `scene`,不从搜索表单读取“导出场景”。
- Rationale: 页面已经按场景拆分,继续暴露场景筛选会造成入口语义冲突,也可能让用户在“导出设备”页面查到 IOT 卡或订单任务。
- Decision: 权限编码按场景和操作拆分,默认建议如下。
- Rationale: 用户明确要求三个界面的按钮权限编码不同;列表访问、详情、下载、取消均需要避免跨场景复用。
| 场景 | 页面访问 | 详情 | 下载 | 取消 |
| --- | --- | --- | --- | --- |
| 导出设备 | `export_task:device:list` | `export_task:device:detail` | `export_task:device:download` | `export_task:device:cancel` |
| 导出IOT卡 | `export_task:iot_card:list` | `export_task:iot_card:detail` | `export_task:iot_card:download` | `export_task:iot_card:cancel` |
| 导出订单 | `export_task:order:list` | `export_task:order:detail` | `export_task:order:download` | `export_task:order:cancel` |
- Decision: 业务列表创建导出任务按钮继续使用来源业务权限,默认建议如下。
- Rationale: 创建导出任务属于来源业务列表能力,不应因为拥有某个导出任务管理页面权限就获得业务列表导出入口。
| 入口 | 创建导出任务权限 | scene |
| --- | --- | --- |
| 设备管理 | `devices:export` | `device` |
| IOT 卡管理 | `iot_card:export` | `iot_card` |
| 订单列表 | `orders:export` | `order` |
- Decision: 详情页可继续复用同一组件,但必须根据任务场景或来源页面使用对应场景的详情、下载、取消权限。
- Rationale: 详情页面本身也包含下载和取消按钮,若继续使用通用权限会绕过三个列表页面的按钮隔离要求。
## Risks / Trade-offs
- Risk: 后端或权限配置已经发放了通用 `export_task:*` 权限,切换为场景级权限后用户可能暂时看不到页面或按钮。
- Mitigation: 实施时同步更新权限种子/菜单配置,并在上线前完成角色权限迁移或重新授权。
- Risk: 订单导出场景提案正在独立推进,可能继续给旧“导出列表”增加订单筛选。
- Mitigation: 实施本变更前先合并范围,保留 `scene=order` 和订单创建入口,移除旧列表中的订单场景筛选实现。
- Risk: 详情页直接通过 URL 打开时缺少来源页面上下文。
- Mitigation: 详情页读取任务详情后按返回的 `scene` 计算对应权限,缺少该场景详情权限时展示无权限提示并阻止后续按钮操作。
## Migration Plan
1. 增加三条导出任务列表路由和菜单项,删除或隐藏旧通用“导出列表”入口。
2. 将导出任务列表改造成固定场景列表,移除“导出场景”搜索项和冗余场景列。
3. 为三页分别注入固定 `scene`、标题和场景级权限映射。
4. 调整导出任务详情页和行操作的权限判断,改用任务场景对应权限。
5. 同步订单导出入口,确保订单导出任务进入“导出订单”页面查看。
6. 更新后端/菜单权限配置并完成角色授权迁移。
## Open Questions
- 最终权限编码是否采用本设计建议,还是需要对齐后端已有权限命名规范。
- 旧通用导出列表路由是否直接移除,还是短期隐藏并重定向到用户有权限的第一个场景页面。

View File

@@ -0,0 +1,39 @@
# Change: 拆分导出任务列表为固定场景页面
## Why
当前导出管理只有一个“导出列表”页面需要用户通过“导出场景”筛选设备、IOT 卡和订单导出任务。产品希望把该页面拆成三个固定场景页面,入口更直接,并且三个页面及页面按钮使用不同权限编码。
## What Changes
- 将导出管理下的单个“导出列表”替换为三个页面:
- 导出设备
- 导出IOT卡
- 导出订单
- 三个页面分别固定查询 `scene=device``scene=iot_card``scene=order`,页面内不再展示或提交用户可选的“导出场景”筛选项。
- 导出列表中的“场景”列可移除或默认不展示,因为页面本身已经代表固定场景。
- 三个页面的菜单访问、详情、下载、取消等按钮/操作均使用场景级独立权限编码,不再复用同一组通用导出任务按钮权限。
- 业务列表发起导出的按钮权限继续按来源业务隔离设备导出、IOT 卡导出、订单导出使用不同权限编码。
- 订单导出任务仍使用 `scene=order`,并与现有订单导出场景提案合并实施或调整其导出列表筛选相关内容。
## Impact
- Affected specs:
- `export-task-management`
- Affected code:
- `src/router/routesAlias.ts`
- `src/router/routes/asyncRoutes.ts`
- `src/views/asset-management/export-task-management/export-task-list/index.vue`
- `src/views/asset-management/export-task-management/export-task-detail/index.vue`
- `src/types/api/exportTask.ts`
- `src/components/business/ExportTaskCreateDialog.vue`
- `src/views/asset-management/device-list/index.vue`
- `src/views/asset-management/iot-card-management/index.vue`
- `src/views/order-management/order-list/index.vue`
- 菜单、权限和国际化文案配置
- Dependencies:
- 后端导出任务列表接口继续支持 `scene=device``scene=iot_card``scene=order` 过滤。
- 后端/权限配置需要提供三套场景级导出任务权限编码。
- 现有 `add-order-export-scene` 提案与本提案存在重叠:其“在导出列表场景筛选中新增订单”应被本提案的三个固定页面方案替代。
- Breaking changes:
- 原通用“导出列表”菜单入口和 `export_task:list/detail/download/cancel` 这组通用页面操作权限将被场景级页面/按钮权限取代。

View File

@@ -0,0 +1,111 @@
## ADDED Requirements
### Requirement: Scene-Specific Export Task Pages
The admin frontend SHALL replace the single generic export task list entry with three scene-specific export task pages: 导出设备, 导出IOT卡, and 导出订单.
#### Scenario: Render scene-specific export task navigation
- **GIVEN** 用户打开资产管理下的导出管理菜单
- **WHEN** 系统渲染导出任务导航
- **THEN** 系统 MUST provide separate entries for 导出设备, 导出IOT卡, and 导出订单
- **AND** 系统 MUST NOT expose the old generic 导出列表 menu entry as the primary navigation entry
#### Scenario: Query device export tasks
- **GIVEN** 用户打开导出设备页面
- **WHEN** 页面查询导出任务列表
- **THEN** 系统 MUST call `GET /api/admin/export-tasks` with `scene=device`
- **AND** 用户 MUST NOT be able to change this page to query `iot_card` or `order` tasks through a scene selector
#### Scenario: Query IOT card export tasks
- **GIVEN** 用户打开导出IOT卡页面
- **WHEN** 页面查询导出任务列表
- **THEN** 系统 MUST call `GET /api/admin/export-tasks` with `scene=iot_card`
- **AND** 用户 MUST NOT be able to change this page to query `device` or `order` tasks through a scene selector
#### Scenario: Query order export tasks
- **GIVEN** 用户打开导出订单页面
- **WHEN** 页面查询导出任务列表
- **THEN** 系统 MUST call `GET /api/admin/export-tasks` with `scene=order`
- **AND** 用户 MUST NOT be able to change this page to query `device` or `iot_card` tasks through a scene selector
### Requirement: Remove Export Scene Filtering From Scene Pages
Scene-specific export task pages SHALL remove user-facing export scene filtering because each page has exactly one fixed scene.
#### Scenario: Hide scene search field
- **GIVEN** 用户打开任一导出任务场景页面
- **WHEN** 页面渲染搜索表单
- **THEN** 页面 MUST NOT display a 导出场景 search item
- **AND** reset and search actions MUST keep the page's fixed `scene` unchanged
#### Scenario: Avoid redundant scene column
- **GIVEN** 用户打开任一导出任务场景页面
- **WHEN** 页面渲染任务表格
- **THEN** 页面 SHOULD remove or hide the 场景 column by default
- **AND** 页面 MUST continue to display task number, status, progress, format, row counts, shard progress, error message, and created time
### Requirement: Scene-Specific Export Task Permissions
The admin frontend MUST gate each export task page and page action with scene-specific permission codes so that device, IOT card, and order export task permissions are independent.
#### Scenario: Gate scene pages independently
- **GIVEN** 用户 only has `export_task:device:list`
- **WHEN** 系统渲染导出管理菜单
- **THEN** 系统 MUST show 导出设备
- **AND** 系统 MUST NOT show 导出IOT卡 or 导出订单 unless the user also has `export_task:iot_card:list` or `export_task:order:list`
#### Scenario: Gate device export task actions independently
- **GIVEN** 用户打开导出设备页面
- **WHEN** 页面渲染详情、下载 or 取消操作
- **THEN** 系统 MUST use `export_task:device:detail`, `export_task:device:download`, and `export_task:device:cancel` respectively
- **AND** 系统 MUST NOT treat IOT card or order export task action permissions as sufficient for device actions
#### Scenario: Gate IOT card export task actions independently
- **GIVEN** 用户打开导出IOT卡页面
- **WHEN** 页面渲染详情、下载 or 取消操作
- **THEN** 系统 MUST use `export_task:iot_card:detail`, `export_task:iot_card:download`, and `export_task:iot_card:cancel` respectively
- **AND** 系统 MUST NOT treat device or order export task action permissions as sufficient for IOT card actions
#### Scenario: Gate order export task actions independently
- **GIVEN** 用户打开导出订单页面
- **WHEN** 页面渲染详情、下载 or 取消操作
- **THEN** 系统 MUST use `export_task:order:detail`, `export_task:order:download`, and `export_task:order:cancel` respectively
- **AND** 系统 MUST NOT treat device or IOT card export task action permissions as sufficient for order actions
#### Scenario: Keep business export buttons independent
- **GIVEN** 用户分别访问设备管理、IOT 卡管理 and 订单列表
- **WHEN** 页面渲染创建导出任务按钮
- **THEN** 设备管理 MUST use `devices:export`
- **AND** IOT 卡管理 MUST use `iot_card:export`
- **AND** 订单列表 MUST use `orders:export`
- **AND** possessing one of these permissions MUST NOT reveal or enable the other two export buttons
### Requirement: Scene-Aware Export Task Detail Permissions
The export task detail page SHALL apply detail, download, and cancel permissions according to the task's scene.
#### Scenario: Enforce scene permissions on direct detail access
- **GIVEN** 用户直接打开某个导出任务详情 URL
- **WHEN** 系统加载任务详情 and reads the returned `scene`
- **THEN** 系统 MUST require the matching scene detail permission before showing the detail content
- **AND** 系统 MUST use the matching scene download and cancel permissions before showing detail-page buttons
#### Scenario: Prevent cross-scene action leakage
- **GIVEN** 用户 has only order export task download permission
- **AND** 用户打开 a completed device export task detail
- **WHEN** 页面渲染详情操作区
- **THEN** 页面 MUST NOT show the device task download button
- **AND** 页面 MUST NOT allow downloading that device task through the order download permission

View File

@@ -0,0 +1,46 @@
## 1. Proposal Review
- [x] 1.1 确认三个页面名称为“导出设备”“导出IOT卡”“导出订单”。
- [x] 1.2 确认固定场景值分别为 `device``iot_card``order`
- [x] 1.3 确认三套页面和按钮权限编码,或按后端权限命名规范替换设计中的默认建议。
- [x] 1.4 确认本提案与 `add-order-export-scene` 合并实施,订单场景不再通过旧导出场景筛选进入。
## 2. Routing And Menu
- [x] 2.1 新增三条导出任务列表路由别名和路由记录。
- [x] 2.2 在导出管理菜单下新增“导出设备”“导出IOT卡”“导出订单”。
- [x] 2.3 删除或隐藏旧“导出列表”菜单入口。
- [x] 2.4 为三条路由分别配置独立页面访问权限。
- [x] 2.5 更新菜单国际化文案和权限配置。
## 3. Export Task List Pages
- [x] 3.1 将现有导出任务列表改造成可复用固定场景列表,支持传入页面标题、固定 `scene` 和权限映射。
- [x] 3.2 移除搜索表单中的“导出场景”筛选项。
- [x] 3.3 列表查询始终携带当前页面固定 `scene`
- [x] 3.4 移除或默认隐藏列表中的“场景”列。
- [x] 3.5 保留任务状态、创建时间、分页、刷新等现有列表能力。
## 4. Scene-Specific Actions
- [x] 4.1 详情入口按当前页面场景检查对应 `detail` 权限。
- [x] 4.2 下载按钮按当前页面场景检查对应 `download` 权限。
- [x] 4.3 取消按钮按当前页面场景检查对应 `cancel` 权限。
- [x] 4.4 导出任务详情页根据任务 `scene` 使用对应详情、下载、取消权限。
- [x] 4.5 验证只有某一场景权限时,不会看到其他场景页面或按钮。
## 5. Business Export Entrypoints
- [x] 5.1 设备管理导出按钮继续使用 `devices:export` 并创建 `scene=device` 任务。
- [x] 5.2 IOT 卡管理导出按钮继续使用 `iot_card:export` 并创建 `scene=iot_card` 任务。
- [x] 5.3 订单列表导出按钮使用 `orders:export` 并创建 `scene=order` 任务。
- [x] 5.4 确认三个业务导出按钮权限编码互不复用。
## 6. Verification
- [x] 6.1 验证“导出设备”页面请求 `GET /api/admin/export-tasks` 时固定携带 `scene=device`
- [x] 6.2 验证“导出IOT卡”页面请求固定携带 `scene=iot_card`
- [x] 6.3 验证“导出订单”页面请求固定携带 `scene=order`
- [x] 6.4 验证三个页面均不展示“导出场景”筛选项。
- [x] 6.5 验证三页详情、下载、取消按钮按各自权限显示和拦截。
- [x] 6.6 运行类型检查、lint 和相关页面手动回归。

View File

@@ -0,0 +1,110 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'
async function resolveSrcAlias(path) {
const resolvedPath = join(process.cwd(), 'src', path.slice(2))
try {
if ((await stat(resolvedPath)).isDirectory()) {
return join(resolvedPath, 'index.ts')
}
} catch {
// Let esbuild report the original resolution failure.
}
return resolvedPath
}
async function importBundledActions() {
const tempDir = await mkdtemp(join(tmpdir(), 'agent-recharge-actions-'))
const bundlePath = join(tempDir, 'agentRechargeActions.mjs')
await esbuild.build({
entryPoints: ['src/views/finance/agent-recharge/agentRechargeActions.ts'],
bundle: true,
outfile: bundlePath,
format: 'esm',
platform: 'node',
absWorkingDir: process.cwd(),
plugins: [
{
name: 'alias-src',
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => ({
path: await resolveSrcAlias(args.path)
}))
}
}
]
})
const module = await import(pathToFileURL(bundlePath).href)
return {
module,
cleanup: () => rm(tempDir, { recursive: true, force: true })
}
}
test('pending agent recharge exposes reject action for admins with rejection permission', async () => {
const { module, cleanup } = await importBundledActions()
const { buildAgentRechargeActions } = module
const row = {
id: 42,
status: 1,
payment_method: 'wechat',
payment_voucher_key: []
}
const handledRows = []
try {
const actions = buildAgentRechargeActions(row, {
hasAuth: (permission) => permission === 'agent_recharge:reject',
onViewPaymentVoucher: () => {},
onConfirmPayment: () => {},
onReject: (recharge) => handledRows.push(recharge)
})
assert.deepEqual(
actions.map((action) => action.label),
['拒绝']
)
actions[0].handler()
assert.deepEqual(handledRows, [row])
} finally {
await cleanup()
}
})
test('non-pending agent recharge does not expose reject action', async () => {
const { module, cleanup } = await importBundledActions()
const { buildAgentRechargeActions } = module
const row = {
id: 42,
status: 2,
payment_method: 'wechat',
payment_voucher_key: []
}
try {
const actions = buildAgentRechargeActions(row, {
hasAuth: (permission) => permission === 'agent_recharge:reject',
onViewPaymentVoucher: () => {},
onConfirmPayment: () => {},
onReject: () => {}
})
assert.deepEqual(
actions.map((action) => action.label),
[]
)
} finally {
await cleanup()
}
})

View File

@@ -0,0 +1,49 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'
async function importBundledDisplay() {
const tempDir = await mkdtemp(join(tmpdir(), 'agent-recharge-display-'))
const bundlePath = join(tempDir, 'agentRechargeDisplay.mjs')
await esbuild.build({
entryPoints: ['src/views/finance/agent-recharge/agentRechargeDisplay.ts'],
bundle: true,
outfile: bundlePath,
format: 'esm',
platform: 'node',
absWorkingDir: process.cwd()
})
const module = await import(pathToFileURL(bundlePath).href)
return {
module,
cleanup: () => rm(tempDir, { recursive: true, force: true })
}
}
test('agent recharge rejection reason display uses backend rejection_reason when present', async () => {
const { module, cleanup } = await importBundledDisplay()
try {
assert.equal(module.formatRejectionReason('凭证不清晰'), '凭证不清晰')
} finally {
await cleanup()
}
})
test('agent recharge rejection reason display falls back to dash when absent', async () => {
const { module, cleanup } = await importBundledDisplay()
try {
assert.equal(module.formatRejectionReason(''), '-')
assert.equal(module.formatRejectionReason(null), '-')
assert.equal(module.formatRejectionReason(undefined), '-')
} finally {
await cleanup()
}
})

View File

@@ -0,0 +1,69 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'
test('agent recharge rejection posts the rejection reason to the order rejection endpoint', async () => {
const calls = []
const tempDir = await mkdtemp(join(tmpdir(), 'agent-recharge-service-'))
const bundlePath = join(tempDir, 'agentRechargeService.mjs')
const httpMockPath = join(tempDir, 'httpMock.mjs')
await writeFile(
httpMockPath,
`
export const calls = globalThis.__agentRechargeHttpCalls
export default {
post(options) {
calls.push(options)
return Promise.resolve({ code: 0, data: undefined })
}
}
`,
'utf8'
)
globalThis.__agentRechargeHttpCalls = calls
try {
await esbuild.build({
entryPoints: ['src/api/modules/agentRecharge.ts'],
bundle: true,
outfile: bundlePath,
format: 'esm',
platform: 'node',
absWorkingDir: process.cwd(),
plugins: [
{
name: 'mock-http-boundary',
setup(build) {
build.onResolve({ filter: /^@\/utils\/http$/ }, () => ({
path: httpMockPath
}))
}
}
]
})
const { AgentRechargeService } = await import(pathToFileURL(bundlePath).href)
await AgentRechargeService.rejectAgentRecharge(42, {
rejection_reason: '支付凭证不符合要求'
})
assert.deepEqual(calls, [
{
url: '/api/admin/agent-recharges/42/reject',
data: {
rejection_reason: '支付凭证不符合要求'
}
}
])
} finally {
delete globalThis.__agentRechargeHttpCalls
await rm(tempDir, { recursive: true, force: true })
}
})

View File

@@ -9,6 +9,7 @@ import type {
AgentRechargeListResponse,
CreateAgentRechargeRequest,
ConfirmOfflinePaymentRequest,
RejectAgentRechargeRequest,
BaseResponse
} from '@/types/api'
@@ -55,4 +56,16 @@ export class AgentRechargeService extends BaseService {
data
)
}
/**
* 拒绝代理充值订单
* @param id 充值订单ID
* @param data 拒绝请求参数
*/
static rejectAgentRecharge(
id: number,
data: RejectAgentRechargeRequest
): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/agent-recharges/${id}/reject`, data)
}
}

View File

@@ -11,6 +11,7 @@ import {
} from '@/utils/business/apiRateLimit'
import type {
BaseResponse,
AssetResolveParams,
AssetResolveResponse,
AssetRealtimeStatusResponse,
AssetRefreshResponse,
@@ -49,9 +50,13 @@ export class AssetService extends BaseService {
* 支持虚拟号、ICCID、IMEI、SN、MSISDN
* GET /api/admin/assets/resolve/:identifier
* @param identifier 资产标识符虚拟号、ICCID、IMEI、SN、MSISDN
* @param params 查询参数
*/
static resolveAsset(identifier: string): Promise<BaseResponse<AssetResolveResponse>> {
return this.getOne<AssetResolveResponse>(`/api/admin/assets/resolve/${identifier}`)
static resolveAsset(
identifier: string,
params?: AssetResolveParams
): Promise<BaseResponse<AssetResolveResponse>> {
return this.getOne<AssetResolveResponse>(`/api/admin/assets/resolve/${identifier}`, params)
}
/**
@@ -165,9 +170,8 @@ export class AssetService extends BaseService {
* @param identifier 资产标识符ICCID 或 VirtualNo
*/
static stopAsset(identifier: string): Promise<BaseResponse<DeviceStopResponse | void>> {
return this.post<BaseResponse<DeviceStopResponse | void>>(
`/api/admin/assets/${identifier}/stop`,
{}
return runRateLimitedAssetAction('stop', identifier, () =>
this.post<BaseResponse<DeviceStopResponse | void>>(`/api/admin/assets/${identifier}/stop`, {})
)
}

View File

@@ -21,7 +21,7 @@ export { ShopSeriesGrantService } from './shopSeriesGrant'
export { OrderService } from './order'
export { AssetService } from './asset'
export { AgentRechargeService } from './agentRecharge'
export { WechatConfigService } from './wechatConfig'
export { PaymentSettingsService } from './paymentSettings'
export { ExchangeService } from './exchange'
export { RefundService } from './refund'
export { DataCleanupService } from './dataCleanup'

View File

@@ -0,0 +1,80 @@
/**
* 支付配置管理 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
PaymentSettings,
PaymentSettingsQueryParams,
PaymentSettingsListResponse,
CreatePaymentSettingsRequest,
UpdatePaymentSettingsRequest
} from '@/types/api/paymentSettings'
const PAYMENT_SETTINGS_BASE_URL = '/api/admin/wechat-configs'
export class PaymentSettingsService extends BaseService {
/**
* 获取支付配置列表
*/
static getPaymentSettings(
params?: PaymentSettingsQueryParams
): Promise<BaseResponse<PaymentSettingsListResponse>> {
return this.get<BaseResponse<PaymentSettingsListResponse>>(PAYMENT_SETTINGS_BASE_URL, params)
}
/**
* 获取支付配置详情
*/
static getPaymentSettingsById(id: number): Promise<BaseResponse<PaymentSettings>> {
return this.get<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}`)
}
/**
* 创建支付配置
*/
static createPaymentSettings(
data: CreatePaymentSettingsRequest
): Promise<BaseResponse<PaymentSettings>> {
return this.post<BaseResponse<PaymentSettings>>(PAYMENT_SETTINGS_BASE_URL, data)
}
/**
* 更新支付配置
*/
static updatePaymentSettings(
id: number,
data: UpdatePaymentSettingsRequest
): Promise<BaseResponse<PaymentSettings>> {
return this.put<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}`, data)
}
/**
* 删除支付配置
*/
static deletePaymentSettings(id: number): Promise<BaseResponse<void>> {
return this.delete<BaseResponse<void>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}`)
}
/**
* 激活支付配置
*/
static activatePaymentSettings(id: number): Promise<BaseResponse<PaymentSettings>> {
return this.post<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}/activate`)
}
/**
* 停用支付配置
*/
static deactivatePaymentSettings(id: number): Promise<BaseResponse<PaymentSettings>> {
return this.post<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}/deactivate`)
}
/**
* 获取当前生效的支付配置
*/
static getActivePaymentSettings(): Promise<BaseResponse<PaymentSettings>> {
return this.get<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/active`)
}
}

View File

@@ -1,78 +0,0 @@
/**
* 支付配置管理 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
WechatConfig,
WechatConfigQueryParams,
WechatConfigListResponse,
CreateWechatConfigRequest,
UpdateWechatConfigRequest
} from '@/types/api/wechatConfig'
const PAYMENT_CONFIG_BASE_URL = '/api/admin/wechat-configs'
export class WechatConfigService extends BaseService {
/**
* 获取支付配置列表
*/
static getWechatConfigs(
params?: WechatConfigQueryParams
): Promise<BaseResponse<WechatConfigListResponse>> {
return this.get<BaseResponse<WechatConfigListResponse>>(PAYMENT_CONFIG_BASE_URL, params)
}
/**
* 获取支付配置详情
*/
static getWechatConfigById(id: number): Promise<BaseResponse<WechatConfig>> {
return this.get<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/${id}`)
}
/**
* 创建支付配置
*/
static createWechatConfig(data: CreateWechatConfigRequest): Promise<BaseResponse<WechatConfig>> {
return this.post<BaseResponse<WechatConfig>>(PAYMENT_CONFIG_BASE_URL, data)
}
/**
* 更新支付配置
*/
static updateWechatConfig(
id: number,
data: UpdateWechatConfigRequest
): Promise<BaseResponse<WechatConfig>> {
return this.put<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/${id}`, data)
}
/**
* 删除支付配置
*/
static deleteWechatConfig(id: number): Promise<BaseResponse<void>> {
return this.delete<BaseResponse<void>>(`${PAYMENT_CONFIG_BASE_URL}/${id}`)
}
/**
* 激活支付配置
*/
static activateWechatConfig(id: number): Promise<BaseResponse<WechatConfig>> {
return this.post<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/${id}/activate`)
}
/**
* 停用支付配置
*/
static deactivateWechatConfig(id: number): Promise<BaseResponse<WechatConfig>> {
return this.post<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/${id}/deactivate`)
}
/**
* 获取当前生效的支付配置
*/
static getActiveWechatConfig(): Promise<BaseResponse<WechatConfig>> {
return this.get<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/active`)
}
}

View File

@@ -37,6 +37,7 @@
import { ElMessage } from 'element-plus'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { getExportTaskSceneName } from '@/config/constants'
import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api'
const props = withDefaults(
@@ -73,15 +74,7 @@
set: (value: boolean) => emit('update:modelValue', value)
})
const sceneName = computed(() => {
const sceneMap: Record<ExportTaskScene, string> = {
device: '设备管理',
iot_card: 'IoT卡管理',
order: '订单管理'
}
return sceneMap[props.scene]
})
const sceneName = computed(() => getExportTaskSceneName(props.scene))
watch(
() => props.modelValue,

View File

@@ -3,18 +3,17 @@
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElNotification, ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { HOME_PAGE, RoutesAlias } from '@/router/routesAlias'
import { RoutesAlias } from '@/router/routesAlias'
import AppConfig from '@/config'
import { AuthService } from '@/api/modules'
import { ApiStatus } from '@/utils/http/status'
import { saveCredentials, getRememberedCredentials } from '@/utils/auth/rememberPassword'
import { usernameRules, passwordRules } from '@/utils/auth/loginValidation'
import { getRedirectPath } from '@/router/guards/permission'
/**
* 登录表单数据
@@ -28,7 +27,6 @@ interface LoginForm {
export function useLogin() {
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 表单引用
@@ -108,9 +106,24 @@ export function useLogin() {
return
}
// 检查用户类型 - 禁止企业账号登录
if (response.data.user && response.data.user.user_type === 4) {
ElMessage.error('企业账号无法登录后台管理系统,请使用平台/代理账号登录')
if (!response.data.user) {
ElMessage.error(t('login.error.getUserInfoFailed'))
return
}
// 登录成功但没有后台菜单或权限时,仅提示并停止后续流程。
// 这里不保存登录态,也不触发后续跳转,避免进入路由守卫后再连续调用退出接口。
const hasPermissions =
Array.isArray(response.data.permissions) && response.data.permissions.length > 0
const hasMenus = Array.isArray(response.data.menus) && response.data.menus.length > 0
if (!hasPermissions || !hasMenus) {
ElMessage.warning({
message: '当前账号暂无任何权限,请联系管理员分配角色和权限',
duration: 4000,
showClose: true
})
saveCredentials(formData.username, formData.password, formData.rememberPassword)
return
}
@@ -118,13 +131,8 @@ export function useLogin() {
userStore.setToken(response.data.access_token, response.data.refresh_token)
// 保存用户信息
if (response.data.user) {
userStore.setUserInfo(response.data.user)
userStore.setLoginStatus(true)
} else {
ElMessage.error(t('login.error.getUserInfoFailed'))
return
}
userStore.setUserInfo(response.data.user)
userStore.setLoginStatus(true)
// 保存权限、菜单和按钮
if (response.data.permissions) {
@@ -137,19 +145,6 @@ export function useLogin() {
userStore.setButtons(response.data.buttons)
}
// 检查权限和菜单是否为空
const hasPermissions = response.data.permissions && response.data.permissions.length > 0
const hasMenus = response.data.menus && response.data.menus.length > 0
if (!hasPermissions || !hasMenus) {
ElMessage.warning({
message: '当前账号暂无任何权限,请联系管理员分配角色和权限',
duration: 4000,
showClose: true
})
// 虽然没有权限,但仍然允许登录,让用户看到空白页面而不是停留在登录页
}
// 保存记住密码
saveCredentials(formData.username, formData.password, formData.rememberPassword)

View File

@@ -0,0 +1,55 @@
import type { ExportTaskScene } from '@/types/api'
export interface ExportTaskScenePermissions {
detail: string
download: string
cancel: string
}
export interface ExportTaskSceneConfig {
scene: ExportTaskScene
sceneName: string
pageTitle: string
permissions: ExportTaskScenePermissions
}
export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneConfig> = {
device: {
scene: 'device',
sceneName: '设备管理',
pageTitle: '导出设备',
permissions: {
detail: 'export_task:device_detail',
download: 'export_task:device_download',
cancel: 'export_task:device_cancel'
}
},
iot_card: {
scene: 'iot_card',
sceneName: 'IOT卡管理',
pageTitle: '导出IOT卡',
permissions: {
detail: 'export_task:iot_card_detail',
download: 'export_task:iot_card_download',
cancel: 'export_task:iot_card_cancel'
}
},
order: {
scene: 'order',
sceneName: '订单管理',
pageTitle: '导出订单',
permissions: {
detail: 'export_task:order_detail',
download: 'export_task:order_download',
cancel: 'export_task:order_cancel'
}
}
}
export const getExportTaskSceneConfig = (scene?: ExportTaskScene) => {
return scene ? EXPORT_TASK_SCENE_CONFIG[scene] : undefined
}
export const getExportTaskSceneName = (scene?: ExportTaskScene) => {
return getExportTaskSceneConfig(scene)?.sceneName || '-'
}

View File

@@ -31,3 +31,6 @@ export * from './roleTypes'
// 启用状态相关
export * from './enableStatus'
// 导出任务相关
export * from './exportTask'

View File

@@ -449,12 +449,17 @@
"taskManagement": "Task Management",
"exportTaskManagement": "Export Management",
"exportTaskList": "Export List",
"exportDevice": "Export Devices",
"exportIotCard": "Export IOT Cards",
"exportOrder": "Export Orders",
"exportTaskDetail": "Export Task Detail",
"exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail"
},
"settings": {
"title": "Settings Management",
"paymentSettings": "Payment Settings",
"detailsOfPaymentConfiguration": "Payment Configuration Details",
"paymentMerchant": "Payment Merchant",
"developerApi": "Developer API",
"commissionTemplate": "Commission Template"

View File

@@ -383,6 +383,9 @@
"taskManagement": "任务管理",
"exportTaskManagement": "导出管理",
"exportTaskList": "导出列表",
"exportDevice": "导出设备",
"exportIotCard": "导出IOT卡",
"exportOrder": "导出订单",
"exportTaskDetail": "导出任务详情",
"exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情"
@@ -407,7 +410,7 @@
},
"settings": {
"title": "设置管理",
"wechatPayConfiguration": "微信配置",
"paymentSettings": "支付设置",
"detailsOfPaymentConfiguration": "支付配置详情",
"withdrawalSettings": "提现配置",
"passwordSettings": "密码设置"

View File

@@ -331,8 +331,7 @@ function buildRouteMap(routes: AppRouteRecord[], parentPath = ''): Map<string, A
*/
function convertBackendMenuToRoute(
menu: any,
routeMap: Map<string, AppRouteRecord>,
parentPath = ''
routeMap: Map<string, AppRouteRecord>
): AppRouteRecord | null {
const menuUrl = menu.url || '/'
const matchedRoute = routeMap.get(menuUrl)
@@ -343,7 +342,7 @@ function convertBackendMenuToRoute(
// 如果当前菜单没有匹配的路由,但有 children尝试递归处理 children
if (menu.children && menu.children.length > 0) {
const children = menu.children
.map((child: any) => convertBackendMenuToRoute(child, routeMap, menuUrl))
.map((child: any) => convertBackendMenuToRoute(child, routeMap))
.filter((child: AppRouteRecord | null) => child !== null)
// 如果子菜单有有效的路由,返回一个包含子菜单的占位路由
@@ -387,7 +386,7 @@ function convertBackendMenuToRoute(
// 递归处理后端返回的 children
if (menu.children && menu.children.length > 0) {
const children = menu.children
.map((child: any) => convertBackendMenuToRoute(child, routeMap, menuUrl))
.map((child: any) => convertBackendMenuToRoute(child, routeMap))
.filter((child: AppRouteRecord | null) => child !== null)
if (children.length > 0) {

View File

@@ -451,12 +451,32 @@ export const asyncRoutes: AppRouteRecord[] = [
},
children: [
{
path: 'export-task-list',
name: 'ExportTaskList',
component: RoutesAlias.ExportTaskList,
path: 'export-device',
name: 'ExportDeviceTaskList',
component: RoutesAlias.ExportDeviceTaskList,
meta: {
title: 'menus.assetManagement.exportTaskList',
permissions: ['export_task:list'],
title: 'menus.assetManagement.exportDevice',
exportTaskScene: 'device',
keepAlive: true
}
},
{
path: 'export-iot-card',
name: 'ExportIotCardTaskList',
component: RoutesAlias.ExportIotCardTaskList,
meta: {
title: 'menus.assetManagement.exportIotCard',
exportTaskScene: 'iot_card',
keepAlive: true
}
},
{
path: 'export-order',
name: 'ExportOrderTaskList',
component: RoutesAlias.ExportOrderTaskList,
meta: {
title: 'menus.assetManagement.exportOrder',
exportTaskScene: 'order',
keepAlive: true
}
},
@@ -466,7 +486,6 @@ export const asyncRoutes: AppRouteRecord[] = [
component: RoutesAlias.ExportTaskDetail,
meta: {
title: 'menus.assetManagement.exportTaskDetail',
permissions: ['export_task:detail'],
isHide: true,
keepAlive: false
}
@@ -621,13 +640,13 @@ export const asyncRoutes: AppRouteRecord[] = [
icon: '&#xe715;'
},
children: [
// 微信配
// 支付设
{
path: 'wechat-config',
name: 'WechatConfig',
component: RoutesAlias.WechatConfig,
path: 'payment-settings',
name: 'PaymentSettings',
component: RoutesAlias.PaymentSettings,
meta: {
title: 'menus.settings.wechatPayConfiguration',
title: 'menus.settings.paymentSettings',
keepAlive: true
}
},
@@ -642,11 +661,11 @@ export const asyncRoutes: AppRouteRecord[] = [
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 微信支付置详情
// 支付置详情
{
path: 'wechat-config/detail/:id',
name: 'WechatConfigDetailRoute',
component: RoutesAlias.WechatConfigDetail,
path: 'payment-settings/detail/:id',
name: 'PaymentSettingsDetailRoute',
component: RoutesAlias.PaymentSettingsDetail,
meta: {
title: 'menus.settings.detailsOfPaymentConfiguration',
isHide: true,

View File

@@ -60,7 +60,9 @@ export enum RoutesAlias {
OrderPackageInvalidateTask = '/asset-management/task-management/order-package-invalidate-task', // 订单套餐批量作废任务
OrderPackageInvalidateTaskDetail = '/asset-management/task-management/order-package-invalidate-task/detail', // 订单套餐作废任务详情
TaskDetail = '/asset-management/task-management/task-detail', // 任务详情IoT卡/设备任务详情)
ExportTaskList = '/asset-management/export-task-management/export-task-list', // 导出列表
ExportDeviceTaskList = '/asset-management/export-task-management/export-device', // 导出设备
ExportIotCardTaskList = '/asset-management/export-task-management/export-iot-card', // 导出IOT卡
ExportOrderTaskList = '/asset-management/export-task-management/export-order', // 导出订单
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
// 订单管理
@@ -80,8 +82,8 @@ export enum RoutesAlias {
// 设置管理
WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置
WechatConfig = '/settings/wechat-config', // 微信配
WechatConfigDetail = '/settings/wechat-config/detail', // 微信支付置详情
PaymentSettings = '/settings/payment-settings', // 支付设
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付置详情
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
// 轮询管理

View File

@@ -32,6 +32,7 @@ export interface AgentRecharge {
payment_config_id: number | null
payment_transaction_id: string
payment_voucher_key?: string[] | string // 凭证附件列表;历史数据可能为单字符串或逗号字符串
rejection_reason?: string | null // 拒绝原因
remark?: string // 运营备注
created_at: string
paid_at: string | null
@@ -72,3 +73,8 @@ export interface CreateAgentRechargeRequest {
export interface ConfirmOfflinePaymentRequest {
operation_password: string
}
// 拒绝代理充值订单请求
export interface RejectAgentRechargeRequest {
rejection_reason: string
}

View File

@@ -42,6 +42,10 @@ export type UsageType = 'single_card' | 'device'
// ========== 资产详情响应 ==========
export interface AssetResolveParams {
include_usage_summary?: boolean // 是否返回当前世代流量汇总字段
}
/**
* 资产解析/详情响应
* 对应接口GET /api/admin/assets/resolve/:identifier
@@ -67,6 +71,8 @@ export interface AssetResolveResponse {
real_used_mb: number // 真流量已用
virtual_total_mb: number // 业务停机阈值(展示用)
virtual_used_mb: number // 展示已用量
total_virtual_used_mb?: number | null // 所有套餐已用量(MB),未请求时为 null
total_virtual_remaining_mb?: number | null // 所有套餐剩余量(MB),未请求时为 null
reduction_pct: number // 展示增幅比例(小数,如 0.428571
device_protect_status?: DeviceProtectStatus // 保护期状态none / stop / start仅 device
activated_at: string // 激活时间

View File

@@ -75,8 +75,8 @@ export * from './asset'
// 代理充值相关
export * from './agentRecharge'
// 微信支付置相关
export * from './wechatConfig'
// 支付置相关
export * from './paymentSettings'
// 退款管理相关
export * from './refund'

View File

@@ -1,5 +1,5 @@
/**
*
*
*/
// 支付渠道类型
@@ -66,8 +66,8 @@ type PaymentConfigWritableFields = Partial<
FuiouConfigFields
>
// 支付
export interface WechatConfig
// 支付
export interface PaymentSettings
extends PaymentConfigBase,
MiniappConfigFields,
OfficialAccountConfigFields,
@@ -76,7 +76,7 @@ export interface WechatConfig
FuiouConfigFields {}
// 查询参数
export interface WechatConfigQueryParams {
export interface PaymentSettingsQueryParams {
page?: number
page_size?: number
provider_type?: PaymentProviderType
@@ -84,23 +84,23 @@ export interface WechatConfigQueryParams {
}
// 列表响应
export interface WechatConfigListResponse {
items: WechatConfig[]
export interface PaymentSettingsListResponse {
items: PaymentSettings[]
page: number
size: number
total: number
page_size?: number
}
// 创建微信支付置请求
export interface CreateWechatConfigRequest extends PaymentConfigWritableFields {
// 创建支付置请求
export interface CreatePaymentSettingsRequest extends PaymentConfigWritableFields {
name: string
provider_type: PaymentProviderType
description?: string
}
// 更新微信支付置请求
export interface UpdateWechatConfigRequest extends PaymentConfigWritableFields {
// 更新支付置请求
export interface UpdatePaymentSettingsRequest extends PaymentConfigWritableFields {
name?: string
description?: string
provider_type?: PaymentProviderType

View File

@@ -20,7 +20,7 @@ export interface PaymentMerchantSetting {
publicKey: string
notifyUrl?: string
}
// 微信配置
// 微信支付配置
wechat?: {
enabled: boolean
appId: string

View File

@@ -3,6 +3,7 @@
*/
import { RouteRecordRaw } from 'vue-router'
import type { ExportTaskScene } from '@/types/api'
// 路由元数据
export interface RouteMeta extends Record<string | number | symbol, unknown> {
@@ -33,6 +34,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
isFirstLevel?: boolean
/** 角色权限 */
roles?: string[]
/** 导出任务固定场景 */
exportTaskScene?: ExportTaskScene
/** 是否固定标签页 */
fixedTab?: boolean
}

View File

@@ -1,7 +1,7 @@
const FIVE_MINUTES_MS = 5 * 60 * 1000
const STORAGE_KEY_PREFIX = 'asset-action-rate-limit'
export type AssetRateLimitedAction = 'refresh' | 'start'
export type AssetRateLimitedAction = 'refresh' | 'start' | 'stop'
export class FrontendRateLimitError extends Error {
readonly remainingMs: number

View File

@@ -1,4 +1,4 @@
import type { PaymentProviderType, WechatConfig } from '@/types/api'
import type { PaymentProviderType, PaymentSettings } from '@/types/api'
const PAYMENT_PROVIDER_LABELS: Record<PaymentProviderType, string> = {
wechat: '微信直连',
@@ -11,7 +11,7 @@ export const getPaymentProviderText = (type: PaymentProviderType): string => {
}
export const getPaymentMerchantId = (
config: Pick<WechatConfig, 'provider_type' | 'wx_mch_id' | 'fy_mchnt_cd'>
config: Pick<PaymentSettings, 'provider_type' | 'wx_mch_id' | 'fy_mchnt_cd'>
): string => {
if (config.provider_type === 'wechat' || config.provider_type === 'wechat_v2') {
return config.wx_mch_id || '-'
@@ -25,7 +25,7 @@ export const getPaymentMerchantId = (
}
export const getPaymentNotifyUrl = (
config: Pick<WechatConfig, 'provider_type' | 'wx_notify_url' | 'fy_notify_url' | 'ali_notify_url'>
config: Pick<PaymentSettings, 'provider_type' | 'wx_notify_url' | 'fy_notify_url' | 'ali_notify_url'>
): string => {
if (config.provider_type === 'wechat' || config.provider_type === 'wechat_v2') {
return config.wx_notify_url || '-'

View File

@@ -9,7 +9,7 @@ const axiosInstance = axios.create({
baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理其他模式使用完整URL
withCredentials: false, // 异步请求携带cookie
transformRequest: [
(data, headers) => {
(data) => {
// 如果是 FormData,不进行转换
if (data instanceof FormData) {
return data
@@ -72,6 +72,7 @@ let failedQueue: Array<{
resolve: (value?: any) => void
reject: (reason?: any) => void
}> = [] // 失败队列,存储因 token 过期而失败的请求
let isHandlingAuthExpiry = false
// 处理失败队列
const processQueue = (error: any, token: string | null = null) => {
@@ -222,6 +223,11 @@ function handleErrorMessage(
const backendMessage = error.response?.data?.msg
const httpStatus = error.response?.status
// 401 过期类错误由 clearLocalStateAndRedirect 统一提示,避免重复弹出后端报错。
if (httpStatus === 401 || error.response?.data?.code === ApiStatus.unauthorized) {
return
}
// 如果是404错误且未配置显示404错误则不显示任何提示
if (httpStatus === 404 && !show404Error) {
return
@@ -306,9 +312,13 @@ const api = {
// 仅清理本地状态(不调用接口)- 用于401等情况
const clearLocalStateAndRedirect = () => {
if (isHandlingAuthExpiry) return
isHandlingAuthExpiry = true
ElMessage.error('登录已过期,请重新登录')
setTimeout(() => {
useUserStore().clearLocalState()
isHandlingAuthExpiry = false
}, 1000)
}

View File

@@ -32,15 +32,13 @@
<ElDescriptionsItem label="IMEI">{{
cardInfo?.gateway_card_imei || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商账户">{{
cardInfo?.carrier_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商账户">{{ carrierAccountDisplay }}</ElDescriptionsItem>
<ElDescriptionsItem label="实名状态">
<ElTag :type="getRealNameStatusType(cardInfo?.real_name_status)" size="small">
{{ getRealNameStatusName(cardInfo?.real_name_status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="实名认证策略">
<ElDescriptionsItem v-if="canShowSensitiveFields" label="实名认证策略">
{{ getRealnamePolicyName(cardInfo?.realname_policy) }}
</ElDescriptionsItem>
@@ -82,6 +80,12 @@
<ElDescriptionsItem label="运营商状态" :span="1">
<span class="gateway-extend-text">{{ cardInfo?.gateway_extend || '-' }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="所有套餐已用量">
{{ formatUsageSummaryMb(cardInfo?.total_virtual_used_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="所有套餐剩余量">
{{ formatUsageSummaryMb(cardInfo?.total_virtual_remaining_mb) }}
</ElDescriptionsItem>
<template v-if="cardInfo?.bound_device_id">
<ElDescriptionsItem label="绑定设备号">
@@ -151,18 +155,26 @@
<ElDescriptionsItem label="最大卡槽数">{{
cardInfo?.max_sim_slots || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="实名认证策略">
<ElDescriptionsItem v-if="canShowSensitiveFields" label="实名认证策略">
{{ getRealnamePolicyName(cardInfo?.realname_policy) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{ cardInfo?.device_type || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{ cardInfo?.manufacturer || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem v-if="canShowSensitiveFields" label="制造商">{{
cardInfo?.manufacturer || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最后在线时间">{{
formatDateTime(cardInfo?.last_online_time) || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="SN">{{ cardInfo?.sn || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="所有套餐已用量">
{{ formatUsageSummaryMb(cardInfo?.total_virtual_used_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="所有套餐剩余量">
{{ formatUsageSummaryMb(cardInfo?.total_virtual_remaining_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="最后同步时间">{{
formatDateTime(deviceRealtime?.last_sync_time) || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="SN">{{ cardInfo?.sn || '-' }}</ElDescriptionsItem>
</template>
</ElDescriptions>
@@ -184,7 +196,7 @@
<el-icon
class="copy-icon"
@click="handleCopyICCID(scope.row.iccid)"
style=" color: var(--el-color-primary);cursor: pointer"
style="color: var(--el-color-primary); cursor: pointer"
>
<CopyDocument />
</el-icon>
@@ -239,20 +251,22 @@
type="success"
size="small"
link
:disabled="isBindingStartDisabled(scope.row)"
@click="handleEnableBindingCard(scope.row)"
v-permission="'iot_info:start'"
>
启用此卡
{{ getBindingStartButtonText(scope.row) }}
</ElButton>
<ElButton
v-if="scope.row.network_status === 1"
type="warning"
size="small"
link
:disabled="isBindingStopDisabled(scope.row)"
@click="handleDisableBindingCard(scope.row)"
v-permission="'iot_info:stop'"
>
停用此卡
{{ getBindingStopButtonText(scope.row) }}
</ElButton>
<ElButton
type="primary"
@@ -397,17 +411,19 @@
v-if="cardInfo?.network_status === 0"
v-permission="'iot_info:start'"
type="success"
:disabled="startDisabled"
@click="handleEnableCard"
>
启用此卡
{{ startRemainingText ? `启用此卡(${startRemainingText})` : '启用此卡' }}
</ElButton>
<ElButton
v-if="cardInfo?.network_status === 1"
v-permission="'iot_info:stop'"
type="warning"
:disabled="stopDisabled"
@click="handleDisableCard"
>
停用此卡
{{ stopRemainingText ? `停用此卡(${stopRemainingText})` : '停用此卡' }}
</ElButton>
<!--<ElButton type="danger" v-permission="'iot_info:handler_stop'" @click="handleManualDeactivate"> 手动停用 </ElButton>-->
<!-- 手动停用暂时不开放 -->
@@ -508,6 +524,7 @@
iccid?: string
imei?: string
msisdn?: string
carrier_type?: string
carrier_name?: string
real_name_status?: number
activation_status_name?: string
@@ -528,6 +545,8 @@
supplier?: string
gateway_card_imei?: string
gateway_extend?: string
total_virtual_used_mb?: number | null
total_virtual_remaining_mb?: number | null
last_sync_time?: string | null
last_data_check_at?: string | null
last_real_name_check_at?: string | null
@@ -580,10 +599,22 @@
deviceRealtime: DeviceRealtimeInfo | null
pollingEnabled: boolean
realtimeLoading?: boolean
startDisabled?: boolean
stopDisabled?: boolean
startRemainingText?: string
stopRemainingText?: string
getStartRemainingText?: (identifier: string) => string
getStopRemainingText?: (identifier: string) => string
isStartDisabled?: (identifier: string) => boolean
isStopDisabled?: (identifier: string) => boolean
}
const props = withDefaults(defineProps<Props>(), {
realtimeLoading: false
realtimeLoading: false,
startDisabled: false,
stopDisabled: false,
startRemainingText: '',
stopRemainingText: ''
})
const { hasAuth } = useAuth()
@@ -613,6 +644,22 @@
return ![3, 4].includes(userType) && hasAuth(CARD_MONTH_USAGE_PERMISSION)
})
const isAgentOrEnterpriseAccount = computed(() => {
const userType = Number(userStore.info.user_type ?? 0)
return [3, 4].includes(userType)
})
const canShowSensitiveFields = computed(() => {
return !isAgentOrEnterpriseAccount.value
})
const carrierAccountDisplay = computed(() => {
if (isAgentOrEnterpriseAccount.value) {
return getCarrierTypeName(props.cardInfo?.carrier_type)
}
return props.cardInfo?.carrier_name || '-'
})
const descriptionsColumn = computed(() => {
if (width.value <= 640) return 1
if (width.value <= 1024) return 2
@@ -649,6 +696,7 @@
getRealNameStatusName,
getRealNameStatusType,
getRealnamePolicyName,
getCarrierTypeName,
getAssetStatusName,
getAssetStatusType,
getOnlineStatusName,
@@ -694,6 +742,11 @@
props.cardInfo?.last_card_status_check_at !== undefined
)
const formatUsageSummaryMb = (value?: number | null) => {
if (value === undefined || value === null) return '-'
return formatDataSize(value)
}
// 处理轮询开关变化
const handlePollingChange = (value: string | number | boolean) => {
emit('update:pollingEnabled', Boolean(value))
@@ -717,17 +770,15 @@
// 卡操作
const handleEnableCard = () => {
if (props.startDisabled) return
emit('enableCard')
}
const handleDisableCard = () => {
if (props.stopDisabled) return
emit('disableCard')
}
const handleManualDeactivate = () => {
emit('manualDeactivate')
}
// 设备操作
const handleRebootDevice = () => {
emit('rebootDevice')
@@ -758,7 +809,28 @@
}
// 绑定卡操作
const getBindingStartRemainingText = (card: BindingCard) =>
props.getStartRemainingText?.(card.iccid) || ''
const getBindingStopRemainingText = (card: BindingCard) =>
props.getStopRemainingText?.(card.iccid) || ''
const isBindingStartDisabled = (card: BindingCard) => props.isStartDisabled?.(card.iccid) || false
const isBindingStopDisabled = (card: BindingCard) => props.isStopDisabled?.(card.iccid) || false
const getBindingStartButtonText = (card: BindingCard) => {
const remainingText = getBindingStartRemainingText(card)
return remainingText ? `启用此卡(${remainingText})` : '启用此卡'
}
const getBindingStopButtonText = (card: BindingCard) => {
const remainingText = getBindingStopRemainingText(card)
return remainingText ? `停用此卡(${remainingText})` : '停用此卡'
}
const handleEnableBindingCard = (card: BindingCard) => {
if (isBindingStartDisabled(card)) return
ElMessageBox.confirm(`确定要启用此卡(${card.iccid})吗?`, '启用确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@@ -771,6 +843,7 @@
}
const handleDisableBindingCard = (card: BindingCard) => {
if (isBindingStopDisabled(card)) return
ElMessageBox.confirm(`确定要停用此卡(${card.iccid})吗?`, '停用确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',

View File

@@ -2,6 +2,9 @@
* 资产信息格式化工具函数
*/
import { getCarrierTypeLabel } from '@/config/constants/carrierTypes'
import type { CarrierType } from '@/types/api'
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
export function useAssetFormatters() {
@@ -76,6 +79,13 @@ export function useAssetFormatters() {
return policyMap[policy] || '未知'
}
// 获取运营商类型名称
const getCarrierTypeName = (carrierType?: string | null) => {
const normalizedCarrierType = carrierType?.trim()
if (!normalizedCarrierType) return '-'
return getCarrierTypeLabel(normalizedCarrierType as CarrierType)
}
// 获取资产状态名称
const getAssetStatusName = (status?: number) => {
if (status === undefined || status === null) return '未知'
@@ -252,6 +262,7 @@ export function useAssetFormatters() {
getRealNameStatusName,
getRealNameStatusType,
getRealnamePolicyName,
getCarrierTypeName,
getAssetStatusName,
getAssetStatusType,
getOnlineStatusName,

View File

@@ -92,7 +92,7 @@ export function useAssetInfo() {
const fetchAssetDetail = async (identifier: string) => {
try {
loading.value = true
const response = await AssetService.resolveAsset(identifier)
const response = await AssetService.resolveAsset(identifier, { include_usage_summary: true })
if (response.code === 0 && response.data) {
const data = response.data
@@ -136,6 +136,8 @@ export function useAssetInfo() {
gateway_card_imei: resolvedGatewayCardImei,
resolved_gateway_card_imei: resolvedGatewayCardImei,
gateway_extend: data.gateway_extend ?? '',
total_virtual_used_mb: data.total_virtual_used_mb ?? null,
total_virtual_remaining_mb: data.total_virtual_remaining_mb ?? null,
bound_device_id: data.bound_device_id,
bound_device_no: data.bound_device_no || '',
bound_device_name: data.bound_device_name || '',

View File

@@ -69,6 +69,10 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
}
} catch (error: any) {
if (error !== 'cancel') {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
return
}
console.error('停用失败:', error)
}
} finally {

View File

@@ -20,6 +20,14 @@
:device-realtime="deviceRealtime"
v-model:polling-enabled="pollingEnabled"
:realtime-loading="realtimeLoading"
:start-disabled="startDisabled"
:stop-disabled="stopDisabled"
:start-remaining-text="startRemainingText"
:stop-remaining-text="stopRemainingText"
:get-start-remaining-text="getStartRemainingText"
:get-stop-remaining-text="getStopRemainingText"
:is-start-disabled="isStartActionDisabled"
:is-stop-disabled="isStopActionDisabled"
@enable-card="handleEnableCard"
@disable-card="handleDisableCard"
@manual-deactivate="handleManualDeactivate"
@@ -176,7 +184,8 @@
import {
formatRemainingTime,
FrontendRateLimitError,
getAssetActionRateLimitState
getAssetActionRateLimitState,
type AssetRateLimitedAction
} from '@/utils/business/apiRateLimit'
// 引入子组件
@@ -248,6 +257,8 @@
// ========== 资产操作方法(使用 composable ==========
const {
enableCardLoading,
disableCardLoading,
enableCard: enableCardOp,
disableCard: disableCardOp,
manualDeactivateCard: manualDeactivateCardOp,
@@ -342,6 +353,59 @@
: ''
)
const startRateLimitState = computed(() => {
const identifier = cardInfo.value?.iccid || cardInfo.value?.identifier
if (!identifier) {
return {
limited: false,
remainingMs: 0,
retryAt: 0
}
}
return getAssetActionRateLimitState('start', identifier, rateLimitNow.value)
})
const stopRateLimitState = computed(() => {
const identifier = cardInfo.value?.iccid || cardInfo.value?.identifier
if (!identifier) {
return {
limited: false,
remainingMs: 0,
retryAt: 0
}
}
return getAssetActionRateLimitState('stop', identifier, rateLimitNow.value)
})
const startDisabled = computed(() => enableCardLoading.value || startRateLimitState.value.limited)
const stopDisabled = computed(() => disableCardLoading.value || stopRateLimitState.value.limited)
const startRemainingText = computed(() =>
startRateLimitState.value.limited
? formatRemainingTime(startRateLimitState.value.remainingMs)
: ''
)
const stopRemainingText = computed(() =>
stopRateLimitState.value.limited
? formatRemainingTime(stopRateLimitState.value.remainingMs)
: ''
)
const getActionRemainingText = (action: AssetRateLimitedAction, identifier?: string) => {
if (!identifier) return ''
const state = getAssetActionRateLimitState(action, identifier, rateLimitNow.value)
return state.limited ? formatRemainingTime(state.remainingMs) : ''
}
const isActionDisabled = (action: AssetRateLimitedAction, identifier?: string) => {
if (!identifier) return false
return getAssetActionRateLimitState(action, identifier, rateLimitNow.value).limited
}
const getStartRemainingText = (identifier: string) => getActionRemainingText('start', identifier)
const getStopRemainingText = (identifier: string) => getActionRemainingText('stop', identifier)
const isStartActionDisabled = (identifier: string) => isActionDisabled('start', identifier)
const isStopActionDisabled = (identifier: string) => isActionDisabled('stop', identifier)
const updateRateLimitNow = () => {
rateLimitNow.value = Date.now()
}
@@ -399,14 +463,24 @@
* IoT卡操作 - 启用卡
*/
const handleEnableCard = async () => {
await enableCardOp()
if (startDisabled.value) return
try {
await enableCardOp()
} finally {
updateRateLimitNow()
}
}
/**
* IoT卡操作 - 停用卡
*/
const handleDisableCard = async () => {
await disableCardOp()
if (stopDisabled.value) return
try {
await disableCardOp()
} finally {
updateRateLimitNow()
}
}
/**
@@ -550,6 +624,7 @@
*/
const handleEnableBindingCard = async (payload: { card: any }) => {
const { card } = payload
if (isStartActionDisabled(card.iccid)) return
try {
const { AssetService } = await import('@/api/modules')
await AssetService.startAsset(card.iccid)
@@ -562,6 +637,8 @@
return
}
console.error('启用绑定卡失败:', error)
} finally {
updateRateLimitNow()
}
}
@@ -570,6 +647,7 @@
*/
const handleDisableBindingCard = async (payload: { card: any }) => {
const { card } = payload
if (isStopActionDisabled(card.iccid)) return
try {
const { AssetService } = await import('@/api/modules')
await AssetService.stopAsset(card.iccid)
@@ -577,7 +655,13 @@
if (cardInfo.value)
await loadRealtimeStatus(cardInfo.value.identifier, cardInfo.value.asset_type)
} catch (error: any) {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
return
}
console.error('停用绑定卡失败:', error)
} finally {
updateRateLimitNow()
}
}

View File

@@ -14,6 +14,7 @@ export interface AssetInfo {
iccid?: string
imei?: string
msisdn?: string
carrier_type?: string
carrier_name?: string
real_name_status?: number
activation_status_name?: string
@@ -30,6 +31,8 @@ export interface AssetInfo {
status?: number
gateway_card_imei?: string
gateway_extend?: string
total_virtual_used_mb?: number | null
total_virtual_remaining_mb?: number | null
last_sync_time?: string | null
last_data_check_at?: string | null
last_real_name_check_at?: string | null

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportDeviceTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportIotCardTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportOrderTaskList' })
</script>

View File

@@ -10,23 +10,11 @@
返回
</ElButton>
<h2 class="detail-title">导出任务详情</h2>
<div class="detail-actions" v-if="taskDetail">
<ElButton
v-if="
taskDetail.status === ExportTaskStatus.COMPLETED && hasAuth('export_task:download')
"
type="primary"
@click="downloadTask"
>
<div class="detail-actions" v-if="taskDetail && canViewDetail">
<ElButton v-if="canDownloadTask" type="primary" @click="downloadTask">
下载文件
</ElButton>
<ElButton
v-if="canCancelTask && hasAuth('export_task:cancel')"
type="danger"
@click="cancelTask"
>
取消任务
</ElButton>
<ElButton v-if="canCancelTask" type="danger" @click="cancelTask"> 取消任务 </ElButton>
</div>
</div>
@@ -36,8 +24,12 @@
</ElIcon>
<div>加载中...</div>
</div>
<DetailPage v-else-if="taskDetail" :sections="detailSections" :data="taskDetail" />
<ElEmpty v-else description="暂无详情" />
<DetailPage
v-else-if="taskDetail && canViewDetail"
:sections="detailSections"
:data="taskDetail"
/>
<ElEmpty v-else :description="forbidden ? '暂无权限查看该导出任务详情' : '暂无详情'" />
</ElCard>
</div>
</ArtTableFullScreen>
@@ -52,8 +44,9 @@
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { getExportTaskSceneConfig, getExportTaskSceneName } from '@/config/constants'
import { formatDateTime } from '@/utils/business/format'
import type { ExportTaskDetail, ExportTaskScene } from '@/types/api'
import type { ExportTaskDetail } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
defineOptions({ name: 'ExportTaskDetail' })
@@ -62,17 +55,22 @@
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const forbidden = ref(false)
const taskDetail = ref<ExportTaskDetail | null>(null)
const getSceneName = (scene?: ExportTaskScene) => {
const sceneMap: Record<ExportTaskScene, string> = {
device: '设备管理',
iot_card: 'IoT卡管理',
order: '订单管理'
}
return scene ? sceneMap[scene] : '-'
}
const taskPermissions = computed(
() => getExportTaskSceneConfig(taskDetail.value?.scene)?.permissions
)
const canViewDetail = computed(
() => !!taskPermissions.value && hasAuth(taskPermissions.value.detail)
)
const canDownloadTask = computed(
() =>
canViewDetail.value &&
taskDetail.value?.status === ExportTaskStatus.COMPLETED &&
!!taskPermissions.value &&
hasAuth(taskPermissions.value.download)
)
const getStatusTagType = (status?: ExportTaskStatus) => {
const statusTypeMap: Record<
@@ -89,10 +87,14 @@
return status ? statusTypeMap[status] : 'info'
}
const canCancelTask = computed(() =>
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
taskDetail.value?.status as ExportTaskStatus
)
const canCancelTask = computed(
() =>
canViewDetail.value &&
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
taskDetail.value?.status as ExportTaskStatus
) &&
!!taskPermissions.value &&
hasAuth(taskPermissions.value.cancel)
)
const detailSections = computed((): DetailSection[] => [
@@ -102,7 +104,7 @@
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{
label: '导出场景',
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getSceneName(data.scene))
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getExportTaskSceneName(data.scene))
},
{
label: '任务状态',
@@ -174,12 +176,13 @@
fullWidth: true,
render: (data: ExportTaskDetail) => {
if (!data.download_url) return h('span', '-')
if (!canDownloadTask.value) return h('span', '-')
return h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => window.open(data.download_url, '_blank')
onClick: () => downloadTask()
},
() => '打开下载地址'
)
@@ -209,10 +212,15 @@
}
loading.value = true
forbidden.value = false
try {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 0) {
taskDetail.value = res.data
if (!canViewDetail.value) {
forbidden.value = true
ElMessage.warning('您没有查看该导出任务详情的权限')
}
} else {
ElMessage.error(res.msg || '获取导出任务详情失败')
}
@@ -225,6 +233,11 @@
}
const downloadTask = () => {
if (!canDownloadTask.value) {
ElMessage.warning('您没有下载该导出任务的权限')
return
}
if (!taskDetail.value?.download_url) {
ElMessage.warning('当前任务暂无可用下载地址')
return
@@ -235,6 +248,10 @@
const cancelTask = () => {
if (!taskDetail.value) return
if (!canCancelTask.value) {
ElMessage.warning('您没有取消该导出任务的权限')
return
}
ElMessageBox.confirm(`确定取消导出任务 ${taskDetail.value.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',

View File

@@ -5,6 +5,7 @@
v-model:filter="searchForm"
:items="searchFormItems"
label-width="110"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
@@ -38,27 +39,36 @@
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElProgress, ElTag } from 'element-plus'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import { getExportTaskSceneConfig } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type { ExportTaskItem, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
defineOptions({ name: 'ExportTaskList' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const taskList = ref<ExportTaskItem[]>([])
const defaultSceneConfig = getExportTaskSceneConfig('device')!
const routeScene = computed(() => route.meta.exportTaskScene as ExportTaskScene | undefined)
const sceneConfig = computed(
() => getExportTaskSceneConfig(routeScene.value) || defaultSceneConfig
)
const currentScene = computed(() => sceneConfig.value.scene)
const currentPermissions = computed(() => sceneConfig.value.permissions)
const initialSearchState = {
scene: undefined as ExportTaskScene | undefined,
status: undefined as ExportTaskStatus | undefined,
dateRange: [] as string[],
start_time: '',
@@ -72,12 +82,6 @@
total: 0
})
const sceneOptions = [
{ label: '设备管理', value: 'device' },
{ label: 'IoT卡管理', value: 'iot_card' },
{ label: '订单管理', value: 'order' }
]
const statusOptions = [
{ label: '待处理', value: ExportTaskStatus.PENDING },
{ label: '处理中', value: ExportTaskStatus.PROCESSING },
@@ -87,13 +91,6 @@
]
const searchFormItems: SearchFormItem[] = [
{
label: '导出场景',
prop: 'scene',
type: 'select',
config: { clearable: true, placeholder: '请选择导出场景' },
options: () => sceneOptions
},
{
label: '任务状态',
prop: 'status',
@@ -117,7 +114,6 @@
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '场景', prop: 'scene' },
{ label: '状态', prop: 'status_name' },
{ label: '进度', prop: 'progress' },
{ label: '格式', prop: 'format' },
@@ -144,12 +140,6 @@
row.task_no || '-'
)
},
{
prop: 'scene',
label: '场景',
width: 120,
formatter: (row: ExportTaskItem) => getSceneName(row.scene)
},
{
prop: 'status_name',
label: '状态',
@@ -181,11 +171,6 @@
}
])
const getSceneName = (scene?: ExportTaskScene) => {
const option = sceneOptions.find((item) => item.value === scene)
return option?.label || '-'
}
const getStatusTagType = (status: ExportTaskStatus) => {
const statusTypeMap: Record<
ExportTaskStatus,
@@ -218,7 +203,7 @@
const res = await ExportTaskService.getExportTasks({
page: pagination.page,
page_size: pagination.pageSize,
scene: searchForm.scene,
scene: currentScene.value,
status: searchForm.status,
start_time: searchForm.start_time || undefined,
end_time: searchForm.end_time || undefined
@@ -272,8 +257,12 @@
return res.data
}
const getRowPermissions = (row: ExportTaskItem) => {
return getExportTaskSceneConfig(row.scene)?.permissions || currentPermissions.value
}
const goDetail = (row: ExportTaskItem) => {
if (!hasAuth('export_task:detail')) {
if (!hasAuth(getRowPermissions(row).detail)) {
ElMessage.warning('您没有查看导出任务详情的权限')
return
}
@@ -285,6 +274,11 @@
}
const downloadTask = async (row: ExportTaskItem) => {
if (!hasAuth(getRowPermissions(row).download)) {
ElMessage.warning('您没有下载导出任务的权限')
return
}
try {
const detail = await getTaskDetail(row.id)
if (!detail.download_url) {
@@ -300,6 +294,11 @@
}
const cancelTask = (row: ExportTaskItem) => {
if (!hasAuth(getRowPermissions(row).cancel)) {
ElMessage.warning('您没有取消导出任务的权限')
return
}
ElMessageBox.confirm(`确定取消导出任务 ${row.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@@ -322,14 +321,15 @@
const getActions = (row: ExportTaskItem) => {
const actions: any[] = []
const permissions = getRowPermissions(row)
if (row.status === ExportTaskStatus.COMPLETED && hasAuth('export_task:download')) {
if (row.status === ExportTaskStatus.COMPLETED && hasAuth(permissions.download)) {
actions.push({ label: '下载', handler: () => downloadTask(row), type: 'primary' })
}
if (
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(row.status) &&
hasAuth('export_task:cancel')
hasAuth(permissions.cancel)
) {
actions.push({ label: '取消', handler: () => cancelTask(row), type: 'danger' })
}
@@ -340,6 +340,12 @@
onMounted(() => {
getTableData()
})
watch(currentScene, () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
})
</script>
<style scoped lang="scss">

View File

@@ -991,12 +991,13 @@
:identifier="operationLogsIdentifier"
download-permission="iot_card:download_log_file"
/>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="iot_card"
:query="exportQuery"
confirm-permission="iot_card:export"
title="导出IoT卡"
title="导出IOT卡"
/>
</ElCard>
</div>
@@ -2920,6 +2921,10 @@
await getTableData()
}
} catch (error: any) {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
return
}
console.error('停用此卡失败:', error)
}
})

View File

@@ -4,7 +4,7 @@
<CommissionSummary v-if="hasPermission('dashboard_analysis:commission_summary')" />
<CommissionStats v-if="hasPermission('dashboard_analysis:commission_stats')" />
<WithdrawalSettings v-if="hasPermission('dashboard_analysis:withdrawal_settings')" />
<ActiveWechatConfig v-if="hasPermission('dashboard_analysis:wechat_config')" />
<ActivePaymentSettings v-if="hasPermission('dashboard_analysis:payment_settings')" />
<!--<el-row :gutter="20">-->
<!-- <el-col :xl="14" :lg="15" :xs="24">-->
@@ -46,7 +46,7 @@
import CommissionSummary from './widget/CommissionSummary.vue'
import CommissionStats from './widget/CommissionStats.vue'
import WithdrawalSettings from './widget/WithdrawalSettings.vue'
import ActiveWechatConfig from './widget/ActiveWechatConfig.vue'
import ActivePaymentSettings from './widget/ActivePaymentSettings.vue'
import { usePermission } from '@/composables/usePermission'
defineOptions({ name: 'Analysis' })

View File

@@ -1,5 +1,5 @@
<template>
<ElCard shadow="never" class="active-wechat-config-widget">
<ElCard shadow="never" class="active-payment-settings-widget">
<template #header>
<div class="card-header">
<div class="header-left">
@@ -94,9 +94,9 @@
</template>
<script setup lang="ts">
import { WechatConfigService } from '@/api/modules'
import { PaymentSettingsService } from '@/api/modules'
import { ElTag } from 'element-plus'
import type { WechatConfig } from '@/types/api'
import type { PaymentSettings } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import {
getPaymentConfigStatus,
@@ -106,26 +106,26 @@
} from '@/utils/business/paymentConfig'
import { usePermission } from '@/composables/usePermission'
defineOptions({ name: 'ActiveWechatConfigWidget' })
defineOptions({ name: 'ActivePaymentSettingsWidget' })
const { hasPermission } = usePermission()
//
const activeConfig = ref<WechatConfig | null>(null)
const activeConfig = ref<PaymentSettings | null>(null)
const getProviderTypeText = (type: WechatConfig['provider_type']): string => {
const getProviderTypeText = (type: PaymentSettings['provider_type']): string => {
return getPaymentProviderText(type)
}
const getMerchantId = (config: WechatConfig): string => {
const getMerchantId = (config: PaymentSettings): string => {
return getPaymentMerchantId(config)
}
const getNotifyUrl = (config: WechatConfig): string => {
const getNotifyUrl = (config: PaymentSettings): string => {
return getPaymentNotifyUrl(config)
}
const getCredentialStatus = (config: WechatConfig): string => {
const getCredentialStatus = (config: PaymentSettings): string => {
if (config.provider_type === 'wechat' || config.provider_type === 'wechat_v2') {
const certStatus = getPaymentConfigStatus(config.wx_cert_content)
const keyStatus = getPaymentConfigStatus(config.wx_key_content)
@@ -156,12 +156,12 @@
//
const loadActiveConfig = async () => {
//
if (!hasPermission('dashboard_analysis:wechat_config')) {
if (!hasPermission('dashboard_analysis:payment_settings')) {
return
}
try {
const res = await WechatConfigService.getActiveWechatConfig()
const res = await PaymentSettingsService.getActivePaymentSettings()
if (res.code === 0 && res.data) {
activeConfig.value = res.data
}
@@ -176,7 +176,7 @@
</script>
<style lang="scss" scoped>
.active-wechat-config-widget {
.active-payment-settings-widget {
:deep(.el-card__header) {
padding: 18px 20px;
background: var(--el-fill-color-light);
@@ -276,7 +276,7 @@
}
@media (width <= 768px) {
.active-wechat-config-widget {
.active-payment-settings-widget {
.card-header {
.header-left,
.header-right {

View File

@@ -0,0 +1,56 @@
import { AgentRechargeStatus, type AgentRecharge } from '@/types/api'
import { hasVoucherKeys } from '@/utils/business'
export interface AgentRechargeAction {
label: string
handler: () => void
type: 'primary' | 'danger'
}
interface BuildAgentRechargeActionsOptions {
hasAuth: (permission: string) => boolean
onViewPaymentVoucher: (row: AgentRecharge) => void
onConfirmPayment: (row: AgentRecharge) => void
onReject: (row: AgentRecharge) => void
}
export const buildAgentRechargeActions = (
row: AgentRecharge,
options: BuildAgentRechargeActionsOptions
): AgentRechargeAction[] => {
const actions: AgentRechargeAction[] = []
if (
row.payment_method === 'offline' &&
hasVoucherKeys(row.payment_voucher_key) &&
options.hasAuth('agent_recharge:view_payment_voucher')
) {
actions.push({
label: '查看支付凭证',
handler: () => options.onViewPaymentVoucher(row),
type: 'primary'
})
}
if (
row.status === AgentRechargeStatus.PENDING &&
row.payment_method === 'offline' &&
options.hasAuth('agent_recharge:confirm_payment')
) {
actions.push({
label: '确认支付',
handler: () => options.onConfirmPayment(row),
type: 'primary'
})
}
if (row.status === AgentRechargeStatus.PENDING && options.hasAuth('agent_recharge:reject')) {
actions.push({
label: '拒绝',
handler: () => options.onReject(row),
type: 'danger'
})
}
return actions
}

View File

@@ -0,0 +1,3 @@
export const formatRejectionReason = (reason?: string | null): string => {
return reason || '-'
}

View File

@@ -41,6 +41,7 @@
import { formatDateTime } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import { formatRejectionReason } from './agentRechargeDisplay'
defineOptions({ name: 'AgentRechargeDetail' })
@@ -111,6 +112,12 @@
h(ElTag, { type: getStatusType(data.status) }, () =>
getStatusText(data.status, data.status_name)
)
},
{
label: '驳回原因',
prop: 'rejection_reason',
formatter: (value) => formatRejectionReason(value),
fullWidth: true
}
]
},

View File

@@ -167,6 +167,38 @@
</template>
</ElDialog>
<!-- 拒绝充值订单对话框 -->
<ElDialog
v-model="rejectDialogVisible"
title="拒绝充值订单"
width="500px"
@closed="handleRejectDialogClosed"
>
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="100px">
<ElFormItem label="充值单号">
<span>{{ currentRecharge?.recharge_no }}</span>
</ElFormItem>
<ElFormItem label="拒绝原因" prop="rejection_reason">
<ElInput
v-model="rejectForm.rejection_reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="rejectDialogVisible = false">取消</ElButton>
<ElButton type="danger" @click="handleRejectRecharge" :loading="rejectLoading">
确认拒绝
</ElButton>
</div>
</template>
</ElDialog>
<!-- 支付凭证预览 -->
<PaymentVoucherDialog
:file-keys="paymentVoucherFileKeys"
@@ -198,7 +230,8 @@
AgentRechargeStatus,
AgentRechargePaymentMethod,
CreateAgentRechargeRequest,
ConfirmOfflinePaymentRequest
ConfirmOfflinePaymentRequest,
RejectAgentRechargeRequest
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -207,6 +240,8 @@
import { useAuth } from '@/composables/useAuth'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { buildAgentRechargeActions } from './agentRechargeActions'
import { formatRejectionReason } from './agentRechargeDisplay'
defineOptions({ name: 'AgentRechargeList' })
@@ -217,9 +252,11 @@
const createLoading = ref(false)
const voucherUploading = ref(false)
const confirmPayLoading = ref(false)
const rejectLoading = ref(false)
const tableRef = ref()
const createDialogVisible = ref(false)
const confirmPayDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const currentRecharge = ref<AgentRecharge | null>(null)
const paymentVoucherFileKeys = ref<string[]>([])
@@ -330,6 +367,7 @@
{ label: '状态', prop: 'status' },
{ label: '支付方式', prop: 'payment_method' },
{ label: '支付通道', prop: 'payment_channel' },
{ label: '驳回原因', prop: 'rejection_reason' },
{ label: '创建时间', prop: 'created_at' },
{ label: '支付时间', prop: 'paid_at' },
{ label: '完成时间', prop: 'completed_at' }
@@ -337,6 +375,7 @@
const createFormRef = ref<FormInstance>()
const confirmPayFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const MIN_RECHARGE_AMOUNT = 0.01
@@ -374,6 +413,10 @@
]
})
const rejectRules = reactive<FormRules>({
rejection_reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const createForm = reactive<{
amount: number
payment_method: string
@@ -392,6 +435,10 @@
operation_password: ''
})
const rejectForm = reactive<RejectAgentRechargeRequest>({
rejection_reason: ''
})
const rechargeList = ref<AgentRecharge[]>([])
// 格式化货币 - 将分转换为元
@@ -496,6 +543,13 @@
showOverflowTooltip: true,
formatter: (row: AgentRecharge) => row.remark || '-'
},
{
prop: 'rejection_reason',
label: '驳回原因',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: AgentRecharge) => formatRejectionReason(row.rejection_reason)
},
{
prop: 'created_at',
label: '创建时间',
@@ -735,6 +789,44 @@
})
}
// 显示拒绝对话框
const handleShowReject = (row: AgentRecharge) => {
currentRecharge.value = row
rejectDialogVisible.value = true
}
// 拒绝对话框关闭后的清理
const handleRejectDialogClosed = () => {
rejectFormRef.value?.resetFields()
rejectForm.rejection_reason = ''
currentRecharge.value = null
}
// 拒绝代理充值订单
const handleRejectRecharge = async () => {
const formRef = rejectFormRef.value
const recharge = currentRecharge.value
if (!formRef || !recharge) return
await formRef.validate(async (valid) => {
if (valid) {
rejectLoading.value = true
try {
await AgentRechargeService.rejectAgentRecharge(recharge.id, {
rejection_reason: rejectForm.rejection_reason
})
ElMessage.success('拒绝成功')
rejectDialogVisible.value = false
await getTableData()
} catch (error) {
console.error(error)
} finally {
rejectLoading.value = false
}
}
})
}
// 处理名称点击
const handleNameClick = (row: AgentRecharge) => {
if (hasAuth('agent_recharge:detail_page')) {
@@ -754,35 +846,12 @@
// 获取操作按钮
const getActions = (row: AgentRecharge) => {
const actions: any[] = []
// 线下支付且有凭证可以查看
if (
row.payment_method === 'offline' &&
hasVoucherKeys(row.payment_voucher_key) &&
hasAuth('agent_recharge:view_payment_voucher')
) {
actions.push({
label: '查看支付凭证',
handler: () => handleViewPaymentVoucher(row),
type: 'primary'
})
}
// 待支付且线下转账的订单可以确认支付
if (
row.status === 1 &&
row.payment_method === 'offline' &&
hasAuth('agent_recharge:confirm_payment')
) {
actions.push({
label: '确认支付',
handler: () => handleShowConfirmPay(row),
type: 'primary'
})
}
return actions
return buildAgentRechargeActions(row, {
hasAuth,
onViewPaymentVoucher: handleViewPaymentVoucher,
onConfirmPayment: handleShowConfirmPay,
onReject: handleShowReject
})
}
// 查看支付凭证

View File

@@ -1,6 +1,6 @@
<template>
<ArtTableFullScreen>
<div class="grant-package-list-page">
<div class="grant-package-list-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<div class="page-header">
<div class="header-left">
@@ -27,9 +27,9 @@
:loading="loading"
:data="packageList"
:marginTop="10"
:pagination="false"
:actions="getActions"
:actionsWidth="150"
height="70vh"
:inlineActionsCount="2"
>
<template #default>
@@ -134,7 +134,6 @@
ElCard,
ElButton,
ElIcon,
ElTable,
ElTableColumn,
ElTag,
ElDialog,
@@ -429,8 +428,23 @@
<style scoped lang="scss">
.grant-package-list-page {
height: 100%;
padding: 16px;
:deep(.art-table-card) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
.el-card__body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
}
.page-header {
display: flex;
align-items: center;

View File

@@ -1,5 +1,5 @@
<template>
<div class="wechat-config-detail-page">
<div class="payment-settings-detail-page">
<ElCard shadow="never">
<!-- 页面头部 -->
<div class="detail-header">
@@ -31,18 +31,18 @@
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { WechatConfigService } from '@/api/modules'
import type { WechatConfig } from '@/types/api'
import { PaymentSettingsService } from '@/api/modules'
import type { PaymentSettings } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getPaymentConfigStatus, getPaymentProviderText } from '@/utils/business/paymentConfig'
defineOptions({ name: 'WechatConfigDetail' })
defineOptions({ name: 'PaymentSettingsDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detailData = ref<WechatConfig | null>(null)
const detailData = ref<PaymentSettings | null>(null)
const configId = computed(() => Number(route.params.id))
const pageTitle = computed(() => `支付配置详情 #${configId.value}`)
@@ -176,9 +176,9 @@
]
}
//
//
const wechatPaySection: DetailSection = {
title: '微信配置',
title: '微信支付配置',
fields: [
{
label: '微信商户号',
@@ -289,7 +289,7 @@
const fetchDetail = async () => {
loading.value = true
try {
const res = await WechatConfigService.getWechatConfigById(configId.value)
const res = await PaymentSettingsService.getPaymentSettingsById(configId.value)
if (res.code === 0) {
detailData.value = res.data
}
@@ -306,7 +306,7 @@
</script>
<style scoped lang="scss">
.wechat-config-detail-page {
.payment-settings-detail-page {
padding: 20px;
}

View File

@@ -1,6 +1,6 @@
<template>
<ArtTableFullScreen>
<div class="wechat-config-page" id="table-full-screen">
<div class="payment-settings-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
@@ -22,7 +22,7 @@
<ElButton
type="primary"
@click="showCreateDialog"
v-if="hasAuth('wechat_config:create')"
v-if="hasAuth('payment_settings:create')"
>新增支付配置</ElButton
>
</template>
@@ -226,7 +226,7 @@
<template v-if="form.provider_type === 'wechat' || form.provider_type === 'wechat_v2'">
<ElDivider content-position="left">
<span class="divider-title">微信配置</span>
<span class="divider-title">微信支付配置</span>
</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
@@ -361,15 +361,15 @@
<script setup lang="ts">
import { h, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { WechatConfigService } from '@/api/modules'
import { PaymentSettingsService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElSwitch, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
CreateWechatConfigRequest,
CreatePaymentSettingsRequest,
PaymentProviderType,
UpdateWechatConfigRequest,
WechatConfig,
WechatConfigQueryParams
UpdatePaymentSettingsRequest,
PaymentSettings,
PaymentSettingsQueryParams
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -383,7 +383,7 @@
isMaskedPaymentConfigValue
} from '@/utils/business/paymentConfig'
defineOptions({ name: 'WechatConfigList' })
defineOptions({ name: 'PaymentSettingsList' })
type PaymentConfigFormModel = {
id: number
@@ -455,7 +455,7 @@
const { hasAuth } = useAuth()
const router = useRouter()
const canUpdateWechatConfigStatus = hasAuth('wechat_config:status')
const canUpdatePaymentSettingsStatus = hasAuth('payment_settings:status')
const loading = ref(false)
const submitLoading = ref(false)
@@ -463,7 +463,7 @@
const dialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const configList = ref<WechatConfig[]>([])
const configList = ref<PaymentSettings[]>([])
const originalFormState = ref<PaymentConfigFormModel | null>(null)
const createInitialFormState = (): PaymentConfigFormModel => ({
@@ -501,7 +501,7 @@
fy_public_key: ''
})
const mapConfigToForm = (detail: WechatConfig): PaymentConfigFormModel => ({
const mapConfigToForm = (detail: PaymentSettings): PaymentConfigFormModel => ({
...createInitialFormState(),
...detail,
description: detail.description || '',
@@ -666,7 +666,7 @@
label: '配置名称',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: WechatConfig) => {
formatter: (row: PaymentSettings) => {
return h(
'span',
{
@@ -685,20 +685,20 @@
label: '配置描述',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: WechatConfig) => row.description || '-'
formatter: (row: PaymentSettings) => row.description || '-'
},
{
prop: 'provider_type',
label: '支付渠道类型',
width: 130,
formatter: (row: WechatConfig) => getPaymentProviderText(row.provider_type)
formatter: (row: PaymentSettings) => getPaymentProviderText(row.provider_type)
},
{
prop: 'is_active',
label: '激活状态',
width: 110,
formatter: (row: WechatConfig) => {
if (canUpdateWechatConfigStatus) {
formatter: (row: PaymentSettings) => {
if (canUpdatePaymentSettingsStatus) {
return h(ElSwitch, {
modelValue: row.is_active,
activeText: '已激活',
@@ -718,47 +718,47 @@
label: '商户号',
width: 150,
showOverflowTooltip: true,
formatter: (row: WechatConfig) => getPaymentMerchantId(row)
formatter: (row: PaymentSettings) => getPaymentMerchantId(row)
},
{
prop: 'miniapp_app_id',
label: '小程序AppID',
width: 160,
showOverflowTooltip: true,
formatter: (row: WechatConfig) => row.miniapp_app_id || '-'
formatter: (row: PaymentSettings) => row.miniapp_app_id || '-'
},
{
prop: 'oa_app_id',
label: '公众号AppID',
width: 160,
showOverflowTooltip: true,
formatter: (row: WechatConfig) => row.oa_app_id || '-'
formatter: (row: PaymentSettings) => row.oa_app_id || '-'
},
{
prop: 'ali_app_id',
label: '支付宝AppID',
width: 160,
showOverflowTooltip: true,
formatter: (row: WechatConfig) => row.ali_app_id || '-'
formatter: (row: PaymentSettings) => row.ali_app_id || '-'
},
{
prop: 'notify_url',
label: '支付回调地址',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: WechatConfig) => getPaymentNotifyUrl(row)
formatter: (row: PaymentSettings) => getPaymentNotifyUrl(row)
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: WechatConfig) => formatDateTime(row.created_at)
formatter: (row: PaymentSettings) => formatDateTime(row.created_at)
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: WechatConfig) => formatDateTime(row.updated_at)
formatter: (row: PaymentSettings) => formatDateTime(row.updated_at)
}
])
@@ -770,14 +770,14 @@
const getTableData = async () => {
loading.value = true
try {
const params: WechatConfigQueryParams = {
const params: PaymentSettingsQueryParams = {
page: pagination.page,
page_size: pagination.page_size,
provider_type: searchForm.provider_type,
is_active: searchForm.is_active !== undefined ? searchForm.is_active === 1 : undefined
}
const res = await WechatConfigService.getWechatConfigs(params)
const res = await PaymentSettingsService.getPaymentSettings(params)
if (res.code === 0) {
configList.value = res.data.items || []
pagination.page = res.data.page || pagination.page
@@ -825,9 +825,9 @@
})
}
const showEditDialog = async (row: WechatConfig) => {
const showEditDialog = async (row: PaymentSettings) => {
try {
const res = await WechatConfigService.getWechatConfigById(row.id)
const res = await PaymentSettingsService.getPaymentSettingsById(row.id)
if (res.code === 0 && res.data) {
const mappedForm = mapConfigToForm(res.data)
Object.assign(form, mappedForm)
@@ -853,8 +853,8 @@
formRef.value?.clearValidate()
}
const buildCreatePayload = (): CreateWechatConfigRequest => {
const payload: CreateWechatConfigRequest = {
const buildCreatePayload = (): CreatePaymentSettingsRequest => {
const payload: CreatePaymentSettingsRequest = {
name: trimString(form.name),
provider_type: form.provider_type,
ali_pay_expire_minutes: form.ali_pay_expire_minutes,
@@ -878,11 +878,11 @@
return payload
}
const buildUpdatePayload = (): UpdateWechatConfigRequest => {
const buildUpdatePayload = (): UpdatePaymentSettingsRequest => {
const initial = originalFormState.value
if (!initial) return {}
const payload: UpdateWechatConfigRequest = {}
const payload: UpdatePaymentSettingsRequest = {}
const currentName = trimString(form.name)
const initialName = trimString(initial.name)
if (currentName !== initialName) {
@@ -936,7 +936,7 @@
submitLoading.value = true
try {
if (dialogType.value === 'add') {
await WechatConfigService.createWechatConfig(buildCreatePayload())
await PaymentSettingsService.createPaymentSettings(buildCreatePayload())
ElMessage.success('创建成功')
} else {
const payload = buildUpdatePayload()
@@ -945,7 +945,7 @@
return
}
await WechatConfigService.updateWechatConfig(form.id, payload)
await PaymentSettingsService.updatePaymentSettings(form.id, payload)
ElMessage.success('更新成功')
}
@@ -958,8 +958,8 @@
}
}
const handleStatusChange = async (row: WechatConfig, newStatus: string | number | boolean) => {
if (!hasAuth('wechat_config:status')) {
const handleStatusChange = async (row: PaymentSettings, newStatus: string | number | boolean) => {
if (!hasAuth('payment_settings:status')) {
ElMessage.warning('您没有修改激活状态的权限')
return
}
@@ -969,10 +969,10 @@
row.is_active = newBoolStatus
try {
if (newBoolStatus) {
await WechatConfigService.activateWechatConfig(row.id)
await PaymentSettingsService.activatePaymentSettings(row.id)
ElMessage.success('激活成功')
} else {
await WechatConfigService.deactivateWechatConfig(row.id)
await PaymentSettingsService.deactivatePaymentSettings(row.id)
ElMessage.success('停用成功')
}
await getTableData()
@@ -982,7 +982,7 @@
}
}
const handleDelete = async (row: WechatConfig) => {
const handleDelete = async (row: PaymentSettings) => {
try {
await ElMessageBox.confirm(`确定要删除支付配置"${row.name}"吗?`, '删除确认', {
confirmButtonText: '确定',
@@ -990,7 +990,7 @@
type: 'warning'
})
await WechatConfigService.deleteWechatConfig(row.id)
await PaymentSettingsService.deletePaymentSettings(row.id)
ElMessage.success('删除成功')
await getTableData()
} catch (error) {
@@ -1000,24 +1000,24 @@
}
}
const handleViewDetail = (row: WechatConfig) => {
const handleViewDetail = (row: PaymentSettings) => {
router.push({
path: `${RoutesAlias.WechatConfig}/detail/${row.id}`
path: `${RoutesAlias.PaymentSettings}/detail/${row.id}`
})
}
const handleNameClick = (row: WechatConfig) => {
if (hasAuth('wechat_config:detail')) {
const handleNameClick = (row: PaymentSettings) => {
if (hasAuth('payment_settings:detail')) {
handleViewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
const getActions = (row: WechatConfig) => {
const getActions = (row: PaymentSettings) => {
const actions: any[] = []
if (hasAuth('wechat_config:edit')) {
if (hasAuth('payment_settings:edit')) {
actions.push({
label: '编辑',
handler: () => showEditDialog(row),
@@ -1025,7 +1025,7 @@
})
}
if (hasAuth('wechat_config:delete')) {
if (hasAuth('payment_settings:delete')) {
actions.push({
label: '删除',
handler: () => handleDelete(row),
@@ -1042,7 +1042,7 @@
</script>
<style scoped lang="scss">
.wechat-config-page {
.payment-settings-page {
height: 100%;
}

View File

@@ -29,8 +29,8 @@
:data="permissionList"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
:pagination="false"
:marginTop="10"
:show-pagination="false"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />