Compare commits

..

19 Commits

Author SHA1 Message Date
luo
ac541b17af fix: some
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 11m35s
2026-09-17 18:43:01 +08:00
luo
68d8f769d1 fix: some
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
2026-09-17 18:37:20 +08:00
luo
068d0c4ac2 fix: git branch
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 8m48s
2026-09-17 15:46:33 +08:00
luo
9a3a764120 fix: git branch
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 35m56s
2026-09-17 14:35:31 +08:00
luo
cea805f5d8 fix: git branch 2026-09-17 14:30:53 +08:00
luo
76d4e4ec02 fix: some 2026-09-17 12:16:20 +08:00
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
190 changed files with 21503 additions and 447 deletions

View File

@@ -3,9 +3,12 @@ name: 构建并部署前端到测试环境
on:
push:
branches:
- develop
- iteration/august
- dev
- test
# 如果不再需要 main 分支触发构建,可以把下面这行删掉;
# 如果只是想保留构建(不部署),可以留着,反正部署条件已经只认 iteration/august
- main
env:
REGISTRY: registry.boss160.cn
@@ -27,7 +30,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
if [ "${{ github.ref }}" = "refs/heads/iteration/august" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
@@ -51,8 +54,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: 部署到本地(仅 iteration/august 分支)
if: github.ref == 'refs/heads/iteration/august'
run: |
# 确保部署目录存在
mkdir -p ${{ env.DEPLOY_DIR }}

View File

@@ -0,0 +1,23 @@
# Change: 代理扫码分销注册与提现资料资格前端对接
## Why
新增强代理扫码分销注册与提现资料资格能力:代理可通过 H5 注册页扫码注册,审批通过后登录后台;代理本人可维护提现资料资格并发起/重提提现,超管可作废资格。后端契约已按 `docs/产品迭代8月份/frontend-api-simple.md` 落地,后台管理端当前缺少对应接口封装、独立注册页与资格管理入口。
本次前端按后端实际契约对齐:注册验码 `POST /api/c/v1/auth/send-code``scene``bind_phone`,返回 `cooldown_seconds`);扫码注册 `POST /api/c/v1/agent-distribution-registrations`(成功仅返回「待审批」);店铺列表/详情/更新响应新增 `distribution_code`;提现资料资格提交/查询/作废与提现申请重提/详情按新文档补齐。
## What Changes
- 新增 `agent-distribution-registration` 能力独立注册页挂在后台项目内、静态路由免登录访问H5 与 PC 同一套表单),支持分销码自动填、短信验证码 60s 倒计时、密码掩码与强度提示、省市区级联;提交成功进入「待审批」结束页,失败统一提示「分销码不可用」且不复用同一验证码。
- 新增公开 API 封装:`sendCode()``registerAgent()`,请求不带登录态(`requestOptions.withToken: false`)。
- 店铺模块补充 `distribution_code` 字段类型,店铺详情页展示分销码与注册二维码。
- 佣金模块新增:提现资料资格提交/替换、资格查询(证件号脱敏、含审批与作废信息)、资格作废(超管,`reason` 必填)、重提被驳回的提现、提现申请详情(含尝试记录与异常标记)。
- 扩展企微审批场景业务类型:`agent_distribution_approval``withdrawal_qualification_approval``commission_withdrawal_approval`
- 提现交互约束:资格替换合同/法人身份证后旧资格失效需重新审批;提现仅在有效资格且余额充足时可提交,否则不冻结、不建单。
- 不实现后端接口、数据库、企微回调;不实现 C 端(客户)登录体系。
## Impact
- Affected specs: `agent-distribution-registration``shop-management``commission-management``wecom-scenes`
- Affected code: `src/api/modules/agentDistribution.ts``src/api/modules/commission.ts``src/types/api/shop.ts``src/types/api/commission.ts``src/types/api/wecom.ts``src/router/routes/staticRoutes.ts``src/router/guards/permission.ts``src/views/agent-registration/index.vue``src/views/shop-management/detail/index.vue``src/views/commission-management/my-commission/index.vue``src/views/settings/wecom/scenes/index.vue`
- Dependencies: `docs/产品迭代8月份/frontend-api-simple.md`

View File

@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: 独立代理注册页免登录访问
代理扫码分销注册页 MUST 独立于登录态提供未登录可访问且挂在后台项目内H5 与 PC 共用同一套表单);注册流程 MUST NOT 复用当前登录态。
#### Scenario: 未登录访问注册页
- **GIVEN** 用户未登录并通过扫码或直接访问注册入口
- **WHEN** 其打开注册页面
- **THEN** 页面 MUST 正常渲染注册表单
- **AND** MUST NOT 跳转到登录页
#### Scenario: 已登录用户访问注册页
- **GIVEN** 用户已登录后台
- **WHEN** 其打开注册页面
- **THEN** 注册流程 MUST 不携带登录态
### Requirement: 发送短信验证码
注册页 MUST 调用 `POST /api/c/v1/auth/send-code` 发送验证码,请求体 `{ phone, scene }``scene``bind_phone`),响应 `data.cooldown_seconds` MUST 用于 60 秒倒计时;请求 MUST 不携带 Token。
#### Scenario: 发送验证码并倒计时
- **GIVEN** 用户输入手机号
- **WHEN** 其点击获取验证码
- **THEN** 系统 MUST 调用发送验证码接口
- **AND** 按钮 MUST 进入 60 秒倒计时且不可重复点击
#### Scenario: 注册失败后不复用验证码
- **GIVEN** 一次注册提交失败
- **WHEN** 用户再次尝试注册
- **THEN** 页面 MUST 提示重新获取验证码
- **AND** MUST NOT 复用已消费的验证码
### Requirement: 代理扫码注册提交
注册页 MUST 调用 `POST /api/c/v1/agent-distribution-registrations` 提交注册,必填 `distribution_code`(扫码自动带)、`phone``code``password``shop_name``shop_code``username`,选填 `contact_name``province``city``district``address`;成功响应为「待审批」,前端 MUST 展示待审批结束页且不自动登录。
#### Scenario: 注册成功进入待审批
- **GIVEN** 用户填写完整表单并通过校验
- **WHEN** 其提交注册
- **THEN** 系统 MUST 调用注册接口
- **AND** 成功时 MUST 展示「待审批,审核结果将通知你」结束页
- **AND** MUST NOT 发放账号凭证或自动登录
#### Scenario: 注册失败统一提示
- **GIVEN** 注册提交失败(无效分销码、上级停用、验证码无效或已消费等)
- **WHEN** 注册接口返回失败
- **THEN** 页面 MUST 统一提示「分销码不可用」
- **AND** MUST NOT 根据错误文案区分原因
### Requirement: 表单适配与敏感项处理
注册表单 MUST 同时适配手机 H5 与 PCH5 单列、大触控区、软键盘友好、地址使用级联选择器PC 使用居中卡片或两列布局且字段一致。密码输入 MUST 掩码并展示强度提示;页面 MUST NOT 展示除手机号外的完整个人敏感信息。
#### Scenario: H5 与 PC 同一表单
- **GIVEN** 用户分别在手机与 PC 打开注册页
- **WHEN** 其填写注册信息
- **THEN** 两端的字段集合 MUST 一致
- **AND** 布局 MUST 按端侧自适应
#### Scenario: 密码强度与掩码
- **GIVEN** 用户在密码输入框输入
- **WHEN** 其提交表单
- **THEN** 输入 MUST 为掩码展示
- **AND** MUST 展示密码强度提示

View File

@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: 提交/替换提现资料资格
代理本人 MUST 能提交或替换提现资料资格,调用 `POST /api/admin/shops/{shop_id}/withdrawal-qualifications`,请求体包含 `subject_type``subject_code``legal_person_id_card` 与合同、身份证正反面、营业执照、门头照、发票等 `*_file_key`,以及 `invoice_title``invoice_subject_code`;附件 MUST 先通过对象存储上传接口取得 `file_key` 再提交。
#### Scenario: 提交资格成功
- **GIVEN** 代理本人已登录并选好主体类型
- **WHEN** 其上传资料并提交
- **THEN** 前端 MUST 先取得全部附件 `file_key`
- **AND** 再调用提交接口并在成功后刷新资格列表
#### Scenario: 替换资格使旧资格失效
- **GIVEN** 已存在通过审批的资格
- **WHEN** 代理提交合同或法人身份证变更
- **THEN** 旧资格 MUST 立即失效
- **AND** 新资格 MUST 重新审批通过后方可提现
### Requirement: 查询提现资料资格
代理本人 MUST 能分页查询提现资料资格,调用 `GET /api/admin/shops/{shop_id}/withdrawal-qualifications``page` / `page_size` / `status`);响应 MUST 展示脱敏的 `subject_code_masked``legal_person_id_card_masked`,并展示 `status``approval_status``invalid_reason``invalidated_at` 与各附件 `file_key` 预览。
#### Scenario: 查看资格版本与审批状态
- **GIVEN** 代理本人打开提现资料资格列表
- **WHEN** 接口返回资格记录
- **THEN** 列表 MUST 展示脱敏证件号与审批状态
- **AND** 证件号 MUST NOT 以明文展示
### Requirement: 作废提现资料资格
超级管理员 MUST 能作废提现资料资格,调用 `POST /api/admin/withdrawal-qualifications/{id}/void``reason` 必填。
#### Scenario: 作废资格须填原因
- **GIVEN** 超级管理员打开作废弹窗
- **WHEN** 其未填写原因直接提交
- **THEN** 前端 MUST 阻止提交并提示填写原因
#### Scenario: 超管作废资格
- **GIVEN** 超级管理员选择一条资格
- **WHEN** 其填写原因并确认作废
- **THEN** 系统 MUST 调用作废接口并在成功后刷新列表
### Requirement: 重提被驳回的提现
代理本人 MUST 能重提被驳回的提现,调用 `PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}`,请求体包含 `account_name``account_number``amount``withdrawal_method``invoice_keys`;成功响应返回新提现单(`withdrawal_no``actual_amount``fee``fee_rate``status`)。
#### Scenario: 重提被驳回的提现
- **GIVEN** 代理本人的提现申请被驳回
- **WHEN** 其修改信息并重新提交
- **THEN** 系统 MUST 调用重提接口
- **AND** 成功后 MUST 刷新提现记录并展示新单号
#### Scenario: 非驳回状态不可重提
- **GIVEN** 提现申请不是被驳回状态
- **WHEN** 代理尝试重提
- **THEN** 重提入口 MUST 不可用或不展示
### Requirement: 提现申请详情
代理本人 MUST 能查看提现申请详情,调用 `GET /api/admin/shops/{shop_id}/withdrawal-requests/{id}`;响应包含 `reject_reason``anomaly_flag``anomaly_name``anomaly_reason``attempts` 尝试记录,前端 MUST 展示这些字段。
#### Scenario: 查看提现详情
- **GIVEN** 代理本人点击提现记录
- **WHEN** 详情返回
- **THEN** 页面 MUST 展示提现金额、实际到账、手续费、状态
- **AND** 展示驳回原因、异常标记与尝试记录

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 店铺数据返回分销码
店铺列表、详情与更新接口响应 MUST 包含 `distribution_code` 字段,前端类型 MUST 覆盖该字段并在列表/详情中展示。
#### Scenario: 店铺详情展示分销码
- **GIVEN** 用户打开店铺详情页
- **WHEN** 店铺存在 `distribution_code`
- **THEN** 页面 MUST 展示分销码
- **AND** MUST 以该码生成注册入口二维码
#### Scenario: 分销码为空
- **GIVEN** 店铺尚未分配分销码
- **WHEN** 页面展示店铺信息
- **THEN** 分销码与二维码区域 MUST 展示空态(`-` 或「未分配」)

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 企微审批场景业务类型扩展
企微审批场景的 `business_type` MUST 支持以下枚举:`refund_approval`(退款审批)、`offline_recharge_approval`(员工线下代充值审批)、`employee_collection_approval`(员工代收款核销审批)、`agent_distribution_approval`(代理扫码分销注册审批)、`withdrawal_qualification_approval`(提现资料资格审批)、`commission_withdrawal_approval`(佣金提现终审)。前端类型与场景配置页选项 MUST 与后端枚举一致;`GET /api/admin/wecom/scenes/{business_type}/fields` 允许查询上述任一类型。
#### Scenario: 场景配置页可选新业务类型
- **GIVEN** 超级管理员打开企微审批场景配置页
- **WHEN** 其新建场景并选择业务类型
- **THEN** 下拉选项 MUST 包含全部六个业务类型
- **AND** 新增三个业务类型 MUST 与后端枚举一致
#### Scenario: 查询新增场景业务字段
- **GIVEN** 已选择 `agent_distribution_approval``withdrawal_qualification_approval``commission_withdrawal_approval`
- **WHEN** 页面加载业务字段
- **THEN** 前端 MUST 调用对应 `business_type` 的字段查询接口

View File

@@ -0,0 +1,29 @@
## 1. 提案与 API 层
- [x] 1.1 新增 `src/api/modules/agentDistribution.ts``sendCode()``registerAgent()`,公开请求不带 Token
- [x] 1.2 扩展 `src/types/api/shop.ts``ShopResponse` 增加 `distribution_code`
- [x] 1.3 扩展 `src/types/api/commission.ts`:提现资料资格提交/查询、作废、重提、详情的请求与响应类型
- [x] 1.4 扩展 `src/api/modules/commission.ts``submitWithdrawalQualification` / `getWithdrawalQualifications` / `voidWithdrawalQualification` / `resubmitWithdrawalRequest` / `getWithdrawalRequestDetail`
- [x] 1.5 扩展 `src/types/api/wecom.ts``WecomBusinessType` 增加三个业务类型
- [x] 1.6 汇总导出新增类型
## 2. 企微审批场景
- [x] 2.1 场景配置页新增三个业务类型选项(`agent_distribution_approval` / `withdrawal_qualification_approval` / `commission_withdrawal_approval`
## 3. 独立注册页
- [x] 3.1 `staticRoutes.ts` 新增 `/agent-registration` 静态路由
- [x] 3.2 `LOGIN_WHITE_LIST` 增加注册页路径
- [x] 3.3 新增 `src/views/agent-registration/index.vue`表单、验证码倒计时、密码强度、省市区级联、提交与结束页、H5/PC 响应式
## 4. 店铺详情
- [x] 4.1 店铺详情页展示 `distribution_code` 与注册二维码
## 5. 我的佣金(代理侧)
- [x] 5.1 新增「提现资料资格」Tab资格列表脱敏展示+ 提交/替换弹窗(附件走上传接口)
- [x] 5.2 提现记录新增「详情」抽屉:展示明细、驳回原因、异常标记与尝试记录
- [x] 5.3 被驳回记录新增「重新提交」弹窗PUT 重提)
- [x] 5.4 超管可见「作废资格」操作reason 必填)
## 6. 验证
- [x] 6.1 运行 `npm run build`(含 `vue-tsc --noEmit`
- [x] 6.2 运行 `npm run check:encoding`
- [x] 6.3 运行 `openspec.cmd validate add-agent-distribution-registration-and-withdrawal --strict`

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
# Change: 业务用户组管理与店铺批量交接/导入AUG26-003
## Why
后台需要把平台用户账号按业务线归入"业务用户组",并基于组对店铺做筛选、批量交接负责人与 CSV 导入负责人。当前前端只有单个店铺设置平台业务员的能力,缺少业务用户组维护、成员归属、按组筛选、批量交接与导入入口;且现有 11 个新端点仅超管与平台账号可访问。
## What Changes
- 新增业务用户组管理模块API 层 + 维护页面 + 成员管理):
- GET/POST /api/admin/business-user-groups、GET/PUT/DELETE /api/admin/business-user-groups/{id}
- PUT /api/admin/business-user-groups/{id}/members、DELETE /api/admin/business-user-groups/members
- 编码创建后不可改;删除需 {"confirm": true} 且仅无成员组可删更新支持部分字段business_line 三态(缺省=不改 / ""=清空 / 枚举=设置)。
- 成员归属整批校验(全部启用平台用户 + 目标组启用),任一无效整批不生效;一账号至多一组。
- 店铺列表/详情读取侧扩展:
- GET /api/admin/shops 新增 business_user_group_id / business_line / ungrouped 筛选。
- 列表/详情响应新增 5 个只读推导字段(组 ID/编码/名称/启用/业务线);组停用仍返回组且 enabled=false不算未分组前端不缓存、不回写改组后重新拉列表。
- 勾选批量交接:
- PUT /api/admin/shops/business-owner/batchshop_ids1-500去重+ business_owner_account_id字段缺失=400null=清空ID=换绑;任一店铺无效或目标非启用业务员 -> 整批不写入,统一按 1005 提示。
- CSV 导入:
- 复用 StorageService.getUploadUrl + 预签名 PUT 直传FilePurpose 增加 shop_import
- POST /api/admin/shops/business-owner-imports 创建异步任务,轮询详情、展示行级结果;前端自带模板(表头 店铺编码,操作类型,业务员登录账号,备注,换绑/清空)。
- 入口与权限(代理/企业不渲染入口):
- 路由 meta roles ['R_SUPER', 'R_ADMIN'];按钮权限码:
- business_user_group:page / business_user_group:create / business_user_group:update / business_user_group:delete / business_user_group:members
- shop:business_owner_batch
- shop:business_owner_import
## Not In Scope
- 不实现后端接口、不改数据库。
- 业务用户组不承载角色权限/数据范围语义;前端不引入组层级与组管理员概念。
- 导入结果不支持导出,仅在页面展示行级明细。
## Impact
- Affected specs: business-user-group-management新增、shop-management扩展
- Affected code:
- src/types/api/businessUserGroup.ts新增
- src/api/modules/businessUserGroup.ts新增
- src/types/api/shop.ts、src/api/modules/shop.ts
- src/api/modules/storage.tsFilePurpose 增加 shop_import
- src/views/shop-management/business-user-groups/index.vue新增页面
- src/views/shop-management/list/index.vue
- src/router/routes/asyncRoutes.ts、src/router/routesAlias.ts、src/locales/langs/{zh,en}.json
- src/template/业务负责人导入模板.csv前端自带模板
- API contracts: 11 个端点7 个业务用户组 + 店铺筛选/字段 + 批量交接 + 导入三步)
- Dependencies:
- docs/产品迭代8月份/业务用户组.md7 个组端点 OpenAPI
- AUG26-003 前端对接说明(店铺侧端点)
## 待确认
- 成员选择器复用 GET /api/admin/shops/business-owner-candidates启用平台账号若组成员可包含非业务员平台账号需后端补充平台账号列表端点。
- 导入任务与行级结果字段名file_key、status、items[] 等)以生成文档为准,提案按对接说明先行对齐。

View File

@@ -0,0 +1,73 @@
## ADDED Requirements
### Requirement: 业务用户组维护
后台 MUST 提供业务用户组列表、详情、创建、更新与删除能力GET/POST /api/admin/business-user-groups、GET/PUT/DELETE /api/admin/business-user-groups/{id})。列表 MUST 支持 page/page_size/enabled/keyword/business_line 筛选,且每项 MUST 返回 business_line_name。创建时 code1-64与 name 必填编码未删除组内唯一且创建后不可修改business_line 可空standard/smart/othersort/enabled/remark 可选。更新 MUST 仅允许名称、业务线、排序、启停与备注,且 business_line 支持三态:字段缺失不修改、传 "" 清空、传枚举设置;删除 MUST 携带 {"confirm": true},仅无成员组可删除。
#### Scenario: 查询业务用户组列表
- **GIVEN** 用户进入业务用户组维护页
- **WHEN** 前端以 page/page_size/enabled/keyword/business_line 发起列表查询
- **THEN** 前端 MUST 展示分页结果且每项显示 business_line_name
- **AND** 下拉数据源复用该列表接口
#### Scenario: 创建时编码不可变
- **GIVEN** 用户填写名称与稳定编码创建用户组
- **WHEN** 提交成功或编码重复被后端拒绝
- **THEN** 编码在创建后 MUST NOT 出现在编辑表单中
- **AND** 编码重复时 MUST 展示后端返回的"业务用户组编码已存在"
#### Scenario: 业务线三态更新
- **GIVEN** 用户编辑某组的业务线
- **WHEN** 表单传 ""、传枚举或省略该字段
- **THEN** 前端 MUST 分别按"清空 / 设置 / 不修改"构造请求体
#### Scenario: 删除需二次确认
- **GIVEN** 用户点击删除某组
- **WHEN** 确认弹窗提交
- **THEN** 前端 MUST 携带 {"confirm": true}
- **AND** 有成员时 MUST 提示"用户组仍有成员,只能停用或先移走成员"且不做物理删除
- **AND** 无成员时删除成功并刷新列表
### Requirement: 成员归属批量维护
后台 MUST 支持按平台用户账号把成员批量设置进组PUT /api/admin/business-user-groups/{id}/members与批量清空DELETE /api/admin/business-user-groups/members携带 body account_ids。两种操作 MUST 复用同一校验:所有账号必须是启用平台用户且目标组启用,任一账号无效则整批不生效;清空后账号回到未分组;一账号至多一组。
#### Scenario: 批量设置成员
- **GIVEN** 用户在成员管理中选择多个启用平台账号
- **WHEN** 保存成员归属
- **THEN** 前端 MUST 以 account_ids 数组整体替换每个账号原归属
- **AND** 刷新后成员关系与最新分组一致
#### Scenario: 批量清空成员
- **GIVEN** 用户选择若干账号执行清空
- **WHEN** 提交清空
- **THEN** 前端 MUST 调用 DELETE /api/admin/business-user-groups/members 并携带 account_ids body
- **AND** 清空成功的账号回到未分组
#### Scenario: 停用组的成员展示
- **GIVEN** 某组被停用但仍有成员
- **WHEN** 用户在成员管理或店铺列表看到该组
- **THEN** 前端 MUST 展示"已停用"标记并提供改组/清空入口
### Requirement: 访问控制与入口
业务用户组维护入口 MUST 仅对超级管理员与平台账号可见,代理/企业账号 MUST NOT 渲染入口;所有按钮 MUST 受权限码控制。
#### Scenario: 菜单按角色隐藏
- **GIVEN** 代理或企业账号登录
- **WHEN** 系统渲染菜单
- **THEN** MUST NOT 展示业务用户组菜单项
#### Scenario: 按钮权限控制
- **GIVEN** 平台账号具备部分业务用户组权限
- **WHEN** 渲染操作按钮
- **THEN** 前端 MUST 按 business_user_group:create/update/delete/members 控制显隐

View File

@@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: 店铺列表/详情业务用户组字段与筛选
店铺列表查询 MUST 支持 business_user_group_id、business_line、ungrouped 三个新筛选;列表与详情响应 MUST 提供 5 个只读推导字段business_user_group_id可空、business_user_group_code、business_user_group_name、business_user_group_enabled、business_user_group_business_line。组停用仍返回组且 enabled=false不算未分组店铺不存组字段实时推导前端 MUST NOT 缓存或回写,改动组成员后重新拉取列表。
#### Scenario: 按业务用户组筛选
- **GIVEN** 用户在店铺列表选择业务用户组、业务线或勾选"未分组"
- **WHEN** 发起列表查询
- **THEN** 前端 MUST 提交 business_user_group_id/business_line/ungrouped 参数
- **AND** ungrouped=true 表示无负责人或负责人无分组
#### Scenario: 展示组字段
- **GIVEN** 店铺属于某启用或停用的业务用户组
- **WHEN** 渲染列表或详情
- **THEN** 前端 MUST 展示组名称/编码/业务线
- **AND** 组停用时 MUST 标记"已停用"且仍展示组归属
### Requirement: 勾选批量交接平台业务员
店铺列表 MUST 支持勾选店铺批量交接平台业务员PUT /api/admin/shops/business-owner/batchbody shop_ids 1-500 去重 + business_owner_account_id。字段缺失 MUST 按参数错误处理400传 null 表示清空负责人;传 ID 表示换绑。任一店铺不存在/已删除/越权或目标非启用业务员时整批不写入,前端 MUST 统一提示"无权限操作该资源或资源不存在";成功响应含 batch_key/shop_count/cleared。
#### Scenario: 批量换绑
- **GIVEN** 用户勾选 1-500 家店铺并选择启用业务员
- **WHEN** 提交批量交接
- **THEN** 前端 MUST 提交 shop_ids去重与 business_owner_account_id
- **AND** 成功后展示 shop_count 并刷新列表
#### Scenario: 批量清空负责人
- **GIVEN** 用户勾选店铺并选择"清空负责人"
- **WHEN** 提交批量交接
- **THEN** 前端 MUST 显式提交 business_owner_account_id: null
- **AND** MUST NOT 省略该字段
#### Scenario: 整批失败统一提示
- **GIVEN** 批量交接请求因任一店铺或目标账号不满足条件而被后端拒绝
- **WHEN** 前端收到 1005
- **THEN** 前端 MUST 展示统一文案且不写入任何店铺
### Requirement: 业务负责人 CSV 导入
店铺列表 MUST 支持通过 CSV 导入批量交接/清空业务负责人。流程 MUST 为:前端自带模板(表头 店铺编码,操作类型,业务员登录账号,备注,操作类型仅"换绑"或"清空"-> POST /api/admin/storage/upload-urlpurpose=shop_import拿预签名 URL 直传15 分钟有效)-> POST /api/admin/shops/business-owner-importsfile_key创建异步任务 -> 轮询 GET /api/admin/shops/business-owner-imports/{id} 展示结果。行级失败不等于任务失败:任务 status=3 也可能有失败行MUST 按 items[] 展示明细;表头/编码不符为任务级失败items 为空数组,只展示 error_message。
#### Scenario: 下载模板并上传
- **GIVEN** 用户进入导入界面
- **WHEN** 点击下载模板或选择文件
- **THEN** 前端 MUST 提供表头完全一致的模板文件
- **AND** 上传后 MUST 以 purpose=shop_import 获取上传地址并直传
#### Scenario: 创建任务并轮询
- **GIVEN** 文件上传成功拿到 file_key
- **WHEN** 提交创建导入任务
- **THEN** 前端 MUST 携带 file_key 调用创建接口
- **AND** MUST 轮询任务详情直到 status 为已完成(3)或失败(4)
#### Scenario: 展示行级失败明细
- **GIVEN** 任务完成但存在失败行
- **WHEN** 前端渲染结果
- **THEN** 前端 MUST 展示 total_count/success_count/fail_count
- **AND** MUST 按 items[] 展示各行的 line/shop_code/operation_type/status/reason
- **AND** 失败行保留原负责人
#### Scenario: 任务级失败提示
- **GIVEN** 表头或编码不符导致任务级失败
- **WHEN** 前端渲染结果
- **THEN** 前端 MUST 只展示 error_message 且明细为空

View File

@@ -0,0 +1,34 @@
## 1. API Contract 层
- [x] 1.1 新增 src/types/api/businessUserGroup.ts组项/分页/创建/更新/删除/成员设置与结果类型
- [x] 1.2 新增 src/api/modules/businessUserGroup.ts封装 7 个组端点DELETE 带 body需请求层支持
- [x] 1.3 扩展 src/types/api/shop.ts列表筛选 business_user_group_id/business_line/ungrouped响应只读组字段批量交接与导入任务/行级结果类型
- [x] 1.4 扩展 src/api/modules/shop.tsbatchUpdateBusinessOwner、createBusinessOwnerImport、getBusinessOwnerImportDetail、getBusinessOwnerImportList
- [x] 1.5 扩展 src/api/modules/storage.tsFilePurpose 增加 shop_import
## 2. 业务用户组页面
- [x] 2.1 新增 src/views/shop-management/business-user-groups/index.vue列表筛选/分页/启停/业务线)
- [x] 2.2 新建/编辑弹窗code 创建后不可改、business_line 三态、名称/排序/备注/启停
- [x] 2.3 删除二次确认confirm=true有成员时按后端提示处理
- [x] 2.4 成员管理(对话框/抽屉):成员选择器(复用 business-owner-candidates+ 批量设置 + 批量清空;停用组保留成员并标记"已停用"
## 3. 店铺列表扩展
- [x] 3.1 列表搜索区新增业务用户组下拉与业务线筛选、ungrouped 勾选(数据源来自组列表接口)
- [x] 3.2 列表/详情展示组字段(停用组标记,不缓存不回写)
- [x] 3.3 批量交接弹窗:勾选店铺 + 选择业务员/清空(显式 null整批失败统一 1005 文案
- [x] 3.4 导入入口:模板下载、文件校验/上传purpose=shop_import、创建任务、轮询并展示行级明细
- [x] 3.5 导入任务列表(状态筛选)
## 4. 路由/菜单/权限/i18n
- [x] 4.1 新增 /shop-management/business-user-groups 路由与菜单roles ['R_SUPER', 'R_ADMIN']
- [x] 4.2 按钮权限编码落地business_user_group*/shop:business_owner_batch/shop:business_owner_import
- [x] 4.3 中英文案 menus.shopManagement.businessUserGroups 等
## 5. 验证
- [x] 5.1 运行 npm run build含 vue-tsc --noEmit
- [x] 5.2 运行 npm run check:encoding
- [x] 5.3 运行 openspec validate add-business-user-group-management --strict

View File

@@ -0,0 +1,60 @@
# Change: 退款佣金回扣改为独立回溯明细(前端适配)
## Why
后端调整了退款佣金回扣口径:退款不再把原佣金整单置为「已失效」,而是原佣金行保持「已发放」不变,另新增一条独立负数、不可提现的「回溯明细」行。对应前端接口说明(简短版)要求:
- 佣金记录列表新增 `status` 查询参数(含 5 回溯)与行级 `source` 字段(`original` / `clawback`)。
- 新增佣金记录详情接口 `GET /api/admin/shops/{shop_id}/commission-records/{id}?source=clawback`
- 新增导出场景 `scene=commission_record`(后端已进白名单)。
- 两个必改点:列表行 key 必须用 `source + ':' + id`(原佣金与回溯 ID 空间独立,同一页会重复);渲染需支持负数金额(标红)与「不可提现」标识。
前端当前实现基于旧语义(`ShopCommissionRecordItem``source`,列表行 key 直接用 `id`,退款后原行被视为已失效),需按新口径适配。
## What Changes
- 类型层:`ShopCommissionRecordItem` 新增 `source``original_commission_id``refund_id``refund_no``withdrawable``clawback_records``clawback_total_amount`;新增回溯摘要与详情类型;`CommissionRecordQueryParams.status` 支持 5回溯
- API 层:新增 `getShopCommissionRecordDetail(shopId, id, source?)``source` 省略时按原佣金处理。
- 列表页(代理侧「我的佣金」与管理侧「代理资金概览 > 佣金明细」):行 key 改为 `source + ':' + id`;回溯行展示「回溯」状态与「不可提现」标识;负数金额与可为负的 `balance_after` 标红;`status` 筛选项新增「回溯」;详情入口必须携带该行 `source`
- 详情展示:原佣金详情展示 `clawback_records` 摘要与 `clawback_total_amount`;回溯详情展示来源 `original_commission`;越权/不存在统一提示「佣金明细不存在」,不做存在性判断。
- 导出:`ExportTaskScene` 增加 `commission_record`;新增导出佣金记录场景页、路由、菜单与 i18n佣金记录列表导出入口只提交受支持的筛选 key`shop_id` / `status` / `commission_source` / `order_no`)。
- 字段兜底:后端新增字段为 `omitempty`,前端取 `?? []` / `?? 0`,金额单位为分。
## Not In Scope
- 不实现后端接口、不改数据库、不改审批实例数据。
- 既有字段无改名/改类型;`commission-stats``commission-daily-stats` 口径未变,本次不动。
- 时间筛选属另一 Change`add-export-time-filter-standards`),本次不涉及。
- 语义提醒:退款不再写 `status=4`,旧假设「退款后原行变已失效」已失效,需在文案/注释体现。
## Impact
- Affected specs: `commission-management``export-task-management`
- Affected code:
- `src/types/api/commission.ts`
- `src/api/modules/commission.ts`
- `src/views/commission-management/my-commission/index.vue`
- `src/views/commission-management/agent-fund-overview/index.vue`
- `src/types/api/exportTask.ts`
- `src/config/constants/exportTask.ts`
- `src/views/asset-management/export-task-management/export-commission-record/index.vue`
- `src/router/routes/asyncRoutes.ts``src/router/routesAlias.ts``src/locales/langs/{zh,en}.json`
- Dependencies:
- `docs/产品迭代8月份/frontend-api-simple.md`(后端契约)
- 后端已把 `scene=commission_record` 加入导出场景白名单
- 需确认导出场景权限编码与场景页是否必需(见 Open Questions
- Breaking changes:
- 列表行 key 由 `id` 改为 `source + ':' + id`;未携带 `source` 的详情请求会命中原佣金行。
## 接口确认结果(来自 OpenAPI
- 详情响应 `DtoShopCommissionRecordDetailResp``source``record``DtoShopCommissionRecordItem`)、`clawback_records`(仅原佣金返回)、`original_commission`(仅回溯详情返回)。
- `DtoShopCommissionRecordItem` 新增 `source``released_at``withdrawable`booleannullable仅回溯行返回且恒 false`original_commission_id``refund_id``refund_no``clawback_records``clawback_total_amount`
- `DtoShopCommissionClawbackItem``id``amount`(恒负)、`balance_after`(可为负)、`original_commission_id``refund_id``refund_no``status`5 回溯)、`status_name``withdrawable``created_at`
- 详情接口越权与不存在返回同一结果,前端统一提示「佣金明细不存在」。
- 已确认需要新增「导出佣金记录」菜单页。
## 待确认(已按仓库既有约定实现,如需调整请告知)
1. 导出场景权限编码:按既有约定实现为 `export_task:commission_record_detail` / `export_task:commission_record_download`
2. 佣金记录列表导出按钮权限:按 `agent_wallet_transaction:export` 的同级约定实现为 `commission_record:export`

View File

@@ -0,0 +1,48 @@
## ADDED Requirements
### Requirement: 佣金记录列表回溯行与状态筛选
佣金记录列表(`GET /api/admin/shops/{shop_id}/commission-records`MUST 支持 `status` 查询参数1 已冻结 / 2 解冻中 / 3 已发放 / 4 已失效 / 5 回溯 / 99 待人工修正),且 MUST NOT 提供 `source` 查询参数;`status=5` 即等价于只看回溯行。
#### Scenario: 只看回溯行
- **GIVEN** 用户在佣金记录列表选择状态「回溯」
- **WHEN** 前端发起列表查询
- **THEN** 前端 MUST 只传 `status=5`
- **AND** 前端 MUST NOT 传 `source` 参数
### Requirement: 佣金记录行新增字段与兜底
列表响应的每一行 MUST 支持 `source``original` / `clawback`);回溯行 MUST 支持 `original_commission_id``refund_id``refund_no``withdrawable`(恒 `false`)与可为负数的 `amount``balance_after`;原佣金行 MUST 支持 `clawback_records` 摘要与 `clawback_total_amount`(负值)。后端 `omitempty` 缺省字段前端 MUST 以 `?? []` / `?? 0` 兜底,金额单位为分。
#### Scenario: 缺省字段兜底
- **GIVEN** 列表响应中的原佣金行未返回 `clawback_records`
- **WHEN** 前端渲染该行
- **THEN** 前端 MUST 按空数组处理并正常渲染
- **AND** 前端 MUST NOT 因缺省字段报错或渲染 `undefined`
### Requirement: 列表行标识与负数渲染
列表 MUST 使用 `source + ':' + id` 作为行 key原佣金与回溯 ID 空间独立,同一页可能出现相同 `id`);回溯行 MUST 展示「不可提现」标识;负数 `amount``balance_after` MUST 标红显示;任何跳转详情的入口 MUST 携带该行的 `source`,缺省按原佣金处理。
#### Scenario: 同一页出现重复 id
- **GIVEN** 同一页列表中同时存在原佣金行与回溯行且 `id` 相同
- **WHEN** 前端渲染表格
- **THEN** 前端 MUST 以 `source + ':' + id` 区分两行
- **AND** 前端 MUST NOT 出现行复用或渲染错位
#### Scenario: 负数金额标红
- **GIVEN** 某回溯行 `amount` 为负值
- **WHEN** 前端渲染金额列
- **THEN** 前端 MUST 标红展示该负值
### Requirement: 佣金记录详情接口封装
前端 MUST 提供 `GET /api/admin/shops/{shop_id}/commission-records/{id}` 的详情封装,并支持可选 `source``source=clawback` 时返回回溯详情,`source` 省略时按原佣金处理。原佣金详情 MUST 返回 `clawback_records`;回溯详情 MUST 返回 `original_commission`;越权与不存在 MUST 返回同一结果(佣金明细不存在),前端 MUST NOT 用该接口做存在性判断。
#### Scenario: 打开回溯行详情
- **GIVEN** 用户在列表点击某条回溯行
- **WHEN** 前端请求详情
- **THEN** 前端 MUST 携带 `source=clawback` 与该行 `id`
- **AND** 详情 MUST 展示来源 `original_commission`
#### Scenario: 越权统一提示
- **GIVEN** 详情接口返回「佣金明细不存在」
- **WHEN** 前端渲染详情
- **THEN** 前端 MUST 展示统一不存在提示
- **AND** 前端 MUST NOT 区分越权与不存在两种原因

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 佣金记录导出场景
前端 MUST 支持导出场景 `scene=commission_record`(后端已进白名单),并在导出管理中提供该场景的任务列表入口;创建导出任务时 MUST 提交固定 `scene=commission_record`。导出粒度为佣金记录:原佣金与回溯各占一行,金额与余额同样按分转元、负数带 `-` 展示。
#### Scenario: 场景页固定查询
- **GIVEN** 用户打开导出佣金记录页面
- **WHEN** 页面查询导出任务列表
- **THEN** 前端 MUST 调用 `GET /api/admin/export-tasks` 且带 `scene=commission_record`
- **AND** 用户 MUST NOT 能在该页面切换到其他场景
### Requirement: 佣金记录导出筛选受限
佣金记录列表发起导出时,`query` MUST 只包含后端支持的筛选 key`shop_id``status``commission_source``order_no`MUST NOT 提交 `iccid``virtual_no` 及时间筛选,导出弹窗 MUST NOT 直接复用列表全部条件。
#### Scenario: 列表筛选含不支持的 key
- **GIVEN** 佣金记录列表当前筛选包含 `iccid` 与时间范围
- **WHEN** 用户确认创建导出任务
- **THEN** 提交的 `query` MUST NOT 包含 `iccid``virtual_no` 或时间字段
- **AND** 提交的 `query` MUST 仅保留 `shop_id``status``commission_source``order_no` 中已填写的项

View File

@@ -0,0 +1,27 @@
## 1. 类型与 API 层commission-api
- [x] 1.1 `CommissionStatus` 补充 5回溯修正 `CommissionRecordQueryParams.status` 过期注释并支持回溯值
- [x] 1.2 `ShopCommissionRecordItem` 新增 `source``original_commission_id``refund_id``refund_no``withdrawable``clawback_records``clawback_total_amount`
- [x] 1.3 新增回溯摘要与佣金记录详情类型(原佣金详情含 `clawback_records`,回溯详情含 `original_commission`
- [x] 1.4 `CommissionService` 新增 `getShopCommissionRecordDetail(shopId, id, source?)`
## 2. 列表页commission-records-list
- [x] 2.1 行 key 改为 `source + ':' + id``my-commission``agent-fund-overview` 佣金明细)
- [x] 2.2 回溯行展示「回溯」状态与「不可提现」标识
- [x] 2.3 负数金额与可为负的 `balance_after` 标红渲染
- [x] 2.4 `status` 筛选项新增「回溯」
- [x] 2.5 列表新增详情入口,且请求必须携带该行 `source`
## 3. 详情展示commission-record-detail
- [x] 3.1 原佣金详情展示 `clawback_records` 摘要与 `clawback_total_amount`
- [x] 3.2 回溯详情展示来源 `original_commission`
- [x] 3.3 越权/不存在统一提示「佣金明细不存在」,不作为存在性判断依据
## 4. 导出commission-record-export
- [x] 4.1 `ExportTaskScene` 增加 `commission_record` 并补场景配置
- [x] 4.2 新增导出佣金记录场景页、路由、菜单与中英文案
- [x] 4.3 佣金记录列表新增导出入口,`query` 仅提交 `shop_id` / `status` / `commission_source` / `order_no`
## 5. 验证
- [x] 5.1 运行 `npm run build`(含 `vue-tsc --noEmit`
- [x] 5.2 运行 `npm run check:encoding`
- [x] 5.3 运行 `openspec.cmd validate add-commission-clawback-records --strict`

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`,确保类型与编码通过。
- [x] 7.2 校验普通员工与超管的菜单、列与操作可见性差异。 — 已核验:应用/账单/收款方式页 v-permission 按钮、bills 关闭操作 hasAuth(billClose)+canCloseBill、表单 isActing=isSuperAdmin 控制代办字段
- [x] 7.3 校验金额分/元转换、附件预签名展示与 503 错误兜底。 — 已核验ApplicationFormDialog 用 fenToYuan/yuanToFendetail.vue 用 PaymentVoucherDialog 预览凭证getErrorMessage 提取后端 msg含 503 未配置场景)兜底

View File

@@ -0,0 +1,38 @@
# Design: H5 运营弹窗配置管理(管理后台)
## 页面结构
- 设置管理下新增「H5运营弹窗配置」入口仅超管/平台):
- 列表页 `src/views/settings/h5-popup-configuration/index.vue`
- 创建/编辑表单弹窗(复用 ArtForm + ArtSearchBar/ArtTable 模式)
- 详情以弹窗/抽屉展示完整配置
- 分页、筛选、表格沿用 ArtTable / ArtSearchBar / ArtTableHeader 与统一响应结构(`code/msg/timestamp/data`、分页 `items/page/size/total`)。
## 接口与类型
- 新增 `src/api/modules/h5PopupConfiguration.ts``src/types/api/h5PopupConfiguration.ts`
- `GET /api/admin/h5-popup-configurations`page/page_size/enabled倒序分页
- `POST /api/admin/h5-popup-configurations`(创建)
- `GET /api/admin/h5-popup-configurations/{id}`(详情)
- `PUT /api/admin/h5-popup-configurations/{id}`(更新,整表提交,版本 +1
- `POST /api/admin/h5-popup-configurations/{id}/enable``{id}/disable`(请求体 `{ id }`,返回更新后配置)
- 枚举映射常量集中放置:`pages`home/asset_detail/package_purchase/asset_wallet_recharge`frequency`once/daily`action_type`package_purchase/asset_wallet_recharge空字符串 = 无)、`card_types`CMCC/CUCC/CTCC/CBN
## 表单约定
- `pages` 多选必填(空数组非法);`title`1100/`content`12000/`frequency`/起止时间必填。
- 范围三选器(店铺/设备类型/卡类型)空数组 = 全量,回显「全部」。
- 起止时间用 datetimerange提交转 ISO 8601`starts_at`/`ends_at``ends_at` 不得早于 `starts_at`
- `priority` 数字输入01000000`enabled` 开关;`action_type` 下拉(含「无」选项,提交空字符串清除受控动作)。
- 编辑表单整表提交;编辑保存成功后提示「每次更新版本递增,旧版本通知保留原快照,新版本可向原命中客户按频率重新投放一次」。
- 启用/停用为行操作(停用需二次确认),以接口返回的更新后配置刷新当前行。
## 权限与可见性
- 权限码:`h5_popup_configuration:list`(菜单/列表)、`:create``:update``:enable``:disable`
- 路由 `meta.permissions` + 按钮 `v-permission`/`usePermission`,仅超管/平台可见;代理/企业/个人由后端 403 兜底并原文透传失败文案。
- 不提供删除入口(文档无 DELETE 接口)。
## 待确认
- 权限编码以后端菜单配置为准(前端默认 `h5_popup_configuration:list/create/update/enable/disable`)。

View File

@@ -0,0 +1,46 @@
# Change: 新增 H5 运营弹窗配置管理(管理后台)
## Why
H5 端(个人客户)需要在首页、资产详情、套餐购买、资产钱包充值等页面投放运营/风险弹窗,弹窗文案、命中页面、投放范围与生效规则需要后台配置。当前后台没有任何弹窗配置入口,运营只能走数据库操作。
本 Change 为管理后台新增「H5 运营弹窗配置」管理能力:配置标题与正文、命中页面、投放范围(店铺/设备类型/卡类型)、优先级、投放频率、生效时间与启停。
H5 端契约(`GET /api/c/v1/popup-candidates``POST /api/c/v1/risk-exchanges/{asset_id}/address`、通知列表/未读/已读接口)不在本 Change 范围,需求方已明确不处理 C 端。
## What Changes
- 新增 6 个后台接口(仅超管/平台,代理/企业/个人 403
- `GET /api/admin/h5-popup-configurations`:配置列表,`page`110000/ `page_size`1100/ `enabled` 筛选,按最近更新时间倒序分页返回 `items/page/size/total`
- `POST /api/admin/h5-popup-configurations`:创建;`title`/`content`/`pages`/`frequency`/`starts_at`/`ends_at` 必填;`priority`/`enabled`/`action_type`/`shop_ids`/`device_types`/`card_types` 可选(范围集合空数组 = 全量)。
- `GET /api/admin/h5-popup-configurations/{id}`:详情(含 `version``frequency_text``enabled_text``creator`/`updater`)。
- `PUT /api/admin/h5-popup-configurations/{id}`:更新;成功即版本 +1新版本可向原命中客户按频率重新投放一次旧版本已投放通知的内容与快照不被改写`id` 外全部字段可选,**不传保持原值**`action_type` 传空字符串 = 清除受控动作;`shop_ids`/`device_types`/`card_types` 传空数组 = 改为全量;`pages` 传空数组非法(页面必选);`enabled` 不传保持原值(启停刷新最近更新时间);`ends_at` 不得早于 `starts_at``content` 12000 字符、`title` 1100 字符、`priority` 01000000。
- `POST /api/admin/h5-popup-configurations/{id}/enable`:启用,参与候选匹配,刷新 `updated_at`(影响同优先级排序),返回更新后的配置。
- `POST /api/admin/h5-popup-configurations/{id}/disable`:停用,停止新投放,历史通知在展示期内仍可见;刷新 `updated_at`,返回更新后的配置。
- 新增「H5 运营弹窗配置」管理页(设置管理下,仅超管/平台可见):分页列表 + 创建/编辑表单 + 详情 + 行操作启用/停用。
- 前端契约约定:
- 范围集合 `shop_ids` / `device_types` / `card_types` 空数组 = 全量,列表与编辑回显「全部」。
- 枚举:`pages` = home / asset_detail / package_purchase / asset_wallet_recharge`frequency` = once / daily`action_type` = package_purchase / asset_wallet_recharge空字符串 = 无受控动作);`card_types` = CMCC / CUCC / CTCC / CBN。
- 无「删除」接口,页面不提供删除入口;停用不清数据,列表保留历史配置。
- 编辑表单整表提交(所有字段都传,未传语义仅在部分更新时生效);范围清空传空数组、动作清除传空字符串、页面集合不可为空。
- 权限编码由前端确定:`h5_popup_configuration:list` / `:create` / `:update` / `:enable` / `:disable`
## Impact
- Affected specs:
- `h5-popup-configuration-management`
- Affected code:
- `src/api/modules/h5PopupConfiguration.ts`(新增)
- `src/types/api/h5PopupConfiguration.ts`(新增)
- `src/api/modules/index.ts``src/types/api/index.ts`
- `src/config/constants/`(权限码常量)
- `src/router/routesAlias.ts``src/router/routes/asyncRoutes.ts`(设置管理下新增菜单与路由)
- `src/locales/langs/zh.json``src/locales/langs/en.json`
- `src/views/settings/h5-popup-configuration/`(列表、表单弹窗、详情)
- Dependencies:
- 后端按 `docs/产品迭代8月份/通知.md` 提供上述 6 接口Bearer JWT 鉴权,代理/企业/个人 403。
- Breaking changes:
- 无;全部为新增页面、接口模块与类型。
- 待确认:
- 权限编码(前端默认 `h5_popup_configuration:list/create/update/enable/disable`)以后端菜单配置为准。
- 列表分页上限与「按最近更新时间倒序」以文档为准,联调核对后端行为。

View File

@@ -0,0 +1,117 @@
## ADDED Requirements
### Requirement: H5 运营弹窗配置列表
后台 MUST 提供 H5 运营弹窗配置的分页列表GET /api/admin/h5-popup-configurations支持 `page`110000/ `page_size`1100/ `enabled` 筛选,按最近更新时间倒序返回 `items/page/size/total`;列表项 MUST 包含标题、命中页面、范围(店铺/设备类型/卡类型)、优先级、频率(`frequency_text`)、启停(`enabled_text`)、受控动作、生效起止时间、版本、创建/更新人与时间。
#### Scenario: 按启停状态筛选并分页
- **GIVEN** 超管/平台账号进入「H5运营弹窗配置」列表
- **WHEN** 以 page/page_size/enabled 发起查询
- **THEN** 前端 MUST 展示分页结果(后端按最近更新时间倒序)
- **AND** 切换 enabled 筛选后按新条件重新请求
#### Scenario: 范围空数组显示全部
- **GIVEN** 某条配置的 shop_ids/device_types/card_types 为空数组
- **THEN** 列表 MUST 在范围列展示「全部」
#### Scenario: 越权访问
- **GIVEN** 代理/企业/个人账号访问列表接口或直达路由
- **THEN** 后端 MUST 返回 403前端 MUST NOT 渲染入口
- **AND** 失败文案 MUST 按后端返回原文透传
### Requirement: 创建弹窗配置
后台 MUST 支持创建 H5 运营弹窗配置POST /api/admin/h5-popup-configurations`title`/`content`/`pages`/`frequency`/`starts_at`/`ends_at` 必填;`priority`/`enabled`/`action_type`/`shop_ids`/`device_types`/`card_types` 可选,范围集合空数组 = 全量。
#### Scenario: 必填校验
- **GIVEN** 用户提交创建表单
- **WHEN** title/content/pages/frequency/starts_at/ends_at 任一缺失
- **THEN** 前端 MUST 拦截并提示必填MUST NOT 发送请求
#### Scenario: 范围集合为空数组表示全量
- **GIVEN** 用户未选择店铺/设备类型/卡类型范围
- **THEN** 请求体对应数组 MUST 传空数组,后端按全量投放处理
### Requirement: 弹窗配置详情
后台 MUST 提供弹窗配置详情GET /api/admin/h5-popup-configurations/{id}),返回完整配置含 `version``frequency_text``enabled_text``creator`/`updater`
#### Scenario: 查看详情
- **GIVEN** 用户点击列表行「详情」
- **WHEN** 请求详情成功
- **THEN** 前端 MUST 展示完整配置要素(含版本与启停/频率中文名)
### Requirement: 更新弹窗配置
后台 MUST 支持更新弹窗配置PUT /api/admin/h5-popup-configurations/{id}),成功即版本 +1新版本可向原命中客户按频率重新投放一次旧版本已投放通知的内容与快照不被改写。除 `id` 外所有字段均可选,**不传保持原值**`action_type` 传空字符串 = 清除受控动作;`shop_ids`/`device_types`/`card_types` 传空数组 = 改为全量;`pages` 传空数组非法(页面必选);`enabled` 不传保持原值(启停刷新最近更新时间);`ends_at` 不得早于 `starts_at``content` 12000 字符、`title` 1100 字符、`priority` 01000000。
#### Scenario: 更新成功版本递增并重新投放
- **GIVEN** 用户保存编辑
- **WHEN** 更新成功
- **THEN** 前端 MUST 提示「每次更新版本递增,旧版本通知保留原快照,新版本可向原命中客户按频率重新投放一次」并刷新列表
- **AND** 列表/详情版本号较更新前 +1旧版本已投放通知内容不被改写
#### Scenario: 范围清空表示改为全量
- **GIVEN** 编辑时清空某范围集合
- **THEN** 提交对应数组 MUST 为空数组,后端按全量处理
#### Scenario: 受控动作清除
- **GIVEN** 编辑时把 action_type 从 package_purchase 切换为「无」
- **THEN** 请求体 action_type MUST 传空字符串,后端清除受控动作
#### Scenario: 页面集合不得为空
- **GIVEN** 编辑时清空全部命中页面
- **THEN** 前端 MUST 拦截提示命中页面必选MUST NOT 传空 pages 数组
#### Scenario: 未传字段保持原值
- **GIVEN** 请求体省略某字段(如 enabled
- **THEN** 后端 MUST 保持原值;前端编辑表单整表提交,范围清空按空数组、动作清除按空字符串处理
### Requirement: 启用与停用弹窗配置
后台 MUST 支持启用/停用弹窗配置POST /api/admin/h5-popup-configurations/{id}/enable、/{id}/disable请求体 `{ id }`,返回更新后的配置),停用停止新投放且历史通知在展示期内仍可见,启用参与候选匹配;两者均刷新最近更新时间(影响同优先级排序)且不递增版本。
#### Scenario: 停用需二次确认
- **GIVEN** 用户点击某启用的配置「停用」
- **WHEN** 二次确认提交
- **THEN** 前端 MUST 调用 disable 并提示「停用后停止新投放,历史通知在展示期内仍可见」
- **AND** 以返回的更新后配置刷新当前行(状态为停用、最近更新时间更新、版本不变)
#### Scenario: 启用
- **GIVEN** 用户点击某停用的配置「启用」
- **THEN** 前端 MUST 调用 enable 并提示「启用后参与候选匹配」
- **AND** 以返回的更新后配置刷新当前行(状态为启用、最近更新时间更新、版本不变)
### Requirement: 角色权限与交互约定
弹窗配置入口 MUST 仅对超级管理员与平台账号可见;前端权限码固定为 `h5_popup_configuration:list`(菜单/列表)、`:create``:update``:enable``:disable`;页面 MUST NOT 提供删除入口(文档无 DELETE 接口);停用不清数据,列表保留历史配置。
#### Scenario: 入口按角色隐藏
- **GIVEN** 代理/企业/个人账号登录
- **WHEN** 系统渲染菜单
- **THEN** MUST NOT 展示「H5运营弹窗配置」菜单项
#### Scenario: 按钮权限码控制
- **GIVEN** 账号具备列表权限但缺 enable/disable 权限
- **THEN** 前端 MUST 只渲染具备权限的操作按钮
#### Scenario: 不提供删除入口
- **GIVEN** 用户查看列表或详情
- **THEN** 页面 MUST NOT 出现删除操作,仅提供启用/停用

View File

@@ -0,0 +1,43 @@
## 1. Contract Confirmation
- [x] 1.1 权限编码前端确定:`h5_popup_configuration:list` / `:create` / `:update` / `:enable` / `:disable`(以后端菜单配置为准)。
- [x] 1.2 更新接口语义按文档:不传保持原值;`action_type` 空串 = 清除;范围空数组 = 改全量;`pages` 空数组非法;`enabled` 不传保持原值;`ends_at` 不得早于 `starts_at``content` 12000、`title` 1100、`priority` 01000000更新成功版本 +1 且旧版本通知不被改写。
- [x] 1.3 `enable`/`disable` 请求体 `{ id }`,返回更新后的 DtoH5PopupConfigurationResponse。
- [x] 1.4 列表 `page` 110000、`page_size` 1100、`enabled` 筛选,按最近更新时间倒序分页。
## 2. API And Types
- [ ] 2.1 新增 `src/api/modules/h5PopupConfiguration.ts`:列表、创建、详情、更新、启用、停用 6 个接口。
- [ ] 2.2 新增 `src/types/api/h5PopupConfiguration.ts`:请求/响应 DTO含 pages/frequency/action_type/card_types 枚举、范围集合、version、creator/updater 等;更新语义按「不传保持原值/空数组=全量/空串=清除」注释)。
- [ ] 2.3 在 `src/api/modules/index.ts``src/types/api/index.ts` 导出新模块/类型。
- [ ] 2.4 对齐统一响应 `code/msg/timestamp/data` 与分页 `items/page/size/total`
## 3. List Page
- [ ] 3.1 设置管理下新增「H5运营弹窗配置」菜单与路由仅超管/平台可见,权限码 `h5_popup_configuration:list`)。
- [ ] 3.2 列表:`page`/`page_size`/`enabled` 筛选,分页展示,按后端倒序。
- [ ] 3.3 列:标题、命中页面、范围(店铺/设备类型/卡类型,空数组显示「全部」)、优先级、频率、启停状态、受控动作、生效起止、版本、创建/更新人与时间。
- [ ] 3.4 行操作:详情、编辑、启用/停用(停用需二次确认);无删除入口。
## 4. Create / Edit / Detail
- [ ] 4.1 创建表单:`title`/`content`/`pages` 多选/`frequency`/起止时间必填校验pages 非空);`priority`/`enabled`/`action_type`/范围可选。
- [ ] 4.2 范围集合 `shop_ids`/`device_types`/`card_types` 空数组 = 全量;编辑读回空数组显示「全部」。
- [ ] 4.3 编辑表单整表提交;保存成功提示「每次更新版本递增,旧版本通知保留原快照,新版本可向原命中客户按频率重新投放一次」。
- [ ] 4.4 编辑支持 `action_type` 清空(「无」→ 空字符串)与 `ends_at` 不早于 `starts_at` 的前端校验。
- [ ] 4.5 详情(弹窗/抽屉)展示完整配置(含 version、frequency_text、enabled_text、creator/updater
## 5. Permissions And UX
- [ ] 5.1 按钮/入口按 `h5_popup_configuration:list/create/update/enable/disable` 接入 `usePermission`/`v-permission`,无权限不渲染。
- [ ] 5.2 固定提示停用「停用后停止新投放历史通知在展示期内仍可见」启用「启用后参与候选匹配」403 文案原文透传。
- [ ] 5.3 表单校验与请求失败提示稳定,不破坏列表渲染。
## 6. Verification
- [ ] 6.1 `vue-tsc --noEmit` 与新增文件 eslint 通过。
- [ ] 6.2 列表筛选/分页/倒序展示正确。
- [ ] 6.3 创建必填校验、范围全量语义、编辑版本 +1 提示正确。
- [ ] 6.4 启用/停用流程与提示正确;停用不清数据;启停不递增版本、刷新最近更新时间。
- [ ] 6.5 代理/企业/个人看不到入口;直达路由 403 文案透传。
- [ ] 6.6 联调:编辑整表提交后版本 +1、启停返回完整配置并刷新行`ends_at` 早于 `starts_at` 被后端拒绝。

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,51 @@
# Design: 套餐真流量预警前端对接
## 背景
后端已按 `docs/产品迭代8月份/套餐真流量预警_API_前端简版.md` 在测试环境上线 6 个接口released。前端需要新增规则配置与预警记录两个页面并接入既有权限与导出任务体系。本设计记录关键决策。
## 决策
### 1. 页面挂载位置:套餐管理分组
规则数据源是套餐商品(`package_id`),预警记录也按套餐筛选,因此两个页面都挂在既有 `/package-management` 路由分组下,与套餐列表、代理系列授权平级。
- 路由:`/package-management/traffic-alert-rules``/package-management/traffic-alerts`
- 详情:`/package-management/traffic-alerts/detail/:id`(隐藏路由)
### 2. 导出走专用接口 + 专用弹窗,不复用 ExportTaskCreateDialog
既有 `ExportTaskCreateDialog` 调用通用 `POST /api/admin/export-tasks`,而真流量预警导出是专用端点 `POST /api/admin/package-traffic-alerts/export`,请求体为 `format` + 与列表一致的一组筛选参数。因此:
- 新建轻量弹窗(或扩展 `ExportTaskCreateDialog` 支持自定义 submit展示“基于当前筛选条件全量导出不仅导出当前分页数据”
- 提交成功后提示“导出任务已创建”,并提供跳转既有导出任务列表页的入口,下载走既有能力
- 导出任务列表若按场景过滤,需要新增场景配置;场景枚举值联调时与后端确认后落入 `EXPORT_TASK_SCENE_CONFIG`
### 3. 权限模型
- 遵循八月迭代约定:权限编码集中定义在 `AUGUST_PERMISSIONS.packageTrafficAlert`,页面/按钮通过 `v-permission` + `useAuth().hasAuth` 引用
- 页面级:`rules_view` / `records_view` 控制菜单与按钮可见性(后端菜单权限同源)
- 按钮级:规则创建/修改(含启停)、记录详情、导出分别独立编码
- 403 策略:接口对无权限账号统一返回 403列表接口 403 时提示“无权限访问”;详情越权按资源不可见处理(复用现有 404 类提示文案),不暴露资源存在性
### 4. 阈值输入校验
- 前端 `ElInputNumber``min=1``max=100``precision=2`
- 创建时 `threshold_percent` 必填;修改时三字段均可选(后端按传入字段更新)
- `remark` 创建/修改均限制 500 字符(`maxlength` + 计数器)
### 5. 快照与归属变化展示
- 列表与详情的业务字段均为触发时快照,仅 `business_user_group_names` 为当前值
- 详情中 `shop_changed_since_trigger` / `owner_changed_since_trigger``true` 时,用 `ElAlert`info提示归属已变化并展示当前店铺/业务员与快照值
- 空值约定:无店铺/业务员时字段可能为 `null` 或空数组,统一渲染 `-`(业务员数组 join 展示,空数组显示 `-`
### 6. 规则列表的 real_data_mb 展示
- `real_data_mb` 为套餐商品当前真流量额度,仅用于配置校验展示(如“按 80% 约对应 x GB”不作为预警分母
- 页面以 GB 展示(`/1024`,保留两位小数),避免 MB 数字过长
## 风险
- 记录列表/详情接口字段名未完整给出,联调时以测试环境实际返回为准,类型定义需保留一定弹性(可选字段)
- 导出任务场景值未给出,若后端未登记场景枚举,导出任务列表页的场景筛选需兼容新值

View File

@@ -0,0 +1,93 @@
# Change: 新增套餐真流量预警(规则配置 + 预警记录)
## Why
根据 `docs/产品迭代8月份/套餐真流量预警_API_前端简版.md`,后端已在测试环境(`https://cmp-api.boss160.cn`)实现套餐真流量预警能力:套餐商品的真流量使用量达到配置阈值后生成预警记录,并通知对应业务员。共 6 个接口,仅超级管理员/平台账号可访问,其他账号返回 403通用响应为 `code / data / msg / timestamp`
当前前端(套餐管理模块)缺少:
1. 真流量预警规则配置入口:查看、创建、修改规则(阈值 1100 允许两位小数、启停、备注),以及同一套餐商品仅一条规则的限制提示。
2. 预警记录查看:支持套餐、店铺、业务员、资产类型、资产关键词、阈值、触发时间、通知状态 8 项筛选的列表,触发时快照详情(含归属是否变化提示),以及复用既有异步导出任务体系的记录导出。
此变更完成上述 6 个接口的前端对接。
## What Changes
### 1. API 层
- **新增**: `src/api/modules/packageTrafficAlert.ts``PackageTrafficAlertService`,继承 BaseService
- `getAlertRules``GET /api/admin/package-traffic-alert-rules``package_id``enabled``page``page_size`
- `createAlertRule``POST /api/admin/package-traffic-alert-rules``package_id``threshold_percent``enabled``remark`
- `updateAlertRule``PUT /api/admin/package-traffic-alert-rules/{id}``threshold_percent``enabled``remark`,均为可选)
- `getAlertRecords``GET /api/admin/package-traffic-alerts`8 项筛选 + 分页)
- `getAlertRecordDetail``GET /api/admin/package-traffic-alerts/{id}`
- `exportAlertRecords``POST /api/admin/package-traffic-alerts/export``format` + 与列表一致的筛选参数)
- **修改**: `src/api/modules/index.ts` — 导出 `PackageTrafficAlertService`
### 2. 类型定义
- **新增**: `src/types/api/packageTrafficAlert.ts` — 规则/记录/导出请求响应类型,字段与接口文档保持 snake_case
- **修改**: `src/types/api/index.ts` — 导出新类型
### 3. 常量与权限
- **修改**: `src/config/constants/augustIteration.ts``AUGUST_PERMISSIONS` 新增 `packageTrafficAlert` 权限组
- `trafficAlertRules: 'package_traffic_alert:rules_view'`
- `trafficAlertRuleCreate: 'package_traffic_alert:rule_create'`
- `trafficAlertRuleUpdate: 'package_traffic_alert:rule_update'`
- `trafficAlertRecords: 'package_traffic_alert:records_view'`
- `trafficAlertRecordDetail: 'package_traffic_alert:record_detail'`
- `trafficAlertExport: 'package_traffic_alert:export'`
- **新增/修改**: 通知状态枚举常量1 已通知 / 2 待投递 / 3 投递失败 / 4 未通知(接收人已失效)/ 5 未通知(无有效业务员))及对应 tag 类型映射
- 若既有导出任务列表需要区分本场景,补充对应导出场景配置(场景值联调时与后端确认)
### 4. 页面
**预警规则页** `src/views/package-management/traffic-alert-rules/index.vue`
- 列表:套餐名称、当前真流量额度(`real_data_mb`,按 GB 展示,仅作参考、不作为预警分母)、阈值百分比、启用状态、备注、更新时间
- 筛选:套餐(`package_id`)、启用状态(`enabled`,不传查全部);分页默认 20、最大 100
- 新增/编辑弹窗套餐选择器、阈值1100两位小数、启用开关、备注最多 500 字符)
- 约束:同一套餐商品最多一条规则,后端拒绝重复创建时前端展示错误信息
- 修改规则不影响既有预警快照;停用后扫描不再创建新预警(页面文案说明)
**预警记录页** `src/views/package-management/traffic-alerts/index.vue`
- 筛选套餐、店铺、业务员、资产类型、资产关键词、阈值两位小数、触发时间范围RFC3339、通知状态
- 列表:除 `business_user_group_names` 外均为触发时快照;`notification_status` 按枚举展示
- 详情:展示快照字段;`shop_changed_since_trigger` / `owner_changed_since_trigger` 为真时提示“触发后店铺/业务员归属已变化”;`null` 或空数组统一显示 `-`
- 导出弹窗选择格式xlsx/csv携带当前筛选条件调用专用导出接口创建异步任务创建成功后提示到既有“导出任务列表”下载
- 越权查询详情:统一按资源不可见处理(与不存在资源一致的提示)
### 5. 路由与菜单
- **修改**: `src/router/routesAlias.ts` — 新增 `TrafficAlertRules``TrafficAlerts``TrafficAlertDetail` 别名
- **修改**: `src/router/routes/asyncRoutes.ts` — 套餐管理分组下新增两个子路由(记录详情用隐藏路由)
- **修改**: `src/locales/langs/zh.json` / `src/locales/langs/en.json``menus.packageManagement` 新增 `trafficAlertRules``trafficAlerts``trafficAlertDetail`
## Impact
### 受影响的规范
- `package-traffic-alert` — 新增能力
### 受影响的代码
- `src/api/modules/packageTrafficAlert.ts`(新增)、`src/api/modules/index.ts`
- `src/types/api/packageTrafficAlert.ts`(新增)、`src/types/api/index.ts`
- `src/config/constants/augustIteration.ts`、导出场景/通知状态相关常量
- `src/views/package-management/traffic-alert-rules/index.vue`(新增)
- `src/views/package-management/traffic-alerts/index.vue``detail.vue`(新增)
- `src/router/routesAlias.ts``src/router/routes/asyncRoutes.ts`
- `src/locales/langs/zh.json``src/locales/langs/en.json`
### 依赖关系
- 依赖后端 6 个接口在测试环境可用(文档标记 released
- 复用既有基础设施:`BaseService`/request 封装、`useAuth` + `v-permission``PackageSelector`、店铺/业务员选择组件、既有导出任务列表下载能力
### 注意事项
- 文档声明 6 个接口,但简版仅详细给出 4 个(规则列表/创建/修改 + 记录导出);预警记录列表与详情两个接口以“前端注意事项”的筛选项、快照字段与导出筛选字段为准,字段名在联调时与测试环境核对
- 全部接口对非超级管理员/平台账号返回 403菜单可见性由后端菜单权限控制前端对 403 做友好提示,详情越权按资源不可见处理
- 无破坏性变更新增页面、API 模块、类型、权限编码均为增量

View File

@@ -0,0 +1,132 @@
# Package Traffic Alert Specification
## ADDED Requirements
### Requirement: 套餐真流量预警规则列表查询
系统 SHALL 提供套餐真流量预警规则列表查询页面,展示规则与套餐商品关联信息,支持按套餐商品与启用状态筛选和分页。
#### Scenario: 查询全部规则
- **WHEN** 具备权限的用户(超级管理员/平台账号)访问预警规则页面
- **THEN** 系统调用 `GET /api/admin/package-traffic-alert-rules` 加载规则列表
- **AND** 列表展示套餐名称、当前真流量额度(`real_data_mb`,按 GB 展示)、阈值百分比、启用状态(含中文状态名)、备注、最近更新时间
- **AND** 分页默认每页 20 条,最大 100 条
#### Scenario: 按条件筛选
- **WHEN** 用户选择套餐商品或启用状态进行筛选
- **THEN** 系统携带 `package_id` / `enabled` 查询参数重新加载列表
- **AND** 不传启用状态时查询全部规则
#### Scenario: 无权限访问
- **WHEN** 非超级管理员/平台账号调用规则接口
- **THEN** 接口返回 403
- **AND** 页面展示无权限访问提示,不展示业务数据
### Requirement: 创建套餐真流量预警规则
系统 SHALL 允许管理员为套餐商品创建真流量预警规则,并执行阈值与备注校验;同一套餐商品最多一条规则。
#### Scenario: 成功创建规则
- **WHEN** 用户选择套餐商品填写阈值百分比1100允许两位小数
- **AND** 设置启用状态与备注(最多 500 字符)后提交
- **THEN** 系统调用 `POST /api/admin/package-traffic-alert-rules` 创建规则
- **AND** 成功后刷新列表并展示新规则详情(含套餐名称、当前真流量额度、更新时间)
#### Scenario: 阈值校验
- **WHEN** 用户填写的阈值小于 1、大于 100 或超过两位小数
- **THEN** 前端阻止提交并展示校验错误提示
#### Scenario: 重复规则
- **WHEN** 用户为已存在规则的套餐商品再次创建规则
- **THEN** 后端拒绝创建
- **AND** 前端展示后端返回的错误信息,列表保持原状
### Requirement: 修改套餐真流量预警规则
系统 SHALL 允许管理员修改规则的阈值、启用状态与备注;修改不影响既有预警快照,停用后扫描不再创建新预警。
#### Scenario: 修改规则字段
- **WHEN** 用户打开编辑弹窗并修改阈值、启用状态或备注后提交
- **THEN** 系统调用 `PUT /api/admin/package-traffic-alert-rules/{id}`,仅提交修改的字段
- **AND** 成功后刷新列表展示最新规则
#### Scenario: 停用规则
- **WHEN** 用户关闭规则的启用开关
- **THEN** 系统调用修改接口仅提交 `enabled=false`
- **AND** 页面说明停用后不再产生新预警,既有预警记录保留
#### Scenario: 规则不存在或无权限
- **WHEN** 修改的规则 ID 不存在或用户无权限
- **THEN** 系统按接口错误处理并展示对应提示
### Requirement: 套餐真流量预警记录列表查询
系统 SHALL 提供预警记录列表页面,支持套餐、店铺、业务员、资产类型、资产关键词、阈值、触发时间范围、通知状态 8 项筛选与分页;列表数据除 `business_user_group_names` 外均为触发时快照。
#### Scenario: 查询预警记录
- **WHEN** 具备权限的用户访问预警记录页面
- **THEN** 系统调用 `GET /api/admin/package-traffic-alerts` 加载记录列表
- **AND** 列表展示触发时间、资产信息、套餐、阈值快照、店铺、业务员与通知状态
#### Scenario: 组合筛选
- **WHEN** 用户组合使用任意筛选条件(含两位小数阈值与 RFC3339 触发时间范围)
- **THEN** 系统携带对应查询参数请求列表,返回满足全部条件的记录
#### Scenario: 通知状态展示
- **WHEN** 记录包含 `notification_status`
- **THEN** 系统按枚举展示1 已通知 / 2 待投递 / 3 投递失败 / 4 未通知(接收人已失效)/ 5 未通知(无有效业务员)
#### Scenario: 空值展示
- **WHEN** 记录的店铺或业务员字段为 `null` 或空数组
- **THEN** 系统统一展示 `-`
- **AND** `business_user_group_names` 为当前归属值,其余字段保持触发时快照
### Requirement: 查看预警记录详情
系统 SHALL 允许管理员查看单条预警记录的触发时快照详情,并标识触发后店铺/业务员归属是否发生变化;越权查询按资源不可见处理。
#### Scenario: 查看存在的记录
- **WHEN** 用户点击记录打开详情
- **THEN** 系统调用 `GET /api/admin/package-traffic-alerts/{id}` 展示触发时快照字段
#### Scenario: 归属变化提示
- **WHEN** 详情返回 `shop_changed_since_trigger``owner_changed_since_trigger` 为 true
- **THEN** 系统提示触发后店铺/业务员归属已变化
- **AND** 展示当前归属与触发时快照值的差异
#### Scenario: 越权或不存在
- **WHEN** 用户无权限查看该记录或记录不存在
- **THEN** 系统统一按资源不可见处理
- **AND** 展示与记录不存在一致的提示,不暴露资源存在性
### Requirement: 导出套餐真流量达量预警
系统 SHALL 允许管理员按当前筛选条件创建预警记录异步导出任务;导出接口仅创建任务,文件通过既有导出任务列表下载。
#### Scenario: 创建导出任务
- **WHEN** 用户在预警记录页点击导出并选择格式xlsx/csv
- **THEN** 系统携带 `format` 与当前筛选条件调用 `POST /api/admin/package-traffic-alerts/export`
- **AND** 成功后展示任务信息(`task_id``task_no`、状态)并引导用户到既有导出任务列表下载
#### Scenario: 导出范围说明
- **WHEN** 导出弹窗打开
- **THEN** 系统说明导出基于当前筛选条件全量导出,不仅导出当前分页数据
- **AND** 任务创建时冻结筛选条件、时间范围与可见资产范围,归属列按执行时当前归属补充

View File

@@ -0,0 +1,40 @@
# Tasks: 套餐真流量预警前端对接
## 1. API 与类型
- [x] 1.1 新增 `src/types/api/packageTrafficAlert.ts`:规则列表/规则项、创建/修改参数、预警记录列表/记录项、详情、导出请求/响应类型snake_case对齐接口文档记录字段按前端注意事项预留可选
- [x] 1.2 `src/types/api/index.ts` 导出新类型
- [x] 1.3 新增 `src/api/modules/packageTrafficAlert.ts``getAlertRules` / `createAlertRule` / `updateAlertRule` / `getAlertRecords` / `getAlertRecordDetail` / `exportAlertRecords` 6 个方法
- [x] 1.4 `src/api/modules/index.ts` 导出 `PackageTrafficAlertService`
## 2. 常量与权限
- [x] 2.1 `src/config/constants/augustIteration.ts``AUGUST_PERMISSIONS` 新增 `packageTrafficAlert` 权限组rules_view / rule_create / rule_update / records_view / record_detail / export
- [x] 2.2 新增通知状态常量:枚举 1-5 文案(已通知/待投递/投递失败/未通知(接收人已失效)/未通知(无有效业务员))及 tag 类型映射
- [x] 2.3 若导出任务列表需要场景区分,补充真流量预警导出场景配置(场景值与后端确认后更新 `EXPORT_TASK_SCENE_CONFIG``getExportTaskSceneName`
## 3. 预警规则页
- [x] 3.1 新增 `src/views/package-management/traffic-alert-rules/index.vue`:筛选栏(套餐选择器、启用状态)、列表(套餐名称、真流量额度 GB、阈值、启用状态、备注、更新时间、分页默认 20/最大 100
- [x] 3.2 新增/编辑弹窗:套餐选择器(编辑时不可改)、阈值 ElInputNumber1-100两位小数创建必填、启用开关、备注≤500 字符带计数);创建成功提示同一套餐最多一条规则
- [x] 3.3 启用状态行内开关(调用修改接口,仅传 enabled文案说明“停用后不再产生新预警既有预警保留”
- [x] 3.4 403/错误处理:无权限提示;重复创建等后端错误展示 msg
## 4. 预警记录页
- [x] 4.1 新增 `src/views/package-management/traffic-alerts/index.vue`:筛选栏(套餐、店铺、业务员、资产类型、资产关键词、阈值、触发时间范围、通知状态)、列表(触发时快照字段 + `business_user_group_names` 当前值 + 通知状态 tag、分页
- [x] 4.2 新增 `detail.vue`(或详情抽屉):快照字段展示、`shop_changed_since_trigger` / `owner_changed_since_trigger` 变化提示、空值/空数组显示 `-`、越权按资源不可见处理
- [x] 4.3 导出弹窗格式选择xlsx/csv+“基于当前筛选条件全量导出”说明,调用 `exportAlertRecords` 携带当前筛选,成功后提示并引导跳转导出任务列表下载
## 5. 路由、菜单与国际化
- [x] 5.1 `src/router/routesAlias.ts`:新增 `TrafficAlertRules` / `TrafficAlerts` / `TrafficAlertDetail`
- [x] 5.2 `src/router/routes/asyncRoutes.ts`套餐管理分组下新增规则页、记录页keepAlive与隐藏详情路由
- [x] 5.3 `src/locales/langs/zh.json` / `en.json``menus.packageManagement` 新增 `trafficAlertRules` / `trafficAlerts` / `trafficAlertDetail`
## 6. 联调与验收
- [ ] 6.1 测试环境(`https://cmp-api.boss160.cn`)联调 6 个接口,核对记录列表/详情实际字段名并修正类型
- [ ] 6.2 权限验证:超级管理员/平台账号正常访问;其他账号 403 提示符合预期;详情越权按资源不可见
- [ ] 6.3 边界验证:阈值 0.99/1/100/100.01 校验、备注 500 字符、同套餐重复创建、空店铺/业务员展示 `-`、导出任务创建后可在导出任务列表下载
- [x] 6.4 ESLint / Stylelint / `vue-tsc` 类型检查通过

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,70 @@
# 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`
- 待联调责任方(前端/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,37 @@
# Design: 手机号资产关联管理(管理后台)
## 页面结构
- 资产管理下新增「手机号资产关联」分组:
- 关联列表页(`phone-asset-association/index.vue`
- CSV 解绑导入任务列表页(`unbind-import-tasks/index.vue`
- 解绑导入任务详情页(`unbind-import-tasks/detail.vue`),复用 `device-task` / `iot-card-task` 的逐行结果表格模式。
- 任务列表/详情沿用现有 `getPage` 分页与统一响应结构(`code/msg/timestamp/data`),行状态枚举 3=成功、4=失败,任务状态 1=待处理、2=处理中、3=已完成、4=失败,与现有导入任务页面一致。
## 接口与类型
- 新增 `src/api/modules/phoneAsset.ts``src/types/api/phoneAsset.ts`,集中放置 6 个接口与相关 DTO 类型,便于联调收敛(与 `add-employee-collection` 契约处理方式一致)。
- 既有列表的 `associated_phones` 直接在 `device.ts` / `card.ts` / `asset.ts` 对应响应类型上增加字段,不新增接口。
- `FilePurpose` 增加 `phone_unbind_import`,上传前在业务组件校验扩展名 `.csv`
## 解绑导入流程
- 上传复用 `StorageService.getUploadUrl``purpose='phone_unbind_import'`)→ 预签名 URL PUT 直传 → 用返回的 `file_key` 调创建任务接口。
- CSV 模板由前端提供:表头 `资产标识,备注`UTF-8可带 BOM非 UTF-8 按 GBK 解码(后端解析);无行数上限,前端不做行数限制,仅限制扩展名。
- 任务创建后进入任务列表跟踪,与导出任务交互模式一致。
## 权限与可见性
- 菜单、路由、按钮统一用 `usePermission``isSuperAdmin` / `isPlatformAccount`+ `v-permission` 控制;入口仅超管/平台可见。
- 越权兜底由后端 403 保证,前端将「无权限操作该资源或资源不存在」等固定文案原样透传,不自行拼接。
- 不提供任何创建/补录关联入口。
## 待确认
- `DELETE /phone-asset-associations/{id}``reason` / `confirmed` 字段名与传输位置:该接口文档块只声明路径参数 `id`、未定义请求体,前端当前按 query 参数(`?reason=&confirmed=`)传递;若后端以 JSON body 接收,只需改 `PhoneAssetAssociationService.unbindAssociation`,页面无需调整。
- 新增菜单/按钮权限编码(前端默认 `phone_asset_association:*`)以管理后台权限配置为准。
已确认的契约(文档已提供):
- `GET /phone-asset-associations/unbind-imports`:筛选参数 `page`(默认 1/ `page_size`(默认 20最大 100/ `status`14响应 `items/page/size/total`
- 任务状态 1=待处理、2=处理中、3=已完成、4=失败;逐行状态 3=成功、4=失败。

View File

@@ -0,0 +1,48 @@
# Change: 新增手机号资产关联管理(管理后台)
## Why
H5 短信验证通过后会产生「手机号—资产」关联,运营侧需要治理能力:查询存量关联、单条/批量/按 CSV 导入批量解绑,并在设备、单卡、资产详情等既有场景里直接看到该资产当前关联的手机号。目前管理后台没有任何关联查询与解绑入口,只能走数据库操作。
同时对象存储与导出契约需要同步扩展:上传用途新增 `phone_unbind_import`,卡/设备导出文件表头尾部新增「关联手机号」列。
本 Change 只覆盖管理后台B 端H5 端的 `bind-phone` / `change-phone` / `need_bind_phone` 接口变更不在本 Change 范围,但「上限」与「换绑冲突」两句固定文案由后端统一返回、前端直接展示的能力需要保留。
## What Changes
- 新增“手机号资产关联”能力,仅超管/平台账号可见可操作(代理/企业/个人由后端 403 兜底):
- `GET /api/admin/phone-asset-associations`关联列表支持资产标识ICCID/虚拟号/IMEI/SN/接入号精确匹配、完整手机号精确匹配、状态0 已失效 / 1 有效)、创建时间区间筛选,分页返回。
- `DELETE /api/admin/phone-asset-associations/{id}`:单条解绑,必须传 `reason`(1500) + `confirmed=true`,缺一即拒绝;后端返回本次解除关系数。
- `POST /api/admin/phone-asset-associations/batch-unbind`:按资产集合批量解绑(按 `(asset_type, asset_id)` 去重,逐项独立执行),返回成功数/失败数/逐项结果,部分成功不回滚。
- `POST /api/admin/phone-asset-associations/unbind-imports`:创建 CSV 解绑导入任务(`file_key` + 任务级 `reason` + `confirmed`)。
- `GET /api/admin/phone-asset-associations/unbind-imports`:解绑导入任务列表。
- `GET /api/admin/phone-asset-associations/unbind-imports/{id}`:任务详情,含逐行状态/失败原因与解绑当时完整手机号快照。
- 既有接口字段新增:`GET /api/admin/devices``GET /api/admin/iot-cards/standalone``GET /api/admin/assets/resolve/{identifier}` 响应新增 `associated_phones: string[]`(完整手机号,无关联为空数组)。
- 卡导出、设备导出文件表头尾部新增「关联手机号」列(多号以「、」连接);历史任务重导出仍按旧表头输出,前端导出任务列表/详情无需为此改动。
- `POST /api/admin/storage/upload-url``purpose` 枚举新增 `phone_unbind_import`(仅 .csv目录前缀 `phone-unbind-imports/YYYY/MM/DD/uuid.csv`)。
- 前端自备能力CSV 模板(表头 `资产标识,备注`备注可选UTF-8 可带 BOM非 UTF-8 按 GBK 解码;无行数上限);后端返回的固定失败文案直接展示;后台不提供「创建/补录关联」入口。
## Impact
- Affected specs:
- `phone-asset-association-management`
- Affected code:
- `src/api/modules/phoneAsset.ts`(新增)
- `src/types/api/phoneAsset.ts`(新增)
- `src/api/modules/index.ts``src/types/api/index.ts`
- `src/api/modules/storage.ts``FilePurpose` 增加 `phone_unbind_import`
- `src/api/modules/device.ts``src/api/modules/card.ts``src/api/modules/asset.ts``associated_phones` 字段类型)
- `src/types/api/device.ts``src/types/api/card.ts``src/types/api/asset.ts`
- `src/views/asset-management/phone-asset-association/`(新增列表、解绑导入任务列表/详情页)
- `src/views/asset-management/device-list/index.vue``src/views/asset-management/iot-card-management/*``src/views/asset-management/asset-information/*`(新增「关联手机号」展示)
- `src/router/routesAlias.ts``src/router/routes/asyncRoutes.ts`(菜单与路由)
- 菜单、权限码与国际化文案配置文件
- Dependencies:
- 后端按 `docs/产品迭代8月份/手机号资产关联.md` 提供上述接口、Bearer JWT 鉴权与 403 语义。
- 解绑导入复用现有对象存储上传流程(`StorageService.getUploadUrl` + PUT 直传 + `file_key`)。
- 新增按钮/页面的权限编码以管理后台菜单权限配置为准,前端用 `v-permission``usePermission` 控制可见性。
- Breaking changes:
- 无。全部为新增页面、接口模块、类型字段与枚举扩展。
- 待确认契约(当前文档未完整提供,后端需在联调前明确):
- `DELETE /phone-asset-associations/{id}``reason` / `confirmed` 传输位置(请求体字段名,当前导出文档缺少该请求体定义)。
- `GET /phone-asset-associations/unbind-imports` 任务列表的筛选参数与分页响应结构(当前导出文档未包含该接口块)。

View File

@@ -0,0 +1,177 @@
## ADDED Requirements
### Requirement: Phone-Asset Association List Page
The admin frontend SHALL provide a phone-asset association list page accessible only to super admin and platform accounts, listing phoneasset relationships created via H5 SMS verification.
#### Scenario: Query association list
- **GIVEN** 用户打开「手机号资产关联」列表页
- **WHEN** 用户提交分页、资产标识、手机号、状态或创建时间范围筛选条件
- **THEN** 系统 MUST call `GET /api/admin/phone-asset-associations`
- **AND** 系统 MUST support query parameters `page`, `page_size`, `asset_identifier`, `phone`, `status`, `created_at_start`, and `created_at_end`
- **AND** 系统 MUST parse `data.page`, `data.size`, `data.total`, and `data.items` from the response
- **AND** 资产标识 MUST 支持 ICCID、虚拟号、IMEI、SN 或接入号精确匹配;手机号 MUST 使用完整值精确匹配
#### Scenario: Render association table columns
- **GIVEN** 关联列表接口返回 `items`
- **WHEN** 表格渲染每一行关联
- **THEN** 系统 MUST 展示资产类型/ID/标识(`asset_type``asset_id``asset_identifier`)、完整手机号(`phone`)、建立时间(`established_at`)、建立来源(`source`,中文映射固定为 H5 短信验证)与状态
- **AND** 系统 MUST 用 `status_name` 展示状态中文名称
- **AND** 对已失效关系(`status=0`MUST 展示失效时间、失效方式(`invalidation_method_name`)与失效原因(`invalidation_reason`,可为空)
#### Scenario: Vehicle the 403 semantics
- **GIVEN** 非超管/平台账号访问关联列表或任何解绑接口
- **WHEN** 后端返回 403
- **THEN** 前端 MUST 直接展示后端文案「无权限操作该资源或资源不存在」
- **AND** 前端 MUST NOT 区分越权、资产不存在与已无有效关系(后端明确不形成可枚举差异)
### Requirement: Single Association Unbind
The admin frontend SHALL support unbinding a single phoneasset association with a mandatory reason and confirmation.
#### Scenario: Unbind single association with reason and confirmation
- **GIVEN** 用户在关联列表选择一条有效关联执行解绑
- **WHEN** 用户填写解除原因1500 字符)并勾选二次确认后提交
- **THEN** 系统 MUST call `DELETE /api/admin/phone-asset-associations/{id}` with the association id in the path
- **AND** 请求 MUST 携带 `reason``confirmed=true`,二者缺一即不发请求(前端表单校验兜底,后端兜底拒绝)
- **AND** 系统 MUST 展示响应中的解除结果,包括失效时间与本次解除的有效关系数(`unbound_count`
#### Scenario: Refuse without reason or confirmation
- **GIVEN** 用户未填写原因或未勾选二次确认
- **WHEN** 用户点击确认解绑
- **THEN** 前端 MUST 阻止提交并提示原因必填/需二次确认
- **AND** 没有任何关系被解除的承诺 MUST 与后端说明一致,前端不调用接口
### Requirement: Batch Asset Unbind
The admin frontend SHALL support unbinding all current valid associations of multiple assets in one request with per-item results.
#### Scenario: Batch unbind selected assets
- **GIVEN** 用户勾选多个资产(列表或他处选择的资产集合)执行批量解绑
- **WHEN** 用户填写解除原因并二次确认后提交
- **THEN** 系统 MUST call `POST /api/admin/phone-asset-associations/batch-unbind`
- **AND** 请求体 MUST 包含 `assets`(每项 `asset_type`+`asset_id`)、`confirmed=true``reason`
- **AND** 请求前前端 MUST 按 `(asset_type, asset_id)` 去重并限制数量(后端上限 200前端同步校验
- **AND** 系统 MUST 解析 `success_count``fail_count` 与逐项结果 `items`(每项含 `asset_id``asset_type``success`、失败 `reason``unbound_count`
- **AND** 部分成功 MUST 不回滚成功项,前端 MUST 按逐项结果展示成功/失败明细
#### Scenario: Show failure copy for failed items
- **GIVEN** 批量解绑存在失败项(越权、资产不存在或已无有效关系)
- **WHEN** 逐项结果返回失败
- **THEN** 前端 MUST 直接展示后端返回的失败文案
- **AND** 失败项 MUST 可被定位到对应资产标识与类型
### Requirement: CSV Unbind Import Task
The admin frontend SHALL support creating, listing, and viewing phone-asset unbind import tasks driven by an uploaded CSV.
#### Scenario: Provide CSV template download
- **GIVEN** 用户进入「CSV 解绑导入」入口
- **WHEN** 用户需要模板
- **THEN** 系统 MUST 提供可下载的 CSV 模板,表头固定为 `资产标识,备注`,备注列为可选
- **AND** 模板 MUST 以 UTF-8 编码(可带 BOM
#### Scenario: Upload CSV and create unbind import task
- **GIVEN** 用户选择 CSV 文件UTF-8 可带 BOM非 UTF-8 按 GBK 解码;无行数上限)
- **WHEN** 用户填写任务级解绑原因并二次确认后创建任务
- **THEN** 系统 MUST 先调用 `POST /api/admin/storage/upload-url``purpose=phone_unbind_import`,仅接受 `.csv`
- **AND** 系统 MUST 使用预签名 URL 完成 PUT 直传,取回 `file_key`
- **AND** 系统 MUST call `POST /api/admin/phone-asset-associations/unbind-imports`,请求体包含 `file_key`、任务级 `reason``confirmed=true`
- **AND** 系统 MUST 展示创建返回的任务编号、状态1 待处理 / 2 处理中 / 3 已完成 / 4 失败)与中文状态名
#### Scenario: List unbind import tasks
- **GIVEN** 用户打开「解绑导入任务」列表
- **WHEN** 用户按分页/状态等条件查询
- **THEN** 系统 MUST call `GET /api/admin/phone-asset-associations/unbind-imports`
- **AND** 列表 MUST 展示任务编号、文件名、状态、成功/失败行数、总数、创建人、创建/开始/完成时间与任务级解绑原因
#### Scenario: View unbind import task detail with row results
- **GIVEN** 用户在任务列表点击某个任务
- **WHEN** 任务详情加载
- **THEN** 系统 MUST call `GET /api/admin/phone-asset-associations/unbind-imports/{id}`
- **AND** 详情 MUST 展示任务级信息(含 `unbind_reason``error_message` 与任务统计)
- **AND** 逐行结果 MUST 展示行号(`line`,自数据首行起计)、`asset_identifier` 原文、定位到的资产类型/ID未定位时为空/0、行状态3 成功 / 4 失败)与失败原因
- **AND** 成功行 MUST 展示解绑当时完整关联手机号快照(`associated_phones`)与该行解除的有效关系数(`unbound_count`
- **AND** 任务级失败时 `items` 为空数组,前端 MUST 展示任务的 `error_message`
### Requirement: Associated Phones Display on Existing Lists
The admin frontend SHALL display the current associated phone numbers of an asset on existing device, standalone IoT card, and asset resolve surfaces.
#### Scenario: Show associated phones on device list
- **GIVEN** `GET /api/admin/devices` 响应包含 `associated_phones`
- **WHEN** 设备列表渲染
- **THEN** 系统 MUST 展示「关联手机号」列,值为完整手机号数组(无关联显示为空/「-」)
#### Scenario: Show associated phones on standalone IoT card list
- **GIVEN** `GET /api/admin/iot-cards/standalone` 响应包含 `associated_phones`
- **WHEN** 单卡列表渲染
- **THEN** 系统 MUST 展示「关联手机号」列,多号以「、」分隔
#### Scenario: Show associated phones on asset resolve detail
- **GIVEN** `GET /api/admin/assets/resolve/{identifier}` 响应包含 `associated_phones`
- **WHEN** 资产详情渲染
- **THEN** 系统 MUST 展示「关联手机号」字段,无关联显示为空/「-」
### Requirement: Storage and Export Contract Extension
The admin frontend SHALL extend the upload-purpose enum and align with the export column change for card and device export files.
#### Scenario: Upload URL for phone unbind imports
- **GIVEN** 需要上传解绑导入 CSV
- **WHEN** 调用 `POST /api/admin/storage/upload-url`
- **THEN** 请求 MUST 使用 `purpose=phone_unbind_import`
- **AND** `FilePurpose` 类型 MUST 增加 `phone_unbind_import`
- **AND** 上传目录前缀为 `phone-unbind-imports/YYYY/MM/DD/uuid.csv`,前端不感知前缀,仅约束扩展名为 `.csv`
#### Scenario: Export files gain associated phone column
- **GIVEN** 卡导出/设备导出任务完成并生成文件
- **WHEN** 用户下载导出文件
- **THEN** 文件表头尾部 MUST 包含「关联手机号」列(多号以「、」连接)
- **AND** 前端导出任务列表/详情页 MUST NOT 为此改动调用任何新接口
- **AND** 历史任务重导出仍按旧表头输出,前端不需要兼容性处理
### Requirement: Role-Based Entry Visibility and No Creation Entry
The admin frontend SHALL gate all new phone-asset association pages and actions to super admin/platform accounts, and MUST NOT offer any create or supplement entry.
#### Scenario: Hide entries for non-platform roles
- **GIVEN** 当前用户为代理/企业/个人账号
- **WHEN** 渲染菜单与页面按钮
- **THEN** 导航菜单、解绑按钮与 CSV 导入入口 MUST 不渲染
- **AND** 若有越权直达路由,后端 403 文案 MUST 原样展示
#### Scenario: No create/supplement entry
- **GIVEN** 用户打开「手机号资产关联」相关页面
- **WHEN** 页面可用操作被枚举
- **THEN** 页面 MUST NOT 提供创建或补录关联的入口
- **AND** 关联只能由 H5 短信验证产生,前端默认不展示任何新增表单
### Requirement: Unify Backend-Returned Error Copy
The admin frontend SHALL render backend-provided fixed error copy verbatim without composing its own messages.
#### Scenario: Display fixed failure copy
- **GIVEN** 解绑相关接口返回失败
- **WHEN** 失败文案为约定固定文案之一
- **THEN** 前端 MUST 直接在弹窗/错误提示中展示「无权限操作该资源或资源不存在」
- **AND** 对后端可能返回的「该手机号最多关联10项有效资产」「新手机号已存在与待迁移资产相同的有效关联换绑已回滚」MUST 仅作透传展示,不在前端拼接或改写

View File

@@ -0,0 +1,66 @@
## 1. Contract Confirmation
- [x] 1.1 以 `docs/产品迭代8月份/手机号资产关联.md` 为准:该接口只定义路径参数 `id`、无请求体,故 `reason` / `confirmed` 按 query 参数传递,不再另行确认。
- [x] 1.2 确认 `GET /phone-asset-associations/unbind-imports` 任务列表的筛选参数(分页/状态/时间?)与分页响应结构。
- [x] 1.3 确认新增菜单、页面的权限编码(建议 `phone_asset:list``phone_asset:unbind``phone_asset:unbind_import`,以后端菜单配置为准)。
- [x] 1.4 确认关联列表页菜单位置与路由归属(资产管理下新增分组或独立入口)。
## 2. API And Types
- [x] 2.1 新增 `src/api/modules/phoneAsset.ts`:关联列表、单条解绑、批量解绑、创建解绑导入任务、导入任务列表、导入任务详情。
- [x] 2.2 新增 `src/types/api/phoneAsset.ts`:关联列表项/分页响应、单条解绑响应、批量解绑请求/逐项结果、导入任务列表项/详情/逐行结果(含 `associated_phones` 快照、行状态 3/4、任务状态 1-4
- [x] 2.3 对齐统一响应结构 `code/msg/timestamp/data` 与分页结构 `page/size/total/items`
- [x] 2.4 `src/api/modules/storage.ts``FilePurpose` 增加 `phone_unbind_import`,上传前校验扩展名为 `.csv`
- [x] 2.5 在 `src/api/modules/index.ts``src/types/api/index.ts` 导出新模块/类型。
- [x] 2.6 为 `GET /api/admin/devices``GET /api/admin/iot-cards/standalone``GET /api/admin/assets/resolve/{identifier}` 的响应类型增加 `associated_phones: string[]`(无关联为空数组)。
## 3. Association List Page
- [x] 3.1 在资产管理下新增「手机号资产关联」页面/路由/菜单(仅超管/平台可见)。
- [x] 3.2 筛选区资产标识精确、完整手机号精确、状态0 已失效 / 1 有效)、创建时间区间;分页 `page/page_size`(默认 20最大 100
- [x] 3.3 表格列:资产类型/ID/标识、完整手机号、建立时间、建立来源(固定 H5 短信验证)、状态;已失效行展示失效时间/失效方式/失效原因。
- [x] 3.4 提供模板下载与 CSV 导入入口(见第 5 节),列表行操作提供单条解绑按钮。
## 4. Unbind Operations
- [x] 4.1 单条解绑弹窗必填原因1500带长度校验+ 勾选二次确认,缺一禁用提交;提交调用 `DELETE /phone-asset-associations/{id}`
- [x] 4.2 批量解绑弹窗:支持勾选资产集合,前端按 `(asset_type, asset_id)` 去重、上限 200 校验;请求体 `assets` + `reason` + `confirmed=true`
- [x] 4.3 批量结果展示:成功数/失败数 + 逐项明细(资产标识/类型/成功与否/失败原因/解除关系数),部分成功不回滚;失败文案直接展示后端返回。
- [x] 4.4 解绑按钮仅超管/平台可见,`v-permission` 接入403 文案透传展示。
## 5. CSV Unbind Import Tasks
- [x] 5.1 提供 CSV 模板下载:表头 `资产标识,备注`备注可选UTF-8可带 BOM
- [x] 5.2 上传流程:`StorageService.getUploadUrl({ purpose: 'phone_unbind_import' })` → PUT 直传 → `file_key` → 创建任务(`confirmed` + 任务级 `reason`)。
- [x] 5.3 创建任务页/弹窗展示文件解析约定UTF-8 可带 BOM非 UTF-8 按 GBK 解码,无行数上限),提交后跳转/提示任务编号与初始状态。
- [x] 5.4 「解绑导入任务」列表页:分页 + 状态筛选,展示任务编号、文件名、状态、成功/失败行数、总数、创建人、时间与任务级 `unbind_reason`
- [x] 5.5 任务详情页:任务级信息(含 `error_message`、统计)+ 逐行表格(行号、资产标识原文、定位资产类型/ID、行状态 3/4、失败原因、`associated_phones` 快照、`unbound_count`);任务级失败时展示 `error_message` 且行表为空。
- [x] 5.6 任务列表/详情仅超管/平台可见,按钮与入口受权限控制。
## 6. Existing Module Modification
- [x] 6.1 设备列表页新增「关联手机号」列(`associated_phones`,无关联显示「-」)。
- [x] 6.2 IoT 单卡列表页新增「关联手机号」列(多号以「、」连接)。
- [x] 6.3 资产详情(`assets/resolve`)新增「关联手机号」字段展示。
- [x] 6.4 确认卡/设备导出文件新列由后端模板生成,前端导出任务列表/详情无需改动。
## 7. Routing, Permissions And UX
- [x] 7.1 新增路由、路由别名与菜单项,仅超管/平台账号可见。
- [x] 7.2 所有新增页面/按钮接入 `usePermission`/`v-permission`,无权限不渲染,不依赖禁用态。
- [x] 7.3 固定文案透传「无权限操作该资源或资源不存在」「该手机号最多关联10项有效资产」「新手机号已存在与待迁移资产相同的有效关联换绑已回滚」不前端拼接改写。
- [x] 7.4 关联页不提供创建/补录入口。
- [x] 7.5 操作失败提示稳定,不破坏列表渲染;上传/任务创建过程有 loading 与超时兜底。
## 8. Verification
- [x] 8.1 类型检查通过vue-tsc --noEmit即 npm run build 的类型阶段),新增文件 eslint 通过。
- [x] 8.2 关联列表:各筛选维度、分页、状态/失效信息展示正确。 — 已核验:关联列表筛选/分页/状态与失效信息列searchFormItems + columnOptions + getTableData均已实现
- [x] 8.3 单条解绑:缺 reason/confirmed 被前端拦截;成功与失败文案正确。 — 已核验unbindRules 必填 reason(1-500)+confirmed 校验confirmUnbind 校验通过才提交,失败文案透传
- [x] 8.4 批量解绑去重、200 上限、逐项结果展示、部分成功不回滚提示正确。 — 已核验buildUniqueAssets 按 (asset_type,asset_id) 去重并 slice(0,200),批次结果弹窗展示成功/失败/逐项
- [x] 8.5 CSV 导入模板下载、GBK/UTF-8 文件上传、任务创建/列表/详情逐行与手机号快照展示正确;任务级失败场景正确。 — 已核验downloadTemplate 生成 BOM 模板、handleFileChange 限 .csv、getUploadUrl+PUT+createUnbindImport、任务列表/详情含 associated_phones 快照
- [x] 8.6 设备/单卡/详情页关联手机号列展示正确(含空数组)。 — 已核验device-list/index.vue:1898、iot-card-management/index.vue:2097、BasicInfoCard.vue:54 展示关联手机号,空数组显示 -
- [x] 8.7 代理/企业/个人账号看不到任何入口;直达路由 403 文案透传。 — 已核验:路由 meta.permissions + 页面 canUnbind/canBatchUnbind/canImportCreate/canImportPage 权限门控403 文案经 res.msg/normalizeApiError 原样透传
- [x] 8.8 契约核对:任务列表参数/响应与 `phone_unbind_import` 上传前缀均按文档实现;文档未定义项不再单独确认。
> 8.28.7 已按静态代码核验勾选(实现已落地);联调如发现契约偏差再修正。

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
- [x] 8.1 校验 `methods` 为空、超管调用新接口 403、允许范围变更不影响存量待支付单三种情况。 — 已核验index.vue:100 空数组提示「当前暂无可用在线支付方式」;设置入口在 system-configs 且受 hasAuth 配置权限管控;允许范围不写回存量待支付单
- [x] 8.2 校验 OCR 成功、失败、超时三条路径均不阻断人工填写与提交。 — 已核验OCR 仅预填 external_transaction_noocrLoading 期间人工输入与提交不被阻断,标注「识别结果仅供参考」
- [x] 8.3 校验线下单与在线单的两类交易号、收款方式快照与其他凭证展示正确。 — 已核验:列表/详情展示 external_transaction_no、offline_payment_method_name/code 快照、other_voucher_key 凭证组
- [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,42 @@
# Change: 换货迁移状态与店铺负责人导入字段对齐AUG26-005
## Why
换货单新增业务数据迁移状态migration_status前端需要按新状态展示迁移结果不能再以 migrate_data/migration_completed/migration_balance 推断migration_status=failed 的单「确认完成」仅超管/平台账号可重试,代理/企业账号触发 403/1005。同时店铺负责人导入任务/行级接口按生成文档补齐 task_no、file_name、creator_name、started_at、completed_at、status_name 等字段,前端类型与页面展示需对齐。
## What Changes
- 换货响应字段GET /api/admin/exchanges、GET /api/admin/exchanges/{id} 以及创建/发货/完成接口返回的同一个换货对象新增 migration_statusnot_migrated/pending/migrated/failed、migration_status_name中文名不迁移/待迁移/已迁移/迁移失败、migration_failure_reason仅 migration_status=failed 时返回migrate_data、migration_completed、migration_balance 仍在、语义未变,但不再用于推断迁移结果。
- 换货列表在「状态」列之后新增「迁移状态」列中文名failed 红色标记);详情页新增迁移信息展示(迁移状态 + failed 时的失败原因)。
- 确认完成:非 failed 单沿用既有 exchange:complete 权限failed 单仅超管/平台账号可重试,代理/企业账号前端不渲染入口(后端仍 403/1005 兜底);迁移执行失败返回 code=1206换货单保持「已发货待确认」前端展示后端安全文案、修复条件后可重试。
- 导出换货导出在「状态」列之后新增「迁移状态」列15→16不导出失败原因前端导出为任务式、无列模板前端零改动。
- 店铺负责人导入:任务对象(列表/详情/创建响应)按生成文档补齐 task_no/file_name/creator_name/started_at/completed_at/status_name行级结果补齐 status_name创建任务接口返回值对齐任务对象含 id导入页展示任务编号/文件名/创建人/开始·完成时间/状态中文名。
- 无新增路由、无新增列表筛选、请求入参与权限入口不变(发货 migrate_data 仍为「是否执行迁移」,决定状态落 pending 或 not_migrated
## Not In Scope
- 后端实现failed 单权限拦截、1206 返回与迁移执行),仅前端适配。
- 导出文件内容列(换货导出列 15→16 由后端模板生成)。
- migrate_data/migration_completed/migration_balance 字段删除或语义调整。
- 换货单请求入参、列表筛选、权限编码的新增与变更。
## Impact
- Affected specs: exchange-management新增、shop-management扩展
- Affected code:
- src/api/modules/exchange.tsExchangeResponse 迁移字段与枚举)
- src/views/asset-management/exchange-management/index.vue、detail.vue
- src/types/api/shop.tsBusinessOwnerImportDetail/BusinessOwnerImportRow
- src/api/modules/shop.tscreateBusinessOwnerImport 返回值对齐)
- src/views/shop-management/business-owner-import/index.vue
- API contracts: DtoExchangeOrderResponse 新增 3 个迁移字段;店铺负责人导入任务/行级字段对齐生成文档
- Dependencies:
- docs/产品迭代8月份/换货和店铺负责人导入.md7 个换货端点 + 3 个导入端点 OpenAPI
- AUG26-005 换货迁移状态接口变动说明
- Related active changes: update-exchange-flow-direct-type同属换货流程需合并考虑add-business-user-group-management店铺负责人导入页面基础
## 待确认
- 1206 响应走 HTTP 4xx 还是 200+code=1206前端按现有统一错误处理展示后端 msg如需要定制提示如「迁移失败修复后可重试」需后端确认返回结构。
- failed 单「确认完成」的隐藏依据:按 user_type代理/企业)在前端隐藏,超管/平台显示,权限码沿用 exchange:complete。
- 导入任务创建响应含 id以生成文档为准前端改为使用返回对象的 id 打开结果抽屉。

View File

@@ -0,0 +1,66 @@
## ADDED Requirements
### Requirement: 换货业务数据迁移状态契约
换货单对象 MUST 提供 migration_statusnot_migrated/pending/migrated/failed、migration_status_name中文与 migration_failure_reason仅 failed 时返回);迁移结果判定 MUST 以 migration_status 为准,前端 MUST NOT 再用 migrate_data/migration_completed/migration_balance 推断。列表、详情、创建/发货/完成返回的同一个换货对象 MUST 携带上述字段。
#### Scenario: 列表与详情返回迁移状态
- **GIVEN** 用户打开换货列表或详情
- **WHEN** 响应数据加载
- **THEN** 每单 MUST 展示 migration_status 与 migration_status_name
- **AND** migration_status=failed 时 MUST 展示 migration_failure_reason其他状态 MUST NOT 依赖该字段
#### Scenario: 迁移结果以状态字段为准
- **WHEN** 前端判定迁移结果
- **THEN** 判定依据 MUST 使用 migration_statuspending/migrated/failed
- **AND** 不得使用 migrate_data/migration_completed/migration_balance 推断
### Requirement: 换货列表与详情迁移状态展示
换货列表 MUST 在「状态」列之后新增「迁移状态」列并展示中文名;换货详情 MUST 展示迁移状态failed 时展示后端返回的安全失败原因。
#### Scenario: 列表迁移状态列
- **GIVEN** 换货列表加载完成
- **WHEN** 渲染表格
- **THEN** 「状态」列后 MUST 出现「迁移状态」列
- **AND** 该列 MUST 展示 migration_status_name
#### Scenario: 详情展示失败原因
- **GIVEN** 某换货单 migration_status=failed
- **WHEN** 用户打开详情
- **THEN** 详情 MUST 展示迁移状态为「迁移失败」
- **AND** MUST 展示后端返回的 migration_failure_reason 安全文案
### Requirement: 迁移失败单的确认完成权限控制
非 failed 换货单的「确认完成」权限 MUST 与既有逻辑一致exchange:completemigration_status=failed 的物流换货单重试 MUST 仅对超级管理员/平台账号开放,代理/企业账号前端 MUST NOT 渲染该入口,后端仍以 403/1005 兜底。
#### Scenario: 代理/企业账号隐藏重试入口
- **GIVEN** 当前账号为代理或企业账号
- **AND** 换货单 migration_status=failed 且 status=3
- **WHEN** 渲染行操作
- **THEN** 前端 MUST NOT 显示「确认完成」操作
#### Scenario: 超管/平台账号可重试
- **GIVEN** 当前账号为超级管理员或平台账号
- **AND** 换货单 migration_status=failed
- **WHEN** 渲染行操作
- **THEN** 前端 MUST 提供「确认完成」重试入口
### Requirement: 迁移执行失败1206处理
确认完成触发迁移执行失败时后端返回 code=1206 与安全文案,换货单保持「已发货待确认」,修复条件后可重试;前端 MUST 展示后端安全文案且不得改变订单状态展示。
#### Scenario: 1206 提示并可重试
- **GIVEN** 用户对含迁移的换货单执行确认完成
- **WHEN** 后端返回 code=1206
- **THEN** 前端 MUST 展示后端安全文案(不含数据库/渠道原文)
- **AND** 换货单状态仍展示为「已发货待确认」
- **AND** 用户可以修复条件后再次发起确认完成

View File

@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: 店铺负责人导入任务字段对齐生成文档
店铺负责人导入任务对象列表、详情、创建响应MUST 按生成文档提供 task_no、file_name、creator_name、started_at、completed_at、status_name任务状态中文名行级结果 MUST 提供 status_name。前端类型与导入页展示 MUST 对齐;创建导入任务返回任务对象(含 id前端不再依赖 task_id 字段。
#### Scenario: 任务列表展示扩展字段
- **GIVEN** 用户进入业务负责人导入任务列表
- **WHEN** 列表加载完成
- **THEN** 前端 MUST 展示任务编号、文件名、创建人、开始/完成时间与状态中文名
- **AND** 原有状态/总数/成功/失败/创建时间展示 MUST 保留
#### Scenario: 结果抽屉与行级详情
- **GIVEN** 用户查看导入结果抽屉
- **WHEN** 展示任务详情
- **THEN** 任务级信息 MUST 展示文件名、创建人、开始/完成时间、状态中文名
- **AND** 行级表格 MUST 展示 status_name
#### Scenario: 创建导入任务使用任务对象返回
- **WHEN** 前端调用 POST /api/admin/shops/business-owner-imports 创建任务
- **THEN** 返回值 MUST 按任务对象解析file_key 校验与异步执行语义不变)
- **AND** 前端 MUST 使用返回对象的 id 打开结果抽屉

View File

@@ -0,0 +1,25 @@
## 1. API 类型层
- [x] 1.1 src/api/modules/exchange.tsExchangeResponse 新增 migration_status枚举 not_migrated/pending/migrated/failed、migration_status_name、migration_failure_reason
- [x] 1.2 src/types/api/shop.tsBusinessOwnerImportDetail 新增 task_no/file_name/creator_name/started_at/completed_at/status_nameBusinessOwnerImportRow 新增 status_name
- [x] 1.3 src/api/modules/shop.tscreateBusinessOwnerImport 返回值对齐任务对象(含 id导入页改用返回对象 id 打开结果抽屉
## 2. 换货列表
- [x] 2.1 「状态」列之后新增「迁移状态」列中文名failed 用 danger 标记)
- [x] 2.2 failed 单的「确认完成」:代理/企业账号user_type隐藏重试入口超管/平台保留(权限码沿用 exchange:complete
- [x] 2.3 code=1206 处理:确认完成后统一提示后端安全文案,列表/详情保持「已发货待确认」并可重试;不展示数据库/渠道原文
## 3. 换货详情
- [x] 3.1 新增迁移信息展示:迁移状态(中文)+ 失败原因(仅 failed 展示)
## 4. 店铺负责人导入页
- [x] 4.1 任务列表展示任务编号/文件名/创建人/开始时间/完成时间/状态中文名(保留原有 状态/总数/成功/失败/创建时间)
- [x] 4.2 结果抽屉:任务级信息展示文件名/创建人/开始·完成时间/状态中文名;行级表格展示状态中文名
## 5. 验证
- [x] 5.1 vue-tsc --noEmit、check-encoding、相关文件 eslint
- [x] 5.2 openspec validate update-exchange-migration-and-shop-import --strict

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

@@ -0,0 +1,25 @@
# Change: 退款管理契约适配(字段与状态枚举)
## Why
后端按 `docs/产品迭代8月份/refund-api-simple-2.md` 调整退款管理契约:本次未新增任何路由,前端无需新增 URL但创建/重提/审批的请求体、列表与详情的响应字段、状态枚举1-6、可重提规则、审批尝试历史`attempts[]`)、退款方式与渠道退款/失败分类展示,以及支付商户/旧支付配置新增退款用 PEM 凭证字段均发生变化。后台前端现有 `Refund` 类型、创建/重提表单、列表/详情展示与审批入口尚未对齐,需按新文档适配。
## What Changes
- 状态与枚举:`RefundStatus` 扩展 5原路退款处理中与 6原路退款失败新增 `RefundMethod``original_route`/`customer_account`/`asset_wallet`/`agent_wallet`)、`channel_refund_status`0-3`failure_reason` 枚举及名称映射。
- 响应字段:列表/详情 `Refund` 补充 `method``method_name``frozen_actual_received_amount``customer_account_info``channel_refund_status(_name)``channel_refund_no``channel_refund_request_no``channel_refund_amount``channel_refunded_at``failure_reason(_name)``failure_message``anomaly_flag``anomaly_reason``latest_attempt_id``latest_approval_instance_id``attempts[]``refund_package_used_mb``refund_package_total_mb``attempts[]` 按文档定义 14 个子字段。
- 创建退款:请求体新增 `method`(必填)与可选 `customer_account_info``customer_account`(客户收款信息)方式下 MUST 同时给 `customer_account_info` 与至少 1 个凭证;`actual_received_amount` 废弃、不再提交。
- 重提退款:可重提状态扩为 3已拒绝/4已退回/6原路退款失败`anomaly_flag=1` 的申请禁止重提;请求体新增可选 `method``customer_account_info`(不传沿用原值);每次重提新建审批尝试与审批实例。
- 审批操作:补封装 `approve`/`reject`/`return` 接口;已关联企微审批实例(`approval_instance_id`)的申请审批按钮隐藏/禁用,仅存量无实例可用。
- 页面:列表页新增状态 5/6 展示与退款方式、冻结实收金额、渠道退款状态/流水号/金额、失败分类、异常标记、套餐已用量/总量等列;详情页展示新字段与 `attempts[]` 尝试历史;创建/重提弹窗支持退款方式与客户收款信息。
- 支付商户:`wechat_v2` 凭证模板 optional 新增 `wx_client_cert_content``wx_client_key_content`PEM 文本),不填不影响支付/查单/回调,仅原路退款按凭证不完整禁用并提示。
- 旧支付配置:`wechat-configs` 创建/更新/查询响应新增 `wx_client_cert_content``wx_client_key_content`(可选),支付配置表单补齐两个可选 PEM 输入。
- 退款导出:无新接口,`scene=refund` 导出列由后端模板扩展(本轮 2 列 + 上一轮 7 列),前端导出任务列表维持复用,不新增列定义。
- `customer_account_info` 子字段结构以 `docs/admin-openapi.yaml` 为准;本提案先以宽松键值结构(`Record<string, string>`)承载与展示,表单按退款方式联动必填。
- 不实现后端接口、数据库、渠道退款;不改动审批实例数据。
## Impact
- Affected specs: `refund-api``refund-pages``payment-merchant-credentials``wechat-configs`
- Affected code: `src/types/api/refund.ts``src/api/modules/refund.ts``src/views/finance/refund/index.vue``src/views/finance/refund/detail.vue``src/components/business/CreateRefundDialog.vue``src/types/api/paymentMerchantPools.ts``src/views/settings/payment-merchant-pools/components/MerchantManagement.vue``src/types/api/paymentSettings.ts``src/views/settings/payment-settings/index.vue``src/views/settings/payment-settings/detail.vue`
- Dependencies: `docs/产品迭代8月份/refund-api-simple-2.md`

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: 微信直连 V2 退款凭证字段
`wechat_v2` 商户凭证模板 MUST 在 optional 中新增 `wx_client_cert_content``wx_client_key_content`PEM 文本),仅在 `provider_type=wechat_v2` 时展示;填写后用于原路退款,不填不影响支付/查单/回调。
#### Scenario: 商户表单展示 PEM 文本域
- **GIVEN** 服务商类型为 `wechat_v2`
- **WHEN** 打开新建/编辑商户表单
- **THEN** 表单 MUST 提供「客户端证书内容」「客户端私钥内容」两个可选文本域
#### Scenario: 凭证不完整提示
- **GIVEN** `wechat_v2` 商户仅填写证书内容而未填写私钥内容
- **WHEN** 保存商户
- **THEN** 允许保存,但前端 MUST 提示该商户因凭证不完整不可用于原路退款

View File

@@ -0,0 +1,59 @@
## ADDED Requirements
### Requirement: 退款状态与退款方式等枚举
前端 MUST 定义并导出 `RefundStatus`1 待审批、2 已通过、3 已拒绝、4 已退回、5 原路退款处理中、6 原路退款失败)、`RefundMethod``original_route`/`customer_account`/`asset_wallet`/`agent_wallet`)、`channel_refund_status`0 未发起或不适用、1 处理中、2 已成功、3 已失败)与 `failure_reason``channel_rejected`/`credential_invalid`/`insufficient_balance`/`timeout_unknown`/`approval_rejected`/`revoked_after_approved`/`payment_fact_invalid`)枚举,并提供状态名/Tag 映射与可重提规则。
#### Scenario: 状态 5/6 正常展示
- **GIVEN** 退款申请状态为 5 或 6
- **WHEN** 列表/详情渲染状态
- **THEN** 前端 MUST 展示「原路退款处理中」或「原路退款失败」,不得回退为默认样式
#### Scenario: 原路退款失败可重提规则
- **GIVEN** 退款申请状态为 6原路退款失败
- **WHEN** `anomaly_flag` 为 1
- **THEN** 前端 MUST 禁止重提并隐藏/禁用重提入口
### Requirement: 退款响应新增字段
`Refund` 列表项与详情 MUST 支持 `method``method_name``frozen_actual_received_amount``customer_account_info``channel_refund_status``channel_refund_status_name``channel_refund_no``channel_refund_request_no``channel_refund_amount``channel_refunded_at``failure_reason``failure_reason_name``failure_message``anomaly_flag``anomaly_reason``latest_attempt_id``latest_approval_instance_id``refund_package_used_mb``refund_package_total_mb`(单位 MB解析不到套餐为 0`attempts[]`
#### Scenario: 列表展示渠道退款与失败分类
- **GIVEN** 列表接口返回新增字段
- **WHEN** 渲染列
- **THEN** 前端 MUST 展示退款方式、冻结实收金额、渠道退款状态、渠道退款流水号、渠道退款金额、失败分类与异常标记
### Requirement: 审批尝试历史
详情响应 MUST 支持 `attempts[]`,子字段为 `id``attempt_no``method``method_name``refund_amount``frozen_actual_received_amount``refund_reason``customer_account_info``customer_voucher_key``channel_refund_request_no``submitted_by_account_id``approval_instance_id``approval_status``approval_status_name``created_at`;详情页 MUST 按时间倒序展示尝试记录。
#### Scenario: 展示多次尝试记录
- **GIVEN** 详情返回多条 attempts 记录
- **WHEN** 渲染尝试历史
- **THEN** 前端 MUST 展示每条尝试的编号、退款方式、金额、审批状态与时间
### Requirement: 创建退款请求契约
`POST /api/admin/refunds` 请求体 MUST 携带 `method`(必填)、`order_id``requested_refund_amount``package_usage_id``refund_reason`,可选 `customer_account_info``refund_voucher_key`MUST NOT 再提交 `actual_received_amount`(已废弃)。
#### Scenario: 客户收款信息方式校验
- **GIVEN** 退款方式选择 `customer_account`
- **WHEN** `customer_account_info` 为空或凭证数量为 0
- **THEN** 前端 MUST 阻止提交并提示同时填写客户收款信息与至少 1 个凭证
#### Scenario: 非客户收款方式
- **GIVEN** 退款方式为 `original_route`/`asset_wallet`/`agent_wallet`
- **WHEN** 提交创建申请
- **THEN** 客户收款信息与凭证 MUST 允许为空
### Requirement: 重提退款请求契约与可重提规则
`POST /api/admin/refunds/{id}/resubmit` 请求体 MUST 支持可选 `method``customer_account_info`(不传沿用原值)、`requested_refund_amount``refund_reason``refund_voucher_key`;可重提状态 MUST 为 3已拒绝/4已退回/6原路退款失败`anomaly_flag=1` 的申请必须禁止重提。
#### Scenario: 已拒绝申请允许重提
- **GIVEN** 退款申请状态为 3已拒绝`anomaly_flag` 不为 1
- **WHEN** 用户点击重新提交
- **THEN** 前端 MUST 允许重提并支持可选携带 `method` / `customer_account_info`
### Requirement: 审批接口封装与按钮保护
`RefundService` MUST 提供 `approveRefund``approved_refund_amount``remark`)、`rejectRefund``reject_reason`)、`returnRefund``remark`);列表/详情审批按钮 MUST 在 `approval_instance_id` 存在时隐藏/禁用,仅存量无审批实例的申请可用。
#### Scenario: 已关联企微审批实例不可审批
- **GIVEN** 退款申请已关联 `approval_instance_id`
- **WHEN** 渲染审批操作
- **THEN** 通过/拒绝/退回按钮 MUST 隐藏或禁用

View File

@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: 退款列表页字段与操作适配
列表页 MUST 展示状态 5/6Tag并支持新增列退款方式、冻结实收金额、渠道退款状态、渠道退款流水号、渠道退款金额、失败分类、异常标记、当前退款套餐已用量MB、当前退款套餐总量MB操作列的「重新申请」MUST 按枚举可重提规则3/4/66 且 `anomaly_flag=1` 禁止)控制,审批类操作 MUST 按 `approval_instance_id` 与权限控制。
#### Scenario: 异常标记展示
- **GIVEN** 列表项 `anomaly_flag` 为 1
- **WHEN** 渲染异常标记列
- **THEN** 前端 MUST 明确标注异常并展示 `anomaly_reason`
### Requirement: 退款详情页尝试历史与字段展示
详情页 MUST 展示新增响应字段(退款方式、冻结实收金额、渠道退款状态/流水号/申请号/金额/时间、失败分类/原因/消息、异常标记与原因、套餐已用量/总量),并展示 `attempts[]` 尝试历史(尝试编号、退款方式、金额、审批状态、提交时间、凭证)。
#### Scenario: 详情展示新字段与尝试历史
- **GIVEN** 详情接口返回渠道退款与失败分类字段及 attempts 记录
- **WHEN** 渲染详情页
- **THEN** 前端 MUST 展示退款方式、渠道退款状态、失败分类与尝试历史
### Requirement: 创建退款弹窗
创建弹窗 MUST 新增「退款方式」选择(`original_route` 原路退回 / `customer_account` 客户收款信息 / `asset_wallet` 资产钱包 / `agent_wallet` 代理钱包);选择 `customer_account` 时 MUST 展开客户收款信息表单并要求同时提供至少 1 个退款凭证其余方式隐藏客户收款信息表单且凭证改为选填MUST 移除实收金额输入与提交字段。
#### Scenario: 切换退款方式联动
- **GIVEN** 创建弹窗打开且退款方式切换为 `customer_account`
- **WHEN** 客户收款信息为空
- **THEN** 前端 MUST 在提交时阻止并提示补充信息
### Requirement: 重提弹窗
重提弹窗 MUST 支持可选重新选择退款方式与客户收款信息(不传沿用原值),其余字段沿用现有重提逻辑;成功重提后 MUST 刷新列表并提示重新审批。
#### Scenario: 未重新选择时沿用原值
- **GIVEN** 重提弹窗打开且未重新选择退款方式与客户收款信息
- **WHEN** 提交重提
- **THEN** 请求体 MUST 不携带 `method` / `customer_account_info`,由后端沿用原值

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: 旧支付配置退款凭证字段
支付配置(`/api/admin/wechat-configs`)创建/更新请求 MUST 支持可选 `wx_client_cert_content``wx_client_key_content`PEM 文本);列表/详情/active 响应类型 MUST 补充同名字段。
#### Scenario: 支付配置表单新增 PEM 输入
- **GIVEN** 打开支付配置新建/编辑表单
- **WHEN** 渲染表单
- **THEN** 表单 MUST 提供「客户端证书内容」「客户端私钥内容」两个可选文本域,且不填可正常保存
#### Scenario: 配置详情展示 PEM 字段
- **GIVEN** 打开支付配置详情
- **WHEN** 详情响应包含两个 PEM 字段
- **THEN** 前端 MUST 按既有凭证展示约定透出字段内容(不回显或仅展示存在性,按现状约定)

View File

@@ -0,0 +1,24 @@
## 1. 类型与 API 层refund-api
- [x] 1.1 `RefundStatus` 增加 5/6新增 `RefundMethod``channel_refund_status``failure_reason` 枚举与名称映射
- [x] 1.2 `Refund` 响应补充全部新增字段method、冻结实收、渠道退款、失败分类、异常标记、套餐用量等
- [x] 1.3 新增 `RefundAttemptItem`attempts[] 14 个子字段)
- [x] 1.4 `CreateRefundRequest` 契约更新:`method` 必填、`customer_account_info` 可选、移除 `actual_received_amount`
- [x] 1.5 `ResubmitRefundRequest` 契约更新:可选 `method`/`customer_account_info`、移除 `actual_received_amount`
- [x] 1.6 `RefundService` 新增 `approveRefund`/`rejectRefund`/`returnRefund`
## 2. 退款页面refund-pages
- [x] 2.1 列表页状态 5/6 Tag 展示与新列(退款方式、冻结实收金额、渠道退款状态、渠道退款流水号、渠道退款金额、失败分类、异常标记、套餐已用量/总量)
- [x] 2.2 `canResubmit` 改为枚举判断3/4/66 且 `anomaly_flag=1` 禁止)
- [x] 2.3 列表/详情审批按钮(通过/拒绝/退回)按 `approval_instance_id` 与权限控制
- [x] 2.4 详情页新增字段展示与 `attempts[]` 尝试历史时间线
- [x] 2.5 创建弹窗:退款方式选择 + 客户收款信息customer_account 方式联动必填 + 至少 1 凭证)
- [x] 2.6 重提弹窗:可选 `method`/`customer_account_info` 重新选择(不选沿用原值)
## 3. 支付商户payment-merchant-credentials
- [x] 3.1 `wechat_v2` 凭证模板 optional 新增 `wx_client_cert_content``wx_client_key_content`
- [x] 3.2 商户表单支持两个可选 PEM 文本域
## 4. 旧支付配置wechat-configs
- [x] 4.1 `paymentSettings` 类型新增 `wx_client_cert_content``wx_client_key_content`
- [x] 4.2 支付配置表单新增两个可选 PEM 输入
## 5. 验证
- [x] 5.1 运行 `npm run build`(含 `vue-tsc --noEmit`
- [x] 5.2 运行 `npm run check:encoding`
- [x] 5.3 运行 `openspec.cmd validate update-refund-management --strict`

View File

@@ -0,0 +1,74 @@
/**
* 代理扫码分销注册相关 API公开无需登录
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
/**
* 发送短信验证码请求参数
*/
export interface SendCodeParams {
phone: string // 手机号
scene: 'bind_phone' // 业务场景
}
/**
* 发送短信验证码响应
*/
export interface SendCodeResponse {
cooldown_seconds: number // 冷却秒数,用于倒计时
}
/**
* 代理扫码注册请求参数
*/
export interface AgentDistributionRegistrationParams {
distribution_code: string // 分销码
phone: string // 手机号
code: string // 短信验证码
password: string // 密码
shop_name: string // 店铺名称
shop_code: string // 店铺编号
username: string // 用户名
contact_name?: string // 联系人(选填)
province?: string // 省(选填)
city?: string // 市(选填)
district?: string // 区县(选填)
address?: string // 详细地址(选填)
}
/**
* 代理扫码注册响应
*/
export interface AgentDistributionRegistrationResponse {
id: number // 注册记录ID
status: number // 状态0 待审批)
status_name: string // 状态名称
}
export class AgentDistributionService extends BaseService {
/**
* 发送短信验证码
* POST /api/c/v1/auth/send-code公开不带 Token
*/
static sendCode(params: SendCodeParams): Promise<BaseResponse<SendCodeResponse>> {
return this.post<BaseResponse<SendCodeResponse>>('/api/c/v1/auth/send-code', params, {
requestOptions: { withToken: false }
})
}
/**
* 代理扫码注册
* POST /api/c/v1/agent-distribution-registrations公开不带 Token
*/
static registerAgent(
params: AgentDistributionRegistrationParams
): Promise<BaseResponse<AgentDistributionRegistrationResponse>> {
return this.post<BaseResponse<AgentDistributionRegistrationResponse>>(
'/api/c/v1/agent-distribution-registrations',
params,
{ requestOptions: { withToken: false } }
)
}
}

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

View File

@@ -0,0 +1,93 @@
/**
* 业务用户组相关 API - AUG26-003
*/
import { BaseService } from '../BaseService'
import type {
BusinessUserGroupItem,
BusinessUserGroupMembersResult,
BusinessUserGroupQueryParams,
CreateBusinessUserGroupParams,
DeleteBusinessUserGroupParams,
SetBusinessUserGroupMembersParams,
UpdateBusinessUserGroupParams,
BaseResponse,
PaginationResponse
} from '@/types/api'
export class BusinessUserGroupService extends BaseService {
/**
* 查询业务用户组列表
* GET /api/admin/business-user-groups
*/
static getGroups(
params?: BusinessUserGroupQueryParams
): Promise<PaginationResponse<BusinessUserGroupItem>> {
return this.getPage<BusinessUserGroupItem>('/api/admin/business-user-groups', params)
}
/**
* 查询业务用户组详情
* GET /api/admin/business-user-groups/{id}
*/
static getGroup(id: number): Promise<BaseResponse<BusinessUserGroupItem>> {
return this.getOne<BusinessUserGroupItem>(`/api/admin/business-user-groups/${id}`)
}
/**
* 创建业务用户组
* POST /api/admin/business-user-groups
*/
static createGroup(
data: CreateBusinessUserGroupParams
): Promise<BaseResponse<BusinessUserGroupItem>> {
return this.create<BusinessUserGroupItem>('/api/admin/business-user-groups', data)
}
/**
* 更新业务用户组
* PUT /api/admin/business-user-groups/{id}
*/
static updateGroup(
id: number,
data: UpdateBusinessUserGroupParams
): Promise<BaseResponse<BusinessUserGroupItem>> {
return this.update<BusinessUserGroupItem>(`/api/admin/business-user-groups/${id}`, data)
}
/**
* 删除业务用户组(必须携带 confirm body
* DELETE /api/admin/business-user-groups/{id}
*/
static deleteGroup(id: number, data: DeleteBusinessUserGroupParams): Promise<BaseResponse> {
return this.delete<BaseResponse>(`/api/admin/business-user-groups/${id}`, undefined, { data })
}
/**
* 批量设置平台用户业务用户组归属(整体替换)
* PUT /api/admin/business-user-groups/{id}/members
*/
static setGroupMembers(
id: number,
data: SetBusinessUserGroupMembersParams
): Promise<BaseResponse<BusinessUserGroupMembersResult>> {
return this.put<BaseResponse<BusinessUserGroupMembersResult>>(
`/api/admin/business-user-groups/${id}/members`,
data
)
}
/**
* 批量清空平台用户业务用户组归属DELETE 带 body
* DELETE /api/admin/business-user-groups/members
*/
static clearGroupMembers(
data: SetBusinessUserGroupMembersParams
): Promise<BaseResponse<BusinessUserGroupMembersResult>> {
return this.delete<BaseResponse<BusinessUserGroupMembersResult>>(
'/api/admin/business-user-groups/members',
undefined,
{ data }
)
}
}

View File

@@ -15,6 +15,8 @@ import type {
CommissionRecordQueryParams,
SubmitWithdrawalParams,
ShopCommissionRecordPageResult,
ShopCommissionRecordDetail,
ShopCommissionRecordSource,
ShopFundSummaryQueryParams,
ShopFundSummaryPageResult,
ShopCommissionStatsQueryParams,
@@ -23,10 +25,85 @@ import type {
DailyCommissionStatsItem,
ResolveCommissionParams,
MainWalletTransactionQueryParams,
MainWalletTransactionPageResult
MainWalletTransactionPageResult,
WithdrawalQualificationSubmitParams,
WithdrawalQualificationQueryParams,
WithdrawalQualificationPageResult,
VoidWithdrawalQualificationParams,
ResubmitWithdrawalRequestParams,
WithdrawalRequestDetail,
} from '@/types/api/commission'
export class CommissionService extends BaseService {
// ==================== 提现资料资格相关 ====================
/**
* 提交/替换提现资料资格
* POST /api/admin/shops/{shop_id}/withdrawal-qualifications
*/
static submitWithdrawalQualification(
shopId: number,
data: WithdrawalQualificationSubmitParams
): Promise<BaseResponse<{ id: number; status: number; status_name: string }>> {
return this.post<BaseResponse<{ id: number; status: number; status_name: string }>>(
`/api/admin/shops/${shopId}/withdrawal-qualifications`,
data
)
}
/**
* 查询提现资料资格
* GET /api/admin/shops/{shop_id}/withdrawal-qualifications
*/
static getWithdrawalQualifications(
shopId: number,
params?: WithdrawalQualificationQueryParams
): Promise<BaseResponse<WithdrawalQualificationPageResult>> {
return this.get<BaseResponse<WithdrawalQualificationPageResult>>(
`/api/admin/shops/${shopId}/withdrawal-qualifications`,
params
)
}
/**
* 作废提现资料资格(超管)
* POST /api/admin/withdrawal-qualifications/{id}/void
*/
static voidWithdrawalQualification(
id: number,
data: VoidWithdrawalQualificationParams
): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/withdrawal-qualifications/${id}/void`, data)
}
/**
* 重提被驳回的提现
* PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}
*/
static resubmitWithdrawalRequest(
shopId: number,
id: number,
data: ResubmitWithdrawalRequestParams
): Promise<BaseResponse<WithdrawalRequestDetail>> {
return this.put<BaseResponse<WithdrawalRequestDetail>>(
`/api/admin/shops/${shopId}/withdrawal-requests/${id}`,
data
)
}
/**
* 提现申请详情
* GET /api/admin/shops/{shop_id}/withdrawal-requests/{id}
*/
static getWithdrawalRequestDetail(
shopId: number,
id: number
): Promise<BaseResponse<WithdrawalRequestDetail>> {
return this.get<BaseResponse<WithdrawalRequestDetail>>(
`/api/admin/shops/${shopId}/withdrawal-requests/${id}`
)
}
// ==================== 提现申请管理 ====================
/**
@@ -107,6 +184,22 @@ export class CommissionService extends BaseService {
)
}
/**
* 获取佣金明细详情
* GET /api/admin/shops/{shop_id}/commission-records/{id}
* source 省略时按原佣金处理;越权与不存在返回同一结果(佣金明细不存在)
*/
static getShopCommissionRecordDetail(
shopId: number,
id: number,
source?: ShopCommissionRecordSource
): Promise<BaseResponse<ShopCommissionRecordDetail>> {
return this.get<BaseResponse<ShopCommissionRecordDetail>>(
`/api/admin/shops/${shopId}/commission-records/${id}`,
source ? { source } : undefined
)
}
/**
* 获取代理商提现记录
* GET /api/admin/shops/{shop_id}/withdrawal-requests

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

@@ -8,6 +8,7 @@ import type { BaseResponse } from '@/types/api'
export type ExchangeAssetType = 'iot_card' | 'device'
export type ExchangeFlowType = 'shipping' | 'direct'
export type ExchangeStatus = 1 | 2 | 3 | 4 | 5
export type ExchangeMigrationStatus = 'not_migrated' | 'pending' | 'migrated' | 'failed'
// 换货单查询参数
export interface ExchangeQueryParams {
@@ -64,6 +65,9 @@ export interface ExchangeResponse {
current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态
processing_status_name?: string | null // 业务处理状态名称
migration_status?: ExchangeMigrationStatus // 迁移状态not_migrated/pending/migrated/failed
migration_status_name?: string // 迁移状态中文名
migration_failure_reason?: string | null // 迁移失败原因(仅 migration_status=failed 时返回)
remark?: string
created_at: string
updated_at: string

View File

@@ -0,0 +1,87 @@
/**
* H5 运营弹窗配置相关 API
* 契约来源docs/产品迭代8月份/通知.md
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
CreateH5PopupConfigurationRequest,
H5PopupConfiguration,
H5PopupConfigurationListResponse,
H5PopupConfigurationQueryParams,
UpdateH5PopupConfigurationRequest
} from '@/types/api'
/**
* H5 运营弹窗配置服务
* 仅超级管理员和平台账号可访问,代理/企业/个人由后端 403 兜底。
*/
export class H5PopupConfigurationService extends BaseService {
/**
* 查询运营弹窗配置列表(分页,按最近更新时间倒序)
* GET /api/admin/h5-popup-configurations
*/
static getConfigurations(
params?: H5PopupConfigurationQueryParams
): Promise<BaseResponse<H5PopupConfigurationListResponse>> {
return this.get<BaseResponse<H5PopupConfigurationListResponse>>(
'/api/admin/h5-popup-configurations',
params
)
}
/**
* 创建运营弹窗配置
* POST /api/admin/h5-popup-configurations
*/
static createConfiguration(
data: CreateH5PopupConfigurationRequest
): Promise<BaseResponse<H5PopupConfiguration>> {
return this.post<BaseResponse<H5PopupConfiguration>>('/api/admin/h5-popup-configurations', data)
}
/**
* 查询运营弹窗配置详情
* GET /api/admin/h5-popup-configurations/{id}
*/
static getConfiguration(id: number): Promise<BaseResponse<H5PopupConfiguration>> {
return this.getOne<H5PopupConfiguration>(`/api/admin/h5-popup-configurations/${id}`)
}
/**
* 更新运营弹窗配置(成功即版本 +1
* PUT /api/admin/h5-popup-configurations/{id}
*/
static updateConfiguration(
id: number,
data: UpdateH5PopupConfigurationRequest
): Promise<BaseResponse<H5PopupConfiguration>> {
return this.put<BaseResponse<H5PopupConfiguration>>(
`/api/admin/h5-popup-configurations/${id}`,
data
)
}
/**
* 启用运营弹窗配置(刷新最近更新时间,不递增版本)
* POST /api/admin/h5-popup-configurations/{id}/enable
*/
static enableConfiguration(id: number): Promise<BaseResponse<H5PopupConfiguration>> {
return this.post<BaseResponse<H5PopupConfiguration>>(
`/api/admin/h5-popup-configurations/${id}/enable`,
{ id }
)
}
/**
* 停用运营弹窗配置(刷新最近更新时间,不递增版本)
* POST /api/admin/h5-popup-configurations/{id}/disable
*/
static disableConfiguration(id: number): Promise<BaseResponse<H5PopupConfiguration>> {
return this.post<BaseResponse<H5PopupConfiguration>>(
`/api/admin/h5-popup-configurations/${id}/disable`,
{ id }
)
}
}

View File

@@ -10,6 +10,7 @@ export { AccountService } from './account'
export { ShopService } from './shop'
export { CardService } from './card'
export { CommissionService } from './commission'
export { BusinessUserGroupService } from './businessUserGroup'
export { EnterpriseService } from './enterprise'
export { StorageService } from './storage'
export { AuthorizationService } from './authorization'
@@ -23,6 +24,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 +41,19 @@ export { BulkPurchaseService } from './bulkPurchase'
export { NotificationService } from './notification'
export { AuditService } from './audit'
export { WecomService } from './wecom'
export { EmployeeCollectionService } from './employeeCollection'
export { PhoneAssetAssociationService } from './phoneAsset'
export { AgentDistributionService } from './agentDistribution'
export { H5PopupConfigurationService } from './h5PopupConfiguration'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'
export { PackageTrafficAlertService } from './packageTrafficAlert'
export { PollingPriorityQueueService } from './pollingPriorityQueue'
export { AssetWalletService } from './assetWallet'

View File

@@ -0,0 +1,101 @@
/**
* 套餐真流量预警 API 服务
* 6 个接口仅超级管理员/平台账号可访问,其他账号由后端 403 兜底
* 对应 docs/产品迭代8月份/套餐真流量预警_API_前端简版.md
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
PaginationResponse,
PackageTrafficAlertRuleItem,
PackageTrafficAlertRuleQueryParams,
CreatePackageTrafficAlertRuleParams,
UpdatePackageTrafficAlertRuleParams,
PackageTrafficAlertRecordItem,
PackageTrafficAlertRecordDetail,
PackageTrafficAlertRecordQueryParams,
ExportPackageTrafficAlertParams,
CreatePackageTrafficAlertExportResponse
} from '@/types/api'
export class PackageTrafficAlertService extends BaseService {
/**
* 查询套餐真流量预警规则列表
* GET /api/admin/package-traffic-alert-rules
* 返回套餐、商品当前真流量额度、阈值、启用状态、备注与更新时间
*/
static getAlertRules(
params?: PackageTrafficAlertRuleQueryParams
): Promise<PaginationResponse<PackageTrafficAlertRuleItem>> {
return this.getPage<PackageTrafficAlertRuleItem>(
'/api/admin/package-traffic-alert-rules',
params
)
}
/**
* 创建套餐真流量预警规则
* POST /api/admin/package-traffic-alert-rules
* 同一套餐商品最多一条真流量预警规则
*/
static createAlertRule(
data: CreatePackageTrafficAlertRuleParams
): Promise<BaseResponse<PackageTrafficAlertRuleItem>> {
return this.create<PackageTrafficAlertRuleItem>('/api/admin/package-traffic-alert-rules', data)
}
/**
* 修改套餐真流量预警规则
* PUT /api/admin/package-traffic-alert-rules/{id}
* 允许修改阈值、启停与备注;修改不影响既有预警快照,停用后扫描不再创建新预警
*/
static updateAlertRule(
id: number,
data: UpdatePackageTrafficAlertRuleParams
): Promise<BaseResponse<PackageTrafficAlertRuleItem>> {
return this.update<PackageTrafficAlertRuleItem>(
`/api/admin/package-traffic-alert-rules/${id}`,
data
)
}
/**
* 查询套餐真流量预警记录列表
* GET /api/admin/package-traffic-alerts
* 列表数据除 business_user_group_names 外均为触发时快照
*/
static getAlertRecords(
params?: PackageTrafficAlertRecordQueryParams
): Promise<PaginationResponse<PackageTrafficAlertRecordItem>> {
return this.getPage<PackageTrafficAlertRecordItem>('/api/admin/package-traffic-alerts', params)
}
/**
* 查询套餐真流量预警记录详情
* GET /api/admin/package-traffic-alerts/{id}
* 越权查询统一按资源不可见处理
*/
static getAlertRecordDetail(id: number): Promise<BaseResponse<PackageTrafficAlertRecordDetail>> {
// 越权查询详情统一按资源不可见处理,抑制全局报错提示,由页面给出统一文案
return this.getOne<PackageTrafficAlertRecordDetail>(
`/api/admin/package-traffic-alerts/${id}`,
undefined,
{ requestOptions: { errorMessageMode: 'none' } }
)
}
/**
* 导出套餐真流量达量预警
* POST /api/admin/package-traffic-alerts/export
* 只创建异步导出任务,后续通过既有导出任务列表下载文件
*/
static exportAlertRecords(
data: ExportPackageTrafficAlertParams
): Promise<BaseResponse<CreatePackageTrafficAlertExportResponse>> {
return this.post<BaseResponse<CreatePackageTrafficAlertExportResponse>>(
'/api/admin/package-traffic-alerts/export',
data
)
}
}

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

@@ -0,0 +1,107 @@
/**
* 手机号资产关联相关 API
* 契约来源docs/产品迭代8月份/手机号资产关联.md
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
PhoneAssetAssociationListResponse,
PhoneAssetAssociationQueryParams,
BatchUnbindPhoneAssetAssociationRequest,
BatchUnbindPhoneAssetAssociationResponse,
CreatePhoneAssetUnbindImportRequest,
PhoneAssetUnbindImportTask,
PhoneAssetUnbindImportTaskDetail,
PhoneAssetUnbindImportTaskListResponse,
PhoneAssetUnbindImportTaskQueryParams,
UnbindPhoneAssetAssociationResponse
} from '@/types/api'
/**
* 手机号资产关联服务
* 仅超级管理员和平台账号可访问,代理/企业/个人由后端 403 兜底。
*/
export class PhoneAssetAssociationService extends BaseService {
/**
* 查询手机号资产关联列表(分页)
* GET /api/admin/phone-asset-associations
*/
static getAssociations(
params?: PhoneAssetAssociationQueryParams
): Promise<BaseResponse<PhoneAssetAssociationListResponse>> {
return this.get<BaseResponse<PhoneAssetAssociationListResponse>>(
'/api/admin/phone-asset-associations',
params
)
}
/**
* 解除单条手机号资产关联
* DELETE /api/admin/phone-asset-associations/{id}
*
* 注意接口文档未定义请求体reason 与 confirmed 当前按查询参数传递;
* 联调时若后端以 JSON body 接收,需调整为请求体方式。
*/
static unbindAssociation(
id: number,
reason: string,
confirmed: boolean
): Promise<BaseResponse<UnbindPhoneAssetAssociationResponse>> {
return this.delete<BaseResponse<UnbindPhoneAssetAssociationResponse>>(
`/api/admin/phone-asset-associations/${id}`,
{ reason, confirmed }
)
}
/**
* 按资产批量解除手机号关联
* POST /api/admin/phone-asset-associations/batch-unbind
*/
static batchUnbind(
data: BatchUnbindPhoneAssetAssociationRequest
): Promise<BaseResponse<BatchUnbindPhoneAssetAssociationResponse>> {
return this.post<BaseResponse<BatchUnbindPhoneAssetAssociationResponse>>(
'/api/admin/phone-asset-associations/batch-unbind',
data
)
}
/**
* 创建 CSV 解绑导入任务
* POST /api/admin/phone-asset-associations/unbind-imports
*/
static createUnbindImport(
data: CreatePhoneAssetUnbindImportRequest
): Promise<BaseResponse<PhoneAssetUnbindImportTask>> {
return this.post<BaseResponse<PhoneAssetUnbindImportTask>>(
'/api/admin/phone-asset-associations/unbind-imports',
data
)
}
/**
* 查询解绑导入任务列表(分页)
* GET /api/admin/phone-asset-associations/unbind-imports
*/
static getUnbindImportTasks(
params?: PhoneAssetUnbindImportTaskQueryParams
): Promise<BaseResponse<PhoneAssetUnbindImportTaskListResponse>> {
return this.get<BaseResponse<PhoneAssetUnbindImportTaskListResponse>>(
'/api/admin/phone-asset-associations/unbind-imports',
params
)
}
/**
* 查询解绑导入任务详情
* GET /api/admin/phone-asset-associations/unbind-imports/{id}
*/
static getUnbindImportTaskDetail(
id: number
): Promise<BaseResponse<PhoneAssetUnbindImportTaskDetail>> {
return this.getOne<PhoneAssetUnbindImportTaskDetail>(
`/api/admin/phone-asset-associations/unbind-imports/${id}`
)
}
}

View File

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

View File

@@ -9,6 +9,9 @@ import type {
RefundListResponse,
CreateRefundRequest,
ResubmitRefundRequest,
ApproveRefundRequest,
RejectRefundRequest,
ReturnRefundRequest,
BaseResponse
} from '@/types/api'
@@ -37,6 +40,33 @@ export class RefundService extends BaseService {
return this.post<BaseResponse<Refund>>('/api/admin/refunds', data)
}
/**
* 审批通过退款申请(仅存量无企微审批实例的申请可用)
* @param id 退款申请ID
* @param data 审批通过请求参数
*/
static approveRefund(id: number, data: ApproveRefundRequest): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/approve`, data)
}
/**
* 审批拒绝退款申请(仅存量无企微审批实例的申请可用)
* @param id 退款申请ID
* @param data 拒绝请求参数
*/
static rejectRefund(id: number, data: RejectRefundRequest): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/reject`, data)
}
/**
* 退回退款申请(仅存量无企微审批实例的申请可用)
* @param id 退款申请ID
* @param data 退回请求参数
*/
static returnRefund(id: number, data: ReturnRefundRequest): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/return`, data)
}
/**
* 重新提交退款申请
* @param id 退款申请ID
@@ -53,4 +83,4 @@ export class RefundService extends BaseService {
static triggerApproval(id: number): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/trigger-approval`)
}
}
}

View File

@@ -12,6 +12,11 @@ import type {
AssignShopRolesRequest,
UpdateShopCreditLimitRequest,
UpdateShopCreditLimitResponse,
BatchUpdateBusinessOwnerParams,
BatchUpdateBusinessOwnerResult,
CreateBusinessOwnerImportParams,
BusinessOwnerImportDetail,
BusinessOwnerImportQueryParams,
BaseResponse,
PaginationResponse,
ShopBusinessOwnerCandidate,
@@ -155,4 +160,56 @@ export class ShopService extends BaseService {
data
)
}
// ========== 批量交接平台业务员 ==========
/**
* 勾选批量交接平台业务员
* PUT /api/admin/shops/business-owner/batch
* @param data shop_ids1-500+ business_owner_account_idnull=清空)
*/
static batchUpdateBusinessOwner(
data: BatchUpdateBusinessOwnerParams
): Promise<BaseResponse<BatchUpdateBusinessOwnerResult>> {
return this.put<BaseResponse<BatchUpdateBusinessOwnerResult>>(
'/api/admin/shops/business-owner/batch',
data
)
}
// ========== 业务负责人 CSV 导入 ==========
/**
* 创建业务负责人导入任务
* POST /api/admin/shops/business-owner-imports
* @param data 上传后拿到的 file_key
*/
static createBusinessOwnerImport(
data: CreateBusinessOwnerImportParams
): Promise<BaseResponse<BusinessOwnerImportDetail>> {
return this.create<BusinessOwnerImportDetail>('/api/admin/shops/business-owner-imports', data)
}
/**
* 查询业务负责人导入任务详情
* GET /api/admin/shops/business-owner-imports/{id}
*/
static getBusinessOwnerImportDetail(
id: number
): Promise<BaseResponse<BusinessOwnerImportDetail>> {
return this.getOne<BusinessOwnerImportDetail>(`/api/admin/shops/business-owner-imports/${id}`)
}
/**
* 查询业务负责人导入任务列表
* GET /api/admin/shops/business-owner-imports
*/
static getBusinessOwnerImportList(
params?: BusinessOwnerImportQueryParams
): Promise<PaginationResponse<BusinessOwnerImportDetail>> {
return this.getPage<BusinessOwnerImportDetail>(
'/api/admin/shops/business-owner-imports',
params
)
}
}

View File

@@ -27,6 +27,8 @@ export type FilePurpose =
| 'iot_import'
| 'batch_purchase'
| 'device_batch_allocation'
| 'shop_import'
| 'phone_unbind_import'
| 'export'
| 'attachment'

View File

@@ -0,0 +1,269 @@
<template>
<ElDialog
:model-value="modelValue"
title="佣金明细详情"
width="780px"
@update:model-value="handleVisibleChange"
@closed="handleDialogClosed"
>
<div v-loading="loading" class="commission-record-detail">
<template v-if="record">
<ElDescriptions :column="2" border size="small">
<ElDescriptionsItem label="记录类型">
<ElTag :type="isClawback ? 'danger' : 'primary'" size="small">
{{ recordSourceName }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="statusTagType" size="small">
{{ record.status_name || statusName }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金金额">
<span :class="{ 'amount-negative': isNegative(record.amount) }">
{{ formatMoney(record.amount) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="入账后佣金余额">
<span :class="{ 'amount-negative': isNegative(record.balance_after) }">
{{ formatMoney(record.balance_after) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金来源">
<ElTag :type="sourceTagType" size="small">{{ sourceName }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="是否可提现">
<ElTag v-if="isClawback" type="danger" size="small">不可提现</ElTag>
<span v-else>可提现</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="订单号">{{ record.order_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="卖家店铺">
{{ record.seller_shop_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="ICCID">{{ record.iccid || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备虚拟号">
{{ record.virtual_no || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="记录生成时间">
{{ record.created_at ? formatDateTime(record.created_at) : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金入账时间">
{{ record.released_at ? formatDateTime(record.released_at) : '-' }}
</ElDescriptionsItem>
<template v-if="isClawback">
<ElDescriptionsItem label="被回溯原佣金ID">
{{ record.original_commission_id ?? '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="来源退款单号">
{{ record.refund_no || '-' }}
</ElDescriptionsItem>
</template>
</ElDescriptions>
<template v-if="!isClawback">
<div class="detail-section-title">
回溯明细
<span class="detail-section-summary">
累计回溯金额{{ formatMoney(clawbackTotalAmount) }}
</span>
</div>
<ElTable :data="clawbackRecords" size="small" border :max-height="260">
<ElTableColumn label="回溯金额" prop="amount" width="120">
<template #default="{ row }">
<span class="amount-negative">{{ formatMoney(row.amount) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="回溯后余额" prop="balance_after" width="130">
<template #default="{ row }">
<span :class="{ 'amount-negative': isNegative(row.balance_after) }">
{{ formatMoney(row.balance_after) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn
label="来源退款单号"
prop="refund_no"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.refund_no || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status_name" width="100">
<template #default="{ row }">
<ElTag type="danger" size="small">{{ row.status_name || '回溯' }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="生成时间" prop="created_at" width="180">
<template #default="{ row }">
{{ row.created_at ? formatDateTime(row.created_at) : '-' }}
</template>
</ElTableColumn>
</ElTable>
</template>
<template v-else-if="originalCommission">
<div class="detail-section-title">来源原佣金</div>
<ElDescriptions :column="2" border size="small">
<ElDescriptionsItem label="原佣金记录ID">
{{ originalCommission.id }}
</ElDescriptionsItem>
<ElDescriptionsItem label="原佣金金额">
{{ formatMoney(originalCommission.amount) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="原佣金余额">
{{ formatMoney(originalCommission.balance_after) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="原佣金状态">
{{ originalCommission.status_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="订单号">
{{ originalCommission.order_no || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金入账时间">
{{
originalCommission.released_at
? formatDateTime(originalCommission.released_at)
: '-'
}}
</ElDescriptionsItem>
</ElDescriptions>
</template>
</template>
<ElEmpty v-else-if="!loading" description="佣金明细不存在" />
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { CommissionService } from '@/api/modules'
import type { ShopCommissionRecordDetail, ShopCommissionRecordSource } from '@/types/api'
import {
COMMISSION_RECORD_SOURCE_MAP,
CommissionSourceMap,
CommissionStatusMap,
isCommissionClawbackRecord
} from '@/config/constants/commission'
import { formatDateTime, formatMoney } from '@/utils/business/format'
import { getErrorMessage } from '@/utils/business'
defineOptions({ name: 'CommissionRecordDetailDialog' })
const props = withDefaults(
defineProps<{
modelValue: boolean
shopId?: number | null
recordId?: number | null
source?: ShopCommissionRecordSource
}>(),
{
shopId: null,
recordId: null,
source: 'original'
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const loading = ref(false)
const detail = ref<ShopCommissionRecordDetail | null>(null)
const record = computed(() => detail.value?.record || null)
const isClawback = computed(() =>
isCommissionClawbackRecord({
source: detail.value?.source || props.source,
status: detail.value?.record?.status
})
)
const commissionStatusMeta = computed(
() =>
CommissionStatusMap[Number(record.value?.status) as keyof typeof CommissionStatusMap] || null
)
const commissionSourceMeta = computed(
() =>
CommissionSourceMap[
(record.value?.commission_source || '') as keyof typeof CommissionSourceMap
] || null
)
const recordSourceMeta = computed(
() => COMMISSION_RECORD_SOURCE_MAP[isClawback.value ? 'clawback' : 'original']
)
const recordSourceName = computed(() => recordSourceMeta.value.label)
const statusName = computed(() => commissionStatusMeta.value?.label || '-')
const statusTagType = computed(() => commissionStatusMeta.value?.type || 'info')
const sourceName = computed(
() => commissionSourceMeta.value?.label || record.value?.commission_source || '-'
)
const sourceTagType = computed(() => commissionSourceMeta.value?.type || 'info')
const clawbackRecords = computed(() => detail.value?.clawback_records ?? [])
const clawbackTotalAmount = computed(() => record.value?.clawback_total_amount ?? 0)
const originalCommission = computed(() => detail.value?.original_commission || null)
const isNegative = (value?: number | null) => typeof value === 'number' && value < 0
const loadDetail = async () => {
if (!props.shopId || !props.recordId) return
loading.value = true
detail.value = null
try {
const res = await CommissionService.getShopCommissionRecordDetail(
props.shopId,
props.recordId,
props.source
)
if (res.code === 0) {
detail.value = res.data
} else {
ElMessage.error(res.msg || '佣金明细不存在')
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '佣金明细不存在'))
} finally {
loading.value = false
}
}
const handleVisibleChange = (value: boolean) => {
emit('update:modelValue', value)
}
const handleDialogClosed = () => {
detail.value = null
}
watch(
() => [props.modelValue, props.shopId, props.recordId, props.source] as const,
([visible]) => {
if (visible) loadDetail()
}
)
</script>
<style scoped lang="scss">
.commission-record-detail {
min-height: 120px;
}
.amount-negative {
color: var(--el-color-danger);
}
.detail-section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 16px 0 8px;
font-size: 14px;
font-weight: 600;
}
.detail-section-summary {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<ElDialog v-model="dialogVisible" title="创建退款申请" width="40%" @closed="handleDialogClosed">
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px">
<ElDialog v-model="dialogVisible" title="创建退款申请" width="45%" @closed="handleDialogClosed">
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<ElFormItem label="订单号" prop="order_id">
<ElSelect
v-if="!hasInitialOrder"
@@ -23,6 +23,13 @@
</ElSelect>
<ElInput v-else :model-value="props.initialOrderNo" disabled style="width: 100%" />
</ElFormItem>
<ElFormItem label="退款方式" prop="method">
<ElRadioGroup v-model="formData.method">
<ElRadio v-for="opt in RefundMethodOptions" :key="opt.value" :label="opt.value">
{{ opt.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="退款金额" prop="requested_refund_amount">
<ElInputNumber
v-model="formData.requested_refund_amount"
@@ -46,7 +53,29 @@
placeholder="请输入退款原因"
/>
</ElFormItem>
<ElFormItem label="退款凭证" prop="refund_voucher_key">
<template v-if="formData.method === RefundMethod.CUSTOMER_ACCOUNT">
<ElFormItem label="收款账户名" prop="customer_account_name">
<ElInput
v-model="formData.customer_account_name"
placeholder="请输入收款账户名"
maxlength="50"
/>
</ElFormItem>
<ElFormItem label="收款账号" prop="customer_account_number">
<ElInput
v-model="formData.customer_account_number"
placeholder="请输入收款账号"
maxlength="64"
/>
</ElFormItem>
<ElFormItem label="开户银行">
<ElInput v-model="formData.customer_bank_name" placeholder="选填" maxlength="50" />
</ElFormItem>
</template>
<ElFormItem
:label="formData.method === RefundMethod.CUSTOMER_ACCOUNT ? '退款凭证(必填)' : '退款凭证'"
prop="refund_voucher_key"
>
<VoucherUpload
ref="uploadRef"
v-model="formData.refund_voucher_key"
@@ -54,6 +83,9 @@
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('refund_voucher_key')"
/>
<div v-if="formData.method === RefundMethod.CUSTOMER_ACCOUNT" class="voucher-tip">
客户收款信息方式下须同时提供客户收款信息与至少 1 个退款凭证
</div>
</ElFormItem>
</ElForm>
<template #footer>
@@ -77,7 +109,8 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { RefundService, OrderService } from '@/api/modules'
import type { CreateRefundRequest, Order } from '@/types/api'
import { RefundMethod, type CreateRefundRequest, type Order } from '@/types/api'
import { RefundMethodOptions } from '@/config/constants/refund'
import { formatMoney, yuanToFen } from '@/utils/business/format'
import { getErrorMessage } from '@/utils/business'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
@@ -110,16 +143,24 @@
const formData = reactive<{
order_id: number | null
package_usage_id?: number
method?: RefundMethod
requested_refund_amount?: number
actual_received_amount: number
refund_reason: string
customer_account_name: string
customer_account_number: string
customer_bank_name: string
refund_voucher_key: string[]
}>({
order_id: null,
package_usage_id: undefined,
method: RefundMethod.ORIGINAL_ROUTE,
requested_refund_amount: undefined,
actual_received_amount: 0,
refund_reason: '',
customer_account_name: '',
customer_account_number: '',
customer_bank_name: '',
refund_voucher_key: []
})
@@ -134,14 +175,43 @@
}
}
const requireCustomerAccount = (field: string, message: string) => ({
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (formData.method === RefundMethod.CUSTOMER_ACCOUNT && !value?.trim()) {
callback(new Error(message))
} else {
callback()
}
},
trigger: 'blur'
})
const validateVoucherKey = (
_rule: unknown,
value: string[],
callback: (error?: Error) => void
) => {
if (
formData.method === RefundMethod.CUSTOMER_ACCOUNT &&
(!Array.isArray(value) || value.length === 0)
) {
callback(new Error('请上传至少 1 个退款凭证'))
} else {
callback()
}
}
const formRules = reactive<FormRules>({
order_id: [{ required: true, message: '请选择订单', trigger: 'change' }],
method: [{ required: true, message: '请选择退款方式', trigger: 'change' }],
requested_refund_amount: [
{ required: true, message: '请输入申请退款金额', trigger: 'blur' },
{ validator: validateRefundAmount, trigger: 'blur' }
],
refund_reason: [{ required: true, message: '请输入退款原因', trigger: 'blur' }],
refund_voucher_key: [{ required: true, message: '请上传退款凭证', trigger: 'change' }]
customer_account_name: requireCustomerAccount('customer_account_name', '请填写收款账户名'),
customer_account_number: requireCustomerAccount('customer_account_number', '请填写收款账号'),
refund_voucher_key: [{ validator: validateVoucherKey, trigger: 'change' }]
})
const hasInitialOrder = computed(() => !!props.initialOrderId || !!props.initialOrderNo)
@@ -202,9 +272,13 @@
voucherUploading.value = false
formData.order_id = null
formData.package_usage_id = undefined
formData.method = RefundMethod.ORIGINAL_ROUTE
formData.requested_refund_amount = undefined
formData.actual_received_amount = 0
formData.refund_reason = ''
formData.customer_account_name = ''
formData.customer_account_number = ''
formData.customer_bank_name = ''
formData.refund_voucher_key = []
orderSearchOptions.value = []
uploadRef.value?.clearFiles(false)
@@ -222,20 +296,24 @@
submitLoading.value = true
try {
const data: CreateRefundRequest = {
actual_received_amount: formData.actual_received_amount,
order_id: formData.order_id!,
method: formData.method!,
package_usage_id: formData.package_usage_id ?? 0,
refund_reason: formData.refund_reason,
refund_voucher_key: formData.refund_voucher_key,
requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0
}
if (formData.method === RefundMethod.CUSTOMER_ACCOUNT) {
data.customer_account_info = `${formData.customer_account_name.trim()} ${formData.customer_account_number.trim()}${formData.customer_bank_name.trim() ? ` ${formData.customer_bank_name.trim()}` : ''}`
data.refund_voucher_key = formData.refund_voucher_key
} else if (formData.refund_voucher_key.length) {
data.refund_voucher_key = formData.refund_voucher_key
}
await RefundService.createRefund(data)
ElMessage.success('退款申请创建成功')
dialogVisible.value = false
emit('success')
} catch (error) {
console.error(error)
ElMessage.error(getErrorMessage(error, '退款申请创建失败'))
} finally {
submitLoading.value = false
}
@@ -257,3 +335,12 @@
}
})
</script>
<style scoped lang="scss">
.voucher-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<ElDialog
:model-value="modelValue"
:title="dialogTitle"
width="480px"
@update:model-value="handleVisibleChange"
@closed="handleDialogClosed"
>
<template v-if="action === 'approve'">
<ElForm ref="approveFormRef" :model="approveForm" :rules="approveRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no || '-' }}</span>
</ElFormItem>
<ElFormItem label="审批退款金额">
<ElInputNumber
v-model="approveForm.approved_refund_amount"
:min="0"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则按申请金额审批"
/>
</ElFormItem>
<ElFormItem label="审批备注">
<ElInput
v-model="approveForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入审批备注(选填)"
/>
</ElFormItem>
</ElForm>
</template>
<template v-else-if="action === 'reject'">
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no || '-' }}</span>
</ElFormItem>
<ElFormItem label="拒绝原因" prop="reject_reason">
<ElInput
v-model="rejectForm.reject_reason"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
</template>
<template v-else>
<ElForm ref="returnFormRef" :model="returnForm" :rules="returnRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no || '-' }}</span>
</ElFormItem>
<ElFormItem label="退回备注">
<ElInput
v-model="returnForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入退回备注(选填)"
/>
</ElFormItem>
</ElForm>
</template>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleVisibleChange(false)">取消</ElButton>
<ElButton type="primary" :loading="submitLoading" @click="handleConfirm">
{{ confirmText }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { RefundService } from '@/api/modules'
import type { Refund } from '@/types/api'
import { yuanToFen } from '@/utils/business/format'
defineOptions({ name: 'RefundApprovalDialog' })
type ApprovalAction = 'approve' | 'reject' | 'return'
const props = defineProps<{
modelValue: boolean
refund: Refund | null
action: ApprovalAction
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const submitLoading = ref(false)
const approveFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const returnFormRef = ref<FormInstance>()
const approveForm = reactive<{
approved_refund_amount?: number
remark: string
}>({
approved_refund_amount: undefined,
remark: ''
})
const rejectForm = reactive<{
reject_reason: string
}>({
reject_reason: ''
})
const returnForm = reactive<{
remark: string
}>({
remark: ''
})
const approveRules = reactive<FormRules>({})
const rejectRules = reactive<FormRules>({
reject_reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const returnRules = reactive<FormRules>({})
const dialogTitle = computed(() => {
if (props.action === 'approve') return '审批通过退款申请'
if (props.action === 'reject') return '审批拒绝退款申请'
return '退回退款申请'
})
const confirmText = computed(() => {
if (props.action === 'approve') return '确认通过'
if (props.action === 'reject') return '确认拒绝'
return '确认退回'
})
const handleVisibleChange = (visible: boolean) => {
emit('update:modelValue', visible)
}
const handleDialogClosed = () => {
approveForm.approved_refund_amount = undefined
approveForm.remark = ''
rejectForm.reject_reason = ''
returnForm.remark = ''
submitLoading.value = false
}
const handleConfirm = async () => {
if (!props.refund || submitLoading.value) return
let valid = true
if (props.action === 'approve') {
valid = await approveFormRef.value?.validate().catch(() => false) !== false
} else if (props.action === 'reject') {
valid = await rejectFormRef.value?.validate().catch(() => false) !== false
} else {
valid = await returnFormRef.value?.validate().catch(() => false) !== false
}
if (!valid) return
submitLoading.value = true
try {
const refundId = props.refund.id
if (props.action === 'approve') {
await RefundService.approveRefund(refundId, {
approved_refund_amount: yuanToFen(approveForm.approved_refund_amount),
remark: approveForm.remark.trim() || undefined
})
ElMessage.success('审批通过成功')
} else if (props.action === 'reject') {
await RefundService.rejectRefund(refundId, {
reject_reason: rejectForm.reject_reason.trim()
})
ElMessage.success('审批拒绝成功')
} else {
await RefundService.returnRefund(refundId, {
remark: returnForm.remark.trim() || undefined
})
ElMessage.success('退回成功')
}
emit('update:modelValue', false)
emit('success')
} catch (error) {
console.error(error)
ElMessage.error('操作失败,请稍后重试')
} finally {
submitLoading.value = false
}
}
</script>

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

@@ -71,7 +71,7 @@
<!-- 分页 -->
<div
v-if="pagination && tableData.length > 0"
v-if="pagination && (alwaysShowPagination || tableData.length > 0)"
class="table-pagination"
:class="paginationAlign"
>
@@ -113,7 +113,7 @@
/** 是否显示加载状态 */
loading?: boolean
/** 行数据的 Key用于标识每一行数据 */
rowKey?: string
rowKey?: string | ((row: any) => string)
/** 行的 className 的回调方法 */
rowClassName?: ((data: { row: any; rowIndex: number }) => string) | string
/** 是否显示边框 */
@@ -144,6 +144,8 @@
pageSizes?: number[]
/** 只有一页时是否隐藏分页器 */
hideOnSinglePage?: boolean
/** 数据为空时也始终显示分页器 */
alwaysShowPagination?: boolean
/** 分页器的对齐方式 */
paginationAlign?: 'left' | 'center' | 'right'
/** 分页器的大小 */
@@ -185,6 +187,7 @@
currentPage: 1,
pageSize: 20,
hideOnSinglePage: false,
alwaysShowPagination: false,
pageSizes: () => [10, 20, 30, 50],
paginationAlign: 'center',
paginationSize: 'default',

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,45 @@
/**
* 八月迭代新增后台权限编码。
* 页面和按钮统一引用这里的常量,后端菜单权限可直接复用同名编码。
*/
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',
applicationViewVoucher: 'employee_collection:application_view_voucher',
paymentMethodPage: 'employee_collection:payment_method_view',
paymentMethodCreate: 'employee_collection:payment_method_create',
paymentMethodEdit: 'employee_collection:payment_method_edit',
paymentMethodDelete: 'employee_collection:payment_method_delete'
},
phoneAssetAssociation: {
list: 'phone_asset_association:list',
unbind: 'phone_asset_association:unbind',
unbindBatch: 'phone_asset_association:unbind_batch',
unbindImportPage: 'phone_asset_association:unbind_import_view',
unbindImportCreate: 'phone_asset_association:unbind_import_create',
unbindImportDetail: 'phone_asset_association:unbind_import_detail'
},
h5PopupConfiguration: {
list: 'h5_popup_configuration:list',
create: 'h5_popup_configuration:create',
update: 'h5_popup_configuration:update',
enable: 'h5_popup_configuration:enable',
disable: 'h5_popup_configuration:disable'
},
packageTrafficAlert: {
rulesView: 'package_traffic_alert:rules_view',
ruleCreate: 'package_traffic_alert:rule_create',
ruleUpdate: 'package_traffic_alert:rule_update',
recordsView: 'package_traffic_alert:records_view',
recordDetail: 'package_traffic_alert:record_detail',
export: 'package_traffic_alert:export'
}
} as const
export type AugustPermission = string

View File

@@ -0,0 +1,48 @@
/**
* 业务用户组相关常量与权限编码
*/
import type { BusinessUserGroupBusinessLine } from '@/types/api'
/** 业务线选项standard 标品 / smart 智能产品 / other 其他 */
export const BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS: Array<{
label: string
value: BusinessUserGroupBusinessLine
type?: 'primary' | 'success' | 'warning' | 'info' | 'danger'
}> = [
{ label: '标品', value: 'standard', type: 'primary' },
{ label: '智能产品', value: 'smart', type: 'success' },
{ label: '其他', value: 'other', type: 'warning' }
]
/** 业务线文案映射 */
export const BUSINESS_USER_GROUP_BUSINESS_LINE_MAP = BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS.reduce(
(map, item) => {
map[item.value] = item
return map
},
{} as Record<BusinessUserGroupBusinessLine, (typeof BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS)[number]>
)
/** 获取业务线标签 */
export function getBusinessUserGroupBusinessLineLabel(businessLine?: string | null): string {
if (!businessLine) return '-'
return BUSINESS_USER_GROUP_BUSINESS_LINE_MAP[businessLine as BusinessUserGroupBusinessLine]
?.label ?? businessLine
}
/** 业务用户组页面与按钮权限编码 */
export const BUSINESS_USER_GROUP_PERMISSIONS = {
page: 'business_user_group:page',
create: 'business_user_group:create',
update: 'business_user_group:update',
delete: 'business_user_group:delete',
members: 'business_user_group:members'
}
/** 店铺业务负责人运营权限编码 */
export const SHOP_BUSINESS_OWNER_PERMISSIONS = {
batch: 'shop:business_owner_batch',
importPage: 'shop:business_owner_import',
importCreate: 'shop:business_owner_import'
}

View File

@@ -10,6 +10,7 @@ export const COMMISSION_STATUS_OPTIONS = [
{ label: '解冻中', value: CommissionStatus.UNFREEZING, type: 'warning' as const },
{ label: '已发放', value: CommissionStatus.RELEASED, type: 'success' as const },
{ label: '已失效', value: CommissionStatus.INVALID, type: 'danger' as const },
{ label: '回溯', value: CommissionStatus.CLAWBACK, type: 'danger' as const },
{ label: '待人工修正', value: CommissionStatus.PENDING_CORRECTION, type: 'warning' as const }
]
@@ -46,6 +47,7 @@ export const CommissionStatusMap = {
2: { label: '解冻中', type: 'warning' as const, color: '#E6A23C' },
3: { label: '已发放', type: 'success' as const, color: '#67C23A' },
4: { label: '已失效', type: 'danger' as const, color: '#F56C6C' },
5: { label: '回溯', type: 'danger' as const, color: '#F56C6C' },
99: { label: '待人工修正', type: 'warning' as const, color: '#E6A23C' }
}
@@ -113,3 +115,33 @@ export function getCommissionTypeType(type: string) {
const config = CommissionTypeMap[type as keyof typeof CommissionTypeMap]
return config?.type || 'info'
}
// ========== 佣金记录(含回溯明细)映射 ==========
// 记录来源映射 (original:原佣金, clawback:回溯明细)
export const COMMISSION_RECORD_SOURCE_MAP = {
original: { label: '原佣金', type: 'primary' as const },
clawback: { label: '回溯明细', type: 'danger' as const }
}
// 佣金记录状态筛选选项(含回溯,不含待人工修正)
export const COMMISSION_RECORD_STATUS_OPTIONS = [
{ label: '已冻结', value: CommissionStatus.FROZEN },
{ label: '解冻中', value: CommissionStatus.UNFREEZING },
{ label: '已发放', value: CommissionStatus.RELEASED },
{ label: '已失效', value: CommissionStatus.INVALID },
{ label: '回溯', value: CommissionStatus.CLAWBACK }
]
// 是否回溯明细行source 与 status 双判断,兼容未返回 source 的响应)
export function isCommissionClawbackRecord(record: {
source?: string
status?: number
}): boolean {
return record.source === 'clawback' || record.status === CommissionStatus.CLAWBACK
}
// 列表行 key原佣金与回溯明细 ID 空间独立,同一页可能出现相同 id
export function getCommissionRecordRowKey(record: { id: number; source?: string }): string {
return `${record.source || 'original'}:${record.id}`
}

View File

@@ -84,6 +84,24 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
detail: 'export_task:exchange_detail',
download: 'export_task:exchange_download'
}
},
commission_record: {
scene: 'commission_record',
sceneName: '佣金记录',
pageTitle: '导出佣金记录',
permissions: {
detail: 'export_task:commission_record_detail',
download: 'export_task:commission_record_download'
}
},
package_traffic_alert: {
scene: 'package_traffic_alert',
sceneName: '套餐真流量预警',
pageTitle: '导出套餐真流量预警',
permissions: {
detail: 'export_task:package_traffic_alert_detail',
download: 'export_task:package_traffic_alert_download'
}
}
}

View File

@@ -34,7 +34,15 @@ export * from './enableStatus'
// 导出任务相关
export * from './exportTask'
export * from './businessUserGroup'
// 套餐真流量预警相关
export * from './packageTrafficAlert'
// 批量订购相关
export * from './bulkPurchase'
export * from './julyIteration'
export * from './augustIteration'
// 轮询优先队列相关
export * from './pollingPriorityQueue'

View File

@@ -0,0 +1,60 @@
/**
* 套餐真流量预警相关常量
* 对应 docs/产品迭代8月份/套餐真流量预警_API_前端简版.md
*/
import {
PackageTrafficAlertNotificationStatus,
type PackageTrafficAlertAssetType
} from '@/types/api'
/** 通知投递结果选项1:已通知, 2:待投递, 3:投递失败, 4:未通知(接收人已失效), 5:未通知(无有效业务员)) */
export const TRAFFIC_ALERT_NOTIFICATION_STATUS_OPTIONS = [
{
label: '已通知',
value: PackageTrafficAlertNotificationStatus.NOTIFIED,
tagType: 'success' as const
},
{
label: '待投递',
value: PackageTrafficAlertNotificationStatus.PENDING_DELIVERY,
tagType: 'info' as const
},
{
label: '投递失败',
value: PackageTrafficAlertNotificationStatus.DELIVERY_FAILED,
tagType: 'danger' as const
},
{
label: '未通知(接收人已失效)',
value: PackageTrafficAlertNotificationStatus.NOT_NOTIFIED_RECEIVER_INVALID,
tagType: 'warning' as const
},
{
label: '未通知(无有效业务员)',
value: PackageTrafficAlertNotificationStatus.NOT_NOTIFIED_NO_VALID_OWNER,
tagType: 'warning' as const
}
]
export const getTrafficAlertNotificationStatusOption = (
status?: PackageTrafficAlertNotificationStatus | null
) => TRAFFIC_ALERT_NOTIFICATION_STATUS_OPTIONS.find((item) => item.value === status)
/** 资产类型选项iot_card:物联网卡, device:设备) */
export const TRAFFIC_ALERT_ASSET_TYPE_OPTIONS: Array<{
label: string
value: PackageTrafficAlertAssetType
}> = [
{ label: '物联网卡', value: 'iot_card' },
{ label: '设备', value: 'device' }
]
export const getTrafficAlertAssetTypeName = (assetType?: PackageTrafficAlertAssetType | null) =>
TRAFFIC_ALERT_ASSET_TYPE_OPTIONS.find((item) => item.value === assetType)?.label || '-'
/** 导出格式选项xlsx:Excel, csv:CSV */
export const TRAFFIC_ALERT_EXPORT_FORMAT_OPTIONS = [
{ label: 'XLSX', value: 'xlsx' as const },
{ label: 'CSV', value: 'csv' as const }
]

View File

@@ -0,0 +1,17 @@
/**
* 支付商户与商户池管理权限编码。
* 页面和按钮统一引用这里的常量,后端菜单权限可直接复用同名编码。
* 编码命名参考代理充值页 agent_recharge:<action> 风格。
*/
export const PAYMENT_MERCHANT_POOL_PERMISSIONS = {
merchantCreate: 'payment_merchant_pool:merchant_create',
merchantEdit: 'payment_merchant_pool:merchant_edit',
merchantToggle: 'payment_merchant_pool:merchant_toggle',
merchantDelete: 'payment_merchant_pool:merchant_delete',
merchantDetail: 'payment_merchant_pool:merchant_detail',
poolCreate: 'payment_merchant_pool:pool_create',
poolEdit: 'payment_merchant_pool:pool_edit',
poolToggle: 'payment_merchant_pool:pool_toggle',
poolDetail: 'payment_merchant_pool:pool_detail',
wechatAuthEdit: 'payment_merchant_pool:wechat_auth_edit'
} as const

View File

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

View File

@@ -0,0 +1,89 @@
/**
* 退款管理展示与规则常量
*/
import {
RefundStatus,
RefundMethod,
ChannelRefundStatus,
RefundFailureReason
} from '@/types/api/refund'
/** 退款状态名称映射 */
export const RefundStatusNameMap: Record<RefundStatus, string> = {
[RefundStatus.PENDING]: '待审批',
[RefundStatus.APPROVED]: '已通过',
[RefundStatus.REJECTED]: '已拒绝',
[RefundStatus.RETURNED]: '已退回',
[RefundStatus.REFUNDING]: '原路退款处理中',
[RefundStatus.REFUND_FAILED]: '原路退款失败'
}
/** 退款状态标签类型映射 */
export const RefundStatusTypeMap: Record<
RefundStatus,
'warning' | 'success' | 'danger' | 'info'
> = {
[RefundStatus.PENDING]: 'warning',
[RefundStatus.APPROVED]: 'success',
[RefundStatus.REJECTED]: 'danger',
[RefundStatus.RETURNED]: 'info',
[RefundStatus.REFUNDING]: 'warning',
[RefundStatus.REFUND_FAILED]: 'danger'
}
/** 退款方式名称映射 */
export const RefundMethodNameMap: Record<RefundMethod, string> = {
[RefundMethod.ORIGINAL_ROUTE]: '原路退回',
[RefundMethod.CUSTOMER_ACCOUNT]: '客户收款信息',
[RefundMethod.ASSET_WALLET]: '资产钱包',
[RefundMethod.AGENT_WALLET]: '代理钱包'
}
/** 渠道退款状态名称映射 */
export const ChannelRefundStatusNameMap: Record<ChannelRefundStatus, string> = {
[ChannelRefundStatus.NOT_STARTED]: '未发起或不适用',
[ChannelRefundStatus.PROCESSING]: '处理中',
[ChannelRefundStatus.SUCCEEDED]: '已成功',
[ChannelRefundStatus.FAILED]: '已失败'
}
/** 失败分类名称映射 */
export const RefundFailureReasonNameMap: Record<RefundFailureReason, string> = {
[RefundFailureReason.CHANNEL_REJECTED]: '渠道明确拒绝',
[RefundFailureReason.CREDENTIAL_INVALID]: '渠道凭证失效',
[RefundFailureReason.INSUFFICIENT_BALANCE]: '渠道余额不足',
[RefundFailureReason.TIMEOUT_UNKNOWN]: '超时或结果未知',
[RefundFailureReason.APPROVAL_REJECTED]: '企微驳回或关闭',
[RefundFailureReason.REVOKED_AFTER_APPROVED]: '企微通过后撤销',
[RefundFailureReason.PAYMENT_FACT_INVALID]: '本地原支付事实不可用'
}
/** 退款方式选项(表单用) */
export const RefundMethodOptions = [
{ label: '原路退回', value: RefundMethod.ORIGINAL_ROUTE },
{ label: '客户收款信息', value: RefundMethod.CUSTOMER_ACCOUNT },
{ label: '资产钱包', value: RefundMethod.ASSET_WALLET },
{ label: '代理钱包', value: RefundMethod.AGENT_WALLET }
]
/**
* 是否可重提3 已拒绝 / 4 已退回 / 6 原路退款失败anomaly_flag=1 时禁止)
*/
export const canResubmitRefund = (item: {
status?: RefundStatus
anomaly_flag?: number
}): boolean => {
if (!item.status) return false
if (item.status === RefundStatus.REJECTED || item.status === RefundStatus.RETURNED) return true
if (item.status === RefundStatus.REFUND_FAILED) return item.anomaly_flag !== 1
return false
}
/**
* 审批操作是否可用:仅待审批且未关联企微审批实例(存量无实例)的申请
*/
export const canManualApproveRefund = (item: {
status?: RefundStatus
approval_instance_id?: number | null
}): boolean => item.status === RefundStatus.PENDING && !item.approval_instance_id

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": {
@@ -392,7 +413,10 @@
"seriesAssignDetail": "Series Assignment Detail",
"packageSeries": "Package Series",
"packageSeriesDetail": "Package Series Detail",
"packageCommission": "Package Commission Cards"
"packageCommission": "Package Commission Cards",
"trafficAlertRules": "Traffic Alert Rules",
"trafficAlerts": "Traffic Alert Records",
"trafficAlertDetail": "Traffic Alert Record Detail"
},
"accountManagement": {
"title": "Account Management",
@@ -412,7 +436,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",
@@ -429,7 +458,9 @@
},
"shopManagement": {
"title": "Shop Management",
"shopList": "Shop List"
"shopList": "Shop List",
"businessUserGroups": "Business User Groups",
"businessOwnerImport": "Business Owner Import"
},
"assetManagement": {
"title": "Asset Management",
@@ -468,9 +499,14 @@
"exportRefund": "Export Refunds",
"exportAgentRecharge": "Export Agent Recharges",
"exportExchange": "Export Exchanges",
"exportCommissionRecord": "Export Commission Records",
"exportPackageTrafficAlert": "Export Package Traffic Alerts",
"exportTaskDetail": "Export Task Detail",
"exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail"
"exchangeDetail": "Exchange Order Detail",
"phoneAssetAssociation": "Phone-Asset Associations",
"phoneAssetUnbindImportTasks": "Unbind Import Tasks",
"phoneAssetUnbindImportTaskDetail": "Unbind Import Task Detail"
},
"settings": {
"title": "Settings Management",
@@ -478,7 +514,15 @@
"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",
"detailsOfPaymentMerchant": "Payment Merchant Details",
"detailsOfPaymentMerchantPool": "Merchant Pool Details",
"h5PopupConfiguration": "H5 Popup Configurations",
"assetWalletAutoRenewal": "Asset Wallet Auto Renewal"
},
"batch": {
"title": "Batch Operations",

Some files were not shown because too many files have changed in this diff Show More