Compare commits

18 Commits

Author SHA1 Message Date
luo
2308d82d0f fix: 代理 2026-09-12 11:27:50 +08:00
luo
d3d257cdf8 feat: 支付商户 2026-09-10 16:50:06 +08:00
luo
ef966171b4 feat: 套餐分层级 2026-09-08 16:39:29 +08:00
luo
65cc63674e fix: 格式化时间
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m44s
2026-09-08 14:33:59 +08:00
luo
01cddd7eda fix:bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m46s
2026-09-08 10:59:38 +08:00
luo
a7b006076a fix: 字段位置交换
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 9m55s
2026-09-04 17:08:35 +08:00
luo
c36c59e268 fix: 推送到main
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 23m29s
2026-08-31 17:27:08 +08:00
luo
e56951d4b7 fix: 资产信息 绑定卡运营商显示逻辑 2026-08-31 17:10:27 +08:00
luo
b5c58d695f fix: ui 2026-08-24 16:44:08 +08:00
luo
6f6ac83b71 fix: ui 2026-08-24 16:37:21 +08:00
luo
48e4adf3ff fix: ui 2026-08-24 16:33:20 +08:00
luo
d335984c20 fix: 店铺用户名 2026-08-24 16:10:24 +08:00
luo
89e6d19d1f fix: other 2026-08-24 10:18:09 +08:00
luo
889ff3bc28 Merge branch 'develop' 2026-08-21 17:23:16 +08:00
luo
3a819e86c9 fix: 套餐用量显示
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m52s
2026-07-23 12:02:34 +08:00
luo
e9c0e17d6a fix: 套餐用量显示
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
2026-07-23 11:52:25 +08:00
luo
db7b3562da fix:将文案虚流量改成流量
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 7m23s
2026-07-21 15:24:02 +08:00
luo
f52970ae6c fix: 资产信息流量显示
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m22s
2026-07-21 14:51:29 +08:00
87 changed files with 8767 additions and 347 deletions

View File

@@ -3,7 +3,7 @@ name: 构建并部署前端到测试环境
on:
push:
branches:
- develop
- main
- dev
- test
@@ -27,7 +27,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
@@ -51,8 +51,8 @@ jobs:
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: 部署到本地(仅 develop 分支)
if: github.ref == 'refs/heads/develop'
- name: 部署到本地(仅 main 分支)
if: github.ref == 'refs/heads/main'
run: |
# 确保部署目录存在
mkdir -p ${{ env.DEPLOY_DIR }}

View 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` 与状态字段禁用关闭按钮,最终一致性以后端校验为准。

View 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`(未被引用时可改),可停用。
- 新增「员工代收款账单」页面:应收/已核销/未核销/待处理账单统计 + 列表 + 详情;普通员工仅见本人账单,超级管理员可见全部;存在审批中申请时账单不可关闭,关闭必须填写原因。
- 新增「核销申请」页面:列表 + 详情(含分摊账单、付款凭证、付款信息与全部审批尝试记录)+ 创建/重新提交弹窗;一笔线下收款可核销 1N 张账单,填写付款金额、付款方名称、付款时间、外部交易流水号、选择收款方式并上传付款凭证;提交后自动发起企微审批;仅已驳回申请可修改并重新提交,重新提交生成新的审批实例且历史记录不被覆盖。
- 扩展企业微信审批场景:新增 `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 片段为准;类型集中在单一文件,联调时便于收敛。

View File

@@ -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 能够使用一笔线下收款核销 1N 张账单。创建申请 MUST 提交收款方式 `payment_method_id`、付款金额 `paid_amount`(分,大于 0、付款方名称 `payer_name`、付款时间 `paid_at`(带时区 RFC3339、外部交易流水号 `external_transaction_no`、付款凭证 `payment_voucher_keys`15 个对象 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 继续要求上传付款凭证

View 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 实现创建/重新提交弹窗:选择 1N 张可核销账单、按账单录入分摊金额、填写付款金额/付款方名称/付款时间/外部交易流水号、选择收款方式并上传付款凭证。
- [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 错误兜底。

View File

@@ -0,0 +1,17 @@
# Change: Add purchased package names to the order list
## Why
Order list records already include their purchased line items, but operators cannot identify the purchased packages without opening the order detail. Showing these names next to the order number makes multi-package orders immediately understandable.
## What Changes
- Place the existing `资产标识符` column immediately after `订单编号`, followed by a `购买套餐名称` column in the order list.
- Render `items[].package_name` in the API-returned order, joining multiple item names with the Chinese enumeration separator `、`.
- Show a placeholder when an order has no returned items or no usable package name.
## Impact
- Affected specs: `order-list-purchased-package-names`
- Affected code: `src/views/order-management/order-list/index.vue`
- Dependencies: `GET /api/admin/orders` returns `items` with each item's `package_name`.

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Order list purchased package name display
The order list SHALL display `资产标识符` immediately after `订单编号`, followed by a `购买套餐名称` column. The package-name column MUST render package names from the current row's `items` in their API-returned order and MUST join multiple names with `、`.
#### Scenario: Order contains multiple purchased packages
- **GIVEN** an order response whose `items` contains package names `正式套餐` and `加油包33`
- **WHEN** the order is rendered in the order list
- **THEN** `资产标识符` MUST be positioned between `订单编号` and `购买套餐名称`
- **AND** the `购买套餐名称` column MUST display `正式套餐、加油包33`
- **AND** the order-number column MUST retain its existing navigation behavior
#### Scenario: Order has no available package names
- **GIVEN** an order response with `items=null`, an empty item list, or item records without usable names
- **WHEN** the order is rendered in the order list
- **THEN** the `购买套餐名称` column MUST display the standard placeholder

View File

@@ -0,0 +1,10 @@
## 1. Implementation
- [x] 1.1 Place `资产标识符` after order number and add the purchased-package-name column immediately after it in the order-list column chooser.
- [x] 1.2 Render package names from `Order.items` in API-returned order, separating multiple values with `、`.
- [x] 1.3 Render a placeholder for null, empty, or unnamed order items without altering the order-number link behavior.
## 2. Verification
- [x] 2.1 Verify a single item renders its package name and multiple items render `套餐A、套餐B`.
- [x] 2.2 Run targeted format, type, and lint checks.

View File

@@ -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或继续保持单个只写对象但不得把结构暴露为可回显配置。
- 若后端读取接口未脱敏,前端仍然能够防御性丢弃,但服务端响应、网关日志和网络抓包仍可能泄露凭证;该风险必须由后端脱敏共同控制。
- 客户支付端若不在当前仓库,文案和错误码任务需要跨仓库联调;本提案负责固化契约,不能仅通过后台页面上线完成验收。
- 金额阈值若直接按分展示会降低可读性,因此采用元输入、分传输;需要测试防止小数点精度和空值转换错误。

View File

@@ -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、商户池、轮询策略、阈值或路由世代。

View File

@@ -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 复用失败支付请求的商户选择或前端临时支付状态

View File

@@ -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 在提交后缓存敏感字段值

View File

@@ -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`

View File

@@ -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 图片时以声明类型为准。
- **收款方式字典项被引用后会冻结**:展示历史单依赖快照字段,避免字典改名或停用导致历史数据展示漂移。

View File

@@ -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 端在线充值。

View File

@@ -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 使用相同的内容类型

View File

@@ -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` 获取实际可用方式

View File

@@ -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`

View File

@@ -0,0 +1,19 @@
# Change: 统一资产套餐流量展示文案
## Why
资产信息页的当前生效套餐和套餐列表对同一虚流量数据使用了不一致且容易造成误解的展示文案。需要按业务术语统一为更通用的流量用量表述。
## What Changes
- 将“当前生效套餐”中虚流量统计区域的标题从“虚流量使用”调整为“流量用量”。
- 将“套餐列表”流量字段中虚流量已用值的标签从“已使用虚流量”调整为“已使用流量”。
- 保持现有流量字段、数值计算、进度条、可见性权限和接口契约不变。
## Impact
- Affected specs: `asset-package-traffic-display`
- Affected code:
- `src/views/asset-management/asset-information/components/CurrentPackageCard.vue`
- `src/views/asset-management/asset-information/components/PackageListCard.vue`
- Dependencies: `update-asset-package-traffic-threshold-layout` 中定义的套餐流量展示能力

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Asset Package Traffic Terminology
The asset information page SHALL use the approved traffic terminology while retaining the existing virtual traffic data source and display behavior.
#### Scenario: Current package displays the traffic usage heading
- **GIVEN** 当前生效套餐的虚流量统计区域可见
- **WHEN** 页面渲染该统计区域
- **THEN** 系统 MUST 将区域标题显示为“流量用量”
- **AND** 系统 MUST NOT 显示“虚流量使用”作为该区域标题
#### Scenario: Package list displays the used traffic label
- **GIVEN** 套餐列表记录的虚流量统计区域可见
- **WHEN** 页面渲染该记录的已用流量值
- **THEN** 系统 MUST 将字段标签显示为“已使用流量”
- **AND** 系统 MUST NOT 显示“已使用虚流量”作为该字段标签

View File

@@ -0,0 +1,5 @@
## 1. Implementation
- [x] 1.1 将当前生效套餐虚流量统计区域标题更新为“流量用量”。
- [x] 1.2 将套餐列表虚流量已用字段标签更新为“已使用流量”。
- [x] 1.3 验证文案变更不影响现有流量数值、进度条和权限可见性。

View File

@@ -0,0 +1,27 @@
# Change: 调整资产套餐真虚流量展示布局
## Why
资产信息页“当前生效套餐”和“套餐列表”目前将虚流量停机阈值及其断点标记放在虚流量使用区块,而真流量剩余未直接关联停机阈值。业务需要让停机阈值和断点与真流量剩余在同一区域展示,同时使虚流量区块完整呈现其基于真流量总量计算的总量和剩余。
## What Changes
- 在“当前生效套餐”和“套餐列表”的管理员/平台双流量视图中,将“虚流量停机阈值”展示项移至“真流量剩余”之后,继续显示现有的 `virtual_total_mb` 值。
- 将虚流量停机阈值对应的断点标记从虚流量进度条移至真流量进度条;断点位置继续按 `virtual_total_mb / real_total_mb` 计算。
- 在“虚流量使用”区块的“已使用”后新增:
- “总量”:显示真流量总量 `real_total_mb`
- “剩余”:按 `real_total_mb - virtual_used_mb` 计算。
- 保持真流量和虚流量进度条的现有计算口径、字段来源、权限控制及非管理员单一流量展示不变。
- 不修改后端接口、响应字段或数据模型。
## Impact
- Affected specs:
- `asset-package-traffic-display`
- `asset-package-traffic-permissions`
- Affected code:
- `src/views/asset-management/asset-information/components/CurrentPackageCard.vue`
- `src/views/asset-management/asset-information/components/PackageListCard.vue`
- Dependencies:
- 继续使用现有套餐流量字段:`real_total_mb``real_used_mb``virtual_used_mb``virtual_total_mb`
- 继续使用权限 `asset_info:view_current_package_real_usage``asset_info:view_current_package_virtual_usage`

View File

@@ -0,0 +1,55 @@
## ADDED Requirements
### Requirement: Virtual Traffic Threshold Aligns With Real Traffic Remaining
For super admins and platform users, the asset information page SHALL display the virtual traffic stop threshold immediately after real traffic remaining in both the `当前生效套餐` card and each applicable `套餐列表` row.
The displayed stop-threshold value MUST continue to use `virtual_total_mb`. The corresponding breakpoint marker MUST render on the real traffic progress bar and MUST continue to calculate its position as `virtual_total_mb / real_total_mb`.
#### Scenario: Current package shows threshold and breakpoint with real traffic
- **GIVEN** 当前登录账号的 `user_type``1``2`
- **AND** 当前套餐可展示真流量和虚流量使用信息
- **WHEN** 页面渲染“当前生效套餐”的流量信息
- **THEN** 系统 MUST 在真流量剩余后展示虚流量停机阈值
- **AND** 停机阈值 MUST 显示 `virtual_total_mb` 的现有格式化值
- **AND** 系统 MUST 将停机阈值断点标记渲染在真流量进度条
- **AND** 断点位置 MUST 按 `virtual_total_mb / real_total_mb` 计算
- **AND** 系统 MUST NOT 在虚流量进度条渲染该断点标记
#### Scenario: Package list shows threshold and breakpoint with real traffic
- **GIVEN** 当前登录账号的 `user_type``1``2`
- **AND** 套餐列表中的记录可展示真流量和虚流量使用信息
- **WHEN** 页面渲染该记录的流量信息
- **THEN** 系统 MUST 在真流量剩余后展示虚流量停机阈值
- **AND** 停机阈值 MUST 显示该记录的 `virtual_total_mb` 的现有格式化值
- **AND** 系统 MUST 将该记录的停机阈值断点标记渲染在真流量进度条
- **AND** 断点位置 MUST 按该记录的 `virtual_total_mb / real_total_mb` 计算
- **AND** 系统 MUST NOT 在虚流量进度条渲染该断点标记
### Requirement: Virtual Traffic Usage Displays Real Total And Remaining
For super admins and platform users, the `虚流量使用` section on the asset information page SHALL display virtual traffic used, real traffic total, and remaining virtual traffic capacity in that order in both the `当前生效套餐` card and each applicable `套餐列表` row.
The total MUST use `real_total_mb`. The remaining value MUST be calculated as `real_total_mb - virtual_used_mb`.
#### Scenario: Current package virtual traffic shows total and remaining
- **GIVEN** 当前登录账号的 `user_type``1``2`
- **AND** 当前套餐可展示虚流量使用信息
- **WHEN** 页面渲染“当前生效套餐”的虚流量使用区块
- **THEN** 系统 MUST 在虚流量已使用后展示总量
- **AND** 总量 MUST 显示 `real_total_mb` 的现有格式化值
- **AND** 系统 MUST 在总量后展示剩余
- **AND** 剩余 MUST 显示 `real_total_mb - virtual_used_mb` 的现有格式化值
#### Scenario: Package list virtual traffic shows total and remaining
- **GIVEN** 当前登录账号的 `user_type``1``2`
- **AND** 套餐列表中的记录可展示虚流量使用信息
- **WHEN** 页面渲染该记录的虚流量使用区块
- **THEN** 系统 MUST 在虚流量已使用后展示总量
- **AND** 总量 MUST 显示该记录的 `real_total_mb` 的现有格式化值
- **AND** 系统 MUST 在总量后展示剩余
- **AND** 剩余 MUST 显示 `real_total_mb - virtual_used_mb` 的现有格式化值

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Traffic Threshold And Virtual Statistics Follow Existing Usage Permissions
The asset information page SHALL apply the existing real and virtual traffic usage permissions to the relocated virtual traffic stop threshold, breakpoint marker, and added virtual traffic statistics.
#### Scenario: Hidden real traffic usage also hides relocated threshold and breakpoint
- **GIVEN** 当前登录账号的 `user_type``1``2`
- **AND** 账号缺少 `asset_info:view_current_package_real_usage` 权限
- **WHEN** 页面渲染“当前生效套餐”或“套餐列表”的套餐流量信息
- **THEN** 系统 MUST NOT 显示真流量统计
- **AND** 系统 MUST NOT 显示位于真流量区域的虚流量停机阈值
- **AND** 系统 MUST NOT 显示位于真流量进度条的停机阈值断点标记
#### Scenario: Hidden virtual traffic usage also hides added virtual statistics
- **GIVEN** 当前登录账号的 `user_type``1``2`
- **AND** 账号缺少 `asset_info:view_current_package_virtual_usage` 权限
- **WHEN** 页面渲染“当前生效套餐”或“套餐列表”的套餐流量信息
- **THEN** 系统 MUST NOT 显示虚流量使用区块
- **AND** 系统 MUST NOT 显示虚流量区块中的已使用、总量或剩余统计

View File

@@ -0,0 +1,16 @@
## 1. Implementation
- [x] 1.1 调整“当前生效套餐”真流量统计项,在真流量剩余后展示虚流量停机阈值,并保持 `virtual_total_mb` 的现有值格式化规则。
- [x] 1.2 将“当前生效套餐”的停机阈值断点标记移至真流量进度条,继续按 `virtual_total_mb / real_total_mb` 定位。
- [x] 1.3 调整“当前生效套餐”虚流量统计项,在已使用后展示真流量总量和按 `real_total_mb - virtual_used_mb` 计算的剩余量。
- [x] 1.4 对套餐列表的每条管理员/平台套餐记录应用相同的阈值、断点和虚流量统计布局。
- [x] 1.5 保持非管理员单一流量展示、现有权限可见性和各进度条百分比计算不变。
## 2. Verification
- [x] 2.1 验证“当前生效套餐”中停机阈值显示在真流量剩余后,且其值仍为 `virtual_total_mb`
- [x] 2.2 验证“当前生效套餐”中断点显示在真流量进度条,且其位置仍按 `virtual_total_mb / real_total_mb` 计算。
- [x] 2.3 验证“当前生效套餐”虚流量使用依次显示已使用、总量(`real_total_mb`)和剩余(`real_total_mb - virtual_used_mb`)。
- [x] 2.4 验证套餐列表的管理员/平台套餐记录具有与当前生效套餐一致的展示布局和计算结果。
- [x] 2.5 验证真流量或虚流量查看权限缺失时,不展示该权限对应的统计项及关联阈值/断点内容。
- [x] 2.6 验证非管理员账号继续使用现有单一流量展示,且真流量与虚流量进度百分比不变。

View File

@@ -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。
- 凭证字段枚举与后端校验规则必须保持一致;后端新增字段时前端需要同步更新枚举,否则提交会被前端拦截。

View File

@@ -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` 等内部标识。
- 不改动商户池轮询策略、成员排序规则和阈值换算规则。

View File

@@ -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`

View File

@@ -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`

View File

@@ -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 查询参数

View File

@@ -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
)
}

View 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
)
}
}

View File

@@ -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'

View 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>
)
}))
}
}

View File

@@ -3,7 +3,6 @@
v-model="dialogVisible"
title="手动更新卡实名状态"
width="30%"
modal-class="asset-information-dialog"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px">

View File

@@ -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、精确 MIMEimage/jpeg与通配 MIMEimage/*
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

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="设置实名认证策略"
width="40%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="资产标识">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="切换SIM卡"
width="40%"
modal-class="asset-information-dialog"
@close="handleClose"
>
<div v-if="loading" style="padding: 40px; text-align: center">

View File

@@ -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
}
}

View 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

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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)}`
}

View File

@@ -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',

View File

@@ -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', // 企业微信成员

View File

@@ -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

View File

@@ -39,6 +39,9 @@ export enum PackageUsageStatus {
// 套餐类型
export type PackageType = 'formal' | 'addon'
// 套餐关系状态(历史套餐列表)
export type AssetPackageRelationshipStatus = 'master_missing'
// 套餐使用类型
export type UsageType = 'single_card' | 'device'
@@ -236,6 +239,8 @@ export interface AssetBoundCard {
card_id: number // 卡 ID
iccid: string // ICCID
msisdn: string // 手机号
carrier_type?: string // 运营商类型
carrier_name?: string // 运营商名称
network_status: NetworkStatus // 网络状态
real_name_status: RealNameStatus // 实名状态
real_name_at?: string | null // 实名时间
@@ -330,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 // 兼容前端当前映射字段
@@ -351,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[] // 套餐记录列表
@@ -387,6 +395,7 @@ export interface AssetPackageListResponse {
export interface AssetPackageParams {
page?: number // 页码默认1
page_size?: number // 每页数量默认50
status?: PackageUsageStatus | null // 按同一条使用记录筛选
}
export interface UpdateAssetPackageUsedDataRequest {

View 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>

View File

@@ -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'

View 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: '' }
}

View File

@@ -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

View File

@@ -40,6 +40,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
exportTaskScene?: ExportTaskScene
/** 是否固定标签页 */
fixedTab?: boolean
/** 仅允许指定 user_type 访问1=超级管理员2=平台用户),为空时不做用户类型限制 */
allowedUserTypes?: number[]
}
// 扩展路由记录

View 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)

View File

@@ -118,13 +118,13 @@
label="实名时间"
label-class-name="asset-label asset-label--realname-time"
>{{
cardInfo?.real_name_at ? formatDateTime(cardInfo.real_name_at) : '-'
cardInfo?.real_name_at ? formatAssetInfoTime(cardInfo.real_name_at) : '-'
}}</ElDescriptionsItem
>
<ElDescriptionsItem
label="实名同步时间"
label-class-name="asset-label asset-label--realname-sync"
>{{ formatDateTime(cardInfo?.last_real_name_check_at) || '-' }}</ElDescriptionsItem
>{{ formatAssetInfoTime(cardInfo?.last_real_name_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
@@ -143,12 +143,12 @@
<ElDescriptionsItem
label="卡状态同步时间"
label-class-name="asset-label asset-label--card-sync"
>{{ formatDateTime(cardInfo?.last_card_status_check_at) || '-' }}</ElDescriptionsItem
>{{ formatAssetInfoTime(cardInfo?.last_card_status_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="流量同步时间"
label-class-name="asset-label asset-label--traffic-sync"
>{{ formatDateTime(cardInfo?.last_data_check_at) || '-' }}</ElDescriptionsItem
>{{ formatAssetInfoTime(cardInfo?.last_data_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
@@ -241,11 +241,10 @@
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem
label="激活状态"
label-class-name="asset-label asset-label--activation-status"
label="虚拟号"
label-class-name="asset-label asset-label--virtual-number"
>{{ cardInfo?.virtual_no || '-' }}</ElDescriptionsItem
>
{{ cardInfo?.activation_status_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="设备型号"
@@ -257,7 +256,7 @@
label-class-name="asset-label asset-label--realname-time"
>{{
selectedDeviceStatusCard?.real_name_at
? formatDateTime(selectedDeviceStatusCard.real_name_at)
? formatAssetInfoTime(selectedDeviceStatusCard.real_name_at)
: '-'
}}</ElDescriptionsItem
>
@@ -275,10 +274,11 @@
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem
label="虚拟号"
label-class-name="asset-label asset-label--virtual-number"
>{{ cardInfo?.virtual_no || '-' }}</ElDescriptionsItem
label="激活状态"
label-class-name="asset-label asset-label--activation-status"
>
{{ cardInfo?.activation_status_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="切卡模式"
label-class-name="asset-label asset-label--switch-mode"
@@ -434,7 +434,7 @@
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">运营商</span>
<span class="binding-card-field-value">{{ bindingCard.carrier_name || '-' }}</span>
<span class="binding-card-field-value">{{ getCarrierAccountDisplay(bindingCard) }}</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">卡槽位置</span>
@@ -467,7 +467,11 @@
<div class="binding-card-field">
<span class="binding-card-field-label">实名时间</span>
<span class="binding-card-field-value">
{{ bindingCard.real_name_at ? formatDateTime(bindingCard.real_name_at) : '-' }}
{{
bindingCard.real_name_at
? formatAssetInfoTime(bindingCard.real_name_at)
: '-'
}}
</span>
</div>
<div class="binding-card-field">
@@ -679,6 +683,7 @@
card_id?: number
iccid: string
msisdn?: string
carrier_type?: string
carrier_name?: string
slot_position?: number
is_current: boolean
@@ -883,12 +888,17 @@
return !isAgentOrEnterpriseAccount.value
})
const carrierAccountDisplay = computed(() => {
const getCarrierAccountDisplay = (card?: {
carrier_type?: string
carrier_name?: string
}) => {
if (isAgentOrEnterpriseAccount.value) {
return getCarrierTypeName(props.cardInfo?.carrier_type)
return getCarrierTypeName(card?.carrier_type)
}
return props.cardInfo?.carrier_name || '-'
})
return card?.carrier_name || '-'
}
const carrierAccountDisplay = computed(() => getCarrierAccountDisplay(props.cardInfo))
const descriptionsColumn = computed(() => {
if (width.value <= 640) return 1
@@ -989,19 +999,19 @@
{
key: 'realname',
label: '实名同步时间',
value: formatDateTime(props.cardInfo?.last_real_name_check_at) || '-',
value: formatAssetInfoTime(props.cardInfo?.last_real_name_check_at),
icon: 'realname-sync'
},
{
key: 'cardSync',
label: '卡状态同步时间',
value: formatDateTime(props.cardInfo?.last_card_status_check_at) || '-',
value: formatAssetInfoTime(props.cardInfo?.last_card_status_check_at),
icon: 'card-sync'
},
{
key: 'trafficSync',
label: '流量同步时间',
value: formatDateTime(props.cardInfo?.last_data_check_at) || '-',
value: formatAssetInfoTime(props.cardInfo?.last_data_check_at),
icon: 'traffic-sync'
},
{
@@ -1134,6 +1144,34 @@
return formatDataSize(value)
}
/**
* 接口中的这几项时间以其原始文本为基准直接加 8 小时;时区后缀不参与换算。
* 例如 2026-08-06T11:54:26.693382+08:00 显示为 2026-08-06 19:54:26。
*/
const formatAssetInfoTime = (value?: string | null) => {
if (!value?.trim()) return '-'
const timeText = value.trim()
const match = timeText.match(
/^(\d{4})-(\d{2})-(\d{2})(?:T|\s)(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i
)
if (!match) return '-'
const [, year, month, day, hours, minutes, seconds = '00'] = match
const date = new Date(
Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hours) + 8,
Number(minutes),
Number(seconds)
)
)
return formatDateTime(date)
}
// 处理轮询开关变化
const handlePollingChange = (value: string | number | boolean) => {
emit('update:pollingEnabled', Boolean(value))
@@ -1452,7 +1490,7 @@
}
:deep(.asset-label--bound-device-name::before) {
content: '\e6ef';
content: '\e7d8';
}
:deep(.asset-label--device-name::before) {

View File

@@ -31,7 +31,7 @@
<template v-if="isAdminOrPlatform">
<div class="flow-stats-container">
<!-- 真流量 -->
<div v-if="canViewCurrentPackageRealUsage" class="flow-stat-section">
<div v-if="canShowCurrentPackageRealUsage" class="flow-stat-section">
<div class="flow-stat-label">真流量使用</div>
<div class="flow-stat-row">
<span class="stat-item">
@@ -54,28 +54,49 @@
)
}}</span>
</span>
<span v-if="canShowCurrentPackageVirtualUsage" class="stat-item">
<span class="stat-label">停机阈值</span>
<span class="stat-value">{{
formatDataSize(currentPackage.virtual_total_mb || 0)
}}</span>
</span>
</div>
<div class="progress-wrapper">
<div class="progress-track">
<ElProgress
:percentage="
getUsageProgress(
currentPackage.real_used_mb || 0,
currentPackage.real_total_mb || 0
)
"
:color="
getProgressColorByPercentage(
getUsageProgress(
currentPackage.real_used_mb || 0,
currentPackage.real_total_mb || 0
)
)
"
:stroke-width="10"
/>
<div
v-if="canShowCurrentPackageVirtualUsage"
class="breakpoint-marker"
:style="
getBreakpointMarkerStyle(
currentPackage.virtual_total_mb || 0,
currentPackage.real_total_mb || 0
)
"
:title="`停机阈值/真流量总量: ${formatDataSize(currentPackage.virtual_total_mb || 0)} / ${formatDataSize(currentPackage.real_total_mb || 0)}`"
></div>
</div>
</div>
<ElProgress
:percentage="
getUsageProgress(
currentPackage.real_used_mb || 0,
currentPackage.real_total_mb || 0
)
"
:color="
getProgressColorByPercentage(
getUsageProgress(
currentPackage.real_used_mb || 0,
currentPackage.real_total_mb || 0
)
)
"
:stroke-width="10"
/>
</div>
<!-- 虚流量 -->
<div v-if="canShowCurrentPackageVirtualUsage" class="flow-stat-section">
<div class="flow-stat-label">流量使</div>
<div class="flow-stat-label">流量用</div>
<div class="flow-stat-row">
<span class="stat-item">
<span class="stat-label">已使用</span>
@@ -84,9 +105,17 @@
}}</span>
</span>
<span class="stat-item">
<span class="stat-label">停机阈值</span>
<span class="stat-label">总量</span>
<span class="stat-value">{{
formatDataSize(currentPackage.virtual_total_mb || 0)
formatDataSize(currentPackage.real_total_mb || 0)
}}</span>
</span>
<span class="stat-item">
<span class="stat-label">剩余</span>
<span class="stat-value">{{
formatDataSize(
(currentPackage.real_total_mb || 0) - (currentPackage.virtual_used_mb || 0)
)
}}</span>
</span>
</div>
@@ -110,16 +139,6 @@
"
:stroke-width="10"
/>
<div
class="breakpoint-marker"
:style="
getBreakpointMarkerStyle(
currentPackage.virtual_total_mb || 0,
currentPackage.real_total_mb || 0
)
"
:title="`停机阈值/真流量总量: ${formatDataSize(currentPackage.virtual_total_mb || 0)} / ${formatDataSize(currentPackage.real_total_mb || 0)}`"
></div>
</div>
<span class="progress-percentage">
{{
@@ -317,13 +336,17 @@
const canViewCurrentPackageVirtualUsage = computed(() =>
hasAuth('asset_info:view_current_package_virtual_usage')
)
const canShowCurrentPackageRealUsage = computed(
() =>
canViewCurrentPackageRealUsage.value || props.currentPackage?.enable_virtual_data === false
)
const canShowCurrentPackageVirtualUsage = computed(
() =>
canViewCurrentPackageVirtualUsage.value && props.currentPackage?.enable_virtual_data !== false
)
const shouldShowTrafficCard = computed(() => {
if (!isAdminOrPlatform.value) return true
return canViewCurrentPackageRealUsage.value || canShowCurrentPackageVirtualUsage.value
return canShowCurrentPackageRealUsage.value || canShowCurrentPackageVirtualUsage.value
})
const descriptionsColumn = computed(() => {

View File

@@ -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">
<span class="package-name-text">{{ scope.row.package_name }}</span>
<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">
@@ -74,7 +94,7 @@
<template #default="scope">
<div class="traffic-progress">
<template v-if="isAdminOrPlatform && shouldShowAdminTrafficBreakdown(scope.row)">
<div v-if="canViewCurrentPackageRealUsage" class="progress-text">
<div v-if="canShowPackageRealUsage(scope.row)" class="progress-text">
<span class="used"
>已使用真流量: {{ formatDataSize(scope.row.real_used_mb || 0) }}</span
>
@@ -89,29 +109,60 @@
)
}}</span
>
<span v-if="canShowPackageVirtualUsage(scope.row)" class="total"
>停机阈值: {{ formatDataSize(scope.row.virtual_total_mb || 0) }}</span
>
</div>
<div v-if="canShowPackageRealUsage(scope.row)" class="progress-wrapper">
<div class="progress-track">
<ElProgress
style="margin-top: 4px"
:percentage="
getUsageProgress(
scope.row.real_used_mb || 0,
scope.row.real_total_mb || 0
)
"
:color="
getProgressColorByPercentage(
getUsageProgress(
scope.row.real_used_mb || 0,
scope.row.real_total_mb || 0
)
)
"
/>
<div
v-if="canShowPackageVirtualUsage(scope.row)"
class="breakpoint-marker"
:style="
getBreakpointMarkerStyle(
scope.row.virtual_total_mb || 0,
scope.row.real_total_mb || 0
)
"
:title="`停机阈值/真流量总量: ${formatDataSize(scope.row.virtual_total_mb || 0)} / ${formatDataSize(scope.row.real_total_mb || 0)}`"
></div>
</div>
</div>
<ElProgress
v-if="canViewCurrentPackageRealUsage"
style="margin-top: 4px"
:percentage="
getUsageProgress(scope.row.real_used_mb || 0, scope.row.real_total_mb || 0)
"
:color="
getProgressColorByPercentage(
getUsageProgress(scope.row.real_used_mb || 0, scope.row.real_total_mb || 0)
)
"
/>
<div
v-if="canShowPackageVirtualUsage(scope.row)"
class="progress-text"
:style="{ marginTop: canViewCurrentPackageRealUsage ? '8px' : '0' }"
:style="{ marginTop: canShowPackageRealUsage(scope.row) ? '8px' : '0' }"
>
<span class="used"
>已使用流量: {{ formatDataSize(scope.row.virtual_used_mb || 0) }}</span
>已使用流量: {{ formatDataSize(scope.row.virtual_used_mb || 0) }}</span
>
<span class="total"
>停机阈值: {{ formatDataSize(scope.row.virtual_total_mb || 0) }}</span
>总量: {{ formatDataSize(scope.row.real_total_mb || 0) }}</span
>
<span class="remaining"
>剩余:
{{
formatDataSize(
(scope.row.real_total_mb || 0) - (scope.row.virtual_used_mb || 0)
)
}}</span
>
</div>
<div v-if="canShowPackageVirtualUsage(scope.row)" class="progress-wrapper">
@@ -134,16 +185,6 @@
)
"
/>
<div
class="breakpoint-marker"
:style="
getBreakpointMarkerStyle(
scope.row.virtual_total_mb || 0,
scope.row.real_total_mb || 0
)
"
:title="`停机阈值/真流量总量: ${formatDataSize(scope.row.virtual_total_mb || 0)} / ${formatDataSize(scope.row.real_total_mb || 0)}`"
></div>
</div>
<span class="progress-percentage">
{{
@@ -298,8 +339,9 @@
v-model="usedDataForm.data_usage"
:min="0"
:max="usedDataMaxValue"
:precision="3"
:step="0.1"
:precision="0"
:step="1"
step-strictly
class="used-data-input__number"
placeholder="请输入新的套餐真实已用量"
/>
@@ -436,7 +478,7 @@
const usedDataMaxValue = computed(() => {
const realTotalMb = selectedPackage.value?.real_total_mb ?? 0
return usedDataForm.unit === 'GB' ? Number((realTotalMb / 1024).toFixed(3)) : realTotalMb
return usedDataForm.unit === 'GB' ? Math.floor(realTotalMb / 1024) : Math.floor(realTotalMb)
})
const validateUsedData = (
@@ -450,6 +492,11 @@
return
}
if (!Number.isInteger(usedDataForm.data_usage)) {
callback(new Error('新的已用量必须为整数'))
return
}
if (dataUsageMb === undefined || dataUsageMb < 0) {
callback(new Error('请输入可换算为非负数字 MB 的已用量'))
return
@@ -579,12 +626,15 @@
// Props 使用 API 类型
type PackageInfo = AssetPackageUsageRecord
const canShowPackageRealUsage = (pkg: PackageInfo) =>
canViewCurrentPackageRealUsage.value || pkg.enable_virtual_data === false
const canShowPackageVirtualUsage = (pkg: PackageInfo) =>
canViewCurrentPackageVirtualUsage.value && pkg.enable_virtual_data !== false
const shouldShowAdminTrafficBreakdown = (pkg: PackageInfo) => {
if (!isAdminOrPlatform.value) return true
return canViewCurrentPackageRealUsage.value || canShowPackageVirtualUsage(pkg)
return canShowPackageRealUsage(pkg) || canShowPackageVirtualUsage(pkg)
}
interface Props {
@@ -629,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],
@@ -727,6 +796,12 @@
}
}
:deep(.package-order-column .cell) {
display: flex;
align-items: center;
min-width: 0;
}
.package-name-text {
display: block;
overflow: hidden;
@@ -735,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) {

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="套餐流量详单"
width="900px"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="data">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="往期订单"
width="60%"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="loading" style="padding: 40px 0; text-align: center">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="套餐充值"
width="40%"
modal-class="asset-information-dialog"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">

View File

@@ -5,7 +5,7 @@
width="500px"
:lock-scroll="true"
:z-index="4000"
modal-class="asset-information-dialog speed-limit-modal"
modal-class="speed-limit-modal"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="固定档位" prop="code">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="设置切卡模式"
width="45%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="设备标识">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="设置WiFi"
width="40%"
modal-class="asset-information-dialog"
:append-to-body="true"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" autocomplete="off">

View File

@@ -62,7 +62,7 @@ export function useAssetFormatters() {
const getRealNameStatusType = (status?: number): 'info' | 'success' | 'warning' | 'danger' => {
if (status === undefined || status === null) return 'info'
const map: Record<number, 'info' | 'success' | 'warning' | 'danger'> = {
0: 'info',
0: 'danger',
1: 'success'
}
return map[status] || 'info'

View File

@@ -30,6 +30,9 @@ const NULLABLE_REALTIME_CARD_FIELDS = new Set([
'last_card_status_check_at'
])
// 详情接口是绑定卡实名时间的唯一来源;实时状态接口不应覆盖或保留旧值。
const DETAIL_ONLY_BOUND_CARD_FIELDS = new Set(['real_name_at'])
/**
* 格式化数据大小(MB -> GB/MB)
*/
@@ -60,10 +63,13 @@ const mergeAssetBoundCards = (
): AssetBoundCard[] =>
realtimeCards.map((realtimeCard) => {
const existingCard = existingCards.find((card) => card.iccid === realtimeCard.iccid)
if (!existingCard) return realtimeCard
if (!existingCard) {
return { ...realtimeCard, real_name_at: null }
}
const mergedCard = { ...existingCard } as unknown as Record<string, unknown>
for (const [key, value] of Object.entries(realtimeCard)) {
if (DETAIL_ONLY_BOUND_CARD_FIELDS.has(key)) continue
if (value !== undefined && (value !== null || NULLABLE_REALTIME_CARD_FIELDS.has(key))) {
mergedCard[key] = value
}
@@ -214,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

View File

@@ -1653,112 +1653,3 @@
background: #ffffff;
}
</style>
<style lang="scss">
/* Dialogs are teleported to body, so their surface needs a page-level hook. */
.asset-information-dialog.el-dialog {
max-width: calc(100vw - 32px);
overflow: hidden;
border: 1px solid #e5eaf1;
border-radius: 14px;
box-shadow: 0 18px 50px rgb(15 23 42 / 18%);
.el-dialog__header {
padding: 20px 24px 16px;
margin: 0;
border-bottom: 1px solid #e5eaf1;
}
.el-dialog__title {
font-size: 16px;
font-weight: 650;
color: #172033;
letter-spacing: -0.01em;
}
.el-dialog__body {
padding: 22px 24px 20px;
}
.el-dialog__footer {
padding: 14px 24px 20px;
border-top: 1px solid #eef2f7;
}
.el-form-item {
margin-bottom: 18px;
}
.el-form-item__label {
color: #526078;
}
.el-button {
min-height: 34px;
border-radius: 8px;
}
.el-table {
--el-table-header-bg-color: #f8fafc;
--el-table-row-hover-bg-color: #f5f8ff;
--el-table-border-color: #e5eaf1;
border-radius: 9px;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #526078;
background: #f8fafc;
}
.dialog-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
@media (width <= 768px) {
.asset-information-dialog.el-dialog {
width: calc(100vw - 20px) !important;
.el-dialog__header {
padding: 16px 18px 13px;
}
.el-dialog__body {
max-height: calc(100vh - 150px);
padding: 18px;
overflow-y: auto;
}
.el-dialog__footer {
padding: 12px 18px 16px;
}
}
}
html.dark .asset-information-dialog.el-dialog {
border-color: #2b3a52;
.el-dialog__header,
.el-dialog__footer {
border-color: #2b3a52;
}
.el-dialog__title,
.el-form-item__label {
color: #e5edf8;
}
.el-table {
--el-table-header-bg-color: #202d42;
--el-table-row-hover-bg-color: #223451;
--el-table-border-color: #2b3a52;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #afbdd0;
background: #202d42;
}
}
</style>

View File

@@ -56,6 +56,7 @@ export interface BindingCard {
msisdn?: string
is_current: boolean
status?: number
carrier_type?: string
carrier_name?: string
slot_position?: number
network_status?: number

View File

@@ -238,7 +238,10 @@
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="seriesBindingForm.selection_type">
<ElRadioGroup
v-model="seriesBindingForm.selection_type"
@change="handleDeviceBatchSelectionTypeChange"
>
<ElRadio label="list">设备ID列表</ElRadio>
<ElRadio label="range">设备号范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -736,7 +739,10 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseDeviceAuthorizeForm.selection_type">
<ElRadioGroup
v-model="enterpriseDeviceAuthorizeForm.selection_type"
@change="handleDeviceBatchSelectionTypeChange"
>
<ElRadio label="list">设备号列表</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
@@ -842,7 +848,10 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseDeviceRecallForm.selection_type">
<ElRadioGroup
v-model="enterpriseDeviceRecallForm.selection_type"
@change="handleDeviceBatchSelectionTypeChange"
>
<ElRadio label="list">设备号列表</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
@@ -1355,7 +1364,7 @@
// 搜索表单
const searchForm = reactive<Record<string, any>>({ ...initialSearchState })
// 搜索批次号(用于搜索表单)
// 批次号仅在用户切换至筛选条件或输入批次号搜索时加载。
const loadSearchBatchNoOptions = async (batchNo?: string) => {
try {
const params: any = {
@@ -1374,11 +1383,14 @@
}
}
// 搜索批次号
const handleSearchBatchNo = (query: string) => {
if (query) {
loadSearchBatchNoOptions(query)
} else {
loadSearchBatchNoOptions(query || undefined)
}
const handleDeviceBatchSelectionTypeChange = (
selectionType: string | number | boolean | undefined
) => {
if (selectionType === DeviceSelectionType.FILTER) {
loadSearchBatchNoOptions()
}
}
@@ -2517,7 +2529,6 @@
resetEnterpriseDeviceAuthorizeForm()
await Promise.all([
handleSearchEnterprise(),
loadSearchBatchNoOptions(),
loadTopLevelShopCascadeOptions()
])
enterpriseDeviceAuthorizeDialogVisible.value = true
@@ -2526,7 +2537,7 @@
const showEnterpriseDeviceRecallDialog = async () => {
resetEnterpriseDeviceRecallForm()
await Promise.all([handleSearchEnterprise(), loadSearchBatchNoOptions()])
await handleSearchEnterprise()
enterpriseDeviceRecallDialogVisible.value = true
enterpriseDeviceRecallFormRef.value?.clearValidate()
}
@@ -2667,7 +2678,7 @@
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER
})
await Promise.all([loadPackageSeriesList(), loadSearchBatchNoOptions()])
await loadPackageSeriesList()
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}

View File

@@ -119,7 +119,10 @@
/>
</ElFormItem>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="allocateForm.selection_type">
<ElRadioGroup
v-model="allocateForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -246,7 +249,10 @@
>
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="100px">
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="recallForm.selection_type">
<ElRadioGroup
v-model="recallForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -415,7 +421,10 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseCardAuthorizeForm.selection_type">
<ElRadioGroup
v-model="enterpriseCardAuthorizeForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -568,7 +577,10 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseCardRecallForm.selection_type">
<ElRadioGroup
v-model="enterpriseCardRecallForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -739,7 +751,10 @@
label-width="100"
>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="seriesBindingForm.selection_type">
<ElRadioGroup
v-model="seriesBindingForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -1338,10 +1353,9 @@
}
}
// 批量分配对话框批次号选项
// 批次号仅在用户切换至筛选条件时加载;避免弹窗打开即请求导入任务列表。
const allocateBatchNoOptions = ref<any[]>([])
// 加载批量分配对话框批次号选项
const loadAllocateBatchNoOptions = async (batchNo?: string) => {
try {
const params: any = {
@@ -1360,11 +1374,14 @@
}
}
// 搜索批量分配对话框批次号
const handleSearchAllocateBatchNo = (query: string) => {
if (query) {
loadAllocateBatchNoOptions(query)
} else {
loadAllocateBatchNoOptions(query || undefined)
}
const handleCardBatchSelectionTypeChange = (
selectionType: string | number | boolean | undefined
) => {
if (selectionType === CardSelectionType.FILTER) {
loadAllocateBatchNoOptions()
}
}
@@ -1843,6 +1860,19 @@
{ label: '无换卡记录', value: false }
]
},
{
label: '是否绑设备',
prop: 'is_standalone',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '未绑定设备', value: true },
{ label: '已绑定设备', value: false }
]
},
{
label: '起始号',
prop: 'iccid_start',
@@ -1860,19 +1890,6 @@
clearable: true,
placeholder: '请输入ICCID结束号'
}
},
{
label: '是否绑设备',
prop: 'is_standalone',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '未绑定设备', value: true },
{ label: '已绑定设备', value: false }
]
}
]
@@ -2481,7 +2498,6 @@
remark: ''
})
loadAllocateCarrierOptions()
loadAllocateBatchNoOptions()
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
}
@@ -2507,7 +2523,6 @@
recallFormRef.value.resetFields()
}
loadAllocateCarrierOptions()
loadAllocateBatchNoOptions()
}
// 关闭批量分配对话框
@@ -2677,7 +2692,6 @@
await Promise.all([
handleSearchEnterprise(),
loadAllocateCarrierOptions(),
loadAllocateBatchNoOptions(),
loadTopLevelShopCascadeOptions()
])
enterpriseCardAuthorizeDialogVisible.value = true
@@ -2688,8 +2702,7 @@
resetEnterpriseCardRecallForm()
await Promise.all([
handleSearchEnterprise(),
loadAllocateCarrierOptions(),
loadAllocateBatchNoOptions()
loadAllocateCarrierOptions()
])
enterpriseCardRecallDialogVisible.value = true
enterpriseCardRecallFormRef.value?.clearValidate()
@@ -2812,8 +2825,7 @@
})
await Promise.all([
loadPackageSeriesList(),
loadAllocateCarrierOptions(),
loadAllocateBatchNoOptions()
loadAllocateCarrierOptions()
])
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()

View File

@@ -193,12 +193,28 @@
prop: 'payment_channel',
formatter: (value) => value || '-'
},
{
label: '第三方支付流水号',
prop: 'payment_transaction_id',
formatter: (value) => value || '-',
fullWidth: true
},
...(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: 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', '-')
}
]
: []),

View File

@@ -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;

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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
}

View 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>

View File

@@ -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>
@@ -507,12 +517,13 @@
const columnOptions = computed(() => {
const options = [
{ label: '订单编号', prop: 'order_no' },
{ label: '资产标识符', prop: 'asset_identifier' },
{ label: '购买套餐名称', prop: 'purchased_package_names' },
{ label: '买家手机号', prop: 'buyer_phone' },
{ label: '买家昵称', prop: 'buyer_nickname' },
{ label: '订单类型', prop: 'order_type' },
{ label: '资产类型', prop: 'asset_type' },
{ label: '买家类型', prop: 'buyer_type' },
{ label: '资产标识符', prop: 'asset_identifier' },
{ label: '订单渠道', prop: 'purchase_role' },
{ label: '购买备注', prop: 'purchase_remark' },
{ label: '是否代购', prop: 'is_purchase_on_behalf' },
@@ -569,8 +580,8 @@
]
}
// 线下支付时,支付凭证为必填
if (createForm.payment_method === 'offline') {
// 线下支付时,支付凭证为必填;生成员工代收款账单时可在核销申请中提交
if (createForm.payment_method === 'offline' && !generatesCollectionBill.value) {
baseRules.payment_voucher_key = [
{
required: true,
@@ -640,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) {
@@ -727,6 +766,14 @@
return `¥${(amount / 100).toFixed(2)}`
}
const formatPurchasedPackageNames = (order: Order): string => {
const packageNames = (order.items || [])
.map((item) => (item.package_name || '').trim())
.filter(Boolean)
return packageNames.join('、') || '-'
}
// 获取支付状态标签类型
const getPaymentStatusType = (
status: PaymentStatus
@@ -847,6 +894,20 @@
)
}
},
{
prop: 'asset_identifier',
label: '资产标识符',
width: 180,
showOverflowTooltip: true,
formatter: (row: Order) => row.asset_identifier || '-'
},
{
prop: 'purchased_package_names',
label: '购买套餐名称',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: Order) => formatPurchasedPackageNames(row)
},
{
prop: 'buyer_phone',
label: '买家手机号',
@@ -911,13 +972,6 @@
width: 180,
formatter: (row: Order) => (row.expires_at ? formatDateTime(row.expires_at) : '-')
},
{
prop: 'asset_identifier',
label: '资产标识符',
width: 180,
showOverflowTooltip: true,
formatter: (row: Order) => row.asset_identifier || '-'
},
{
prop: 'purchase_role',
label: '订单渠道',
@@ -1260,9 +1314,10 @@
return
}
// 线下支付时,支付凭证为必填
// 线下支付时,支付凭证为必填;生成员工代收款账单时可在核销申请中提交
if (
createForm.payment_method === 'offline' &&
!generatesCollectionBill.value &&
!hasVoucherKeys(createForm.payment_voucher_key)
) {
ElMessage.error('线下支付必须上传支付凭证')

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -64,6 +64,7 @@
>
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
<ElOption label="员工代收款审批" value="employee_collection_approval" />
</ElSelect>
</ElFormItem>
</ElCol>

View File

@@ -1080,7 +1080,8 @@
],
init_username: [
{ required: true, message: '请输入初始账号用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' },
{ pattern: /^[A-Za-z\d]+$/, message: '用户名只能包含英文或数字', trigger: 'blur' }
],
init_password: [
{ required: true, message: '请输入初始账号密码', trigger: 'blur' },