fix: some
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 7m3s

This commit is contained in:
luo
2026-09-18 10:31:14 +08:00
parent ac541b17af
commit 5e7f5eaba4
19 changed files with 463 additions and 128 deletions

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
## Context
- 仓库当前没有归档的 `openspec/specs/`,既有变更统一使用 `## ADDED Requirements` 的 delta本次沿用。
- OCR 能力由 `POST /api/admin/agent-recharges/payment-voucher-ocr` 提供,语义为「凭证图片对象键 → `external_transaction_no` 预填」,员工代收款核销申请直接复用,不新增后端接口。
- 注册接口 `POST /api/c/v1/agent-distribution-registrations` 契约中的 `shop_code` 仍为必填,改为前端生成后提交。
## Goals / Non-Goals
- Goals: 注册者自主填写店铺信息、店铺编号自动生成、用户名长度校验、免登录页不被存储检查登出、H5 可滚动;两处付款凭证支持上传后自动识别并保留手动识别。
- Non-Goals: 不新增或修改后端接口契约;不做店铺编号前端唯一性保证(由后端唯一性校验兜底);不改变 OCR 结果只预填交易流水号的约束。
## Decisions
- Decision: 店铺编号生成规则为 `S` + `yyyyMMddHHmmss` + 3 位随机数长度不超过后端字段上限50
- Rationale: 时间戳 + 随机数在客户端生成后碰撞概率低,且保持可读性;唯一性最终由后端校验。
- Decision: 核销申请复用代理充值的 OCR 接口 `POST /api/admin/agent-recharges/payment-voucher-ocr`
- Rationale: 该接口本质是通用凭证识别,仅返回交易流水号;避免为同一能力重复定义接口。
- Decision: 自动识别以「凭证上传成功」为触发点,取首个已上传凭证对象键,并用 `ocrLoading` 防重复触发;手动按钮逻辑不变。
- Rationale: 首个凭证即用户主凭证,与手动识别取键规则一致,避免自动/手动结果不一致。
- Decision: 存储兼容性检查在免登录路由上直接跳过,不执行登出。
- Rationale: 全新浏览器无任何本地存储时,`validateStorageData()` 会判定需要重新登录并跳转登录页,破坏免登录页。
## Risks / Trade-offs
- 前端生成店铺编号可能撞号 → 采用时间戳 + 随机,后端唯一性校验兜底。
- 复用 `agent-recharges` 路径的 OCR 接口语义略有偏差 → 接口本身为通用凭证识别,后续若后端提供独立接口可平滑替换。
- 上传后自动识别会多一次网络请求 → 仅上传成功时触发一次,识别期间 loading 且禁止重复触发。
## Open Questions
- 无。

View File

@@ -0,0 +1,26 @@
# Change: 代理注册表单调整与付款凭证上传后自动识别
## Why
代理扫码注册页当前把上级店铺的店铺名称与店铺编号从二维码 URL 带入并锁定注册者无法填写自己的店铺信息用户名缺少最小长度校验未登录访问注册页时会被本地存储兼容性检查强制登出并跳转登录页H5 下页面无法纵向滚动,超出内容不可见。同时,线下代充与员工代收款核销申请虽已支持人工识别付款凭证,但需要上传后再手动点击,且核销申请弹窗尚未提供该能力。
## What Changes
- 代理扫码注册页:店铺名称改为注册者自行填写;店铺编号由前端生成后随注册请求提交,二维码 URL 不再携带 `shop_name` / `shop_code`
- 代理扫码注册页:用户名校验长度必须大于 2 个字符。
- 修复未登录访问代理注册页被跳转登录页的问题(存储兼容性检查不得对免登录页执行登出)。
- 修复代理注册页 H5 内容超出视口后无法纵向滚动的问题。
- 员工代收款「创建核销申请」弹窗:新增付款凭证「识别凭证」能力,并把付款凭证字段移动到外部交易流水号上方。
- 线下代充与核销申请:付款凭证上传成功后自动识别一次交易流水号,同时保留手动「识别凭证」按钮。
## Impact
- Affected specs: `agent-distribution-registration``employee-collection``agent-recharge`
- Affected code:
- `src/views/agent-registration/index.vue`
- `src/views/shop-management/detail/index.vue`
- `src/utils/storage/storage.ts`
- `src/assets/styles/app.scss`
- `src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue`
- `src/views/finance/agent-recharge/index.vue`
- `src/components/business/VoucherUpload.vue`(如需新增上传成功事件)

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: 代理注册店铺信息填写与店铺编号生成
代理注册页 SHALL 由注册者自行填写店铺名称;店铺编号 SHALL 由前端生成并随注册请求提交为 `shop_code`;注册页 MUST NOT 从二维码 URL 带入或锁定店铺名称与店铺编号。
#### Scenario: 扫码进入注册页
- **GIVEN** 用户通过店铺分销二维码进入注册页URL 携带 `distribution_code`
- **WHEN** 页面初始化
- **THEN** 店铺名称输入框 MUST 为空且可编辑
- **AND** 店铺编号 MUST NOT 由 URL 预填或锁定
- **AND** 分销码 MUST 由 URL 带入并锁定
#### Scenario: 提交注册生成店铺编号
- **GIVEN** 注册者已填写店铺名称与其他必填项
- **WHEN** 其提交注册
- **THEN** 请求体 MUST 携带前端生成的 `shop_code`
- **AND** `shop_code` MUST 非空
- **AND** MUST NOT 因店铺编号为空阻止提交
#### Scenario: 店铺名称必填
- **GIVEN** 注册者未填写店铺名称
- **WHEN** 其提交注册
- **THEN** 前端 MUST 阻止提交并提示填写店铺名称
### Requirement: 代理注册用户名长度校验
代理注册页用户名 SHALL 至少包含 3 个字符;不满足时前端 MUST 阻止提交并提示。
#### Scenario: 用户名长度不足
- **GIVEN** 注册者输入的用户名长度为 1 或 2
- **WHEN** 其提交注册
- **THEN** 前端 MUST 阻止提交
- **AND** MUST 提示用户名长度必须大于 2 个字符

View File

@@ -0,0 +1,26 @@
## ADDED Requirements
### Requirement: 线下代充付款凭证上传后自动识别
线下代充弹窗 SHALL 在支付凭证上传成功后自动识别一次交易流水号,同时保留手动「识别凭证」按钮;自动识别失败 MUST NOT 阻断提交,且 MUST 只预填 `external_transaction_no`
#### Scenario: 上传支付凭证后自动识别
- **GIVEN** 线下代充弹窗已选择支付凭证
- **WHEN** 凭证上传成功
- **THEN** 前端 MUST 自动调用 `POST /api/admin/agent-recharges/payment-voucher-ocr`
- **AND** 识别成功时 MUST 将返回的 `external_transaction_no` 预填入交易流水号输入框
#### Scenario: 保留手动识别
- **GIVEN** 支付凭证已上传
- **WHEN** 用户点击「识别凭证」
- **THEN** 前端 MUST 支持手动触发识别
#### Scenario: 自动识别失败不阻断
- **GIVEN** 自动识别返回失败或超时
- **WHEN** 识别结束
- **THEN** 前端 MUST NOT 阻断用户手动填写与提交
- **AND** MUST NOT 清空用户已填写内容
- **AND** MUST 只预填 `external_transaction_no`,不得预填金额、付款人、付款时间或备注

View File

@@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: 核销申请付款凭证识别
创建核销申请弹窗 SHALL 将付款凭证字段置于外部交易流水号字段上方,并 SHALL 支持上传付款凭证后自动识别交易流水号预填,同时保留手动「识别凭证」按钮;识别结果 MUST 只作预填且保持可编辑,识别失败 MUST NOT 阻断提交或清空已填内容。
#### Scenario: 上传凭证后自动识别
- **GIVEN** 用户在创建核销申请弹窗上传付款凭证
- **WHEN** 凭证上传成功
- **THEN** 前端 MUST 自动调用付款凭证识别接口
- **AND** 识别成功时 MUST 将返回的 `external_transaction_no` 预填入交易流水号输入框
#### Scenario: 手动识别
- **GIVEN** 付款凭证已上传
- **WHEN** 用户点击「识别凭证」
- **THEN** 前端 MUST 调用识别接口并预填交易流水号
#### Scenario: 识别失败不阻断
- **GIVEN** 识别接口返回失败或超时
- **WHEN** 自动或手动识别结束
- **THEN** 前端 MUST 只展示提示
- **AND** MUST NOT 清空已填写的交易流水号
- **AND** MUST NOT 阻止用户提交
#### Scenario: 字段顺序
- **GIVEN** 用户打开创建核销申请弹窗
- **WHEN** 表单渲染
- **THEN** 付款凭证字段 MUST 位于外部交易流水号字段之上
### Requirement: 核销申请凭证识别接口契约
核销申请付款凭证识别 SHALL 复用 `POST /api/admin/agent-recharges/payment-voucher-ocr`,请求体 `{ payment_voucher_key }`,响应取 `external_transaction_no`;请求超时 MUST NOT 低于 30 秒,请求期间 MUST 展示 loading 并防止重复触发。
#### Scenario: 识别请求契约
- **GIVEN** 已上传至少 1 个付款凭证
- **WHEN** 前端发起识别
- **THEN** 请求体 MUST 为 `{ payment_voucher_key }`,取首个已上传凭证对象键
- **AND** 响应 MUST 只用于预填 `external_transaction_no`
- **AND** 请求超时 MUST NOT 低于 30 秒

View File

@@ -0,0 +1,27 @@
## 1. 代理注册页表单调整
- [x] 1.1 店铺名称改为注册者自行填写,移除从 URL 带入与锁定逻辑(`src/views/agent-registration/index.vue`
- [x] 1.2 店铺编号由前端生成后提交为 `shop_code`,移除 URL 带入、锁定与输入框
- [x] 1.3 用户名增加「长度必须大于 2 个字符」校验规则与提示
- [x] 1.4 店铺详情页注册二维码 URL 移除 `shop_name``shop_code` 查询参数(`src/views/shop-management/detail/index.vue`
## 2. 代理注册页缺陷修复
- [x] 2.1 修复未登录访问注册页被跳转登录页:存储兼容性检查排除免登录路由(`src/utils/storage/storage.ts`
- [x] 2.2 修复 H5 页面溢出后无法纵向滚动(`src/views/agent-registration/index.vue` / `src/assets/styles/app.scss`
## 3. 核销申请付款凭证识别
- [x] 3.1 将付款凭证字段移动到外部交易流水号字段上方(`ApplicationFormDialog.vue`
- [x] 3.2 新增付款凭证「识别凭证」按钮与 OCR 调用,复用 `POST /api/admin/agent-recharges/payment-voucher-ocr`
- [x] 3.3 付款凭证上传成功后自动识别一次,保留手动识别
- [x] 3.4 识别期间展示 loading失败只提示且不清空已填内容、不阻断提交
## 4. 线下代充上传后自动识别
- [x] 4.1 支付凭证上传成功后自动触发一次识别,保留手动「识别凭证」按钮(`src/views/finance/agent-recharge/index.vue`
## 5. 验证
- [x] 5.1 运行 `eslint``vue-tsc --noEmit``vite build --mode production` 均通过
- [x] 5.2 运行 `openspec validate update-agent-registration-and-voucher-recognition --strict` 通过

View File

@@ -22,6 +22,9 @@ export class AssetWalletService extends BaseService {
static saveAutoRenewalConfig( static saveAutoRenewalConfig(
data: UpdateAssetAutoRenewalConfigRequest data: UpdateAssetAutoRenewalConfigRequest
): Promise<BaseResponse<AssetAutoRenewalConfig>> { ): Promise<BaseResponse<AssetAutoRenewalConfig>> {
return this.put<BaseResponse<AssetAutoRenewalConfig>>('/api/admin/asset-auto-renewal-config', data) return this.put<BaseResponse<AssetAutoRenewalConfig>>(
'/api/admin/asset-auto-renewal-config',
data
)
} }
} }

View File

@@ -38,9 +38,7 @@ export const POLLING_PRIORITY_STATUS_OPTIONS: Array<{
{ label: '失败', value: 'failed' } { label: '失败', value: 'failed' }
] ]
export const getPollingPriorityStatusName = ( export const getPollingPriorityStatusName = (status?: PollingPriorityItemStatus | string | null) =>
status?: PollingPriorityItemStatus | string | null
) =>
POLLING_PRIORITY_STATUS_OPTIONS.find((item) => item.value === status)?.label || POLLING_PRIORITY_STATUS_OPTIONS.find((item) => item.value === status)?.label ||
String(status || '-') String(status || '-')

View File

@@ -104,12 +104,35 @@ class StorageCompatibilityManager {
return location.href.includes(RoutesAlias.Login) return location.href.includes(RoutesAlias.Login)
} }
/**
* 检查当前是否处于免登录页面
*/
private isOnPublicPage(): boolean {
if (this.isOnLoginPage()) return true
const route = router.currentRoute.value
if (route?.meta?.noLogin) return true
// 兜底:初始导航可能尚未完成,按地址判断免登录路由
const hashPath = location.hash.replace(/^#/, '').split('?')[0]
const pathName = location.pathname.split('?')[0]
return router.getRoutes().some((record) => {
if (!record.meta?.noLogin) return false
return (
hashPath === record.path ||
hashPath.startsWith(`${record.path}/`) ||
pathName === record.path ||
pathName.startsWith(`${record.path}/`)
)
})
}
/** /**
* 验证存储数据完整性 * 验证存储数据完整性
*/ */
validateStorageData(): boolean { validateStorageData(): boolean {
// 如果在登录页,跳过验证 // 如果在登录页或免登录页,跳过验证
if (this.isOnLoginPage()) { if (this.isOnPublicPage()) {
return true return true
} }

View File

@@ -64,27 +64,21 @@
<ElFormItem label="店铺名称" prop="shop_name"> <ElFormItem label="店铺名称" prop="shop_name">
<ElInput <ElInput
v-model="form.shop_name" v-model="form.shop_name"
:disabled="lockedShopName"
placeholder="请输入店铺名称" placeholder="请输入店铺名称"
maxlength="50" maxlength="50"
clearable clearable
/> />
</ElFormItem> </ElFormItem>
<ElFormItem label="店铺编号" prop="shop_code"> <ElFormItem label="用户名" prop="username">
<ElInput <ElInput
v-model="form.shop_code" v-model="form.username"
:disabled="lockedShopCode" placeholder="请输入登录用户名"
placeholder="请输入店铺编号"
maxlength="50" maxlength="50"
clearable clearable
/> />
</ElFormItem> </ElFormItem>
</div> </div>
<ElFormItem label="用户名" prop="username">
<ElInput v-model="form.username" placeholder="请输入登录用户名" maxlength="50" clearable />
</ElFormItem>
<div class="form-grid"> <div class="form-grid">
<ElFormItem label="联系人" prop="contact_name"> <ElFormItem label="联系人" prop="contact_name">
<ElInput v-model="form.contact_name" placeholder="选填" maxlength="50" clearable /> <ElInput v-model="form.contact_name" placeholder="选填" maxlength="50" clearable />
@@ -102,7 +96,12 @@
</div> </div>
<ElFormItem label="详细地址" prop="address"> <ElFormItem label="详细地址" prop="address">
<ElInput v-model="form.address" placeholder="选填,请输入详细地址" maxlength="200" clearable /> <ElInput
v-model="form.address"
placeholder="选填,请输入详细地址"
maxlength="200"
clearable
/>
</ElFormItem> </ElFormItem>
<ElButton <ElButton
@@ -120,7 +119,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus' import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
@@ -140,7 +139,6 @@
code: string code: string
password: string password: string
shop_name: string shop_name: string
shop_code: string
username: string username: string
contact_name: string contact_name: string
province: string province: string
@@ -155,8 +153,7 @@
phone: '', phone: '',
code: '', code: '',
password: '', password: '',
shop_name: (route.query.shop_name as string) || '', shop_name: '',
shop_code: (route.query.shop_code as string) || '',
username: '', username: '',
contact_name: '', contact_name: '',
province: '', province: '',
@@ -185,13 +182,13 @@
{ min: 8, max: 32, message: '密码长度为 8-32 位', trigger: 'blur' } { min: 8, max: 32, message: '密码长度为 8-32 位', trigger: 'blur' }
], ],
shop_name: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }], shop_name: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }],
shop_code: [{ required: true, message: '请输入店铺编号', trigger: 'blur' }], username: [
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }] { required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, message: '用户名长度必须大于 2 个字符', trigger: 'blur' }
]
} }
const lockedDistributionCode = computed(() => !!route.query.distribution_code) 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 canSendCode = computed(() => countdown.value <= 0 && !sending.value)
@@ -258,6 +255,16 @@
form.district = parts[2] || '' form.district = parts[2] || ''
} }
const generateShopCode = (): string => {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(
now.getHours()
)}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const randomPart = Math.floor(100 + Math.random() * 900)
return `S${datePart}${randomPart}`
}
const handleSubmit = () => { const handleSubmit = () => {
if (!formRef.value) return if (!formRef.value) return
formRef.value.validate(async (valid) => { formRef.value.validate(async (valid) => {
@@ -270,7 +277,7 @@
code: form.code.trim(), code: form.code.trim(),
password: form.password, password: form.password,
shop_name: form.shop_name.trim(), shop_name: form.shop_name.trim(),
shop_code: form.shop_code.trim(), shop_code: generateShopCode(),
username: form.username.trim() username: form.username.trim()
} }
if (form.contact_name.trim()) params.contact_name = form.contact_name.trim() if (form.contact_name.trim()) params.contact_name = form.contact_name.trim()
@@ -297,17 +304,28 @@
submitted.value = false submitted.value = false
formRef.value?.resetFields() formRef.value?.resetFields()
form.distribution_code = (route.query.distribution_code as string) || form.distribution_code 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 = '' form.code = ''
} }
onMounted(() => {
document.documentElement.classList.add('allow-touch-scroll')
document.body.classList.add('allow-touch-scroll')
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (timer) clearInterval(timer) if (timer) clearInterval(timer)
document.documentElement.classList.remove('allow-touch-scroll')
document.body.classList.remove('allow-touch-scroll')
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
:global(html.allow-touch-scroll),
:global(body.allow-touch-scroll) {
touch-action: pan-y;
overflow-y: auto;
}
.agent-registration-page { .agent-registration-page {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View File

@@ -1374,7 +1374,7 @@
{ {
prop: 'status_name', prop: 'status_name',
label: '状态', label: '状态',
width: 100, width: 120,
formatter: (row: WithdrawalQualificationItem) => { formatter: (row: WithdrawalQualificationItem) => {
const tagType = row.status === 0 ? 'warning' : row.status === 1 ? 'success' : 'danger' const tagType = row.status === 0 ? 'warning' : row.status === 1 ? 'success' : 'danger'
return h(ElTag, { type: tagType }, () => row.status_name || String(row.status)) return h(ElTag, { type: tagType }, () => row.status_name || String(row.status))
@@ -1389,7 +1389,8 @@
{ {
prop: 'attachments', prop: 'attachments',
label: '附件', label: '附件',
minWidth: 200, minWidth: 240,
showOverflowTooltip: true,
formatter: (row: WithdrawalQualificationItem) => qualificationAttachmentLabels(row) formatter: (row: WithdrawalQualificationItem) => qualificationAttachmentLabels(row)
}, },
{ {

View File

@@ -124,7 +124,7 @@
accept="image/*" accept="image/*"
content-type="image/jpeg" content-type="image/jpeg"
@uploading-change="voucherUploading = $event" @uploading-change="voucherUploading = $event"
@change="createFormRef?.validateField('payment_voucher_key')" @change="handleVoucherChange"
/> />
</ElFormItem> </ElFormItem>
<ElFormItem <ElFormItem
@@ -473,6 +473,7 @@
const paymentMethodOptionsLoading = ref(false) const paymentMethodOptionsLoading = ref(false)
const otherVoucherUploading = ref(false) const otherVoucherUploading = ref(false)
const ocrLoading = ref(false) const ocrLoading = ref(false)
const recognizedVoucherKey = ref('')
// 搜索表单初始值 // 搜索表单初始值
const initialSearchState: AgentRechargeQueryParams = { const initialSearchState: AgentRechargeQueryParams = {
@@ -1177,6 +1178,21 @@
// 已上传的支付凭证对象键OCR 识别取第一个) // 已上传的支付凭证对象键OCR 识别取第一个)
const offlineVoucherKeys = computed(() => toVoucherKeyList(createForm.payment_voucher_key)) const offlineVoucherKeys = computed(() => toVoucherKeyList(createForm.payment_voucher_key))
// 凭证变更:校验字段,并对首次上传自动识别一次(已有流水号则跳过)
const handleVoucherChange = (keys: string[] | string) => {
createFormRef.value?.validateField('payment_voucher_key')
const list = toVoucherKeyList(keys)
const firstKey = list[0] || ''
if (!firstKey) {
recognizedVoucherKey.value = ''
return
}
if (firstKey === recognizedVoucherKey.value) return
recognizedVoucherKey.value = firstKey
if (createForm.external_transaction_no?.trim()) return
void handleRecognizeVoucher()
}
// 识别付款凭证中的交易流水号:只做预填,失败不阻断人工填写 // 识别付款凭证中的交易流水号:只做预填,失败不阻断人工填写
const handleRecognizeVoucher = async () => { const handleRecognizeVoucher = async () => {
if (ocrLoading.value) return if (ocrLoading.value) return

View File

@@ -97,15 +97,6 @@
/> />
</ElFormItem> </ElFormItem>
<ElFormItem label="外部交易流水号" prop="external_transaction_no">
<ElInput
v-model="form.external_transaction_no"
maxlength="128"
show-word-limit
placeholder="请输入经人工核对的外部交易流水号"
/>
</ElFormItem>
<ElFormItem label="付款凭证" prop="payment_voucher_keys"> <ElFormItem label="付款凭证" prop="payment_voucher_keys">
<VoucherUpload <VoucherUpload
ref="uploadRef" ref="uploadRef"
@@ -113,10 +104,30 @@
voucher-name="付款凭证" voucher-name="付款凭证"
:max-count="5" :max-count="5"
@uploading-change="voucherUploading = $event" @uploading-change="voucherUploading = $event"
@change="formRef?.validateField('payment_voucher_keys')" @change="handleVoucherChange"
/> />
</ElFormItem> </ElFormItem>
<ElFormItem label="外部交易流水号" prop="external_transaction_no">
<div class="external-transaction-row">
<ElInput
v-model="form.external_transaction_no"
maxlength="128"
show-word-limit
placeholder="请输入经人工核对的外部交易流水号,或点击右侧识别凭证预填"
:disabled="ocrLoading"
/>
<ElButton
:loading="ocrLoading"
:disabled="!voucherKeys.length"
@click="handleRecognizeVoucher"
>
识别凭证
</ElButton>
</div>
<div class="external-transaction-tip"> 识别结果仅供参考请对照凭证核对后再提交 </div>
</ElFormItem>
<ElFormItem label="备注" prop="remark"> <ElFormItem label="备注" prop="remark">
<ElInput <ElInput
v-model="form.remark" v-model="form.remark"
@@ -160,7 +171,7 @@
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
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 { EmployeeCollectionService } from '@/api/modules' import { AgentRechargeService, EmployeeCollectionService } from '@/api/modules'
import type { import type {
EmployeeCollectionAllocation, EmployeeCollectionAllocation,
EmployeeCollectionAllocationRequest, EmployeeCollectionAllocationRequest,
@@ -209,6 +220,8 @@
const billsLoading = ref(false) const billsLoading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const voucherUploading = ref(false) const voucherUploading = ref(false)
const ocrLoading = ref(false)
const recognizedVoucherKey = ref('')
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([]) const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
const candidateBills = ref<BillOption[]>([]) const candidateBills = ref<BillOption[]>([])
const selectedBillIds = ref<number[]>([]) const selectedBillIds = ref<number[]>([])
@@ -230,6 +243,7 @@
const dialogTitle = computed(() => (isResubmit.value ? '修改并重新提交核销申请' : '创建核销申请')) const dialogTitle = computed(() => (isResubmit.value ? '修改并重新提交核销申请' : '创建核销申请'))
const enabledPaymentMethods = computed(() => paymentMethods.value.filter((item) => item.enabled)) const enabledPaymentMethods = computed(() => paymentMethods.value.filter((item) => item.enabled))
const enabledPaymentMethodCount = computed(() => enabledPaymentMethods.value.length) const enabledPaymentMethodCount = computed(() => enabledPaymentMethods.value.length)
const voucherKeys = computed(() => toVoucherKeyList(form.payment_voucher_keys))
const paidAmountFen = computed(() => yuanToFen(form.paid_amount) || 0) const paidAmountFen = computed(() => yuanToFen(form.paid_amount) || 0)
const totalAmountFen = computed(() => const totalAmountFen = computed(() =>
@@ -376,6 +390,7 @@
form.acting_reason = '' form.acting_reason = ''
selectedBillIds.value = [] selectedBillIds.value = []
Object.keys(amountMap).forEach((key) => delete amountMap[Number(key)]) Object.keys(amountMap).forEach((key) => delete amountMap[Number(key)])
recognizedVoucherKey.value = ''
uploadRef.value?.clearFiles() uploadRef.value?.clearFiles()
} }
@@ -457,6 +472,58 @@
return payload return payload
} }
const recognizeVoucher = async (keysOverride?: string[]): Promise<void> => {
if (ocrLoading.value) return
const keys = keysOverride?.length ? keysOverride : voucherKeys.value
if (!keys.length) {
ElMessage.warning('请先上传付款凭证')
return
}
ocrLoading.value = true
try {
const res = await AgentRechargeService.recognizePaymentVoucher({
payment_voucher_key: keys[0]
})
if (res.code !== 0) {
ElMessage.warning(res.msg || '识别失败,请手动填写交易流水号')
return
}
const transactionNo = res.data?.external_transaction_no
if (!transactionNo) {
ElMessage.warning('未识别到交易流水号,请手动填写')
return
}
form.external_transaction_no = transactionNo
formRef.value?.validateField('external_transaction_no')
ElMessage.success('已识别交易流水号,请对照凭证核对')
} catch (error) {
console.error('识别付款凭证失败:', error)
ElMessage.warning('识别失败,请手动填写交易流水号')
} finally {
ocrLoading.value = false
}
}
const handleRecognizeVoucher = () => {
void recognizeVoucher()
}
const handleVoucherChange = (keys: string[] | string) => {
formRef.value?.validateField('payment_voucher_keys')
const list = toVoucherKeyList(keys)
const firstKey = list[0] || ''
if (!firstKey) {
recognizedVoucherKey.value = ''
return
}
if (firstKey === recognizedVoucherKey.value) return
recognizedVoucherKey.value = firstKey
if (form.external_transaction_no.trim()) return
void recognizeVoucher(list)
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate() await formRef.value.validate()
@@ -571,4 +638,17 @@
gap: 12px; gap: 12px;
justify-content: flex-end; justify-content: flex-end;
} }
.external-transaction-row {
display: flex;
gap: 8px;
width: 100%;
}
.external-transaction-tip {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style> </style>

View File

@@ -537,7 +537,7 @@
{ {
prop: 'method', prop: 'method',
label: '退款方式', label: '退款方式',
width: 130, width: 160,
formatter: (row: Refund) => row.method_name || '-' formatter: (row: Refund) => row.method_name || '-'
}, },
{ {

View File

@@ -48,10 +48,10 @@
<el-dialog <el-dialog
v-model="createDialogVisible" v-model="createDialogVisible"
title="人工优先入队" title="人工优先入队"
width="520px" width="30%"
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px"> <el-form ref="createFormRef" :model="createForm" :rules="createRules">
<el-form-item label="卡ICCID" prop="card_id"> <el-form-item label="卡ICCID" prop="card_id">
<el-select <el-select
v-model="createForm.card_id" v-model="createForm.card_id"
@@ -93,8 +93,12 @@
<!-- 入队结果 --> <!-- 入队结果 -->
<el-dialog v-model="resultDialogVisible" title="入队结果" width="620px"> <el-dialog v-model="resultDialogVisible" title="入队结果" width="620px">
<div class="result-summary"> <div class="result-summary">
<el-tag type="success" class="result-item">新建 {{ createResult?.created_count ?? 0 }} </el-tag> <el-tag type="success" class="result-item"
<el-tag type="warning" class="result-item">合并 {{ createResult?.merged_count ?? 0 }} </el-tag> >新建 {{ createResult?.created_count ?? 0 }} </el-tag
>
<el-tag type="warning" class="result-item"
>合并 {{ createResult?.merged_count ?? 0 }} </el-tag
>
<span class="result-item"> <span class="result-item">
任务类型{{ (createResult?.task_type_names || []).join('、') || '-' }} 任务类型{{ (createResult?.task_type_names || []).join('、') || '-' }}
</span> </span>
@@ -129,8 +133,12 @@
<el-descriptions-item label="触发类型"> <el-descriptions-item label="触发类型">
{{ detailData.trigger_type_name }} {{ detailData.trigger_type_name }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="触发次数">{{ detailData.trigger_count }}</el-descriptions-item> <el-descriptions-item label="触发次数">{{
<el-descriptions-item label="尝试次数">{{ detailData.attempt_count }}</el-descriptions-item> detailData.trigger_count
}}</el-descriptions-item>
<el-descriptions-item label="尝试次数">{{
detailData.attempt_count
}}</el-descriptions-item>
<el-descriptions-item label="执行结果"> <el-descriptions-item label="执行结果">
{{ detailData.result_name }} {{ detailData.result_name }}
</el-descriptions-item> </el-descriptions-item>
@@ -295,10 +303,8 @@
label: '执行结果', label: '执行结果',
width: 110, width: 110,
formatter: (row: PollingPriorityItem) => formatter: (row: PollingPriorityItem) =>
h( h(ElTag, { type: getResultTagType(row.result) }, () =>
ElTag, getPollingPriorityResultName(row.result)
{ type: getResultTagType(row.result) },
() => getPollingPriorityResultName(row.result)
) )
}, },
{ {

View File

@@ -34,11 +34,7 @@
</ElRadioGroup> </ElRadioGroup>
</ElFormItem> </ElFormItem>
<ElFormItem <ElFormItem v-if="form.scope === 'specified'" label="指定主套餐" prop="package_ids">
v-if="form.scope === 'specified'"
label="指定主套餐"
prop="package_ids"
>
<ElSelect <ElSelect
v-model="form.package_ids" v-model="form.package_ids"
multiple multiple
@@ -70,6 +66,8 @@
</ElForm> </ElForm>
<div class="config-meta"> <div class="config-meta">
<span>总开关{{ config?.enabled_name || '-' }}</span>
<span>适用范围{{ config?.scope_name || '-' }}</span>
<span>配置版本v{{ config?.config_version ?? '-' }}</span> <span>配置版本v{{ config?.config_version ?? '-' }}</span>
<span>最近保存{{ config?.updater || '-' }}</span> <span>最近保存{{ config?.updater || '-' }}</span>
<span>{{ config?.updated_at ? formatDateTime(config.updated_at) : '-' }}</span> <span>{{ config?.updated_at ? formatDateTime(config.updated_at) : '-' }}</span>

View File

@@ -89,7 +89,7 @@
const code = shopData?.distribution_code const code = shopData?.distribution_code
if (!shopData || !code) return '' if (!shopData || !code) return ''
const base = `${window.location.origin}${window.location.pathname}` const base = `${window.location.origin}${window.location.pathname}`
return `${base}#/agent-registration?distribution_code=${encodeURIComponent(code)}&shop_name=${encodeURIComponent(shopData.shop_name)}&shop_code=${encodeURIComponent(shopData.shop_code)}` return `${base}#/agent-registration?distribution_code=${encodeURIComponent(code)}`
}) })
const formatBusinessOwner = (value: ShopResponse) => { const formatBusinessOwner = (value: ShopResponse) => {