Compare commits
3 Commits
main
...
iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2308d82d0f | ||
|
|
d3d257cdf8 | ||
|
|
ef966171b4 |
88
openspec/changes/add-employee-collection/design.md
Normal file
88
openspec/changes/add-employee-collection/design.md
Normal file
@@ -0,0 +1,88 @@
|
||||
## Context
|
||||
|
||||
员工代收款是 8 月迭代新增的财务能力,普通员工与超级管理员共用同一套接口,靠登录态区分数据范围。后台管理端需要新增三类页面,并复用已有的表格、搜索、详情与上传组件。`docs/admin-openapi.yaml` 未随仓库提供,接口字段以后端契约(需求文档 + 创建订单接口 OpenAPI 片段)为准,类型集中在一个文件便于联调收敛。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- 在财务管理下提供收款方式、员工代收款账单、核销申请三类页面。
|
||||
- 复用 `ArtTableFullScreen`、`ArtSearchBar`、`ArtTableHeader`、`ArtTable`、`DetailPage`、`VoucherUpload`、`PaymentVoucherDialog`、`useCheckedColumns` 等既有组件与约定。
|
||||
- 复用既有企微审批场景配置能力,仅新增业务类型。
|
||||
|
||||
**Non-Goals**
|
||||
|
||||
- 不实现后端接口、数据库、Worker、企微回调。
|
||||
- 不实现 H5/C 端页面与支付流程。
|
||||
- 不新增导出任务场景(需求文档未要求)。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 接口契约
|
||||
|
||||
| 能力 | 关键字段 |
|
||||
|---|---|
|
||||
| 统一响应 | `{ code, data, msg, timestamp }` |
|
||||
| 列表分页 | 账单列表返回 `{ items, total, page, size }`,取数处对 `items` / `list` / `records` 做兼容 |
|
||||
| 收款方式 | `{ id, code, name, sort, enabled, remark, created_at, updated_at }`,列表接口返回 `{ items, page, size, total }`,支持 `page` / `page_size` / `enabled` / `keyword` 筛选 |
|
||||
| 账单列表项 | `{ id, source_type, source_type_name, source_no, debtor_snapshot, customer_snapshot, receivable_amount, received_amount, reserved_amount, remaining_amount, status, status_name, approval_pending, closed_reason, created_at, updated_at }` |
|
||||
| 账单详情 | `data` 为 `{ bill, refunds, allocations, applications }`;`refunds` 为退款冲销(`refund_id`、`source_order_id`、`refund_amount`、`reduced_amount`、`bill_receivable_amount`、`outcome_name`),`allocations` 为账单侧分摊(含 `application_id` / `application_status_name` / `attempt_id`),`applications` 内嵌该申请的 `attempts` |
|
||||
| 账单统计 | `{ receivable_total, received_total, unsettled_total, pending_bill_count }` |
|
||||
| 账单筛选 | `page`、`page_size`、`source_type`、`source_no`、`status`、`debtor_account_id`、`customer_id`、`created_from`(`YYYY-MM-DD`)、`created_to`(`YYYY-MM-DD`) |
|
||||
| 核销申请请求体 | `{ payment_method_id, paid_amount, paid_at, payer_name, external_transaction_no, payment_voucher_keys, remark, allocations: [{ bill_id, amount }], acting_reason }` |
|
||||
| 核销申请列表项 | `{ id, applicant_account_id, acting_operator_id, payment_method_id, payment_method_name, paid_amount, payer_name, external_transaction_no, status, status_name, terminal_reason, decided_at, created_at, updated_at }` |
|
||||
| 核销申请详情 | `data` 为 `{ application, allocations, attempts }`,`attempts` 保存每次提交的完整材料快照,重新提交不清空历史 |
|
||||
|
||||
账单状态为数字枚举 `0` 待核销 / `1` 部分核销 / `2` 已核销 / `3` 已关闭;申请状态为数字枚举 `0` 审批中 / `1` 已通过 / `2` 已驳回 / `3` 已撤销或已关闭;两者展示均优先使用后端 `status_name`。
|
||||
|
||||
### 页面与路由组织
|
||||
|
||||
在 `/finance` 下新增:
|
||||
|
||||
| 路由 | 页面 | 说明 |
|
||||
|---|---|---|
|
||||
| `/finance/employee-collection/bills` | 员工代收款账单 | 统计 + 列表 |
|
||||
| `/finance/employee-collection/bills/detail/:id` | 账单详情 | 隐藏菜单 |
|
||||
| `/finance/employee-collection/applications` | 核销申请 | 列表 + 创建 |
|
||||
| `/finance/employee-collection/applications/detail/:id` | 核销申请详情 | 隐藏菜单 |
|
||||
| `/finance/employee-collection/payment-methods` | 收款方式管理 | 仅超管 |
|
||||
|
||||
账单与申请拆分为独立菜单,符合项目「列表页 + 详情页」的既有组织方式,避免单页堆叠过多交互。账单列表的「店铺」筛选用远程搜索复用 `ShopService.getShops`,与退款列表一致。
|
||||
|
||||
账单详情与核销申请详情保持只读:页面只在顶部保留「返回」导航,创建核销申请、关闭账单、修改并重新提交等操作入口统一放在列表页的操作列,详情页不出现业务操作按钮。
|
||||
|
||||
### 权限编码
|
||||
|
||||
新增 `src/config/constants/augustIteration.ts`,沿用 `模块:动作` 风格,例如 `employee_collection:bill_close`、`employee_collection:application_create`。页面级 `permissions` 用于菜单可见性,按钮级编码用于 `hasAuth()` / `v-permission`。
|
||||
|
||||
### 金额、时间与附件
|
||||
|
||||
- 金额统一以「分」传输,展示时通过 `fenToYuan` / `formatCurrency` 转换,与退款、代理充值保持一致。
|
||||
- 所有时间字段统一通过 `formatDateTime` 格式化为 `YYYY-MM-DD HH:mm:ss`,不在模板中直接输出后端原始时间字符串。
|
||||
- 附件仅返回对象 Key(`payment_voucher_keys`),展示复用 `PaymentVoucherDialog`,由预签名下载接口换取访问地址。
|
||||
|
||||
### 核销申请分摊
|
||||
|
||||
创建申请时按账单逐条录入核销金额,并填写付款事实(付款金额、付款方名称、付款时间、外部交易流水号),前端校验:
|
||||
|
||||
- 至少选择 1 张账单,最多 N 张;
|
||||
- 单张核销金额不得大于账单未核销金额,且大于 0;
|
||||
- 付款金额(`paid_amount`,分)不得小于各账单分摊之和(允许存在差额);
|
||||
- 超管代办时 `acting_reason` 必填;
|
||||
- 付款凭证至少 1 个 Key。
|
||||
|
||||
提交成功后自动发起企微审批;仅已驳回申请可再次进入弹窗修改并重新提交,重新提交生成新的审批实例,历史审批记录只读展示。未配置企微审批场景时后端返回 503,前端展示「企微审批场景未配置,请联系管理员」并保留已填内容。
|
||||
|
||||
### 企微审批场景
|
||||
|
||||
`WecomBusinessType` 增加 `employee_collection_approval`,企微审批场景页面下拉新增「员工代收款审批」。模板控件同步、字段查询、字段映射保存全部复用既有 `WecomService`。
|
||||
|
||||
### 订单付款凭证规则
|
||||
|
||||
线下订单创建时,满足「平台账号(`user_type` 为 1 或 2)操作 + 非赠送套餐 + 实际收款金额大于 0」条件的订单会生成员工代收款账单,此时 `payment_voucher_key` 非必填,字段结构不变,付款凭证改在核销申请中提交。前端以当前登录账号类型、所选套餐是否赠送、套餐有效价格(`effective_retail_price` / `suggested_retail_price` / `retail_price`)判断是否展示提示并放宽必填;赠送套餐或其他非平台账号的线下订单仍需上传凭证。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **接口字段以契约文档为准**:`docs/admin-openapi.yaml` 未入库,字段来自需求文档与创建订单接口片段;类型集中在一个文件,便于联调时收敛修改。
|
||||
- **员工/超管同接口**:前端不做数据范围过滤,仅做展示与操作可见性控制,数据隔离以后端为准。
|
||||
- **关闭账单与审批中申请**:前端依据 `approval_pending` 与状态字段禁用关闭按钮,最终一致性以后端校验为准。
|
||||
25
openspec/changes/add-employee-collection/proposal.md
Normal file
25
openspec/changes/add-employee-collection/proposal.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Change: 员工代收款功能前端对接
|
||||
|
||||
## Why
|
||||
|
||||
8 月产品迭代新增「员工代收款」能力:员工线下代收款项后,通过创建核销申请、经企业微信审批完成账单核销;超级管理员可维护收款方式、查看全部账单并关闭账单。后端接口已按 `docs/产品迭代8月份/员工代收款功能简介.md` 落地,后台管理端目前缺少收款方式管理、账单列表与核销申请页面,普通员工与超级管理员的分类视图、以及线下订单付款凭证规则尚未接入。
|
||||
|
||||
本次已按后端实际契约对齐字段:列表响应统一使用 `items` / `total` / `page` / `size`;账单状态(`0` 待核销 / `1` 部分核销 / `2` 已核销 / `3` 已关闭)与申请状态(`0` 审批中 / `1` 已通过 / `2` 已驳回 / `3` 已撤销或已关闭)为数字枚举;收款方式列表返回 `{ items, page, size, total }` 并支持 `enabled` / `keyword` 筛选;核销申请请求体使用 `paid_amount`、`paid_at`、`payer_name`、`external_transaction_no`、`payment_voucher_keys` 与 `allocations`。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `employee-collection` 能力的前端类型与服务封装:收款方式 4 个接口、员工代收款账单 4 个接口、核销申请 4 个接口。
|
||||
- 新增「收款方式管理」页面(仅超级管理员):列表 + 新增/编辑弹窗 + 删除;已被核销申请引用的方式不可删除、不可修改 `code`(未被引用时可改),可停用。
|
||||
- 新增「员工代收款账单」页面:应收/已核销/未核销/待处理账单统计 + 列表 + 详情;普通员工仅见本人账单,超级管理员可见全部;存在审批中申请时账单不可关闭,关闭必须填写原因。
|
||||
- 新增「核销申请」页面:列表 + 详情(含分摊账单、付款凭证、付款信息与全部审批尝试记录)+ 创建/重新提交弹窗;一笔线下收款可核销 1~N 张账单,填写付款金额、付款方名称、付款时间、外部交易流水号、选择收款方式并上传付款凭证;提交后自动发起企微审批;仅已驳回申请可修改并重新提交,重新提交生成新的审批实例且历史记录不被覆盖。
|
||||
- 扩展企业微信审批场景:新增 `employee_collection_approval` 业务类型,复用既有企微应用列表、模板控件同步与业务字段查询接口。
|
||||
- 调整订单创建:由平台账号操作、实际收款金额大于 0 且非赠送的线下订单会生成员工代收款账单,该场景 `payment_voucher_key` 改为非必填,付款凭证改在核销申请中提交,字段结构保持不变。
|
||||
- 附件接口仅返回对象 Key,统一通过系统既有预签名下载接口展示。
|
||||
- **不实现后端接口、数据库、Worker、企微回调**;**不实现 H5/C 端页面**。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `employee-collection`
|
||||
- Affected code: `src/types/api/employeeCollection.ts`、`src/api/modules/employeeCollection.ts`、`src/views/finance/employee-collection/*`、`src/router/routes/asyncRoutes.ts`、`src/router/routesAlias.ts`、`src/config/constants/augustIteration.ts`、`src/types/api/wecom.ts`、`src/views/settings/wecom/scenes/index.vue`、`src/views/order-management/order-list/index.vue`、`src/locales/langs/{zh,en}.json`
|
||||
- Dependencies: `docs/产品迭代8月份/员工代收款功能简介.md`
|
||||
- Contract note: `docs/admin-openapi.yaml` 未随仓库提供,字段以需求文档描述的后端契约与创建订单接口的 OpenAPI 片段为准;类型集中在单一文件,联调时便于收敛。
|
||||
@@ -0,0 +1,155 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 收款方式管理
|
||||
超级管理员 MUST 能够维护线下收款方式,包括名称、编码、排序、状态与备注;普通员工只能看到启用的收款方式。收款方式列表接口 MUST 返回 `{ items, page, size, total }`,并 MUST 支持 `enabled` 与 `keyword` 筛选。已被核销申请引用的收款方式 MUST NOT 被删除;引用后修改稳定编码 MUST 被后端拒绝,前端 MUST 展示错误提示。
|
||||
|
||||
#### Scenario: 超级管理员新增收款方式
|
||||
- **GIVEN** 超级管理员已登录并拥有收款方式新增权限
|
||||
- **WHEN** 其填写名称、唯一编码、排序、状态与备注并提交
|
||||
- **THEN** 系统 MUST 调用新增接口并在成功后刷新列表
|
||||
- **AND** 新增成功后 MUST 清空并关闭弹窗
|
||||
|
||||
#### Scenario: 删除被引用的收款方式
|
||||
- **GIVEN** 某收款方式已被业务引用
|
||||
- **WHEN** 超级管理员尝试删除该方式
|
||||
- **THEN** 前端 MUST 阻止删除或展示后端返回的业务错误
|
||||
- **AND** MUST 提示改为停用
|
||||
|
||||
#### Scenario: 修改收款方式编码
|
||||
- **GIVEN** 超级管理员打开编辑弹窗
|
||||
- **WHEN** 其修改稳定编码并提交
|
||||
- **THEN** 前端 MUST 提交最新编码
|
||||
- **AND** 若该方式已被核销申请引用,后端拒绝时前端 MUST 展示错误提示
|
||||
|
||||
### Requirement: 员工代收款账单统计
|
||||
账单页面 MUST 展示应收金额、已核销金额、未核销金额与待处理账单数量四项统计,数据 MUST 来自 `GET /api/admin/employee-collection-bills/statistics`,字段为 `receivable_total`、`received_total`、`unsettled_total`、`pending_bill_count`;前端 MUST NOT 通过遍历当前分页数据自行计算。
|
||||
|
||||
#### Scenario: 加载账单统计
|
||||
- **GIVEN** 用户进入员工代收款账单页面
|
||||
- **WHEN** 页面初始化或筛选条件变化
|
||||
- **THEN** 前端 MUST 调用账单统计接口
|
||||
- **AND** MUST 将后端返回的「分」按元格式化后展示应收、已核销与未核销金额
|
||||
|
||||
### Requirement: 员工代收款账单列表与详情
|
||||
账单列表响应 MUST 为 `{ items, total, page, size }`,前端 MUST 兼容 `items` / `list` / `records` 等列表字段。列表 MUST 支持按来源(`source_type`)、来源单号(`source_no`)、账单状态、客户/店铺(`customer_id`)与创建时间(`created_from` / `created_to`,`YYYY-MM-DD`)筛选,并展示账单编号、来源、关联单号、负责员工、客户/店铺、应收金额、已核销金额、未核销金额与状态。账单状态 MUST 为数字枚举:`0` 待核销、`1` 部分核销、`2` 已核销、`3` 已关闭,展示 MUST 优先使用后端 `status_name`。账单详情响应 MUST 为 `{ bill, refunds, allocations, applications }`:`refunds` MUST 展示退款金额、冲减应收、冲销前应收与处理结果,`applications` MUST 可展开查看该申请的审批尝试记录。
|
||||
|
||||
#### Scenario: 普通员工查看账单
|
||||
- **GIVEN** 普通员工已登录
|
||||
- **WHEN** 其打开账单列表
|
||||
- **THEN** 列表 MUST 只展示后端返回的本人账单数据
|
||||
- **AND** MUST NOT 展示仅超管可见的操作入口
|
||||
|
||||
#### Scenario: 打开账单详情
|
||||
- **GIVEN** 用户拥有账单详情权限
|
||||
- **WHEN** 其点击账单号或详情操作
|
||||
- **THEN** 前端 MUST 跳转账单详情页并加载对应账单(详情数据取自 `data.bill`)
|
||||
- **AND** 详情 MUST 展示退款冲销、核销分摊与关联核销申请
|
||||
|
||||
### Requirement: 关闭账单
|
||||
超级管理员 MUST 能够关闭账单,且关闭原因必填(最多 500 字符)。仅待核销或部分核销账单可关闭;存在审批中的核销申请(`approval_pending` 为真)时,账单 MUST NOT 被关闭。
|
||||
|
||||
#### Scenario: 存在审批中申请时关闭账单
|
||||
- **GIVEN** 账单存在审批中的核销申请
|
||||
- **WHEN** 用户查看该账单操作
|
||||
- **THEN** 关闭入口 MUST 被禁用或不可见
|
||||
- **AND** MUST 展示不可关闭的原因提示
|
||||
|
||||
#### Scenario: 关闭原因必填
|
||||
- **GIVEN** 账单可关闭
|
||||
- **WHEN** 超级管理员打开关闭弹窗并留空原因提交
|
||||
- **THEN** 前端 MUST 阻止提交并提示填写原因
|
||||
|
||||
### Requirement: 创建核销申请
|
||||
员工 MUST 能够使用一笔线下收款核销 1~N 张账单。创建申请 MUST 提交收款方式 `payment_method_id`、付款金额 `paid_amount`(分,大于 0)、付款方名称 `payer_name`、付款时间 `paid_at`(带时区 RFC3339)、外部交易流水号 `external_transaction_no`、付款凭证 `payment_voucher_keys`(1~5 个对象 Key)与账单分摊 `allocations`。超级管理员代办时 MUST 填写 `acting_reason`,本人办理 MUST NOT 提交该字段。提交成功后系统 MUST 自动发起企业微信审批。
|
||||
|
||||
#### Scenario: 一笔收款核销多张账单
|
||||
- **GIVEN** 用户选择了多张可核销账单
|
||||
- **WHEN** 其录入各账单核销金额、选择收款方式并填写付款事实后提交
|
||||
- **THEN** 前端 MUST 校验各账单核销金额大于 0 且不超过账单未核销余额
|
||||
- **AND** MUST 以 `allocations: [{ bill_id, amount }]` 提交账单分摊明细
|
||||
|
||||
#### Scenario: 付款金额小于核销合计
|
||||
- **GIVEN** 用户已录入各账单核销金额
|
||||
- **WHEN** 其填写的付款金额小于核销合计即提交
|
||||
- **THEN** 前端 MUST 阻止提交并提示付款金额不能小于核销合计
|
||||
|
||||
#### Scenario: 缺少付款凭证
|
||||
- **GIVEN** 用户已选择账单与收款方式
|
||||
- **WHEN** 其未上传任何付款凭证即提交
|
||||
- **THEN** 前端 MUST 阻止提交并提示上传付款凭证
|
||||
|
||||
#### Scenario: 缺少付款事实
|
||||
- **GIVEN** 用户已选择账单与收款方式
|
||||
- **WHEN** 其未填写付款方名称、付款时间或外部交易流水号即提交
|
||||
- **THEN** 前端 MUST 阻止提交并提示补齐必填项
|
||||
|
||||
#### Scenario: 超管代办未填写原因
|
||||
- **GIVEN** 超级管理员以代办身份创建申请
|
||||
- **WHEN** 其未填写 `acting_reason` 即提交
|
||||
- **THEN** 前端 MUST 阻止提交并提示填写代办原因
|
||||
|
||||
#### Scenario: 企微审批场景未配置
|
||||
- **GIVEN** 企业微信审批场景尚未配置
|
||||
- **WHEN** 用户提交核销申请
|
||||
- **THEN** 前端 MUST 展示后端返回的 503 提示「企微审批场景未配置,请联系管理员」
|
||||
- **AND** MUST 保留用户已填写的内容且不产生申请数据
|
||||
|
||||
### Requirement: 核销申请列表与详情
|
||||
核销申请列表响应 MUST 为 `{ items, page, size, total }`,MUST 支持按状态、收款方式与创建时间筛选;列表项 MUST 包含 `payment_method_name`、`paid_amount`、`status`、`status_name` 与 `created_at`,状态 MUST 优先展示后端 `status_name`。申请详情响应 MUST 为 `{ application, allocations, attempts }`;`attempts` MUST 按提交顺序展示全部审批尝试记录,包含付款金额、付款方、流水号、付款凭证与审批意见。
|
||||
|
||||
#### Scenario: 查看审批历史
|
||||
- **GIVEN** 申请存在多次审批尝试记录
|
||||
- **WHEN** 用户打开申请详情
|
||||
- **THEN** 详情 MUST 按提交顺序展示每次尝试的提交材料与审批状态
|
||||
- **AND** 历史材料 MUST NOT 因重新提交而被覆盖
|
||||
|
||||
### Requirement: 核销申请重新提交
|
||||
仅已驳回(`status` 为 `2`)的申请 MUST 允许修改并重新提交;重新提交 MUST 生成新的企业微信审批实例,且历史审批记录 MUST NOT 被覆盖。重新提交入口 MUST 位于核销申请列表的操作列,详情页 MUST 只读且 MUST NOT 展示业务操作按钮(仅保留返回导航)。
|
||||
|
||||
#### Scenario: 重新提交被驳回申请
|
||||
- **GIVEN** 申请状态为已驳回
|
||||
- **WHEN** 用户在核销申请列表点击「修改并重新提交」并修改账单分摊、收款方式、付款事实或付款凭证后提交
|
||||
- **THEN** 前端 MUST 调用修改接口重新提交
|
||||
- **AND** 成功后 MUST 刷新详情并展示新的审批实例状态
|
||||
|
||||
#### Scenario: 非驳回申请不可修改
|
||||
- **GIVEN** 申请处于审批中或已通过
|
||||
- **WHEN** 用户查看核销申请列表
|
||||
- **THEN** 修改并重新提交入口 MUST 不可见或不可用
|
||||
|
||||
#### Scenario: 详情页只读
|
||||
- **GIVEN** 用户打开账单详情或核销申请详情
|
||||
- **WHEN** 页面渲染完成
|
||||
- **THEN** 页面 MUST NOT 展示创建核销申请、关闭账单或修改并重新提交等业务操作按钮
|
||||
- **AND** MUST 只保留返回导航
|
||||
|
||||
### Requirement: 企业微信审批场景配置
|
||||
企业微信审批场景 MUST 支持 `employee_collection_approval` 业务类型,复用既有的应用列表、模板控件同步、业务字段查询与字段映射保存接口。
|
||||
|
||||
#### Scenario: 配置员工代收款审批场景
|
||||
- **GIVEN** 超级管理员打开企微审批场景页面
|
||||
- **WHEN** 其选择业务类型「员工代收款审批」
|
||||
- **THEN** 前端 MUST 使用 `employee_collection_approval` 调用模板同步、字段查询与保存接口
|
||||
|
||||
### Requirement: 附件预签名展示
|
||||
附件接口 MUST 只返回对象存储 Key(`payment_voucher_keys`);前端 MUST 通过系统既有的预签名下载接口获取实际访问地址后再展示。
|
||||
|
||||
#### Scenario: 查看付款凭证
|
||||
- **GIVEN** 申请包含付款凭证 Key
|
||||
- **WHEN** 用户点击查看付款凭证
|
||||
- **THEN** 前端 MUST 先批量换取预签名地址
|
||||
- **AND** 图片 MUST 支持预览,非图片 MUST 支持查看或下载
|
||||
|
||||
### Requirement: 订单付款凭证规则调整
|
||||
由平台账号(`user_type` 为 1 或 2)操作、实际收款金额大于 0 且非赠送的线下订单会生成员工代收款账单;该场景 `payment_voucher_key` MUST 变为非必填,付款凭证改在核销申请中提交,订单字段结构 MUST 保持不变。其余线下订单 MUST 继续要求付款凭证。
|
||||
|
||||
#### Scenario: 线下订单生成代收款账单
|
||||
- **GIVEN** 当前登录账号为平台账号,所选套餐非赠送且实际收款金额大于 0,支付方式为线下支付
|
||||
- **WHEN** 用户创建该订单
|
||||
- **THEN** 前端 MUST 不再强制要求上传付款凭证
|
||||
- **AND** MUST 提示付款凭证将在核销申请中提交
|
||||
|
||||
#### Scenario: 赠送套餐或非平台账号的线下订单
|
||||
- **GIVEN** 所选套餐为赠送套餐,或当前账号非平台账号,或实际收款金额为 0
|
||||
- **WHEN** 用户以线下支付方式创建订单
|
||||
- **THEN** 前端 MUST 继续要求上传付款凭证
|
||||
48
openspec/changes/add-employee-collection/tasks.md
Normal file
48
openspec/changes/add-employee-collection/tasks.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## 1. Contract and API Types
|
||||
|
||||
- [x] 1.1 新增 `src/types/api/employeeCollection.ts`:收款方式、账单、核销申请、统计、查询参数与请求/响应类型;列表统一使用 `items` / `total` / `page` / `size`。
|
||||
- [x] 1.2 定义账单状态(`0` 待核销 / `1` 部分核销 / `2` 已核销 / `3` 已关闭)与申请状态(`0` 审批中 / `1` 已通过 / `2` 已驳回 / `3` 已撤销或已关闭)数字枚举,并保留后端 `*_name` 展示字段。
|
||||
- [x] 1.3 金额字段以「分」传输;附件字段使用 `payment_voucher_keys` 对象存储 Key 数组,展示复用预签名下载接口。
|
||||
- [x] 1.4 新增 `src/api/modules/employeeCollection.ts` 并在 `src/api/modules/index.ts`、`src/types/api/index.ts` 导出。
|
||||
- [x] 1.5 按后端实际契约收敛字段:账单详情为 `{ bill, refunds, allocations, applications }`,核销申请详情为 `{ application, allocations, attempts }`,付款金额/付款方/付款时间/外部交易流水号以 `paid_amount` / `payer_name` / `paid_at` / `external_transaction_no` 提交。
|
||||
|
||||
## 2. Permissions, Routes, and Menu
|
||||
|
||||
- [x] 2.1 新增 `src/config/constants/augustIteration.ts`,集中声明页面与按钮权限编码。
|
||||
- [x] 2.2 在 `src/router/routesAlias.ts` 与 `src/router/routes/asyncRoutes.ts` 的财务管理下新增账单、核销申请、收款方式路由。
|
||||
- [x] 2.3 在 `src/locales/langs/zh.json`、`en.json` 的 `menus.financialManagement` 下补充菜单文案。
|
||||
|
||||
## 3. Payment Methods Page
|
||||
|
||||
- [x] 3.1 实现收款方式列表(名称、编码、排序、状态、备注、创建时间);接口返回 `{ items, page, size, total }`,复用 `ArtTableFullScreen` / `ArtSearchBar` / `ArtTableHeader` / `ArtTable`。
|
||||
- [x] 3.2 实现新增/编辑弹窗;编辑时允许提交 `code`,被核销申请引用时由后端拒绝并提示。
|
||||
- [x] 3.3 实现删除操作,被引用的方式给出提示并引导停用。
|
||||
|
||||
## 4. Bills Pages
|
||||
|
||||
- [x] 4.1 实现账单统计卡片(应收、已核销、未核销、待处理账单数量),数据来自 `GET /api/admin/employee-collection-bills/statistics`。
|
||||
- [x] 4.2 实现账单列表与筛选(来源、来源单号、状态、客户/店铺、创建时间范围),展示账单号、来源、关联单号、负责员工、客户/店铺、应收/已核销/未核销金额与状态。
|
||||
- [x] 4.3 实现账单详情,展示账单信息、退款冲销、核销分摊与关联核销申请(关联申请可展开查看审批尝试记录)。
|
||||
- [x] 4.4 实现超管关闭账单弹窗,关闭原因必填;存在审批中申请时禁用关闭并给出说明。
|
||||
- [x] 4.5 列表「创建核销申请」入口按权限与账单状态控制可用性。
|
||||
|
||||
## 5. Applications Pages
|
||||
|
||||
- [x] 5.1 实现核销申请列表与筛选(状态、收款方式、创建时间),展示申请编号、收款方式、付款金额、状态与提交时间。
|
||||
- [x] 5.2 实现创建/重新提交弹窗:选择 1~N 张可核销账单、按账单录入分摊金额、填写付款金额/付款方名称/付款时间/外部交易流水号、选择收款方式并上传付款凭证。
|
||||
- [x] 5.3 超管代办时必须填写 `acting_reason`,否则禁止提交。
|
||||
- [x] 5.4 实现申请详情,展示申请信息、分摊账单、付款凭证与全部审批尝试记录;历史记录只读,不被重新提交覆盖。
|
||||
- [x] 5.5 仅已驳回申请展示「修改并重新提交」,提交成功后生成新的审批实例并刷新详情。
|
||||
- [x] 5.6 统一处理 503「企微审批场景未配置」等业务错误,保留用户已填内容。
|
||||
|
||||
## 6. WeCom Scene and Order Rule
|
||||
|
||||
- [x] 6.1 扩展 `WecomBusinessType` 增加 `employee_collection_approval`,并在企微审批场景页面新增可选业务类型。
|
||||
- [x] 6.2 场景保存复用既有 `inspectTemplate`、`getBusinessFields`、`saveScene` 接口,无需新增企微接口。
|
||||
- [x] 6.3 调整线下订单创建:平台账号操作、非赠送且实际收款金额大于 0 时 `payment_voucher_key` 非必填,字段结构不变。
|
||||
|
||||
## 7. Verification
|
||||
|
||||
- [x] 7.1 运行 `npm run build`(含 `vue-tsc --noEmit`)与 `npm run check:encoding`,确保类型与编码通过。
|
||||
- [ ] 7.2 校验普通员工与超管的菜单、列与操作可见性差异。
|
||||
- [ ] 7.3 校验金额分/元转换、附件预签名展示与 503 错误兜底。
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,92 @@
|
||||
## Context
|
||||
|
||||
AUG26-017 给代理预存款带来两类变化:在线自充的收款方式从「由支付配置自动决定」改为「超管配置允许范围」;线下预存款审批从「金额 + 支付凭证」补齐为「收款方式 + 交易流水号 + 其他凭证」。后端接口边界已经确认:
|
||||
|
||||
| 用途 | 接口 |
|
||||
|---|---|
|
||||
| 在线可用方式 | `GET /api/admin/agent-self-recharge-payment-methods`,代理/平台可用,超管 403;旧接口 `GET /api/admin/agent-recharges/payment-methods` 保留但前端不再调用 |
|
||||
| 允许范围读取 | `GET /api/admin/system-configs`(`module`、`page`、`page_size`,返回 `list` / `page` / `page_size` / `total`) |
|
||||
| 允许范围写入 | `PUT /api/admin/system-configs/{key}`,请求体 `{ key, value }`,`value` 为字符串化配置值 |
|
||||
| 交易流水号识别 | `POST /api/admin/agent-recharges/payment-voucher-ocr`,请求 `{ payment_voucher_key }`,响应 `{ external_transaction_no }` |
|
||||
| 线下创建 | `POST /api/admin/agent-recharges`,`payment_method=offline` 时新增 `offline_payment_method_id`、`external_transaction_no`、`other_voucher_key` |
|
||||
| 列表与详情 | `GET /api/admin/agent-recharges`、`GET /api/admin/agent-recharges/{id}` 新增 5 个响应字段 |
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- 在线充值只展示后端返回的实际可用方式,并优雅处理空列表。
|
||||
- 超管可在既有系统配置页面维护允许范围,无需新增接口或页面。
|
||||
- 线下预存款申请可提交收款方式、交易流水号与其他凭证,并支持 OCR 预填流水号。
|
||||
- 列表与详情正确展示两类交易号与收款方式快照。
|
||||
|
||||
**Non-Goals**
|
||||
|
||||
- 不新增「代理自充设置」独立页面与专用配置接口。
|
||||
- 不实现允许范围的审计查询接口(后端记录操作者、前后值与时间,前端不查询)。
|
||||
- 不实现后端接口、商户池交集计算、企微审批回调。
|
||||
- 不实现 H5/C 端在线充值,不为企业账号做分支。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 允许范围沿用受控系统配置
|
||||
|
||||
允许范围配置项的读取与写入统一走系统配置:读取 `GET /api/admin/system-configs`,写入 `PUT /api/admin/system-configs/{key}`。系统配置页已是元数据驱动的通用渲染器(`value_type` 决定控件、`enum_values` 决定枚举选项、`readonly` 与 `sensitive` 决定保护策略),因此后端的允许范围配置项注册后即可在页面中展示与编辑。
|
||||
|
||||
前端 MUST NOT 硬编码该配置项的 `config_key`,也 MUST NOT 新增专用设置写接口。该配置项归属 `c2b.payment` 模块,属于现有 `SystemConfigModule` 取值,无需扩展模块枚举。
|
||||
|
||||
允许范围的枚举取值为 `wechat_only`(仅微信支付)、`alipay_only`(仅支付宝支付)、`both`(同时支持微信与支付宝);系统配置页展示与选择时使用中文标签,未命中映射时回退展示原值。
|
||||
|
||||
超管入口使用一个「代理自充设置」菜单项,跳转到系统配置页面并带 `module=c2b.payment` 过滤条件;系统配置页面除了既有的 `config_key` query,还需支持从 `route.query.module` 初始化模块筛选,保证跳转后列表已按模块收敛。
|
||||
|
||||
### 在线可用方式
|
||||
|
||||
| 项 | 取值 |
|
||||
|---|---|
|
||||
| 接口 | `GET /api/admin/agent-self-recharge-payment-methods` |
|
||||
| 响应 | `{ methods: (wechat \| alipay)[], min_amount, max_amount }` |
|
||||
| 空列表 | 只提示「当前暂无可用的在线支付方式」,禁用提交,不解释被限制还是无可用商户 |
|
||||
| 超管 | 该接口对超管返回 403,前端在超管视角不请求它,仅通过系统配置查看允许范围 |
|
||||
| 存量单 | 配置变更不影响已创建的待支付单,前端不在配置变更后刷新待支付单 |
|
||||
|
||||
金额上下限优先使用接口返回的 `min_amount` / `max_amount`(单位分),接口未返回时回退既有常量。
|
||||
|
||||
### 线下预存款创建字段
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `offline_payment_method_id` | 是 | 取自 `GET /api/admin/employee-collection-payment-methods` 的启用项(`enabled=true`,`page_size=100`) |
|
||||
| `external_transaction_no` | 是 | 交易流水号,OCR 预填后人工确认,可编辑;前端不做重复校验 |
|
||||
| `other_voucher_key` | 否 | 其他凭证对象键数组,最多 5 个 |
|
||||
| `payment_voucher_key` | 是 | 支付凭证,至少 1 个,与其他凭证分开提交 |
|
||||
|
||||
历史线下单的收款方式只用 `offline_payment_method_code` / `offline_payment_method_name` 快照展示,不用 `offline_payment_method_id` 反查字典当前值。
|
||||
|
||||
列表筛选保持后端已有参数集合(`page`、`page_size`、`shop_id`、`status`、`recharge_source`、`start_date`、`end_date`),不新增交易流水号筛选,交易流水号只做展示。
|
||||
|
||||
### OCR 预填交互
|
||||
|
||||
- 入口:线下代充弹窗支付凭证区的「识别凭证」按钮,取已上传的第一个 `payment_voucher_key`;未上传凭证时禁用。
|
||||
- 请求:`POST /api/admin/agent-recharges/payment-voucher-ocr`;通过 `BaseService.post` 的第三个参数传 `{ timeout: 30000 }`,请求期间按钮与交易流水号字段展示 loading 并防重复点击。
|
||||
- 成功:把 `external_transaction_no` 写入交易流水号输入框,字段保持可编辑并提示对照凭证核对。
|
||||
- 失败(凭证不是图片、对象不存在、识别服务异常):只提示,不清空已填内容、不阻断手工填写与提交。
|
||||
- 只预填交易流水号,金额、付款人、付款时间、备注一律不预填。
|
||||
|
||||
### 凭证上传类型
|
||||
|
||||
取上传地址时必须显式声明 `content_type` 为 `image/jpeg`,否则 OCR 会以「不是图片」直接拒绝。`VoucherUpload` 新增可选 `contentType` prop,代理充值的支付凭证与其他凭证固定传 `image/jpeg`,并把它透传给 `StorageService.getUploadUrl` 与 `StorageService.uploadFile`。
|
||||
|
||||
### 交易流水号展示
|
||||
|
||||
`payment_transaction_id` 是在线渠道返回的权威交易号,只有在线单有值;`external_transaction_no` 是线下人工申报的交易流水号。两者独立展示、互不覆盖:在线单只展示前者,线下单只展示后者。
|
||||
|
||||
### 详情页保持只读
|
||||
|
||||
代理充值详情页只做信息展示,顶部仅保留返回导航;本次新增的交易流水号、收款方式与其他凭证都只读呈现,不引入任何业务操作按钮。创建、确认线下充值、驳回等操作入口仍留在列表页操作列。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **配置 Key 由后端注册决定**:前端不硬编码,配置项按 `c2b.payment` 模块渲染;若后端最终调整模块归属,只需同步调整菜单跳转的 `module` 参数。
|
||||
- **OCR 端到端约 15 至 16 秒**:必须配置不低于 30 秒的超时并展示 loading,否则用户容易重复点击。
|
||||
- **强制声明 `image/jpeg`**:按需求文档要求统一声明为 `image/jpeg`,上传非 JPEG 图片时以声明类型为准。
|
||||
- **收款方式字典项被引用后会冻结**:展示历史单依赖快照字段,避免字典改名或停用导致历史数据展示漂移。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Change: 代理自充收款方式配置与线下预存款审批字段
|
||||
|
||||
## Why
|
||||
|
||||
8 月迭代 AUG26-017(对应 PRD-008-021 与 PRD-008-013)包含两项要求:代理在线自充的收款方式由超级管理员维护「允许范围」(仅微信 / 仅支付宝 / 同时支持),代理实际可用方式取允许范围与当前可用商户方式的交集,交集为空时不允许创建在线充值单;线下预存款审批需要补齐「收款方式」「交易流水号」「其他凭证」,其中交易流水号支持从付款凭证 OCR 预填。
|
||||
|
||||
后台管理端现状与该要求有差距:在线充值直接调用旧接口 `GET /api/admin/agent-recharges/payment-methods`,线下代充表单只有店铺、支付凭证与运营备注,充值列表与详情也没有交易流水号、线下收款方式快照与其他凭证字段。
|
||||
|
||||
本次按后端实际 OpenAPI 契约对齐:可用方式改读 `GET /api/admin/agent-self-recharge-payment-methods`;允许范围的读取与写入沿用受控系统配置 `GET /api/admin/system-configs` 与 `PUT /api/admin/system-configs/{key}`;交易流水号识别使用 `POST /api/admin/agent-recharges/payment-voucher-ocr`。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在线充值可用支付方式改由 `GET /api/admin/agent-self-recharge-payment-methods` 提供,读取 `methods`、`min_amount`、`max_amount`;旧接口 `GET /api/admin/agent-recharges/payment-methods` 保留但前端不再调用。
|
||||
- `methods` 为空时只提示「当前暂无可用的在线支付方式」并禁用提交,不解释是被允许范围限制还是无可用商户。
|
||||
- 代理自充允许范围不新增专用接口与专用页面:配置项归属 `c2b.payment` 模块,超管沿用「系统配置」页面,通过 `GET /api/admin/system-configs` 读取、`PUT /api/admin/system-configs/{key}` 提交字符串化 `value`;前端不硬编码配置 Key,允许范围对代理与平台账号不可见。
|
||||
- 新增「代理自充设置」菜单(仅超级管理员),跳转到系统配置页面并带 `module=c2b.payment` 过滤条件;系统配置页面支持从路由 query 初始化模块筛选。
|
||||
- 线下预存款创建弹窗新增:收款方式(数据源 `GET /api/admin/employee-collection-payment-methods` 的启用项,提交 `offline_payment_method_id`)、交易流水号(必填、可编辑)、其他凭证(可选,最多 5 个 `other_voucher_key`);支付凭证 `payment_voucher_key` 仍必填且至少 1 个,与其他凭证分开提交。
|
||||
- 新增付款凭证 OCR 预填:`POST /api/admin/agent-recharges/payment-voucher-ocr` 请求 `{ payment_voucher_key }`、响应只有 `{ external_transaction_no }`;结果只作预填且始终可编辑,识别失败不阻断人工填写与提交,请求超时不低于 30 秒并展示 loading。
|
||||
- 充值列表与详情展示新增字段:`external_transaction_no`、`offline_payment_method_id`、`offline_payment_method_code`、`offline_payment_method_name`、`other_voucher_key`;线下单收款方式使用编码/名称快照展示,不用 `id` 反查字典当前值。
|
||||
- 代理充值列表不新增交易流水号筛选:`GET /api/admin/agent-recharges` 未提供该查询参数,交易流水号仅做展示。
|
||||
- 付款凭证与其他凭证获取上传地址时显式声明 `content_type` 为 `image/jpeg`。
|
||||
- 交易流水号 `external_transaction_no` 与在线渠道 `payment_transaction_id` 独立展示、互不覆盖;前端不做交易流水号重复校验。
|
||||
- 不实现后端接口、商户池逻辑、企微审批回调与 H5/C 端页面。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `agent-recharge`、`system-config-management`
|
||||
- Affected code:
|
||||
- `src/types/api/agentRecharge.ts`、`src/api/modules/agentRecharge.ts`、`src/types/api/index.ts`
|
||||
- `src/views/finance/agent-recharge/index.vue`、`src/views/finance/agent-recharge/detail.vue`
|
||||
- `src/views/settings/system-configs/index.vue`、`src/types/api/systemConfig.ts`
|
||||
- `src/router/routesAlias.ts`、`src/router/routes/asyncRoutes.ts`
|
||||
- `src/components/business/VoucherUpload.vue`
|
||||
- `src/locales/langs/{zh,en}.json`
|
||||
- Dependencies: `docs/产品迭代8月份/代理.md`
|
||||
- Out of scope: 代理自充设置独立页面、允许范围审计查询接口、企业账号分支、H5/C 端在线充值。
|
||||
@@ -0,0 +1,132 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理在线自充可用收款方式
|
||||
|
||||
前端 SHALL 通过 `GET /api/admin/agent-self-recharge-payment-methods` 获取代理在线自充的可用支付方式与金额范围,并在该接口返回空 `methods` 时禁止创建在线充值单。
|
||||
|
||||
#### Scenario: 读取可用支付方式
|
||||
|
||||
- **WHEN** 平台账号或代理账号进入代理充值页面并打开在线充值弹窗
|
||||
- **THEN** 前端 MUST 调用 `GET /api/admin/agent-self-recharge-payment-methods`
|
||||
- **AND** 前端 MUST 使用响应 `methods` 渲染可选的微信与支付宝方式
|
||||
- **AND** 前端 MUST 使用响应 `min_amount` 与 `max_amount` 作为金额上下限
|
||||
- **AND** 前端 MUST NOT 再调用 `GET /api/admin/agent-recharges/payment-methods`
|
||||
|
||||
#### Scenario: 可用方式为空
|
||||
|
||||
- **GIVEN** `GET /api/admin/agent-self-recharge-payment-methods` 返回空 `methods`
|
||||
- **WHEN** 用户打开在线充值弹窗
|
||||
- **THEN** 前端 MUST 提示「当前暂无可用的在线支付方式」
|
||||
- **AND** 前端 MUST 禁用在线充值提交
|
||||
- **AND** 前端 MUST NOT 解释为空的原因是被允许范围限制还是无可用商户
|
||||
- **AND** 前端 MUST NOT 因空列表报错或阻断页面其余功能
|
||||
|
||||
#### Scenario: 超级管理员不通过该接口读取允许范围
|
||||
|
||||
- **WHEN** 当前登录账号为超级管理员
|
||||
- **THEN** 前端 MUST NOT 为读取允许范围调用 `GET /api/admin/agent-self-recharge-payment-methods`
|
||||
- **AND** 前端 MUST 统一处理该接口对超级管理员返回的 403
|
||||
|
||||
#### Scenario: 允许范围变更不影响存量待支付单
|
||||
|
||||
- **WHEN** 允许范围配置发生变更
|
||||
- **THEN** 前端 MUST NOT 刷新或改写已创建待支付充值单的支付方式与商户信息
|
||||
|
||||
### Requirement: 线下预存款申请字段
|
||||
|
||||
线下预存款创建请求 SHALL 在 `payment_method` 为 `offline` 时提交 `offline_payment_method_id`、`external_transaction_no` 与可选的 `other_voucher_key`,并与既有的 `payment_voucher_key` 分开提交。
|
||||
|
||||
#### Scenario: 提交线下预存款申请
|
||||
|
||||
- **GIVEN** 用户以平台账号或超级管理员身份打开线下代充弹窗
|
||||
- **WHEN** 用户提交申请
|
||||
- **THEN** 请求体 MUST 包含 `amount`、`payment_method` 为 `offline` 与 `shop_id`
|
||||
- **AND** MUST 包含必填 `offline_payment_method_id`,取值来自 `GET /api/admin/employee-collection-payment-methods` 的启用项
|
||||
- **AND** MUST 包含必填 `external_transaction_no`
|
||||
- **AND** MUST 包含至少 1 个 `payment_voucher_key`
|
||||
- **AND** MAY 包含最多 5 个 `other_voucher_key`
|
||||
|
||||
#### Scenario: 支付凭证与其他凭证分开提交
|
||||
|
||||
- **WHEN** 用户上传支付凭证与其他凭证
|
||||
- **THEN** 支付凭证 MUST 通过 `payment_voucher_key` 提交
|
||||
- **AND** 其他凭证 MUST 通过 `other_voucher_key` 提交
|
||||
- **AND** 前端 MUST NOT 将两类凭证合并到同一字段
|
||||
|
||||
#### Scenario: 交易流水号与在线交易号互不覆盖
|
||||
|
||||
- **WHEN** 前端提交或展示线下预存款申请
|
||||
- **THEN** `external_transaction_no` MUST 只作为线下人工申报的交易流水号
|
||||
- **AND** 前端 MUST NOT 用 `payment_transaction_id` 覆盖或替代 `external_transaction_no`
|
||||
- **AND** 前端 MUST NOT 对 `external_transaction_no` 做重复性校验
|
||||
|
||||
### Requirement: 付款凭证交易流水号识别预填
|
||||
|
||||
前端 SHALL 通过 `POST /api/admin/agent-recharges/payment-voucher-ocr` 以已上传的付款凭证对象键换取交易流水号预填值,识别失败 MUST NOT 阻断人工填写与提交。
|
||||
|
||||
#### Scenario: 识别成功预填交易流水号
|
||||
|
||||
- **GIVEN** 线下代充弹窗已上传至少 1 个付款凭证
|
||||
- **WHEN** 用户点击识别凭证
|
||||
- **THEN** 前端 MUST 调用 `POST /api/admin/agent-recharges/payment-voucher-ocr`,请求体为 `{ payment_voucher_key }`
|
||||
- **AND** 前端 MUST 将响应 `external_transaction_no` 写入交易流水号输入框
|
||||
- **AND** 交易流水号输入框 MUST 保持可编辑并提示用户对照凭证核对
|
||||
|
||||
#### Scenario: 识别失败不阻断提交
|
||||
|
||||
- **GIVEN** OCR 返回失败,包括凭证不是图片、对象不存在或识别服务异常
|
||||
- **WHEN** 用户点击识别凭证
|
||||
- **THEN** 前端 MUST 展示失败提示
|
||||
- **AND** 前端 MUST NOT 阻断用户手动填写交易流水号并提交
|
||||
- **AND** 前端 MUST NOT 清空用户已填写内容
|
||||
|
||||
#### Scenario: 识别请求展示加载态并使用足够超时
|
||||
|
||||
- **WHEN** 前端发起 OCR 请求
|
||||
- **THEN** 前端 MUST 在请求期间展示 loading 并防止重复提交
|
||||
- **AND** 请求超时 MUST NOT 低于 30 秒
|
||||
|
||||
#### Scenario: 只预填交易流水号
|
||||
|
||||
- **WHEN** OCR 识别成功
|
||||
- **THEN** 前端 MUST 只预填 `external_transaction_no`
|
||||
- **AND** 前端 MUST NOT 预填金额、付款人、付款时间或备注
|
||||
|
||||
### Requirement: 代理充值交易流水号与收款方式展示
|
||||
|
||||
代理充值列表与详情 SHALL 展示线下预存款的 `external_transaction_no` 与收款方式快照,在线充值只展示 `payment_transaction_id`。
|
||||
|
||||
#### Scenario: 列表展示线下补充字段
|
||||
|
||||
- **WHEN** 列表返回 `payment_method` 为 `offline` 的充值单
|
||||
- **THEN** 列表 MUST 展示 `external_transaction_no`
|
||||
- **AND** 列表 MUST 使用 `offline_payment_method_name` 展示收款方式
|
||||
|
||||
#### Scenario: 详情展示收款方式快照
|
||||
|
||||
- **WHEN** 详情返回历史线下充值单
|
||||
- **THEN** 前端 MUST 使用 `offline_payment_method_code` 与 `offline_payment_method_name` 展示收款方式
|
||||
- **AND** 前端 MUST NOT 用 `offline_payment_method_id` 反查字典当前值
|
||||
- **AND** 前端 MUST 展示 `other_voucher_key` 对应的其他凭证
|
||||
|
||||
#### Scenario: 两类交易号独立展示
|
||||
|
||||
- **WHEN** 详情展示在线充值单
|
||||
- **THEN** 前端 MUST 只展示 `payment_transaction_id`
|
||||
- **AND** 前端 MUST NOT 把 `external_transaction_no` 当作在线渠道交易号展示
|
||||
|
||||
#### Scenario: 列表不新增交易流水号筛选
|
||||
|
||||
- **WHEN** 前端实现代理充值列表筛选
|
||||
- **THEN** 前端 MUST 只使用 `page`、`page_size`、`shop_id`、`status`、`recharge_source`、`start_date` 与 `end_date`
|
||||
- **AND** 前端 MUST NOT 新增交易流水号筛选条件
|
||||
|
||||
### Requirement: 付款凭证上传类型声明
|
||||
|
||||
代理充值付款凭证与其他凭证在获取上传地址时 SHALL 显式声明 `content_type` 为 `image/jpeg`。
|
||||
|
||||
#### Scenario: 上传地址声明图片类型
|
||||
|
||||
- **WHEN** 前端为代理充值线下凭证请求上传地址
|
||||
- **THEN** 请求 MUST 携带 `content_type` 为 `image/jpeg`
|
||||
- **AND** 上传请求 MUST 使用相同的内容类型
|
||||
@@ -0,0 +1,42 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理自充允许范围配置项
|
||||
|
||||
超级管理员维护的代理自充允许范围 SHALL 作为受控系统配置项,通过既有系统配置页面读取与更新,前端 MUST NOT 新增专用接口或专用页面。
|
||||
|
||||
#### Scenario: 通过系统配置读取允许范围
|
||||
|
||||
- **WHEN** 超级管理员进入系统配置页面
|
||||
- **THEN** 前端 MUST 调用 `GET /api/admin/system-configs` 获取配置列表
|
||||
- **AND** 后端注册的代理自充允许范围配置项 MUST 依据返回元数据渲染
|
||||
- **AND** 前端 MUST NOT 为读取允许范围调用专用接口
|
||||
|
||||
#### Scenario: 以枚举控件编辑允许范围
|
||||
|
||||
- **GIVEN** 允许范围配置项返回非空 `enum_values`
|
||||
- **WHEN** 超级管理员编辑该项
|
||||
- **THEN** 页面 MUST 使用枚举选择控件渲染允许范围
|
||||
- **AND** 页面 MUST 以字符串化 `value` 调用 `PUT /api/admin/system-configs/{key}`
|
||||
- **AND** 页面 MUST 在提交前校验取值属于 `enum_values`
|
||||
- **AND** 允许范围枚举值 MUST 以中文标签展示,包括仅微信支付、仅支付宝支付与同时支持微信与支付宝
|
||||
|
||||
#### Scenario: 不硬编码配置 Key
|
||||
|
||||
- **WHEN** 前端实现允许范围配置项的展示与编辑
|
||||
- **THEN** 前端 MUST NOT 硬编码该配置项的 `config_key`
|
||||
- **AND** 前端 MUST 依据 `config_key`、`module`、`value_type`、`control` 与 `enum_values` 等元数据驱动渲染
|
||||
- **AND** 该配置项归属 `c2b.payment` 模块,前端 MUST NOT 扩展模块枚举取值
|
||||
|
||||
#### Scenario: 代理自充设置菜单带入模块筛选
|
||||
|
||||
- **GIVEN** 当前登录账号为超级管理员
|
||||
- **WHEN** 用户点击「代理自充设置」菜单
|
||||
- **THEN** 前端 MUST 跳转到系统配置页面并携带 `module=c2b.payment`
|
||||
- **AND** 系统配置页面 MUST 使用该 query 初始化模块筛选并据此查询配置列表
|
||||
- **AND** 非超级管理员 MUST NOT 看到该菜单
|
||||
|
||||
#### Scenario: 允许范围对代理与平台不可见
|
||||
|
||||
- **WHEN** 当前登录账号不是超级管理员
|
||||
- **THEN** 前端 MUST NOT 向代理与平台账号展示允许范围配置的入口或当前值
|
||||
- **AND** 代理与平台账号 MUST 只能通过 `GET /api/admin/agent-self-recharge-payment-methods` 获取实际可用方式
|
||||
@@ -0,0 +1,55 @@
|
||||
## 1. Contract and API Types
|
||||
|
||||
- [x] 1.1 `src/types/api/agentRecharge.ts`:`AgentRecharge` 新增 `external_transaction_no`、`offline_payment_method_id`、`offline_payment_method_code`、`offline_payment_method_name`、`other_voucher_key`(`string[] | null`),保持 `payment_transaction_id` 与 `payment_voucher_key` 语义不变。
|
||||
- [x] 1.2 扩展 `CreateAgentRechargeOfflineRequest`:新增必填 `offline_payment_method_id`、必填 `external_transaction_no`、可选 `other_voucher_key`(最多 5 个)。
|
||||
- [x] 1.3 新增 `AgentRechargePaymentVoucherOcrRequest { payment_voucher_key }` 与 `AgentRechargePaymentVoucherOcrResponse { external_transaction_no }`。
|
||||
- [x] 1.4 在 `src/types/api/index.ts` 导出新增类型。
|
||||
|
||||
## 2. Agent Recharge Service
|
||||
|
||||
- [x] 2.1 `src/api/modules/agentRecharge.ts` 新增 `getSelfRechargePaymentMethods()`,请求 `GET /api/admin/agent-self-recharge-payment-methods`。
|
||||
- [x] 2.2 新增 `recognizePaymentVoucher(data)`,请求 `POST /api/admin/agent-recharges/payment-voucher-ocr`,超时不低于 30000 毫秒。
|
||||
- [x] 2.3 保留旧 `getPaymentMethods()` 方法,但页面不再调用。
|
||||
|
||||
## 3. Online Recharge Available Methods
|
||||
|
||||
- [x] 3.1 在线充值弹窗改用 `getSelfRechargePaymentMethods()` 读取 `methods` / `min_amount` / `max_amount`,并把 `wechat`、`alipay` 映射为微信、支付宝选项。
|
||||
- [x] 3.2 `methods` 为空时提示「当前暂无可用的在线支付方式」并禁用提交,不解释原因。
|
||||
- [x] 3.3 超管视角不请求该接口(403 兜底);允许范围变更后不刷新已创建的待支付单。
|
||||
|
||||
## 4. Super Admin Allow-Range Config
|
||||
|
||||
- [x] 4.1 允许范围沿用系统配置页通用渲染:`GET /api/admin/system-configs` 读取、`PUT /api/admin/system-configs/{key}` 提交字符串化 `value`;不新增专用接口与专用页面。
|
||||
- [x] 4.2 `wechat_only` / `alipay_only` / `both` 在系统配置页展示与选择时显示中文标签,未命中映射时回退原值。
|
||||
- [x] 4.3 新增「代理自充设置」菜单(仅超级管理员),跳转到系统配置页面并带 `module=c2b.payment`;系统配置页支持从 `route.query.module` 初始化模块筛选。
|
||||
- [x] 4.4 在 `zh.json`、`en.json` 的 `menus.settings` 下补充 `agentSelfRecharge` 中英文文案,并清理系统配置页预存的空样式块。
|
||||
|
||||
## 5. Offline Pre-deposit Create Dialog
|
||||
|
||||
- [x] 5.1 新增「收款方式」下拉,数据来自 `GET /api/admin/employee-collection-payment-methods`(`enabled=true`、`page_size=100`),必填,提交 `offline_payment_method_id`。
|
||||
- [x] 5.2 新增「交易流水号」输入(必填、可编辑),并提示对照凭证核对。
|
||||
- [x] 5.3 新增「其他凭证」上传(可选,最多 5 个),提交 `other_voucher_key`;支付凭证仍必填且至少 1 个,两者分开提交。
|
||||
- [x] 5.4 `VoucherUpload` 新增可选 `contentType` prop 并透传给 `getUploadUrl` 与 `uploadFile`;代理充值线下凭证固定传 `image/jpeg`。
|
||||
- [x] 5.5 线下提交体按契约组装:`{ amount, payment_method: offline, shop_id, offline_payment_method_id, external_transaction_no, payment_voucher_key, other_voucher_key, remark }`,未填写的可选字段不提交。
|
||||
|
||||
## 6. Voucher OCR Prefill
|
||||
|
||||
- [x] 6.1 支付凭证区新增「识别凭证」按钮,取第一个已上传凭证 Key;未上传凭证时禁用。
|
||||
- [x] 6.2 识别期间展示 loading(按钮与交易流水号字段)并防重复点击,超时不低于 30 秒。
|
||||
- [x] 6.3 识别成功写入 `external_transaction_no`,字段保持可编辑;失败只提示,不清空已填内容、不阻断提交。
|
||||
- [x] 6.4 不预填金额、付款人、付款时间与备注。
|
||||
|
||||
## 7. List and Detail Display
|
||||
|
||||
- [x] 7.1 列表新增线下交易流水号与线下收款方式(`offline_payment_method_name` 快照)展示,仅线下单展示。
|
||||
- [x] 7.2 详情新增交易流水号、收款方式(`offline_payment_method_code` 与 `offline_payment_method_name` 快照)与其他凭证预览;在线单只展示 `payment_transaction_id`。
|
||||
- [x] 7.3 不使用 `offline_payment_method_id` 反查字典当前值,也不做交易流水号重复校验。
|
||||
- [x] 7.4 列表不新增交易流水号筛选,保持后端已有查询参数集合不变。
|
||||
|
||||
## 8. Verification
|
||||
|
||||
- [ ] 8.1 校验 `methods` 为空、超管调用新接口 403、允许范围变更不影响存量待支付单三种情况。
|
||||
- [ ] 8.2 校验 OCR 成功、失败、超时三条路径均不阻断人工填写与提交。
|
||||
- [ ] 8.3 校验线下单与在线单的两类交易号、收款方式快照与其他凭证展示正确。
|
||||
- [x] 8.4 运行 `npm run build`(含 `vue-tsc --noEmit`)与 `npm run check:encoding`。
|
||||
- [x] 8.5 运行 `openspec validate update-agent-self-recharge-and-offline-approval --strict`。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 商户池详情与商户凭证契约设计
|
||||
|
||||
## Context
|
||||
|
||||
支付商户与商户池管理页已经上线,运营在使用时遇到三个问题:商户池详情入口和支付商户不一致且信息不完整、新增商户池直接报错、商户凭证缺少字段枚举与类型校验。本次优化只新增详情页、收敛成员顺序数据源,并在既有凭证写入表单上叠加契约校验,不改变接口路径与请求结构。
|
||||
|
||||
## Goals
|
||||
|
||||
- 让商户池详情的入口和展示完整度与支付商户详情对齐。
|
||||
- 让新增/编辑商户池恢复可用,并且拖拽排序结果仍按顺序提交。
|
||||
- 让前端提交的商户凭证满足后端的字段枚举、必填键、值类型与商户标识约束。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不改变“更换凭证”表单的交互方式与凭证值不回显约束。
|
||||
- 不新增后端接口。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 商户池详情复用支付商户详情的入口模式
|
||||
|
||||
支付商户详情已采用“列表点击名称 → 独立详情页”的模式,因此商户池改为同样方式:列表名称列渲染为可点击文本,点击后跳转 `/settings/payment-merchant-pools/pool-detail/:id`,并移除行操作里的“详情”抽屉。
|
||||
|
||||
详情页复用 `src/components/common/DetailPage.vue`,按“基本信息”“轮询配置”“运行状态”三组展示字段。创建时间、更新时间只在接口返回时渲染,避免出现空白字段行。
|
||||
|
||||
替代方案是保留抽屉并补齐字段;但两个详情入口不一致会让运营难以形成稳定预期,因此不采用。
|
||||
|
||||
### 成员商户名称由前端解析
|
||||
|
||||
详情接口只返回 `member_ids`。详情页在加载详情后按该商户池的 `payment_method` 拉取支付商户列表,把成员 ID 映射为商户名称展示,同时保留成员数量。解析不到名称时展示“未知商户”,不展示 `member_ids` 原始 ID,避免把内部标识暴露给运营。
|
||||
|
||||
### 成员顺序使用单一数据源
|
||||
|
||||
原实现同时监听 `form.member_ids` 和 `orderedMemberIds`,并在两个回调中互相赋新数组,形成“赋值 → 触发 → 再赋值”的无限循环,从而在新增商户池时触发 `Maximum recursive updates exceeded`。
|
||||
|
||||
修复方式是把成员顺序收敛为单一数据源:`orderedMemberIds` 改为基于 `form.member_ids` 的 `computed`(get 返回成员数组,set 写回成员数组)。`VueDraggable` 通过 `v-model` 触发 setter 写回 `form.member_ids`,不再存在互相触发的 watch。
|
||||
|
||||
### 凭证字段枚举固化在类型模块并在提交前校验
|
||||
|
||||
凭证必填键由后端契约按 `payment_method` 与 `provider_type` 组合给出,前端与该契约保持一致,因此把枚举定义在 `src/types/api/paymentMerchantPools.ts`:`PAYMENT_CREDENTIAL_FIELD_SPECS` 描述每个 `provider_type` 的必填键与可选键,`PAYMENT_CREDENTIAL_BOOLEAN_KEYS`、`PAYMENT_CREDENTIAL_INTEGER_KEYS` 描述 `ali_production`、`ali_pay_expire_minutes` 的值类型,`PAYMENT_MERCHANT_IDENTITY_KEYS` 描述商户标识必须一致的凭证字段。枚举之外的字段名一律拦截,避免提交必然被后端拒绝的请求。
|
||||
|
||||
`buildPaymentCredentials` 负责提交前校验并转换:拒绝不属于当前服务商的字段名、补齐必填键检查、把布尔字段与整数字段从输入框字符串转换为布尔值/数字、校验 `merchant_identity` 与对应凭证字段一致。表单继续使用“字段名 + 字段值”的通用编辑方式,只在分隔线下方展示当前服务商的必填/可选字段提示。
|
||||
|
||||
替代方案是把凭证表单改成按服务商渲染固定中文标签字段;该方案会改变既有交互与凭证只写约定,本次不采用。
|
||||
|
||||
### 凭证仅平台账号可读写
|
||||
|
||||
凭证读取与写入入口复用既有平台账号限制:`/settings/payment-merchant-pools` 及其详情路由都带 `allowedUserTypes: [1, 2]`,页面内 `canManage` 再按 `isPlatformAccount` 过滤操作。凭证值只在当前编辑会话内存中存在,提交、取消或关闭后清空,不进入 Pinia 持久化、浏览器存储、日志或错误上报。
|
||||
|
||||
## Risks and Trade-offs
|
||||
|
||||
- 详情页与支付商户详情一样受 `allowedUserTypes: [1, 2]` 限制,代理与企业账号无法访问。
|
||||
- 商户池成员名称依赖支付商户列表接口,成员数量超过单页上限时可能解析不到名称,此时展示“未知商户”而不是原始 ID。
|
||||
- 凭证字段枚举与后端校验规则必须保持一致;后端新增字段时前端需要同步更新枚举,否则提交会被前端拦截。
|
||||
@@ -0,0 +1,48 @@
|
||||
# Change: 商户池详情与商户凭证契约
|
||||
|
||||
## Why
|
||||
|
||||
`docs/产品迭代8月份/支付商户优化.md` 记录了支付商户与商户池管理页的三个问题,加上运营补充的凭证契约与权限要求,本次处理三件事:
|
||||
|
||||
- 商户池详情与支付商户详情的入口不一致:支付商户是点击名称进入独立详情页,商户池却是行操作里的详情抽屉,且详情没有展示接口已返回的全部字段(例如 `time_period_started_at`),成员只给出数量。
|
||||
- 点击“新增商户池”时页面抛出 `Maximum recursive updates exceeded in component <PaymentMerchantPoolManagement>`,新增和编辑商户池流程完全不可用。
|
||||
- 商户凭证的字段契约此前只存在于接口文档:必填键由 `payment_method` 与 `provider_type` 组合决定,前端提交前没有字段枚举与类型校验,容易出现漏填必填键、`ali_production` / `ali_pay_expire_minutes` 以字符串提交、`merchant_identity` 与凭证字段不一致等会被后端拒绝的请求。
|
||||
|
||||
“更换凭证”表单的交互(手工逐行填写字段名与字段值、值不回显)按反馈保持原有行为,本次只在其上叠加字段契约校验。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增商户池详情页,并改为点击列表中商户池名称进入,与支付商户详情保持一致;移除列表行操作中的“详情”入口。
|
||||
- 商户池详情页展示详情接口返回的业务字段:商户池名称、支付方式、启停状态、成员数量、成员商户、轮询策略、统计周期、金额/笔数/时间阈值、时间起点、路由世代、备注,以及接口返回时的创建时间和更新时间。
|
||||
- 成员商户按支付商户名称展示,不展示 `member_ids` 原始 ID;名称无法解析时展示“未知商户”占位文案。
|
||||
- 合并商户池表单中 `form.member_ids` 与 `orderedMemberIds` 的双向 `watch` 为单一数据源,消除递归更新错误,同时保持拖拽排序结果按顺序提交。
|
||||
- 将商户凭证字段枚举(各 `payment_method` + `provider_type` 组合的必填键与可选键)固化到前端类型模块,并在提交前校验字段枚举、必填键、值类型(布尔/整数/字符串)与 `merchant_identity` 一致性。
|
||||
- 凭证写入表单展示当前服务商组合的必填/可选字段提示,降低漏填必填键的概率。
|
||||
- 明确凭证访问控制:仅超级管理员(`user_type=1`)与平台用户(`user_type=2`)可读写商户凭证,凭证内容不进入日志、审计与支付快照。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `payment-merchant-pool-management`
|
||||
- Affected code:
|
||||
- `src/views/settings/payment-merchant-pools/pool-detail.vue`(新增)
|
||||
- `src/views/settings/payment-merchant-pools/components/PoolManagement.vue`
|
||||
- `src/views/settings/payment-merchant-pools/components/MerchantManagement.vue`
|
||||
- `src/types/api/paymentMerchantPools.ts`
|
||||
- `src/router/routes/asyncRoutes.ts`
|
||||
- `src/router/routesAlias.ts`
|
||||
- `src/locales/langs/zh.json`、`src/locales/langs/en.json`
|
||||
- Dependencies:
|
||||
- 后端 `GET /api/admin/payment-merchant-pools/{id}` 返回 `member_ids`、`routing_epoch`、`time_period_started_at` 等字段。
|
||||
- 后端按 `payment_method` 与 `provider_type` 组合校验凭证必填键与值类型。
|
||||
- 成员商户名称由前端使用同 `payment_method` 的支付商户列表解析,不要求后端在商户池详情返回名称。
|
||||
- Compatibility:
|
||||
- 不改变“更换凭证”表单的交互方式与凭证值不回显约束。
|
||||
- 不影响支付商户列表与支付商户详情页的信息结构。
|
||||
- 不改变商户池创建、更新、启用、停用的请求结构。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不按服务商渲染带中文标签的固定凭证表单,仍保留“字段名 + 字段值”的通用编辑方式。
|
||||
- 不新增商户池详情、凭证校验之外的后端接口。
|
||||
- 不在商户池详情页展示 `id`、`member_ids` 等内部标识。
|
||||
- 不改动商户池轮询策略、成员排序规则和阈值换算规则。
|
||||
@@ -0,0 +1,155 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 商户池详情入口
|
||||
|
||||
系统 SHALL 与支付商户保持一致,以点击列表中商户池名称的方式进入独立详情页,并且不再在行操作中提供“详情”入口。
|
||||
|
||||
#### Scenario: 点击商户池名称进入详情页
|
||||
|
||||
- **GIVEN** 管理员位于支付商户与商户池管理页的“商户池”页签
|
||||
- **WHEN** 管理员点击列表中某个商户池名称
|
||||
- **THEN** 系统 MUST 跳转到该商户池的详情页
|
||||
- **AND** 详情页 MUST 通过 `GET /api/admin/payment-merchant-pools/{id}` 加载数据
|
||||
- **AND** 列表行操作 MUST NOT 再提供“详情”操作
|
||||
|
||||
#### Scenario: 商户池名称以可点击样式展示
|
||||
|
||||
- **GIVEN** 商户池列表加载成功
|
||||
- **WHEN** 页面渲染商户池名称列
|
||||
- **THEN** 商户池名称 MUST 以可点击样式展示
|
||||
- **AND** 点击名称 MUST NOT 触发表格行选中或其他行操作
|
||||
|
||||
#### Scenario: 非平台账号不能查看商户池详情
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 不是 `1` 或 `2`
|
||||
- **WHEN** 用户点击商户池名称或直接访问商户池详情页地址
|
||||
- **THEN** 系统 MUST NOT 展示商户池详情数据
|
||||
- **AND** 路由 MUST 按平台账号限制拦截该访问
|
||||
|
||||
### Requirement: 商户池详情信息完整
|
||||
|
||||
系统 SHALL 在商户池详情页展示详情接口返回的全部业务字段,并 SHALL 以支付商户名称展示成员商户,不得展示 `id` 或 `member_ids` 原始标识。
|
||||
|
||||
#### Scenario: 展示轮询策略与阈值字段
|
||||
|
||||
- **GIVEN** 管理员进入某个商户池详情页
|
||||
- **WHEN** 详情数据加载成功
|
||||
- **THEN** 详情 MUST 展示商户池名称、支付方式、启停状态、轮询策略、路由世代和备注
|
||||
- **AND** `strategy` 为 `amount` 或 `count` 时 MUST 展示统计周期和对应阈值
|
||||
- **AND** `strategy` 为 `time` 时 MUST 展示时间单位、时间长度和时间起点
|
||||
|
||||
#### Scenario: 成员商户按名称展示
|
||||
|
||||
- **GIVEN** 商户池详情返回 `member_ids`
|
||||
- **WHEN** 页面展示成员信息
|
||||
- **THEN** 页面 MUST 展示成员商户名称和成员数量
|
||||
- **AND** 页面 MUST NOT 展示 `member_ids` 原始 ID
|
||||
- **AND** 成员名称无法解析时 MUST 展示“未知商户”而不是 ID
|
||||
|
||||
#### Scenario: 未返回的字段不渲染
|
||||
|
||||
- **GIVEN** 商户池详情接口未返回创建时间或更新时间
|
||||
- **WHEN** 页面展示商户池详情
|
||||
- **THEN** 页面 MUST NOT 渲染对应字段的空白行
|
||||
|
||||
### Requirement: 商户池成员顺序单一数据源
|
||||
|
||||
系统 SHALL 使用单一数据源维护商户池成员顺序,保证新增、编辑和拖拽排序成员时不会出现递归更新错误,并且提交顺序与页面展示顺序一致。
|
||||
|
||||
#### Scenario: 新增商户池不再递归更新
|
||||
|
||||
- **WHEN** 管理员点击“新增商户池”
|
||||
- **THEN** 系统 MUST NOT 抛出 `Maximum recursive updates exceeded`
|
||||
- **AND** 表单 MUST 正常打开并允许选择成员
|
||||
|
||||
#### Scenario: 拖拽后按展示顺序提交
|
||||
|
||||
- **GIVEN** 商户池表单中存在多个成员商户
|
||||
- **WHEN** 管理员拖拽调整成员顺序并提交
|
||||
- **THEN** `member_ids` MUST 按拖拽后的顺序提交
|
||||
- **AND** 再次编辑该商户池时 MUST 按提交顺序展示成员
|
||||
|
||||
### Requirement: 商户凭证字段枚举契约
|
||||
|
||||
系统 SHALL 按 `payment_method` 与 `provider_type` 组合枚举商户凭证的必填键与可选键,并在提交前校验字段枚举、必填键、值类型与商户标识一致性。
|
||||
|
||||
字段枚举以后端契约为准(键名与渠道配置字段一致),前端必须与后端保持一致,后端新增或调整字段时需同步更新前端枚举。`credentials` MUST 为扁平 JSON 对象,必填键为:
|
||||
|
||||
- `payment_method=wechat`、`provider_type=wechat`:`wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url`
|
||||
- `payment_method=wechat`、`provider_type=wechat_v2`:`wx_mch_id`、`wx_api_v2_key`、`wx_notify_url`
|
||||
- `payment_method=wechat`、`provider_type=fuiou`:`fy_mchnt_cd`、`fy_ins_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url`
|
||||
- `payment_method=alipay`、`provider_type=alipay`:`ali_app_id`、`ali_private_key`、`ali_public_key`、`ali_notify_url`、`ali_return_url`
|
||||
|
||||
可选键为:`wechat` 可附 `wx_api_v2_key`;`alipay` 可附 `ali_production`(布尔,是否生产环境)与 `ali_pay_expire_minutes`(整数,支付过期分钟数)。除 `ali_production` 与 `ali_pay_expire_minutes` 外,凭证值 MUST 为字符串。`merchant_identity` MUST 分别等于 `wx_mch_id`、`fy_mchnt_cd` 或 `ali_app_id`。
|
||||
|
||||
#### Scenario: 提交微信直连凭证
|
||||
|
||||
- **GIVEN** 管理员提交 `payment_method=wechat`、`provider_type=wechat` 的商户凭证
|
||||
- **THEN** 请求 MUST 包含 `wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url`
|
||||
- **AND** 请求 MAY 附带 `wx_api_v2_key`
|
||||
- **AND** `merchant_identity` MUST 等于 `wx_mch_id`
|
||||
|
||||
#### Scenario: 提交微信直连 V2 凭证
|
||||
|
||||
- **GIVEN** 管理员提交 `payment_method=wechat`、`provider_type=wechat_v2` 的商户凭证
|
||||
- **THEN** 请求 MUST 包含 `wx_mch_id`、`wx_api_v2_key`、`wx_notify_url`
|
||||
- **AND** `merchant_identity` MUST 等于 `wx_mch_id`
|
||||
|
||||
#### Scenario: 提交富友凭证
|
||||
|
||||
- **GIVEN** 管理员提交 `payment_method=wechat`、`provider_type=fuiou` 的商户凭证
|
||||
- **THEN** 请求 MUST 包含 `fy_mchnt_cd`、`fy_ins_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url`
|
||||
- **AND** `merchant_identity` MUST 等于 `fy_mchnt_cd`
|
||||
|
||||
#### Scenario: 提交支付宝凭证
|
||||
|
||||
- **GIVEN** 管理员提交 `payment_method=alipay`、`provider_type=alipay` 的商户凭证
|
||||
- **THEN** 请求 MUST 包含 `ali_app_id`、`ali_private_key`、`ali_public_key`、`ali_notify_url`、`ali_return_url`
|
||||
- **AND** 请求 MAY 附带 `ali_production` 与 `ali_pay_expire_minutes`
|
||||
- **AND** `merchant_identity` MUST 等于 `ali_app_id`
|
||||
|
||||
#### Scenario: 缺少必填字段时拒绝提交
|
||||
|
||||
- **WHEN** 提交的凭证缺少当前服务商组合的必填键
|
||||
- **THEN** 前端 MUST 阻止提交并提示缺失的凭证字段名
|
||||
|
||||
#### Scenario: 拒绝不属于当前服务商的凭证字段
|
||||
|
||||
- **WHEN** 提交的凭证包含必填键与可选键之外的字段名
|
||||
- **THEN** 前端 MUST 阻止提交并提示该字段不属于当前服务商支持的字段
|
||||
|
||||
#### Scenario: 校验凭证值类型
|
||||
|
||||
- **GIVEN** 凭证包含 `ali_production` 或 `ali_pay_expire_minutes`
|
||||
- **WHEN** 管理员提交凭证
|
||||
- **THEN** `ali_production` MUST 以布尔值提交
|
||||
- **AND** `ali_pay_expire_minutes` MUST 以正整数提交
|
||||
- **AND** 其余凭证字段 MUST 以字符串提交
|
||||
|
||||
#### Scenario: 校验商户标识一致性
|
||||
|
||||
- **WHEN** `merchant_identity` 与 `wx_mch_id`、`fy_mchnt_cd` 或 `ali_app_id` 的值不一致
|
||||
- **THEN** 前端 MUST 阻止提交并提示商户标识必须与对应凭证字段一致
|
||||
|
||||
### Requirement: 商户凭证访问控制
|
||||
|
||||
系统 SHALL 仅允许超级管理员(`user_type=1`)与平台用户(`user_type=2`)读取和写入商户凭证,且凭证内容 MUST NOT 进入日志、审计记录或支付快照。
|
||||
|
||||
#### Scenario: 平台账号可读写凭证
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 为 `1` 或 `2`
|
||||
- **WHEN** 账号打开支付商户管理页、支付商户详情页或提交商户凭证
|
||||
- **THEN** 系统 MUST 允许读取凭证配置状态与写入凭证
|
||||
|
||||
#### Scenario: 非平台账号不可读写凭证
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 为 `3` 或 `4`
|
||||
- **WHEN** 账号访问支付商户管理页或支付商户详情页
|
||||
- **THEN** 系统 MUST 拒绝访问并 MUST NOT 返回凭证字段或凭证状态
|
||||
- **AND** 页面 MUST NOT 渲染凭证写入入口
|
||||
|
||||
#### Scenario: 凭证不写入日志与快照
|
||||
|
||||
- **WHEN** 创建、更新或删除支付商户成功或失败
|
||||
- **THEN** 凭证内容 MUST NOT 出现在浏览器存储、页面日志、错误上报或支付快照中
|
||||
- **AND** 页面 MUST 只展示“已配置/未配置”状态与 `credential_version`
|
||||
@@ -0,0 +1,33 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. 商户池详情入口
|
||||
|
||||
- [x] 1.1 新增商户池详情页 `pool-detail.vue`,复用 `DetailPage` 展示基本信息、轮询配置和运行状态。
|
||||
- [x] 1.2 新增 `/settings/payment-merchant-pools/pool-detail/:id` 路由与路由别名,并按平台账号(`allowedUserTypes: [1, 2]`)限制访问。
|
||||
- [x] 1.3 商户池列表名称列改为可点击文本,点击跳转对应详情页;移除行操作中的“详情”入口。
|
||||
- [x] 1.4 补充中英文路由标题文案 `menus.settings.detailsOfPaymentMerchantPool`。
|
||||
|
||||
## 2. 商户池详情内容
|
||||
|
||||
- [x] 2.1 详情页展示详情接口返回的全部业务字段:名称、支付方式、启停状态、成员数量、成员商户、轮询策略、统计周期、金额/笔数/时间阈值、时间起点、路由世代和备注。
|
||||
- [x] 2.2 成员按支付商户名称展示,不展示 `member_ids` 原始 ID,无法解析时展示“未知商户”。
|
||||
- [x] 2.3 创建时间、更新时间等字段仅在接口返回时渲染,避免空白字段行。
|
||||
|
||||
## 3. 商户池表单稳定性
|
||||
|
||||
- [x] 3.1 将 `orderedMemberIds` 改为基于 `form.member_ids` 的单一数据源,移除互相赋值的双向 watch。
|
||||
- [x] 3.2 验证新增、编辑、切换支付方式、添加成员、移除成员和拖拽排序后提交的成员顺序正确。
|
||||
- 新增/编辑/切换支付方式直接重置 `form.member_ids`;添加与移除成员在 `form.member_ids` 上增删;拖拽经 `VueDraggable` 的 `v-model` 写回同一数组,`buildPayload` 按数组顺序提交。
|
||||
|
||||
## 4. 商户凭证字段枚举与校验
|
||||
|
||||
- [x] 4.1 在 `src/types/api/paymentMerchantPools.ts` 固化凭证字段枚举:`PAYMENT_CREDENTIAL_FIELD_SPECS`(各 `provider_type` 的必填键与可选键)、`PAYMENT_CREDENTIAL_BOOLEAN_KEYS`、`PAYMENT_CREDENTIAL_INTEGER_KEYS`、`PAYMENT_MERCHANT_IDENTITY_KEYS`。
|
||||
- [x] 4.2 实现 `buildPaymentCredentials`:校验字段枚举与必填键,把 `ali_production` 转为布尔值、`ali_pay_expire_minutes` 转为正整数,其余字段保持字符串,并校验 `merchant_identity` 与对应凭证字段一致。
|
||||
- [x] 4.3 支付商户凭证写入表单接入该校验,并在“写入支付凭证”分隔线下方展示当前服务商组合的必填/可选字段提示。
|
||||
- [x] 4.4 凭证读写仍仅限超级管理员(`user_type=1`)与平台用户(`user_type=2`):路由 `allowedUserTypes` 与页面 `canManage` 双层限制,凭证值不进入持久化、日志与错误上报。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
- [x] 5.1 运行 `eslint`、`vue-tsc --noEmit` 与 `vite build`,确认改动通过类型检查和构建。
|
||||
- `eslint`(改动文件)、`vue-tsc --noEmit` 均通过;`vite build --mode development` 成功产出 `MerchantManagement`、`PoolManagement` 与 `pool-detail` chunk。
|
||||
- [x] 5.2 执行 `openspec validate update-payment-merchant-pool-optimization --strict`。
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
AgentRechargeListResponse,
|
||||
CreateAgentRechargeRequest,
|
||||
AgentRechargePaymentMethods,
|
||||
AgentRechargePaymentVoucherOcrRequest,
|
||||
AgentRechargePaymentVoucherOcrResponse,
|
||||
AgentRechargePaymentStatusResponse,
|
||||
ConfirmOfflinePaymentRequest,
|
||||
RejectAgentRechargeRequest,
|
||||
@@ -25,6 +27,30 @@ export class AgentRechargeService extends BaseService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理自充可用支付方式和金额限制
|
||||
* 仅代理与平台账号可用,超级管理员调用返回 403
|
||||
*/
|
||||
static getSelfRechargePaymentMethods(): Promise<BaseResponse<AgentRechargePaymentMethods>> {
|
||||
return this.get<BaseResponse<AgentRechargePaymentMethods>>(
|
||||
'/api/admin/agent-self-recharge-payment-methods'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别付款凭证中的交易流水号
|
||||
* 只返回预填值,识别较慢且失败不阻断人工填写
|
||||
*/
|
||||
static recognizePaymentVoucher(
|
||||
data: AgentRechargePaymentVoucherOcrRequest
|
||||
): Promise<BaseResponse<AgentRechargePaymentVoucherOcrResponse>> {
|
||||
return this.post<BaseResponse<AgentRechargePaymentVoucherOcrResponse>>(
|
||||
'/api/admin/agent-recharges/payment-voucher-ocr',
|
||||
data,
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理充值订单列表
|
||||
* @param params 查询参数
|
||||
|
||||
@@ -104,7 +104,8 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询该资产所有套餐记录,含虚流量换算字段(分页)
|
||||
* 查询该资产的历史套餐关系组(主套餐—加油包,分页)
|
||||
* total 仅统计顶层关系组,子项由后端随父项一起返回。
|
||||
* GET /api/admin/assets/:identifier/packages?page=1&page_size=50
|
||||
* @param identifier 资产标识符(ICCID 或 VirtualNo)
|
||||
* @param params 查询参数(可选分页参数)
|
||||
@@ -115,12 +116,7 @@ export class AssetService extends BaseService {
|
||||
): Promise<BaseResponse<AssetPackageListResponse>> {
|
||||
return this.get<BaseResponse<AssetPackageListResponse>>(
|
||||
`/api/admin/assets/${identifier}/packages`,
|
||||
params,
|
||||
{
|
||||
requestOptions: {
|
||||
show404Error: false // 404时不显示错误提示
|
||||
}
|
||||
}
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
149
src/api/modules/employeeCollection.ts
Normal file
149
src/api/modules/employeeCollection.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 员工代收款相关 API
|
||||
*/
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import type {
|
||||
CloseEmployeeCollectionBillRequest,
|
||||
CloseEmployeeCollectionBillResponse,
|
||||
CreateEmployeeCollectionPaymentMethodRequest,
|
||||
DeleteEmployeeCollectionPaymentMethodResponse,
|
||||
EmployeeCollectionApplicationListResponse,
|
||||
EmployeeCollectionApplicationQueryParams,
|
||||
EmployeeCollectionApplicationRequest,
|
||||
EmployeeCollectionApplicationResponse,
|
||||
EmployeeCollectionApplicationSubmitResponse,
|
||||
EmployeeCollectionBillListResponse,
|
||||
EmployeeCollectionBillQueryParams,
|
||||
EmployeeCollectionBillResponse,
|
||||
EmployeeCollectionBillStatisticsResponse,
|
||||
EmployeeCollectionPaymentMethodListResponse,
|
||||
EmployeeCollectionPaymentMethodQueryParams,
|
||||
EmployeeCollectionPaymentMethodResponse,
|
||||
UpdateEmployeeCollectionPaymentMethodRequest
|
||||
} from '@/types/api'
|
||||
|
||||
const PAYMENT_METHODS_BASE_URL = '/api/admin/employee-collection-payment-methods'
|
||||
const BILLS_BASE_URL = '/api/admin/employee-collection-bills'
|
||||
const APPLICATIONS_BASE_URL = '/api/admin/employee-collection-applications'
|
||||
|
||||
export class EmployeeCollectionService extends BaseService {
|
||||
/**
|
||||
* 获取收款方式列表
|
||||
* 超级管理员返回全部,普通员工仅返回启用项
|
||||
*/
|
||||
static getPaymentMethods(
|
||||
params?: EmployeeCollectionPaymentMethodQueryParams
|
||||
): Promise<EmployeeCollectionPaymentMethodListResponse> {
|
||||
return this.get<EmployeeCollectionPaymentMethodListResponse>(PAYMENT_METHODS_BASE_URL, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增收款方式
|
||||
*/
|
||||
static createPaymentMethod(
|
||||
data: CreateEmployeeCollectionPaymentMethodRequest
|
||||
): Promise<EmployeeCollectionPaymentMethodResponse> {
|
||||
return this.post<EmployeeCollectionPaymentMethodResponse>(PAYMENT_METHODS_BASE_URL, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改收款方式(已被引用时不可修改 code)
|
||||
*/
|
||||
static updatePaymentMethod(
|
||||
id: number,
|
||||
data: UpdateEmployeeCollectionPaymentMethodRequest
|
||||
): Promise<EmployeeCollectionPaymentMethodResponse> {
|
||||
return this.put<EmployeeCollectionPaymentMethodResponse>(
|
||||
`${PAYMENT_METHODS_BASE_URL}/${id}`,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除收款方式(已被引用时不可删除,只能停用)
|
||||
*/
|
||||
static deletePaymentMethod(id: number): Promise<DeleteEmployeeCollectionPaymentMethodResponse> {
|
||||
return this.delete<DeleteEmployeeCollectionPaymentMethodResponse>(
|
||||
`${PAYMENT_METHODS_BASE_URL}/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取员工代收款账单列表
|
||||
*/
|
||||
static getBills(
|
||||
params?: EmployeeCollectionBillQueryParams
|
||||
): Promise<EmployeeCollectionBillListResponse> {
|
||||
return this.get<EmployeeCollectionBillListResponse>(BILLS_BASE_URL, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单统计(应收/已核销/未核销/待处理数量)
|
||||
*/
|
||||
static getBillStatistics(
|
||||
params?: EmployeeCollectionBillQueryParams
|
||||
): Promise<EmployeeCollectionBillStatisticsResponse> {
|
||||
return this.get<EmployeeCollectionBillStatisticsResponse>(
|
||||
`${BILLS_BASE_URL}/statistics`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单详情(含退款冲销、核销分摊、关联申请、企微审批历史)
|
||||
*/
|
||||
static getBillById(id: number): Promise<EmployeeCollectionBillResponse> {
|
||||
return this.getOne<EmployeeCollectionBillResponse['data']>(`${BILLS_BASE_URL}/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭账单(仅超级管理员,必须填写原因)
|
||||
*/
|
||||
static closeBill(
|
||||
id: number,
|
||||
data: CloseEmployeeCollectionBillRequest
|
||||
): Promise<CloseEmployeeCollectionBillResponse> {
|
||||
return this.post<CloseEmployeeCollectionBillResponse>(`${BILLS_BASE_URL}/${id}/close`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建核销申请(提交后自动发起企微审批)
|
||||
*/
|
||||
static createApplication(
|
||||
data: EmployeeCollectionApplicationRequest
|
||||
): Promise<EmployeeCollectionApplicationSubmitResponse> {
|
||||
return this.post<EmployeeCollectionApplicationSubmitResponse>(APPLICATIONS_BASE_URL, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取核销申请列表
|
||||
*/
|
||||
static getApplications(
|
||||
params?: EmployeeCollectionApplicationQueryParams
|
||||
): Promise<EmployeeCollectionApplicationListResponse> {
|
||||
return this.get<EmployeeCollectionApplicationListResponse>(APPLICATIONS_BASE_URL, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取核销申请详情(含分摊账单、付款凭证、审批意见与提交历史)
|
||||
*/
|
||||
static getApplicationById(id: number): Promise<EmployeeCollectionApplicationResponse> {
|
||||
return this.getOne<EmployeeCollectionApplicationResponse['data']>(
|
||||
`${APPLICATIONS_BASE_URL}/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回后修改并重新提交(生成新的企微审批实例)
|
||||
*/
|
||||
static updateApplication(
|
||||
id: number,
|
||||
data: EmployeeCollectionApplicationRequest
|
||||
): Promise<EmployeeCollectionApplicationSubmitResponse> {
|
||||
return this.put<EmployeeCollectionApplicationSubmitResponse>(
|
||||
`${APPLICATIONS_BASE_URL}/${id}`,
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* API 服务模块统一导出
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,7 @@ export { OrderService } from './order'
|
||||
export { AssetService } from './asset'
|
||||
export { AgentRechargeService } from './agentRecharge'
|
||||
export { PaymentSettingsService } from './paymentSettings'
|
||||
export { PaymentMerchantPoolsService } from './paymentMerchantPools'
|
||||
export { SystemConfigService } from './systemConfig'
|
||||
export { ExchangeService } from './exchange'
|
||||
export { RefundService } from './refund'
|
||||
@@ -39,6 +40,7 @@ export { BulkPurchaseService } from './bulkPurchase'
|
||||
export { NotificationService } from './notification'
|
||||
export { AuditService } from './audit'
|
||||
export { WecomService } from './wecom'
|
||||
export { EmployeeCollectionService } from './employeeCollection'
|
||||
|
||||
// TODO: 按需添加其他业务模块
|
||||
// export { SettingService } from './setting'
|
||||
|
||||
231
src/api/modules/paymentMerchantPools.ts
Normal file
231
src/api/modules/paymentMerchantPools.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 支付商户、商户池及微信授权配置 API
|
||||
*/
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import type { BaseResponse } from '@/types/api'
|
||||
import type {
|
||||
CreatePaymentMerchantRequest,
|
||||
PaymentMerchant,
|
||||
PaymentCredentialValue,
|
||||
PaymentCredentials,
|
||||
PaymentMerchantDetail,
|
||||
PaymentMerchantDetailResponse,
|
||||
PaymentMerchantPageResponse,
|
||||
PaymentMerchantPageResult,
|
||||
PaymentMerchantProviderType,
|
||||
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 }
|
||||
|
||||
type RawPaymentMerchantDetail = PaymentMerchant & { credentials?: Record<string, unknown> }
|
||||
|
||||
const MERCHANT_CREDENTIAL_DISPLAY_KEYS: Record<PaymentMerchantProviderType, string[]> = {
|
||||
wechat: ['wx_mch_id', 'wx_serial_no', 'wx_notify_url'],
|
||||
wechat_v2: ['wx_mch_id', 'wx_serial_no', 'wx_notify_url'],
|
||||
fuiou: ['fy_api_url', 'fy_ins_cd', 'fy_mchnt_cd', 'fy_term_id', 'fy_notify_url'],
|
||||
alipay: [
|
||||
'ali_app_id',
|
||||
'ali_notify_url',
|
||||
'ali_return_url',
|
||||
'ali_pay_expire_minutes',
|
||||
'ali_production'
|
||||
]
|
||||
}
|
||||
|
||||
const MERCHANT_CREDENTIAL_STATUS_KEYS: Record<PaymentMerchantProviderType, string[]> = {
|
||||
wechat: ['wx_api_v2_key', 'wx_api_v3_key', 'wx_cert_content', 'wx_key_content'],
|
||||
wechat_v2: ['wx_api_v2_key', 'wx_api_v3_key', 'wx_cert_content', 'wx_key_content'],
|
||||
fuiou: ['fy_private_key', 'fy_public_key'],
|
||||
alipay: ['ali_private_key', 'ali_public_key']
|
||||
}
|
||||
|
||||
const sanitizeMerchant = (merchant: RawPaymentMerchant): PaymentMerchant => {
|
||||
// 仅通过解构丢弃 credentials 字段,避免在前端页面/状态中保留支付凭证明文
|
||||
const { credentials: _ignoredCredentials, ...safeMerchant } = merchant
|
||||
void _ignoredCredentials
|
||||
return safeMerchant
|
||||
}
|
||||
|
||||
const sanitizeMerchantDetail = (merchant: RawPaymentMerchantDetail): PaymentMerchantDetail => {
|
||||
const { credentials: rawCredentials, ...baseMerchant } = merchant
|
||||
const credentials: PaymentCredentials = {}
|
||||
const provider = merchant.provider_type
|
||||
const source = rawCredentials || {}
|
||||
|
||||
for (const key of MERCHANT_CREDENTIAL_DISPLAY_KEYS[provider] || []) {
|
||||
const value = source[key]
|
||||
if (value !== undefined && value !== null) {
|
||||
credentials[key] = value as PaymentCredentialValue
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of MERCHANT_CREDENTIAL_STATUS_KEYS[provider] || []) {
|
||||
const value = source[key]
|
||||
credentials[key] = value ? '已配置' : '未配置'
|
||||
}
|
||||
|
||||
return {
|
||||
...baseMerchant,
|
||||
credentials
|
||||
}
|
||||
}
|
||||
|
||||
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 getPaymentMerchantDetailById(id: number): Promise<PaymentMerchantDetailResponse> {
|
||||
return this.get<PaymentMerchantDetailResponse>(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`).then(
|
||||
(response) => ({
|
||||
...response,
|
||||
data: sanitizeMerchantDetail(response.data as unknown as RawPaymentMerchantDetail)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@
|
||||
tip?: string
|
||||
purpose?: FilePurpose
|
||||
maxSizeMb?: number
|
||||
contentType?: string
|
||||
singleColumnCsv?: boolean
|
||||
maxCsvRows?: number
|
||||
}
|
||||
@@ -79,6 +80,7 @@
|
||||
tip: '',
|
||||
purpose: 'attachment',
|
||||
maxSizeMb: 0,
|
||||
contentType: '',
|
||||
singleColumnCsv: false,
|
||||
maxCsvRows: 0
|
||||
})
|
||||
@@ -190,6 +192,35 @@
|
||||
uploadRef.value?.handleRemove(uploadFile)
|
||||
}
|
||||
|
||||
// accept 支持扩展名(.csv)、精确 MIME(image/jpeg)与通配 MIME(image/*)
|
||||
const isAcceptMatched = (file: File) => {
|
||||
if (!props.accept) return true
|
||||
|
||||
const fileName = file.name.toLowerCase()
|
||||
const fileType = (file.type || '').toLowerCase()
|
||||
|
||||
return props.accept.split(',').some((type) => {
|
||||
const value = type.trim().toLowerCase()
|
||||
if (!value) return false
|
||||
if (value.startsWith('.')) return fileName.endsWith(value)
|
||||
if (value.endsWith('/*')) return fileType.startsWith(value.slice(0, -1))
|
||||
return fileType === value
|
||||
})
|
||||
}
|
||||
|
||||
const getAcceptWarning = () => {
|
||||
const values = props.accept
|
||||
.split(',')
|
||||
.map((type) => type.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
if (values.length > 0 && values.every((value) => value.startsWith('image/'))) {
|
||||
return '只能上传图片格式的文件'
|
||||
}
|
||||
|
||||
return `只能上传 ${props.accept} 格式的文件`
|
||||
}
|
||||
|
||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
@@ -219,14 +250,8 @@
|
||||
}
|
||||
|
||||
if (props.accept) {
|
||||
const accepted = props.accept.split(',').some((type) => {
|
||||
const value = type.trim().toLowerCase()
|
||||
return value.startsWith('.')
|
||||
? file.name.toLowerCase().endsWith(value)
|
||||
: file.type.toLowerCase() === value
|
||||
})
|
||||
if (!accepted) {
|
||||
ElMessage.warning(`只能上传 ${props.accept} 格式的文件`)
|
||||
if (!isAcceptMatched(file)) {
|
||||
ElMessage.warning(getAcceptWarning())
|
||||
removeUploadFile(uploadFile)
|
||||
return
|
||||
}
|
||||
@@ -249,10 +274,10 @@
|
||||
try {
|
||||
ElMessage.info(`正在上传${props.voucherName}...`)
|
||||
|
||||
const contentType = file.type || 'application/octet-stream'
|
||||
const uploadContentType = props.contentType || file.type || 'application/octet-stream'
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
content_type: contentType,
|
||||
content_type: uploadContentType,
|
||||
purpose: props.purpose
|
||||
})
|
||||
|
||||
@@ -263,7 +288,7 @@
|
||||
}
|
||||
|
||||
const { upload_url, file_key } = uploadUrlRes.data
|
||||
await StorageService.uploadFile(upload_url, file, contentType)
|
||||
await StorageService.uploadFile(upload_url, file, uploadContentType)
|
||||
|
||||
if (uploadBatch !== activeUploadBatch || removedUploadUids.has(uploadFile.uid)) {
|
||||
return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 权限检查 Composable
|
||||
* 用于在模板或脚本中检查用户权限
|
||||
*/
|
||||
@@ -18,6 +18,9 @@ export function usePermission() {
|
||||
// 是否是超级管理员
|
||||
const isSuperAdmin = computed(() => userStore.isSuperAdmin)
|
||||
|
||||
// 是否是超级管理员或平台用户
|
||||
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
|
||||
/**
|
||||
* 检查是否有指定权限
|
||||
* @param permission 权限码
|
||||
@@ -76,6 +79,7 @@ export function usePermission() {
|
||||
permissions,
|
||||
buttons,
|
||||
isSuperAdmin,
|
||||
isPlatformAccount,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
hasAllPermissions,
|
||||
@@ -84,3 +88,4 @@ export function usePermission() {
|
||||
hasButton
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
src/config/constants/augustIteration.ts
Normal file
21
src/config/constants/augustIteration.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 八月迭代新增后台权限编码。
|
||||
* 页面和按钮统一引用这里的常量,后端菜单权限可直接复用同名编码。
|
||||
*/
|
||||
export const AUGUST_PERMISSIONS = {
|
||||
employeeCollection: {
|
||||
billPage: 'employee_collection:bill_view',
|
||||
billDetail: 'employee_collection:bill_detail',
|
||||
billClose: 'employee_collection:bill_close',
|
||||
applicationPage: 'employee_collection:application_view',
|
||||
applicationCreate: 'employee_collection:application_create',
|
||||
applicationDetail: 'employee_collection:application_detail',
|
||||
applicationUpdate: 'employee_collection:application_update',
|
||||
paymentMethodPage: 'employee_collection:payment_method_view',
|
||||
paymentMethodCreate: 'employee_collection:payment_method_create',
|
||||
paymentMethodEdit: 'employee_collection:payment_method_edit',
|
||||
paymentMethodDelete: 'employee_collection:payment_method_delete'
|
||||
}
|
||||
} as const
|
||||
|
||||
export type AugustPermission = string
|
||||
@@ -26,11 +26,20 @@
|
||||
"setting": {
|
||||
"menuType": {
|
||||
"title": "Menu Layout",
|
||||
"list": ["Vertical", "Horizontal", "Mixed", "Dual"]
|
||||
"list": [
|
||||
"Vertical",
|
||||
"Horizontal",
|
||||
"Mixed",
|
||||
"Dual"
|
||||
]
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme Style",
|
||||
"list": ["Light", "Dark", "System"]
|
||||
"list": [
|
||||
"Light",
|
||||
"Dark",
|
||||
"System"
|
||||
]
|
||||
},
|
||||
"menu": {
|
||||
"title": "Menu Style"
|
||||
@@ -40,11 +49,17 @@
|
||||
},
|
||||
"box": {
|
||||
"title": "Box Style",
|
||||
"list": ["Border", "Shadow"]
|
||||
"list": [
|
||||
"Border",
|
||||
"Shadow"
|
||||
]
|
||||
},
|
||||
"container": {
|
||||
"title": "Container Width",
|
||||
"list": ["Full", "Boxed"]
|
||||
"list": [
|
||||
"Full",
|
||||
"Boxed"
|
||||
]
|
||||
},
|
||||
"basics": {
|
||||
"title": "Basic Config",
|
||||
@@ -82,8 +97,14 @@
|
||||
"notice": {
|
||||
"title": "Notice",
|
||||
"btnRead": "Mark as read",
|
||||
"bar": ["Notice", "Message", "Todo"],
|
||||
"text": ["No"],
|
||||
"bar": [
|
||||
"Notice",
|
||||
"Message",
|
||||
"Todo"
|
||||
],
|
||||
"text": [
|
||||
"No"
|
||||
],
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"worktab": {
|
||||
@@ -275,10 +296,10 @@
|
||||
"evening": "Good evening!"
|
||||
},
|
||||
"exceptionPage": {
|
||||
"gohome": "Go Home",
|
||||
"403": "Sorry, you do not have permission to access this page",
|
||||
"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": {
|
||||
"login": {
|
||||
@@ -412,7 +433,12 @@
|
||||
"agentRechargeDetail": "Agent Recharge Details",
|
||||
"refundManagement": "Refund Management",
|
||||
"refundDetail": "Refund Details",
|
||||
"agentFundOverview": "Agent Fund Overview"
|
||||
"agentFundOverview": "Agent Fund Overview",
|
||||
"employeeCollectionBills": "Employee Collection Bills",
|
||||
"employeeCollectionBillDetail": "Bill Details",
|
||||
"employeeCollectionApplications": "Reconciliation Applications",
|
||||
"employeeCollectionApplicationDetail": "Application Details",
|
||||
"employeeCollectionPaymentMethods": "Payment Methods"
|
||||
},
|
||||
"deviceManagement": {
|
||||
"title": "Device Management",
|
||||
@@ -475,10 +501,15 @@
|
||||
"settings": {
|
||||
"title": "Settings Management",
|
||||
"paymentSettings": "Payment Settings",
|
||||
"agentSelfRecharge": "Agent Self-Recharge Settings",
|
||||
"detailsOfPaymentConfiguration": "Payment Configuration Details",
|
||||
"paymentMerchant": "Payment Merchant",
|
||||
"developerApi": "Developer API",
|
||||
"commissionTemplate": "Commission Template"
|
||||
"commissionTemplate": "Commission Template",
|
||||
"paymentMerchantPools": "Merchant Pool Management",
|
||||
"paymentMerchantPoolsTabMerchants": "Payment Merchants",
|
||||
"paymentMerchantPoolsTabPools": "Merchant Pools",
|
||||
"paymentMerchantPoolsTabWechatAuth": "WeChat Authorization"
|
||||
},
|
||||
"batch": {
|
||||
"title": "Batch Operations",
|
||||
|
||||
@@ -27,11 +27,20 @@
|
||||
"setting": {
|
||||
"menuType": {
|
||||
"title": "菜单布局",
|
||||
"list": ["垂直", "水平", "混合", "双列"]
|
||||
"list": [
|
||||
"垂直",
|
||||
"水平",
|
||||
"混合",
|
||||
"双列"
|
||||
]
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题风格",
|
||||
"list": ["浅色", "深色", "系统"]
|
||||
"list": [
|
||||
"浅色",
|
||||
"深色",
|
||||
"系统"
|
||||
]
|
||||
},
|
||||
"menu": {
|
||||
"title": "菜单风格"
|
||||
@@ -41,11 +50,17 @@
|
||||
},
|
||||
"box": {
|
||||
"title": "盒子样式",
|
||||
"list": ["边框", "阴影"]
|
||||
"list": [
|
||||
"边框",
|
||||
"阴影"
|
||||
]
|
||||
},
|
||||
"container": {
|
||||
"title": "容器宽度",
|
||||
"list": ["铺满", "定宽"]
|
||||
"list": [
|
||||
"铺满",
|
||||
"定宽"
|
||||
]
|
||||
},
|
||||
"basics": {
|
||||
"title": "基础配置",
|
||||
@@ -83,8 +98,14 @@
|
||||
"notice": {
|
||||
"title": "通知",
|
||||
"btnRead": "标为已读",
|
||||
"bar": ["通知", "消息", "代办"],
|
||||
"text": ["暂无"],
|
||||
"bar": [
|
||||
"通知",
|
||||
"消息",
|
||||
"代办"
|
||||
],
|
||||
"text": [
|
||||
"暂无"
|
||||
],
|
||||
"viewAll": "查看全部"
|
||||
},
|
||||
"worktab": {
|
||||
@@ -110,7 +131,11 @@
|
||||
"admin": "管理员",
|
||||
"user": "普通用户"
|
||||
},
|
||||
"placeholder": ["请输入手机号", "请输入密码", "请拖动滑块完成验证"],
|
||||
"placeholder": [
|
||||
"请输入手机号",
|
||||
"请输入密码",
|
||||
"请拖动滑块完成验证"
|
||||
],
|
||||
"sliderText": "按住滑块拖动",
|
||||
"sliderSuccessText": "验证成功",
|
||||
"rememberPwd": "记住密码",
|
||||
@@ -153,7 +178,11 @@
|
||||
"register": {
|
||||
"title": "创建账号",
|
||||
"subTitle": "欢迎加入我们,请填写以下信息完成注册",
|
||||
"placeholder": ["请输入账号", "请输入密码", "请再次输入密码"],
|
||||
"placeholder": [
|
||||
"请输入账号",
|
||||
"请输入密码",
|
||||
"请再次输入密码"
|
||||
],
|
||||
"rule": [
|
||||
"请再次输入密码",
|
||||
"两次输入密码不一致!",
|
||||
@@ -288,10 +317,10 @@
|
||||
"evening": "晚上好!"
|
||||
},
|
||||
"exceptionPage": {
|
||||
"gohome": "返回首页",
|
||||
"403": "抱歉,您无权访问该页面",
|
||||
"404": "抱歉,您访问的页面不存在",
|
||||
"500": "抱歉,服务器出错了"
|
||||
"500": "抱歉,服务器出错了",
|
||||
"gohome": "返回首页"
|
||||
},
|
||||
"menus": {
|
||||
"login": {
|
||||
@@ -409,7 +438,12 @@
|
||||
"agentRechargeDetail": "代理充值详情",
|
||||
"refundManagement": "退款管理",
|
||||
"refundDetail": "退款详情",
|
||||
"agentFundOverview": "代理商资金概况"
|
||||
"agentFundOverview": "代理商资金概况",
|
||||
"employeeCollectionBills": "员工代收款账单",
|
||||
"employeeCollectionBillDetail": "账单详情",
|
||||
"employeeCollectionApplications": "核销申请",
|
||||
"employeeCollectionApplicationDetail": "核销申请详情",
|
||||
"employeeCollectionPaymentMethods": "收款方式管理"
|
||||
},
|
||||
"commission": {
|
||||
"title": "佣金管理",
|
||||
@@ -419,9 +453,14 @@
|
||||
"settings": {
|
||||
"title": "设置管理",
|
||||
"paymentSettings": "支付设置",
|
||||
"agentSelfRecharge": "代理自充设置",
|
||||
"detailsOfPaymentConfiguration": "支付配置详情",
|
||||
"withdrawalSettings": "提现配置",
|
||||
"passwordSettings": "密码设置"
|
||||
"passwordSettings": "密码设置",
|
||||
"paymentMerchantPools": "商户池管理",
|
||||
"paymentMerchantPoolsTabMerchants": "支付商户",
|
||||
"paymentMerchantPoolsTabPools": "商户池",
|
||||
"paymentMerchantPoolsTabWechatAuth": "微信授权配置"
|
||||
}
|
||||
},
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -195,3 +204,4 @@ export const buildLoginRedirect = (currentPath: string): string => {
|
||||
}
|
||||
return `/auth/login?redirect=${encodeURIComponent(currentPath)}`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { RoutesAlias } from '../routesAlias'
|
||||
import { RoutesAlias } from '../routesAlias'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
|
||||
|
||||
/**
|
||||
* 菜单列表、异步路由
|
||||
@@ -690,6 +691,64 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
keepAlive: false
|
||||
}
|
||||
},
|
||||
// 员工代收款账单
|
||||
{
|
||||
path: 'employee-collection/bills',
|
||||
name: 'EmployeeCollectionBills',
|
||||
component: RoutesAlias.EmployeeCollectionBills,
|
||||
meta: {
|
||||
title: 'menus.financialManagement.employeeCollectionBills',
|
||||
keepAlive: true,
|
||||
permissions: [AUGUST_PERMISSIONS.employeeCollection.billPage]
|
||||
}
|
||||
},
|
||||
// 员工代收款账单详情
|
||||
{
|
||||
path: 'employee-collection/bills/detail/:id',
|
||||
name: 'EmployeeCollectionBillDetailRoute',
|
||||
component: RoutesAlias.EmployeeCollectionBillDetail,
|
||||
meta: {
|
||||
title: 'menus.financialManagement.employeeCollectionBillDetail',
|
||||
isHide: true,
|
||||
keepAlive: false,
|
||||
permissions: [AUGUST_PERMISSIONS.employeeCollection.billDetail]
|
||||
}
|
||||
},
|
||||
// 核销申请
|
||||
{
|
||||
path: 'employee-collection/applications',
|
||||
name: 'EmployeeCollectionApplications',
|
||||
component: RoutesAlias.EmployeeCollectionApplications,
|
||||
meta: {
|
||||
title: 'menus.financialManagement.employeeCollectionApplications',
|
||||
keepAlive: true,
|
||||
permissions: [AUGUST_PERMISSIONS.employeeCollection.applicationPage]
|
||||
}
|
||||
},
|
||||
// 核销申请详情
|
||||
{
|
||||
path: 'employee-collection/applications/detail/:id',
|
||||
name: 'EmployeeCollectionApplicationDetailRoute',
|
||||
component: RoutesAlias.EmployeeCollectionApplicationDetail,
|
||||
meta: {
|
||||
title: 'menus.financialManagement.employeeCollectionApplicationDetail',
|
||||
isHide: true,
|
||||
keepAlive: false,
|
||||
permissions: [AUGUST_PERMISSIONS.employeeCollection.applicationDetail]
|
||||
}
|
||||
},
|
||||
// 收款方式管理(仅超级管理员)
|
||||
{
|
||||
path: 'employee-collection/payment-methods',
|
||||
name: 'EmployeeCollectionPaymentMethods',
|
||||
component: RoutesAlias.EmployeeCollectionPaymentMethods,
|
||||
meta: {
|
||||
title: 'menus.financialManagement.employeeCollectionPaymentMethods',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER'],
|
||||
permissions: [AUGUST_PERMISSIONS.employeeCollection.paymentMethodPage]
|
||||
}
|
||||
},
|
||||
// 代理商资金概况
|
||||
{
|
||||
path: 'agent-fund-overview',
|
||||
@@ -836,6 +895,41 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
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-merchant-pools/detail/:id',
|
||||
name: 'PaymentMerchantPoolsDetailRoute',
|
||||
component: RoutesAlias.PaymentMerchantPoolsDetail,
|
||||
meta: {
|
||||
title: 'menus.settings.detailsOfPaymentMerchant',
|
||||
isHide: true,
|
||||
keepAlive: false,
|
||||
allowedUserTypes: [1, 2]
|
||||
}
|
||||
},
|
||||
// 商户池详情
|
||||
{
|
||||
path: 'payment-merchant-pools/pool-detail/:id',
|
||||
name: 'PaymentMerchantPoolDetailRoute',
|
||||
component: RoutesAlias.PaymentMerchantPoolDetail,
|
||||
meta: {
|
||||
title: 'menus.settings.detailsOfPaymentMerchantPool',
|
||||
isHide: true,
|
||||
keepAlive: false,
|
||||
allowedUserTypes: [1, 2]
|
||||
}
|
||||
},
|
||||
// 支付设置详情
|
||||
{
|
||||
path: 'payment-settings/detail/:id',
|
||||
@@ -869,6 +963,17 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
// 代理自充设置(跳转系统配置并带上模块筛选)
|
||||
{
|
||||
path: 'agent-self-recharge',
|
||||
name: 'AgentSelfRechargeSettings',
|
||||
redirect: { path: RoutesAlias.SystemConfigs, query: { module: 'c2b.payment' } },
|
||||
meta: {
|
||||
title: 'menus.settings.agentSelfRecharge',
|
||||
keepAlive: false,
|
||||
roles: ['R_SUPER']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wecom',
|
||||
name: 'WecomSettings',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 路由别名,方便快速找到页面,同时可以用作路由跳转
|
||||
*/
|
||||
export enum RoutesAlias {
|
||||
@@ -90,6 +90,11 @@ export enum RoutesAlias {
|
||||
RefundManagement = '/finance/refund', // 退款管理
|
||||
RefundDetail = '/finance/refund/detail', // 退款详情
|
||||
AgentFundOverview = '/finance/agent-fund-overview', // 代理商资金概况
|
||||
EmployeeCollectionBills = '/finance/employee-collection/bills', // 员工代收款账单
|
||||
EmployeeCollectionBillDetail = '/finance/employee-collection/bills/detail', // 员工代收款账单详情
|
||||
EmployeeCollectionApplications = '/finance/employee-collection/applications', // 核销申请
|
||||
EmployeeCollectionApplicationDetail = '/finance/employee-collection/applications/detail', // 核销申请详情
|
||||
EmployeeCollectionPaymentMethods = '/finance/employee-collection/payment-methods', // 收款方式管理
|
||||
|
||||
ExpiringAssets = '/asset-management/expiring-assets', // 临期资产
|
||||
|
||||
@@ -102,8 +107,12 @@ export enum RoutesAlias {
|
||||
WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置
|
||||
PaymentSettings = '/settings/payment-settings', // 支付设置
|
||||
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
|
||||
PaymentMerchantPools = '/settings/payment-merchant-pools', // 支付商户与商户池管理
|
||||
PaymentMerchantPoolsDetail = '/settings/payment-merchant-pools/detail', // 支付商户详情
|
||||
PaymentMerchantPoolDetail = '/settings/payment-merchant-pools/pool-detail', // 商户池详情
|
||||
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
|
||||
SystemConfigs = '/settings/system-configs', // 系统配置
|
||||
AgentSelfRechargeSettings = '/settings/agent-self-recharge', // 代理自充设置
|
||||
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
|
||||
WecomApplications = '/settings/wecom/applications', // 企业微信应用
|
||||
WecomMembers = '/settings/wecom/members', // 企业微信成员
|
||||
|
||||
@@ -47,6 +47,11 @@ export interface AgentRecharge {
|
||||
recharge_source?: AgentRechargeSource | null
|
||||
recharge_source_name?: string | null
|
||||
payment_voucher_key?: string[] | string // 凭证附件列表;历史数据可能为单字符串或逗号字符串
|
||||
external_transaction_no?: string | null // 线下人工申报的交易流水号,与在线 payment_transaction_id 互不覆盖
|
||||
offline_payment_method_id?: number | null // 线下收款方式字典ID,仅线下充值有值
|
||||
offline_payment_method_code?: string | null // 线下收款方式稳定编码快照
|
||||
offline_payment_method_name?: string | null // 线下收款方式名称快照
|
||||
other_voucher_key?: string[] | null // 其他凭证对象存储Key列表,仅线下充值有值
|
||||
rejection_reason?: string | null // 拒绝原因
|
||||
remark?: string // 运营备注
|
||||
submitter_name?: string | null // 提交人名称
|
||||
@@ -98,6 +103,9 @@ export interface CreateAgentRechargeOfflineRequest {
|
||||
payment_method: 'offline'
|
||||
shop_id: number
|
||||
payment_voucher_key: string[] // 线下支付凭证附件列表(payment_method=offline 时必填)
|
||||
offline_payment_method_id: number // 线下收款方式字典ID,取自收款方式字典启用项
|
||||
external_transaction_no: string // 线下交易流水号,OCR 预填后人工确认
|
||||
other_voucher_key?: string[] // 其他凭证对象存储Key列表,最多 5 个
|
||||
remark?: string // 运营备注,最多 1000 字
|
||||
}
|
||||
|
||||
@@ -112,6 +120,19 @@ export interface AgentRechargePaymentMethods {
|
||||
max_amount: number
|
||||
}
|
||||
|
||||
// 代理自充可用支付方式和金额范围(GET /api/admin/agent-self-recharge-payment-methods)
|
||||
export type AgentSelfRechargePaymentMethods = AgentRechargePaymentMethods
|
||||
|
||||
// 识别付款凭证中的交易流水号请求
|
||||
export interface AgentRechargePaymentVoucherOcrRequest {
|
||||
payment_voucher_key: string // 付款凭证对象存储Key,必须指向已上传的图片类型附件
|
||||
}
|
||||
|
||||
// 识别付款凭证中的交易流水号响应(只返回交易流水号预填值)
|
||||
export interface AgentRechargePaymentVoucherOcrResponse {
|
||||
external_transaction_no: string
|
||||
}
|
||||
|
||||
// 在线充值支付及钱包到账状态
|
||||
export interface AgentRechargePaymentStatusResponse {
|
||||
recharge_id: number
|
||||
|
||||
@@ -39,6 +39,9 @@ export enum PackageUsageStatus {
|
||||
// 套餐类型
|
||||
export type PackageType = 'formal' | 'addon'
|
||||
|
||||
// 套餐关系状态(历史套餐列表)
|
||||
export type AssetPackageRelationshipStatus = 'master_missing'
|
||||
|
||||
// 套餐使用类型
|
||||
export type UsageType = 'single_card' | 'device'
|
||||
|
||||
@@ -332,7 +335,7 @@ export type AssetRefreshResponse = AssetRealtimeStatusResponse
|
||||
|
||||
/**
|
||||
* 资产套餐使用记录
|
||||
* 对应接口:GET /api/admin/assets/:asset_type/:id/packages
|
||||
* 对应接口:GET /api/admin/assets/:identifier/packages
|
||||
*/
|
||||
export interface AssetPackageUsageRecord {
|
||||
id?: number // 兼容前端当前映射字段
|
||||
@@ -353,31 +356,34 @@ export interface AssetPackageUsageRecord {
|
||||
virtual_ratio?: number // 虚流量比例(real/virtual)
|
||||
reduction_pct?: number // 展示增幅比例(小数,如 0.428571)
|
||||
enable_virtual_data?: boolean // 是否启用虚流量
|
||||
paid_amount?: number // 实际支付金额(分)
|
||||
retail_amount?: number // 零售价(分)
|
||||
paid_amount?: number | null // 实际支付金额(分,仅平台账号可见)
|
||||
retail_amount?: number | null // 零售价(分)
|
||||
package_price?: number // 套餐价格(分)
|
||||
start_time?: string // 开始时间(兼容前端现有映射字段)
|
||||
expire_time?: string // 到期时间(兼容前端现有映射字段)
|
||||
duration_days?: number // 套餐时长(兼容前端现有映射字段)
|
||||
activated_at?: string // 激活时间
|
||||
expires_at?: string // 到期时间
|
||||
master_usage_id?: number | null // 主套餐 ID(加油包时有值)
|
||||
master_usage_id?: number | null // 主套餐使用记录 ID(加油包时有值)
|
||||
relationship_status?: AssetPackageRelationshipStatus // 关系状态;master_missing 表示主套餐缺失
|
||||
relationship_status_name?: string // 关系异常状态名称
|
||||
expand_by_default?: boolean // 是否默认展开该主套餐下的加油包
|
||||
children?: AssetPackageUsageRecord[] // 归属于该主套餐的加油包,不单独分页
|
||||
priority?: number // 优先级
|
||||
created_at?: string // 创建时间
|
||||
order_id?: number // 订单ID
|
||||
refund_id?: number // 退款ID(有退款时才有值)
|
||||
refund_no?: string // 退款单号快照
|
||||
expiry_base?: ExpiryBase | null // 到期计时基准
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产套餐列表分页响应
|
||||
* 对应接口:GET /api/admin/assets/:asset_type/:id/packages?page=1&page_size=50
|
||||
* 每个 items 元素为一个顶层主套餐关系组,total 只统计顶层组。
|
||||
* 对应接口:GET /api/admin/assets/:identifier/packages?page=1&page_size=50
|
||||
*/
|
||||
export interface AssetPackageUsageRecord {
|
||||
expiry_base?: ExpiryBase | null // 到期计时基准
|
||||
}
|
||||
|
||||
export interface AssetPackageListResponse {
|
||||
total: number // 总记录数
|
||||
total: number // 顶层主套餐关系组总数
|
||||
page: number // 当前页码
|
||||
page_size: number // 每页数量
|
||||
items: AssetPackageUsageRecord[] // 套餐记录列表
|
||||
@@ -389,6 +395,7 @@ export interface AssetPackageListResponse {
|
||||
export interface AssetPackageParams {
|
||||
page?: number // 页码,默认1
|
||||
page_size?: number // 每页数量,默认50
|
||||
status?: PackageUsageStatus | null // 按同一条使用记录筛选
|
||||
}
|
||||
|
||||
export interface UpdateAssetPackageUsedDataRequest {
|
||||
|
||||
302
src/types/api/employeeCollection.ts
Normal file
302
src/types/api/employeeCollection.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 员工代收款相关类型定义
|
||||
*
|
||||
* 依据后端 OpenAPI(员工代收款分组):
|
||||
* - 列表响应为 { items, page, size, total }
|
||||
* - 账单状态与申请状态均为数字枚举
|
||||
* - 金额单位为「分」,附件字段为对象存储 Key 数组
|
||||
*/
|
||||
|
||||
import type { BaseResponse, PaginationParams } from './common'
|
||||
|
||||
// ========== 收款方式字典 ==========
|
||||
|
||||
export interface EmployeeCollectionPaymentMethod {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
sort: number
|
||||
enabled: boolean
|
||||
remark?: string | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
/** 收款方式列表查询参数,后端返回 { items, page, size, total } */
|
||||
export interface EmployeeCollectionPaymentMethodQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
enabled?: boolean
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface CreateEmployeeCollectionPaymentMethodRequest {
|
||||
code: string
|
||||
name: string
|
||||
sort?: number
|
||||
enabled?: boolean
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface UpdateEmployeeCollectionPaymentMethodRequest {
|
||||
code?: string
|
||||
name?: string
|
||||
sort?: number
|
||||
enabled?: boolean
|
||||
remark?: string
|
||||
}
|
||||
|
||||
// ========== 通用列表结构 ==========
|
||||
|
||||
/** 后端列表统一返回 { items, page, size, total } */
|
||||
export interface EmployeeCollectionListData<T> {
|
||||
items?: T[] | null
|
||||
list?: T[] | null
|
||||
total?: number
|
||||
page?: number
|
||||
size?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
// ========== 员工代收款账单 ==========
|
||||
|
||||
/** 账单状态:0 待核销 / 1 部分核销 / 2 已核销 / 3 已关闭 */
|
||||
export type EmployeeCollectionBillStatus = number
|
||||
|
||||
/** 账单责任人(员工)快照 */
|
||||
export interface EmployeeCollectionDebtorSnapshot {
|
||||
account_id?: number | null
|
||||
account_name?: string | null
|
||||
account_type?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 账单客户 / 店铺快照 */
|
||||
export interface EmployeeCollectionCustomerSnapshot {
|
||||
shop_id?: number | null
|
||||
seller_shop_id?: number | null
|
||||
buyer_id?: number | null
|
||||
buyer_type?: string | null
|
||||
buyer_nickname?: string | null
|
||||
asset_identifier?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface EmployeeCollectionBill {
|
||||
id: number
|
||||
source_type?: string | null
|
||||
source_type_name?: string | null
|
||||
source_id?: number | null
|
||||
source_no?: string | null
|
||||
debtor_account_id?: number | null
|
||||
debtor_snapshot?: EmployeeCollectionDebtorSnapshot | null
|
||||
customer_snapshot?: EmployeeCollectionCustomerSnapshot | null
|
||||
receivable_amount?: number | null // 应收金额(分)
|
||||
received_amount?: number | null // 已核销金额(分)
|
||||
reserved_amount?: number | null // 审批中预占金额(分)
|
||||
remaining_amount?: number | null // 未核销金额(分)
|
||||
status: EmployeeCollectionBillStatus
|
||||
status_name?: string | null
|
||||
approval_pending?: boolean // 是否存在审批中的核销申请
|
||||
closed_reason?: string | null
|
||||
closed_at?: string | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
/** 核销申请详情中的账单分摊 */
|
||||
export interface EmployeeCollectionAllocation {
|
||||
id?: number
|
||||
bill_id: number
|
||||
amount: number // 本次分摊金额(分)
|
||||
status?: number | null // 0 审批中预占 / 1 已通过 / 2 已驳回或已释放
|
||||
status_name?: string | null
|
||||
bill_status?: number | null
|
||||
bill_status_name?: string | null
|
||||
bill_source_type?: string | null
|
||||
bill_source_no?: string | null
|
||||
bill_receivable_amount?: number | null
|
||||
bill_received_amount?: number | null
|
||||
bill_reserved_amount?: number | null
|
||||
created_at?: string | null
|
||||
released_at?: string | null
|
||||
}
|
||||
|
||||
/** 账单详情中的核销分摊(含所属核销申请信息) */
|
||||
export interface EmployeeCollectionBillAllocation {
|
||||
id?: number
|
||||
amount: number
|
||||
application_id?: number | null
|
||||
application_status?: number | null
|
||||
application_status_name?: string | null
|
||||
attempt_id?: number | null
|
||||
status?: number | null
|
||||
status_name?: string | null
|
||||
created_at?: string | null
|
||||
released_at?: string | null
|
||||
}
|
||||
|
||||
/** 来源订单退款冲销关联(账单详情 refunds) */
|
||||
export interface EmployeeCollectionBillRefund {
|
||||
id?: number
|
||||
refund_id?: number
|
||||
source_order_id?: number
|
||||
refund_amount?: number | null // 本次退款成功金额(分)
|
||||
reduced_amount?: number | null // 实际冲减应收金额(分)
|
||||
bill_receivable_amount?: number | null // 冲销前账单应收金额快照(分)
|
||||
outcome?: string | null // closed_full / reduced / hint_only
|
||||
outcome_name?: string | null
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
/** 账单详情(接口返回 { bill, refunds, allocations, applications }) */
|
||||
export interface EmployeeCollectionBillDetailData {
|
||||
bill: EmployeeCollectionBill
|
||||
refunds?: EmployeeCollectionBillRefund[] | null
|
||||
allocations?: EmployeeCollectionBillAllocation[] | null
|
||||
applications?: EmployeeCollectionBillApplication[] | null
|
||||
}
|
||||
|
||||
/** 账单统计 */
|
||||
export interface EmployeeCollectionBillStatistics {
|
||||
receivable_total: number // 应收总金额(分)
|
||||
received_total: number // 已收款(已核销)总金额(分)
|
||||
unsettled_total: number // 未结(未核销)总金额(分)
|
||||
pending_bill_count: number // 待处理账单数量
|
||||
}
|
||||
|
||||
export interface EmployeeCollectionBillQueryParams extends PaginationParams {
|
||||
source_type?: string
|
||||
source_no?: string
|
||||
status?: EmployeeCollectionBillStatus
|
||||
debtor_account_id?: number
|
||||
customer_id?: number
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
}
|
||||
|
||||
export interface CloseEmployeeCollectionBillRequest {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type CloseEmployeeCollectionBillResult = EmployeeCollectionBill
|
||||
|
||||
// ========== 核销申请 ==========
|
||||
|
||||
/** 申请状态:0 审批中 / 1 已通过 / 2 已驳回 / 3 已撤销或已关闭 */
|
||||
export type EmployeeCollectionApplicationStatus = number
|
||||
|
||||
/** 审批尝试记录(历史材料不被覆盖) */
|
||||
export interface EmployeeCollectionApplicationAttempt {
|
||||
id?: number
|
||||
attempt_no?: number
|
||||
approval_instance_id?: number | null
|
||||
approval_status?: number | null // 通用审批实例状态
|
||||
approval_status_name?: string | null
|
||||
approval_opinion?: string | null
|
||||
acting_reason?: string | null
|
||||
external_transaction_no?: string | null
|
||||
paid_amount?: number | null
|
||||
paid_at?: string | null
|
||||
payer_name?: string | null
|
||||
payment_method_id?: number | null
|
||||
payment_method_code?: string | null
|
||||
payment_method_name?: string | null
|
||||
payment_voucher_keys?: string[] | null
|
||||
remark?: string | null
|
||||
submitted_by_account_id?: number | null
|
||||
allocation_snapshot?: Array<Record<string, string>> | null
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
export interface EmployeeCollectionApplication {
|
||||
id: number
|
||||
applicant_account_id?: number | null
|
||||
acting_operator_id?: number | null // 0 表示本人办理
|
||||
acting_reason?: string | null
|
||||
payment_method_id?: number | null
|
||||
payment_method_code?: string | null
|
||||
payment_method_name?: string | null
|
||||
paid_amount?: number | null // 人工确认的付款金额(分)
|
||||
paid_at?: string | null
|
||||
payer_name?: string | null
|
||||
external_transaction_no?: string | null
|
||||
payment_voucher_keys?: string[] | null
|
||||
remark?: string | null
|
||||
status: EmployeeCollectionApplicationStatus
|
||||
status_name?: string | null
|
||||
terminal_reason?: string | null
|
||||
decided_at?: string | null
|
||||
latest_approval_instance_id?: number | null
|
||||
latest_attempt_id?: number | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
/** 账单详情中的核销申请(含该申请的审批尝试记录) */
|
||||
export interface EmployeeCollectionBillApplication extends EmployeeCollectionApplication {
|
||||
attempts?: EmployeeCollectionApplicationAttempt[] | null
|
||||
}
|
||||
|
||||
/** 申请详情(接口返回 { application, allocations, attempts }) */
|
||||
export interface EmployeeCollectionApplicationDetailData {
|
||||
application: EmployeeCollectionApplication
|
||||
allocations?: EmployeeCollectionAllocation[] | null
|
||||
attempts?: EmployeeCollectionApplicationAttempt[] | null
|
||||
}
|
||||
|
||||
export interface EmployeeCollectionApplicationQueryParams extends PaginationParams {
|
||||
status?: EmployeeCollectionApplicationStatus
|
||||
applicant_account_id?: number
|
||||
payment_method_id?: number
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
}
|
||||
|
||||
export interface EmployeeCollectionAllocationRequest {
|
||||
bill_id: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface EmployeeCollectionApplicationRequest {
|
||||
payment_method_id: number
|
||||
paid_amount: number
|
||||
paid_at: string
|
||||
payer_name: string
|
||||
external_transaction_no: string
|
||||
payment_voucher_keys: string[]
|
||||
remark?: string
|
||||
allocations: EmployeeCollectionAllocationRequest[]
|
||||
acting_reason?: string
|
||||
}
|
||||
|
||||
export interface EmployeeCollectionApplicationSubmitResult {
|
||||
allocations?: EmployeeCollectionAllocation[] | null
|
||||
application?: EmployeeCollectionApplication | null
|
||||
approval_instance_id?: number | null
|
||||
approval_status?: number | null
|
||||
approval_status_name?: string | null
|
||||
attempt?: EmployeeCollectionApplicationAttempt | null
|
||||
}
|
||||
|
||||
// ========== 响应类型 ==========
|
||||
|
||||
export type EmployeeCollectionPaymentMethodListResponse = BaseResponse<
|
||||
EmployeeCollectionPaymentMethod[] | EmployeeCollectionListData<EmployeeCollectionPaymentMethod>
|
||||
>
|
||||
export type EmployeeCollectionPaymentMethodResponse = BaseResponse<EmployeeCollectionPaymentMethod>
|
||||
export type DeleteEmployeeCollectionPaymentMethodResponse = BaseResponse<null>
|
||||
export type EmployeeCollectionBillListResponse = BaseResponse<
|
||||
EmployeeCollectionListData<EmployeeCollectionBill>
|
||||
>
|
||||
export type EmployeeCollectionBillResponse = BaseResponse<EmployeeCollectionBillDetailData>
|
||||
export type EmployeeCollectionBillStatisticsResponse =
|
||||
BaseResponse<EmployeeCollectionBillStatistics>
|
||||
export type CloseEmployeeCollectionBillResponse = BaseResponse<CloseEmployeeCollectionBillResult>
|
||||
export type EmployeeCollectionApplicationListResponse = BaseResponse<
|
||||
EmployeeCollectionListData<EmployeeCollectionApplication>
|
||||
>
|
||||
export type EmployeeCollectionApplicationResponse =
|
||||
BaseResponse<EmployeeCollectionApplicationDetailData>
|
||||
export type EmployeeCollectionApplicationSubmitResponse =
|
||||
BaseResponse<EmployeeCollectionApplicationSubmitResult>
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* API 类型统一导出
|
||||
*/
|
||||
|
||||
@@ -78,6 +78,9 @@ export * from './agentRecharge'
|
||||
// 支付设置相关
|
||||
export * from './paymentSettings'
|
||||
|
||||
// 支付商户与商户池相关
|
||||
export * from './paymentMerchantPools'
|
||||
|
||||
// 系统配置相关
|
||||
export * from './systemConfig'
|
||||
|
||||
@@ -131,3 +134,6 @@ export * from './audit'
|
||||
|
||||
// 企业微信审批配置相关
|
||||
export * from './wecom'
|
||||
|
||||
// 员工代收款相关
|
||||
export * from './employeeCollection'
|
||||
|
||||
359
src/types/api/paymentMerchantPools.ts
Normal file
359
src/types/api/paymentMerchantPools.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 支付商户与商户池相关类型定义
|
||||
*/
|
||||
|
||||
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 PaymentMerchantDetail extends PaymentMerchant {
|
||||
credentials: PaymentCredentials
|
||||
}
|
||||
|
||||
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 PaymentMerchantDetailResponse = BaseResponse<PaymentMerchantDetail>
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户凭证字段枚举:必填键由 `payment_method` 与 `provider_type` 组合决定,
|
||||
* 可选键为允许附带但非必须的字段,键名与渠道配置字段一致。
|
||||
*/
|
||||
export interface PaymentCredentialFieldSpec {
|
||||
required: string[]
|
||||
optional: string[]
|
||||
}
|
||||
|
||||
export const PAYMENT_CREDENTIAL_FIELD_SPECS: Record<
|
||||
PaymentMerchantProviderType,
|
||||
PaymentCredentialFieldSpec
|
||||
> = {
|
||||
wechat: {
|
||||
required: [
|
||||
'wx_mch_id',
|
||||
'wx_api_v3_key',
|
||||
'wx_cert_content',
|
||||
'wx_key_content',
|
||||
'wx_serial_no',
|
||||
'wx_notify_url'
|
||||
],
|
||||
optional: ['wx_api_v2_key']
|
||||
},
|
||||
wechat_v2: {
|
||||
required: ['wx_mch_id', 'wx_api_v2_key', 'wx_notify_url'],
|
||||
optional: []
|
||||
},
|
||||
fuiou: {
|
||||
required: [
|
||||
'fy_mchnt_cd',
|
||||
'fy_ins_cd',
|
||||
'fy_term_id',
|
||||
'fy_private_key',
|
||||
'fy_public_key',
|
||||
'fy_api_url',
|
||||
'fy_notify_url'
|
||||
],
|
||||
optional: []
|
||||
},
|
||||
alipay: {
|
||||
required: [
|
||||
'ali_app_id',
|
||||
'ali_private_key',
|
||||
'ali_public_key',
|
||||
'ali_notify_url',
|
||||
'ali_return_url'
|
||||
],
|
||||
optional: ['ali_production', 'ali_pay_expire_minutes']
|
||||
}
|
||||
}
|
||||
|
||||
export function getPaymentCredentialFieldSpec(
|
||||
providerType: PaymentMerchantProviderType
|
||||
): PaymentCredentialFieldSpec {
|
||||
return PAYMENT_CREDENTIAL_FIELD_SPECS[providerType] || { required: [], optional: [] }
|
||||
}
|
||||
|
||||
/** 凭证字段中的布尔字段:值必须为布尔值 */
|
||||
export const PAYMENT_CREDENTIAL_BOOLEAN_KEYS: string[] = ['ali_production']
|
||||
|
||||
/** 凭证字段中的整数字段:值必须为正整数 */
|
||||
export const PAYMENT_CREDENTIAL_INTEGER_KEYS: string[] = ['ali_pay_expire_minutes']
|
||||
|
||||
/** `merchant_identity` 必须与其值保持一致的凭证字段 */
|
||||
export const PAYMENT_MERCHANT_IDENTITY_KEYS: Record<PaymentMerchantProviderType, string> = {
|
||||
wechat: 'wx_mch_id',
|
||||
wechat_v2: 'wx_mch_id',
|
||||
fuiou: 'fy_mchnt_cd',
|
||||
alipay: 'ali_app_id'
|
||||
}
|
||||
|
||||
export interface PaymentCredentialEntryInput {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface PaymentCredentialValidationResult {
|
||||
credentials: PaymentCredentials | null
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并转换商户凭证输入:字段枚举、必填键、值类型与商户标识一致性。
|
||||
*/
|
||||
export function buildPaymentCredentials(
|
||||
providerType: PaymentMerchantProviderType,
|
||||
merchantIdentity: string,
|
||||
entries: PaymentCredentialEntryInput[]
|
||||
): PaymentCredentialValidationResult {
|
||||
const spec = getPaymentCredentialFieldSpec(providerType)
|
||||
const allowedKeys = [...spec.required, ...spec.optional]
|
||||
const credentials: Record<string, PaymentCredentialValue> = {}
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = entry.key.trim()
|
||||
if (!key) {
|
||||
return { credentials: null, error: '请填写凭证字段名' }
|
||||
}
|
||||
if (!allowedKeys.includes(key)) {
|
||||
return { credentials: null, error: `凭证字段 ${key} 不属于当前服务商支持的字段` }
|
||||
}
|
||||
if (!entry.value) {
|
||||
return { credentials: null, error: `请填写凭证字段 ${key} 的值` }
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(credentials, key)) {
|
||||
return { credentials: null, error: `凭证字段 ${key} 重复` }
|
||||
}
|
||||
|
||||
if (PAYMENT_CREDENTIAL_BOOLEAN_KEYS.includes(key)) {
|
||||
if (entry.value !== 'true' && entry.value !== 'false') {
|
||||
return { credentials: null, error: `凭证字段 ${key} 必须为布尔值 true 或 false` }
|
||||
}
|
||||
credentials[key] = entry.value === 'true'
|
||||
continue
|
||||
}
|
||||
|
||||
if (PAYMENT_CREDENTIAL_INTEGER_KEYS.includes(key)) {
|
||||
const numberValue = Number(entry.value)
|
||||
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
||||
return { credentials: null, error: `凭证字段 ${key} 必须为正整数` }
|
||||
}
|
||||
credentials[key] = numberValue
|
||||
continue
|
||||
}
|
||||
|
||||
credentials[key] = entry.value
|
||||
}
|
||||
|
||||
for (const key of spec.required) {
|
||||
if (!Object.prototype.hasOwnProperty.call(credentials, key)) {
|
||||
return { credentials: null, error: `缺少必填凭证字段 ${key}` }
|
||||
}
|
||||
}
|
||||
|
||||
const identityKey = PAYMENT_MERCHANT_IDENTITY_KEYS[providerType]
|
||||
if (identityKey && credentials[identityKey] !== merchantIdentity.trim()) {
|
||||
return { credentials: null, error: `商户标识必须与凭证字段 ${identityKey} 的值一致` }
|
||||
}
|
||||
|
||||
return { credentials, error: '' }
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { BaseResponse, PaginationData, PaginationParams } from './common'
|
||||
|
||||
export type WecomBusinessType = 'refund_approval' | 'offline_recharge_approval'
|
||||
export type WecomBusinessType =
|
||||
| 'refund_approval'
|
||||
| 'offline_recharge_approval'
|
||||
| 'employee_collection_approval'
|
||||
|
||||
export interface WecomApplication {
|
||||
id: number
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
|
||||
exportTaskScene?: ExportTaskScene
|
||||
/** 是否固定标签页 */
|
||||
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)
|
||||
@@ -29,8 +29,19 @@
|
||||
</template>
|
||||
</ElEmpty>
|
||||
<div v-else-if="packageList && packageList.length > 0" class="table-scroll-container">
|
||||
<ElTable :data="packageList" class="package-table" border>
|
||||
<ElTableColumn label="订单号" width="240">
|
||||
<ElTable
|
||||
:data="packageList"
|
||||
:expand-row-keys="defaultExpandedPackageUsageIds"
|
||||
:tree-props="{ children: 'children' }"
|
||||
row-key="package_usage_id"
|
||||
class="package-table"
|
||||
border
|
||||
>
|
||||
<ElTableColumn
|
||||
label="订单号"
|
||||
:width="orderNoColumnWidth"
|
||||
class-name="package-order-column"
|
||||
>
|
||||
<template #default="scope">
|
||||
<div class="order-no-cell">
|
||||
<ElTooltip :content="scope.row.order_no" placement="top" :show-after="300">
|
||||
@@ -50,7 +61,16 @@
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="套餐名称" width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="package-name-cell">
|
||||
<span class="package-name-text">{{ scope.row.package_name }}</span>
|
||||
<ElTag
|
||||
v-if="scope.row.relationship_status === 'master_missing'"
|
||||
type="warning"
|
||||
size="small"
|
||||
>
|
||||
{{ scope.row.relationship_status_name || '主套餐缺失' }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="isAdminOrPlatform" label="套餐成本价格" width="120">
|
||||
@@ -659,6 +679,25 @@
|
||||
total: props.total
|
||||
})
|
||||
|
||||
const defaultExpandedPackageUsageIds = computed(() =>
|
||||
props.packageList
|
||||
.filter((pkg) => pkg.expand_by_default && pkg.children?.length)
|
||||
.map((pkg) => pkg.package_usage_id ?? pkg.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.map(String)
|
||||
)
|
||||
|
||||
const getLongestOrderNoLength = (packages: PackageInfo[]): number =>
|
||||
packages.reduce((longestLength, pkg) => {
|
||||
const childLongestLength = pkg.children?.length ? getLongestOrderNoLength(pkg.children) : 0
|
||||
return Math.max(longestLength, pkg.order_no?.length || 0, childLongestLength)
|
||||
}, 0)
|
||||
|
||||
// 为树形展开按钮、层级缩进和单元格留出空间,避免订单号过早省略。
|
||||
const orderNoColumnWidth = computed(() =>
|
||||
Math.min(Math.max(240, getLongestOrderNoLength(props.packageList) * 8 + 80), 500)
|
||||
)
|
||||
|
||||
// 监听props变化更新分页
|
||||
watch(
|
||||
() => [props.page, props.pageSize, props.total],
|
||||
@@ -757,6 +796,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.package-order-column .cell) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.package-name-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
@@ -765,8 +810,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-name-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
|
||||
.package-name-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.order-no-cell {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-tooltip__trigger) {
|
||||
|
||||
@@ -220,11 +220,12 @@ export function useAssetInfo() {
|
||||
page_size: pageSize
|
||||
})
|
||||
if (response.code === 0 && response.data) {
|
||||
// 使用分页响应中的 items 数组
|
||||
packageList.value = response.data.items || []
|
||||
// items 代表顶层主套餐关系组,必须保留其 children,不得展平。
|
||||
const items = response.data.items || []
|
||||
packageList.value = items
|
||||
// 返回分页信息供外部使用
|
||||
return {
|
||||
items: response.data.items || [],
|
||||
items,
|
||||
total: response.data.total || 0,
|
||||
page: response.data.page || 1,
|
||||
pageSize: response.data.page_size || pageSize
|
||||
|
||||
@@ -193,12 +193,28 @@
|
||||
prop: 'payment_channel',
|
||||
formatter: (value) => value || '-'
|
||||
},
|
||||
...(isOfflineRecharge
|
||||
? [
|
||||
{
|
||||
label: '交易流水号',
|
||||
prop: 'external_transaction_no',
|
||||
formatter: (value: string | null | undefined) => value || '-',
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '收款方式',
|
||||
formatter: (_: unknown, data: AgentRecharge) =>
|
||||
data.offline_payment_method_name || data.offline_payment_method_code || '-'
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: '第三方支付流水号',
|
||||
prop: 'payment_transaction_id',
|
||||
formatter: (value) => value || '-',
|
||||
formatter: (value: string | null | undefined) => value || '-',
|
||||
fullWidth: true
|
||||
},
|
||||
}
|
||||
]),
|
||||
{
|
||||
label: '支付单号',
|
||||
prop: 'payment_no',
|
||||
@@ -226,6 +242,26 @@
|
||||
() => '查看支付凭证'
|
||||
)
|
||||
: h('span', '-')
|
||||
},
|
||||
{
|
||||
label: '其他凭证',
|
||||
fullWidth: true,
|
||||
render: (data: AgentRecharge) =>
|
||||
hasVoucherKeys(data.other_voucher_key ?? undefined)
|
||||
? h(
|
||||
ElButton,
|
||||
{
|
||||
type: 'primary',
|
||||
link: true,
|
||||
onClick: () => {
|
||||
paymentVoucherFileKeys.value = toVoucherKeyList(
|
||||
data.other_voucher_key ?? undefined
|
||||
)
|
||||
}
|
||||
},
|
||||
() => '查看其他凭证'
|
||||
)
|
||||
: h('span', '-')
|
||||
}
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -121,10 +121,66 @@
|
||||
ref="uploadRef"
|
||||
v-model="createForm.payment_voucher_key"
|
||||
voucher-name="支付凭证"
|
||||
accept="image/*"
|
||||
content-type="image/jpeg"
|
||||
@uploading-change="voucherUploading = $event"
|
||||
@change="createFormRef?.validateField('payment_voucher_key')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="createMode === 'offline'"
|
||||
label="收款方式"
|
||||
prop="offline_payment_method_id"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="createForm.offline_payment_method_id"
|
||||
placeholder="请选择收款方式"
|
||||
style="width: 100%"
|
||||
filterable
|
||||
:loading="paymentMethodOptionsLoading"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in offlinePaymentMethodOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="createForm.payment_method === 'offline'"
|
||||
label="交易流水号"
|
||||
prop="external_transaction_no"
|
||||
>
|
||||
<div class="external-transaction-row">
|
||||
<ElInput
|
||||
v-model="createForm.external_transaction_no"
|
||||
placeholder="请输入交易流水号,或点击右侧识别凭证预填"
|
||||
:disabled="ocrLoading"
|
||||
/>
|
||||
<ElButton
|
||||
:loading="ocrLoading"
|
||||
:disabled="!offlineVoucherKeys.length"
|
||||
@click="handleRecognizeVoucher"
|
||||
>
|
||||
识别凭证
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="external-transaction-tip">
|
||||
识别结果仅供参考,请对照凭证核对后再提交。
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="createMode === 'offline'" label="其他凭证">
|
||||
<VoucherUpload
|
||||
ref="otherUploadRef"
|
||||
v-model="createForm.other_voucher_key"
|
||||
voucher-name="其他凭证"
|
||||
:max-count="5"
|
||||
accept="image/*"
|
||||
content-type="image/jpeg"
|
||||
@uploading-change="otherVoucherUploading = $event"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="createMode === 'offline'" label="运营备注" prop="remark">
|
||||
<ElInput
|
||||
v-model="createForm.remark"
|
||||
@@ -297,7 +353,12 @@
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { AgentRechargeService, CommissionService, ShopService } from '@/api/modules'
|
||||
import {
|
||||
AgentRechargeService,
|
||||
CommissionService,
|
||||
EmployeeCollectionService,
|
||||
ShopService
|
||||
} from '@/api/modules'
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
@@ -319,6 +380,7 @@
|
||||
AgentRechargePaymentStatusResponse,
|
||||
CreateAgentRechargeRequest,
|
||||
ConfirmOfflinePaymentRequest,
|
||||
EmployeeCollectionPaymentMethod,
|
||||
RejectAgentRechargeRequest
|
||||
} from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
@@ -343,6 +405,7 @@
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import { buildAgentRechargeActions } from './agentRechargeActions'
|
||||
import { formatRejectionReason } from './agentRechargeDisplay'
|
||||
import { normalizeCollectionList } from '@/views/finance/employee-collection/employeeCollectionDisplay'
|
||||
import {
|
||||
amountYuanToFen,
|
||||
createOnlineRechargeRequestId,
|
||||
@@ -406,6 +469,10 @@
|
||||
max_amount: ONLINE_MAX_RECHARGE_AMOUNT_FEN
|
||||
})
|
||||
const paymentVoucherFileKeys = ref<string[]>([])
|
||||
const offlinePaymentMethodOptions = ref<EmployeeCollectionPaymentMethod[]>([])
|
||||
const paymentMethodOptionsLoading = ref(false)
|
||||
const otherVoucherUploading = ref(false)
|
||||
const ocrLoading = ref(false)
|
||||
|
||||
// 搜索表单初始值
|
||||
const initialSearchState: AgentRechargeQueryParams = {
|
||||
@@ -542,6 +609,8 @@
|
||||
{ label: '业务处理状态', prop: 'processing_status_name' },
|
||||
{ label: '支付方式', prop: 'payment_method' },
|
||||
{ label: '支付通道', prop: 'payment_channel' },
|
||||
{ label: '交易流水号', prop: 'external_transaction_no' },
|
||||
{ label: '收款方式', prop: 'offline_payment_method_name' },
|
||||
{ label: '运营备注', prop: 'remark' },
|
||||
{ label: '驳回原因', prop: 'rejection_reason' },
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
@@ -554,6 +623,7 @@
|
||||
const confirmPayFormRef = ref<FormInstance>()
|
||||
const rejectFormRef = ref<FormInstance>()
|
||||
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const otherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const OFFLINE_MIN_RECHARGE_AMOUNT = 0.01
|
||||
const OFFLINE_MAX_RECHARGE_AMOUNT = 1_000_000
|
||||
|
||||
@@ -579,6 +649,8 @@
|
||||
() =>
|
||||
createLoading.value ||
|
||||
voucherUploading.value ||
|
||||
otherVoucherUploading.value ||
|
||||
ocrLoading.value ||
|
||||
paymentMethodsLoading.value ||
|
||||
(createMode.value === 'online' && onlinePaymentMethods.value.length === 0)
|
||||
)
|
||||
@@ -631,6 +703,12 @@
|
||||
}
|
||||
if (createMode.value === 'offline') {
|
||||
rules.payment_voucher_key = [{ required: true, message: '请上传支付凭证', trigger: 'change' }]
|
||||
rules.offline_payment_method_id = [
|
||||
{ required: true, message: '请选择收款方式', trigger: 'change' }
|
||||
]
|
||||
rules.external_transaction_no = [
|
||||
{ required: true, message: '请输入交易流水号', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
if (createMode.value === 'online') delete rules.shop_id
|
||||
return rules
|
||||
@@ -651,12 +729,18 @@
|
||||
payment_method: AgentRechargePaymentMethod | ''
|
||||
shop_id: number | null
|
||||
payment_voucher_key: string[]
|
||||
offline_payment_method_id: number | null
|
||||
external_transaction_no: string
|
||||
other_voucher_key: string[]
|
||||
remark: string
|
||||
}>({
|
||||
amount: OFFLINE_MIN_RECHARGE_AMOUNT,
|
||||
payment_method: '',
|
||||
shop_id: null,
|
||||
payment_voucher_key: [],
|
||||
offline_payment_method_id: null,
|
||||
external_transaction_no: '',
|
||||
other_voucher_key: [],
|
||||
remark: ''
|
||||
})
|
||||
|
||||
@@ -803,6 +887,21 @@
|
||||
width: 120,
|
||||
formatter: (row: AgentRecharge) => row.payment_channel || '-'
|
||||
},
|
||||
{
|
||||
prop: 'external_transaction_no',
|
||||
label: '交易流水号',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: AgentRecharge) => row.external_transaction_no || '-'
|
||||
},
|
||||
{
|
||||
prop: 'offline_payment_method_name',
|
||||
label: '收款方式',
|
||||
width: 140,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: AgentRecharge) =>
|
||||
row.payment_method === 'offline' ? row.offline_payment_method_name || '-' : '-'
|
||||
},
|
||||
{
|
||||
prop: 'remark',
|
||||
label: '运营备注',
|
||||
@@ -1000,8 +1099,9 @@
|
||||
if (createMode.value === 'online') {
|
||||
await loadPaymentMethods()
|
||||
} else {
|
||||
// 重新加载店铺列表,确保获取最新数据
|
||||
// 重新加载店铺列表与收款方式字典,确保获取最新数据
|
||||
await loadShops()
|
||||
await loadPaymentMethodOptions()
|
||||
}
|
||||
|
||||
createDialogVisible.value = true
|
||||
@@ -1012,10 +1112,16 @@
|
||||
createForm.payment_method = ''
|
||||
createForm.shop_id = null
|
||||
createForm.payment_voucher_key = []
|
||||
createForm.offline_payment_method_id = null
|
||||
createForm.external_transaction_no = ''
|
||||
createForm.other_voucher_key = []
|
||||
createForm.remark = ''
|
||||
onlineRequestId.value = null
|
||||
voucherUploading.value = false
|
||||
otherVoucherUploading.value = false
|
||||
ocrLoading.value = false
|
||||
uploadRef.value?.clearFiles(false)
|
||||
otherUploadRef.value?.clearFiles(false)
|
||||
}
|
||||
|
||||
// 对话框关闭后的清理
|
||||
@@ -1024,12 +1130,12 @@
|
||||
resetCreateForm()
|
||||
}
|
||||
|
||||
// 加载代理在线充值可用支付方式
|
||||
// 加载代理在线充值可用支付方式(超管调用该接口返回 403,仅通过系统配置查看允许范围)
|
||||
const loadPaymentMethods = async () => {
|
||||
paymentMethodsLoading.value = true
|
||||
onlinePaymentMethods.value = []
|
||||
try {
|
||||
const res = await AgentRechargeService.getPaymentMethods()
|
||||
const res = await AgentRechargeService.getSelfRechargePaymentMethods()
|
||||
if (res.code === 0) {
|
||||
onlinePaymentMethods.value = Array.isArray(res.data?.methods) ? res.data.methods : []
|
||||
paymentMethodsBounds.min_amount = ONLINE_MIN_RECHARGE_AMOUNT_FEN
|
||||
@@ -1037,15 +1143,75 @@
|
||||
Number(res.data?.max_amount) || ONLINE_MAX_RECHARGE_AMOUNT_FEN
|
||||
createForm.amount = minimumAmountYuan.value
|
||||
} else {
|
||||
ElMessage.warning(res.msg || '当前暂无可用在线支付方式')
|
||||
ElMessage.warning(res.msg || '当前暂无可用的在线支付方式')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载在线支付方式失败:', error)
|
||||
ElMessage.warning('当前暂无可用的在线支付方式')
|
||||
} finally {
|
||||
paymentMethodsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载线下收款方式字典启用项
|
||||
const loadPaymentMethodOptions = async () => {
|
||||
paymentMethodOptionsLoading.value = true
|
||||
offlinePaymentMethodOptions.value = []
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getPaymentMethods({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
enabled: true
|
||||
})
|
||||
if (res.code === 0) {
|
||||
offlinePaymentMethodOptions.value =
|
||||
normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载线下收款方式失败:', error)
|
||||
} finally {
|
||||
paymentMethodOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 已上传的支付凭证对象键(OCR 识别取第一个)
|
||||
const offlineVoucherKeys = computed(() => toVoucherKeyList(createForm.payment_voucher_key))
|
||||
|
||||
// 识别付款凭证中的交易流水号:只做预填,失败不阻断人工填写
|
||||
const handleRecognizeVoucher = async () => {
|
||||
if (ocrLoading.value) return
|
||||
|
||||
const voucherKeys = offlineVoucherKeys.value
|
||||
if (!voucherKeys.length) {
|
||||
ElMessage.warning('请先上传支付凭证')
|
||||
return
|
||||
}
|
||||
|
||||
ocrLoading.value = true
|
||||
try {
|
||||
const res = await AgentRechargeService.recognizePaymentVoucher({
|
||||
payment_voucher_key: voucherKeys[0]
|
||||
})
|
||||
if (res.code !== 0) {
|
||||
ElMessage.warning(res.msg || '识别失败,请手动填写交易流水号')
|
||||
return
|
||||
}
|
||||
const transactionNo = res.data?.external_transaction_no
|
||||
if (!transactionNo) {
|
||||
ElMessage.warning('未识别到交易流水号,请手动填写')
|
||||
return
|
||||
}
|
||||
createForm.external_transaction_no = transactionNo
|
||||
createFormRef.value?.validateField('external_transaction_no')
|
||||
ElMessage.success('已识别交易流水号,请对照凭证核对')
|
||||
} catch (error) {
|
||||
console.error('识别付款凭证失败:', error)
|
||||
ElMessage.warning('识别失败,请手动填写交易流水号')
|
||||
} finally {
|
||||
ocrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建充值订单
|
||||
const handleCreateRecharge = async () => {
|
||||
if (createLoading.value) return
|
||||
@@ -1056,6 +1222,10 @@
|
||||
ElMessage.warning('支付凭证上传中,请稍候')
|
||||
return
|
||||
}
|
||||
if (otherVoucherUploading.value) {
|
||||
ElMessage.warning('其他凭证上传中,请稍候')
|
||||
return
|
||||
}
|
||||
|
||||
createLoading.value = true
|
||||
try {
|
||||
@@ -1071,6 +1241,10 @@
|
||||
ElMessage.warning('请选择目标店铺')
|
||||
return
|
||||
}
|
||||
if (!createForm.offline_payment_method_id) {
|
||||
ElMessage.warning('请选择收款方式')
|
||||
return
|
||||
}
|
||||
|
||||
const voucherKeys = toVoucherKeyList(createForm.payment_voucher_key)
|
||||
if (!hasVoucherKeys(voucherKeys)) {
|
||||
@@ -1078,11 +1252,15 @@
|
||||
return
|
||||
}
|
||||
|
||||
const otherVoucherKeys = toVoucherKeyList(createForm.other_voucher_key)
|
||||
const data: CreateAgentRechargeRequest = {
|
||||
amount: amountYuanToFen(createForm.amount),
|
||||
payment_method: 'offline',
|
||||
shop_id: createForm.shop_id,
|
||||
offline_payment_method_id: createForm.offline_payment_method_id,
|
||||
external_transaction_no: createForm.external_transaction_no.trim(),
|
||||
payment_voucher_key: voucherKeys,
|
||||
other_voucher_key: otherVoucherKeys.length ? otherVoucherKeys : undefined,
|
||||
remark: createForm.remark || undefined
|
||||
}
|
||||
|
||||
@@ -1430,6 +1608,18 @@
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.external-transaction-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.external-transaction-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.online-recharge-qr-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="760px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="140px">
|
||||
<ElFormItem label="收款方式" prop="payment_method_id">
|
||||
<ElSelect
|
||||
v-model="form.payment_method_id"
|
||||
placeholder="请选择收款方式"
|
||||
style="width: 100%"
|
||||
:loading="paymentMethodsLoading"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in enabledPaymentMethods"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<div v-if="!paymentMethodsLoading && !enabledPaymentMethodCount" class="form-tip">
|
||||
暂无可用的收款方式,请联系管理员
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="核销账单" required>
|
||||
<div class="bill-selector">
|
||||
<div v-if="billsLoading" class="bill-selector__empty">加载中...</div>
|
||||
<ElEmpty
|
||||
v-else-if="!candidateBills.length"
|
||||
description="暂无可核销账单"
|
||||
:image-size="60"
|
||||
/>
|
||||
<div v-else class="bill-selector__list">
|
||||
<div v-for="bill in candidateBills" :key="bill.id" class="bill-row">
|
||||
<ElCheckbox
|
||||
:model-value="selectedBillIds.includes(bill.id)"
|
||||
@change="toggleBill(bill)"
|
||||
/>
|
||||
<div class="bill-row__main">
|
||||
<div class="bill-row__title">账单 #{{ bill.id }}</div>
|
||||
<div class="bill-row__meta">
|
||||
单号:{{ bill.source_no || '-' }} · 未核销
|
||||
{{ formatCollectionCurrency(bill.remaining_amount) }}
|
||||
</div>
|
||||
</div>
|
||||
<ElInputNumber
|
||||
v-if="selectedBillIds.includes(bill.id)"
|
||||
v-model="amountMap[bill.id]"
|
||||
:min="0"
|
||||
:max="fenToYuan(bill.remaining_amount)"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
size="small"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bill-selector__total">
|
||||
已选 {{ selectedBillIds.length }} 张账单,核销合计
|
||||
{{ formatCollectionCurrency(totalAmountFen) }}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="付款金额" prop="paid_amount">
|
||||
<ElInputNumber
|
||||
v-model="form.paid_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
style="width: 220px"
|
||||
/>
|
||||
<span class="amount-tip">本次线下收款金额(元),不得小于核销合计</span>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="付款方名称" prop="payer_name">
|
||||
<ElInput
|
||||
v-model="form.payer_name"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
placeholder="请输入付款方名称"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="付款时间" prop="paid_at">
|
||||
<ElDatePicker
|
||||
v-model="form.paid_at"
|
||||
type="datetime"
|
||||
placeholder="请选择付款时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="外部交易流水号" prop="external_transaction_no">
|
||||
<ElInput
|
||||
v-model="form.external_transaction_no"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入经人工核对的外部交易流水号"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="付款凭证" prop="payment_voucher_keys">
|
||||
<VoucherUpload
|
||||
ref="uploadRef"
|
||||
v-model="form.payment_voucher_keys"
|
||||
voucher-name="付款凭证"
|
||||
:max-count="5"
|
||||
@uploading-change="voucherUploading = $event"
|
||||
@change="formRef?.validateField('payment_voucher_keys')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="备注" prop="remark">
|
||||
<ElInput
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem v-if="isActing" label="代办原因" prop="acting_reason">
|
||||
<ElInput
|
||||
v-model="form.acting_reason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="超级管理员代办必须填写原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitting || voucherUploading"
|
||||
:disabled="voucherUploading || !enabledPaymentMethodCount"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ voucherUploading ? '凭证上传中...' : '提交' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { EmployeeCollectionService } from '@/api/modules'
|
||||
import type {
|
||||
EmployeeCollectionAllocation,
|
||||
EmployeeCollectionAllocationRequest,
|
||||
EmployeeCollectionApplication,
|
||||
EmployeeCollectionApplicationRequest,
|
||||
EmployeeCollectionBill,
|
||||
EmployeeCollectionPaymentMethod
|
||||
} from '@/types/api'
|
||||
import { fenToYuan, yuanToFen } from '@/utils/business/format'
|
||||
import { getErrorMessage, toVoucherKeyList } from '@/utils/business'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import {
|
||||
canCreateApplication,
|
||||
formatCollectionCurrency,
|
||||
normalizeCollectionList
|
||||
} from '../../employeeCollectionDisplay'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
application?: EmployeeCollectionApplication | null
|
||||
presetBill?: EmployeeCollectionBill | null
|
||||
}
|
||||
|
||||
interface BillOption {
|
||||
id: number
|
||||
source_no?: string | null
|
||||
remaining_amount: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
application: null,
|
||||
presetBill: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
|
||||
const paymentMethodsLoading = ref(false)
|
||||
const billsLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const voucherUploading = ref(false)
|
||||
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
|
||||
const candidateBills = ref<BillOption[]>([])
|
||||
const selectedBillIds = ref<number[]>([])
|
||||
const amountMap = reactive<Record<number, number>>({})
|
||||
|
||||
const form = reactive({
|
||||
payment_method_id: undefined as number | undefined,
|
||||
paid_amount: 0,
|
||||
payer_name: '',
|
||||
paid_at: '',
|
||||
external_transaction_no: '',
|
||||
payment_voucher_keys: [] as string[],
|
||||
remark: '',
|
||||
acting_reason: ''
|
||||
})
|
||||
|
||||
const isActing = computed(() => userStore.isSuperAdmin)
|
||||
const isResubmit = computed(() => !!props.application)
|
||||
const dialogTitle = computed(() => (isResubmit.value ? '修改并重新提交核销申请' : '创建核销申请'))
|
||||
const enabledPaymentMethods = computed(() => paymentMethods.value.filter((item) => item.enabled))
|
||||
const enabledPaymentMethodCount = computed(() => enabledPaymentMethods.value.length)
|
||||
|
||||
const paidAmountFen = computed(() => yuanToFen(form.paid_amount) || 0)
|
||||
const totalAmountFen = computed(() =>
|
||||
selectedBillIds.value.reduce((total, billId) => total + (yuanToFen(amountMap[billId]) || 0), 0)
|
||||
)
|
||||
|
||||
const rules = computed<FormRules>(() => {
|
||||
const base: FormRules = {
|
||||
payment_method_id: [{ required: true, message: '请选择收款方式', trigger: 'change' }],
|
||||
paid_amount: [
|
||||
{ required: true, message: '请输入付款金额', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, _value, callback) => {
|
||||
if (paidAmountFen.value <= 0) {
|
||||
callback(new Error('付款金额必须大于 0'))
|
||||
return
|
||||
}
|
||||
if (paidAmountFen.value < totalAmountFen.value) {
|
||||
callback(new Error('付款金额不能小于核销合计'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
payer_name: [{ required: true, message: '请输入付款方名称', trigger: 'blur' }],
|
||||
paid_at: [{ required: true, message: '请选择付款时间', trigger: 'change' }],
|
||||
external_transaction_no: [
|
||||
{ required: true, message: '请输入外部交易流水号', trigger: 'blur' }
|
||||
],
|
||||
payment_voucher_keys: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule, value, callback) => {
|
||||
if (Array.isArray(value) && value.length) callback()
|
||||
else callback(new Error('请上传付款凭证(1-5 个)'))
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
if (isActing.value) {
|
||||
base.acting_reason = [{ required: true, message: '请填写代办原因', trigger: 'blur' }]
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
watch(totalAmountFen, (total) => {
|
||||
if (paidAmountFen.value < total) {
|
||||
form.paid_amount = fenToYuan(total)
|
||||
}
|
||||
})
|
||||
|
||||
const loadPaymentMethods = async () => {
|
||||
paymentMethodsLoading.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getPaymentMethods({ page: 1, page_size: 100 })
|
||||
if (res.code === 0) {
|
||||
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
|
||||
} finally {
|
||||
paymentMethodsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toBillOption = (bill: {
|
||||
id: number
|
||||
source_no?: string | null
|
||||
remaining_amount?: number | null
|
||||
}): BillOption => ({
|
||||
id: bill.id,
|
||||
source_no: bill.source_no,
|
||||
remaining_amount: bill.remaining_amount ?? 0
|
||||
})
|
||||
|
||||
const loadCandidateBills = async () => {
|
||||
billsLoading.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getBills({ page: 1, page_size: 100 })
|
||||
const list: BillOption[] =
|
||||
res.code === 0
|
||||
? normalizeCollectionList<EmployeeCollectionBill>(res.data)
|
||||
.filter((bill) => canCreateApplication(bill))
|
||||
.map((bill) => toBillOption(bill))
|
||||
: []
|
||||
|
||||
if (props.presetBill && !list.some((bill) => bill.id === props.presetBill?.id)) {
|
||||
list.unshift(toBillOption(props.presetBill))
|
||||
}
|
||||
|
||||
candidateBills.value = list
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取可核销账单失败'))
|
||||
} finally {
|
||||
billsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePaidAtForPicker = (value?: string | null): string => {
|
||||
if (!value) return ''
|
||||
const match = value
|
||||
.trim()
|
||||
.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?\s*(Z|[+-]\d{2}:?\d{2})?$/)
|
||||
if (!match) return value
|
||||
const base = `${match[1]}T${match[2]}`
|
||||
const zone = match[3]
|
||||
if (!zone) return base
|
||||
if (zone === 'Z') return `${base}+00:00`
|
||||
return `${base}${zone.includes(':') ? zone : `${zone.slice(0, 3)}:${zone.slice(3)}`}`
|
||||
}
|
||||
|
||||
const fetchApplicationAllocations = async (
|
||||
applicationId: number
|
||||
): Promise<EmployeeCollectionAllocation[]> => {
|
||||
const res = await EmployeeCollectionService.getApplicationById(applicationId)
|
||||
if (res.code === 0 && res.data) {
|
||||
return res.data.allocations || []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const toggleBill = (bill: BillOption) => {
|
||||
const index = selectedBillIds.value.indexOf(bill.id)
|
||||
if (index >= 0) {
|
||||
selectedBillIds.value.splice(index, 1)
|
||||
delete amountMap[bill.id]
|
||||
} else {
|
||||
selectedBillIds.value.push(bill.id)
|
||||
amountMap[bill.id] = fenToYuan(bill.remaining_amount)
|
||||
}
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
form.payment_method_id = undefined
|
||||
form.paid_amount = 0
|
||||
form.payer_name = ''
|
||||
form.paid_at = ''
|
||||
form.external_transaction_no = ''
|
||||
form.payment_voucher_keys = []
|
||||
form.remark = ''
|
||||
form.acting_reason = ''
|
||||
selectedBillIds.value = []
|
||||
Object.keys(amountMap).forEach((key) => delete amountMap[Number(key)])
|
||||
uploadRef.value?.clearFiles()
|
||||
}
|
||||
|
||||
const initialize = async (): Promise<void> => {
|
||||
resetState()
|
||||
await Promise.all([loadPaymentMethods(), loadCandidateBills()])
|
||||
|
||||
if (props.application) {
|
||||
const application = props.application
|
||||
form.payment_method_id = application.payment_method_id ?? undefined
|
||||
form.paid_amount = fenToYuan(application.paid_amount)
|
||||
form.payer_name = application.payer_name || ''
|
||||
form.paid_at = normalizePaidAtForPicker(application.paid_at)
|
||||
form.external_transaction_no = application.external_transaction_no || ''
|
||||
form.payment_voucher_keys = toVoucherKeyList(application.payment_voucher_keys ?? undefined)
|
||||
form.remark = application.remark || ''
|
||||
form.acting_reason = application.acting_reason || ''
|
||||
|
||||
const allocations = await fetchApplicationAllocations(application.id)
|
||||
allocations.forEach((allocation) => {
|
||||
if (!candidateBills.value.some((item) => item.id === allocation.bill_id)) {
|
||||
candidateBills.value.push({
|
||||
id: allocation.bill_id,
|
||||
source_no: allocation.bill_source_no,
|
||||
remaining_amount: Math.max(
|
||||
allocation.amount,
|
||||
(allocation.bill_receivable_amount ?? 0) -
|
||||
(allocation.bill_received_amount ?? 0) -
|
||||
(allocation.bill_reserved_amount ?? 0),
|
||||
0
|
||||
)
|
||||
})
|
||||
}
|
||||
if (!selectedBillIds.value.includes(allocation.bill_id)) {
|
||||
selectedBillIds.value.push(allocation.bill_id)
|
||||
}
|
||||
amountMap[allocation.bill_id] = fenToYuan(allocation.amount)
|
||||
})
|
||||
|
||||
if (paidAmountFen.value < totalAmountFen.value) {
|
||||
form.paid_amount = fenToYuan(totalAmountFen.value)
|
||||
}
|
||||
} else if (props.presetBill) {
|
||||
const bill = candidateBills.value.find((item) => item.id === props.presetBill?.id)
|
||||
selectedBillIds.value = [props.presetBill.id]
|
||||
amountMap[props.presetBill.id] = fenToYuan(
|
||||
bill?.remaining_amount ?? props.presetBill.remaining_amount ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) void initialize()
|
||||
}
|
||||
)
|
||||
|
||||
const handleClosed = () => {
|
||||
resetState()
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const buildAllocations = (): EmployeeCollectionAllocationRequest[] | null => {
|
||||
const payload: EmployeeCollectionAllocationRequest[] = []
|
||||
for (const billId of selectedBillIds.value) {
|
||||
const amount = yuanToFen(amountMap[billId]) || 0
|
||||
const option = candidateBills.value.find((bill) => bill.id === billId)
|
||||
if (amount <= 0) {
|
||||
ElMessage.warning('请填写每张账单的核销金额')
|
||||
return null
|
||||
}
|
||||
if (option && amount > option.remaining_amount) {
|
||||
ElMessage.warning('核销金额不能超过账单未核销金额')
|
||||
return null
|
||||
}
|
||||
payload.push({ bill_id: billId, amount })
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
|
||||
if (!selectedBillIds.value.length) {
|
||||
ElMessage.warning('请至少选择一张待核销账单')
|
||||
return
|
||||
}
|
||||
const allocations = buildAllocations()
|
||||
if (!allocations) return
|
||||
|
||||
const payload: EmployeeCollectionApplicationRequest = {
|
||||
payment_method_id: form.payment_method_id as number,
|
||||
paid_amount: paidAmountFen.value,
|
||||
paid_at: form.paid_at,
|
||||
payer_name: form.payer_name.trim(),
|
||||
external_transaction_no: form.external_transaction_no.trim(),
|
||||
payment_voucher_keys: toVoucherKeyList(form.payment_voucher_keys),
|
||||
remark: form.remark.trim() || undefined,
|
||||
allocations,
|
||||
acting_reason: isActing.value ? form.acting_reason.trim() : undefined
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = props.application
|
||||
? await EmployeeCollectionService.updateApplication(props.application.id, payload)
|
||||
: await EmployeeCollectionService.createApplication(payload)
|
||||
if (res.code !== 0) return
|
||||
ElMessage.success(isResubmit.value ? '重新提交成功' : '核销申请已提交')
|
||||
emit('update:modelValue', false)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '提交核销申请失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.amount-tip {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.bill-selector {
|
||||
width: 100%;
|
||||
|
||||
&__empty {
|
||||
padding: 16px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
padding-right: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__total {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.bill-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
308
src/views/finance/employee-collection/applications/detail.vue
Normal file
308
src/views/finance/employee-collection/applications/detail.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="employee-collection-application-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<div class="detail-header">
|
||||
<ElButton @click="handleBack">
|
||||
<template #icon>
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
</template>
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">核销申请详情</h2>
|
||||
</div>
|
||||
|
||||
<DetailPage v-if="application" :sections="detailSections" :data="application" />
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<ElIcon class="is-loading"><Loading /></ElIcon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="allocations.length" shadow="never" class="block-card">
|
||||
<template #header>
|
||||
<div class="block-title">分摊账单</div>
|
||||
</template>
|
||||
<ElTable :data="allocations" border>
|
||||
<ElTableColumn label="账单编号" width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="link-text" @click="handleViewBill(row.bill_id)">#{{ row.bill_id }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="bill_source_no" label="来源单号" min-width="190" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.bill_source_no || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="本次核销金额" width="140">
|
||||
<template #default="{ row }">{{ formatCollectionCurrency(row.amount) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="账单状态" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.bill_status_name || getBillStatusLabel(row.bill_status) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="分摊状态" width="130">
|
||||
<template #default="{ row }">{{ row.status_name || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="created_at" label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="released_at" label="释放时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.released_at) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="block-card">
|
||||
<template #header>
|
||||
<div class="block-title">付款凭证</div>
|
||||
</template>
|
||||
<ElButton v-if="voucherKeys.length" @click="voucherVisible = true">查看付款凭证</ElButton>
|
||||
<span v-else class="empty-text">暂无付款凭证</span>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="attempts.length" shadow="never" class="block-card">
|
||||
<template #header>
|
||||
<div class="block-title">提交与审批记录</div>
|
||||
</template>
|
||||
<ElTimeline>
|
||||
<ElTimelineItem
|
||||
v-for="(item, index) in attempts"
|
||||
:key="index"
|
||||
:timestamp="item.created_at ? formatDateTime(item.created_at) : ''"
|
||||
placement="top"
|
||||
>
|
||||
<div class="timeline-title">
|
||||
第 {{ item.attempt_no || index + 1 }} 次提交 · {{ item.approval_status_name || '-' }}
|
||||
</div>
|
||||
<div class="timeline-operator">
|
||||
付款方:{{ item.payer_name || '-' }} · 金额:{{
|
||||
formatCollectionCurrency(item.paid_amount)
|
||||
}}
|
||||
</div>
|
||||
<div class="timeline-operator">
|
||||
流水号:{{ item.external_transaction_no || '-' }} · 收款方式:{{
|
||||
item.payment_method_name || '-'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="item.acting_reason" class="timeline-comment"
|
||||
>代办原因:{{ item.acting_reason }}</div
|
||||
>
|
||||
<div v-if="item.approval_opinion" class="timeline-comment">
|
||||
审批意见:{{ item.approval_opinion }}
|
||||
</div>
|
||||
<div v-if="item.remark" class="timeline-comment">备注:{{ item.remark }}</div>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</ElCard>
|
||||
|
||||
<PaymentVoucherDialog :file-keys="voucherKeys" @close="voucherVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElIcon,
|
||||
ElMessage,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTimeline,
|
||||
ElTimelineItem
|
||||
} from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { EmployeeCollectionService } from '@/api/modules'
|
||||
import type {
|
||||
EmployeeCollectionAllocation,
|
||||
EmployeeCollectionApplication,
|
||||
EmployeeCollectionApplicationAttempt,
|
||||
EmployeeCollectionApplicationDetailData
|
||||
} from '@/types/api'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { getErrorMessage, toVoucherKeyList } from '@/utils/business'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import {
|
||||
formatCollectionCurrency,
|
||||
getApplicationStatusLabel,
|
||||
getBillStatusLabel
|
||||
} from '../employeeCollectionDisplay'
|
||||
|
||||
defineOptions({ name: 'EmployeeCollectionApplicationDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref<EmployeeCollectionApplicationDetailData | null>(null)
|
||||
const voucherVisible = ref(false)
|
||||
|
||||
const application = computed<EmployeeCollectionApplication | null>(
|
||||
() => detail.value?.application || null
|
||||
)
|
||||
const allocations = computed<EmployeeCollectionAllocation[]>(
|
||||
() => detail.value?.allocations || []
|
||||
)
|
||||
const attempts = computed<EmployeeCollectionApplicationAttempt[]>(
|
||||
() => detail.value?.attempts || []
|
||||
)
|
||||
const voucherKeys = computed(() =>
|
||||
toVoucherKeyList(application.value?.payment_voucher_keys ?? undefined)
|
||||
)
|
||||
|
||||
const detailSections = computed((): DetailSection[] => [
|
||||
{
|
||||
title: '申请信息',
|
||||
fields: [
|
||||
{ label: '申请编号', prop: 'id', formatter: (value) => (value ? `#${value}` : '-') },
|
||||
{ label: '收款方式', prop: 'payment_method_name', formatter: (value) => value || '-' },
|
||||
{
|
||||
label: '付款金额',
|
||||
formatter: (_, data) => formatCollectionCurrency(data.paid_amount)
|
||||
},
|
||||
{ label: '付款方名称', prop: 'payer_name', formatter: (value) => value || '-' },
|
||||
{
|
||||
label: '付款时间',
|
||||
prop: 'paid_at',
|
||||
formatter: (value) => formatDateTime(value)
|
||||
},
|
||||
{
|
||||
label: '外部交易流水号',
|
||||
prop: 'external_transaction_no',
|
||||
formatter: (value) => value || '-'
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'status',
|
||||
formatter: (value, data) => data.status_name || getApplicationStatusLabel(value)
|
||||
},
|
||||
{
|
||||
label: '审批实例ID',
|
||||
prop: 'latest_approval_instance_id',
|
||||
formatter: (value) => (value ? String(value) : '-')
|
||||
},
|
||||
{ label: '提交时间', prop: 'created_at', formatter: (value) => formatDateTime(value) },
|
||||
{ label: '更新时间', prop: 'updated_at', formatter: (value) => formatDateTime(value) },
|
||||
{ label: '审批终态时间', prop: 'decided_at', formatter: (value) => formatDateTime(value) },
|
||||
{
|
||||
label: '备注',
|
||||
prop: 'remark',
|
||||
formatter: (value) => value || '-',
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '代办原因',
|
||||
prop: 'acting_reason',
|
||||
formatter: (value) => value || '-',
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '异常终态说明',
|
||||
prop: 'terminal_reason',
|
||||
formatter: (value) => value || '-',
|
||||
fullWidth: true
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const loadDetail = async () => {
|
||||
const id = Number(route.params.id)
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getApplicationById(id)
|
||||
if (res.code === 0) {
|
||||
detail.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取核销申请详情失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewBill = (billId: number) => {
|
||||
if (!billId) return
|
||||
router.push({ path: `${RoutesAlias.EmployeeCollectionBillDetail}/${billId}` })
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.push({ path: RoutesAlias.EmployeeCollectionApplications })
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.employee-collection-application-detail-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.detail-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.block-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.timeline-operator {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.timeline-comment {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
300
src/views/finance/employee-collection/applications/index.vue
Normal file
300
src/views/finance/employee-collection/applications/index.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div id="table-full-screen" class="employee-collection-applications-page">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:column-list="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="getTableData"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
v-permission="AUGUST_PERMISSIONS.employeeCollection.applicationCreate"
|
||||
type="primary"
|
||||
@click="openCreateApplication"
|
||||
>
|
||||
创建核销申请
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="applicationList"
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="pagination.total"
|
||||
:margin-top="10"
|
||||
:actions="getActions"
|
||||
:actions-width="200"
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<ApplicationFormDialog
|
||||
v-model="applicationDialogVisible"
|
||||
:application="currentApplication"
|
||||
@success="handleApplicationSuccess"
|
||||
/>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElButton, ElMessage, ElTag } from 'element-plus'
|
||||
import { EmployeeCollectionService } from '@/api/modules'
|
||||
import type {
|
||||
EmployeeCollectionApplication,
|
||||
EmployeeCollectionApplicationQueryParams,
|
||||
EmployeeCollectionPaymentMethod
|
||||
} from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { getErrorMessage } from '@/utils/business'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import ApplicationFormDialog from './components/ApplicationFormDialog.vue'
|
||||
import {
|
||||
APPLICATION_STATUS_OPTIONS,
|
||||
canResubmitApplication,
|
||||
formatCollectionCurrency,
|
||||
getApplicationStatusLabel,
|
||||
getApplicationStatusTagType,
|
||||
normalizeCollectionList,
|
||||
normalizeCollectionPage
|
||||
} from '../employeeCollectionDisplay'
|
||||
|
||||
defineOptions({ name: 'EmployeeCollectionApplications' })
|
||||
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const loading = ref(false)
|
||||
const tableRef = ref()
|
||||
const applicationDialogVisible = ref(false)
|
||||
const applicationList = ref<EmployeeCollectionApplication[]>([])
|
||||
const currentApplication = ref<EmployeeCollectionApplication | null>(null)
|
||||
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
|
||||
|
||||
const initialSearchState = {
|
||||
status: undefined as number | undefined,
|
||||
payment_method_id: undefined as number | undefined,
|
||||
dateRange: [] as string[],
|
||||
created_from: '',
|
||||
created_to: ''
|
||||
}
|
||||
const searchForm = reactive({ ...initialSearchState })
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'status',
|
||||
type: 'select',
|
||||
placeholder: '请选择状态',
|
||||
options: APPLICATION_STATUS_OPTIONS,
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '收款方式',
|
||||
prop: 'payment_method_id',
|
||||
type: 'select',
|
||||
placeholder: '请选择收款方式',
|
||||
options: () => paymentMethods.value.map((item) => ({ label: item.name, value: item.id })),
|
||||
config: { clearable: true, filterable: true }
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
prop: 'dateRange',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
valueFormat: 'YYYY-MM-DD'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '申请编号', prop: 'id' },
|
||||
{ label: '收款方式', prop: 'payment_method_name' },
|
||||
{ label: '付款金额', prop: 'paid_amount' },
|
||||
{ label: '状态', prop: 'status' },
|
||||
{ label: '提交时间', prop: 'created_at' }
|
||||
]
|
||||
|
||||
const handleViewDetail = (row: EmployeeCollectionApplication) => {
|
||||
router.push({ path: `${RoutesAlias.EmployeeCollectionApplicationDetail}/${row.id}` })
|
||||
}
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'id',
|
||||
label: '申请编号',
|
||||
minWidth: 140,
|
||||
formatter: (row: EmployeeCollectionApplication) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: () => handleViewDetail(row)
|
||||
},
|
||||
`#${row.id}`
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'payment_method_name',
|
||||
label: '收款方式',
|
||||
width: 140,
|
||||
formatter: (row: EmployeeCollectionApplication) => row.payment_method_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'paid_amount',
|
||||
label: '付款金额',
|
||||
width: 130,
|
||||
formatter: (row: EmployeeCollectionApplication) => formatCollectionCurrency(row.paid_amount)
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 130,
|
||||
formatter: (row: EmployeeCollectionApplication) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: getApplicationStatusTagType(row.status), effect: 'plain' },
|
||||
() => row.status_name || getApplicationStatusLabel(row.status)
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '提交时间',
|
||||
width: 170,
|
||||
formatter: (row: EmployeeCollectionApplication) => formatDateTime(row.created_at)
|
||||
}
|
||||
])
|
||||
|
||||
const buildQueryParams = (): EmployeeCollectionApplicationQueryParams => ({
|
||||
status: searchForm.status,
|
||||
payment_method_id: searchForm.payment_method_id,
|
||||
created_from: searchForm.created_from || undefined,
|
||||
created_to: searchForm.created_to || undefined
|
||||
})
|
||||
|
||||
const loadPaymentMethods = async () => {
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getPaymentMethods({ page: 1, page_size: 100 })
|
||||
if (res.code === 0) {
|
||||
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load payment methods failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getApplications({
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
...buildQueryParams()
|
||||
})
|
||||
if (res.code === 0) {
|
||||
const { list, total } = normalizeCollectionPage<EmployeeCollectionApplication>(res.data)
|
||||
applicationList.value = list
|
||||
pagination.total = total
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取核销申请列表失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
|
||||
searchForm.created_from = searchForm.dateRange[0]
|
||||
searchForm.created_to = searchForm.dateRange[1]
|
||||
} else {
|
||||
searchForm.created_from = ''
|
||||
searchForm.created_to = ''
|
||||
}
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const openCreateApplication = () => {
|
||||
currentApplication.value = null
|
||||
applicationDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleResubmit = (row: EmployeeCollectionApplication) => {
|
||||
currentApplication.value = row
|
||||
applicationDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleApplicationSuccess = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const getActions = (row: EmployeeCollectionApplication) => {
|
||||
const actions: any[] = []
|
||||
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationDetail)) {
|
||||
actions.push({ label: '详情', handler: () => handleViewDetail(row), type: 'primary' })
|
||||
}
|
||||
if (
|
||||
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationUpdate) &&
|
||||
canResubmitApplication(row)
|
||||
) {
|
||||
actions.push({ label: '修改并重新提交', handler: () => handleResubmit(row), type: 'primary' })
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void getTableData()
|
||||
void loadPaymentMethods()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.employee-collection-applications-page {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
368
src/views/finance/employee-collection/bills/detail.vue
Normal file
368
src/views/finance/employee-collection/bills/detail.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="employee-collection-bill-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<div class="detail-header">
|
||||
<ElButton @click="handleBack">
|
||||
<template #icon>
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
</template>
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">员工代收款账单详情</h2>
|
||||
</div>
|
||||
|
||||
<DetailPage v-if="bill" :sections="detailSections" :data="bill" />
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<ElIcon class="is-loading"><Loading /></ElIcon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="allocations.length" shadow="never" class="block-card">
|
||||
<template #header>
|
||||
<div class="block-title">核销分摊</div>
|
||||
</template>
|
||||
<ElTable :data="allocations" border>
|
||||
<ElTableColumn label="核销申请" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.application_id ? `#${row.application_id}` : '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="核销金额" width="140">
|
||||
<template #default="{ row }">{{ formatCollectionCurrency(row.amount) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="分摊状态" width="130">
|
||||
<template #default="{ row }">{{ row.status_name || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="申请状态" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.application_status_name || getApplicationStatusLabel(row.application_status) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="所属尝试" width="110">
|
||||
<template #default="{ row }">{{ row.attempt_id ? `#${row.attempt_id}` : '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="created_at" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="released_at" label="释放时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.released_at) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="applications.length" shadow="never" class="block-card">
|
||||
<template #header>
|
||||
<div class="block-title">关联核销申请</div>
|
||||
</template>
|
||||
<ElTable :data="applications" border>
|
||||
<ElTableColumn type="expand" width="48">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.attempts?.length" class="attempt-list">
|
||||
<ElTimeline>
|
||||
<ElTimelineItem
|
||||
v-for="(item, index) in row.attempts"
|
||||
:key="index"
|
||||
:timestamp="item.created_at ? formatDateTime(item.created_at) : ''"
|
||||
placement="top"
|
||||
>
|
||||
<div class="timeline-title">
|
||||
第 {{ item.attempt_no || index + 1 }} 次提交 ·
|
||||
{{ item.approval_status_name || '-' }}
|
||||
</div>
|
||||
<div class="timeline-operator">
|
||||
付款方:{{ item.payer_name || '-' }} · 金额:{{
|
||||
formatCollectionCurrency(item.paid_amount)
|
||||
}}
|
||||
</div>
|
||||
<div class="timeline-operator">
|
||||
流水号:{{ item.external_transaction_no || '-' }} · 收款方式:{{
|
||||
item.payment_method_name || '-'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="item.approval_opinion" class="timeline-comment">
|
||||
审批意见:{{ item.approval_opinion }}
|
||||
</div>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</div>
|
||||
<span v-else class="empty-text">暂无审批记录</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="申请编号" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="link-text" @click="handleViewApplication(row)">#{{ row.id }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="收款方式" width="140">
|
||||
<template #default="{ row }">{{ row.payment_method_name || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="付款金额" width="140">
|
||||
<template #default="{ row }">{{ formatCollectionCurrency(row.paid_amount) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.status_name || getApplicationStatusLabel(row.status) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="created_at" label="提交时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="refunds.length" shadow="never" class="block-card">
|
||||
<template #header>
|
||||
<div class="block-title">退款冲销</div>
|
||||
</template>
|
||||
<ElTable :data="refunds" border>
|
||||
<ElTableColumn label="退款申请ID" width="130">
|
||||
<template #default="{ row }">{{ row.refund_id ? `#${row.refund_id}` : '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="来源订单ID" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.source_order_id ? `#${row.source_order_id}` : '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="退款金额" width="130">
|
||||
<template #default="{ row }">{{ formatCollectionCurrency(row.refund_amount) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="冲减应收" width="130">
|
||||
<template #default="{ row }">{{ formatCollectionCurrency(row.reduced_amount) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="冲销前应收" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ formatCollectionCurrency(row.bill_receivable_amount) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="处理结果" width="150">
|
||||
<template #default="{ row }">{{ row.outcome_name || row.outcome || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="created_at" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElIcon,
|
||||
ElMessage,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTimeline,
|
||||
ElTimelineItem
|
||||
} from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { EmployeeCollectionService } from '@/api/modules'
|
||||
import type {
|
||||
EmployeeCollectionBill,
|
||||
EmployeeCollectionBillAllocation,
|
||||
EmployeeCollectionBillApplication,
|
||||
EmployeeCollectionBillDetailData,
|
||||
EmployeeCollectionBillRefund
|
||||
} from '@/types/api'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { getErrorMessage } from '@/utils/business'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
formatCollectionCurrency,
|
||||
getApplicationStatusLabel,
|
||||
getBillSourceLabel,
|
||||
getBillStatusLabel
|
||||
} from '../employeeCollectionDisplay'
|
||||
|
||||
defineOptions({ name: 'EmployeeCollectionBillDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref<EmployeeCollectionBillDetailData | null>(null)
|
||||
|
||||
const bill = computed<EmployeeCollectionBill | null>(() => detail.value?.bill || null)
|
||||
const allocations = computed<EmployeeCollectionBillAllocation[]>(
|
||||
() => detail.value?.allocations || []
|
||||
)
|
||||
const applications = computed<EmployeeCollectionBillApplication[]>(
|
||||
() => detail.value?.applications || []
|
||||
)
|
||||
const refunds = computed<EmployeeCollectionBillRefund[]>(() => detail.value?.refunds || [])
|
||||
|
||||
const detailSections = computed((): DetailSection[] => [
|
||||
{
|
||||
title: '账单信息',
|
||||
fields: [
|
||||
{ label: '账单编号', prop: 'id', formatter: (value) => (value ? `#${value}` : '-') },
|
||||
{
|
||||
label: '来源',
|
||||
prop: 'source_type_name',
|
||||
formatter: (value, data) => value || getBillSourceLabel(data.source_type)
|
||||
},
|
||||
{ label: '关联单号', prop: 'source_no', formatter: (value) => value || '-' },
|
||||
{
|
||||
label: '负责员工',
|
||||
prop: 'debtor_snapshot.account_name',
|
||||
formatter: (value) => value || '-'
|
||||
},
|
||||
{
|
||||
label: '客户',
|
||||
prop: 'customer_snapshot.buyer_nickname',
|
||||
formatter: (value) => value || '-'
|
||||
},
|
||||
{
|
||||
label: '店铺',
|
||||
prop: 'customer_snapshot.shop_id',
|
||||
formatter: (value) => (value ? `店铺 #${value}` : '-')
|
||||
},
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'customer_snapshot.asset_identifier',
|
||||
formatter: (value) => value || '-'
|
||||
},
|
||||
{
|
||||
label: '账单状态',
|
||||
prop: 'status_name',
|
||||
formatter: (value, data) => value || getBillStatusLabel(data.status)
|
||||
},
|
||||
{
|
||||
label: '应收金额',
|
||||
formatter: (_, data) => formatCollectionCurrency(data.receivable_amount)
|
||||
},
|
||||
{
|
||||
label: '已核销金额',
|
||||
formatter: (_, data) => formatCollectionCurrency(data.received_amount)
|
||||
},
|
||||
{
|
||||
label: '审批中预占金额',
|
||||
formatter: (_, data) => formatCollectionCurrency(data.reserved_amount)
|
||||
},
|
||||
{
|
||||
label: '未核销金额',
|
||||
formatter: (_, data) => formatCollectionCurrency(data.remaining_amount)
|
||||
},
|
||||
{
|
||||
label: '审批中申请',
|
||||
formatter: (_, data) => (data.approval_pending ? '存在审批中的申请' : '无')
|
||||
},
|
||||
{ label: '创建时间', prop: 'created_at', formatter: (value) => formatDateTime(value) },
|
||||
{ label: '更新时间', prop: 'updated_at', formatter: (value) => formatDateTime(value) },
|
||||
{ label: '关闭时间', prop: 'closed_at', formatter: (value) => formatDateTime(value) },
|
||||
{
|
||||
label: '关闭原因',
|
||||
prop: 'closed_reason',
|
||||
formatter: (value) => value || '-',
|
||||
fullWidth: true
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const loadDetail = async () => {
|
||||
const id = Number(route.params.id)
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getBillById(id)
|
||||
if (res.code === 0) {
|
||||
detail.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取账单详情失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewApplication = (row: EmployeeCollectionBillApplication) => {
|
||||
router.push({ path: `${RoutesAlias.EmployeeCollectionApplicationDetail}/${row.id}` })
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.push({ path: RoutesAlias.EmployeeCollectionBills })
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.employee-collection-bill-detail-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.detail-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.block-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attempt-list {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.timeline-operator {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.timeline-comment {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
539
src/views/finance/employee-collection/bills/index.vue
Normal file
539
src/views/finance/employee-collection/bills/index.vue
Normal file
@@ -0,0 +1,539 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div id="table-full-screen" class="employee-collection-bills-page">
|
||||
<div class="stat-row">
|
||||
<ElCard shadow="never" class="stat-card">
|
||||
<div class="stat-card__label">应收金额</div>
|
||||
<div class="stat-card__value">
|
||||
{{ formatCollectionCurrency(statistics.receivable_total) }}
|
||||
</div>
|
||||
</ElCard>
|
||||
<ElCard shadow="never" class="stat-card">
|
||||
<div class="stat-card__label">已核销金额</div>
|
||||
<div class="stat-card__value stat-card__value--success">
|
||||
{{ formatCollectionCurrency(statistics.received_total) }}
|
||||
</div>
|
||||
</ElCard>
|
||||
<ElCard shadow="never" class="stat-card">
|
||||
<div class="stat-card__label">未核销金额</div>
|
||||
<div class="stat-card__value stat-card__value--warning">
|
||||
{{ formatCollectionCurrency(statistics.unsettled_total) }}
|
||||
</div>
|
||||
</ElCard>
|
||||
<ElCard shadow="never" class="stat-card">
|
||||
<div class="stat-card__label">待处理账单</div>
|
||||
<div class="stat-card__value">{{ statistics.pending_bill_count }}</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:column-list="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
v-permission="AUGUST_PERMISSIONS.employeeCollection.applicationCreate"
|
||||
type="primary"
|
||||
:disabled="!hasSelectableBill"
|
||||
@click="openCreateApplication()"
|
||||
>
|
||||
创建核销申请
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="billList"
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="pagination.total"
|
||||
:margin-top="10"
|
||||
:actions="getActions"
|
||||
:actions-width="200"
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<ElDialog v-model="closeDialogVisible" title="关闭账单" width="480px" destroy-on-close>
|
||||
<ElForm ref="closeFormRef" :model="closeForm" :rules="closeRules" label-width="88px">
|
||||
<ElFormItem label="账单编号">
|
||||
<span>{{ currentBill ? `#${currentBill.id}` : '-' }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="关闭原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="closeForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请填写关闭原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="closeDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="closeSubmitting" @click="handleCloseBill">
|
||||
确定关闭
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ApplicationFormDialog
|
||||
v-model="applicationDialogVisible"
|
||||
:preset-bill="presetBill"
|
||||
@success="handleApplicationSuccess"
|
||||
/>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElButton, ElMessage, ElTag } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { EmployeeCollectionService, ShopService } from '@/api/modules'
|
||||
import type {
|
||||
EmployeeCollectionBill,
|
||||
EmployeeCollectionBillQueryParams,
|
||||
EmployeeCollectionBillStatistics
|
||||
} from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { getErrorMessage } from '@/utils/business'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import ApplicationFormDialog from '../applications/components/ApplicationFormDialog.vue'
|
||||
import {
|
||||
BILL_SOURCE_OPTIONS,
|
||||
BILL_STATUS_OPTIONS,
|
||||
canCloseBill,
|
||||
canCreateApplication,
|
||||
formatCollectionCurrency,
|
||||
getBillCustomerLabel,
|
||||
getBillDebtorName,
|
||||
getBillSourceLabel,
|
||||
getBillStatusLabel,
|
||||
getBillStatusTagType,
|
||||
normalizeCollectionPage
|
||||
} from '../employeeCollectionDisplay'
|
||||
|
||||
defineOptions({ name: 'EmployeeCollectionBills' })
|
||||
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const loading = ref(false)
|
||||
const closeSubmitting = ref(false)
|
||||
const tableRef = ref()
|
||||
const closeFormRef = ref<FormInstance>()
|
||||
const closeDialogVisible = ref(false)
|
||||
const applicationDialogVisible = ref(false)
|
||||
const currentBill = ref<EmployeeCollectionBill | null>(null)
|
||||
const presetBill = ref<EmployeeCollectionBill | null>(null)
|
||||
const billList = ref<EmployeeCollectionBill[]>([])
|
||||
const shopOptions = ref<Array<{ id: number; shop_name: string }>>([])
|
||||
const statistics = ref<EmployeeCollectionBillStatistics>({
|
||||
receivable_total: 0,
|
||||
received_total: 0,
|
||||
unsettled_total: 0,
|
||||
pending_bill_count: 0
|
||||
})
|
||||
|
||||
const initialSearchState = {
|
||||
source_type: undefined as string | undefined,
|
||||
source_no: '',
|
||||
status: undefined as number | undefined,
|
||||
customer_id: undefined as number | undefined,
|
||||
dateRange: [] as string[],
|
||||
created_from: '',
|
||||
created_to: ''
|
||||
}
|
||||
const searchForm = reactive({ ...initialSearchState })
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
const closeForm = reactive({ reason: '' })
|
||||
|
||||
const closeRules: FormRules = {
|
||||
reason: [{ required: true, message: '请填写关闭原因', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const shopNameMap = computed<Record<number, string>>(() =>
|
||||
shopOptions.value.reduce<Record<number, string>>((map, shop) => {
|
||||
map[shop.id] = shop.shop_name
|
||||
return map
|
||||
}, {})
|
||||
)
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '来源',
|
||||
prop: 'source_type',
|
||||
type: 'select',
|
||||
placeholder: '请选择来源',
|
||||
options: BILL_SOURCE_OPTIONS,
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '来源单号',
|
||||
prop: 'source_no',
|
||||
type: 'input',
|
||||
placeholder: '请输入来源单号',
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '核销状态',
|
||||
prop: 'status',
|
||||
type: 'select',
|
||||
placeholder: '请选择核销状态',
|
||||
options: BILL_STATUS_OPTIONS,
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '店铺',
|
||||
prop: 'customer_id',
|
||||
type: 'select',
|
||||
placeholder: '请选择店铺',
|
||||
options: () => shopOptions.value.map((shop) => ({ label: shop.shop_name, value: shop.id })),
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
remoteMethod: (query: string) => searchShops(query)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '起止时间',
|
||||
prop: 'dateRange',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
valueFormat: 'YYYY-MM-DD'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '账单编号', prop: 'id' },
|
||||
{ label: '来源', prop: 'source_type' },
|
||||
{ label: '关联单号', prop: 'source_no' },
|
||||
{ label: '负责员工', prop: 'debtor_snapshot' },
|
||||
{ label: '客户/店铺', prop: 'customer_snapshot' },
|
||||
{ label: '应收金额', prop: 'receivable_amount' },
|
||||
{ label: '已核销金额', prop: 'received_amount' },
|
||||
{ label: '未核销金额', prop: 'remaining_amount' },
|
||||
{ label: '状态', prop: 'status' },
|
||||
{ label: '创建时间', prop: 'created_at' }
|
||||
]
|
||||
|
||||
const hasSelectableBill = computed(() =>
|
||||
billList.value.some((bill) => canCreateApplication(bill))
|
||||
)
|
||||
|
||||
const buildQueryParams = (): EmployeeCollectionBillQueryParams => ({
|
||||
source_type: searchForm.source_type,
|
||||
source_no: searchForm.source_no.trim() || undefined,
|
||||
status: searchForm.status,
|
||||
customer_id: searchForm.customer_id,
|
||||
created_from: searchForm.created_from || undefined,
|
||||
created_to: searchForm.created_to || undefined
|
||||
})
|
||||
|
||||
const searchShops = async (query: string) => {
|
||||
try {
|
||||
const params: any = { page: 1, page_size: 20 }
|
||||
if (query) params.shop_name = query
|
||||
const res = await ShopService.getShops(params)
|
||||
if (res.code === 0) {
|
||||
shopOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search shops failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = (row: EmployeeCollectionBill) => {
|
||||
router.push({ path: `${RoutesAlias.EmployeeCollectionBillDetail}/${row.id}` })
|
||||
}
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'id',
|
||||
label: '账单编号',
|
||||
minWidth: 120,
|
||||
formatter: (row: EmployeeCollectionBill) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: () => handleViewDetail(row)
|
||||
},
|
||||
`#${row.id}`
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'source_type',
|
||||
label: '来源',
|
||||
width: 160,
|
||||
formatter: (row: EmployeeCollectionBill) =>
|
||||
row.source_type_name || getBillSourceLabel(row.source_type)
|
||||
},
|
||||
{ prop: 'source_no', label: '关联单号', width: 210, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'debtor_snapshot',
|
||||
label: '负责员工',
|
||||
width: 120,
|
||||
formatter: (row: EmployeeCollectionBill) => getBillDebtorName(row)
|
||||
},
|
||||
{
|
||||
prop: 'customer_snapshot',
|
||||
label: '客户/店铺',
|
||||
width: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: EmployeeCollectionBill) => getBillCustomerLabel(row, shopNameMap.value)
|
||||
},
|
||||
{
|
||||
prop: 'receivable_amount',
|
||||
label: '应收金额',
|
||||
width: 120,
|
||||
formatter: (row: EmployeeCollectionBill) => formatCollectionCurrency(row.receivable_amount)
|
||||
},
|
||||
{
|
||||
prop: 'received_amount',
|
||||
label: '已核销金额',
|
||||
width: 130,
|
||||
formatter: (row: EmployeeCollectionBill) => formatCollectionCurrency(row.received_amount)
|
||||
},
|
||||
{
|
||||
prop: 'remaining_amount',
|
||||
label: '未核销金额',
|
||||
width: 130,
|
||||
formatter: (row: EmployeeCollectionBill) => formatCollectionCurrency(row.remaining_amount)
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 110,
|
||||
formatter: (row: EmployeeCollectionBill) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: getBillStatusTagType(row.status), effect: 'plain' },
|
||||
() => row.status_name || getBillStatusLabel(row.status)
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 170,
|
||||
formatter: (row: EmployeeCollectionBill) => formatDateTime(row.created_at)
|
||||
}
|
||||
])
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getBills({
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
...buildQueryParams()
|
||||
})
|
||||
if (res.code === 0) {
|
||||
const { list, total } = normalizeCollectionPage<EmployeeCollectionBill>(res.data)
|
||||
billList.value = list
|
||||
pagination.total = total
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取账单列表失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatistics = async () => {
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getBillStatistics(buildQueryParams())
|
||||
if (res.code === 0 && res.data) {
|
||||
statistics.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取账单统计失败'))
|
||||
}
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
void getTableData()
|
||||
void getStatistics()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
|
||||
searchForm.created_from = searchForm.dateRange[0]
|
||||
searchForm.created_to = searchForm.dateRange[1]
|
||||
} else {
|
||||
searchForm.created_from = ''
|
||||
searchForm.created_to = ''
|
||||
}
|
||||
pagination.page = 1
|
||||
reload()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
reload()
|
||||
}
|
||||
|
||||
const handleRefresh = () => reload()
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const openCreateApplication = (row?: EmployeeCollectionBill) => {
|
||||
presetBill.value = row || null
|
||||
applicationDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleApplicationSuccess = () => {
|
||||
reload()
|
||||
}
|
||||
|
||||
const openCloseDialog = (row: EmployeeCollectionBill) => {
|
||||
if (!canCloseBill(row)) {
|
||||
ElMessage.warning('账单已关闭,无需重复操作')
|
||||
return
|
||||
}
|
||||
if (row.approval_pending) {
|
||||
ElMessage.warning('存在审批中的核销申请,账单暂不可关闭')
|
||||
return
|
||||
}
|
||||
currentBill.value = row
|
||||
closeForm.reason = ''
|
||||
closeDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleCloseBill = async () => {
|
||||
if (!closeFormRef.value || !currentBill.value) return
|
||||
await closeFormRef.value.validate()
|
||||
closeSubmitting.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.closeBill(currentBill.value.id, {
|
||||
reason: closeForm.reason.trim()
|
||||
})
|
||||
if (res.code !== 0) return
|
||||
ElMessage.success('账单已关闭')
|
||||
closeDialogVisible.value = false
|
||||
reload()
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '关闭账单失败'))
|
||||
} finally {
|
||||
closeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getActions = (row: EmployeeCollectionBill) => {
|
||||
const actions: any[] = []
|
||||
if (
|
||||
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationCreate) &&
|
||||
canCreateApplication(row)
|
||||
) {
|
||||
actions.push({
|
||||
label: '创建核销申请',
|
||||
handler: () => openCreateApplication(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.billDetail)) {
|
||||
actions.push({ label: '详情', handler: () => handleViewDetail(row), type: 'primary' })
|
||||
}
|
||||
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.billClose) && canCloseBill(row)) {
|
||||
actions.push({ label: '关闭账单', handler: () => openCloseDialog(row), type: 'danger' })
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reload()
|
||||
void searchShops('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.employee-collection-bills-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__value {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&--success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
&--warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.stat-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 员工代收款展示辅助函数
|
||||
*/
|
||||
|
||||
import type {
|
||||
EmployeeCollectionApplication,
|
||||
EmployeeCollectionApplicationStatus,
|
||||
EmployeeCollectionBill,
|
||||
EmployeeCollectionBillStatus
|
||||
} from '@/types/api'
|
||||
|
||||
type TagType = 'success' | 'warning' | 'danger' | 'info'
|
||||
|
||||
/** 账单状态:0 待核销 / 1 部分核销 / 2 已核销 / 3 已关闭 */
|
||||
export const BILL_STATUS = {
|
||||
PENDING: 0,
|
||||
PARTIAL: 1,
|
||||
VERIFIED: 2,
|
||||
CLOSED: 3
|
||||
} as const
|
||||
|
||||
/** 申请状态:0 审批中 / 1 已通过 / 2 已驳回 / 3 已撤销或已关闭 */
|
||||
export const APPLICATION_STATUS = {
|
||||
PENDING: 0,
|
||||
APPROVED: 1,
|
||||
REJECTED: 2,
|
||||
REVOKED: 3
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 兼容后端列表返回结构:
|
||||
* - 直接返回数组
|
||||
* - { list: [...] } / { items: [...] } / { records: [...] }
|
||||
*/
|
||||
export const normalizeCollectionPage = <T>(data: unknown): { list: T[]; total: number } => {
|
||||
if (Array.isArray(data)) {
|
||||
return { list: data as T[], total: data.length }
|
||||
}
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
for (const key of ['items', 'list', 'records']) {
|
||||
const value = record[key]
|
||||
if (Array.isArray(value)) {
|
||||
const total = Number(record.total)
|
||||
return { list: value as T[], total: Number.isNaN(total) ? value.length : total }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { list: [], total: 0 }
|
||||
}
|
||||
|
||||
export const normalizeCollectionList = <T>(data: unknown): T[] =>
|
||||
normalizeCollectionPage<T>(data).list
|
||||
|
||||
/**
|
||||
* 金额格式化(分 -> 元)
|
||||
*/
|
||||
export const formatCollectionCurrency = (amount?: number | null): string => {
|
||||
if (amount === undefined || amount === null || Number.isNaN(amount)) return '-'
|
||||
return `¥${(amount / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单状态选项
|
||||
*/
|
||||
export const BILL_STATUS_OPTIONS = [
|
||||
{ label: '待核销', value: BILL_STATUS.PENDING },
|
||||
{ label: '部分核销', value: BILL_STATUS.PARTIAL },
|
||||
{ label: '已核销', value: BILL_STATUS.VERIFIED },
|
||||
{ label: '已关闭', value: BILL_STATUS.CLOSED }
|
||||
]
|
||||
|
||||
/**
|
||||
* 核销申请状态选项
|
||||
*/
|
||||
export const APPLICATION_STATUS_OPTIONS = [
|
||||
{ label: '审批中', value: APPLICATION_STATUS.PENDING },
|
||||
{ label: '已通过', value: APPLICATION_STATUS.APPROVED },
|
||||
{ label: '已驳回', value: APPLICATION_STATUS.REJECTED },
|
||||
{ label: '已撤销或已关闭', value: APPLICATION_STATUS.REVOKED }
|
||||
]
|
||||
|
||||
/**
|
||||
* 账单来源选项
|
||||
*/
|
||||
export const BILL_SOURCE_OPTIONS = [
|
||||
{ label: '后台线下套餐订单', value: 'order' },
|
||||
{ label: '代理线下充值', value: 'recharge' }
|
||||
]
|
||||
|
||||
const BILL_STATUS_LABEL_MAP: Record<number, string> = BILL_STATUS_OPTIONS.reduce(
|
||||
(map, item) => ({ ...map, [item.value]: item.label }),
|
||||
{}
|
||||
)
|
||||
|
||||
const APPLICATION_STATUS_LABEL_MAP: Record<number, string> = APPLICATION_STATUS_OPTIONS.reduce(
|
||||
(map, item) => ({ ...map, [item.value]: item.label }),
|
||||
{}
|
||||
)
|
||||
|
||||
const BILL_SOURCE_LABEL_MAP: Record<string, string> = BILL_SOURCE_OPTIONS.reduce(
|
||||
(map, item) => ({ ...map, [item.value]: item.label }),
|
||||
{}
|
||||
)
|
||||
|
||||
/**
|
||||
* 账单状态文案(优先使用后端 status_name)
|
||||
*/
|
||||
export const getBillStatusLabel = (status?: EmployeeCollectionBillStatus | null): string => {
|
||||
if (status === undefined || status === null) return '-'
|
||||
return BILL_STATUS_LABEL_MAP[status] ?? String(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销申请状态文案
|
||||
*/
|
||||
export const getApplicationStatusLabel = (
|
||||
status?: EmployeeCollectionApplicationStatus | null
|
||||
): string => {
|
||||
if (status === undefined || status === null) return '-'
|
||||
return APPLICATION_STATUS_LABEL_MAP[status] ?? String(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单来源文案(优先使用后端 source_type_name)
|
||||
*/
|
||||
export const getBillSourceLabel = (source?: string | null): string => {
|
||||
if (!source) return '-'
|
||||
return BILL_SOURCE_LABEL_MAP[source] || source
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单状态标签类型
|
||||
*/
|
||||
export const getBillStatusTagType = (status?: EmployeeCollectionBillStatus | null): TagType => {
|
||||
switch (status) {
|
||||
case BILL_STATUS.PENDING:
|
||||
return 'warning'
|
||||
case BILL_STATUS.PARTIAL:
|
||||
return 'info'
|
||||
case BILL_STATUS.VERIFIED:
|
||||
return 'success'
|
||||
case BILL_STATUS.CLOSED:
|
||||
return 'info'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销申请状态标签类型
|
||||
*/
|
||||
export const getApplicationStatusTagType = (
|
||||
status?: EmployeeCollectionApplicationStatus | null
|
||||
): TagType => {
|
||||
switch (status) {
|
||||
case APPLICATION_STATUS.PENDING:
|
||||
return 'warning'
|
||||
case APPLICATION_STATUS.APPROVED:
|
||||
return 'success'
|
||||
case APPLICATION_STATUS.REJECTED:
|
||||
return 'danger'
|
||||
case APPLICATION_STATUS.REVOKED:
|
||||
return 'info'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单责任人(员工)名称
|
||||
*/
|
||||
export const getBillDebtorName = (bill?: EmployeeCollectionBill | null): string =>
|
||||
bill?.debtor_snapshot?.account_name || '-'
|
||||
|
||||
/**
|
||||
* 账单关联店铺 ID
|
||||
*/
|
||||
export const getBillShopId = (bill?: EmployeeCollectionBill | null): number | undefined =>
|
||||
bill?.customer_snapshot?.shop_id ?? bill?.customer_snapshot?.seller_shop_id ?? undefined
|
||||
|
||||
/**
|
||||
* 账单客户展示:优先买家昵称,其次店铺名称,最后店铺 ID
|
||||
*/
|
||||
export const getBillCustomerLabel = (
|
||||
bill?: EmployeeCollectionBill | null,
|
||||
shopNameMap?: Record<number, string>
|
||||
): string => {
|
||||
const nickname = bill?.customer_snapshot?.buyer_nickname
|
||||
if (nickname) return nickname
|
||||
const shopId = getBillShopId(bill)
|
||||
if (!shopId) return '-'
|
||||
return shopNameMap?.[shopId] || `店铺 #${shopId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单是否可关闭:仅待核销或部分核销可关闭(存在审批中预占时由调用方拦截或后端拒绝)
|
||||
*/
|
||||
export const canCloseBill = (bill: EmployeeCollectionBill): boolean =>
|
||||
bill.status === BILL_STATUS.PENDING || bill.status === BILL_STATUS.PARTIAL
|
||||
|
||||
/**
|
||||
* 账单是否可发起核销申请
|
||||
*/
|
||||
export const canCreateApplication = (bill: EmployeeCollectionBill): boolean => {
|
||||
if (bill.status === BILL_STATUS.CLOSED) return false
|
||||
return (bill.remaining_amount ?? 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请是否可修改并重新提交
|
||||
*/
|
||||
export const canResubmitApplication = (application: EmployeeCollectionApplication): boolean => {
|
||||
return application.status === APPLICATION_STATUS.REJECTED
|
||||
}
|
||||
348
src/views/finance/employee-collection/payment-methods/index.vue
Normal file
348
src/views/finance/employee-collection/payment-methods/index.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div id="table-full-screen" class="employee-collection-payment-methods-page">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:column-list="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="getTableData"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
v-permission="AUGUST_PERMISSIONS.employeeCollection.paymentMethodCreate"
|
||||
type="primary"
|
||||
@click="openCreate"
|
||||
>
|
||||
新增收款方式
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="filteredPaymentMethods"
|
||||
:pagination="false"
|
||||
:margin-top="10"
|
||||
:actions="getActions"
|
||||
:actions-width="140"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="editing ? '编辑收款方式' : '新增收款方式'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
@closed="handleDialogClosed"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="56px">
|
||||
<ElFormItem label="名称" prop="name">
|
||||
<ElInput v-model="form.name" maxlength="100" placeholder="请输入收款方式名称" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="编码" prop="code">
|
||||
<ElInput v-model="form.code" maxlength="64" placeholder="请输入唯一编码,如 cash" />
|
||||
<div v-if="editing" class="form-tip">编码在未被核销申请引用时可修改</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="排序" prop="sort">
|
||||
<ElInputNumber v-model="form.sort" :min="0" :max="9999" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="状态">
|
||||
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="备注" prop="remark">
|
||||
<ElInput
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="handleSubmit">确定</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { EmployeeCollectionService } from '@/api/modules'
|
||||
import type {
|
||||
EmployeeCollectionPaymentMethod,
|
||||
EmployeeCollectionPaymentMethodQueryParams
|
||||
} from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { STATUS_SELECT_OPTIONS } from '@/config/constants/status'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
|
||||
import { getErrorMessage } from '@/utils/business'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeCollectionList } from '../employeeCollectionDisplay'
|
||||
|
||||
defineOptions({ name: 'EmployeeCollectionPaymentMethods' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const tableRef = ref()
|
||||
const formRef = ref<FormInstance>()
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(false)
|
||||
const currentId = ref<number | null>(null)
|
||||
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
|
||||
|
||||
const initialSearchState = {
|
||||
keyword: '',
|
||||
enabled: undefined as number | undefined
|
||||
}
|
||||
const searchForm = reactive({ ...initialSearchState })
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '关键字',
|
||||
prop: 'keyword',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称或编码',
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'enabled',
|
||||
type: 'select',
|
||||
placeholder: '请选择状态',
|
||||
options: STATUS_SELECT_OPTIONS,
|
||||
config: { clearable: true }
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '名称', prop: 'name' },
|
||||
{ label: '编码', prop: 'code' },
|
||||
{ label: '排序', prop: 'sort' },
|
||||
{ label: '状态', prop: 'enabled' },
|
||||
{ label: '备注', prop: 'remark' },
|
||||
{ label: '创建时间', prop: 'created_at' }
|
||||
]
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{ prop: 'name', label: '名称', minWidth: 140 },
|
||||
{ prop: 'code', label: '编码', minWidth: 140 },
|
||||
{ prop: 'sort', label: '排序', width: 90 },
|
||||
{
|
||||
prop: 'enabled',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: (row: EmployeeCollectionPaymentMethod) =>
|
||||
h(ElTag, { type: row.enabled ? 'success' : 'info', effect: 'plain' }, () =>
|
||||
row.enabled ? '启用' : '停用'
|
||||
)
|
||||
},
|
||||
{ prop: 'remark', label: '备注', minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 170,
|
||||
formatter: (row: EmployeeCollectionPaymentMethod) => formatDateTime(row.created_at)
|
||||
}
|
||||
])
|
||||
|
||||
const filteredPaymentMethods = computed(() => {
|
||||
const keyword = searchForm.keyword?.trim().toLowerCase()
|
||||
return paymentMethods.value.filter((item) => {
|
||||
const matchKeyword =
|
||||
!keyword ||
|
||||
(item.name || '').toLowerCase().includes(keyword) ||
|
||||
(item.code || '').toLowerCase().includes(keyword)
|
||||
const matchEnabled =
|
||||
searchForm.enabled === undefined ||
|
||||
searchForm.enabled === null ||
|
||||
(searchForm.enabled === 1 ? item.enabled : !item.enabled)
|
||||
return matchKeyword && matchEnabled
|
||||
})
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
sort: 0,
|
||||
enabled: true,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入收款方式名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入收款方式编码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: EmployeeCollectionPaymentMethodQueryParams = { page: 1, page_size: 100 }
|
||||
if (searchForm.enabled !== undefined && searchForm.enabled !== null) {
|
||||
params.enabled = searchForm.enabled === 1
|
||||
}
|
||||
if (searchForm.keyword?.trim()) {
|
||||
params.keyword = searchForm.keyword.trim()
|
||||
}
|
||||
const res = await EmployeeCollectionService.getPaymentMethods(params)
|
||||
if (res.code === 0) {
|
||||
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => getTableData()
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
void getTableData()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.code = ''
|
||||
form.name = ''
|
||||
form.sort = 0
|
||||
form.enabled = true
|
||||
form.remark = ''
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
editing.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = (row: EmployeeCollectionPaymentMethod) => {
|
||||
editing.value = true
|
||||
currentId.value = row.id
|
||||
form.code = row.code
|
||||
form.name = row.name
|
||||
form.sort = row.sort ?? 0
|
||||
form.enabled = !!row.enabled
|
||||
form.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDialogClosed = () => {
|
||||
resetForm()
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value && currentId.value !== null) {
|
||||
const res = await EmployeeCollectionService.updatePaymentMethod(currentId.value, {
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
sort: form.sort,
|
||||
enabled: form.enabled,
|
||||
remark: form.remark.trim()
|
||||
})
|
||||
if (res.code !== 0) return
|
||||
} else {
|
||||
const res = await EmployeeCollectionService.createPaymentMethod({
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
sort: form.sort,
|
||||
enabled: form.enabled,
|
||||
remark: form.remark.trim()
|
||||
})
|
||||
if (res.code !== 0) return
|
||||
}
|
||||
ElMessage.success(editing.value ? '修改成功' : '新增成功')
|
||||
dialogVisible.value = false
|
||||
await getTableData()
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '保存失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (row: EmployeeCollectionPaymentMethod) => {
|
||||
ElMessageBox.confirm(`确定删除收款方式「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await EmployeeCollectionService.deletePaymentMethod(row.id)
|
||||
if (res && res.code !== 0) return
|
||||
ElMessage.success('删除成功')
|
||||
await getTableData()
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
getErrorMessage(error, '删除失败;该收款方式可能已被业务引用,请改为停用')
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 用户取消
|
||||
})
|
||||
}
|
||||
|
||||
const getActions = (row: EmployeeCollectionPaymentMethod) => {
|
||||
const actions: any[] = []
|
||||
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.paymentMethodEdit)) {
|
||||
actions.push({ label: '编辑', handler: () => openEdit(row), type: 'primary' })
|
||||
}
|
||||
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.paymentMethodDelete)) {
|
||||
actions.push({ label: '删除', handler: () => handleDelete(row), type: 'danger' })
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
onMounted(() => void getTableData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.employee-collection-payment-methods-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -197,7 +197,11 @@
|
||||
提示: 使用钱包支付时,订单将直接完成
|
||||
</template>
|
||||
<template v-else-if="createForm.payment_method === 'offline'">
|
||||
提示: 线下支付订单需要手动确认支付
|
||||
{{
|
||||
generatesCollectionBill
|
||||
? '提示: 线下支付订单将生成员工代收款账单,付款凭证可在核销申请中提交'
|
||||
: '提示: 线下支付订单需要手动确认支付'
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
@@ -213,6 +217,12 @@
|
||||
@uploading-change="voucherUploading = $event"
|
||||
@change="createFormRef?.validateField('payment_voucher_key')"
|
||||
/>
|
||||
<div
|
||||
v-if="generatesCollectionBill"
|
||||
style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)"
|
||||
>
|
||||
付款凭证为选填,也可在员工代收款核销申请中提交
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
@@ -570,8 +580,8 @@
|
||||
]
|
||||
}
|
||||
|
||||
// 线下支付时,支付凭证为必填
|
||||
if (createForm.payment_method === 'offline') {
|
||||
// 线下支付时,支付凭证为必填;生成员工代收款账单时可在核销申请中提交
|
||||
if (createForm.payment_method === 'offline' && !generatesCollectionBill.value) {
|
||||
baseRules.payment_voucher_key = [
|
||||
{
|
||||
required: true,
|
||||
@@ -641,6 +651,34 @@
|
||||
return `¥${(pkg.suggested_retail_price / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
// 平台账号(超级管理员/平台用户)操作
|
||||
const isPlatformAccount = computed(() => {
|
||||
const userType = Number(userStore.info.user_type)
|
||||
return userType === 1 || userType === 2
|
||||
})
|
||||
|
||||
// 选中套餐的实际收款金额(分)
|
||||
const selectedPackageAmountFen = computed(() => {
|
||||
const selectedPackage = packageOptions.value.find((pkg) => pkg.id === createForm.package_id)
|
||||
if (!selectedPackage) return 0
|
||||
return (
|
||||
selectedPackage.effective_retail_price ??
|
||||
selectedPackage.suggested_retail_price ??
|
||||
selectedPackage.retail_price ??
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
// 由平台账号操作、实际收款金额大于 0 且非赠送的线下订单会生成员工代收款账单,
|
||||
// 该场景付款凭证由核销申请环节提供,创建订单时可为空
|
||||
const generatesCollectionBill = computed(
|
||||
() =>
|
||||
createForm.payment_method === 'offline' &&
|
||||
isPlatformAccount.value &&
|
||||
!selectedPackageIsGift.value &&
|
||||
selectedPackageAmountFen.value > 0
|
||||
)
|
||||
|
||||
// IoT卡选择变化时,根据series_id加载套餐列表
|
||||
const handleIotCardChange = (cardId: number | null) => {
|
||||
if (!cardId) {
|
||||
@@ -1276,9 +1314,10 @@
|
||||
return
|
||||
}
|
||||
|
||||
// 线下支付时,支付凭证为必填
|
||||
// 线下支付时,支付凭证为必填;生成员工代收款账单时可在核销申请中提交
|
||||
if (
|
||||
createForm.payment_method === 'offline' &&
|
||||
!generatesCollectionBill.value &&
|
||||
!hasVoucherKeys(createForm.payment_voucher_key)
|
||||
) {
|
||||
ElMessage.error('线下支付必须上传支付凭证')
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
<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="160"
|
||||
@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"
|
||||
>
|
||||
<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>
|
||||
<div class="credential-tip">
|
||||
必填字段:{{ credentialFieldSpec.required.join('、') || '-' }}
|
||||
<template v-if="credentialFieldSpec.optional.length">
|
||||
;可选字段:{{ credentialFieldSpec.optional.join('、') }}
|
||||
</template>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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,
|
||||
buildPaymentCredentials,
|
||||
getPaymentMethodLabel,
|
||||
getPaymentProviderLabel,
|
||||
getPaymentCredentialFieldSpec,
|
||||
type PaymentCredentialEntry,
|
||||
type PaymentCredentials,
|
||||
type PaymentMerchant,
|
||||
type PaymentMerchantMethod,
|
||||
type PaymentMerchantPageResult,
|
||||
type PaymentMerchantProviderType,
|
||||
type PaymentMerchantQueryParams
|
||||
} from '@/types/api/paymentMerchantPools'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
|
||||
defineOptions({ name: 'PaymentMerchantManagement' })
|
||||
|
||||
type FilterVo = string | number | undefined | null | unknown[]
|
||||
|
||||
const { isPlatformAccount } = usePermission()
|
||||
const router = useRouter()
|
||||
const canManage = computed(() => isPlatformAccount.value)
|
||||
|
||||
const handleNameClick = (row: PaymentMerchant) => {
|
||||
if (!canManage.value) {
|
||||
ElMessage.warning('您没有查看支付商户详情的权限')
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: `${RoutesAlias.PaymentMerchantPoolsDetail}/${row.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
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,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PaymentMerchant) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
handleNameClick(row)
|
||||
}
|
||||
},
|
||||
row.name
|
||||
)
|
||||
},
|
||||
{
|
||||
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 credentialTemplateKeys = ref<string[]>([])
|
||||
|
||||
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 credentialFieldSpec = computed(() => getPaymentCredentialFieldSpec(form.provider_type))
|
||||
|
||||
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 = ''
|
||||
credentialTemplateKeys.value = []
|
||||
}
|
||||
|
||||
const clearSensitiveForm = () => {
|
||||
resetSensitiveForm()
|
||||
form.id = 0
|
||||
form.credential_version = 0
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const buildCredentialPayload = (): PaymentCredentials | null => {
|
||||
const { credentials, error } = buildPaymentCredentials(
|
||||
form.provider_type,
|
||||
form.merchant_identity,
|
||||
form.credentials
|
||||
)
|
||||
if (!credentials) {
|
||||
credentialError.value = error
|
||||
return null
|
||||
}
|
||||
credentialError.value = ''
|
||||
return credentials
|
||||
}
|
||||
|
||||
const showCreateDrawer = () => {
|
||||
if (!canManage.value) return
|
||||
Object.assign(form, initialFormState())
|
||||
credentialTemplateKeys.value = []
|
||||
formDrawerVisible.value = true
|
||||
formMode.value = 'create'
|
||||
showCredentialEditor.value = true
|
||||
addCredential()
|
||||
}
|
||||
|
||||
const startCredentialReplacement = () => {
|
||||
if (!canManage.value) return
|
||||
form.credentials = []
|
||||
if (credentialTemplateKeys.value.length > 0) {
|
||||
form.credentials = credentialTemplateKeys.value.map((key) => ({
|
||||
localId: generateLocalId(),
|
||||
key,
|
||||
value: ''
|
||||
}))
|
||||
} else {
|
||||
addCredential()
|
||||
}
|
||||
showCredentialEditor.value = true
|
||||
}
|
||||
|
||||
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
|
||||
clearSensitiveForm()
|
||||
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
|
||||
clearSensitiveForm()
|
||||
await loadMerchants()
|
||||
} catch (error) {
|
||||
console.error('更新支付商户失败:', error)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 启停 / 删除
|
||||
const tableRef = ref()
|
||||
|
||||
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: () => 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 = []
|
||||
})
|
||||
</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);
|
||||
}
|
||||
|
||||
.credential-tip {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,834 @@
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
|
||||
defineOptions({ name: 'PaymentMerchantPoolManagement' })
|
||||
|
||||
type FilterVo = string | number | undefined | null | unknown[]
|
||||
|
||||
const { isPlatformAccount } = usePermission()
|
||||
const router = useRouter()
|
||||
const canManage = computed(() => isPlatformAccount.value)
|
||||
|
||||
const handleNameClick = (row: PaymentMerchantPool) => {
|
||||
if (!canManage.value) {
|
||||
ElMessage.warning('您没有查看商户池详情的权限')
|
||||
return
|
||||
}
|
||||
|
||||
router.push({ path: `${RoutesAlias.PaymentMerchantPoolDetail}/${row.id}` })
|
||||
}
|
||||
|
||||
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,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PaymentMerchantPool) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
handleNameClick(row)
|
||||
}
|
||||
},
|
||||
row.name
|
||||
)
|
||||
},
|
||||
{
|
||||
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 || '未知商户'
|
||||
|
||||
// 表单
|
||||
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())
|
||||
|
||||
// 成员顺序以 form.member_ids 为单一数据源:VueDraggable 的 v-model 直接写回该数组,
|
||||
// 避免双向 watch 互相赋值导致的递归更新。
|
||||
const orderedMemberIds = computed<number[]>({
|
||||
get: () => form.member_ids,
|
||||
set: (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 = []
|
||||
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())
|
||||
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 || ''
|
||||
})
|
||||
formDrawerVisible.value = true
|
||||
formMode.value = 'edit'
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, initialFormState())
|
||||
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 tableRef = ref()
|
||||
|
||||
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: () => 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 = []
|
||||
availableMerchants.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>
|
||||
297
src/views/settings/payment-merchant-pools/detail.vue
Normal file
297
src/views/settings/payment-merchant-pools/detail.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="payment-merchant-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<div class="detail-header">
|
||||
<ElButton @click="handleBack">
|
||||
<template #icon>
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
</template>
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">{{ pageTitle }}</h2>
|
||||
</div>
|
||||
|
||||
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<ElIcon class="is-loading"><Loading /></ElIcon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElCard, ElIcon } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailField, DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { PaymentMerchantPoolsService } from '@/api/modules'
|
||||
import type { PaymentMerchantDetail } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getPaymentMethodLabel, getPaymentProviderLabel } from '@/types/api/paymentMerchantPools'
|
||||
|
||||
defineOptions({ name: 'PaymentMerchantPoolsDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detailData = ref<PaymentMerchantDetail | null>(null)
|
||||
|
||||
const merchantId = computed(() => Number(route.params.id))
|
||||
const pageTitle = computed(() => `支付商户详情 #${merchantId.value}`)
|
||||
|
||||
const formatText = (value: string | number | boolean | undefined | null): string => {
|
||||
if (value === undefined || value === null || value === '') return '-'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const formatBoolean = (value: boolean): string => (value ? '是' : '否')
|
||||
|
||||
const formatCredentialStatus = (value: string | number | boolean | undefined | null): string => {
|
||||
if (value === '已配置') return '已配置'
|
||||
if (value === true || value === 1) return '已配置'
|
||||
return '未配置'
|
||||
}
|
||||
|
||||
const baseSection: DetailSection = {
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '商户ID', prop: 'id' },
|
||||
{ label: '商户名称', prop: 'name' },
|
||||
{
|
||||
label: '支付方式',
|
||||
formatter: (_, data) => getPaymentMethodLabel(data.payment_method)
|
||||
},
|
||||
{
|
||||
label: '服务商类型',
|
||||
formatter: (_, data) => getPaymentProviderLabel(data.provider_type)
|
||||
},
|
||||
{ label: '商户标识', prop: 'merchant_identity' },
|
||||
{
|
||||
label: '启停状态',
|
||||
formatter: (_, data) => (data.enabled ? '启用' : '停用')
|
||||
},
|
||||
{
|
||||
label: '凭证状态',
|
||||
formatter: (_, data) =>
|
||||
Number(data.credential_version) > 0 ? '已配置' : '未配置'
|
||||
},
|
||||
{ label: '凭证版本', prop: 'credential_version' },
|
||||
{
|
||||
label: '备注',
|
||||
prop: 'remark',
|
||||
formatter: (value) => formatText(value),
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
prop: 'created_at',
|
||||
formatter: (value) => formatDateTime(value)
|
||||
},
|
||||
{
|
||||
label: '更新时间',
|
||||
prop: 'updated_at',
|
||||
formatter: (value) => formatDateTime(value)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const alipayFields: DetailField[] = [
|
||||
{
|
||||
label: '支付宝AppID',
|
||||
prop: 'credentials.ali_app_id',
|
||||
formatter: (value) => formatText(value)
|
||||
},
|
||||
{
|
||||
label: '支付过期分钟数',
|
||||
prop: 'credentials.ali_pay_expire_minutes',
|
||||
formatter: (value) => formatText(value)
|
||||
},
|
||||
{
|
||||
label: '生产环境',
|
||||
prop: 'credentials.ali_production',
|
||||
formatter: (value) => formatBoolean(value)
|
||||
},
|
||||
{
|
||||
label: '异步通知地址',
|
||||
prop: 'credentials.ali_notify_url',
|
||||
formatter: (value) => formatText(value),
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '同步跳转地址',
|
||||
prop: 'credentials.ali_return_url',
|
||||
formatter: (value) => formatText(value),
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '应用私钥',
|
||||
prop: 'credentials.ali_private_key',
|
||||
formatter: (value) => formatCredentialStatus(value)
|
||||
},
|
||||
{
|
||||
label: '支付宝公钥',
|
||||
prop: 'credentials.ali_public_key',
|
||||
formatter: (value) => formatCredentialStatus(value)
|
||||
}
|
||||
]
|
||||
|
||||
const wechatPayFields: DetailField[] = [
|
||||
{
|
||||
label: '微信商户号',
|
||||
prop: 'credentials.wx_mch_id',
|
||||
formatter: (value) => formatText(value)
|
||||
},
|
||||
{
|
||||
label: '证书序列号',
|
||||
prop: 'credentials.wx_serial_no',
|
||||
formatter: (value) => formatText(value)
|
||||
},
|
||||
{
|
||||
label: '支付回调地址',
|
||||
prop: 'credentials.wx_notify_url',
|
||||
formatter: (value) => formatText(value),
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: 'APIv2密钥',
|
||||
prop: 'credentials.wx_api_v2_key',
|
||||
formatter: (value) => formatCredentialStatus(value)
|
||||
},
|
||||
{
|
||||
label: 'APIv3密钥',
|
||||
prop: 'credentials.wx_api_v3_key',
|
||||
formatter: (value) => formatCredentialStatus(value)
|
||||
},
|
||||
{
|
||||
label: '支付证书',
|
||||
prop: 'credentials.wx_cert_content',
|
||||
formatter: (value) => formatCredentialStatus(value)
|
||||
},
|
||||
{
|
||||
label: '支付密钥',
|
||||
prop: 'credentials.wx_key_content',
|
||||
formatter: (value) => formatCredentialStatus(value)
|
||||
}
|
||||
]
|
||||
|
||||
const fuiouFields: DetailField[] = [
|
||||
{
|
||||
label: '富友API地址',
|
||||
prop: 'credentials.fy_api_url',
|
||||
formatter: (value) => formatText(value),
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '富友机构号',
|
||||
prop: 'credentials.fy_ins_cd',
|
||||
formatter: (value) => formatText(value)
|
||||
},
|
||||
{
|
||||
label: '富友商户号',
|
||||
prop: 'credentials.fy_mchnt_cd',
|
||||
formatter: (value) => formatText(value)
|
||||
},
|
||||
{
|
||||
label: '富友终端号',
|
||||
prop: 'credentials.fy_term_id',
|
||||
formatter: (value) => formatText(value)
|
||||
},
|
||||
{
|
||||
label: '支付回调地址',
|
||||
prop: 'credentials.fy_notify_url',
|
||||
formatter: (value) => formatText(value)
|
||||
},
|
||||
{
|
||||
label: '富友私钥',
|
||||
prop: 'credentials.fy_private_key',
|
||||
formatter: (value) => formatCredentialStatus(value)
|
||||
},
|
||||
{
|
||||
label: '富友公钥',
|
||||
prop: 'credentials.fy_public_key',
|
||||
formatter: (value) => formatCredentialStatus(value)
|
||||
}
|
||||
]
|
||||
|
||||
const credentialSections = computed<DetailSection[]>(() => {
|
||||
if (!detailData.value) return []
|
||||
|
||||
const providerType = detailData.value.provider_type
|
||||
if (providerType === 'alipay') {
|
||||
return [{ title: '支付宝配置', fields: alipayFields }]
|
||||
}
|
||||
|
||||
if (providerType === 'wechat' || providerType === 'wechat_v2') {
|
||||
return [{ title: '微信支付配置', fields: wechatPayFields }]
|
||||
}
|
||||
|
||||
if (providerType === 'fuiou') {
|
||||
return [{ title: '富友支付配置', fields: fuiouFields }]
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const detailSections = computed<DetailSection[]>(() => [baseSection, ...credentialSections.value])
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await PaymentMerchantPoolsService.getPaymentMerchantDetailById(merchantId.value)
|
||||
if (res.code === 0) {
|
||||
detailData.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载支付商户详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.payment-merchant-detail-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.el-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
243
src/views/settings/payment-merchant-pools/pool-detail.vue
Normal file
243
src/views/settings/payment-merchant-pools/pool-detail.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="payment-merchant-pool-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<div class="detail-header">
|
||||
<ElButton @click="handleBack">
|
||||
<template #icon>
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
</template>
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">商户池详情</h2>
|
||||
</div>
|
||||
|
||||
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<ElIcon class="is-loading"><Loading /></ElIcon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElCard, ElIcon } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailField, DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { PaymentMerchantPoolsService } from '@/api/modules'
|
||||
import type {
|
||||
PaymentMerchantMethod,
|
||||
PaymentMerchantPool,
|
||||
PaymentMerchantPoolMemberOption
|
||||
} from '@/types/api'
|
||||
import {
|
||||
getPaymentMethodLabel,
|
||||
getPaymentPoolStrategyLabel,
|
||||
getPaymentStatisticCycleLabel,
|
||||
getPaymentTimePeriodUnitLabel
|
||||
} from '@/types/api/paymentMerchantPools'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
|
||||
defineOptions({ name: 'PaymentMerchantPoolDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detailData = ref<PaymentMerchantPool | null>(null)
|
||||
const memberMerchants = ref<PaymentMerchantPoolMemberOption[]>([])
|
||||
|
||||
const poolId = computed(() => Number(route.params.id))
|
||||
|
||||
const loadMemberMerchants = async (paymentMethod: PaymentMerchantMethod) => {
|
||||
try {
|
||||
const res = await PaymentMerchantPoolsService.getPaymentMerchants({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
payment_method: paymentMethod
|
||||
})
|
||||
if (res.code === 0) {
|
||||
memberMerchants.value = (res.data?.items || []).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
payment_method: item.payment_method,
|
||||
enabled: item.enabled
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载商户池成员商户失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 成员只展示商户名称,不展示 member_ids 原始 ID
|
||||
const memberNamesText = computed(() => {
|
||||
const memberIds = detailData.value?.member_ids || []
|
||||
if (memberIds.length === 0) return '-'
|
||||
return memberIds
|
||||
.map(
|
||||
(memberId) => memberMerchants.value.find((item) => item.id === memberId)?.name || '未知商户'
|
||||
)
|
||||
.join('、')
|
||||
})
|
||||
|
||||
const formatText = (value: string | number | undefined | null): string => {
|
||||
if (value === undefined || value === null || value === '') return '-'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const baseSection: DetailSection = {
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '商户池名称', prop: 'name' },
|
||||
{
|
||||
label: '支付方式',
|
||||
formatter: (_, data) => getPaymentMethodLabel(data.payment_method)
|
||||
},
|
||||
{
|
||||
label: '启停状态',
|
||||
formatter: (_, data) => (data.enabled ? '启用' : '停用')
|
||||
},
|
||||
{
|
||||
label: '成员数量',
|
||||
formatter: (_, data) => `${(data.member_ids || []).length} 个`
|
||||
},
|
||||
{
|
||||
label: '成员商户',
|
||||
formatter: () => memberNamesText.value,
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '备注',
|
||||
prop: 'remark',
|
||||
formatter: (value) => formatText(value),
|
||||
fullWidth: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const rotationFields = computed<DetailField[]>(() => {
|
||||
const data = detailData.value
|
||||
if (!data) return []
|
||||
|
||||
const fields: DetailField[] = [
|
||||
{
|
||||
label: '轮询策略',
|
||||
formatter: (_, row) => getPaymentPoolStrategyLabel(row.strategy)
|
||||
}
|
||||
]
|
||||
|
||||
if (data.strategy === 'amount') {
|
||||
fields.push({
|
||||
label: '统计周期',
|
||||
formatter: (_, row) => getPaymentStatisticCycleLabel(row.statistic_cycle)
|
||||
})
|
||||
fields.push({
|
||||
label: '金额阈值',
|
||||
formatter: (_, row) => `${(Number(row.threshold_amount) / 100).toFixed(2)} 元`
|
||||
})
|
||||
} else if (data.strategy === 'count') {
|
||||
fields.push({
|
||||
label: '统计周期',
|
||||
formatter: (_, row) => getPaymentStatisticCycleLabel(row.statistic_cycle)
|
||||
})
|
||||
fields.push({ label: '笔数阈值', formatter: (_, row) => `${row.threshold_count} 笔` })
|
||||
} else if (data.strategy === 'time') {
|
||||
fields.push({
|
||||
label: '时间周期',
|
||||
formatter: (_, row) =>
|
||||
`${row.time_period_value} ${getPaymentTimePeriodUnitLabel(row.time_period_unit)}`
|
||||
})
|
||||
fields.push({
|
||||
label: '时间起点',
|
||||
formatter: (_, row) => formatDateTime(row.time_period_started_at)
|
||||
})
|
||||
}
|
||||
|
||||
return fields
|
||||
})
|
||||
|
||||
const runtimeFields = computed<DetailField[]>(() => {
|
||||
const data = detailData.value
|
||||
if (!data) return []
|
||||
|
||||
const fields: DetailField[] = [
|
||||
{ label: '路由世代', formatter: (_, row) => `v${row.routing_epoch}` }
|
||||
]
|
||||
|
||||
if (data.created_at) {
|
||||
fields.push({ label: '创建时间', formatter: (_, row) => formatDateTime(row.created_at) })
|
||||
}
|
||||
if (data.updated_at) {
|
||||
fields.push({ label: '更新时间', formatter: (_, row) => formatDateTime(row.updated_at) })
|
||||
}
|
||||
|
||||
return fields
|
||||
})
|
||||
|
||||
const detailSections = computed<DetailSection[]>(() => [
|
||||
baseSection,
|
||||
{ title: '轮询配置', fields: rotationFields.value },
|
||||
{ title: '运行状态', fields: runtimeFields.value }
|
||||
])
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await PaymentMerchantPoolsService.getPaymentMerchantPoolById(poolId.value)
|
||||
if (res.code === 0) {
|
||||
detailData.value = res.data
|
||||
await loadMemberMerchants(res.data.payment_method)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载商户池详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.payment-merchant-pool-detail-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.el-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -68,7 +68,7 @@
|
||||
v-for="item in currentConfig?.enum_values"
|
||||
:key="item"
|
||||
:value="item"
|
||||
:label="item"
|
||||
:label="formatEnumLabel(item)"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput
|
||||
@@ -105,7 +105,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { SystemConfigService } from '@/api/modules'
|
||||
@@ -131,7 +131,21 @@
|
||||
{ label: 'C 端支付方式配置', value: 'c2b.payment' }
|
||||
]
|
||||
|
||||
const searchForm = reactive<{ module?: SystemConfigModule }>({ module: undefined })
|
||||
const configEnumLabels: Record<string, string> = {
|
||||
wechat_only: '仅微信支付',
|
||||
alipay_only: '仅支付宝支付',
|
||||
both: '同时支持微信与支付宝'
|
||||
}
|
||||
|
||||
const formatEnumLabel = (value: string) => configEnumLabels[value] || value
|
||||
|
||||
const isSystemConfigModule = (value: unknown): value is SystemConfigModule =>
|
||||
moduleOptions.some((item) => item.value === value)
|
||||
|
||||
const queryModule = String(route.query.module || '')
|
||||
const searchForm = reactive<{ module?: SystemConfigModule }>({
|
||||
module: isSystemConfigModule(queryModule) ? queryModule : undefined
|
||||
})
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '配置模块',
|
||||
@@ -252,6 +266,9 @@
|
||||
const methods = parsePaymentMethods(config.value)
|
||||
return methods.length ? methods.map((method) => paymentMethodLabels[method]).join('、') : '-'
|
||||
}
|
||||
if (config.enum_values?.length) {
|
||||
return formatEnumLabel(config.value) || '-'
|
||||
}
|
||||
return config.value || '-'
|
||||
}
|
||||
|
||||
@@ -392,10 +409,16 @@
|
||||
loadConfigs()
|
||||
}
|
||||
|
||||
// 菜单跳转可能只变更 query(同一组件实例复用),需要跟随 module 变化重新筛选
|
||||
watch(
|
||||
() => route.query.module,
|
||||
(value) => {
|
||||
const module = String(value || '')
|
||||
searchForm.module = isSystemConfigModule(module) ? module : undefined
|
||||
pagination.page = 1
|
||||
loadConfigs()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(loadConfigs)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-configs-page {
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
>
|
||||
<ElOption label="退款审批" value="refund_approval" />
|
||||
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
|
||||
<ElOption label="员工代收款审批" value="employee_collection_approval" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
Reference in New Issue
Block a user