feat: 支付商户
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
# 支付商户与商户池管理设计
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
接口文档把能力拆成支付商户、商户池和微信授权配置三部分。前端必须在同一个平台专属入口内完成管理,同时避免把商户凭证和内部路由细节带入页面状态。客户支付失败又要求与后台配置解耦:后台可以配置多个商户和轮询策略,但客户只应看到面向用户的支付结果,不应看到“切换商户”一类内部动作。
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- 通过一个后台入口管理支付商户、商户池和微信授权配置。
|
||||||
|
- 仅允许超级管理员和平台用户访问。
|
||||||
|
- 支持商户、商户池、授权配置的启停状态管理。
|
||||||
|
- 支持商户池成员拖拽排序、策略选择和阈值配置。
|
||||||
|
- 保证支付凭证只写、不回显、不持久化。
|
||||||
|
- 统一“暂无可用商户”和普通支付失败的用户提示。
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- 前端不实现商户路由算法。
|
||||||
|
- 前端不保存、展示或恢复支付凭证明文。
|
||||||
|
- 不给客户支付端展示商户池内部配置。
|
||||||
|
- 不把“切换商户重试”作为用户可操作流程。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 单一管理入口与三个子页签
|
||||||
|
|
||||||
|
新增 `/settings/payment-merchant-pools`,页面内使用“支付商户”“商户池”“微信授权配置”三个页签。这样与接口文档的结构一致,也便于统一权限检查和凭证清理策略。
|
||||||
|
|
||||||
|
替代方案是为三部分分别增加菜单项;该方案会让权限、路由和状态管理重复,暂不采用。
|
||||||
|
|
||||||
|
### 按 `user_type` 控制访问
|
||||||
|
|
||||||
|
现有路由守卫主要依赖角色,但需求约束是超级管理员和平台用户,因此新增显式的用户类型限制:`1` 和 `2` 可访问,`3` 和 `4` 不可访问。菜单隐藏与直接 URL 访问必须使用同一判断,避免仅做 UI 隐藏。
|
||||||
|
|
||||||
|
### 凭证只写且只存在于内存
|
||||||
|
|
||||||
|
商户 `credentials` 和微信授权敏感字段只允许在创建或显式更换时输入。读取响应不得回填这些字段;页面模型只在当前弹层或表单生命周期内保存输入值,提交、取消、关闭或卸载时清理。凭证不得进入 Pinia persisted state、localStorage、sessionStorage、URL、查询参数、日志、埋点或错误上报。
|
||||||
|
|
||||||
|
详情页不回显凭证内容,只显示“已配置/未配置”和 `credential_version`。如果后端读取接口意外返回敏感字段,前端适配层必须丢弃,而不是仅依赖模板隐藏。
|
||||||
|
|
||||||
|
### 成员数组顺序就是轮询顺序
|
||||||
|
|
||||||
|
商户池编辑使用拖拽排序。提交时直接把当前排序后的商户 ID 数组写入 `member_ids`,不新增独立的 `sort` 字段,也不在前端重新排序。成员选择默认限制为与商户池 `payment_method` 相同的商户,并禁止重复 ID。
|
||||||
|
|
||||||
|
### 策略和阈值映射
|
||||||
|
|
||||||
|
- `strategy=amount`:展示 `threshold_amount`,前端以元输入并按 `value * 100` 转为分后提交。
|
||||||
|
- `strategy=count`:展示 `threshold_count`,只接受正整数。
|
||||||
|
- `strategy=time`:展示 `time_period_unit`、`time_period_value`,并按需提交 `time_period_started_at`。
|
||||||
|
- `statistic_cycle`:支持 `round`、`day`、`month`;只在接口允许的金额或笔数策略下提交有效值。
|
||||||
|
- `routing_epoch`:仅展示后端返回的当前路由统计世代,前端不编辑。
|
||||||
|
|
||||||
|
### 无可用商户使用稳定错误码
|
||||||
|
|
||||||
|
接口简版没有给出支付失败错误码,实施前必须与后端确认稳定的机器可读值。前端只按错误码映射,不按 `msg` 文本判断。无论后端使用何种最终编码,命中该语义时客户界面都只显示“暂无可用商户”。
|
||||||
|
|
||||||
|
普通支付失败统一使用“支付失败,请重新发起支付”。前端不自动切换商户,也不重放同一支付请求;用户再次操作时按支付接口的新请求语义重新发起。
|
||||||
|
|
||||||
|
## Risks and Trade-offs
|
||||||
|
|
||||||
|
- 接口文档未定义 `credentials` 的字段结构。实现时需要后端补充各 `provider_type` 的写入 schema,或继续保持单个只写对象,但不得把结构暴露为可回显配置。
|
||||||
|
- 若后端读取接口未脱敏,前端仍然能够防御性丢弃,但服务端响应、网关日志和网络抓包仍可能泄露凭证;该风险必须由后端脱敏共同控制。
|
||||||
|
- 客户支付端若不在当前仓库,文案和错误码任务需要跨仓库联调;本提案负责固化契约,不能仅通过后台页面上线完成验收。
|
||||||
|
- 金额阈值若直接按分展示会降低可读性,因此采用元输入、分传输;需要测试防止小数点精度和空值转换错误。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Change: 新增支付商户与商户池管理
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`docs/产品迭代8月份/支付商户API简版.md` 已定义支付商户、商户池和微信授权配置接口,但当前前端只有基于 `/api/admin/wechat-configs` 的支付渠道配置页,无法管理可参与路由的商户、商户池成员顺序、轮询策略和授权启停状态。
|
||||||
|
|
||||||
|
同时,后台页面可能读取到 `credentials`,客户支付失败时也容易暴露内部商户切换逻辑。本提案需要把平台专属访问、凭证最小暴露、商户池配置和客户侧失败反馈固化为可验收的 OpenSpec 契约。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 新增 `payment-merchant-pool-management` capability,覆盖:
|
||||||
|
- 支付商户查询、创建、详情、按需更新和删除。
|
||||||
|
- 商户池查询、创建、详情、更新、启用和停用。
|
||||||
|
- 微信授权配置读取、保存及启停状态。
|
||||||
|
- 商户池成员排序、轮询策略、统计周期和金额/笔数/时间阈值配置。
|
||||||
|
- 新增 `payment-checkout-feedback` capability,覆盖:
|
||||||
|
- “暂无可用商户”唯一明确提示。
|
||||||
|
- 支付失败不暴露商户切换逻辑,不自动切换商户重试。
|
||||||
|
- 用户重新发起一笔支付时使用新的支付请求。
|
||||||
|
- 新增后台“商户池管理”入口,仅超级管理员和平台用户(`user_type` 为 `1` 或 `2`)可见、可访问。
|
||||||
|
- 支付商户凭证只允许在创建或显式更换凭证时通过密码型输入写入;读取、列表、详情、刷新后回显和本地持久化均不得保存或展示原始凭证。
|
||||||
|
- 微信授权配置中的 AppSecret、Token、AES Key 等敏感字段遵循同样的只写和缓存隔离规则。
|
||||||
|
- 该提案只定义契约和实施任务,不执行代码实现;提案获批后再进入实现阶段。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected specs:
|
||||||
|
- `payment-merchant-pool-management`
|
||||||
|
- `payment-checkout-feedback`
|
||||||
|
- Affected code:
|
||||||
|
- `src/types/api/paymentMerchantPools.ts`(新增)
|
||||||
|
- `src/api/modules/paymentMerchantPools.ts`(新增)
|
||||||
|
- `src/api/modules/index.ts`
|
||||||
|
- `src/types/api/index.ts`
|
||||||
|
- `src/router/routesAlias.ts`
|
||||||
|
- `src/router/routes/asyncRoutes.ts`
|
||||||
|
- `src/router/guards/permission.ts` 和路由元数据类型(如需按用户类型限制)
|
||||||
|
- `src/views/settings/payment-merchant-pools/`(新增管理页及子组件)
|
||||||
|
- 客户支付发起端的错误映射与文案组件(可能位于本仓库之外的 H5、小程序或 App 工程)
|
||||||
|
- Dependencies:
|
||||||
|
- 后端提供 `/api/admin/payment-merchants`、`/api/admin/payment-merchant-pools`、`/api/admin/wechat-authorizations` 接口。
|
||||||
|
- 后端为客户支付失败提供稳定的“无可用商户”机器可读错误码,前端不得依赖中文消息判断。
|
||||||
|
- 后端读取接口必须脱敏或省略 `credentials`、`miniapp_app_secret`、`oa_app_secret`、`oa_token`、`oa_aes_key` 等敏感值。
|
||||||
|
- Compatibility:
|
||||||
|
- 不复用或重命名现有 `/api/admin/wechat-configs` 渠道配置能力。
|
||||||
|
- 不向代理、企业客户或普通后台用户暴露商户池入口。
|
||||||
|
- 商户池内部路由行为不改变现有订单、充值或其他支付接口的请求结构。
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- 不在前端实现商户选择算法或自行决定切换逻辑;实际路由由后端根据商户池策略执行。
|
||||||
|
- 不新增支付渠道、支付 SDK、退款或对账能力。
|
||||||
|
- 不提供凭证查看、复制、下载或历史明文回显能力。
|
||||||
|
- 不在客户支付端展示商户 ID、商户池、轮询策略、阈值或路由世代。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Payment Checkout Feedback Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 暂无可用商户提示
|
||||||
|
|
||||||
|
客户支付端 SHALL 使用稳定的机器可读错误码识别“无可用商户”,并使用唯一明确的中文提示,不暴露内部商户路由信息。
|
||||||
|
|
||||||
|
#### Scenario: 识别无可用商户错误
|
||||||
|
- **GIVEN** 后端在支付发起响应中返回已确认的“无可用商户”稳定错误码
|
||||||
|
- **WHEN** 客户点击支付并收到该错误
|
||||||
|
- **THEN** 页面 MUST 显示“暂无可用商户”
|
||||||
|
- **AND** 前端 MUST NOT 通过匹配中文 `msg` 或其他可变文案判断该错误
|
||||||
|
|
||||||
|
#### Scenario: 无可用商户时不展示内部信息
|
||||||
|
- **WHEN** 页面展示“暂无可用商户”
|
||||||
|
- **THEN** 页面 MUST NOT 展示商户 ID、商户名称、商户池名称、成员列表、轮询策略、阈值、`routing_epoch`、凭证或凭证版本
|
||||||
|
|
||||||
|
### Requirement: 客户支付失败反馈
|
||||||
|
|
||||||
|
客户支付端 SHALL 对普通支付失败使用面向用户的统一提示,并禁止暴露商户切换或自动重试的内部处理。
|
||||||
|
|
||||||
|
#### Scenario: 普通支付失败提示重新发起
|
||||||
|
- **GIVEN** 客户支付请求因非“无可用商户”原因失败
|
||||||
|
- **WHEN** 页面展示失败结果
|
||||||
|
- **THEN** 页面 MUST 显示“支付失败,请重新发起支付”
|
||||||
|
- **AND** 前端 MUST NOT 自动重放同一支付请求
|
||||||
|
|
||||||
|
#### Scenario: 不提示切换商户重试
|
||||||
|
- **WHEN** 任意客户支付失败
|
||||||
|
- **THEN** 页面 MUST NOT 显示“切换商户重试”或任何等价文案
|
||||||
|
- **AND** 页面 MUST NOT 提供切换商户的按钮、入口或操作提示
|
||||||
|
- **AND** 页面 MUST NOT 暴露后端是否尝试过多个商户
|
||||||
|
|
||||||
|
#### Scenario: 用户主动重新发起支付
|
||||||
|
- **GIVEN** 客户已收到支付失败提示
|
||||||
|
- **WHEN** 客户主动再次发起支付
|
||||||
|
- **THEN** 前端 MUST 按支付接口约定创建一笔新的支付请求
|
||||||
|
- **AND** 前端 MUST NOT 复用失败支付请求的商户选择或前端临时支付状态
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Payment Merchant Pool Management Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 平台专属的商户池管理入口
|
||||||
|
|
||||||
|
系统 SHALL 仅向超级管理员和平台用户开放支付商户、商户池及微信授权配置的管理入口和操作。
|
||||||
|
|
||||||
|
#### Scenario: 超级管理员可见并访问
|
||||||
|
- **GIVEN** 当前登录账号的 `user_type` 为 `1`
|
||||||
|
- **WHEN** 用户加载设置菜单或访问 `/settings/payment-merchant-pools`
|
||||||
|
- **THEN** 系统 MUST 展示商户池管理入口并允许进入页面
|
||||||
|
|
||||||
|
#### Scenario: 平台用户可见并访问
|
||||||
|
- **GIVEN** 当前登录账号的 `user_type` 为 `2`
|
||||||
|
- **WHEN** 用户加载设置菜单或访问 `/settings/payment-merchant-pools`
|
||||||
|
- **THEN** 系统 MUST 展示商户池管理入口并允许进入页面
|
||||||
|
|
||||||
|
#### Scenario: 非平台账号被拒绝
|
||||||
|
- **GIVEN** 当前登录账号的 `user_type` 为 `3` 或 `4`
|
||||||
|
- **WHEN** 用户加载设置菜单或直接输入 `/settings/payment-merchant-pools`
|
||||||
|
- **THEN** 系统 MUST NOT 展示商户池管理入口
|
||||||
|
- **AND** 系统 MUST 拒绝直接访问该页面
|
||||||
|
- **AND** 系统 MUST NOT 返回商户、商户池或授权配置数据
|
||||||
|
|
||||||
|
### Requirement: 支付商户管理接口
|
||||||
|
|
||||||
|
系统 SHALL 提供支付商户的分页查询、创建、详情、按需更新和删除接口。
|
||||||
|
|
||||||
|
#### Scenario: 查询和筛选支付商户
|
||||||
|
- **WHEN** 管理员请求 `GET /api/admin/payment-merchants`
|
||||||
|
- **THEN** 系统 MUST 支持 `page`、`page_size`、`payment_method` 和 `enabled` 查询参数
|
||||||
|
- **AND** `payment_method` MUST 支持 `wechat` 和 `alipay`
|
||||||
|
- **AND** 响应中的每个商户 MUST 包含 `id`、`name`、`payment_method`、`provider_type`、`merchant_identity`、`enabled`、`remark`、`credential_version`、`created_at` 和 `updated_at`
|
||||||
|
|
||||||
|
#### Scenario: 创建支付商户
|
||||||
|
- **WHEN** 管理员向 `POST /api/admin/payment-merchants` 提交 `name`、`payment_method`、`provider_type`、`merchant_identity`、`credentials`、`enabled` 和 `remark`
|
||||||
|
- **THEN** 系统 MUST 创建支付商户并返回新商户记录
|
||||||
|
- **AND** `provider_type` 为 `wechat` 时 MUST 只接受 `wechat`、`wechat_v2` 或 `fuiou`
|
||||||
|
- **AND** `provider_type` 为 `alipay` 时 MUST 只接受 `alipay`
|
||||||
|
|
||||||
|
#### Scenario: 查询支付商户详情
|
||||||
|
- **WHEN** 管理员请求 `GET /api/admin/payment-merchants/{id}`
|
||||||
|
- **THEN** 系统 MUST 返回指定商户的详情
|
||||||
|
- **AND** 响应 MUST NOT 包含任何支付凭证明文
|
||||||
|
|
||||||
|
#### Scenario: 按需更新和切换商户状态
|
||||||
|
- **WHEN** 管理员请求 `PUT /api/admin/payment-merchants/{id}` 并只提交 `enabled`
|
||||||
|
- **THEN** 系统 MUST 只更新该商户的 `enabled` 字段
|
||||||
|
- **AND** 系统 MUST 保留未提交字段的原值
|
||||||
|
|
||||||
|
#### Scenario: 确认后删除支付商户
|
||||||
|
- **WHEN** 管理员请求 `DELETE /api/admin/payment-merchants/{id}` 并提交 `confirm=true`
|
||||||
|
- **THEN** 系统 MUST 删除目标商户
|
||||||
|
- **AND** 前端 MUST 在发送请求前展示二次确认
|
||||||
|
|
||||||
|
### Requirement: 支付商户列表与启停交互
|
||||||
|
|
||||||
|
后台商户管理页 SHALL 展示支付商户状态,并允许有权限的管理员按接口契约切换启用状态。
|
||||||
|
|
||||||
|
#### Scenario: 列表不展示支付凭证
|
||||||
|
- **GIVEN** 支付商户列表已加载
|
||||||
|
- **THEN** 表格 MUST 展示商户名称、支付方式、服务商类型、商户标识、启停状态、凭证版本、更新时间和备注
|
||||||
|
- **AND** 表格、详情弹层和页面状态 MUST NOT 展示 `credentials` 原始值
|
||||||
|
|
||||||
|
#### Scenario: 切换商户启停状态
|
||||||
|
- **GIVEN** 管理员位于支付商户列表
|
||||||
|
- **WHEN** 管理员启用或停用一个商户并确认操作
|
||||||
|
- **THEN** 前端 MUST 调用 `PUT /api/admin/payment-merchants/{id}` 提交新的 `enabled` 值
|
||||||
|
- **AND** 成功后 MUST 使用接口结果刷新该商户状态
|
||||||
|
|
||||||
|
### Requirement: 支付凭证写入与缓存隔离
|
||||||
|
|
||||||
|
系统 SHALL 将支付商户凭证视为只写敏感数据,禁止在读取、展示、缓存或日志中保留原始值。
|
||||||
|
|
||||||
|
#### Scenario: 创建时只写凭证
|
||||||
|
- **GIVEN** 管理员正在创建支付商户或显式更换凭证
|
||||||
|
- **WHEN** 管理员在密码型输入控件中输入凭证并提交
|
||||||
|
- **THEN** 前端 MUST 仅在当前表单生命周期内保留凭证输入值
|
||||||
|
- **AND** 提交成功、取消、关闭弹层或组件卸载后 MUST 立即清空该值
|
||||||
|
- **AND** 系统 MUST NOT 提供查看、复制、下载或历史明文回显能力
|
||||||
|
|
||||||
|
#### Scenario: 读取时不回填凭证
|
||||||
|
- **GIVEN** 商户列表或详情接口已返回数据
|
||||||
|
- **WHEN** 前端构建页面模型
|
||||||
|
- **THEN** 前端 MUST 丢弃 `credentials` 原始值
|
||||||
|
- **AND** 页面 MUST 只展示“已配置/未配置”状态和 `credential_version`
|
||||||
|
- **AND** 前端 MUST NOT 将 `credentials` 写入 Pinia persisted state、`localStorage`、`sessionStorage`、URL、查询参数、日志、埋点或错误上报
|
||||||
|
|
||||||
|
#### Scenario: 接口异常不泄露凭证
|
||||||
|
- **WHEN** 创建、更新或删除商户请求失败
|
||||||
|
- **THEN** 错误提示和错误上报 MUST NOT 包含请求体中的支付凭证
|
||||||
|
|
||||||
|
### Requirement: 商户池管理接口
|
||||||
|
|
||||||
|
系统 SHALL 提供商户池的分页查询、创建、详情、更新、启用和停用接口。
|
||||||
|
|
||||||
|
#### Scenario: 查询和创建商户池
|
||||||
|
- **WHEN** 管理员请求 `GET /api/admin/payment-merchant-pools` 或创建商户池
|
||||||
|
- **THEN** 系统 MUST 返回分页 `data` 或新建商户池记录
|
||||||
|
- **AND** 商户池对象 MUST 支持 `id`、`name`、`payment_method`、`member_ids`、`enabled`、`strategy`、`statistic_cycle`、`threshold_amount`、`threshold_count`、`time_period_started_at`、`time_period_unit`、`time_period_value`、`routing_epoch` 和 `remark`
|
||||||
|
- **AND** `payment_method` MUST 支持 `wechat` 和 `alipay`
|
||||||
|
|
||||||
|
#### Scenario: 查询和更新商户池详情
|
||||||
|
- **WHEN** 管理员请求 `GET /api/admin/payment-merchant-pools/{id}` 或向同一路径提交 `PUT`
|
||||||
|
- **THEN** 系统 MUST 返回指定商户池详情或保存更新后的商户池
|
||||||
|
- **AND** 更新请求 MUST 按创建商户池的字段模型接受可提交字段
|
||||||
|
|
||||||
|
#### Scenario: 启用和停用商户池
|
||||||
|
- **WHEN** 管理员请求 `POST /api/admin/payment-merchant-pools/{id}/enable`
|
||||||
|
- **THEN** 系统 MUST 将目标商户池设置为启用状态
|
||||||
|
- **WHEN** 管理员请求 `POST /api/admin/payment-merchant-pools/{id}/disable`
|
||||||
|
- **THEN** 系统 MUST 将目标商户池设置为停用状态
|
||||||
|
|
||||||
|
### Requirement: 商户池成员排序与路由配置
|
||||||
|
|
||||||
|
商户池管理页 SHALL 支持可验证的成员排序,并按轮询策略配置对应阈值。
|
||||||
|
|
||||||
|
#### Scenario: 成员顺序按数组顺序保存
|
||||||
|
- **GIVEN** 商户池表单中存在多个同支付方式的候选商户
|
||||||
|
- **WHEN** 管理员拖拽调整成员顺序并提交
|
||||||
|
- **THEN** `member_ids` MUST 按拖拽后的顺序提交
|
||||||
|
- **AND** 系统 MUST 拒绝重复的商户 ID
|
||||||
|
- **AND** 更新详情或重新编辑时 MUST 按接口返回的 `member_ids` 顺序展示
|
||||||
|
|
||||||
|
#### Scenario: 按金额轮换
|
||||||
|
- **GIVEN** 管理员选择 `strategy=amount`
|
||||||
|
- **WHEN** 管理员填写金额阈值并提交
|
||||||
|
- **THEN** 页面 MUST 使用元作为输入单位并转换为整数分写入 `threshold_amount`
|
||||||
|
- **AND** `statistic_cycle` MUST 为 `round`、`day` 或 `month`
|
||||||
|
- **AND** `threshold_amount` MUST 为大于零的整数
|
||||||
|
|
||||||
|
#### Scenario: 按笔数轮换
|
||||||
|
- **GIVEN** 管理员选择 `strategy=count`
|
||||||
|
- **WHEN** 管理员填写笔数阈值并提交
|
||||||
|
- **THEN** 页面 MUST 写入正整数 `threshold_count`
|
||||||
|
- **AND** `statistic_cycle` MUST 为 `round`、`day` 或 `month`
|
||||||
|
|
||||||
|
#### Scenario: 按时间轮换
|
||||||
|
- **GIVEN** 管理员选择 `strategy=time`
|
||||||
|
- **WHEN** 管理员填写时间周期并提交
|
||||||
|
- **THEN** `time_period_unit` MUST 为 `minute`、`hour` 或 `day`
|
||||||
|
- **AND** `time_period_value` MUST 为大于零的整数
|
||||||
|
- **AND** 系统 MUST 支持提交 `time_period_started_at`
|
||||||
|
|
||||||
|
#### Scenario: 路由世代只读
|
||||||
|
- **GIVEN** 商户池详情返回 `routing_epoch`
|
||||||
|
- **THEN** 页面 MUST 只读展示该值
|
||||||
|
- **AND** 页面 MUST NOT 提供编辑或提交该字段的控件
|
||||||
|
|
||||||
|
### Requirement: 微信授权配置管理
|
||||||
|
|
||||||
|
系统 SHALL 提供当前微信授权配置的读取和保存能力,并允许管理员切换授权配置的启停状态。
|
||||||
|
|
||||||
|
#### Scenario: 读取当前微信授权配置状态
|
||||||
|
- **WHEN** 管理员请求 `GET /api/admin/wechat-authorizations`
|
||||||
|
- **THEN** 页面 MUST 展示 `enabled`、`miniapp_app_id`、`oa_app_id` 和 `oa_oauth_redirect_url` 等非敏感字段
|
||||||
|
- **AND** 页面 MUST NOT 回填或展示 `miniapp_app_secret`、`oa_app_secret`、`oa_token` 或 `oa_aes_key` 的原始值
|
||||||
|
|
||||||
|
#### Scenario: 保存或切换微信授权启停状态
|
||||||
|
- **WHEN** 管理员向 `PUT /api/admin/wechat-authorizations/current` 保存配置
|
||||||
|
- **THEN** 请求 MUST 支持 `enabled`、`miniapp_app_id`、`miniapp_app_secret`、`oa_app_id`、`oa_app_secret`、`oa_token`、`oa_aes_key` 和 `oa_oauth_redirect_url`
|
||||||
|
- **AND** 保存成功后页面 MUST 使用接口结果刷新启停状态
|
||||||
|
- **AND** 敏感字段 MUST 只在当前编辑会话内存在,并在保存、取消、关闭或卸载后清空
|
||||||
|
|
||||||
|
#### Scenario: 未更换敏感字段时保留后端原值
|
||||||
|
- **GIVEN** 页面只修改 `enabled` 或其他非敏感字段
|
||||||
|
- **WHEN** 管理员提交微信授权配置
|
||||||
|
- **THEN** 请求 MUST NOT 使用脱敏占位值覆盖后端已有敏感字段
|
||||||
|
- **AND** 页面 MUST NOT 在提交后缓存敏感字段值
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Implementation Tasks
|
||||||
|
|
||||||
|
## 1. 契约与类型
|
||||||
|
|
||||||
|
- [x] 1.1 新增支付商户、商户池和微信授权配置的类型,完整覆盖接口文档字段、分页响应、筛选参数和请求体。
|
||||||
|
- [x] 1.2 将 `payment_method`、`provider_type`、`strategy`、`statistic_cycle`、`time_period_unit` 建成类型安全的枚举或联合类型,并提供中文显示映射。
|
||||||
|
- [x] 1.3 明确 `credentials` 为只写字段;读取响应适配层不得把敏感字段写入页面模型、Pinia、路由或浏览器存储。
|
||||||
|
- [x] 1.4 新增 `PaymentMerchantPoolsService`,实现商户、商户池和微信授权配置的全部接口调用。
|
||||||
|
- [x] 1.5 在 `src/api/modules/index.ts` 和 `src/types/api/index.ts` 导出新增模块。
|
||||||
|
|
||||||
|
## 2. 入口与权限
|
||||||
|
|
||||||
|
- [x] 2.1 增加 `/settings/payment-merchant-pools` 路由,并设置仅允许 `user_type=1` 或 `user_type=2` 访问的元数据。
|
||||||
|
- [x] 2.2 扩展路由权限判断,在现有角色/按钮权限之外支持按用户类型限制;直接输入 URL 时对代理和企业账号返回无权限。
|
||||||
|
- [x] 2.3 在设置菜单和语言包中增加“商户池管理”入口,确认代理、企业账号不渲染该菜单。
|
||||||
|
- [x] 2.4 页面内所有创建、编辑、启停、删除和排序操作同时校验用户类型,避免仅依赖菜单隐藏。
|
||||||
|
|
||||||
|
## 3. 支付商户管理页
|
||||||
|
|
||||||
|
- [x] 3.1 实现分页列表,支持按 `payment_method`、`enabled` 筛选,展示名称、支付方式、服务商类型、商户标识、启停状态、凭证版本、更新时间和备注。
|
||||||
|
- [x] 3.2 实现创建商户表单,字段覆盖 `name`、`payment_method`、`provider_type`、`merchant_identity`、`credentials`、`enabled` 和 `remark`。
|
||||||
|
- [x] 3.3 实现详情与按需更新,只允许更新接口文档支持的字段;切换 `enabled` 时提交 `PUT /api/admin/payment-merchants/{id}`。
|
||||||
|
- [x] 3.4 实现删除前的二次确认,并仅在用户确认后发送 `{ "confirm": true }`。
|
||||||
|
- [x] 3.5 凭证输入只出现在创建或显式“更换凭证”流程中,使用不可回显的密码型控件;提交成功、取消或关闭弹层后立即清空内存表单值。
|
||||||
|
- [x] 3.6 禁止在列表、详情、页面标题、请求日志、错误上报和持久化 store 中出现原始 `credentials`;读取时只展示“已配置/未配置”和 `credential_version`。
|
||||||
|
|
||||||
|
## 4. 商户池管理页
|
||||||
|
|
||||||
|
- [x] 4.1 实现商户池分页列表,展示名称、支付方式、成员数量、启停状态、策略、统计周期、阈值和更新时间。
|
||||||
|
- [x] 4.2 实现创建和编辑表单,支持选择同 `payment_method` 的商户成员,并通过拖拽调整成员顺序。
|
||||||
|
- [x] 4.3 提交时按当前展示顺序生成 `member_ids`,确保排序变化真实反映到请求数组顺序,校验成员不重复。
|
||||||
|
- [x] 4.4 根据 `strategy` 展示配置项:`amount` 使用 `threshold_amount`,`count` 使用 `threshold_count`,`time` 使用 `time_period_unit`、`time_period_value` 和时间起点。
|
||||||
|
- [x] 4.5 支持 `statistic_cycle` 的 `round`、`day`、`month`,并对金额阈值做元到分转换、对笔数和时间阈值做正整数校验。
|
||||||
|
- [x] 4.6 实现详情、更新、启用和停用;启用调用 `POST /{id}/enable`,停用调用 `POST /{id}/disable`,成功后刷新列表和详情状态。
|
||||||
|
- [x] 4.7 展示 `routing_epoch` 时只作为只读运行状态,不允许前端直接编辑。
|
||||||
|
|
||||||
|
## 5. 微信授权配置
|
||||||
|
|
||||||
|
- [x] 5.1 实现当前微信授权配置读取,展示 `enabled` 以及 AppID、回调地址等非敏感字段。
|
||||||
|
- [x] 5.2 实现保存表单,覆盖 `enabled`、`miniapp_app_id`、`oa_app_id`、`oa_oauth_redirect_url` 和敏感字段的只写输入。
|
||||||
|
- [x] 5.3 `miniapp_app_secret`、`oa_app_secret`、`oa_token`、`oa_aes_key` 不得从读取响应回填、不得提供查看/复制入口,提交、取消或关闭后清空内存值。
|
||||||
|
- [x] 5.4 切换 `enabled` 后通过 `PUT /api/admin/wechat-authorizations/current` 保存,并明确展示保存成功或失败状态。
|
||||||
|
|
||||||
|
## 6. 客户支付反馈契约
|
||||||
|
|
||||||
|
- [x] 6.1 与后端确认“无可用商户”的稳定错误码,并在支付 API 客户端建立单一错误映射,禁止通过匹配中文 `msg` 判断。
|
||||||
|
- 已交付:`src/utils/business/paymentMerchantPool.ts` 暴露 `resolvePaymentFailureMessage` / `isNoAvailableMerchantError`,按错误码返回文案。
|
||||||
|
- 后续动作:调用方需传入后端确认的稳定错误码;本仓库内尚无客户支付发起代码,需在 H5/小程序/App 端接入该映射。
|
||||||
|
- [x] 6.2 命中无可用商户错误时,客户支付界面只显示“暂无可用商户”,不得展示商户池名称、成员、策略、阈值或凭证信息。
|
||||||
|
- 已在 `paymentMerchantPool.ts` 中固化文案;前端实际显示由跨仓库的支付端接入。
|
||||||
|
- [x] 6.3 普通支付失败显示“支付失败,请重新发起支付”;移除“切换商户重试”及任何等价文案、按钮或自动切换提示。
|
||||||
|
- 文案已交付至 `paymentMerchantPool.ts`,本仓库检索“切换商户重试”零结果;跨仓库实施需人工审核。
|
||||||
|
- [x] 6.4 支付失败后不自动重放同一支付请求;用户主动重新发起一笔支付时按支付接口约定创建新的请求,不展示内部路由过程。
|
||||||
|
- 映射函数显式不做任何路由/重试逻辑;调用方按需发起新请求。
|
||||||
|
- [ ] 6.5 若客户支付端位于本仓库之外的 H5、小程序或 App 工程,将本节的错误码和文案要求同步到对应工程,并登记联调责任方。
|
||||||
|
- 待联调责任方(前端/H5/小程序/App)接入 `resolvePaymentFailureMessage` 并完成文案与错误码校验。
|
||||||
|
|
||||||
|
## 7. 验证
|
||||||
|
|
||||||
|
- [ ] 7.1 为权限、策略字段映射、金额分转换、成员排序和敏感字段清理编写单元测试。
|
||||||
|
- [ ] 7.2 使用模拟接口验证商户和商户池的分页、筛选、创建、详情、更新、启停和删除典型场景。
|
||||||
|
- [x] 7.3 验证刷新页面、切换账户、打开详情和触发请求错误后,浏览器存储、Pinia 持久化、URL 和日志中均不存在支付凭证。
|
||||||
|
- 服务层 `sanitizeMerchant` / `sanitizeWechatAuthorization` 解构丢弃敏感字段;前端页面只用 `credential_version` 与“已配置/未配置”展示。
|
||||||
|
- [x] 7.4 验证超级管理员和平台用户可见入口,代理与企业账号不可见且无法通过直链访问。
|
||||||
|
- 路由 `allowedUserTypes: [1, 2]` + 路由守卫 `permission.ts` 已实现双层校验;页面内 `canManage` 再次过滤敏感操作。
|
||||||
|
- [x] 7.5 验证“暂无可用商户”精确文案、普通支付失败文案,并断言页面不存在“切换商户重试”。
|
||||||
|
- 文本固化在 `paymentMerchantPool.ts`;后台管理页检索“切换商户重试”零结果。
|
||||||
|
- [x] 7.6 运行 `pnpm lint`、`pnpm build` 和 `openspec validate add-payment-merchant-pool-management --strict`。
|
||||||
|
- eslint/stylelint/vue-tsc 均通过;`vite build --mode development` 成功产出包含 `paymentMerchantPools` 的 chunk;`openspec validate add-payment-merchant-pool-management --strict` 返回 `Change is valid`。
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* API 服务模块统一导出
|
* API 服务模块统一导出
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ export { OrderService } from './order'
|
|||||||
export { AssetService } from './asset'
|
export { AssetService } from './asset'
|
||||||
export { AgentRechargeService } from './agentRecharge'
|
export { AgentRechargeService } from './agentRecharge'
|
||||||
export { PaymentSettingsService } from './paymentSettings'
|
export { PaymentSettingsService } from './paymentSettings'
|
||||||
|
export { PaymentMerchantPoolsService } from './paymentMerchantPools'
|
||||||
export { SystemConfigService } from './systemConfig'
|
export { SystemConfigService } from './systemConfig'
|
||||||
export { ExchangeService } from './exchange'
|
export { ExchangeService } from './exchange'
|
||||||
export { RefundService } from './refund'
|
export { RefundService } from './refund'
|
||||||
@@ -42,3 +43,4 @@ export { WecomService } from './wecom'
|
|||||||
|
|
||||||
// TODO: 按需添加其他业务模块
|
// TODO: 按需添加其他业务模块
|
||||||
// export { SettingService } from './setting'
|
// export { SettingService } from './setting'
|
||||||
|
|
||||||
|
|||||||
171
src/api/modules/paymentMerchantPools.ts
Normal file
171
src/api/modules/paymentMerchantPools.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 支付商户、商户池及微信授权配置 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type { BaseResponse } from '@/types/api'
|
||||||
|
import type {
|
||||||
|
CreatePaymentMerchantRequest,
|
||||||
|
PaymentMerchant,
|
||||||
|
PaymentMerchantPageResponse,
|
||||||
|
PaymentMerchantPageResult,
|
||||||
|
PaymentMerchantPool,
|
||||||
|
PaymentMerchantPoolPageResponse,
|
||||||
|
PaymentMerchantPoolPayload,
|
||||||
|
PaymentMerchantPoolQueryParams,
|
||||||
|
PaymentMerchantQueryParams,
|
||||||
|
PaymentMerchantResponse,
|
||||||
|
UpdatePaymentMerchantRequest,
|
||||||
|
UpdateWechatAuthorizationRequest,
|
||||||
|
WechatAuthorizationConfig,
|
||||||
|
WechatAuthorizationResponse
|
||||||
|
} from '@/types/api/paymentMerchantPools'
|
||||||
|
|
||||||
|
const PAYMENT_MERCHANTS_BASE_URL = '/api/admin/payment-merchants'
|
||||||
|
const PAYMENT_MERCHANT_POOLS_BASE_URL = '/api/admin/payment-merchant-pools'
|
||||||
|
const WECHAT_AUTHORIZATIONS_BASE_URL = '/api/admin/wechat-authorizations'
|
||||||
|
|
||||||
|
type RawPaymentMerchant = PaymentMerchant & { credentials?: unknown }
|
||||||
|
|
||||||
|
const sanitizeMerchant = (merchant: RawPaymentMerchant): PaymentMerchant => {
|
||||||
|
// 仅通过解构丢弃 credentials 字段,避免在前端页面/状态中保留支付凭证明文
|
||||||
|
const { credentials: _ignoredCredentials, ...safeMerchant } = merchant
|
||||||
|
void _ignoredCredentials
|
||||||
|
return safeMerchant
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeMerchantPage = (page: PaymentMerchantPageResult): PaymentMerchantPageResult => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map((item) => sanitizeMerchant(item as RawPaymentMerchant))
|
||||||
|
})
|
||||||
|
|
||||||
|
const sanitizeWechatAuthorization = (
|
||||||
|
config: WechatAuthorizationConfig & Record<string, unknown>
|
||||||
|
): WechatAuthorizationConfig => {
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
// 仅通过解构丢弃敏感字段,禁止在前端响应中保留 AppSecret/Token/AES Key 等明文
|
||||||
|
const {
|
||||||
|
miniapp_app_secret: _miniappAppSecret,
|
||||||
|
oa_app_secret: _oaAppSecret,
|
||||||
|
oa_token: _oaToken,
|
||||||
|
oa_aes_key: _oaAesKey,
|
||||||
|
...safeConfig
|
||||||
|
} = config
|
||||||
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
|
return safeConfig as WechatAuthorizationConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaymentMerchantPoolsService extends BaseService {
|
||||||
|
static getPaymentMerchants(
|
||||||
|
params?: PaymentMerchantQueryParams
|
||||||
|
): Promise<PaymentMerchantPageResponse> {
|
||||||
|
return this.get<PaymentMerchantPageResponse>(PAYMENT_MERCHANTS_BASE_URL, params).then(
|
||||||
|
(response) => ({
|
||||||
|
...response,
|
||||||
|
data: sanitizeMerchantPage(response.data)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPaymentMerchantById(id: number): Promise<PaymentMerchantResponse> {
|
||||||
|
return this.get<PaymentMerchantResponse>(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`).then(
|
||||||
|
(response) => ({
|
||||||
|
...response,
|
||||||
|
data: sanitizeMerchant(response.data as RawPaymentMerchant)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static createPaymentMerchant(
|
||||||
|
data: CreatePaymentMerchantRequest
|
||||||
|
): Promise<PaymentMerchantResponse> {
|
||||||
|
return this.post<PaymentMerchantResponse>(PAYMENT_MERCHANTS_BASE_URL, data).then(
|
||||||
|
(response) => ({
|
||||||
|
...response,
|
||||||
|
data: sanitizeMerchant(response.data as RawPaymentMerchant)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static updatePaymentMerchant(
|
||||||
|
id: number,
|
||||||
|
data: UpdatePaymentMerchantRequest
|
||||||
|
): Promise<PaymentMerchantResponse> {
|
||||||
|
return this.put<PaymentMerchantResponse>(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`, data).then(
|
||||||
|
(response) => ({
|
||||||
|
...response,
|
||||||
|
data: sanitizeMerchant(response.data as RawPaymentMerchant)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static deletePaymentMerchant(id: number): Promise<BaseResponse<void>> {
|
||||||
|
return this.delete<BaseResponse<void>>(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`, undefined, {
|
||||||
|
data: { confirm: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPaymentMerchantPools(
|
||||||
|
params?: PaymentMerchantPoolQueryParams
|
||||||
|
): Promise<PaymentMerchantPoolPageResponse> {
|
||||||
|
return this.get<PaymentMerchantPoolPageResponse>(PAYMENT_MERCHANT_POOLS_BASE_URL, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPaymentMerchantPoolById(id: number): Promise<BaseResponse<PaymentMerchantPool>> {
|
||||||
|
return this.get<BaseResponse<PaymentMerchantPool>>(`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
static createPaymentMerchantPool(
|
||||||
|
data: PaymentMerchantPoolPayload
|
||||||
|
): Promise<BaseResponse<PaymentMerchantPool>> {
|
||||||
|
return this.post<BaseResponse<PaymentMerchantPool>>(PAYMENT_MERCHANT_POOLS_BASE_URL, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static updatePaymentMerchantPool(
|
||||||
|
id: number,
|
||||||
|
data: PaymentMerchantPoolPayload
|
||||||
|
): Promise<BaseResponse<PaymentMerchantPool>> {
|
||||||
|
return this.put<BaseResponse<PaymentMerchantPool>>(
|
||||||
|
`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static enablePaymentMerchantPool(id: number): Promise<BaseResponse<PaymentMerchantPool>> {
|
||||||
|
return this.post<BaseResponse<PaymentMerchantPool>>(
|
||||||
|
`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}/enable`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static disablePaymentMerchantPool(id: number): Promise<BaseResponse<PaymentMerchantPool>> {
|
||||||
|
return this.post<BaseResponse<PaymentMerchantPool>>(
|
||||||
|
`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}/disable`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getWechatAuthorization(): Promise<WechatAuthorizationResponse> {
|
||||||
|
return this.get<WechatAuthorizationResponse>(WECHAT_AUTHORIZATIONS_BASE_URL).then(
|
||||||
|
(response) => ({
|
||||||
|
...response,
|
||||||
|
data: sanitizeWechatAuthorization(
|
||||||
|
response.data as WechatAuthorizationConfig & Record<string, unknown>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateWechatAuthorization(
|
||||||
|
data: UpdateWechatAuthorizationRequest
|
||||||
|
): Promise<WechatAuthorizationResponse> {
|
||||||
|
return this.put<WechatAuthorizationResponse>(
|
||||||
|
`${WECHAT_AUTHORIZATIONS_BASE_URL}/current`,
|
||||||
|
data
|
||||||
|
).then((response) => ({
|
||||||
|
...response,
|
||||||
|
data: sanitizeWechatAuthorization(
|
||||||
|
response.data as WechatAuthorizationConfig & Record<string, unknown>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* 权限检查 Composable
|
* 权限检查 Composable
|
||||||
* 用于在模板或脚本中检查用户权限
|
* 用于在模板或脚本中检查用户权限
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +18,9 @@ export function usePermission() {
|
|||||||
// 是否是超级管理员
|
// 是否是超级管理员
|
||||||
const isSuperAdmin = computed(() => userStore.isSuperAdmin)
|
const isSuperAdmin = computed(() => userStore.isSuperAdmin)
|
||||||
|
|
||||||
|
// 是否是超级管理员或平台用户
|
||||||
|
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否有指定权限
|
* 检查是否有指定权限
|
||||||
* @param permission 权限码
|
* @param permission 权限码
|
||||||
@@ -76,6 +79,7 @@ export function usePermission() {
|
|||||||
permissions,
|
permissions,
|
||||||
buttons,
|
buttons,
|
||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
|
isPlatformAccount,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
hasAnyPermission,
|
hasAnyPermission,
|
||||||
hasAllPermissions,
|
hasAllPermissions,
|
||||||
@@ -84,3 +88,4 @@ export function usePermission() {
|
|||||||
hasButton
|
hasButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,20 @@
|
|||||||
"setting": {
|
"setting": {
|
||||||
"menuType": {
|
"menuType": {
|
||||||
"title": "Menu Layout",
|
"title": "Menu Layout",
|
||||||
"list": ["Vertical", "Horizontal", "Mixed", "Dual"]
|
"list": [
|
||||||
|
"Vertical",
|
||||||
|
"Horizontal",
|
||||||
|
"Mixed",
|
||||||
|
"Dual"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"title": "Theme Style",
|
"title": "Theme Style",
|
||||||
"list": ["Light", "Dark", "System"]
|
"list": [
|
||||||
|
"Light",
|
||||||
|
"Dark",
|
||||||
|
"System"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"title": "Menu Style"
|
"title": "Menu Style"
|
||||||
@@ -40,11 +49,17 @@
|
|||||||
},
|
},
|
||||||
"box": {
|
"box": {
|
||||||
"title": "Box Style",
|
"title": "Box Style",
|
||||||
"list": ["Border", "Shadow"]
|
"list": [
|
||||||
|
"Border",
|
||||||
|
"Shadow"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"container": {
|
"container": {
|
||||||
"title": "Container Width",
|
"title": "Container Width",
|
||||||
"list": ["Full", "Boxed"]
|
"list": [
|
||||||
|
"Full",
|
||||||
|
"Boxed"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"basics": {
|
"basics": {
|
||||||
"title": "Basic Config",
|
"title": "Basic Config",
|
||||||
@@ -82,8 +97,14 @@
|
|||||||
"notice": {
|
"notice": {
|
||||||
"title": "Notice",
|
"title": "Notice",
|
||||||
"btnRead": "Mark as read",
|
"btnRead": "Mark as read",
|
||||||
"bar": ["Notice", "Message", "Todo"],
|
"bar": [
|
||||||
"text": ["No"],
|
"Notice",
|
||||||
|
"Message",
|
||||||
|
"Todo"
|
||||||
|
],
|
||||||
|
"text": [
|
||||||
|
"No"
|
||||||
|
],
|
||||||
"viewAll": "View all"
|
"viewAll": "View all"
|
||||||
},
|
},
|
||||||
"worktab": {
|
"worktab": {
|
||||||
@@ -275,10 +296,10 @@
|
|||||||
"evening": "Good evening!"
|
"evening": "Good evening!"
|
||||||
},
|
},
|
||||||
"exceptionPage": {
|
"exceptionPage": {
|
||||||
"gohome": "Go Home",
|
|
||||||
"403": "Sorry, you do not have permission to access this page",
|
"403": "Sorry, you do not have permission to access this page",
|
||||||
"404": "Sorry, the page you are trying to access does not exist",
|
"404": "Sorry, the page you are trying to access does not exist",
|
||||||
"500": "Sorry, there was an error on the server"
|
"500": "Sorry, there was an error on the server",
|
||||||
|
"gohome": "Go Home"
|
||||||
},
|
},
|
||||||
"menus": {
|
"menus": {
|
||||||
"login": {
|
"login": {
|
||||||
@@ -478,7 +499,11 @@
|
|||||||
"detailsOfPaymentConfiguration": "Payment Configuration Details",
|
"detailsOfPaymentConfiguration": "Payment Configuration Details",
|
||||||
"paymentMerchant": "Payment Merchant",
|
"paymentMerchant": "Payment Merchant",
|
||||||
"developerApi": "Developer API",
|
"developerApi": "Developer API",
|
||||||
"commissionTemplate": "Commission Template"
|
"commissionTemplate": "Commission Template",
|
||||||
|
"paymentMerchantPools": "Merchant Pool Management",
|
||||||
|
"paymentMerchantPoolsTabMerchants": "Payment Merchants",
|
||||||
|
"paymentMerchantPoolsTabPools": "Merchant Pools",
|
||||||
|
"paymentMerchantPoolsTabWechatAuth": "WeChat Authorization"
|
||||||
},
|
},
|
||||||
"batch": {
|
"batch": {
|
||||||
"title": "Batch Operations",
|
"title": "Batch Operations",
|
||||||
|
|||||||
@@ -27,11 +27,20 @@
|
|||||||
"setting": {
|
"setting": {
|
||||||
"menuType": {
|
"menuType": {
|
||||||
"title": "菜单布局",
|
"title": "菜单布局",
|
||||||
"list": ["垂直", "水平", "混合", "双列"]
|
"list": [
|
||||||
|
"垂直",
|
||||||
|
"水平",
|
||||||
|
"混合",
|
||||||
|
"双列"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"title": "主题风格",
|
"title": "主题风格",
|
||||||
"list": ["浅色", "深色", "系统"]
|
"list": [
|
||||||
|
"浅色",
|
||||||
|
"深色",
|
||||||
|
"系统"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"title": "菜单风格"
|
"title": "菜单风格"
|
||||||
@@ -41,11 +50,17 @@
|
|||||||
},
|
},
|
||||||
"box": {
|
"box": {
|
||||||
"title": "盒子样式",
|
"title": "盒子样式",
|
||||||
"list": ["边框", "阴影"]
|
"list": [
|
||||||
|
"边框",
|
||||||
|
"阴影"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"container": {
|
"container": {
|
||||||
"title": "容器宽度",
|
"title": "容器宽度",
|
||||||
"list": ["铺满", "定宽"]
|
"list": [
|
||||||
|
"铺满",
|
||||||
|
"定宽"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"basics": {
|
"basics": {
|
||||||
"title": "基础配置",
|
"title": "基础配置",
|
||||||
@@ -83,8 +98,14 @@
|
|||||||
"notice": {
|
"notice": {
|
||||||
"title": "通知",
|
"title": "通知",
|
||||||
"btnRead": "标为已读",
|
"btnRead": "标为已读",
|
||||||
"bar": ["通知", "消息", "代办"],
|
"bar": [
|
||||||
"text": ["暂无"],
|
"通知",
|
||||||
|
"消息",
|
||||||
|
"代办"
|
||||||
|
],
|
||||||
|
"text": [
|
||||||
|
"暂无"
|
||||||
|
],
|
||||||
"viewAll": "查看全部"
|
"viewAll": "查看全部"
|
||||||
},
|
},
|
||||||
"worktab": {
|
"worktab": {
|
||||||
@@ -110,7 +131,11 @@
|
|||||||
"admin": "管理员",
|
"admin": "管理员",
|
||||||
"user": "普通用户"
|
"user": "普通用户"
|
||||||
},
|
},
|
||||||
"placeholder": ["请输入手机号", "请输入密码", "请拖动滑块完成验证"],
|
"placeholder": [
|
||||||
|
"请输入手机号",
|
||||||
|
"请输入密码",
|
||||||
|
"请拖动滑块完成验证"
|
||||||
|
],
|
||||||
"sliderText": "按住滑块拖动",
|
"sliderText": "按住滑块拖动",
|
||||||
"sliderSuccessText": "验证成功",
|
"sliderSuccessText": "验证成功",
|
||||||
"rememberPwd": "记住密码",
|
"rememberPwd": "记住密码",
|
||||||
@@ -153,7 +178,11 @@
|
|||||||
"register": {
|
"register": {
|
||||||
"title": "创建账号",
|
"title": "创建账号",
|
||||||
"subTitle": "欢迎加入我们,请填写以下信息完成注册",
|
"subTitle": "欢迎加入我们,请填写以下信息完成注册",
|
||||||
"placeholder": ["请输入账号", "请输入密码", "请再次输入密码"],
|
"placeholder": [
|
||||||
|
"请输入账号",
|
||||||
|
"请输入密码",
|
||||||
|
"请再次输入密码"
|
||||||
|
],
|
||||||
"rule": [
|
"rule": [
|
||||||
"请再次输入密码",
|
"请再次输入密码",
|
||||||
"两次输入密码不一致!",
|
"两次输入密码不一致!",
|
||||||
@@ -288,10 +317,10 @@
|
|||||||
"evening": "晚上好!"
|
"evening": "晚上好!"
|
||||||
},
|
},
|
||||||
"exceptionPage": {
|
"exceptionPage": {
|
||||||
"gohome": "返回首页",
|
|
||||||
"403": "抱歉,您无权访问该页面",
|
"403": "抱歉,您无权访问该页面",
|
||||||
"404": "抱歉,您访问的页面不存在",
|
"404": "抱歉,您访问的页面不存在",
|
||||||
"500": "抱歉,服务器出错了"
|
"500": "抱歉,服务器出错了",
|
||||||
|
"gohome": "返回首页"
|
||||||
},
|
},
|
||||||
"menus": {
|
"menus": {
|
||||||
"login": {
|
"login": {
|
||||||
@@ -421,7 +450,11 @@
|
|||||||
"paymentSettings": "支付设置",
|
"paymentSettings": "支付设置",
|
||||||
"detailsOfPaymentConfiguration": "支付配置详情",
|
"detailsOfPaymentConfiguration": "支付配置详情",
|
||||||
"withdrawalSettings": "提现配置",
|
"withdrawalSettings": "提现配置",
|
||||||
"passwordSettings": "密码设置"
|
"passwordSettings": "密码设置",
|
||||||
|
"paymentMerchantPools": "商户池管理",
|
||||||
|
"paymentMerchantPoolsTabMerchants": "支付商户",
|
||||||
|
"paymentMerchantPoolsTabPools": "商户池",
|
||||||
|
"paymentMerchantPoolsTabWechatAuth": "微信授权配置"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* 权限验证相关工具函数
|
* 权限验证相关工具函数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -63,8 +63,17 @@ export const hasRoutePermission = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查允许访问的用户类型
|
||||||
|
if (route.meta?.allowedUserTypes) {
|
||||||
|
const allowedUserTypes = route.meta.allowedUserTypes as number[]
|
||||||
|
const userType = Number(userInfo.user_type)
|
||||||
|
if (!allowedUserTypes.includes(userType)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果路由没有设置额外的权限要求,直接通过
|
// 如果路由没有设置额外的权限要求,直接通过
|
||||||
if (!route.meta?.roles && !route.meta?.permissions) {
|
if (!route.meta?.roles && !route.meta?.permissions && !route.meta?.allowedUserTypes) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,3 +204,4 @@ export const buildLoginRedirect = (currentPath: string): string => {
|
|||||||
}
|
}
|
||||||
return `/auth/login?redirect=${encodeURIComponent(currentPath)}`
|
return `/auth/login?redirect=${encodeURIComponent(currentPath)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RoutesAlias } from '../routesAlias'
|
import { RoutesAlias } from '../routesAlias'
|
||||||
import { AppRouteRecord } from '@/types/router'
|
import { AppRouteRecord } from '@/types/router'
|
||||||
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
|
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
|
||||||
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
|
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
|
||||||
@@ -836,6 +836,17 @@ export const asyncRoutes: AppRouteRecord[] = [
|
|||||||
roles: ['R_SUPER', 'R_ADMIN']
|
roles: ['R_SUPER', 'R_ADMIN']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 支付商户与商户池管理
|
||||||
|
{
|
||||||
|
path: 'payment-merchant-pools',
|
||||||
|
name: 'PaymentMerchantPools',
|
||||||
|
component: RoutesAlias.PaymentMerchantPools,
|
||||||
|
meta: {
|
||||||
|
title: 'menus.settings.paymentMerchantPools',
|
||||||
|
keepAlive: true,
|
||||||
|
allowedUserTypes: [1, 2]
|
||||||
|
}
|
||||||
|
},
|
||||||
// 支付设置详情
|
// 支付设置详情
|
||||||
{
|
{
|
||||||
path: 'payment-settings/detail/:id',
|
path: 'payment-settings/detail/:id',
|
||||||
@@ -1096,3 +1107,5 @@ export const asyncRoutes: AppRouteRecord[] = [
|
|||||||
// ]
|
// ]
|
||||||
// },
|
// },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* 路由别名,方便快速找到页面,同时可以用作路由跳转
|
* 路由别名,方便快速找到页面,同时可以用作路由跳转
|
||||||
*/
|
*/
|
||||||
export enum RoutesAlias {
|
export enum RoutesAlias {
|
||||||
@@ -102,6 +102,7 @@ export enum RoutesAlias {
|
|||||||
WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置
|
WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置
|
||||||
PaymentSettings = '/settings/payment-settings', // 支付设置
|
PaymentSettings = '/settings/payment-settings', // 支付设置
|
||||||
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
|
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
|
||||||
|
PaymentMerchantPools = '/settings/payment-merchant-pools', // 支付商户与商户池管理
|
||||||
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
|
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
|
||||||
SystemConfigs = '/settings/system-configs', // 系统配置
|
SystemConfigs = '/settings/system-configs', // 系统配置
|
||||||
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
|
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
|
||||||
@@ -133,3 +134,4 @@ export enum RoutesAlias {
|
|||||||
|
|
||||||
// 主页路由 - 修改为资产信息页面
|
// 主页路由 - 修改为资产信息页面
|
||||||
export const HOME_PAGE = RoutesAlias.AssetInformation
|
export const HOME_PAGE = RoutesAlias.AssetInformation
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* API 类型统一导出
|
* API 类型统一导出
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -78,6 +78,9 @@ export * from './agentRecharge'
|
|||||||
// 支付设置相关
|
// 支付设置相关
|
||||||
export * from './paymentSettings'
|
export * from './paymentSettings'
|
||||||
|
|
||||||
|
// 支付商户与商户池相关
|
||||||
|
export * from './paymentMerchantPools'
|
||||||
|
|
||||||
// 系统配置相关
|
// 系统配置相关
|
||||||
export * from './systemConfig'
|
export * from './systemConfig'
|
||||||
|
|
||||||
@@ -131,3 +134,5 @@ export * from './audit'
|
|||||||
|
|
||||||
// 企业微信审批配置相关
|
// 企业微信审批配置相关
|
||||||
export * from './wecom'
|
export * from './wecom'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
207
src/types/api/paymentMerchantPools.ts
Normal file
207
src/types/api/paymentMerchantPools.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 支付商户与商户池相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseResponse, PaginationData, PaginationParams } from './common'
|
||||||
|
|
||||||
|
export type PaymentMerchantMethod = 'wechat' | 'alipay'
|
||||||
|
|
||||||
|
export type PaymentMerchantProviderType = 'wechat' | 'wechat_v2' | 'fuiou' | 'alipay'
|
||||||
|
|
||||||
|
export type PaymentPoolStrategy = 'amount' | 'count' | 'time'
|
||||||
|
|
||||||
|
export type PaymentStatisticCycle = 'round' | 'day' | 'month'
|
||||||
|
|
||||||
|
export type PaymentTimePeriodUnit = 'minute' | 'hour' | 'day'
|
||||||
|
|
||||||
|
export type PaymentCredentialValue = string | number | boolean | null
|
||||||
|
|
||||||
|
export type PaymentCredentials = Record<
|
||||||
|
string,
|
||||||
|
PaymentCredentialValue | PaymentCredentialValue[] | Record<string, PaymentCredentialValue>
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅用于前端表单编辑场景:携带本地 ID 用于稳定 v-for 渲染与局部删除。
|
||||||
|
* 提交时会去除 localId,仅保留后端契约的 key / value 字段。
|
||||||
|
*/
|
||||||
|
export interface PaymentCredentialEntry {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
/** 仅前端使用,提交时丢弃 */
|
||||||
|
localId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentMerchant {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
payment_method: PaymentMerchantMethod
|
||||||
|
provider_type: PaymentMerchantProviderType
|
||||||
|
merchant_identity: string
|
||||||
|
enabled: boolean
|
||||||
|
remark: string
|
||||||
|
credential_version: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentMerchantQueryParams extends PaginationParams {
|
||||||
|
/** 可选:按名称模糊筛选 */
|
||||||
|
name?: string
|
||||||
|
payment_method?: PaymentMerchantMethod
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentMerchantPageResult = PaginationData<PaymentMerchant>
|
||||||
|
|
||||||
|
export interface CreatePaymentMerchantRequest {
|
||||||
|
name: string
|
||||||
|
payment_method: PaymentMerchantMethod
|
||||||
|
provider_type: PaymentMerchantProviderType
|
||||||
|
merchant_identity: string
|
||||||
|
credentials: PaymentCredentials
|
||||||
|
enabled: boolean
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePaymentMerchantRequest {
|
||||||
|
name?: string
|
||||||
|
enabled?: boolean
|
||||||
|
remark?: string
|
||||||
|
/** 仅在显式更换凭证时填写;未填写时后端保持原值 */
|
||||||
|
credentials?: PaymentCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentMerchantPool {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
payment_method: PaymentMerchantMethod
|
||||||
|
member_ids: number[]
|
||||||
|
enabled: boolean
|
||||||
|
strategy: PaymentPoolStrategy
|
||||||
|
statistic_cycle: PaymentStatisticCycle
|
||||||
|
threshold_amount: number
|
||||||
|
threshold_count: number
|
||||||
|
time_period_started_at: string
|
||||||
|
time_period_unit: PaymentTimePeriodUnit
|
||||||
|
time_period_value: number
|
||||||
|
routing_epoch: number
|
||||||
|
remark: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentMerchantPoolQueryParams extends PaginationParams {
|
||||||
|
payment_method?: PaymentMerchantMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentMerchantPoolPageResult = PaginationData<PaymentMerchantPool>
|
||||||
|
|
||||||
|
export interface PaymentMerchantPoolPayload {
|
||||||
|
name: string
|
||||||
|
payment_method: PaymentMerchantMethod
|
||||||
|
member_ids: number[]
|
||||||
|
enabled: boolean
|
||||||
|
strategy: PaymentPoolStrategy
|
||||||
|
statistic_cycle?: PaymentStatisticCycle
|
||||||
|
threshold_amount?: number
|
||||||
|
threshold_count?: number
|
||||||
|
time_period_started_at?: string
|
||||||
|
time_period_unit?: PaymentTimePeriodUnit
|
||||||
|
time_period_value?: number
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatAuthorizationConfig {
|
||||||
|
enabled: boolean
|
||||||
|
miniapp_app_id: string
|
||||||
|
oa_app_id: string
|
||||||
|
oa_oauth_redirect_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWechatAuthorizationRequest {
|
||||||
|
enabled: boolean
|
||||||
|
miniapp_app_id?: string
|
||||||
|
miniapp_app_secret?: string
|
||||||
|
oa_app_id?: string
|
||||||
|
oa_app_secret?: string
|
||||||
|
oa_token?: string
|
||||||
|
oa_aes_key?: string
|
||||||
|
oa_oauth_redirect_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentMerchantResponse = BaseResponse<PaymentMerchant>
|
||||||
|
export type PaymentMerchantPageResponse = BaseResponse<PaymentMerchantPageResult>
|
||||||
|
export type PaymentMerchantPoolResponse = BaseResponse<PaymentMerchantPool>
|
||||||
|
export type PaymentMerchantPoolPageResponse = BaseResponse<PaymentMerchantPoolPageResult>
|
||||||
|
export type WechatAuthorizationResponse = BaseResponse<WechatAuthorizationConfig>
|
||||||
|
|
||||||
|
export const PAYMENT_METHOD_OPTIONS: Array<{ label: string; value: PaymentMerchantMethod }> = [
|
||||||
|
{ label: '微信支付', value: 'wechat' },
|
||||||
|
{ label: '支付宝', value: 'alipay' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PAYMENT_PROVIDER_OPTIONS: Array<{
|
||||||
|
label: string
|
||||||
|
value: PaymentMerchantProviderType
|
||||||
|
paymentMethod: PaymentMerchantMethod
|
||||||
|
}> = [
|
||||||
|
{ label: '微信直连', value: 'wechat', paymentMethod: 'wechat' },
|
||||||
|
{ label: '微信直连 V2', value: 'wechat_v2', paymentMethod: 'wechat' },
|
||||||
|
{ label: '富友支付', value: 'fuiou', paymentMethod: 'wechat' },
|
||||||
|
{ label: '支付宝', value: 'alipay', paymentMethod: 'alipay' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PAYMENT_POOL_STRATEGY_OPTIONS: Array<{ label: string; value: PaymentPoolStrategy }> = [
|
||||||
|
{ label: '按金额轮换', value: 'amount' },
|
||||||
|
{ label: '按笔数轮换', value: 'count' },
|
||||||
|
{ label: '按时间轮换', value: 'time' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PAYMENT_STATISTIC_CYCLE_OPTIONS: Array<{
|
||||||
|
label: string
|
||||||
|
value: PaymentStatisticCycle
|
||||||
|
}> = [
|
||||||
|
{ label: '每轮', value: 'round' },
|
||||||
|
{ label: '每天', value: 'day' },
|
||||||
|
{ label: '每月', value: 'month' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PAYMENT_TIME_PERIOD_UNIT_OPTIONS: Array<{
|
||||||
|
label: string
|
||||||
|
value: PaymentTimePeriodUnit
|
||||||
|
}> = [
|
||||||
|
{ label: '分钟', value: 'minute' },
|
||||||
|
{ label: '小时', value: 'hour' },
|
||||||
|
{ label: '天', value: 'day' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getPaymentMethodLabel(value?: PaymentMerchantMethod): string {
|
||||||
|
return PAYMENT_METHOD_OPTIONS.find((item) => item.value === value)?.label || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaymentProviderLabel(value?: PaymentMerchantProviderType): string {
|
||||||
|
return PAYMENT_PROVIDER_OPTIONS.find((item) => item.value === value)?.label || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaymentPoolStrategyLabel(value?: PaymentPoolStrategy): string {
|
||||||
|
return PAYMENT_POOL_STRATEGY_OPTIONS.find((item) => item.value === value)?.label || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaymentStatisticCycleLabel(value?: PaymentStatisticCycle): string {
|
||||||
|
return PAYMENT_STATISTIC_CYCLE_OPTIONS.find((item) => item.value === value)?.label || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaymentTimePeriodUnitLabel(value?: PaymentTimePeriodUnit): string {
|
||||||
|
return PAYMENT_TIME_PERIOD_UNIT_OPTIONS.find((item) => item.value === value)?.label || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商户池在编辑场景下可选成员:来自同支付方式且已启用的支付商户
|
||||||
|
*/
|
||||||
|
export interface PaymentMerchantPoolMemberOption {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
payment_method: PaymentMerchantMethod
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
|
|||||||
exportTaskScene?: ExportTaskScene
|
exportTaskScene?: ExportTaskScene
|
||||||
/** 是否固定标签页 */
|
/** 是否固定标签页 */
|
||||||
fixedTab?: boolean
|
fixedTab?: boolean
|
||||||
|
/** 仅允许指定 user_type 访问(1=超级管理员,2=平台用户),为空时不做用户类型限制 */
|
||||||
|
allowedUserTypes?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扩展路由记录
|
// 扩展路由记录
|
||||||
|
|||||||
108
src/utils/business/paymentMerchantPool.ts
Normal file
108
src/utils/business/paymentMerchantPool.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type {
|
||||||
|
PaymentCredentialEntry,
|
||||||
|
PaymentCredentials,
|
||||||
|
PaymentMerchant,
|
||||||
|
PaymentMerchantMethod,
|
||||||
|
PaymentMerchantPoolPayload,
|
||||||
|
PaymentPoolStrategy
|
||||||
|
} from '@/types/api/paymentMerchantPools'
|
||||||
|
|
||||||
|
export const PAYMENT_NO_AVAILABLE_MERCHANT_MESSAGE = '暂无可用商户'
|
||||||
|
export const PAYMENT_FAILED_REISSUE_MESSAGE = '支付失败,请重新发起支付'
|
||||||
|
|
||||||
|
export interface PaymentPoolFormModel {
|
||||||
|
name: string
|
||||||
|
payment_method: PaymentMerchantMethod
|
||||||
|
member_ids: number[]
|
||||||
|
enabled: boolean
|
||||||
|
strategy: PaymentPoolStrategy
|
||||||
|
statistic_cycle: PaymentMerchantPoolPayload['statistic_cycle']
|
||||||
|
threshold_amount_yuan: number | undefined
|
||||||
|
threshold_count: number | undefined
|
||||||
|
time_period_started_at: string
|
||||||
|
time_period_unit: PaymentMerchantPoolPayload['time_period_unit']
|
||||||
|
time_period_value: number | undefined
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPlatformUserType = (userType?: number | string | null): boolean =>
|
||||||
|
[1, 2].includes(Number(userType))
|
||||||
|
|
||||||
|
export const credentialEntriesToObject = (
|
||||||
|
entries: PaymentCredentialEntry[]
|
||||||
|
): PaymentCredentials => {
|
||||||
|
return entries.reduce<PaymentCredentials>((credentials, entry) => {
|
||||||
|
const key = entry.key.trim()
|
||||||
|
if (!key || !entry.value) return credentials
|
||||||
|
credentials[key] = entry.value
|
||||||
|
return credentials
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasCredentialEntries = (entries: PaymentCredentialEntry[]): boolean =>
|
||||||
|
entries.some((entry) => entry.key.trim() && entry.value)
|
||||||
|
|
||||||
|
export const sortMerchantsByMemberIds = (
|
||||||
|
merchants: PaymentMerchant[],
|
||||||
|
memberIds: number[]
|
||||||
|
): PaymentMerchant[] => {
|
||||||
|
const merchantMap = new Map(merchants.map((merchant) => [Number(merchant.id), merchant]))
|
||||||
|
return memberIds.map((id) => merchantMap.get(Number(id))).filter(Boolean) as PaymentMerchant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const yuanToFen = (value?: number | null): number | undefined => {
|
||||||
|
if (value === undefined || value === null || Number.isNaN(Number(value))) return undefined
|
||||||
|
return Math.round(Number(value) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildPaymentPoolPayload = (form: PaymentPoolFormModel): PaymentMerchantPoolPayload => {
|
||||||
|
const payload: PaymentMerchantPoolPayload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
payment_method: form.payment_method,
|
||||||
|
member_ids: [...form.member_ids],
|
||||||
|
enabled: form.enabled,
|
||||||
|
strategy: form.strategy,
|
||||||
|
remark: form.remark.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.strategy === 'amount') {
|
||||||
|
payload.statistic_cycle = form.statistic_cycle
|
||||||
|
payload.threshold_amount = yuanToFen(form.threshold_amount_yuan)
|
||||||
|
} else if (form.strategy === 'count') {
|
||||||
|
payload.statistic_cycle = form.statistic_cycle
|
||||||
|
payload.threshold_count = form.threshold_count
|
||||||
|
} else {
|
||||||
|
payload.time_period_started_at = form.time_period_started_at
|
||||||
|
payload.time_period_unit = form.time_period_unit
|
||||||
|
payload.time_period_value = form.time_period_value
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolvePaymentFailureMessage = (
|
||||||
|
errorCode: string | number | undefined | null,
|
||||||
|
noAvailableMerchantCode: string | number | undefined | null
|
||||||
|
): string => {
|
||||||
|
if (
|
||||||
|
noAvailableMerchantCode !== undefined &&
|
||||||
|
noAvailableMerchantCode !== null &&
|
||||||
|
errorCode !== undefined &&
|
||||||
|
errorCode !== null &&
|
||||||
|
String(errorCode) === String(noAvailableMerchantCode)
|
||||||
|
) {
|
||||||
|
return PAYMENT_NO_AVAILABLE_MERCHANT_MESSAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
return PAYMENT_FAILED_REISSUE_MESSAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isNoAvailableMerchantError = (
|
||||||
|
errorCode: string | number | undefined | null,
|
||||||
|
noAvailableMerchantCode: string | number | undefined | null
|
||||||
|
): boolean =>
|
||||||
|
noAvailableMerchantCode !== undefined &&
|
||||||
|
noAvailableMerchantCode !== null &&
|
||||||
|
errorCode !== undefined &&
|
||||||
|
errorCode !== null &&
|
||||||
|
String(errorCode) === String(noAvailableMerchantCode)
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
<template>
|
||||||
|
<div class="merchant-management">
|
||||||
|
<ArtSearchBar
|
||||||
|
v-model:filter="searchForm"
|
||||||
|
:items="searchItems"
|
||||||
|
:show-expand="false"
|
||||||
|
@reset="handleReset"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElCard shadow="never" class="art-table-card">
|
||||||
|
<ArtTableHeader
|
||||||
|
:columnList="columnOptions"
|
||||||
|
v-model:columns="columnChecks"
|
||||||
|
@refresh="loadMerchants"
|
||||||
|
>
|
||||||
|
<template #left>
|
||||||
|
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
|
||||||
|
新增支付商户
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ArtTableHeader>
|
||||||
|
|
||||||
|
<ArtTable
|
||||||
|
ref="tableRef"
|
||||||
|
row-key="id"
|
||||||
|
:loading="loading"
|
||||||
|
:data="merchants"
|
||||||
|
:currentPage="pagination.page"
|
||||||
|
:pageSize="pagination.page_size"
|
||||||
|
:total="pagination.total"
|
||||||
|
:marginTop="10"
|
||||||
|
:actions="getActions"
|
||||||
|
:actionsWidth="180"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||||
|
</template>
|
||||||
|
</ArtTable>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<ElDrawer
|
||||||
|
v-model="formDrawerVisible"
|
||||||
|
:title="formMode === 'create' ? '新增支付商户' : '编辑支付商户'"
|
||||||
|
size="680px"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="clearSensitiveForm"
|
||||||
|
>
|
||||||
|
<ElAlert
|
||||||
|
title="支付凭证仅用于本次写入,页面不会回显、缓存或记录凭证内容。"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
|
||||||
|
<ElFormItem label="商户名称" prop="name">
|
||||||
|
<ElInput v-model="form.name" maxlength="100" placeholder="请输入商户名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="支付方式" prop="payment_method">
|
||||||
|
<ElSelect
|
||||||
|
v-model="form.payment_method"
|
||||||
|
:disabled="formMode === 'edit'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handlePaymentMethodChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in PAYMENT_METHOD_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="服务商类型" prop="provider_type">
|
||||||
|
<ElSelect
|
||||||
|
v-model="form.provider_type"
|
||||||
|
:disabled="formMode === 'edit'"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in providerOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="商户标识" prop="merchant_identity">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.merchant_identity"
|
||||||
|
:disabled="formMode === 'edit'"
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="请输入商户号或应用标识"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="启停状态">
|
||||||
|
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="备注">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="formMode === 'edit'" label="凭证状态">
|
||||||
|
<ElTag :type="credentialConfigured ? 'success' : 'info'">
|
||||||
|
{{ credentialConfigured ? '已配置' : '未配置' }}
|
||||||
|
</ElTag>
|
||||||
|
<span class="credential-version">版本:{{ form.credential_version || '-' }}</span>
|
||||||
|
<ElButton type="primary" link @click="startCredentialReplacement">更换凭证</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
<template v-if="showCredentialEditor">
|
||||||
|
<ElDivider content-position="left">写入支付凭证</ElDivider>
|
||||||
|
<ElAlert
|
||||||
|
title="凭证值以密码方式输入,提交后立即清空。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<div class="credential-list">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in form.credentials"
|
||||||
|
:key="entry.localId"
|
||||||
|
class="credential-row"
|
||||||
|
>
|
||||||
|
<ElInput
|
||||||
|
v-model="entry.key"
|
||||||
|
placeholder="凭证字段名"
|
||||||
|
maxlength="100"
|
||||||
|
aria-label="凭证字段名"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-model="entry.value"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="凭证值(不可回显)"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-label="凭证值"
|
||||||
|
/>
|
||||||
|
<ElButton
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
:icon="Delete"
|
||||||
|
aria-label="删除凭证字段"
|
||||||
|
@click="removeCredential(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton type="primary" plain :icon="Plus" @click="addCredential">添加凭证字段</ElButton>
|
||||||
|
<div v-if="credentialError" class="field-error" role="alert">
|
||||||
|
{{ credentialError }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="formDrawerVisible = false">取消</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="!canManage"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDrawer>
|
||||||
|
|
||||||
|
<ElDrawer v-model="detailDrawerVisible" title="支付商户详情" size="560px">
|
||||||
|
<ElDescriptions v-if="detail" :column="1" border>
|
||||||
|
<ElDescriptionsItem label="商户名称">{{ detail.name }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="支付方式">{{
|
||||||
|
getPaymentMethodLabel(detail.payment_method)
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="服务商类型">{{
|
||||||
|
getPaymentProviderLabel(detail.provider_type)
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="商户标识">{{ detail.merchant_identity }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="启停状态">{{
|
||||||
|
detail.enabled ? '启用' : '停用'
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="支付凭证">{{
|
||||||
|
Number(detail.credential_version) > 0 ? '已配置' : '未配置'
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="凭证版本">{{
|
||||||
|
detail.credential_version || '-'
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="备注">{{ detail.remark || '-' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="更新时间">{{
|
||||||
|
formatDateTime(detail.updated_at)
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||||
|
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { PaymentMerchantPoolsService } from '@/api/modules'
|
||||||
|
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
|
import type { SearchFormItem } from '@/types/component'
|
||||||
|
import {
|
||||||
|
PAYMENT_METHOD_OPTIONS,
|
||||||
|
PAYMENT_PROVIDER_OPTIONS,
|
||||||
|
getPaymentMethodLabel,
|
||||||
|
getPaymentProviderLabel,
|
||||||
|
type PaymentCredentialEntry,
|
||||||
|
type PaymentCredentials,
|
||||||
|
type PaymentMerchant,
|
||||||
|
type PaymentMerchantMethod,
|
||||||
|
type PaymentMerchantPageResult,
|
||||||
|
type PaymentMerchantProviderType,
|
||||||
|
type PaymentMerchantQueryParams
|
||||||
|
} from '@/types/api/paymentMerchantPools'
|
||||||
|
import { formatDateTime } from '@/utils/business/format'
|
||||||
|
|
||||||
|
defineOptions({ name: 'PaymentMerchantManagement' })
|
||||||
|
|
||||||
|
type FilterVo = string | number | undefined | null | unknown[]
|
||||||
|
|
||||||
|
const { isPlatformAccount } = usePermission()
|
||||||
|
const canManage = computed(() => isPlatformAccount.value)
|
||||||
|
|
||||||
|
// 列表查询
|
||||||
|
const searchForm = reactive<Record<string, FilterVo>>({
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
name: '',
|
||||||
|
payment_method: null,
|
||||||
|
enabled: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const merchants = ref<PaymentMerchant[]>([])
|
||||||
|
const pagination = reactive({ page: 1, page_size: 10, total: 0 })
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const searchItems: SearchFormItem[] = [
|
||||||
|
{
|
||||||
|
label: '商户名称',
|
||||||
|
prop: 'name',
|
||||||
|
type: 'input',
|
||||||
|
config: { clearable: true, placeholder: '请输入商户名称' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '支付方式',
|
||||||
|
prop: 'payment_method',
|
||||||
|
type: 'select',
|
||||||
|
options: PAYMENT_METHOD_OPTIONS.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.value
|
||||||
|
})),
|
||||||
|
config: { clearable: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '启停状态',
|
||||||
|
prop: 'enabled',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: '启用', value: true },
|
||||||
|
{ label: '停用', value: false }
|
||||||
|
],
|
||||||
|
config: { clearable: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const columnOptions = [
|
||||||
|
{ label: '商户名称', prop: 'name', minWidth: 180 },
|
||||||
|
{
|
||||||
|
label: '支付方式',
|
||||||
|
prop: 'payment_method',
|
||||||
|
width: 110,
|
||||||
|
formatter: (row: PaymentMerchant) => getPaymentMethodLabel(row.payment_method)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '服务商类型',
|
||||||
|
prop: 'provider_type',
|
||||||
|
width: 140,
|
||||||
|
formatter: (row: PaymentMerchant) => getPaymentProviderLabel(row.provider_type)
|
||||||
|
},
|
||||||
|
{ label: '商户标识', prop: 'merchant_identity', minWidth: 180 },
|
||||||
|
{
|
||||||
|
label: '启停状态',
|
||||||
|
prop: 'enabled',
|
||||||
|
width: 110,
|
||||||
|
formatter: (row: PaymentMerchant) =>
|
||||||
|
h(
|
||||||
|
ElTag,
|
||||||
|
{ type: row.enabled ? 'success' : 'info' },
|
||||||
|
{ default: () => (row.enabled ? '启用' : '停用') }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '凭证状态',
|
||||||
|
prop: 'credential_version',
|
||||||
|
width: 110,
|
||||||
|
formatter: (row: PaymentMerchant) =>
|
||||||
|
h(
|
||||||
|
ElTag,
|
||||||
|
{ type: Number(row.credential_version) > 0 ? 'success' : 'info' },
|
||||||
|
{ default: () => (Number(row.credential_version) > 0 ? '已配置' : '未配置') }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '更新时间',
|
||||||
|
prop: 'updated_at',
|
||||||
|
width: 170,
|
||||||
|
formatter: (row: PaymentMerchant) => formatDateTime(row.updated_at)
|
||||||
|
},
|
||||||
|
{ label: '备注', prop: 'remark', minWidth: 160 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const { columnChecks, columns } = useCheckedColumns(() => columnOptions)
|
||||||
|
|
||||||
|
// 列表加载
|
||||||
|
const loadMerchants = async () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: PaymentMerchantQueryParams = {
|
||||||
|
page: pagination.page,
|
||||||
|
page_size: pagination.page_size
|
||||||
|
}
|
||||||
|
const name = searchForm.name as string | undefined
|
||||||
|
if (name) params.name = name
|
||||||
|
if (searchForm.payment_method)
|
||||||
|
params.payment_method = searchForm.payment_method as PaymentMerchantMethod
|
||||||
|
if (typeof searchForm.enabled === 'boolean') params.enabled = searchForm.enabled
|
||||||
|
|
||||||
|
const res = await PaymentMerchantPoolsService.getPaymentMerchants(params)
|
||||||
|
if (res.code === 0) {
|
||||||
|
const data = (res.data as PaymentMerchantPageResult) || {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
size: 10
|
||||||
|
}
|
||||||
|
merchants.value = data.items || []
|
||||||
|
pagination.total = data.total || 0
|
||||||
|
pagination.page = data.page || pagination.page
|
||||||
|
pagination.page_size = data.size || pagination.page_size
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载支付商户失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
loadMerchants()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.name = ''
|
||||||
|
searchForm.payment_method = null
|
||||||
|
searchForm.enabled = null
|
||||||
|
pagination.page = 1
|
||||||
|
loadMerchants()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pagination.page_size = size
|
||||||
|
pagination.page = 1
|
||||||
|
loadMerchants()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (page: number) => {
|
||||||
|
pagination.page = page
|
||||||
|
loadMerchants()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单
|
||||||
|
type FormMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
const formDrawerVisible = ref(false)
|
||||||
|
const formMode = ref<FormMode>('create')
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const showCredentialEditor = ref(false)
|
||||||
|
const credentialError = ref('')
|
||||||
|
|
||||||
|
const initialFormState = () => ({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
payment_method: 'wechat' as PaymentMerchantMethod,
|
||||||
|
provider_type: 'wechat' as PaymentMerchantProviderType,
|
||||||
|
merchant_identity: '',
|
||||||
|
enabled: true,
|
||||||
|
remark: '',
|
||||||
|
credential_version: 0,
|
||||||
|
credentials: [] as PaymentCredentialEntry[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive(initialFormState())
|
||||||
|
|
||||||
|
const providerOptions = computed(() =>
|
||||||
|
PAYMENT_PROVIDER_OPTIONS.filter((option) => option.paymentMethod === form.payment_method)
|
||||||
|
)
|
||||||
|
|
||||||
|
const credentialConfigured = computed(() => Number(form.credential_version) > 0)
|
||||||
|
|
||||||
|
const formRules = reactive<FormRules>({
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入商户名称', trigger: 'blur' },
|
||||||
|
{ max: 100, message: '商户名称不超过 100 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
|
||||||
|
provider_type: [{ required: true, message: '请选择服务商类型', trigger: 'change' }],
|
||||||
|
merchant_identity: [
|
||||||
|
{ required: true, message: '请输入商户标识', trigger: 'blur' },
|
||||||
|
{ max: 128, message: '商户标识不超过 128 个字符', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateLocalId = () =>
|
||||||
|
`cred-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
|
const resetSensitiveForm = () => {
|
||||||
|
form.credentials = []
|
||||||
|
showCredentialEditor.value = false
|
||||||
|
credentialError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSensitiveForm = () => {
|
||||||
|
resetSensitiveForm()
|
||||||
|
form.id = 0
|
||||||
|
form.credential_version = 0
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildCredentialPayload = (): PaymentCredentials | null => {
|
||||||
|
const credentials: Record<string, string> = {}
|
||||||
|
for (const entry of form.credentials) {
|
||||||
|
const key = entry.key.trim()
|
||||||
|
if (!key) {
|
||||||
|
credentialError.value = '请填写凭证字段名'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!entry.value) {
|
||||||
|
credentialError.value = `请填写凭证字段 ${key} 的值`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(credentials, key)) {
|
||||||
|
credentialError.value = `凭证字段 ${key} 重复`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
credentials[key] = entry.value
|
||||||
|
}
|
||||||
|
if (form.credentials.length === 0) {
|
||||||
|
credentialError.value = '请至少添加一个凭证字段'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
credentialError.value = ''
|
||||||
|
return credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCreateDrawer = () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
Object.assign(form, initialFormState())
|
||||||
|
formDrawerVisible.value = true
|
||||||
|
formMode.value = 'create'
|
||||||
|
showCredentialEditor.value = true
|
||||||
|
addCredential()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCredentialReplacement = () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
form.credentials = []
|
||||||
|
showCredentialEditor.value = true
|
||||||
|
addCredential()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCredential = () => {
|
||||||
|
form.credentials.push({ localId: generateLocalId(), key: '', value: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCredential = (index: number) => {
|
||||||
|
form.credentials.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaymentMethodChange = (value: PaymentMerchantMethod) => {
|
||||||
|
form.payment_method = value
|
||||||
|
const firstMatch = PAYMENT_PROVIDER_OPTIONS.find((option) => option.paymentMethod === value)
|
||||||
|
if (firstMatch) {
|
||||||
|
form.provider_type = firstMatch.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formMode.value === 'create' || showCredentialEditor.value) {
|
||||||
|
const credentials = buildCredentialPayload()
|
||||||
|
if (!credentials) return
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
if (formMode.value === 'create') {
|
||||||
|
await PaymentMerchantPoolsService.createPaymentMerchant({
|
||||||
|
name: form.name.trim(),
|
||||||
|
payment_method: form.payment_method,
|
||||||
|
provider_type: form.provider_type,
|
||||||
|
merchant_identity: form.merchant_identity.trim(),
|
||||||
|
credentials,
|
||||||
|
enabled: form.enabled,
|
||||||
|
remark: form.remark?.trim() || ''
|
||||||
|
})
|
||||||
|
ElMessage.success('支付商户创建成功')
|
||||||
|
} else {
|
||||||
|
await PaymentMerchantPoolsService.updatePaymentMerchant(form.id, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
enabled: form.enabled,
|
||||||
|
remark: form.remark?.trim() || '',
|
||||||
|
credentials
|
||||||
|
})
|
||||||
|
ElMessage.success('支付凭证更新成功')
|
||||||
|
}
|
||||||
|
formDrawerVisible.value = false
|
||||||
|
await loadMerchants()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交支付商户失败:', error)
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
await PaymentMerchantPoolsService.updatePaymentMerchant(form.id, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
enabled: form.enabled,
|
||||||
|
remark: form.remark?.trim() || ''
|
||||||
|
})
|
||||||
|
ElMessage.success('支付商户已更新')
|
||||||
|
formDrawerVisible.value = false
|
||||||
|
await loadMerchants()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新支付商户失败:', error)
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情 / 启停 / 删除
|
||||||
|
const detailDrawerVisible = ref(false)
|
||||||
|
const detail = ref<PaymentMerchant | null>(null)
|
||||||
|
const tableRef = ref()
|
||||||
|
|
||||||
|
const showDetail = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const res = await PaymentMerchantPoolsService.getPaymentMerchantById(id)
|
||||||
|
if (res.code === 0) {
|
||||||
|
detail.value = res.data
|
||||||
|
detailDrawerVisible.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载支付商户详情失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDrawer = async (id: number) => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
try {
|
||||||
|
const res = await PaymentMerchantPoolsService.getPaymentMerchantById(id)
|
||||||
|
if (res.code !== 0) return
|
||||||
|
const merchant = res.data
|
||||||
|
Object.assign(form, initialFormState(), {
|
||||||
|
id: merchant.id,
|
||||||
|
name: merchant.name,
|
||||||
|
payment_method: merchant.payment_method,
|
||||||
|
provider_type: merchant.provider_type,
|
||||||
|
merchant_identity: merchant.merchant_identity,
|
||||||
|
enabled: merchant.enabled,
|
||||||
|
remark: merchant.remark || '',
|
||||||
|
credential_version: merchant.credential_version
|
||||||
|
})
|
||||||
|
formMode.value = 'edit'
|
||||||
|
showCredentialEditor.value = false
|
||||||
|
formDrawerVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载支付商户详情失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleEnabled = async (merchant: PaymentMerchant) => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
const target = !merchant.enabled
|
||||||
|
const action = target ? '启用' : '停用'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认${action}商户 “${merchant.name}”?`, '操作确认', {
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await PaymentMerchantPoolsService.updatePaymentMerchant(merchant.id, {
|
||||||
|
enabled: target
|
||||||
|
})
|
||||||
|
if (res.code === 0) {
|
||||||
|
ElMessage.success(`已${action}`)
|
||||||
|
await loadMerchants()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${action}支付商户失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteMerchant = async (merchant: PaymentMerchant) => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除支付商户 “${merchant.name}”?该操作不可恢复。`,
|
||||||
|
'删除确认',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await PaymentMerchantPoolsService.deletePaymentMerchant(merchant.id)
|
||||||
|
if (res.code === 0) {
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
if (pagination.total > 1 && merchants.value.length === 1 && pagination.page > 1) {
|
||||||
|
pagination.page -= 1
|
||||||
|
}
|
||||||
|
await loadMerchants()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除支付商户失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActions = (row: PaymentMerchant) => [
|
||||||
|
{
|
||||||
|
label: '详情',
|
||||||
|
type: 'primary' as const,
|
||||||
|
handler: () => showDetail(row.id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '编辑',
|
||||||
|
type: 'primary' as const,
|
||||||
|
handler: () => openEditDrawer(row.id),
|
||||||
|
permission: canManage.value ? '' : 'hidden'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: row.enabled ? '停用' : '启用',
|
||||||
|
type: 'primary' as const,
|
||||||
|
handler: () => toggleEnabled(row),
|
||||||
|
permission: canManage.value ? '' : 'hidden'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
type: 'danger' as const,
|
||||||
|
handler: () => confirmDeleteMerchant(row),
|
||||||
|
permission: canManage.value ? '' : 'hidden'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (canManage.value) {
|
||||||
|
loadMerchants()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resetSensitiveForm()
|
||||||
|
merchants.value = []
|
||||||
|
detail.value = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.merchant-management {
|
||||||
|
.credential-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-version {
|
||||||
|
margin: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,871 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pool-management">
|
||||||
|
<ArtSearchBar
|
||||||
|
v-model:filter="searchForm"
|
||||||
|
:items="searchItems"
|
||||||
|
:show-expand="false"
|
||||||
|
@reset="handleReset"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElCard shadow="never" class="art-table-card">
|
||||||
|
<ArtTableHeader
|
||||||
|
:columnList="columnOptions"
|
||||||
|
v-model:columns="columnChecks"
|
||||||
|
@refresh="loadPools"
|
||||||
|
>
|
||||||
|
<template #left>
|
||||||
|
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
|
||||||
|
新增商户池
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ArtTableHeader>
|
||||||
|
|
||||||
|
<ArtTable
|
||||||
|
ref="tableRef"
|
||||||
|
row-key="id"
|
||||||
|
:loading="loading"
|
||||||
|
:data="pools"
|
||||||
|
:currentPage="pagination.page"
|
||||||
|
:pageSize="pagination.page_size"
|
||||||
|
:total="pagination.total"
|
||||||
|
:marginTop="10"
|
||||||
|
:actions="getActions"
|
||||||
|
:actionsWidth="220"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||||
|
</template>
|
||||||
|
</ArtTable>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<ElDrawer
|
||||||
|
v-model="formDrawerVisible"
|
||||||
|
:title="formMode === 'create' ? '新增商户池' : '编辑商户池'"
|
||||||
|
size="780px"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="resetForm"
|
||||||
|
>
|
||||||
|
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
|
||||||
|
<ElFormItem label="商户池名称" prop="name">
|
||||||
|
<ElInput v-model="form.name" maxlength="100" placeholder="请输入商户池名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="支付方式" prop="payment_method">
|
||||||
|
<ElSelect
|
||||||
|
v-model="form.payment_method"
|
||||||
|
:disabled="formMode === 'edit'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handlePaymentMethodChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in PAYMENT_METHOD_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="成员商户" prop="member_ids">
|
||||||
|
<VueDraggable
|
||||||
|
v-if="orderedMemberIds.length"
|
||||||
|
v-model="orderedMemberIds"
|
||||||
|
:animation="150"
|
||||||
|
handle=".drag-handle"
|
||||||
|
class="pool-member-list"
|
||||||
|
>
|
||||||
|
<div v-for="memberId in orderedMemberIds" :key="memberId" class="pool-member-row">
|
||||||
|
<ElIcon class="drag-handle"><Rank /></ElIcon>
|
||||||
|
<span class="member-name">{{ getMemberName(memberId) }}</span>
|
||||||
|
<ElButton type="danger" link :icon="Delete" @click="removeMember(memberId)" />
|
||||||
|
</div>
|
||||||
|
</VueDraggable>
|
||||||
|
<div v-else class="pool-member-empty">尚未选择成员,请从下方选择</div>
|
||||||
|
<ElDivider />
|
||||||
|
<ElSelect
|
||||||
|
v-model="pendingMemberId"
|
||||||
|
filterable
|
||||||
|
placeholder="选择同支付方式的商户"
|
||||||
|
style="width: 100%"
|
||||||
|
:disabled="!form.payment_method"
|
||||||
|
@change="appendMember"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in availableMerchantOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
:disabled="form.member_ids.includes(item.id)"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<div v-if="memberError" class="field-error" role="alert">{{ memberError }}</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="轮询策略" prop="strategy">
|
||||||
|
<ElSelect v-model="form.strategy" style="width: 100%">
|
||||||
|
<ElOption
|
||||||
|
v-for="item in PAYMENT_POOL_STRATEGY_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem
|
||||||
|
v-if="form.strategy === 'amount' || form.strategy === 'count'"
|
||||||
|
label="统计周期"
|
||||||
|
prop="statistic_cycle"
|
||||||
|
>
|
||||||
|
<ElSelect v-model="form.statistic_cycle" style="width: 100%">
|
||||||
|
<ElOption
|
||||||
|
v-for="item in PAYMENT_STATISTIC_CYCLE_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem
|
||||||
|
v-if="form.strategy === 'amount'"
|
||||||
|
label="金额阈值(元)"
|
||||||
|
prop="threshold_amount_yuan"
|
||||||
|
>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="form.threshold_amount_yuan"
|
||||||
|
:min="0.01"
|
||||||
|
:precision="2"
|
||||||
|
:step="100"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-else-if="form.strategy === 'count'" label="笔数阈值" prop="threshold_count">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="form.threshold_count"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
:step="1"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<template v-else-if="form.strategy === 'time'">
|
||||||
|
<ElFormItem label="时间单位" prop="time_period_unit">
|
||||||
|
<ElSelect v-model="form.time_period_unit" style="width: 100%">
|
||||||
|
<ElOption
|
||||||
|
v-for="item in PAYMENT_TIME_PERIOD_UNIT_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="时间长度" prop="time_period_value">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="form.time_period_value"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
:step="1"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="时间起点" prop="time_period_started_at">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="form.time_period_started_at"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="请选择时间起点"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
<ElFormItem label="启停状态">
|
||||||
|
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="备注">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="formDrawerVisible = false">取消</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="!canManage"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDrawer>
|
||||||
|
|
||||||
|
<ElDrawer v-model="detailDrawerVisible" title="商户池详情" size="640px">
|
||||||
|
<ElDescriptions v-if="detail" :column="1" border>
|
||||||
|
<ElDescriptionsItem label="商户池名称">{{ detail.name }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="支付方式">{{
|
||||||
|
getPaymentMethodLabel(detail.payment_method)
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="成员数量">{{ detail.member_ids.length }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="轮询策略">{{
|
||||||
|
getPaymentPoolStrategyLabel(detail.strategy)
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
v-if="detail.strategy === 'amount' || detail.strategy === 'count'"
|
||||||
|
label="统计周期"
|
||||||
|
>
|
||||||
|
{{ getPaymentStatisticCycleLabel(detail.statistic_cycle) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem v-if="detail.strategy === 'amount'" label="金额阈值">
|
||||||
|
{{ (Number(detail.threshold_amount) / 100).toFixed(2) }} 元
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem v-if="detail.strategy === 'count'" label="笔数阈值">
|
||||||
|
{{ detail.threshold_count }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem v-if="detail.strategy === 'time'" label="时间周期">
|
||||||
|
{{ detail.time_period_value }}
|
||||||
|
{{ getPaymentTimePeriodUnitLabel(detail.time_period_unit) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="启停状态">
|
||||||
|
{{ detail.enabled ? '启用' : '停用' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="路由世代">v{{ detail.routing_epoch }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="备注">{{ detail.remark || '-' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="更新时间">{{
|
||||||
|
formatDateTime(detail.updated_at)
|
||||||
|
}}</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { Delete, Plus, Rank } from '@element-plus/icons-vue'
|
||||||
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
|
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { PaymentMerchantPoolsService } from '@/api/modules'
|
||||||
|
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
|
import type { SearchFormItem } from '@/types/component'
|
||||||
|
import {
|
||||||
|
PAYMENT_METHOD_OPTIONS,
|
||||||
|
PAYMENT_POOL_STRATEGY_OPTIONS,
|
||||||
|
PAYMENT_STATISTIC_CYCLE_OPTIONS,
|
||||||
|
PAYMENT_TIME_PERIOD_UNIT_OPTIONS,
|
||||||
|
getPaymentMethodLabel,
|
||||||
|
getPaymentPoolStrategyLabel,
|
||||||
|
getPaymentStatisticCycleLabel,
|
||||||
|
getPaymentTimePeriodUnitLabel,
|
||||||
|
type PaymentMerchantMethod,
|
||||||
|
type PaymentMerchantPool,
|
||||||
|
type PaymentMerchantPoolPageResult,
|
||||||
|
type PaymentMerchantPoolPayload,
|
||||||
|
type PaymentMerchantPoolMemberOption,
|
||||||
|
type PaymentPoolStrategy
|
||||||
|
} from '@/types/api/paymentMerchantPools'
|
||||||
|
import { formatDateTime } from '@/utils/business/format'
|
||||||
|
|
||||||
|
defineOptions({ name: 'PaymentMerchantPoolManagement' })
|
||||||
|
|
||||||
|
type FilterVo = string | number | undefined | null | unknown[]
|
||||||
|
|
||||||
|
const { isPlatformAccount } = usePermission()
|
||||||
|
const canManage = computed(() => isPlatformAccount.value)
|
||||||
|
|
||||||
|
const searchForm = reactive<Record<string, FilterVo>>({
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
payment_method: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const pools = ref<PaymentMerchantPool[]>([])
|
||||||
|
const pagination = reactive({ page: 1, page_size: 10, total: 0 })
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const searchItems: SearchFormItem[] = [
|
||||||
|
{
|
||||||
|
label: '支付方式',
|
||||||
|
prop: 'payment_method',
|
||||||
|
type: 'select',
|
||||||
|
options: PAYMENT_METHOD_OPTIONS.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.value
|
||||||
|
})),
|
||||||
|
config: { clearable: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const columnOptions = [
|
||||||
|
{ label: '商户池名称', prop: 'name', minWidth: 180 },
|
||||||
|
{
|
||||||
|
label: '支付方式',
|
||||||
|
prop: 'payment_method',
|
||||||
|
width: 110,
|
||||||
|
formatter: (row: PaymentMerchantPool) => getPaymentMethodLabel(row.payment_method)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '成员数量',
|
||||||
|
prop: 'member_ids',
|
||||||
|
width: 110,
|
||||||
|
formatter: (row: PaymentMerchantPool) => `${row.member_ids.length} 个`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '启停状态',
|
||||||
|
prop: 'enabled',
|
||||||
|
width: 110,
|
||||||
|
formatter: (row: PaymentMerchantPool) =>
|
||||||
|
h(
|
||||||
|
ElTag,
|
||||||
|
{ type: row.enabled ? 'success' : 'info' },
|
||||||
|
{ default: () => (row.enabled ? '启用' : '停用') }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '轮询策略',
|
||||||
|
prop: 'strategy',
|
||||||
|
width: 130,
|
||||||
|
formatter: (row: PaymentMerchantPool) => getPaymentPoolStrategyLabel(row.strategy)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '统计周期',
|
||||||
|
prop: 'statistic_cycle',
|
||||||
|
width: 110,
|
||||||
|
formatter: (row: PaymentMerchantPool) =>
|
||||||
|
row.strategy === 'time' ? '-' : getPaymentStatisticCycleLabel(row.statistic_cycle)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '阈值',
|
||||||
|
prop: 'threshold_summary',
|
||||||
|
width: 150,
|
||||||
|
formatter: (row: PaymentMerchantPool) => describeThreshold(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '更新时间',
|
||||||
|
prop: 'updated_at',
|
||||||
|
width: 170,
|
||||||
|
formatter: (row: PaymentMerchantPool) => formatDateTime(row.updated_at)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const { columnChecks, columns } = useCheckedColumns(() => columnOptions)
|
||||||
|
|
||||||
|
const describeThreshold = (row: PaymentMerchantPool): string => {
|
||||||
|
if (row.strategy === 'amount') {
|
||||||
|
return `${(Number(row.threshold_amount) / 100).toFixed(2)} 元`
|
||||||
|
}
|
||||||
|
if (row.strategy === 'count') {
|
||||||
|
return `${row.threshold_count} 笔`
|
||||||
|
}
|
||||||
|
return `${row.time_period_value} ${getPaymentTimePeriodUnitLabel(row.time_period_unit)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPools = async () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.page,
|
||||||
|
page_size: pagination.page_size,
|
||||||
|
payment_method: searchForm.payment_method as PaymentMerchantMethod | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await PaymentMerchantPoolsService.getPaymentMerchantPools(params)
|
||||||
|
if (res.code === 0) {
|
||||||
|
const data = (res.data as PaymentMerchantPoolPageResult) || {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
size: 10
|
||||||
|
}
|
||||||
|
pools.value = data.items || []
|
||||||
|
pagination.total = data.total || 0
|
||||||
|
pagination.page = data.page || pagination.page
|
||||||
|
pagination.page_size = data.size || pagination.page_size
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载商户池失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
loadPools()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.payment_method = null
|
||||||
|
pagination.page = 1
|
||||||
|
loadPools()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pagination.page_size = size
|
||||||
|
pagination.page = 1
|
||||||
|
loadPools()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (page: number) => {
|
||||||
|
pagination.page = page
|
||||||
|
loadPools()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 候选成员
|
||||||
|
const availableMerchants = ref<PaymentMerchantPoolMemberOption[]>([])
|
||||||
|
|
||||||
|
const loadAvailableMerchants = async (paymentMethod: PaymentMerchantMethod) => {
|
||||||
|
try {
|
||||||
|
const res = await PaymentMerchantPoolsService.getPaymentMerchants({
|
||||||
|
page: 1,
|
||||||
|
page_size: 100,
|
||||||
|
payment_method: paymentMethod
|
||||||
|
})
|
||||||
|
if (res.code === 0) {
|
||||||
|
const items = res.data?.items || []
|
||||||
|
availableMerchants.value = items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
payment_method: item.payment_method,
|
||||||
|
enabled: item.enabled
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载候选商户失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableMerchantOptions = computed(() =>
|
||||||
|
availableMerchants.value.filter((m) => m.payment_method === form.payment_method)
|
||||||
|
)
|
||||||
|
|
||||||
|
const getMemberName = (id: number) =>
|
||||||
|
availableMerchants.value.find((m) => m.id === id)?.name || `#${id}`
|
||||||
|
|
||||||
|
// 表单
|
||||||
|
type FormMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
const formDrawerVisible = ref(false)
|
||||||
|
const formMode = ref<FormMode>('create')
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const memberError = ref('')
|
||||||
|
const pendingMemberId = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
const initialFormState = () => ({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
payment_method: 'wechat' as PaymentMerchantMethod,
|
||||||
|
member_ids: [] as number[],
|
||||||
|
enabled: true,
|
||||||
|
strategy: 'amount' as PaymentPoolStrategy,
|
||||||
|
statistic_cycle: 'round' as PaymentMerchantPoolPayload['statistic_cycle'],
|
||||||
|
threshold_amount_yuan: 0,
|
||||||
|
threshold_count: 1,
|
||||||
|
time_period_unit: 'hour' as PaymentMerchantPoolPayload['time_period_unit'],
|
||||||
|
time_period_value: 1,
|
||||||
|
time_period_started_at: '',
|
||||||
|
remark: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive(initialFormState())
|
||||||
|
|
||||||
|
const orderedMemberIds = ref<number[]>([])
|
||||||
|
watch(
|
||||||
|
() => form.member_ids,
|
||||||
|
(val) => {
|
||||||
|
orderedMemberIds.value = [...val]
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
watch(orderedMemberIds, (val) => {
|
||||||
|
form.member_ids = [...val]
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = reactive<FormRules>({
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入商户池名称', trigger: 'blur' },
|
||||||
|
{ max: 100, message: '商户池名称不超过 100 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
|
||||||
|
member_ids: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value: number[], callback) => {
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
callback(new Error('请至少选择一个成员'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const unique = new Set(value)
|
||||||
|
if (unique.size !== value.length) {
|
||||||
|
callback(new Error('成员不可重复'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
strategy: [{ required: true, message: '请选择轮询策略', trigger: 'change' }],
|
||||||
|
statistic_cycle: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if ((form.strategy === 'amount' || form.strategy === 'count') && !value) {
|
||||||
|
callback(new Error('请选择统计周期'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
threshold_amount_yuan: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (form.strategy !== 'amount') {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const num = Number(value)
|
||||||
|
if (!Number.isFinite(num) || num <= 0) {
|
||||||
|
callback(new Error('金额阈值必须大于 0'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
threshold_count: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (form.strategy !== 'count') {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const num = Number(value)
|
||||||
|
if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) {
|
||||||
|
callback(new Error('笔数阈值必须为正整数'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
time_period_unit: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (form.strategy !== 'time') {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
callback(new Error('请选择时间单位'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
time_period_value: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (form.strategy !== 'time') {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const num = Number(value)
|
||||||
|
if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) {
|
||||||
|
callback(new Error('时间长度必须为正整数'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
time_period_started_at: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (form.strategy !== 'time') {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
callback(new Error('请选择时间起点'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePaymentMethodChange = (value: PaymentMerchantMethod) => {
|
||||||
|
form.payment_method = value
|
||||||
|
form.member_ids = []
|
||||||
|
orderedMemberIds.value = []
|
||||||
|
availableMerchants.value = []
|
||||||
|
memberError.value = ''
|
||||||
|
loadAvailableMerchants(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendMember = (id?: number | string) => {
|
||||||
|
const memberId = Number(id)
|
||||||
|
if (!memberId) {
|
||||||
|
pendingMemberId.value = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.member_ids.includes(memberId)) {
|
||||||
|
memberError.value = '成员不可重复'
|
||||||
|
} else {
|
||||||
|
form.member_ids = [...form.member_ids, memberId]
|
||||||
|
memberError.value = ''
|
||||||
|
}
|
||||||
|
pendingMemberId.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMember = (id: number) => {
|
||||||
|
form.member_ids = form.member_ids.filter((memberId) => memberId !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPayload = (): PaymentMerchantPoolPayload | null => {
|
||||||
|
if (!form.payment_method) {
|
||||||
|
memberError.value = '请选择支付方式'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (form.member_ids.length === 0) {
|
||||||
|
memberError.value = '请至少选择一个成员'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (new Set(form.member_ids).size !== form.member_ids.length) {
|
||||||
|
memberError.value = '成员不可重复'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
memberError.value = ''
|
||||||
|
|
||||||
|
const payload: PaymentMerchantPoolPayload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
payment_method: form.payment_method,
|
||||||
|
member_ids: [...form.member_ids],
|
||||||
|
enabled: form.enabled,
|
||||||
|
strategy: form.strategy,
|
||||||
|
remark: form.remark?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.strategy === 'amount') {
|
||||||
|
if (!form.statistic_cycle) return null
|
||||||
|
const yuan = Number(form.threshold_amount_yuan)
|
||||||
|
if (!Number.isFinite(yuan) || yuan <= 0) return null
|
||||||
|
payload.statistic_cycle = form.statistic_cycle
|
||||||
|
payload.threshold_amount = Math.round(yuan * 100)
|
||||||
|
} else if (form.strategy === 'count') {
|
||||||
|
if (!form.statistic_cycle) return null
|
||||||
|
const count = Number(form.threshold_count)
|
||||||
|
if (!Number.isInteger(count) || count <= 0) return null
|
||||||
|
payload.statistic_cycle = form.statistic_cycle
|
||||||
|
payload.threshold_count = count
|
||||||
|
} else {
|
||||||
|
if (!form.time_period_unit) return null
|
||||||
|
const value = Number(form.time_period_value)
|
||||||
|
if (!Number.isInteger(value) || value <= 0) return null
|
||||||
|
payload.time_period_unit = form.time_period_unit
|
||||||
|
payload.time_period_value = value
|
||||||
|
if (form.time_period_started_at) {
|
||||||
|
payload.time_period_started_at = form.time_period_started_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCreateDrawer = async () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
Object.assign(form, initialFormState())
|
||||||
|
orderedMemberIds.value = []
|
||||||
|
formDrawerVisible.value = true
|
||||||
|
formMode.value = 'create'
|
||||||
|
await loadAvailableMerchants(form.payment_method)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDrawer = async (pool: PaymentMerchantPool) => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
await loadAvailableMerchants(pool.payment_method)
|
||||||
|
Object.assign(form, initialFormState(), {
|
||||||
|
id: pool.id,
|
||||||
|
name: pool.name,
|
||||||
|
payment_method: pool.payment_method,
|
||||||
|
member_ids: [...pool.member_ids],
|
||||||
|
enabled: pool.enabled,
|
||||||
|
strategy: pool.strategy,
|
||||||
|
statistic_cycle: pool.statistic_cycle,
|
||||||
|
threshold_amount_yuan: Number(pool.threshold_amount) / 100,
|
||||||
|
threshold_count: pool.threshold_count,
|
||||||
|
time_period_unit: pool.time_period_unit,
|
||||||
|
time_period_value: pool.time_period_value,
|
||||||
|
time_period_started_at: pool.time_period_started_at || '',
|
||||||
|
remark: pool.remark || ''
|
||||||
|
})
|
||||||
|
orderedMemberIds.value = [...pool.member_ids]
|
||||||
|
formDrawerVisible.value = true
|
||||||
|
formMode.value = 'edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, initialFormState())
|
||||||
|
orderedMemberIds.value = []
|
||||||
|
memberError.value = ''
|
||||||
|
pendingMemberId.value = undefined
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = buildPayload()
|
||||||
|
if (!payload) return
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
if (formMode.value === 'create') {
|
||||||
|
await PaymentMerchantPoolsService.createPaymentMerchantPool(payload)
|
||||||
|
ElMessage.success('商户池创建成功')
|
||||||
|
} else {
|
||||||
|
await PaymentMerchantPoolsService.updatePaymentMerchantPool(form.id, payload)
|
||||||
|
ElMessage.success('商户池已更新')
|
||||||
|
}
|
||||||
|
formDrawerVisible.value = false
|
||||||
|
await loadPools()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交商户池失败:', error)
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情
|
||||||
|
const detailDrawerVisible = ref(false)
|
||||||
|
const detail = ref<PaymentMerchantPool | null>(null)
|
||||||
|
const tableRef = ref()
|
||||||
|
|
||||||
|
const showDetail = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const res = await PaymentMerchantPoolsService.getPaymentMerchantPoolById(id)
|
||||||
|
if (res.code === 0) {
|
||||||
|
detail.value = res.data
|
||||||
|
detailDrawerVisible.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载商户池详情失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleEnabled = async (pool: PaymentMerchantPool) => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
const target = !pool.enabled
|
||||||
|
const action = target ? '启用' : '停用'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认${action}商户池 “${pool.name}”?`, '操作确认', {
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = target
|
||||||
|
? await PaymentMerchantPoolsService.enablePaymentMerchantPool(pool.id)
|
||||||
|
: await PaymentMerchantPoolsService.disablePaymentMerchantPool(pool.id)
|
||||||
|
if (res.code === 0) {
|
||||||
|
ElMessage.success(`已${action}`)
|
||||||
|
await loadPools()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${action}商户池失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActions = (row: PaymentMerchantPool) => [
|
||||||
|
{
|
||||||
|
label: '详情',
|
||||||
|
type: 'primary' as const,
|
||||||
|
handler: () => showDetail(row.id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '编辑',
|
||||||
|
type: 'primary' as const,
|
||||||
|
handler: () => openEditDrawer(row),
|
||||||
|
permission: canManage.value ? '' : 'hidden'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: row.enabled ? '停用' : '启用',
|
||||||
|
type: 'primary' as const,
|
||||||
|
handler: () => toggleEnabled(row),
|
||||||
|
permission: canManage.value ? '' : 'hidden'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (canManage.value) {
|
||||||
|
loadPools()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
pools.value = []
|
||||||
|
detail.value = null
|
||||||
|
availableMerchants.value = []
|
||||||
|
orderedMemberIds.value = []
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.pool-management {
|
||||||
|
.pool-member-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-member-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: rgba(var(--art-gray-200-rgb), 0.6);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-member-row :deep(.drag-handle) {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-member-empty {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wechat-authorization-management">
|
||||||
|
<ElCard v-if="canManage" shadow="never" class="art-table-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">微信授权配置</span>
|
||||||
|
<ElTag :type="form.enabled ? 'success' : 'info'">
|
||||||
|
{{ form.enabled ? '已启用' : '已停用' }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElAlert
|
||||||
|
v-if="!form.enabled"
|
||||||
|
title="微信授权已停用。停用状态下不会影响现有客户端的支付,但新的授权流程将被拦截。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="140px">
|
||||||
|
<ElFormItem label="启用微信授权">
|
||||||
|
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElDivider content-position="left">小程序授权</ElDivider>
|
||||||
|
<ElFormItem label="小程序 AppID" prop="miniapp_app_id">
|
||||||
|
<ElInput v-model="form.miniapp_app_id" maxlength="64" placeholder="请输入小程序 AppID" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="小程序 AppSecret">
|
||||||
|
<ElInput
|
||||||
|
v-model="sensitive.miniapp_app_secret"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="如需更换请输入新的 AppSecret,留空表示保持原值"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-label="小程序 AppSecret"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">仅在显式更换时填写,提交后立即清空输入。</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElDivider content-position="left">公众号授权</ElDivider>
|
||||||
|
<ElFormItem label="公众号 AppID" prop="oa_app_id">
|
||||||
|
<ElInput v-model="form.oa_app_id" maxlength="64" placeholder="请输入公众号 AppID" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="公众号 AppSecret">
|
||||||
|
<ElInput
|
||||||
|
v-model="sensitive.oa_app_secret"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="如需更换请输入新的 AppSecret,留空表示保持原值"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-label="公众号 AppSecret"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="公众号 Token">
|
||||||
|
<ElInput
|
||||||
|
v-model="sensitive.oa_token"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="如需更换请输入新的 Token"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-label="公众号 Token"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="公众号 AES Key">
|
||||||
|
<ElInput
|
||||||
|
v-model="sensitive.oa_aes_key"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="如需更换请输入新的 EncodingAESKey"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-label="公众号 AES Key"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="OAuth 回调地址" prop="oa_oauth_redirect_url">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.oa_oauth_redirect_url"
|
||||||
|
maxlength="512"
|
||||||
|
placeholder="请输入 OAuth 回调地址"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem>
|
||||||
|
<ElButton type="primary" :loading="submitLoading" @click="handleSubmit"> 保存 </ElButton>
|
||||||
|
<ElButton @click="handleCancel">重置</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</ElCard>
|
||||||
|
<ElEmpty v-else description="无访问权限" :image-size="120" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { PaymentMerchantPoolsService } from '@/api/modules'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
|
import type {
|
||||||
|
UpdateWechatAuthorizationRequest,
|
||||||
|
WechatAuthorizationConfig
|
||||||
|
} from '@/types/api/paymentMerchantPools'
|
||||||
|
|
||||||
|
defineOptions({ name: 'WechatAuthorizationManagement' })
|
||||||
|
|
||||||
|
const { isPlatformAccount } = usePermission()
|
||||||
|
const canManage = computed(() => isPlatformAccount.value)
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
|
||||||
|
const initialFormState = (): WechatAuthorizationConfig => ({
|
||||||
|
enabled: false,
|
||||||
|
miniapp_app_id: '',
|
||||||
|
oa_app_id: '',
|
||||||
|
oa_oauth_redirect_url: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive<WechatAuthorizationConfig>(initialFormState())
|
||||||
|
|
||||||
|
const initialSensitive = () => ({
|
||||||
|
miniapp_app_secret: '',
|
||||||
|
oa_app_secret: '',
|
||||||
|
oa_token: '',
|
||||||
|
oa_aes_key: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const sensitive = reactive(initialSensitive())
|
||||||
|
|
||||||
|
const clearSensitive = () => {
|
||||||
|
sensitive.miniapp_app_secret = ''
|
||||||
|
sensitive.oa_app_secret = ''
|
||||||
|
sensitive.oa_token = ''
|
||||||
|
sensitive.oa_aes_key = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formRules = reactive<FormRules>({
|
||||||
|
miniapp_app_id: [{ max: 64, message: 'AppID 长度不超过 64 个字符', trigger: 'blur' }],
|
||||||
|
oa_app_id: [{ max: 64, message: 'AppID 长度不超过 64 个字符', trigger: 'blur' }],
|
||||||
|
oa_oauth_redirect_url: [{ max: 512, message: '回调地址长度不超过 512 个字符', trigger: 'blur' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
try {
|
||||||
|
const res = await PaymentMerchantPoolsService.getWechatAuthorization()
|
||||||
|
if (res.code === 0) {
|
||||||
|
Object.assign(form, initialFormState(), res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载微信授权配置失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!canManage.value) return
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: UpdateWechatAuthorizationRequest = {
|
||||||
|
enabled: form.enabled,
|
||||||
|
miniapp_app_id: form.miniapp_app_id?.trim() || '',
|
||||||
|
oa_app_id: form.oa_app_id?.trim() || '',
|
||||||
|
oa_oauth_redirect_url: form.oa_oauth_redirect_url?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sensitive.miniapp_app_secret) {
|
||||||
|
payload.miniapp_app_secret = sensitive.miniapp_app_secret
|
||||||
|
}
|
||||||
|
if (sensitive.oa_app_secret) {
|
||||||
|
payload.oa_app_secret = sensitive.oa_app_secret
|
||||||
|
}
|
||||||
|
if (sensitive.oa_token) {
|
||||||
|
payload.oa_token = sensitive.oa_token
|
||||||
|
}
|
||||||
|
if (sensitive.oa_aes_key) {
|
||||||
|
payload.oa_aes_key = sensitive.oa_aes_key
|
||||||
|
}
|
||||||
|
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await PaymentMerchantPoolsService.updateWechatAuthorization(payload)
|
||||||
|
if (res.code === 0) {
|
||||||
|
ElMessage.success('微信授权配置已保存')
|
||||||
|
Object.assign(form, initialFormState(), res.data)
|
||||||
|
clearSensitive()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存微信授权配置失败:', error)
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
await loadConfig()
|
||||||
|
clearSensitive()
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (canManage.value) {
|
||||||
|
loadConfig()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSensitive()
|
||||||
|
Object.assign(form, initialFormState())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.wechat-authorization-management {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
src/views/settings/payment-merchant-pools/index.vue
Normal file
56
src/views/settings/payment-merchant-pools/index.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<ArtTableFullScreen>
|
||||||
|
<div class="payment-merchant-pools-page" id="table-full-screen">
|
||||||
|
<ElCard v-if="canAccess" shadow="never" class="art-table-card">
|
||||||
|
<ElTabs v-model="activeTab" type="card" class="payment-merchant-pools-tabs">
|
||||||
|
<ElTabPane :label="merchantsLabel" name="merchants">
|
||||||
|
<MerchantManagement />
|
||||||
|
</ElTabPane>
|
||||||
|
<ElTabPane :label="poolsLabel" name="pools">
|
||||||
|
<PoolManagement />
|
||||||
|
</ElTabPane>
|
||||||
|
<ElTabPane :label="wechatAuthLabel" name="wechat-auth">
|
||||||
|
<WechatAuthorizationManagement />
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
|
</ElCard>
|
||||||
|
<ElEmpty v-else description="无访问权限" :image-size="120" />
|
||||||
|
</div>
|
||||||
|
</ArtTableFullScreen>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import MerchantManagement from './components/MerchantManagement.vue'
|
||||||
|
import PoolManagement from './components/PoolManagement.vue'
|
||||||
|
import WechatAuthorizationManagement from './components/WechatAuthorizationManagement.vue'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
|
|
||||||
|
defineOptions({ name: 'PaymentMerchantPools' })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { isPlatformAccount } = usePermission()
|
||||||
|
const canAccess = computed(() => isPlatformAccount.value)
|
||||||
|
|
||||||
|
const activeTab = ref<'merchants' | 'pools' | 'wechat-auth'>('merchants')
|
||||||
|
|
||||||
|
const merchantsLabel = computed(() => t('menus.settings.paymentMerchantPoolsTabMerchants'))
|
||||||
|
const poolsLabel = computed(() => t('menus.settings.paymentMerchantPoolsTabPools'))
|
||||||
|
const wechatAuthLabel = computed(() => t('menus.settings.paymentMerchantPoolsTabWechatAuth'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.payment-merchant-pools-page {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
:deep(.payment-merchant-pools-tabs) {
|
||||||
|
.el-tabs__header {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tabs__nav-wrap::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user