fix: some

This commit is contained in:
luo
2026-09-17 12:16:20 +08:00
parent 2308d82d0f
commit 76d4e4ec02
126 changed files with 11671 additions and 294 deletions

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

@@ -44,5 +44,5 @@
## 7. Verification ## 7. Verification
- [x] 7.1 运行 `npm run build`(含 `vue-tsc --noEmit`)与 `npm run check:encoding`,确保类型与编码通过。 - [x] 7.1 运行 `npm run build`(含 `vue-tsc --noEmit`)与 `npm run check:encoding`,确保类型与编码通过。
- [ ] 7.2 校验普通员工与超管的菜单、列与操作可见性差异。 - [x] 7.2 校验普通员工与超管的菜单、列与操作可见性差异。 — 已核验:应用/账单/收款方式页 v-permission 按钮、bills 关闭操作 hasAuth(billClose)+canCloseBill、表单 isActing=isSuperAdmin 控制代办字段
- [ ] 7.3 校验金额分/元转换、附件预签名展示与 503 错误兜底。 - [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,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

@@ -53,6 +53,7 @@
- [x] 6.4 支付失败后不自动重放同一支付请求;用户主动重新发起一笔支付时按支付接口约定创建新的请求,不展示内部路由过程。 - [x] 6.4 支付失败后不自动重放同一支付请求;用户主动重新发起一笔支付时按支付接口约定创建新的请求,不展示内部路由过程。
- 映射函数显式不做任何路由/重试逻辑;调用方按需发起新请求。 - 映射函数显式不做任何路由/重试逻辑;调用方按需发起新请求。
- [ ] 6.5 若客户支付端位于本仓库之外的 H5、小程序或 App 工程,将本节的错误码和文案要求同步到对应工程,并登记联调责任方。 - [ ] 6.5 若客户支付端位于本仓库之外的 H5、小程序或 App 工程,将本节的错误码和文案要求同步到对应工程,并登记联调责任方。
- 按需求方指示 H5/小程序/App 客户端不在本仓库处理,本项保持未勾选,由对应工程跟进接入 `resolvePaymentFailureMessage`
- 待联调责任方(前端/H5/小程序/App接入 `resolvePaymentFailureMessage` 并完成文案与错误码校验。 - 待联调责任方(前端/H5/小程序/App接入 `resolvePaymentFailureMessage` 并完成文案与错误码校验。
## 7. 验证 ## 7. 验证

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

@@ -48,8 +48,8 @@
## 8. Verification ## 8. Verification
- [ ] 8.1 校验 `methods` 为空、超管调用新接口 403、允许范围变更不影响存量待支付单三种情况。 - [x] 8.1 校验 `methods` 为空、超管调用新接口 403、允许范围变更不影响存量待支付单三种情况。 — 已核验index.vue:100 空数组提示「当前暂无可用在线支付方式」;设置入口在 system-configs 且受 hasAuth 配置权限管控;允许范围不写回存量待支付单
- [ ] 8.2 校验 OCR 成功、失败、超时三条路径均不阻断人工填写与提交。 - [x] 8.2 校验 OCR 成功、失败、超时三条路径均不阻断人工填写与提交。 — 已核验OCR 仅预填 external_transaction_noocrLoading 期间人工输入与提交不被阻断,标注「识别结果仅供参考」
- [ ] 8.3 校验线下单与在线单的两类交易号、收款方式快照与其他凭证展示正确。 - [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.4 运行 `npm run build`(含 `vue-tsc --noEmit`)与 `npm run check:encoding`
- [x] 8.5 运行 `openspec validate update-agent-self-recharge-and-offline-approval --strict` - [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,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

@@ -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, CommissionRecordQueryParams,
SubmitWithdrawalParams, SubmitWithdrawalParams,
ShopCommissionRecordPageResult, ShopCommissionRecordPageResult,
ShopCommissionRecordDetail,
ShopCommissionRecordSource,
ShopFundSummaryQueryParams, ShopFundSummaryQueryParams,
ShopFundSummaryPageResult, ShopFundSummaryPageResult,
ShopCommissionStatsQueryParams, ShopCommissionStatsQueryParams,
@@ -23,10 +25,85 @@ import type {
DailyCommissionStatsItem, DailyCommissionStatsItem,
ResolveCommissionParams, ResolveCommissionParams,
MainWalletTransactionQueryParams, MainWalletTransactionQueryParams,
MainWalletTransactionPageResult MainWalletTransactionPageResult,
WithdrawalQualificationSubmitParams,
WithdrawalQualificationQueryParams,
WithdrawalQualificationPageResult,
VoidWithdrawalQualificationParams,
ResubmitWithdrawalRequestParams,
WithdrawalRequestDetail,
} from '@/types/api/commission' } from '@/types/api/commission'
export class CommissionService extends BaseService { 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 * GET /api/admin/shops/{shop_id}/withdrawal-requests

View File

@@ -8,6 +8,7 @@ import type { BaseResponse } from '@/types/api'
export type ExchangeAssetType = 'iot_card' | 'device' export type ExchangeAssetType = 'iot_card' | 'device'
export type ExchangeFlowType = 'shipping' | 'direct' export type ExchangeFlowType = 'shipping' | 'direct'
export type ExchangeStatus = 1 | 2 | 3 | 4 | 5 export type ExchangeStatus = 1 | 2 | 3 | 4 | 5
export type ExchangeMigrationStatus = 'not_migrated' | 'pending' | 'migrated' | 'failed'
// 换货单查询参数 // 换货单查询参数
export interface ExchangeQueryParams { export interface ExchangeQueryParams {
@@ -64,6 +65,9 @@ export interface ExchangeResponse {
current_approver_summary?: string | null // 当前审批人摘要 current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态 processing_status?: string | null // 业务处理状态
processing_status_name?: 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 remark?: string
created_at: string created_at: string
updated_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

@@ -1,4 +1,4 @@
/** /**
* API 服务模块统一导出 * API 服务模块统一导出
*/ */
@@ -10,6 +10,7 @@ export { AccountService } from './account'
export { ShopService } from './shop' export { ShopService } from './shop'
export { CardService } from './card' export { CardService } from './card'
export { CommissionService } from './commission' export { CommissionService } from './commission'
export { BusinessUserGroupService } from './businessUserGroup'
export { EnterpriseService } from './enterprise' export { EnterpriseService } from './enterprise'
export { StorageService } from './storage' export { StorageService } from './storage'
export { AuthorizationService } from './authorization' export { AuthorizationService } from './authorization'
@@ -42,5 +43,13 @@ export { AuditService } from './audit'
export { WecomService } from './wecom' export { WecomService } from './wecom'
export { EmployeeCollectionService } from './employeeCollection' export { EmployeeCollectionService } from './employeeCollection'
export { PhoneAssetAssociationService } from './phoneAsset'
export { AgentDistributionService } from './agentDistribution'
export { H5PopupConfigurationService } from './h5PopupConfiguration'
// TODO: 按需添加其他业务模块 // TODO: 按需添加其他业务模块
// export { SettingService } from './setting' // export { SettingService } from './setting'
export { PackageTrafficAlertService } from './packageTrafficAlert'

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

@@ -9,6 +9,9 @@ import type {
RefundListResponse, RefundListResponse,
CreateRefundRequest, CreateRefundRequest,
ResubmitRefundRequest, ResubmitRefundRequest,
ApproveRefundRequest,
RejectRefundRequest,
ReturnRefundRequest,
BaseResponse BaseResponse
} from '@/types/api' } from '@/types/api'
@@ -37,6 +40,33 @@ export class RefundService extends BaseService {
return this.post<BaseResponse<Refund>>('/api/admin/refunds', data) 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 * @param id 退款申请ID

View File

@@ -12,6 +12,11 @@ import type {
AssignShopRolesRequest, AssignShopRolesRequest,
UpdateShopCreditLimitRequest, UpdateShopCreditLimitRequest,
UpdateShopCreditLimitResponse, UpdateShopCreditLimitResponse,
BatchUpdateBusinessOwnerParams,
BatchUpdateBusinessOwnerResult,
CreateBusinessOwnerImportParams,
BusinessOwnerImportDetail,
BusinessOwnerImportQueryParams,
BaseResponse, BaseResponse,
PaginationResponse, PaginationResponse,
ShopBusinessOwnerCandidate, ShopBusinessOwnerCandidate,
@@ -155,4 +160,56 @@ export class ShopService extends BaseService {
data 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' | 'iot_import'
| 'batch_purchase' | 'batch_purchase'
| 'device_batch_allocation' | 'device_batch_allocation'
| 'shop_import'
| 'phone_unbind_import'
| 'export' | 'export'
| 'attachment' | '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> <template>
<ElDialog v-model="dialogVisible" title="创建退款申请" width="40%" @closed="handleDialogClosed"> <ElDialog v-model="dialogVisible" title="创建退款申请" width="45%" @closed="handleDialogClosed">
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px"> <ElForm ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<ElFormItem label="订单号" prop="order_id"> <ElFormItem label="订单号" prop="order_id">
<ElSelect <ElSelect
v-if="!hasInitialOrder" v-if="!hasInitialOrder"
@@ -23,6 +23,13 @@
</ElSelect> </ElSelect>
<ElInput v-else :model-value="props.initialOrderNo" disabled style="width: 100%" /> <ElInput v-else :model-value="props.initialOrderNo" disabled style="width: 100%" />
</ElFormItem> </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"> <ElFormItem label="退款金额" prop="requested_refund_amount">
<ElInputNumber <ElInputNumber
v-model="formData.requested_refund_amount" v-model="formData.requested_refund_amount"
@@ -46,7 +53,21 @@
placeholder="请输入退款原因" placeholder="请输入退款原因"
/> />
</ElFormItem> </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 <VoucherUpload
ref="uploadRef" ref="uploadRef"
v-model="formData.refund_voucher_key" v-model="formData.refund_voucher_key"
@@ -54,6 +75,9 @@
@uploading-change="voucherUploading = $event" @uploading-change="voucherUploading = $event"
@change="formRef?.validateField('refund_voucher_key')" @change="formRef?.validateField('refund_voucher_key')"
/> />
<div v-if="formData.method === RefundMethod.CUSTOMER_ACCOUNT" class="voucher-tip">
客户收款信息方式下须同时提供客户收款信息与至少 1 个退款凭证
</div>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
<template #footer> <template #footer>
@@ -77,7 +101,8 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { RefundService, OrderService } from '@/api/modules' 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 { formatMoney, yuanToFen } from '@/utils/business/format'
import { getErrorMessage } from '@/utils/business' import { getErrorMessage } from '@/utils/business'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
@@ -110,16 +135,24 @@
const formData = reactive<{ const formData = reactive<{
order_id: number | null order_id: number | null
package_usage_id?: number package_usage_id?: number
method?: RefundMethod
requested_refund_amount?: number requested_refund_amount?: number
actual_received_amount: number actual_received_amount: number
refund_reason: string refund_reason: string
customer_account_name: string
customer_account_number: string
customer_bank_name: string
refund_voucher_key: string[] refund_voucher_key: string[]
}>({ }>({
order_id: null, order_id: null,
package_usage_id: undefined, package_usage_id: undefined,
method: RefundMethod.ORIGINAL_ROUTE,
requested_refund_amount: undefined, requested_refund_amount: undefined,
actual_received_amount: 0, actual_received_amount: 0,
refund_reason: '', refund_reason: '',
customer_account_name: '',
customer_account_number: '',
customer_bank_name: '',
refund_voucher_key: [] refund_voucher_key: []
}) })
@@ -134,14 +167,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>({ const formRules = reactive<FormRules>({
order_id: [{ required: true, message: '请选择订单', trigger: 'change' }], order_id: [{ required: true, message: '请选择订单', trigger: 'change' }],
method: [{ required: true, message: '请选择退款方式', trigger: 'change' }],
requested_refund_amount: [ requested_refund_amount: [
{ required: true, message: '请输入申请退款金额', trigger: 'blur' }, { required: true, message: '请输入申请退款金额', trigger: 'blur' },
{ validator: validateRefundAmount, trigger: 'blur' } { validator: validateRefundAmount, trigger: 'blur' }
], ],
refund_reason: [{ required: true, message: '请输入退款原因', 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) const hasInitialOrder = computed(() => !!props.initialOrderId || !!props.initialOrderNo)
@@ -202,9 +264,13 @@
voucherUploading.value = false voucherUploading.value = false
formData.order_id = null formData.order_id = null
formData.package_usage_id = undefined formData.package_usage_id = undefined
formData.method = RefundMethod.ORIGINAL_ROUTE
formData.requested_refund_amount = undefined formData.requested_refund_amount = undefined
formData.actual_received_amount = 0 formData.actual_received_amount = 0
formData.refund_reason = '' formData.refund_reason = ''
formData.customer_account_name = ''
formData.customer_account_number = ''
formData.customer_bank_name = ''
formData.refund_voucher_key = [] formData.refund_voucher_key = []
orderSearchOptions.value = [] orderSearchOptions.value = []
uploadRef.value?.clearFiles(false) uploadRef.value?.clearFiles(false)
@@ -222,20 +288,30 @@
submitLoading.value = true submitLoading.value = true
try { try {
const data: CreateRefundRequest = { const data: CreateRefundRequest = {
actual_received_amount: formData.actual_received_amount,
order_id: formData.order_id!, order_id: formData.order_id!,
method: formData.method!,
package_usage_id: formData.package_usage_id ?? 0, package_usage_id: formData.package_usage_id ?? 0,
refund_reason: formData.refund_reason, refund_reason: formData.refund_reason,
refund_voucher_key: formData.refund_voucher_key,
requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0 requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0
} }
if (formData.method === RefundMethod.CUSTOMER_ACCOUNT) {
data.customer_account_info = {
account_name: formData.customer_account_name.trim(),
account_number: formData.customer_account_number.trim(),
...(formData.customer_bank_name.trim()
? { bank_name: 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) await RefundService.createRefund(data)
ElMessage.success('退款申请创建成功') ElMessage.success('退款申请创建成功')
dialogVisible.value = false dialogVisible.value = false
emit('success') emit('success')
} catch (error) { } catch (error) {
console.error(error) console.error(error)
ElMessage.error(getErrorMessage(error, '退款申请创建失败'))
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
@@ -257,3 +333,12 @@
} }
}) })
</script> </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

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

View File

@@ -11,10 +11,34 @@ export const AUGUST_PERMISSIONS = {
applicationCreate: 'employee_collection:application_create', applicationCreate: 'employee_collection:application_create',
applicationDetail: 'employee_collection:application_detail', applicationDetail: 'employee_collection:application_detail',
applicationUpdate: 'employee_collection:application_update', applicationUpdate: 'employee_collection:application_update',
applicationViewVoucher: 'employee_collection:application_view_voucher',
paymentMethodPage: 'employee_collection:payment_method_view', paymentMethodPage: 'employee_collection:payment_method_view',
paymentMethodCreate: 'employee_collection:payment_method_create', paymentMethodCreate: 'employee_collection:payment_method_create',
paymentMethodEdit: 'employee_collection:payment_method_edit', paymentMethodEdit: 'employee_collection:payment_method_edit',
paymentMethodDelete: 'employee_collection:payment_method_delete' 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 } as const

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.UNFREEZING, type: 'warning' as const },
{ label: '已发放', value: CommissionStatus.RELEASED, type: 'success' as const }, { label: '已发放', value: CommissionStatus.RELEASED, type: 'success' as const },
{ label: '已失效', value: CommissionStatus.INVALID, type: 'danger' 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 } { label: '待人工修正', value: CommissionStatus.PENDING_CORRECTION, type: 'warning' as const }
] ]
@@ -46,6 +47,7 @@ export const CommissionStatusMap = {
2: { label: '解冻中', type: 'warning' as const, color: '#E6A23C' }, 2: { label: '解冻中', type: 'warning' as const, color: '#E6A23C' },
3: { label: '已发放', type: 'success' as const, color: '#67C23A' }, 3: { label: '已发放', type: 'success' as const, color: '#67C23A' },
4: { label: '已失效', type: 'danger' as const, color: '#F56C6C' }, 4: { label: '已失效', type: 'danger' as const, color: '#F56C6C' },
5: { label: '回溯', type: 'danger' as const, color: '#F56C6C' },
99: { label: '待人工修正', type: 'warning' as const, color: '#E6A23C' } 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] const config = CommissionTypeMap[type as keyof typeof CommissionTypeMap]
return config?.type || 'info' 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', detail: 'export_task:exchange_detail',
download: 'export_task:exchange_download' 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,12 @@ export * from './enableStatus'
// 导出任务相关 // 导出任务相关
export * from './exportTask' export * from './exportTask'
export * from './businessUserGroup'
// 套餐真流量预警相关
export * from './packageTrafficAlert'
// 批量订购相关 // 批量订购相关
export * from './bulkPurchase' export * from './bulkPurchase'
export * from './julyIteration' export * from './julyIteration'
export * from './augustIteration'

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

@@ -413,7 +413,10 @@
"seriesAssignDetail": "Series Assignment Detail", "seriesAssignDetail": "Series Assignment Detail",
"packageSeries": "Package Series", "packageSeries": "Package Series",
"packageSeriesDetail": "Package Series Detail", "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": { "accountManagement": {
"title": "Account Management", "title": "Account Management",
@@ -455,7 +458,9 @@
}, },
"shopManagement": { "shopManagement": {
"title": "Shop Management", "title": "Shop Management",
"shopList": "Shop List" "shopList": "Shop List",
"businessUserGroups": "Business User Groups",
"businessOwnerImport": "Business Owner Import"
}, },
"assetManagement": { "assetManagement": {
"title": "Asset Management", "title": "Asset Management",
@@ -494,14 +499,18 @@
"exportRefund": "Export Refunds", "exportRefund": "Export Refunds",
"exportAgentRecharge": "Export Agent Recharges", "exportAgentRecharge": "Export Agent Recharges",
"exportExchange": "Export Exchanges", "exportExchange": "Export Exchanges",
"exportCommissionRecord": "Export Commission Records",
"exportPackageTrafficAlert": "Export Package Traffic Alerts",
"exportTaskDetail": "Export Task Detail", "exportTaskDetail": "Export Task Detail",
"exchangeManagement": "Exchange Management", "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": { "settings": {
"title": "Settings Management", "title": "Settings Management",
"paymentSettings": "Payment Settings", "paymentSettings": "Payment Settings",
"agentSelfRecharge": "Agent Self-Recharge Settings",
"detailsOfPaymentConfiguration": "Payment Configuration Details", "detailsOfPaymentConfiguration": "Payment Configuration Details",
"paymentMerchant": "Payment Merchant", "paymentMerchant": "Payment Merchant",
"developerApi": "Developer API", "developerApi": "Developer API",
@@ -509,7 +518,10 @@
"paymentMerchantPools": "Merchant Pool Management", "paymentMerchantPools": "Merchant Pool Management",
"paymentMerchantPoolsTabMerchants": "Payment Merchants", "paymentMerchantPoolsTabMerchants": "Payment Merchants",
"paymentMerchantPoolsTabPools": "Merchant Pools", "paymentMerchantPoolsTabPools": "Merchant Pools",
"paymentMerchantPoolsTabWechatAuth": "WeChat Authorization" "paymentMerchantPoolsTabWechatAuth": "WeChat Authorization",
"detailsOfPaymentMerchant": "Payment Merchant Details",
"detailsOfPaymentMerchantPool": "Merchant Pool Details",
"h5PopupConfiguration": "H5 Popup Configurations"
}, },
"batch": { "batch": {
"title": "Batch Operations", "title": "Batch Operations",

View File

@@ -380,7 +380,10 @@
"seriesGrantsDetail": "代理系列授权详情", "seriesGrantsDetail": "代理系列授权详情",
"seriesGrantPackages": "代理系列授权套餐列表", "seriesGrantPackages": "代理系列授权套餐列表",
"packageSeries": "套餐系列", "packageSeries": "套餐系列",
"packageSeriesDetail": "套餐系列详情" "packageSeriesDetail": "套餐系列详情",
"trafficAlertRules": "真流量预警规则",
"trafficAlerts": "真流量达量预警",
"trafficAlertDetail": "真流量达量预警详情"
}, },
"accountManagement": { "accountManagement": {
"title": "账号管理", "title": "账号管理",
@@ -391,7 +394,9 @@
}, },
"shopManagement": { "shopManagement": {
"title": "店铺管理", "title": "店铺管理",
"shopList": "店铺列表" "shopList": "店铺列表",
"businessUserGroups": "业务用户组",
"businessOwnerImport": "业务负责人导入"
}, },
"assetManagement": { "assetManagement": {
"title": "资产管理", "title": "资产管理",
@@ -423,9 +428,14 @@
"exportRefund": "导出退款", "exportRefund": "导出退款",
"exportAgentRecharge": "导出代理充值", "exportAgentRecharge": "导出代理充值",
"exportExchange": "导出换货", "exportExchange": "导出换货",
"exportCommissionRecord": "导出佣金记录",
"exportPackageTrafficAlert": "导出套餐真流量预警",
"exportTaskDetail": "导出任务详情", "exportTaskDetail": "导出任务详情",
"exchangeManagement": "换货管理", "exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情" "exchangeDetail": "换货单详情",
"phoneAssetAssociation": "手机号资产关联",
"phoneAssetUnbindImportTasks": "解绑导入任务",
"phoneAssetUnbindImportTaskDetail": "解绑导入任务详情"
}, },
"orderManagement": { "orderManagement": {
"title": "订单管理", "title": "订单管理",
@@ -453,14 +463,16 @@
"settings": { "settings": {
"title": "设置管理", "title": "设置管理",
"paymentSettings": "支付设置", "paymentSettings": "支付设置",
"agentSelfRecharge": "代理自充设置",
"detailsOfPaymentConfiguration": "支付配置详情", "detailsOfPaymentConfiguration": "支付配置详情",
"withdrawalSettings": "提现配置", "withdrawalSettings": "提现配置",
"passwordSettings": "密码设置", "passwordSettings": "密码设置",
"paymentMerchantPools": "商户池管理", "paymentMerchantPools": "商户池管理",
"paymentMerchantPoolsTabMerchants": "支付商户", "paymentMerchantPoolsTabMerchants": "支付商户",
"paymentMerchantPoolsTabPools": "商户池", "paymentMerchantPoolsTabPools": "商户池",
"paymentMerchantPoolsTabWechatAuth": "微信授权配置" "paymentMerchantPoolsTabWechatAuth": "微信授权配置",
"detailsOfPaymentMerchant": "支付商户详情",
"detailsOfPaymentMerchantPool": "商户池详情",
"h5PopupConfiguration": "H5运营弹窗配置"
} }
}, },
"table": { "table": {

View File

@@ -380,6 +380,7 @@ function convertBackendMenuToRoute(
path: menuUrl, path: menuUrl,
name: matchedRoute.name, name: matchedRoute.name,
component: matchedRoute.component, component: matchedRoute.component,
redirect: matchedRoute.redirect,
meta: { meta: {
...matchedRoute.meta, ...matchedRoute.meta,
title: menu.name, title: menu.name,

View File

@@ -10,6 +10,7 @@ import { useUserStore } from '@/store/modules/user'
* 不需要登录的路由白名单 * 不需要登录的路由白名单
*/ */
export const LOGIN_WHITE_LIST = [ export const LOGIN_WHITE_LIST = [
'/agent-registration',
'/auth/login', '/auth/login',
'/exception/403', '/exception/403',
'/exception/404', '/exception/404',

View File

@@ -4,6 +4,11 @@ import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit' import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration' import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
import {
BUSINESS_USER_GROUP_PERMISSIONS,
SHOP_BUSINESS_OWNER_PERMISSIONS
} from '@/config/constants'
/** /**
* 菜单列表、异步路由 * 菜单列表、异步路由
@@ -206,6 +211,37 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true, isHide: true,
keepAlive: false keepAlive: false
} }
},
// 真流量预警规则
{
path: 'traffic-alert-rules',
name: 'TrafficAlertRules',
component: RoutesAlias.TrafficAlertRules,
meta: {
title: 'menus.packageManagement.trafficAlertRules',
keepAlive: true
}
},
// 真流量达量预警记录
{
path: 'traffic-alerts',
name: 'TrafficAlerts',
component: RoutesAlias.TrafficAlerts,
meta: {
title: 'menus.packageManagement.trafficAlerts',
keepAlive: true
}
},
// 真流量达量预警记录详情
{
path: 'traffic-alerts/detail/:id',
name: 'TrafficAlertDetail',
component: RoutesAlias.TrafficAlertDetail,
meta: {
title: 'menus.packageManagement.trafficAlertDetail',
isHide: true,
keepAlive: false
}
} }
] ]
}, },
@@ -240,6 +276,30 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true, isHide: true,
keepAlive: false keepAlive: false
} }
},
// 业务用户组
{
path: 'business-user-groups',
name: 'BusinessUserGroups',
component: RoutesAlias.BusinessUserGroups,
meta: {
title: 'menus.shopManagement.businessUserGroups',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN'],
permissions: [BUSINESS_USER_GROUP_PERMISSIONS.page]
}
},
// 业务负责人导入
{
path: 'business-owner-import',
name: 'BusinessOwnerImport',
component: RoutesAlias.BusinessOwnerImport,
meta: {
title: 'menus.shopManagement.businessOwnerImport',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN'],
permissions: [SHOP_BUSINESS_OWNER_PERMISSIONS.importPage]
}
} }
] ]
}, },
@@ -359,6 +419,40 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: false keepAlive: false
} }
}, },
// 手机号资产关联
{
path: 'phone-asset-association',
name: 'PhoneAssetAssociation',
component: RoutesAlias.PhoneAssetAssociation,
meta: {
title: 'menus.assetManagement.phoneAssetAssociation',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.list],
keepAlive: true
}
},
// 解绑导入任务列表
{
path: 'phone-asset-association/unbind-import-tasks',
name: 'PhoneAssetUnbindImportTasks',
component: RoutesAlias.PhoneAssetUnbindImportTasks,
meta: {
title: 'menus.assetManagement.phoneAssetUnbindImportTasks',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportPage],
keepAlive: true
}
},
// 解绑导入任务详情
{
path: 'phone-asset-association/unbind-import-tasks/detail',
name: 'PhoneAssetUnbindImportTaskDetail',
component: RoutesAlias.PhoneAssetUnbindImportTaskDetail,
meta: {
title: 'menus.assetManagement.phoneAssetUnbindImportTaskDetail',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportDetail],
isHide: true,
keepAlive: false
}
},
// 记录管理 // 记录管理
{ {
path: 'record-management', path: 'record-management',
@@ -590,6 +684,26 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true keepAlive: true
} }
}, },
{
path: 'export-commission-record',
name: 'ExportCommissionRecordTaskList',
component: RoutesAlias.ExportCommissionRecordTaskList,
meta: {
title: 'menus.assetManagement.exportCommissionRecord',
exportTaskScene: 'commission_record',
keepAlive: true
}
},
{
path: 'export-package-traffic-alert',
name: 'ExportPackageTrafficAlertTaskList',
component: RoutesAlias.ExportPackageTrafficAlertTaskList,
meta: {
title: 'menus.assetManagement.exportPackageTrafficAlert',
exportTaskScene: 'package_traffic_alert',
keepAlive: true
}
},
{ {
path: 'export-task-detail', path: 'export-task-detail',
name: 'ExportTaskDetail', name: 'ExportTaskDetail',
@@ -915,7 +1029,8 @@ export const asyncRoutes: AppRouteRecord[] = [
title: 'menus.settings.detailsOfPaymentMerchant', title: 'menus.settings.detailsOfPaymentMerchant',
isHide: true, isHide: true,
keepAlive: false, keepAlive: false,
allowedUserTypes: [1, 2] allowedUserTypes: [1, 2],
permissions: [PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantDetail]
} }
}, },
// 商户池详情 // 商户池详情
@@ -927,7 +1042,8 @@ export const asyncRoutes: AppRouteRecord[] = [
title: 'menus.settings.detailsOfPaymentMerchantPool', title: 'menus.settings.detailsOfPaymentMerchantPool',
isHide: true, isHide: true,
keepAlive: false, keepAlive: false,
allowedUserTypes: [1, 2] allowedUserTypes: [1, 2],
permissions: [PAYMENT_MERCHANT_POOL_PERMISSIONS.poolDetail]
} }
}, },
// 支付设置详情 // 支付设置详情
@@ -963,17 +1079,6 @@ export const asyncRoutes: AppRouteRecord[] = [
roles: ['R_SUPER', 'R_ADMIN'] roles: ['R_SUPER', 'R_ADMIN']
} }
}, },
// 代理自充设置(跳转系统配置并带上模块筛选)
{
path: 'agent-self-recharge',
name: 'AgentSelfRechargeSettings',
redirect: { path: RoutesAlias.SystemConfigs, query: { module: 'c2b.payment' } },
meta: {
title: 'menus.settings.agentSelfRecharge',
keepAlive: false,
roles: ['R_SUPER']
}
},
{ {
path: 'wecom', path: 'wecom',
name: 'WecomSettings', name: 'WecomSettings',
@@ -1016,6 +1121,18 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true, keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN'] roles: ['R_SUPER', 'R_ADMIN']
} }
},
// H5运营弹窗配置
{
path: 'h5-popup-configuration',
name: 'H5PopupConfigurations',
component: RoutesAlias.H5PopupConfigurations,
meta: {
title: 'menus.settings.h5PopupConfiguration',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [AUGUST_PERMISSIONS.h5PopupConfiguration.list]
}
} }
] ]
}, },

View File

@@ -17,6 +17,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@views/auth/login/index.vue'), component: () => import('@views/auth/login/index.vue'),
meta: { title: 'menus.login.title', isHideTab: true, setTheme: true } meta: { title: 'menus.login.title', isHideTab: true, setTheme: true }
}, },
{
path: '/agent-registration',
name: 'AgentRegistration',
component: () => import('@views/agent-registration/index.vue'),
meta: { title: '代理扫码注册', isHideTab: true, setTheme: true, noLogin: true }
},
{ {
path: '/exception', path: '/exception',
component: Home, component: Home,

View File

@@ -38,10 +38,15 @@ export enum RoutesAlias {
SeriesGrants = '/package-management/series-grants', // 代理系列授权 SeriesGrants = '/package-management/series-grants', // 代理系列授权
SeriesGrantsDetail = '/package-management/series-grants/detail', // 代理系列授权详情 SeriesGrantsDetail = '/package-management/series-grants/detail', // 代理系列授权详情
SeriesGrantPackages = '/package-management/series-grants/packages', // 代理系列授权套餐列表 SeriesGrantPackages = '/package-management/series-grants/packages', // 代理系列授权套餐列表
TrafficAlertRules = '/package-management/traffic-alert-rules', // 真流量预警规则
TrafficAlerts = '/package-management/traffic-alerts', // 真流量达量预警记录
TrafficAlertDetail = '/package-management/traffic-alerts/detail', // 真流量达量预警记录详情
// 店铺管理 // 店铺管理
Shop = '/shop-management/list', // 店铺列表 Shop = '/shop-management/list', // 店铺列表
ShopDetail = '/shop-management/detail', // 店铺详情 ShopDetail = '/shop-management/detail', // 店铺详情
BusinessUserGroups = '/shop-management/business-user-groups', // 业务用户组
BusinessOwnerImport = '/shop-management/business-owner-import', // 业务负责人导入
// 通用页面(店铺账号-企业账号) // 通用页面(店铺账号-企业账号)
EnterpriseCustomerAccounts = '/common/account-list', // 企业客户账号列表和店铺账号列表共用 EnterpriseCustomerAccounts = '/common/account-list', // 企业客户账号列表和店铺账号列表共用
@@ -79,6 +84,8 @@ export enum RoutesAlias {
ExportRefundTaskList = '/asset-management/export-task-management/export-refund', // 导出退款 ExportRefundTaskList = '/asset-management/export-task-management/export-refund', // 导出退款
ExportAgentRechargeTaskList = '/asset-management/export-task-management/export-agent-recharge', // 导出代理充值 ExportAgentRechargeTaskList = '/asset-management/export-task-management/export-agent-recharge', // 导出代理充值
ExportExchangeTaskList = '/asset-management/export-task-management/export-exchange', // 导出换货 ExportExchangeTaskList = '/asset-management/export-task-management/export-exchange', // 导出换货
ExportCommissionRecordTaskList = '/asset-management/export-task-management/export-commission-record', // 导出佣金记录
ExportPackageTrafficAlertTaskList = '/asset-management/export-task-management/export-package-traffic-alert', // 导出套餐真流量预警
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情 ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
// 订单管理 // 订单管理
@@ -112,11 +119,11 @@ export enum RoutesAlias {
PaymentMerchantPoolDetail = '/settings/payment-merchant-pools/pool-detail', // 商户池详情 PaymentMerchantPoolDetail = '/settings/payment-merchant-pools/pool-detail', // 商户池详情
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置 OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
SystemConfigs = '/settings/system-configs', // 系统配置 SystemConfigs = '/settings/system-configs', // 系统配置
AgentSelfRechargeSettings = '/settings/agent-self-recharge', // 代理自充设置
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口 WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
WecomApplications = '/settings/wecom/applications', // 企业微信应用 WecomApplications = '/settings/wecom/applications', // 企业微信应用
WecomMembers = '/settings/wecom/members', // 企业微信成员 WecomMembers = '/settings/wecom/members', // 企业微信成员
WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景 WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景
H5PopupConfigurations = '/settings/h5-popup-configuration', // H5运营弹窗配置
// 轮询管理 // 轮询管理
DataCleanup = '/polling-management/data-cleanup', // 数据清理 DataCleanup = '/polling-management/data-cleanup', // 数据清理
@@ -137,7 +144,12 @@ export enum RoutesAlias {
Drag = '/widgets/drag', // 拖拽 Drag = '/widgets/drag', // 拖拽
Charts = '/template/charts', // 图表 Charts = '/template/charts', // 图表
Map = '/template/map', // 地图 Map = '/template/map', // 地图
Calendar = '/template/calendar' // 日历 Calendar = '/template/calendar', // 日历
// 手机号资产关联
PhoneAssetAssociation = '/asset-management/phone-asset-association', // 手机号资产关联列表
PhoneAssetUnbindImportTasks = '/asset-management/phone-asset-association/unbind-import-tasks', // 解绑导入任务列表
PhoneAssetUnbindImportTaskDetail = '/asset-management/phone-asset-association/unbind-import-tasks/detail' // 解绑导入任务详情
} }
// 主页路由 - 修改为资产信息页面 // 主页路由 - 修改为资产信息页面

View File

@@ -7,7 +7,7 @@ import { AppRouteRecord } from '@/types/router'
* @returns 处理后的路由配置 * @returns 处理后的路由配置
*/ */
export const menuDataToRouter = (route: AppRouteRecord, parentPath = ''): AppRouteRecord => { export const menuDataToRouter = (route: AppRouteRecord, parentPath = ''): AppRouteRecord => {
const { id, name, component, meta, children } = route const { id, name, component, redirect, meta, children } = route
const fullPath = buildRoutePath(route, parentPath) const fullPath = buildRoutePath(route, parentPath)
@@ -16,6 +16,7 @@ export const menuDataToRouter = (route: AppRouteRecord, parentPath = ''): AppRou
name, name,
path: fullPath, path: fullPath,
component, component,
redirect,
meta, meta,
children: processChildren(children || [], fullPath) children: processChildren(children || [], fullPath)
} }

View File

@@ -0,0 +1 @@
店铺编码,操作类型,业务员登录账号,备注
1 店铺编码 操作类型 业务员登录账号 备注

View File

@@ -159,6 +159,7 @@ export interface AssetExchangeTrace {
export interface AssetResolveResponse { export interface AssetResolveResponse {
asset_type: AssetType // 资产类型card 或 device asset_type: AssetType // 资产类型card 或 device
asset_id: number // 数据库 ID asset_id: number // 数据库 ID
associated_phones?: string[] // 当前关联手机号列表(完整手机号,无关联时为空数组)
identifier?: string // 原样回传本次查询所用的标识符 identifier?: string // 原样回传本次查询所用的标识符
virtual_no: string // 虚拟号 virtual_no: string // 虚拟号
status: number // 资产状态 status: number // 资产状态

View File

@@ -0,0 +1,74 @@
/**
* 业务用户组相关类型定义 - 匹配后端 AUG26-003 接口
*/
import type { PaginationParams } from './common'
/** 业务线枚举standard 标品 / smart 智能产品 / other 其他 */
export type BusinessUserGroupBusinessLine = 'standard' | 'smart' | 'other'
/** 业务用户组响应实体 */
export interface BusinessUserGroupItem {
id: number // 业务用户组ID
code: string // 业务用户组稳定编码,创建后不可修改
name: string // 业务用户组名称
business_line: string // 所属业务线,空字符串表示未设置
business_line_name: string // 所属业务线中文名称,未设置时为空字符串
enabled: boolean // 是否启用;停用后不得新增成员,也不得作为批量目标
sort: number // 排序值
remark: string // 备注
created_at: string // 创建时间
updated_at: string // 更新时间
}
/** 业务用户组列表查询参数 */
export interface BusinessUserGroupQueryParams extends PaginationParams {
page?: number // 页码,默认 1
page_size?: number // 每页数量,默认 20最大 100
enabled?: boolean // 按启用状态过滤;不传返回全部
keyword?: string // 按稳定编码或名称模糊搜索,最多 100 字符
business_line?: BusinessUserGroupBusinessLine | '' // 按所属业务线过滤
}
/** 业务用户组分页响应 */
export interface BusinessUserGroupPageResult {
items: BusinessUserGroupItem[] | null
page?: number
size?: number
total?: number
}
/** 创建业务用户组请求 */
export interface CreateBusinessUserGroupParams {
code: string // 稳定编码1-64 字符,未删除组内唯一,创建后不可修改
name: string // 名称1-100 字符
business_line?: BusinessUserGroupBusinessLine | '' // 所属业务线,留空表示不设置
enabled?: boolean // 是否启用,默认启用
sort?: number // 排序值,非负整数,默认 0
remark?: string // 备注,最多 500 字符
}
/** 更新业务用户组请求(部分更新,全部可选) */
export interface UpdateBusinessUserGroupParams {
name?: string // 名称1-100 字符
business_line?: BusinessUserGroupBusinessLine | '' | null // 三态:缺省不改 / "" 清空 / 枚举设置
enabled?: boolean // 是否启用
sort?: number // 排序值,非负整数
remark?: string // 备注,最多 500 字符
}
/** 删除业务用户组请求 */
export interface DeleteBusinessUserGroupParams {
confirm: boolean // 确认删除,必须为 true
}
/** 批量设置/清空成员请求 */
export interface SetBusinessUserGroupMembersParams {
account_ids: number[] // 平台用户账号ID列表至少一个每个账号必须是启用平台用户
}
/** 批量设置/清空成员结果 */
export interface BusinessUserGroupMembersResult {
group_id: number // 目标业务用户组ID清空操作为 0
account_ids: number[] | null // 本次成功维护归属的平台用户账号ID列表
}

View File

@@ -384,6 +384,7 @@ export interface StandaloneIotCard {
gateway_card_imei?: string // 网关侧返回的卡 IMEI可空 gateway_card_imei?: string // 网关侧返回的卡 IMEI可空
id: number // 卡ID id: number // 卡ID
iccid: string // ICCID iccid: string // ICCID
associated_phones?: string[] // 当前关联手机号列表(完整手机号,无关联时为空数组)
imsi?: string // IMSI (可选) imsi?: string // IMSI (可选)
msisdn?: string // 卡接入号 (可选) msisdn?: string // 卡接入号 (可选)
virtual_no?: string // 卡虚拟号(可空) virtual_no?: string // 卡虚拟号(可空)

View File

@@ -4,12 +4,131 @@
import { PaginationParams } from '@/types' import { PaginationParams } from '@/types'
// ==================== 提现资料资格相关 ====================
/**
* 提交/替换提现资料资格请求参数
*/
export interface WithdrawalQualificationSubmitParams {
subject_type: string // 主体类型enterprise 等)
subject_code: string // 主体证件号
legal_person_id_card: string // 法人身份证号
contract_file_key: string // 合同附件 Key
id_card_front_file_key: string // 身份证正面 Key
id_card_back_file_key: string // 身份证背面 Key
business_license_file_key?: string // 营业执照 Key
shop_front_file_key?: string // 门头照 Key
invoice_file_key?: string // 发票附件 Key
invoice_title?: string // 发票抬头
invoice_subject_code?: string // 发票主体证件号
}
/**
* 提现资料资格项
*/
export interface WithdrawalQualificationItem {
id: number // 资格ID
shop_id: number // 店铺ID
shop_name: string // 店铺名称
subject_type: string // 主体类型
subject_type_name: string // 主体类型名称
subject_code_masked: string // 脱敏后主体证件号
legal_person_id_card_masked: string // 脱敏后法人身份证号
status: number // 状态0 待审批等)
status_name: string // 状态名称
approval_instance_id?: number // 审批实例ID
approval_status?: number // 审批状态
approval_status_name?: string // 审批状态名称
contract_file_key?: string // 合同附件 Key
id_card_front_file_key?: string // 身份证正面 Key
id_card_back_file_key?: string // 身份证背面 Key
business_license_file_key?: string // 营业执照 Key
shop_front_file_key?: string // 门头照 Key
invoice_file_key?: string // 发票附件 Key
invoice_title?: string // 发票抬头
invalid_reason?: string // 作废原因
invalidated_at?: string | null // 作废时间
created_at: string // 创建时间
updated_at: string // 更新时间
}
/**
* 提现资料资格查询参数
*/
export interface WithdrawalQualificationQueryParams extends PaginationParams {
status?: number // 状态筛选
}
/**
* 提现资料资格分页结果
*/
export interface WithdrawalQualificationPageResult {
items: WithdrawalQualificationItem[] | null
page: number
size: number
total: number
}
/**
* 作废提现资料资格请求参数
*/
export interface VoidWithdrawalQualificationParams {
reason: string // 作废原因(必填)
}
/**
* 重提被驳回的提现请求参数
*/
export interface ResubmitWithdrawalRequestParams {
account_name: string // 收款账户名
account_number: string // 收款账号
amount: number // 提现金额(分)
withdrawal_method: WithdrawalMethod // 提现方式
invoice_keys?: string[] // 发票附件 Key 列表
}
/**
* 提现申请尝试记录项
*/
export interface WithdrawalAttemptItem {
attempt: number // 第几次尝试
status: number // 状态
status_name: string // 状态名称
reject_reason?: string // 驳回原因
created_at: string // 尝试时间
}
/**
* 提现申请详情
*/
export interface WithdrawalRequestDetail {
id: number // 提现申请ID
shop_id: number // 店铺ID
shop_name: string // 店铺名称
withdrawal_no: string // 提现单号
withdrawal_method: WithdrawalMethod // 提现方式
account_name: string // 收款账户名
account_number: string // 收款账号
amount: number // 提现金额(分)
actual_amount: number // 实际到账(分)
fee: number // 手续费(分)
fee_rate: number // 手续费率基点100=1%
status: number // 状态
status_name: string // 状态名称
reject_reason?: string // 驳回原因
anomaly_flag: number // 异常标记
anomaly_name: string // 异常名称
anomaly_reason?: string // 异常原因
attempts?: WithdrawalAttemptItem[] // 尝试记录
}
// 佣金状态 // 佣金状态
export enum CommissionStatus { export enum CommissionStatus {
FROZEN = 1, // 已冻结 FROZEN = 1, // 已冻结
UNFREEZING = 2, // 解冻中 UNFREEZING = 2, // 解冻中
RELEASED = 3, // 已发放 RELEASED = 3, // 已发放
INVALID = 4, // 已失效 INVALID = 4, // 已失效
CLAWBACK = 5, // 回溯
PENDING_CORRECTION = 99 // 待人工修正 PENDING_CORRECTION = 99 // 待人工修正
} }
@@ -146,7 +265,7 @@ export interface MyCommissionRecordItem {
export interface CommissionRecordQueryParams extends PaginationParams { export interface CommissionRecordQueryParams extends PaginationParams {
page_size?: number // 每页数量 page_size?: number // 每页数量
commission_source?: string // 佣金来源cost_diff:成本差价one_time:一次性佣金) commission_source?: string // 佣金来源cost_diff:成本差价one_time:一次性佣金)
status?: number // 状态1已入账2已失效 status?: number // 状态1已冻结2解冻中3已发放4已失效5回溯99待人工修正
start_time?: string // 开始时间 start_time?: string // 开始时间
end_time?: string // 结束时间 end_time?: string // 结束时间
iccid?: string // ICCID模糊查询 iccid?: string // ICCID模糊查询
@@ -189,7 +308,7 @@ export interface ShopCommissionRecordItem {
amount: number // 佣金金额(分) amount: number // 佣金金额(分)
balance_after: number // 入账后佣金余额(分) balance_after: number // 入账后佣金余额(分)
commission_source: string // 佣金来源cost_diff:成本差价one_time:一次性佣金) commission_source: string // 佣金来源cost_diff:成本差价one_time:一次性佣金)
status: number // 状态1已冻结, 2解冻中, 3已发放, 4已失效 status: number // 状态1已冻结, 2解冻中, 3已发放, 4已失效, 5回溯, 99待人工修正
status_name: string // 状态名称 status_name: string // 状态名称
order_id?: number // 订单ID order_id?: number // 订单ID
order_no?: string // 订单号 order_no?: string // 订单号
@@ -199,6 +318,45 @@ export interface ShopCommissionRecordItem {
created_at: string // 佣金入账时间 created_at: string // 佣金入账时间
seller_shop_id?: number // 卖家店铺ID seller_shop_id?: number // 卖家店铺ID
seller_shop_name?: string // 卖家店铺名称 seller_shop_name?: string // 卖家店铺名称
source?: ShopCommissionRecordSource // 记录来源original:原佣金, clawback:回溯明细)
released_at?: string // 佣金入账时间,回溯明细为空
withdrawable?: boolean | null // 是否可提现,仅回溯明细返回且恒为 false
original_commission_id?: number // 被回溯的原佣金记录ID仅回溯明细返回
refund_id?: number // 来源退款申请ID仅回溯明细返回
refund_no?: string // 来源退款单号,仅回溯明细返回
clawback_records?: ShopCommissionClawbackItem[] | null // 该原佣金已生成的全部回溯明细,仅原佣金返回
clawback_total_amount?: number | null // 该原佣金累计回溯金额(分,负值),仅原佣金返回
}
/**
* 佣金记录来源
*/
export type ShopCommissionRecordSource = 'original' | 'clawback'
/**
* 佣金回溯明细项(原佣金详情中返回的回溯摘要,或回溯明细行)
*/
export interface ShopCommissionClawbackItem {
id: number // 回溯明细ID
amount: number // 回溯金额(分),恒为负数
balance_after: number // 回溯后佣金余额(分),可为负数
original_commission_id: number // 被回溯的原佣金记录ID
refund_id?: number // 来源退款申请ID
refund_no?: string // 来源退款单号
status: number // 状态5:回溯)
status_name?: string // 状态名称
withdrawable?: boolean | null // 是否可提现,回溯明细恒为不可提现
created_at: string // 生成时间
}
/**
* 佣金明细详情响应
*/
export interface ShopCommissionRecordDetail {
source?: ShopCommissionRecordSource // 记录来源original:原佣金, clawback:回溯明细)
record: ShopCommissionRecordItem // 当前记录
clawback_records?: ShopCommissionClawbackItem[] | null // 该原佣金已生成的全部回溯明细,仅原佣金详情返回
original_commission?: ShopCommissionRecordItem | null // 回溯明细对应的原佣金,仅回溯详情返回
} }
/** /**

View File

@@ -39,6 +39,7 @@ export type SwitchMode = '0' | '1' // 0=自动, 1=手动
export interface Device { export interface Device {
id: number // 设备ID id: number // 设备ID
virtual_no: string // 虚拟号(原 device_no virtual_no: string // 虚拟号(原 device_no
associated_phones?: string[] // 当前关联手机号列表(完整手机号,无关联时为空数组)
device_name: string // 设备名称 device_name: string // 设备名称
device_model: string // 设备型号 device_model: string // 设备型号
device_type: string // 设备类型 device_type: string // 设备类型

View File

@@ -9,6 +9,8 @@ export type ExportTaskScene =
| 'agent_recharge' | 'agent_recharge'
| 'refund' | 'refund'
| 'exchange' | 'exchange'
| 'commission_record'
| 'package_traffic_alert'
export type ExportTaskFormat = 'xlsx' | 'csv' export type ExportTaskFormat = 'xlsx' | 'csv'

View File

@@ -0,0 +1,113 @@
/**
* H5 运营弹窗配置相关类型
* 契约来源docs/产品迭代8月份/通知.md
*/
// 命中页面集合 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值)
export type H5PopupPage = 'home' | 'asset_detail' | 'package_purchase' | 'asset_wallet_recharge'
// 投放频率 (once:每客户每配置版本仅一次, daily:每客户每配置版本每个上海自然日一次)
export type H5PopupFrequency = 'once' | 'daily'
// 受控动作 (package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值);空字符串表示无受控动作
export type H5PopupActionType = 'package_purchase' | 'asset_wallet_recharge' | ''
// 卡类型范围 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
export type H5PopupCardType = 'CMCC' | 'CUCC' | 'CTCC' | 'CBN'
/**
* H5 运营弹窗配置(列表项 / 详情 / 创建与更新响应)
*/
export interface H5PopupConfiguration {
id: number // 配置ID
title: string // 弹窗标题
content: string // 弹窗正文
pages: H5PopupPage[] | null // 命中页面集合;空数组等价于非法(页面必选)
priority: number // 优先级,数值越大越优先
frequency: H5PopupFrequency // 投放频率
frequency_text: string // 投放频率名称(中文)
enabled: boolean // 是否启用;停用后停止新投放,历史通知在展示期内仍可见
enabled_text: string // 启停状态名称(中文)
action_type: H5PopupActionType // 受控动作;空值表示无受控动作
shop_ids: number[] | null // 店铺范围;空数组表示全量
device_types: string[] | null // 设备类型范围;空数组表示全量
card_types: H5PopupCardType[] | null // 卡类型范围;空数组表示全量
starts_at: string // 生效开始时间ISO 8601
ends_at: string // 生效结束时间ISO 8601
version: number // 配置版本,每次更新递增;版本参与频率去重,旧版本通知保留原快照
creator: number // 创建人账号ID
updater: number // 最近更新人账号ID
created_at: string // 创建时间ISO 8601
updated_at: string // 最近更新时间ISO 8601启停同样刷新该时间
}
/**
* 列表查询参数
* GET /api/admin/h5-popup-configurations
*/
export interface H5PopupConfigurationQueryParams {
page?: number // 页码,默认 1110000
page_size?: number // 每页数量,默认 20最大 100
enabled?: boolean | null // 是否启用筛选
}
/**
* 列表分页响应
*/
export interface H5PopupConfigurationListResponse {
items: H5PopupConfiguration[] | null
page: number
size: number
total: number
}
/**
* 创建请求
* POST /api/admin/h5-popup-configurations
* title/content/pages/frequency/starts_at/ends_at 必填;
* shop_ids/device_types/card_types 空数组 = 全量。
*/
export interface CreateH5PopupConfigurationRequest {
title: string // 弹窗标题1100 字符
content: string // 弹窗正文12000 字符,不接受 HTML、URL 或前端路由
pages: H5PopupPage[] // 命中页面集合(非空)
priority?: number // 优先级数值越大越优先01000000
frequency: H5PopupFrequency // 投放频率
starts_at: string // 生效开始时间ISO 8601
ends_at: string // 生效结束时间ISO 8601不得早于开始时间
enabled?: boolean // 是否启用,默认由后端决定
action_type?: H5PopupActionType // 受控动作
shop_ids?: number[] // 店铺范围,空数组 = 全量
device_types?: string[] // 设备类型范围,空数组 = 全量
card_types?: H5PopupCardType[] // 卡类型范围,空数组 = 全量
}
/**
* 更新请求
* PUT /api/admin/h5-popup-configurations/{id}
* 除 id 外全部可选不传保持原值action_type 空字符串 = 清除受控动作;
* shop_ids/device_types/card_types 空数组 = 改为全量pages 空数组非法。
*/
export interface UpdateH5PopupConfigurationRequest {
id: number // 运营弹窗配置ID
title?: string // 不传保持原值1100 字符
content?: string // 不传保持原值12000 字符
pages?: H5PopupPage[] // 不传保持原值;传空数组等价于非法(页面必选)
priority?: number // 不传保持原值01000000
frequency?: H5PopupFrequency // 不传保持原值
starts_at?: string // 不传保持原值ISO 8601
ends_at?: string // 不传保持原值ISO 8601不得早于开始时间
enabled?: boolean // 不传保持原值;启停会刷新最近更新时间并影响同优先级排序
action_type?: H5PopupActionType | null // 不传保持原值;传空字符串 = 清除受控动作
shop_ids?: number[] | null // 不传保持原值;传空数组 = 改为全量
device_types?: string[] | null // 不传保持原值;传空数组 = 改为全量
card_types?: H5PopupCardType[] | null // 不传保持原值;传空数组 = 改为全量
}
/**
* 启用/停用请求体
* POST /api/admin/h5-popup-configurations/{id}/enable、/{id}/disable
*/
export interface H5PopupConfigurationIDParams {
id: number // 运营弹窗配置ID
}

View File

@@ -1,4 +1,4 @@
/** /**
* API 类型统一导出 * API 类型统一导出
*/ */
@@ -20,6 +20,7 @@ export * from './account'
// 店铺相关 // 店铺相关
export * from './shop' export * from './shop'
export * from './businessUserGroup'
// 网卡相关 // 网卡相关
export * from './card' export * from './card'
@@ -137,3 +138,12 @@ export * from './wecom'
// 员工代收款相关 // 员工代收款相关
export * from './employeeCollection' export * from './employeeCollection'
// 手机号资产关联相关
export * from './phoneAsset'
// H5 运营弹窗配置相关
export * from './h5PopupConfiguration'
// 套餐真流量预警相关
export * from './packageTrafficAlert'

View File

@@ -0,0 +1,137 @@
/**
* 套餐真流量预警相关类型
* 对应 docs/产品迭代8月份/套餐真流量预警_API_前端简版.md
* 6 个接口仅超级管理员/平台账号可访问,其他账号后端返回 403
*/
import type { BaseResponse, PaginationParams, PaginationResponse } from './common'
// ========== 预警规则 ==========
/** 预警规则列表查询参数 */
export interface PackageTrafficAlertRuleQueryParams extends PaginationParams {
package_id?: number // 按套餐商品ID过滤
enabled?: boolean // 按启用状态过滤;不传时查询全部
}
/** 预警规则项 */
export interface PackageTrafficAlertRuleItem {
id: number // 预警规则ID
package_id: number // 套餐商品ID
package_name: string // 套餐名称
real_data_mb: number // 套餐商品当前真流量额度(MB),仅用于配置校验展示,不作为预警分母
threshold_percent: number // 真流量预警阈值百分比1~100允许两位小数
enabled: boolean // 是否启用
enabled_name: string // 启用状态名称(中文)
remark: string // 备注
updated_at: string // 最近更新时间
}
/** 创建预警规则参数 */
export interface CreatePackageTrafficAlertRuleParams {
package_id: number // 套餐商品ID必须存在且真流量额度大于零
threshold_percent: number // 真流量预警阈值百分比,取值 1 至 100允许两位小数
enabled?: boolean // 是否启用(默认 true停用后扫描不再创建新预警
remark?: string // 备注,最多 500 字符
}
/** 修改预警规则参数(允许修改阈值、启停与备注;修改不影响既有预警快照) */
export interface UpdatePackageTrafficAlertRuleParams {
threshold_percent?: number // 新的真流量预警阈值百分比,取值 1 至 100允许两位小数
enabled?: boolean // 是否启用;停用后扫描不再创建新预警,既有预警保留
remark?: string // 备注,最多 500 字符
}
// ========== 预警记录 ==========
/** 资产类型iot_card:物联网卡, device:设备) */
export type PackageTrafficAlertAssetType = 'iot_card' | 'device'
/** 通知投递结果 */
export enum PackageTrafficAlertNotificationStatus {
/** 已通知 */
NOTIFIED = 1,
/** 待投递 */
PENDING_DELIVERY = 2,
/** 投递失败 */
DELIVERY_FAILED = 3,
/** 未通知(接收人已失效) */
NOT_NOTIFIED_RECEIVER_INVALID = 4,
/** 未通知(无有效业务员) */
NOT_NOTIFIED_NO_VALID_OWNER = 5
}
/** 预警记录列表查询参数(与导出筛选一致) */
export interface PackageTrafficAlertRecordQueryParams extends PaginationParams {
package_id?: number // 按阈值来源套餐商品ID过滤
shop_id?: number // 按触发时所属店铺ID过滤
business_owner_account_id?: number // 按触发时店铺业务员账号ID过滤
asset_type?: PackageTrafficAlertAssetType // 资产类型 (iot_card:物联网卡, device:设备)
asset_identifier?: string // 资产或卡标识关键词,匹配资产标识、卡标识与对应标识符快照
threshold_percent?: number // 按触发阈值快照精确过滤,允许两位小数
start_time?: string // 触发时间起始RFC3339含该时刻
end_time?: string // 触发时间截止RFC3339含该时刻
notification_status?: PackageTrafficAlertNotificationStatus // 通知投递结果过滤 (1-5)
}
/**
* 预警记录项
* 除 business_user_group_names当前值其余字段均为触发时快照
*/
export interface PackageTrafficAlertRecordItem {
id: number // 预警记录ID
package_id?: number // 阈值来源套餐商品ID
package_name?: string | null // 套餐名称快照
shop_id?: number | null // 触发时所属店铺ID
shop_name?: string | null // 触发时所属店铺名称
business_owner_account_id?: number | null // 触发时店铺业务员账号ID
business_owner_username?: string | null // 触发时店铺业务员账号名
business_user_group_names?: string[] // 当前业务用户组名称(非触发时快照,可为空数组)
asset_type?: PackageTrafficAlertAssetType | null // 资产类型快照
asset_identifier?: string | null // 资产或卡标识快照
threshold_percent?: number // 触发阈值快照
triggered_at?: string // 触发时间
notification_status?: PackageTrafficAlertNotificationStatus // 通知投递结果
notification_status_name?: string // 通知投递结果名称
}
/** 预警记录详情(在列表项基础上标识触发后归属是否变化) */
export interface PackageTrafficAlertRecordDetail extends PackageTrafficAlertRecordItem {
shop_changed_since_trigger?: boolean // 触发后店铺归属是否变化
owner_changed_since_trigger?: boolean // 触发后业务员归属是否变化
}
// ========== 导出 ==========
/** 创建预警记录导出任务参数(复用既有异步导出任务) */
export interface ExportPackageTrafficAlertParams {
format: 'xlsx' | 'csv' // 导出格式 (xlsx:Excel, csv:CSV)
package_id?: number
shop_id?: number
business_owner_account_id?: number
asset_type?: PackageTrafficAlertAssetType
asset_identifier?: string // 最多 100 字符
threshold_percent?: number
start_time?: string
end_time?: string
notification_status?: PackageTrafficAlertNotificationStatus
}
/** 创建预警记录导出任务响应 */
export interface CreatePackageTrafficAlertExportResponse {
task_id: number // 导出任务ID
task_no: string // 任务编号
status: number // 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)
status_name: string // 任务状态名称(中文)
message: string // 提示信息
}
// ========== 响应类型 ==========
export type PackageTrafficAlertRuleListResponse = PaginationResponse<PackageTrafficAlertRuleItem>
export type PackageTrafficAlertRuleResponse = BaseResponse<PackageTrafficAlertRuleItem>
export type PackageTrafficAlertRecordListResponse =
PaginationResponse<PackageTrafficAlertRecordItem>
export type PackageTrafficAlertRecordDetailResponse = BaseResponse<PackageTrafficAlertRecordDetail>
export type CreatePackageTrafficAlertExportApiResponse =
BaseResponse<CreatePackageTrafficAlertExportResponse>

View File

@@ -241,7 +241,7 @@ export const PAYMENT_CREDENTIAL_FIELD_SPECS: Record<
}, },
wechat_v2: { wechat_v2: {
required: ['wx_mch_id', 'wx_api_v2_key', 'wx_notify_url'], required: ['wx_mch_id', 'wx_api_v2_key', 'wx_notify_url'],
optional: [] optional: ['wx_client_cert_content', 'wx_client_key_content']
}, },
fuiou: { fuiou: {
required: [ required: [

View File

@@ -36,6 +36,8 @@ interface WechatPayConfigFields {
wx_serial_no: string wx_serial_no: string
wx_cert_content: string wx_cert_content: string
wx_key_content: string wx_key_content: string
wx_client_cert_content: string
wx_client_key_content: string
} }
interface AlipayConfigFields { interface AlipayConfigFields {

192
src/types/api/phoneAsset.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* 手机号资产关联相关类型
* 契约来源docs/产品迭代8月份/手机号资产关联.md
*/
// 资产类型
export type PhoneAssetType = 'iot_card' | 'device'
// 关联状态 (0:已失效, 1:有效)
export type PhoneAssetAssociationStatus = 0 | 1
// 失效方式
export type PhoneAssetInvalidationMethod = 'backend_single' | 'backend_batch' | 'csv_import'
// 建立来源(固定为 H5 短信验证)
export type PhoneAssetSource = 'h5_sms_verification'
/**
* 手机号资产关联列表项
* GET /api/admin/phone-asset-associations
*/
export interface PhoneAssetAssociation {
id: number // 关联关系ID
asset_id: number // 资产ID
asset_identifier: string // 资产当前标识(卡为 ICCID设备为虚拟号
asset_type: PhoneAssetType // 资产类型
phone: string // 关联手机号(完整值)
established_at: string // 建立时间(短信验证通过时间)
source: PhoneAssetSource // 建立来源,固定为 h5_sms_verification
status: PhoneAssetAssociationStatus // 关联状态 (0:已失效, 1:有效)
status_name: string // 关联状态中文名称
invalidated_at: string | null // 失效时间;有效关系为空
invalidation_method: PhoneAssetInvalidationMethod | null // 失效方式;有效关系为空
invalidation_method_name: string // 失效方式中文名称;有效关系为空
invalidation_reason: string // 失效原因;有效关系为空
}
/**
* 关联列表查询参数
*/
export interface PhoneAssetAssociationQueryParams {
page?: number // 页码,默认 1
page_size?: number // 每页数量,默认 20最大 100
asset_identifier?: string // 资产标识ICCID、虚拟号、IMEI、SN 或接入号,精确匹配)
phone?: string // 手机号(完整值精确匹配)
status?: PhoneAssetAssociationStatus | null // 关联状态 (0:已失效, 1:有效)
created_at_start?: string // 关联创建时间起(含)
created_at_end?: string // 关联创建时间止(含)
}
/**
* 关联列表分页响应
*/
export interface PhoneAssetAssociationListResponse {
items: PhoneAssetAssociation[] | null
page: number
size: number
total: number
}
/**
* 单条解绑响应
* DELETE /api/admin/phone-asset-associations/{id}
*/
export interface UnbindPhoneAssetAssociationResponse {
asset_id: number // 资产ID
asset_type: PhoneAssetType // 资产类型
id: number // 关联关系ID
invalidated_at: string // 失效时间
unbound_count: number // 本次解除的有效关系数,成功时为 1
}
/**
* 批量解绑资产项
*/
export interface BatchUnbindAssetItem {
asset_id: number // 资产ID
asset_type: PhoneAssetType // 资产类型 (iot_card:物联网卡, device:设备)
}
/**
* 批量解绑请求
* POST /api/admin/phone-asset-associations/batch-unbind
*/
export interface BatchUnbindPhoneAssetAssociationRequest {
assets: BatchUnbindAssetItem[] // 待解除的资产集合,按 (资产类型, 资产ID) 去重后执行
confirmed: boolean // 二次确认,必须为 true
reason: string // 解除原因1500 字符)
}
/**
* 批量解绑逐项结果
*/
export interface BatchUnbindAssetResult {
asset_id: number // 资产ID
asset_type: PhoneAssetType // 资产类型
success: boolean // 该项是否解除成功
reason: string // 该项失败原因,成功时为空
unbound_count: number // 该项解除的有效关系数
}
/**
* 批量解绑响应
*/
export interface BatchUnbindPhoneAssetAssociationResponse {
success_count: number // 解除成功的资产项数
fail_count: number // 解除失败的资产项数
items: BatchUnbindAssetResult[] | null // 逐项结果,顺序与请求去重后的资产集合一致
}
/**
* 解绑导入任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
*/
export type PhoneAssetUnbindImportTaskStatus = 1 | 2 | 3 | 4
/**
* 创建解绑导入任务请求
* POST /api/admin/phone-asset-associations/unbind-imports
*/
export interface CreatePhoneAssetUnbindImportRequest {
file_key: string // CSV 对象存储 Keyphone-unbind-imports/ 开头且扩展名为 .csv
reason: string // 任务级解绑原因1500 字符)
confirmed: boolean // 二次确认,必须为 true
}
/**
* 解绑导入任务(列表项 / 创建响应)
*/
export interface PhoneAssetUnbindImportTask {
id: number // 导入任务ID
task_no: string // 导入任务编号
file_name: string // 上传的源 CSV 文件名
status: PhoneAssetUnbindImportTaskStatus // 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
status_name: string // 任务状态中文名称
total_count: number // 数据行总数;任务级失败时为 0
success_count: number // 成功行数
fail_count: number // 失败行数
unbind_reason: string // 任务级解绑原因
error_message: string // 任务级失败原因;与行级失败原因分开记录
creator_name: string // 任务创建人名称快照
created_at: string // 创建时间
started_at: string // 开始处理时间
completed_at: string // 完成时间
}
/**
* 解绑导入任务列表分页响应
* GET /api/admin/phone-asset-associations/unbind-imports
*/
export interface PhoneAssetUnbindImportTaskListResponse {
items: PhoneAssetUnbindImportTask[] | null
page: number
size: number
total: number
}
/**
* 解绑导入任务列表查询参数
*/
export interface PhoneAssetUnbindImportTaskQueryParams {
page?: number // 页码,默认 1
page_size?: number // 每页数量,默认 20最大 100
status?: PhoneAssetUnbindImportTaskStatus | null // 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
}
/**
* 逐行状态 (3:成功, 4:失败)
*/
export type PhoneAssetUnbindImportItemStatus = 3 | 4
/**
* 解绑导入任务逐行结果
*/
export interface PhoneAssetUnbindImportItem {
line: number // 行号,自数据首行起计(表头不计入)
asset_identifier: string // 导入文件中的资产标识原文
asset_id: number // 资产ID未定位到资产时为 0
asset_type: PhoneAssetType | '' // 资产类型;未定位到资产时为空
status: PhoneAssetUnbindImportItemStatus // 行状态 (3:成功, 4:失败)
status_name: string // 行状态中文名称
reason: string // 失败原因;成功行为空
associated_phones: string[] | null // 解绑当时的完整关联手机号快照;失败行为空数组
unbound_count: number // 该行解除的有效关系数
}
/**
* 解绑导入任务详情
* GET /api/admin/phone-asset-associations/unbind-imports/{id}
*/
export interface PhoneAssetUnbindImportTaskDetail extends PhoneAssetUnbindImportTask {
items: PhoneAssetUnbindImportItem[] | null // 逐行结果明细;任务级失败时为空数组
}

View File

@@ -7,7 +7,36 @@ export enum RefundStatus {
PENDING = 1, // 待审批 PENDING = 1, // 待审批
APPROVED = 2, // 已通过 APPROVED = 2, // 已通过
REJECTED = 3, // 已拒绝 REJECTED = 3, // 已拒绝
RETURNED = 4 // 已退回 RETURNED = 4, // 已退回
REFUNDING = 5, // 原路退款处理中
REFUND_FAILED = 6 // 原路退款失败
}
// 退款方式
export enum RefundMethod {
ORIGINAL_ROUTE = 'original_route', // 原路退回
CUSTOMER_ACCOUNT = 'customer_account', // 客户收款信息
ASSET_WALLET = 'asset_wallet', // 资产钱包
AGENT_WALLET = 'agent_wallet' // 代理钱包
}
// 渠道退款状态
export enum ChannelRefundStatus {
NOT_STARTED = 0, // 未发起或不适用
PROCESSING = 1, // 处理中
SUCCEEDED = 2, // 已成功
FAILED = 3 // 已失败
}
// 失败原因分类
export enum RefundFailureReason {
CHANNEL_REJECTED = 'channel_rejected', // 渠道明确拒绝
CREDENTIAL_INVALID = 'credential_invalid', // 渠道凭证失效
INSUFFICIENT_BALANCE = 'insufficient_balance', // 渠道余额不足
TIMEOUT_UNKNOWN = 'timeout_unknown', // 超时或结果未知
APPROVAL_REJECTED = 'approval_rejected', // 企微驳回或关闭
REVOKED_AFTER_APPROVED = 'revoked_after_approved', // 企微通过后撤销
PAYMENT_FACT_INVALID = 'payment_fact_invalid' // 本地原支付事实不可用
} }
export interface RefundAttachment { export interface RefundAttachment {
@@ -42,6 +71,25 @@ export interface RefundApproval {
business_process_result?: unknown business_process_result?: unknown
} }
// 退款申请审批尝试历史项
export interface RefundAttemptItem {
id: number // 尝试记录ID
attempt_no: number // 第几次尝试
method?: RefundMethod | null // 退款方式
method_name?: string | null // 退款方式名称
refund_amount?: number | null // 退款金额(分)
frozen_actual_received_amount?: number | null // 冻结实收金额(分)
refund_reason?: string | null // 退款原因
customer_account_info?: Record<string, string> | null // 客户收款信息
customer_voucher_key?: string[] | string | null // 客户凭证键
channel_refund_request_no?: string | null // 渠道退款申请号
submitted_by_account_id?: number | null // 提交人账号ID
approval_instance_id?: number | null // 审批实例ID
approval_status?: number | null // 审批状态
approval_status_name?: string | null // 审批状态名称
created_at: string // 创建时间
}
// 退款申请 // 退款申请
export interface Refund { export interface Refund {
id: number id: number
@@ -55,9 +103,29 @@ export interface Refund {
asset_type: string // 资产类型 asset_type: string // 资产类型
requested_refund_amount?: number | null // 申请退款金额(分) requested_refund_amount?: number | null // 申请退款金额(分)
approved_refund_amount?: number | null // 实际退款金额(分) approved_refund_amount?: number | null // 实际退款金额(分)
actual_received_amount?: number | null // 实收金额(分) actual_received_amount?: number | null // 实收金额(分,历史数据保留
status: RefundStatus status: RefundStatus
status_name?: string | null status_name?: string | null
method?: RefundMethod | null // 退款方式
method_name?: string | null // 退款方式名称
frozen_actual_received_amount?: number | null // 冻结实收金额(分)
customer_account_info?: Record<string, string> | null // 客户收款信息
channel_refund_status?: ChannelRefundStatus | null // 渠道退款状态
channel_refund_status_name?: string | null // 渠道退款状态名称
channel_refund_no?: string | null // 渠道退款单号
channel_refund_request_no?: string | null // 渠道退款申请号
channel_refund_amount?: number | null // 渠道退款金额(分)
channel_refunded_at?: string | null // 渠道退款时间
failure_reason?: RefundFailureReason | null // 失败分类
failure_reason_name?: string | null // 失败分类名称
failure_message?: string | null // 失败消息
anomaly_flag?: number // 异常标记0 正常、1 异常)
anomaly_reason?: string | null // 异常原因
latest_attempt_id?: number | null // 最新尝试记录ID
latest_approval_instance_id?: number | null // 最新审批实例ID
attempts?: RefundAttemptItem[] // 审批尝试历史
refund_package_used_mb?: number // 当前退款套餐已用量MB
refund_package_total_mb?: number // 当前退款套餐总量MB
refund_reason: string refund_reason: string
refund_voucher_key?: string[] | string // 退款凭证附件列表;历史数据可能为单字符串或逗号字符串 refund_voucher_key?: string[] | string // 退款凭证附件列表;历史数据可能为单字符串或逗号字符串
attachments?: RefundAttachment[] attachments?: RefundAttachment[]
@@ -110,17 +178,36 @@ export interface RefundListResponse {
// 创建退款申请请求 // 创建退款申请请求
export interface CreateRefundRequest { export interface CreateRefundRequest {
order_id: number order_id: number
method: RefundMethod // 退款方式(必填)
requested_refund_amount: number // 申请退款金额(分) requested_refund_amount: number // 申请退款金额(分)
actual_received_amount: number // 实收金额(分)
package_usage_id: number // 关联套餐使用记录 ID无关联记录时传 0 package_usage_id: number // 关联套餐使用记录 ID无关联记录时传 0
refund_reason: string refund_reason: string
refund_voucher_key: string[] customer_account_info?: Record<string, string> | null // 客户收款信息customer_account 方式必填)
refund_voucher_key?: string[] | null // 退款凭证customer_account 方式必填至少 1 个)
} }
// 重新提交退款申请请求 // 重新提交退款申请请求
export interface ResubmitRefundRequest { export interface ResubmitRefundRequest {
method?: RefundMethod | null // 退款方式(不传沿用原值)
requested_refund_amount?: number requested_refund_amount?: number
actual_received_amount?: number
attachments?: RefundAttachment[]
refund_reason?: string refund_reason?: string
customer_account_info?: Record<string, string> | null // 客户收款信息(不传沿用原值)
refund_voucher_key?: string[] | null
attachments?: RefundAttachment[]
}
// 审批通过退款申请请求
export interface ApproveRefundRequest {
approved_refund_amount?: number | null // 审批退款金额(分)
remark?: string
}
// 审批拒绝退款申请请求
export interface RejectRefundRequest {
reject_reason: string
}
// 退回退款申请请求
export interface ReturnRefundRequest {
remark?: string
} }

View File

@@ -26,7 +26,13 @@ export interface ShopResponse {
business_owner_username: string // 平台业务员账号名 business_owner_username: string // 平台业务员账号名
business_owner_phone_summary: string // 平台业务员手机号摘要 business_owner_phone_summary: string // 平台业务员手机号摘要
business_owner_available: boolean // 是否仍可用于通知接收 business_owner_available: boolean // 是否仍可用于通知接收
distribution_code?: string // 分销码(用于生成注册二维码)
client_login_disabled: boolean // 是否禁止 C 端新登录 client_login_disabled: boolean // 是否禁止 C 端新登录
business_user_group_id: number | null // 业务用户组ID实时推导可空
business_user_group_code: string // 业务用户组编码
business_user_group_name: string // 业务用户组名称
business_user_group_enabled: boolean // 业务用户组是否启用;停用仍返回组且 enabled=false
business_user_group_business_line: string // 业务用户组业务线
} }
// 店铺列表查询参数 // 店铺列表查询参数
@@ -41,6 +47,9 @@ export interface ShopQueryParams extends PaginationParams {
contact_phone?: string // 联系电话精确查询 contact_phone?: string // 联系电话精确查询
business_owner_account_id?: number | null // 平台业务员账号ID business_owner_account_id?: number | null // 平台业务员账号ID
client_login_disabled?: boolean client_login_disabled?: boolean
business_user_group_id?: number | null // 按业务用户组ID筛选
business_line?: string | null // 按业务线筛选standard/smart/other
ungrouped?: boolean // true=无负责人或负责人无分组
page?: number // 页码 page?: number // 页码
page_size?: number // 每页数量 page_size?: number // 每页数量
} }
@@ -139,3 +148,69 @@ export interface UpdateShopCreditLimitResponse {
version: number // 更新后的钱包版本 version: number // 更新后的钱包版本
wallet_id: number // 主钱包 ID wallet_id: number // 主钱包 ID
} }
// ========== 批量交接平台业务员 ==========
/** 批量交接平台业务员请求 */
export interface BatchUpdateBusinessOwnerParams {
shop_ids: number[] // 店铺ID列表1-500自动去重
business_owner_account_id: number | null // 显式必传null=清空负责人ID=换绑
}
/** 批量交接平台业务员响应 */
export interface BatchUpdateBusinessOwnerResult {
batch_key: string // 批次标识
shop_count: number // 处理店铺数
cleared: number // 清空负责人的店铺数
}
// ========== 业务负责人 CSV 导入 ==========
/** 业务负责人导入任务行级结果 */
export interface BusinessOwnerImportRow {
line: number // 行号,从数据首行 1 开始
shop_code: string // 店铺编码
operation_type: string // 操作类型:换绑 / 清空
status: number // 3=成功 4=失败
status_name?: string // 状态中文名(成功/失败)
reason: string // 失败原因
}
/** 业务负责人导入任务详情 */
export interface BusinessOwnerImportDetail {
id: number // 导入任务ID
task_no?: string // 任务编号
file_name?: string // 文件名
creator_name?: string // 创建人
status: number // 1=待处理 2=处理中 3=已完成 4=失败
status_name?: string // 状态中文名(待处理/处理中/已完成/失败)
started_at?: string // 开始时间
completed_at?: string // 完成时间
total_count?: number
success_count?: number
fail_count?: number
error_message?: string | null // 任务级失败原因
items?: BusinessOwnerImportRow[] | null // 行级结果
created_at?: string
updated_at?: string
}
/** 创建业务负责人导入任务请求 */
export interface CreateBusinessOwnerImportParams {
file_key: string // 由 /storage/upload-urlpurpose=shop_import获取
}
/** 业务负责人导入任务列表查询参数 */
export interface BusinessOwnerImportQueryParams {
page?: number
page_size?: number
status?: number // 按状态筛选
}
/** 业务负责人导入任务列表响应 */
export interface BusinessOwnerImportPageResult {
items: BusinessOwnerImportDetail[] | null
page?: number
size?: number
total?: number
}

View File

@@ -4,6 +4,9 @@ export type WecomBusinessType =
| 'refund_approval' | 'refund_approval'
| 'offline_recharge_approval' | 'offline_recharge_approval'
| 'employee_collection_approval' | 'employee_collection_approval'
| 'agent_distribution_approval'
| 'withdrawal_qualification_approval'
| 'commission_withdrawal_approval'
export interface WecomApplication { export interface WecomApplication {
id: number id: number

View File

@@ -86,6 +86,7 @@ declare module 'vue' {
CommentItem: typeof import('./../components/custom/comment-widget/widget/CommentItem.vue')['default'] CommentItem: typeof import('./../components/custom/comment-widget/widget/CommentItem.vue')['default']
CommentWidget: typeof import('./../components/custom/comment-widget/index.vue')['default'] CommentWidget: typeof import('./../components/custom/comment-widget/index.vue')['default']
CommissionDisplay: typeof import('./../components/business/CommissionDisplay.vue')['default'] CommissionDisplay: typeof import('./../components/business/CommissionDisplay.vue')['default']
CommissionRecordDetailDialog: typeof import('./../components/business/CommissionRecordDetailDialog.vue')['default']
ContainerSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default'] ContainerSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default']
CreateRefundDialog: typeof import('./../components/business/CreateRefundDialog.vue')['default'] CreateRefundDialog: typeof import('./../components/business/CreateRefundDialog.vue')['default']
CustomerAccountDialog: typeof import('./../components/business/CustomerAccountDialog.vue')['default'] CustomerAccountDialog: typeof import('./../components/business/CustomerAccountDialog.vue')['default']
@@ -126,6 +127,7 @@ declare module 'vue' {
ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
@@ -152,6 +154,7 @@ declare module 'vue' {
PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default'] PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default']
PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default'] PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default']
RealnamePolicyDialog: typeof import('./../components/device/RealnamePolicyDialog.vue')['default'] RealnamePolicyDialog: typeof import('./../components/device/RealnamePolicyDialog.vue')['default']
RefundApprovalDialog: typeof import('./../components/business/RefundApprovalDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default'] SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default']

View File

@@ -616,7 +616,8 @@
{ {
prop: 'shop_name', prop: 'shop_name',
label: '店铺名称', label: '店铺名称',
minWidth: 150, minWidth: 160,
showOverflowTooltip: true,
formatter: (row: any) => { formatter: (row: any) => {
return row.shop_name || '-' return row.shop_name || '-'
} }

View File

@@ -0,0 +1,425 @@
<template>
<div class="agent-registration-page">
<div class="register-card">
<div class="register-header">
<h1 class="register-title">代理扫码注册</h1>
<p class="register-subtitle">填写资料提交后审核结果将通过通知告知</p>
</div>
<div v-if="submitted" class="register-result">
<ElResult icon="success" title="提交成功" sub-title="待审批审核结果将通知你">
<template #extra>
<ElButton type="primary" @click="handleFillAgain">重新填写</ElButton>
</template>
</ElResult>
</div>
<ElForm
v-else
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
class="register-form"
>
<div class="form-grid">
<ElFormItem label="分销码" prop="distribution_code">
<ElInput
v-model="form.distribution_code"
:disabled="lockedDistributionCode"
placeholder="请输入分销码"
maxlength="64"
clearable
/>
</ElFormItem>
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="form.phone" placeholder="请输入手机号" maxlength="11" clearable />
</ElFormItem>
</div>
<ElFormItem label="短信验证码" prop="code">
<div class="code-row">
<ElInput v-model="form.code" placeholder="请输入短信验证码" maxlength="6" />
<ElButton :disabled="!canSendCode" :loading="sending" @click="handleSendCode">
{{ countdownText }}
</ElButton>
</div>
</ElFormItem>
<ElFormItem label="密码" prop="password">
<ElInput
v-model="form.password"
type="password"
show-password
placeholder="请输入密码8-32 位)"
maxlength="32"
/>
<div class="strength-bar" :class="strengthClass">
<i v-for="n in 3" :key="n" :class="{ active: passwordStrength >= n }" />
</div>
</ElFormItem>
<div class="form-grid">
<ElFormItem label="店铺名称" prop="shop_name">
<ElInput
v-model="form.shop_name"
:disabled="lockedShopName"
placeholder="请输入店铺名称"
maxlength="50"
clearable
/>
</ElFormItem>
<ElFormItem label="店铺编号" prop="shop_code">
<ElInput
v-model="form.shop_code"
:disabled="lockedShopCode"
placeholder="请输入店铺编号"
maxlength="50"
clearable
/>
</ElFormItem>
</div>
<ElFormItem label="用户名" prop="username">
<ElInput v-model="form.username" placeholder="请输入登录用户名" maxlength="50" clearable />
</ElFormItem>
<div class="form-grid">
<ElFormItem label="联系人" prop="contact_name">
<ElInput v-model="form.contact_name" placeholder="选填" maxlength="50" clearable />
</ElFormItem>
<ElFormItem label="省市区">
<ElCascader
v-model="form.region"
:options="regionOptions"
placeholder="请选择省市区"
clearable
class="region-cascader"
@change="handleRegionChange"
/>
</ElFormItem>
</div>
<ElFormItem label="详细地址" prop="address">
<ElInput v-model="form.address" placeholder="选填,请输入详细地址" maxlength="200" clearable />
</ElFormItem>
<ElButton
type="primary"
size="large"
class="submit-btn"
:loading="submitting"
@click="handleSubmit"
>
提交注册
</ElButton>
</ElForm>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
import {
AgentDistributionService,
type AgentDistributionRegistrationParams
} from '@/api/modules/agentDistribution'
import { regionData } from '@/utils/constants/regionData'
const route = useRoute()
const regionOptions = regionData as CascaderOption[]
interface RegisterForm {
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
region: string[]
}
const form = reactive<RegisterForm>({
distribution_code: (route.query.distribution_code as string) || '',
phone: '',
code: '',
password: '',
shop_name: (route.query.shop_name as string) || '',
shop_code: (route.query.shop_code as string) || '',
username: '',
contact_name: '',
province: '',
city: '',
district: '',
address: '',
region: []
})
const formRef = ref<FormInstance>()
const submitted = ref(false)
const sending = ref(false)
const submitting = ref(false)
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
const rules: FormRules = {
distribution_code: [{ required: true, message: '请输入分销码', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
code: [{ required: true, message: '请输入短信验证码', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度为 8-32 位', trigger: 'blur' }
],
shop_name: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }],
shop_code: [{ required: true, message: '请输入店铺编号', trigger: 'blur' }],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
}
const lockedDistributionCode = computed(() => !!route.query.distribution_code)
const lockedShopName = computed(() => !!route.query.shop_name)
const lockedShopCode = computed(() => !!route.query.shop_code)
const canSendCode = computed(() => countdown.value <= 0 && !sending.value)
const countdownText = computed(() =>
countdown.value > 0 ? `${countdown.value}s 后重发` : '获取验证码'
)
const startCountdown = () => {
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
const handleSendCode = async () => {
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
ElMessage.warning('请先输入正确的手机号')
return
}
sending.value = true
try {
const res = await AgentDistributionService.sendCode({
phone: form.phone,
scene: 'bind_phone'
})
if (res.code === 0) {
ElMessage.success('验证码已发送')
startCountdown()
}
} catch {
// 错误提示由请求层统一处理
} finally {
sending.value = false
}
}
const passwordStrength = computed(() => {
const pwd = form.password
if (!pwd) return 0
let score = 0
if (pwd.length >= 8) score += 1
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score += 1
if (/\d/.test(pwd)) score += 1
if (/[^A-Za-z0-9]/.test(pwd)) score += 1
if (score >= 4) return 3
if (score >= 2) return 2
return 1
})
const strengthClass = computed(() => {
const map = ['', 'is-weak', 'is-medium', 'is-strong']
return map[passwordStrength.value] || ''
})
const handleRegionChange = (value: CascaderValue | null | undefined) => {
const parts = Array.isArray(value) ? value.map((item) => String(item)) : []
form.province = parts[0] || ''
form.city = parts[1] || ''
form.district = parts[2] || ''
}
const handleSubmit = () => {
if (!formRef.value) return
formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const params: AgentDistributionRegistrationParams = {
distribution_code: form.distribution_code.trim(),
phone: form.phone.trim(),
code: form.code.trim(),
password: form.password,
shop_name: form.shop_name.trim(),
shop_code: form.shop_code.trim(),
username: form.username.trim()
}
if (form.contact_name.trim()) params.contact_name = form.contact_name.trim()
if (form.province) params.province = form.province
if (form.city) params.city = form.city
if (form.district) params.district = form.district
if (form.address.trim()) params.address = form.address.trim()
const res = await AgentDistributionService.registerAgent(params)
if (res.code === 0) {
submitted.value = true
} else {
ElMessage.error('分销码不可用')
}
} catch {
ElMessage.error('分销码不可用')
} finally {
submitting.value = false
}
})
}
const handleFillAgain = () => {
submitted.value = false
formRef.value?.resetFields()
form.distribution_code = (route.query.distribution_code as string) || form.distribution_code
form.shop_name = (route.query.shop_name as string) || form.shop_name
form.shop_code = (route.query.shop_code as string) || form.shop_code
form.code = ''
}
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped lang="scss">
.agent-registration-page {
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
padding: 48px 16px 64px;
box-sizing: border-box;
background: linear-gradient(160deg, #eef3fb 0%, #f7f9fd 40%, #eef3fb 100%);
.register-card {
width: 100%;
max-width: 520px;
padding: 36px 36px 28px;
box-sizing: border-box;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgb(93 135 255 / 12%);
}
.register-header {
margin-bottom: 24px;
text-align: center;
.register-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.register-subtitle {
margin: 8px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.code-row {
display: flex;
gap: 12px;
width: 100%;
.el-input {
flex: 1;
}
.el-button {
flex: 0 0 auto;
width: 128px;
}
}
.strength-bar {
display: flex;
gap: 6px;
margin-top: 8px;
i {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--el-border-color-lighter);
transition: background-color 0.2s ease;
&.active {
background: var(--el-color-warning);
}
}
&.is-strong i.active {
background: var(--el-color-success);
}
&.is-weak i.active {
background: var(--el-color-danger);
}
}
.region-cascader {
width: 100%;
}
.submit-btn {
width: 100%;
margin-top: 8px;
}
.register-result {
padding: 8px 0;
}
@media (max-width: 768px) {
padding: 24px 12px 40px;
.register-card {
padding: 24px 16px 20px;
border-radius: 10px;
}
.form-grid {
grid-template-columns: 1fr;
}
.code-row .el-button {
width: 112px;
}
}
}
</style>

View File

@@ -47,6 +47,12 @@
border border
size="large" size="large"
> >
<!-- 关联手机号(通用) -->
<ElDescriptionsItem
label="关联手机号"
label-class-name="asset-label asset-label--associated-phones"
>{{ (cardInfo?.associated_phones || []).join('、') || '-' }}</ElDescriptionsItem
>
<!-- 卡专属字段 --> <!-- 卡专属字段 -->
<template v-if="cardInfo?.asset_type === 'card'"> <template v-if="cardInfo?.asset_type === 'card'">
<ElDescriptionsItem <ElDescriptionsItem
@@ -698,6 +704,7 @@
} }
interface AssetInfo { interface AssetInfo {
associated_phones?: string[]
asset_id: number asset_id: number
asset_type: 'card' | 'device' asset_type: 'card' | 'device'
identifier: string identifier: string

View File

@@ -135,6 +135,7 @@ export function useAssetInfo() {
// 卡专属字段 (asset_type === 'card') // 卡专属字段 (asset_type === 'card')
iccid: data.iccid || '', iccid: data.iccid || '',
msisdn: data.msisdn || '', msisdn: data.msisdn || '',
associated_phones: data.associated_phones ?? [],
imsi: data.imsi || '', imsi: data.imsi || '',
carrier_id: data.carrier_id, carrier_id: data.carrier_id,
carrier_type: data.carrier_type || '', carrier_type: data.carrier_type || '',

View File

@@ -16,6 +16,7 @@ export interface AssetInfo {
iccid?: string iccid?: string
imei?: string imei?: string
msisdn?: string msisdn?: string
associated_phones?: string[] // 当前关联手机号(完整值)
carrier_type?: string carrier_type?: string
carrier_name?: string carrier_name?: string
real_name_status?: number real_name_status?: number

View File

@@ -1617,6 +1617,7 @@
{ label: '设备号', prop: 'virtual_no' }, { label: '设备号', prop: 'virtual_no' },
{ label: 'IMEI', prop: 'imei' }, { label: 'IMEI', prop: 'imei' },
{ label: '设备名称', prop: 'device_name' }, { label: '设备名称', prop: 'device_name' },
{ label: '关联手机号', prop: 'associated_phones' },
{ label: '店铺名称', prop: 'shop_name' }, { label: '店铺名称', prop: 'shop_name' },
{ label: '套餐系列', prop: 'series_name' }, { label: '套餐系列', prop: 'series_name' },
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' }, { label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
@@ -1893,10 +1894,19 @@
showOverflowTooltip: true, showOverflowTooltip: true,
minWidth: 150 minWidth: 150
}, },
{
prop: 'associated_phones',
label: '关联手机号',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: Device) =>
row.associated_phones?.length ? row.associated_phones.join('、') : '-'
},
{ {
prop: 'shop_name', prop: 'shop_name',
label: '店铺名称', label: '店铺名称',
minWidth: 120 minWidth: 160,
showOverflowTooltip: true
}, },
{ {
prop: 'series_name', prop: 'series_name',

View File

@@ -34,7 +34,7 @@
import { ArrowLeft, Loading } from '@element-plus/icons-vue' import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import DetailPage from '@/components/common/DetailPage.vue' import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue' import type { DetailField, DetailSection } from '@/components/common/DetailPage.vue'
defineOptions({ name: 'ExchangeDetail' }) defineOptions({ name: 'ExchangeDetail' })
@@ -62,6 +62,16 @@
return types[status] || 'info' return types[status] || 'info'
} }
const getMigrationStatusType = (migrationStatus?: string): TagProps['type'] => {
const types: Record<string, TagProps['type']> = {
not_migrated: 'info',
pending: 'warning',
migrated: 'success',
failed: 'danger'
}
return types[migrationStatus || ''] || 'info'
}
// 根据流程类型动态生成详情页配置 // 根据流程类型动态生成详情页配置
const detailSections = computed<DetailSection[]>(() => { const detailSections = computed<DetailSection[]>(() => {
if (!exchangeDetail.value) return [] if (!exchangeDetail.value) return []
@@ -70,9 +80,7 @@
const sections: DetailSection[] = [] const sections: DetailSection[] = []
// 基本信息 // 基本信息
sections.push({ const basicFields: DetailField[] = [
title: '基本信息',
fields: [
{ label: '换货单号', prop: 'exchange_no' }, { label: '换货单号', prop: 'exchange_no' },
{ {
label: '状态', label: '状态',
@@ -112,9 +120,28 @@
label: '继承归属店铺', label: '继承归属店铺',
formatter: (_, data) => formatter: (_, data) =>
formatExchangeShopName(data.inherited_shop_name, data.inherited_shop_id) formatExchangeShopName(data.inherited_shop_name, data.inherited_shop_id)
},
{
label: '迁移状态',
render: (data) =>
data.migration_status
? h(
ElTag,
{ type: getMigrationStatusType(data.migration_status) },
() => data.migration_status_name || data.migration_status
)
: h('span', '--')
} }
] ]
}) if (exchangeDetail.value.migration_status === 'failed') {
basicFields.push({
label: '迁移失败原因',
prop: 'migration_failure_reason',
formatter: (value) => value || '--',
fullWidth: true
})
}
sections.push({ title: '基本信息', fields: basicFields })
// 收货信息(仅物流换货显示) // 收货信息(仅物流换货显示)
if (flowType === 'shipping') { if (flowType === 'shipping') {

View File

@@ -987,6 +987,19 @@
formatter: (row: ExchangeResponse) => formatter: (row: ExchangeResponse) =>
h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '--') h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '--')
}, },
{
prop: 'migration_status',
label: '迁移状态',
width: 130,
formatter: (row: ExchangeResponse) =>
row.migration_status
? h(
ElTag,
{ type: getMigrationStatusType(row.migration_status) },
() => row.migration_status_name || '--'
)
: '--'
},
{ {
prop: 'created_at', prop: 'created_at',
label: '创建时间', label: '创建时间',
@@ -1006,6 +1019,18 @@
return types[status] || 'info' return types[status] || 'info'
} }
const getMigrationStatusType = (
migrationStatus?: string
): 'success' | 'info' | 'warning' | 'danger' | 'primary' => {
const types: Record<string, 'success' | 'info' | 'warning' | 'danger' | 'primary'> = {
not_migrated: 'info',
pending: 'warning',
migrated: 'success',
failed: 'danger'
}
return types[migrationStatus || ''] || 'info'
}
const getAssetTypeName = (assetType?: string | null) => { const getAssetTypeName = (assetType?: string | null) => {
if (assetType === 'iot_card') return '物联网卡' if (assetType === 'iot_card') return '物联网卡'
if (assetType === 'device') return '设备' if (assetType === 'device') return '设备'
@@ -1638,7 +1663,9 @@
// 状态3已发货待确认确认完成 // 状态3已发货待确认确认完成
if (status === 3) { if (status === 3) {
if (hasAuth('exchange:complete')) { // migration_status=failed 的重试仅超管/平台账号可操作,代理/企业不渲染入口(后端以 403/1005 兜底)
const canRetryFailed = row.migration_status !== 'failed' || userType === 1 || userType === 2
if (hasAuth('exchange:complete') && canRetryFailed) {
actions.push({ actions.push({
label: '确认完成', label: '确认完成',
handler: () => handleCompleteExchange(row), handler: () => handleCompleteExchange(row),

View File

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

View File

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

View File

@@ -1946,6 +1946,7 @@
{ label: 'ICCID', prop: 'iccid' }, { label: 'ICCID', prop: 'iccid' },
{ label: 'IMEI', prop: 'gateway_card_imei' }, { label: 'IMEI', prop: 'gateway_card_imei' },
{ label: 'MSISDN', prop: 'msisdn' }, { label: 'MSISDN', prop: 'msisdn' },
{ label: '关联手机号', prop: 'associated_phones' },
{ label: '卡虚拟号', prop: 'virtual_no' }, { label: '卡虚拟号', prop: 'virtual_no' },
{ label: '运营商', prop: 'carrier_name' }, { label: '运营商', prop: 'carrier_name' },
{ label: '卡业务类型', prop: 'card_category' }, { label: '卡业务类型', prop: 'card_category' },
@@ -2092,6 +2093,14 @@
label: 'MSISDN', label: 'MSISDN',
width: 160 width: 160
}, },
{
prop: 'associated_phones',
label: '关联手机号',
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: StandaloneIotCard) =>
row.associated_phones?.length ? row.associated_phones.join('、') : '-'
},
{ {
prop: 'virtual_no', prop: 'virtual_no',
label: '卡虚拟号', label: '卡虚拟号',

View File

@@ -0,0 +1,750 @@
<template>
<ArtTableFullScreen>
<div class="phone-asset-association-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="110"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<ElButton
type="danger"
plain
:disabled="selectedRows.length === 0"
@click="openBatchUnbindDialog"
v-if="canBatchUnbind"
>
批量解绑
</ElButton>
<ElButton type="primary" plain @click="openImportDialog" v-if="canImportCreate">
CSV解绑导入
</ElButton>
<ElButton @click="goUnbindImportTasks" v-if="canImportPage">解绑导入任务</ElButton>
</template>
</ArtTableHeader>
<ArtTable
:loading="loading"
:data="associationList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@selection-change="handleSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<!-- 单条解绑 -->
<ElDialog
v-model="unbindDialogVisible"
title="解除手机号资产关联"
width="480px"
:close-on-click-modal="false"
>
<ElForm ref="unbindFormRef" :model="unbindForm" :rules="unbindRules" label-width="90px">
<ElFormItem label="资产标识">
<span>{{ unbindForm.asset_identifier || '-' }}</span>
</ElFormItem>
<ElFormItem label="手机号">
<span>{{ unbindForm.phone || '-' }}</span>
</ElFormItem>
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="unbindForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="unbindForm.confirmed">我已确认解除该关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="unbindDialogVisible = false">取消</ElButton>
<ElButton type="danger" :loading="unbindLoading" @click="confirmUnbind">确认解绑</ElButton>
</template>
</ElDialog>
<!-- 批量解绑 -->
<ElDialog
v-model="batchUnbindDialogVisible"
title="批量解除手机号资产关联"
width="520px"
:close-on-click-modal="false"
>
<ElAlert
type="warning"
:closable="false"
:title="`已选择 ${selectedRows.length} 项资产按资产类型与ID去重后执行上限 200`"
style="margin-bottom: 12px"
/>
<ElForm ref="batchFormRef" :model="batchForm" :rules="batchRules" label-width="90px">
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="batchForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="batchForm.confirmed">我已确认解除上述全部关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="batchUnbindDialogVisible = false">取消</ElButton>
<ElButton type="danger" :loading="batchUnbindLoading" @click="confirmBatchUnbind">
确认批量解绑
</ElButton>
</template>
</ElDialog>
<!-- 批量解绑结果 -->
<ElDialog v-model="batchResultVisible" title="批量解绑结果" width="560px">
<div class="batch-result-summary">
<span>成功 {{ batchResult?.success_count ?? 0 }} </span>
<span class="text-danger">失败 {{ batchResult?.fail_count ?? 0 }} </span>
</div>
<ElTable :data="batchResult?.items || []" max-height="320" size="small" border>
<ElTableColumn prop="asset_type" label="资产类型" width="110" />
<ElTableColumn prop="asset_id" label="资产ID" width="90" />
<ElTableColumn prop="success" label="结果" width="80" />
<ElTableColumn prop="unbound_count" label="解除关系数" width="100" />
<ElTableColumn prop="reason" label="失败原因" show-overflow-tooltip />
</ElTable>
<template #footer>
<ElButton type="primary" @click="batchResultVisible = false">关闭</ElButton>
</template>
</ElDialog>
<!-- CSV 解绑导入 -->
<ElDialog
v-model="importDialogVisible"
title="CSV 解绑导入"
width="520px"
:close-on-click-modal="false"
@closed="handleImportDialogClosed"
>
<div class="import-tips">
<span>模板表头</span>
<ElButton link type="primary" @click="downloadTemplate">下载模板</ElButton>
<div class="import-tips-detail">
资产标识,备注备注可选支持 UTF-8可带 BOM UTF-8 GBK 解码无行数上限
</div>
</div>
<ElForm ref="importFormRef" :model="importForm" :rules="importRules" label-width="90px">
<ElFormItem label="CSV 文件" prop="file">
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
accept=".csv"
:on-change="handleFileChange"
:on-exceed="handleFileExceed"
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">拖拽文件到此处<em>点击选择 .csv 文件</em></div>
</ElUpload>
</ElFormItem>
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="importForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写任务级解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="importForm.confirmed">我已确认按模板批量解除关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="importDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="importLoading" @click="confirmImport"
>创建导入任务</ElButton
>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElMessage,
ElTag,
FormInstance,
FormRules,
UploadFile,
UploadInstance
} from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { PhoneAssetAssociationService, StorageService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { RoutesAlias } from '@/router/routesAlias'
import { AUGUST_PERMISSIONS } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type {
BatchUnbindAssetItem,
BatchUnbindPhoneAssetAssociationResponse,
PhoneAssetAssociation,
PhoneAssetAssociationStatus
} from '@/types/api'
defineOptions({ name: 'PhoneAssetAssociation' })
const router = useRouter()
const { hasAuth } = useAuth()
const perms = AUGUST_PERMISSIONS.phoneAssetAssociation
const canBatchUnbind = computed(() => hasAuth(perms.unbindBatch))
const canImportCreate = computed(() => hasAuth(perms.unbindImportCreate))
const canImportPage = computed(() => hasAuth(perms.unbindImportPage))
const canUnbind = computed(() => hasAuth(perms.unbind))
const loading = ref(false)
const associationList = ref<PhoneAssetAssociation[]>([])
const selectedRows = ref<PhoneAssetAssociation[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const initialSearchState = {
asset_identifier: '',
phone: '',
status: undefined as PhoneAssetAssociationStatus | undefined,
dateRange: [] as string[],
created_at_start: '',
created_at_end: ''
}
const searchForm = reactive({ ...initialSearchState })
const statusOptions = [
{ label: '有效', value: 1 as const },
{ label: '已失效', value: 0 as const }
]
const searchFormItems: SearchFormItem[] = [
{
label: '资产标识',
prop: 'asset_identifier',
type: 'input',
config: { clearable: true, placeholder: 'ICCID / 虚拟号 / IMEI / SN / 接入号(精确匹配)' }
},
{
label: '手机号',
prop: 'phone',
type: 'input',
config: { clearable: true, placeholder: '完整手机号(精确匹配)' }
},
{
label: '关联状态',
prop: 'status',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: () => statusOptions
},
{
label: '创建时间',
prop: 'dateRange',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
const columnOptions = [
{ label: '资产类型', prop: 'asset_type' },
{ label: '资产标识', prop: 'asset_identifier' },
{ label: '手机号', prop: 'phone' },
{ label: '建立时间', prop: 'established_at' },
{ label: '来源', prop: 'source' },
{ label: '状态', prop: 'status' },
{ label: '失效时间', prop: 'invalidated_at' },
{ label: '失效方式', prop: 'invalidation_method_name' },
{ label: '失效原因', prop: 'invalidation_reason' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: PhoneAssetAssociation) =>
row.asset_type === 'iot_card' ? '物联网卡' : '设备'
},
{
prop: 'asset_identifier',
label: '资产标识',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: PhoneAssetAssociation) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: () => goToAsset(row)
},
row.asset_identifier
)
},
{ prop: 'phone', label: '手机号', width: 140 },
{
prop: 'established_at',
label: '建立时间',
minWidth: 170,
formatter: (row: PhoneAssetAssociation) => formatDateTime(row.established_at) || '-'
},
{
prop: 'source',
label: '来源',
width: 120,
formatter: () => 'H5 短信验证'
},
{
prop: 'status',
label: '状态',
width: 90,
formatter: (row: PhoneAssetAssociation) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info' },
() => row.status_name || (row.status === 1 ? '有效' : '已失效')
)
},
{
prop: 'invalidated_at',
label: '失效时间',
minWidth: 170,
formatter: (row: PhoneAssetAssociation) => formatDateTime(row.invalidated_at) || '-'
},
{
prop: 'invalidation_method_name',
label: '失效方式',
width: 130,
formatter: (row: PhoneAssetAssociation) => row.invalidation_method_name || '-'
},
{
prop: 'invalidation_reason',
label: '失效原因',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PhoneAssetAssociation) => row.invalidation_reason || '-'
}
])
// ========== 列表数据 ==========
const syncDateRange = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.created_at_start = searchForm.dateRange[0] || ''
searchForm.created_at_end = searchForm.dateRange[1] || ''
} else {
searchForm.created_at_start = ''
searchForm.created_at_end = ''
}
}
const toRfc3339 = (date: string, endOfDay = false) => {
if (!date) return undefined
if (date.includes('T')) return date
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}+08:00`
}
const getTableData = async () => {
loading.value = true
try {
syncDateRange()
const res = await PhoneAssetAssociationService.getAssociations({
page: pagination.page,
page_size: pagination.pageSize,
asset_identifier: searchForm.asset_identifier || undefined,
phone: searchForm.phone || undefined,
status: searchForm.status,
created_at_start: toRfc3339(searchForm.created_at_start),
created_at_end: toRfc3339(searchForm.created_at_end, true)
})
if (res.code === 0 && res.data) {
associationList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取手机号资产关联列表失败')
}
} catch (error) {
console.error('获取手机号资产关联列表失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取手机号资产关联列表失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSearch = () => {
pagination.page = 1
getTableData()
}
const handleRefresh = () => {
getTableData()
}
const handleSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const handleSelectionChange = (rows: PhoneAssetAssociation[]) => {
selectedRows.value = rows
}
const goToAsset = (row: PhoneAssetAssociation) => {
router.push({
path: RoutesAlias.AssetInformation,
query:
row.asset_type === 'iot_card'
? { iccid: row.asset_identifier }
: { virtual_no: row.asset_identifier }
})
}
const getActions = (row: PhoneAssetAssociation) => {
const actions: any[] = []
if (row.status === 1 && canUnbind.value) {
actions.push({ label: '解绑', handler: () => openUnbindDialog(row), type: 'danger' })
}
return actions
}
// ========== 单条解绑 ==========
const unbindDialogVisible = ref(false)
const unbindLoading = ref(false)
const unbindFormRef = ref<FormInstance>()
const unbindForm = reactive<{
id: number
asset_identifier: string
phone: string
reason: string
confirmed: boolean
}>({
id: 0,
asset_identifier: '',
phone: '',
reason: '',
confirmed: false
})
const unbindRules: FormRules = {
reason: [
{ required: true, message: '请填写解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const openUnbindDialog = (row: PhoneAssetAssociation) => {
unbindForm.id = row.id
unbindForm.asset_identifier = row.asset_identifier
unbindForm.phone = row.phone
unbindForm.reason = ''
unbindForm.confirmed = false
unbindDialogVisible.value = true
}
const confirmUnbind = async () => {
if (!(await unbindFormRef.value?.validate().catch(() => false))) return
unbindLoading.value = true
try {
const res = await PhoneAssetAssociationService.unbindAssociation(
unbindForm.id,
unbindForm.reason.trim(),
unbindForm.confirmed
)
if (res.code !== 0) {
ElMessage.error(res.msg || '解除关联失败')
return
}
ElMessage.success('解除关联成功')
unbindDialogVisible.value = false
getTableData()
} catch (error) {
console.error('解除关联失败:', error)
ElMessage.error(normalizeApiError(error).message || '解除关联失败')
} finally {
unbindLoading.value = false
}
}
// ========== 批量解绑 ==========
const batchUnbindDialogVisible = ref(false)
const batchUnbindLoading = ref(false)
const batchFormRef = ref<FormInstance>()
const batchForm = reactive({ reason: '', confirmed: false, assets: [] as BatchUnbindAssetItem[] })
const batchResult = ref<BatchUnbindPhoneAssetAssociationResponse | null>(null)
const batchResultVisible = ref(false)
const batchRules: FormRules = {
reason: [
{ required: true, message: '请填写解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const buildUniqueAssets = (rows: PhoneAssetAssociation[]): BatchUnbindAssetItem[] => {
const map = new Map<string, BatchUnbindAssetItem>()
rows.forEach((row) => {
if (row.status !== 1) return
const key = `${row.asset_type}:${row.asset_id}`
if (!map.has(key)) map.set(key, { asset_type: row.asset_type, asset_id: row.asset_id })
})
return Array.from(map.values()).slice(0, 200)
}
const openBatchUnbindDialog = () => {
batchForm.assets = buildUniqueAssets(selectedRows.value)
if (batchForm.assets.length === 0) {
ElMessage.warning('请选择有效关联的资产')
return
}
batchForm.reason = ''
batchForm.confirmed = false
batchResult.value = null
batchUnbindDialogVisible.value = true
}
const confirmBatchUnbind = async () => {
if (!(await batchFormRef.value?.validate().catch(() => false))) return
batchUnbindLoading.value = true
try {
const res = await PhoneAssetAssociationService.batchUnbind({
assets: batchForm.assets,
reason: batchForm.reason.trim(),
confirmed: batchForm.confirmed
})
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '批量解绑失败')
return
}
batchResult.value = res.data
batchUnbindDialogVisible.value = false
batchResultVisible.value = true
getTableData()
} catch (error) {
console.error('批量解绑失败:', error)
ElMessage.error(normalizeApiError(error).message || '批量解绑失败')
} finally {
batchUnbindLoading.value = false
}
}
// ========== CSV 解绑导入 ==========
const importDialogVisible = ref(false)
const importLoading = ref(false)
const uploadRef = ref<UploadInstance>()
const importFormRef = ref<FormInstance>()
const importForm = reactive({
file: null as File | null,
reason: '',
confirmed: false
})
const importRules: FormRules = {
file: [
{
validator: (_rule, _value, callback) => {
if (!importForm.file) callback(new Error('请选择 CSV 文件'))
else callback()
},
trigger: 'change'
}
],
reason: [
{ required: true, message: '请填写任务级解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const openImportDialog = () => {
importForm.file = null
importForm.reason = ''
importForm.confirmed = false
importDialogVisible.value = true
}
const handleImportDialogClosed = () => {
uploadRef.value?.clearFiles()
}
const handleFileChange = (file: UploadFile) => {
const rawFile = file.raw
if (!rawFile) return
if (!rawFile.name.toLowerCase().endsWith('.csv')) {
ElMessage.error('解绑导入仅支持 .csv 文件')
uploadRef.value?.clearFiles()
importForm.file = null
return
}
importForm.file = rawFile
}
const handleFileExceed = () => {
ElMessage.warning('仅支持上传一个 CSV 文件')
}
const downloadTemplate = () => {
const content = '\uFEFF资产标识,备注\n'
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '手机号资产解绑导入模板.csv'
link.click()
URL.revokeObjectURL(url)
}
const goUnbindImportTasks = () => {
router.push(RoutesAlias.PhoneAssetUnbindImportTasks)
}
const confirmImport = async () => {
if (!(await importFormRef.value?.validate().catch(() => false))) return
const file = importForm.file
if (!file) return
importLoading.value = true
try {
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
purpose: 'phone_unbind_import'
})
if (uploadUrlRes.code !== 0 || !uploadUrlRes.data) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, 'text/csv')
const res = await PhoneAssetAssociationService.createUnbindImport({
file_key,
reason: importForm.reason.trim(),
confirmed: importForm.confirmed
})
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '创建解绑导入任务失败')
return
}
ElMessage.success(`解绑导入任务已创建!任务编号:${res.data.task_no}`)
importDialogVisible.value = false
goUnbindImportTasks()
} catch (error) {
console.error('创建解绑导入任务失败:', error)
ElMessage.error(normalizeApiError(error).message || '创建解绑导入任务失败')
} finally {
importLoading.value = false
}
}
onMounted(() => {
getTableData()
})
</script>
<style scoped lang="scss">
.phone-asset-association-page {
height: 100%;
}
.batch-result-summary {
margin-bottom: 12px;
font-size: 14px;
.text-danger {
color: var(--el-color-danger);
margin-left: 16px;
}
}
.import-tips {
margin-bottom: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
.import-tips-detail {
margin-top: 4px;
line-height: 1.6;
}
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<ArtTableFullScreen>
<div class="unbind-import-task-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<div class="detail-header">
<ElButton @click="goBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">解绑导入任务详情</h2>
</div>
<div v-if="loading && !taskDetail" class="detail-loading">
<ElIcon class="is-loading" :size="36">
<Loading />
</ElIcon>
<div>加载中...</div>
</div>
<template v-else-if="taskDetail">
<DetailPage :sections="detailSections" :data="taskDetail" />
<div class="row-table-title">逐行结果</div>
<ElTable :data="taskDetail.items || []" border stripe size="default" max-height="460">
<ElTableColumn prop="line" label="行号" width="80" />
<ElTableColumn
prop="asset_identifier"
label="资产标识(原文)"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn prop="asset_type" label="资产类型" width="110">
<template #default="{ row }">
{{ row.asset_type ? (row.asset_type === 'iot_card' ? '物联网卡' : '设备') : '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="asset_id" label="资产ID" width="100">
<template #default="{ row }">
{{ row.asset_id ? row.asset_id : '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="status" label="行状态" width="90">
<template #default="{ row }">
<ElTag :type="row.status === 3 ? 'success' : 'danger'" size="small">
{{ row.status_name || (row.status === 3 ? '成功' : '失败') }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
prop="associated_phones"
label="解绑时关联手机号"
min-width="200"
show-overflow-tooltip
>
<template #default="{ row }">
{{ (row.associated_phones || []).join('、') || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="unbound_count" label="解除关系数" width="100" />
<ElTableColumn prop="reason" label="失败原因" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.reason || '-' }}
</template>
</ElTableColumn>
</ElTable>
</template>
<ElEmpty v-else description="暂无详情" />
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { PhoneAssetAssociationService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { RoutesAlias } from '@/router/routesAlias'
import { AUGUST_PERMISSIONS } from '@/config/constants'
import type {
PhoneAssetUnbindImportTaskDetail,
PhoneAssetUnbindImportTaskStatus
} from '@/types/api'
defineOptions({ name: 'PhoneAssetUnbindImportTaskDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const taskId = computed(() => Number(route.query.id || route.params.id || 0))
const loading = ref(false)
const taskDetail = ref<PhoneAssetUnbindImportTaskDetail | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const getStatusTagType = (status?: PhoneAssetUnbindImportTaskStatus) => {
const map: Record<number, 'info' | 'warning' | 'success' | 'danger'> = {
1: 'info',
2: 'warning',
3: 'success',
4: 'danger'
}
return status ? map[status] || 'info' : 'info'
}
const fetchDetail = async () => {
if (!taskId.value) {
ElMessage.error('缺少任务ID')
return
}
loading.value = true
try {
const res = await PhoneAssetAssociationService.getUnbindImportTaskDetail(taskId.value)
if (res.code === 403) {
ElMessage.error(res.msg || '无权限查看该任务详情')
return
}
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '获取解绑导入任务详情失败')
return
}
taskDetail.value = res.data
startPollingIfNeeded()
} catch (error) {
console.error('获取解绑导入任务详情失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取解绑导入任务详情失败')
} finally {
loading.value = false
}
}
const startPollingIfNeeded = () => {
const status = taskDetail.value?.status
if (status === 1 || status === 2) {
if (!pollTimer) {
pollTimer = setInterval(fetchDetail, 5000)
}
} else {
stopPolling()
}
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
const detailSections = computed((): DetailSection[] => {
const data = taskDetail.value
if (!data) return []
return [
{
title: '任务基本信息',
fields: [
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{ label: '文件名', prop: 'file_name', formatter: (value: string) => value || '-' },
{
label: '任务状态',
render: (task: PhoneAssetUnbindImportTaskDetail) =>
h(ElTag, { type: getStatusTagType(task.status) }, () => task.status_name || '-')
},
{
label: '任务级解绑原因',
prop: 'unbind_reason',
fullWidth: true,
formatter: (value: string) => value || '-'
},
{
label: '创建人',
prop: 'creator_name',
formatter: (value: string) => value || '-'
},
{
label: '创建时间',
prop: 'created_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '开始时间',
prop: 'started_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '完成时间',
prop: 'completed_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '任务级错误',
prop: 'error_message',
fullWidth: true,
formatter: (value: string) => value || '-'
}
]
},
{
title: '处理统计',
fields: [
{
label: '数据行总数',
prop: 'total_count',
formatter: (value: number) => String(value ?? 0)
},
{
label: '成功行数',
prop: 'success_count',
formatter: (value: number) => String(value ?? 0)
},
{
label: '失败行数',
prop: 'fail_count',
formatter: (value: number) => String(value ?? 0)
}
]
}
]
})
const goBack = () => {
router.push(RoutesAlias.PhoneAssetUnbindImportTasks)
}
onMounted(() => {
if (!hasAuth(AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportDetail)) {
ElMessage.warning('您没有查看解绑导入任务详情的权限')
goBack()
return
}
fetchDetail()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<style scoped lang="scss">
.unbind-import-task-detail-page {
height: 100%;
}
.detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
.detail-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.detail-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 60px 0;
color: var(--el-text-color-secondary);
}
.row-table-title {
margin: 20px 0 12px;
font-size: 15px;
font-weight: 600;
}
</style>

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