Compare commits
18 Commits
99de03604d
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5079f97326 | ||
|
|
830476d49d | ||
|
|
47a2d88c9d | ||
|
|
6febc66eca | ||
|
|
45d255cabf | ||
|
|
eddfd157ad | ||
| 09225dd8bc | |||
| 563804f67f | |||
|
|
e95dc8e4f5 | ||
|
|
ca338dd345 | ||
|
|
fac05db39a | ||
|
|
2c8524485b | ||
|
|
4c0207c6e6 | ||
|
|
5b8d6610ab | ||
|
|
a036b10641 | ||
|
|
c1a7118d35 | ||
|
|
f27bbacb1e | ||
|
|
acda0d82c8 |
@@ -3,7 +3,7 @@ name: 构建并部署前端到测试环境
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- dev
|
||||
- test
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: 设置镜像标签
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||
@@ -51,8 +51,8 @@ jobs:
|
||||
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
|
||||
- name: 部署到本地(仅 main 分支)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
- name: 部署到本地(仅 develop 分支)
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
run: |
|
||||
# 确保部署目录存在
|
||||
mkdir -p ${{ env.DEPLOY_DIR }}
|
||||
|
||||
@@ -30,7 +30,7 @@ RUN pnpm run nd
|
||||
# ================================
|
||||
# 阶段 2: 运行阶段
|
||||
# ================================
|
||||
FROM --platform=linux/amd64 nginx:alpine
|
||||
FROM --platform=linux/amd64 nginx:1.31.2-alpine
|
||||
|
||||
# 使用阿里云镜像源加速
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
image: registry.boss160.cn/junhong/cmp-admin-web:latest
|
||||
@@ -9,12 +8,19 @@ services:
|
||||
- '3001:80'
|
||||
networks:
|
||||
- junhong-network
|
||||
|
||||
# === 以下是新增的修复内容 ===
|
||||
tmpfs:
|
||||
- /run:rw
|
||||
- /tmp:rw
|
||||
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/health']
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
logging:
|
||||
driver: 'json-file'
|
||||
options:
|
||||
|
||||
33
openspec/changes/add-asset-batch-realname-policy/proposal.md
Normal file
33
openspec/changes/add-asset-batch-realname-policy/proposal.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Change: 新增资产批量实名认证策略配置
|
||||
|
||||
## Why
|
||||
|
||||
运营需要一次性为多个卡或设备配置实名认证顺序。现有后台仅支持单资产设置实名认证策略,无法满足列表多选后的批量配置需求。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在后台卡列表和设备列表分别增加“批量修改实名顺序”入口,基于当前勾选资产执行配置。
|
||||
- 批量配置弹框展示已选资产数量,并提供“无需实名”“先实名后购买”“先购买后实名”三种互斥策略。
|
||||
- 卡列表调用 `POST /api/admin/iot-cards/batch-update-realname-policy`;设备列表调用 `POST /api/admin/devices/batch-update-realname-policy`。
|
||||
- 批量请求传递 `asset_ids` 和 `realname_policy`;前端限制单次提交至多 500 条,并在超限时阻止提交并明确提示。
|
||||
- 设备批量配置弹框提示“实际H5流程由设备策略决定”。
|
||||
- 后端批量接口按全成全败处理;前端在失败时展示明确的后端业务错误,成功后刷新当前列表。
|
||||
- 前端不根据资产类型、卡类型或其他字段自行覆盖或推导实名认证策略。
|
||||
- 本提案不包含任何 H5 初始化、购买流程或 `effective_realname_policy` 的处理。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `iot-card-management`
|
||||
- `device-management`
|
||||
- Affected code:
|
||||
- `src/api/modules/asset.ts` 或对应卡、设备 API 模块
|
||||
- `src/types/api/asset.ts` 或对应卡、设备 API 类型
|
||||
- `src/views/asset-management/iot-card-management/index.vue`
|
||||
- `src/views/asset-management/device-list/index.vue`
|
||||
- API contracts:
|
||||
- `POST /api/admin/iot-cards/batch-update-realname-policy`
|
||||
- `POST /api/admin/devices/batch-update-realname-policy`
|
||||
- Out of scope:
|
||||
- H5 初始化返回字段和购买流程
|
||||
- 单资产实名认证策略设置接口 `PATCH /api/admin/assets/{identifier}/realname-mode`
|
||||
@@ -0,0 +1,54 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Device Batch Realname Policy Configuration
|
||||
|
||||
The device management list SHALL provide a `批量修改实名顺序` action for selected devices. The action SHALL submit the selected device IDs and exactly one realname policy to `POST /api/admin/devices/batch-update-realname-policy`.
|
||||
|
||||
#### Scenario: Open batch realname policy dialog for selected devices
|
||||
|
||||
- **GIVEN** 用户在设备列表勾选了一台或多台设备
|
||||
- **WHEN** 用户点击“批量修改实名顺序”
|
||||
- **THEN** 页面 MUST open a dialog that displays the selected device count
|
||||
- **AND** 页面 MUST provide mutually exclusive options `无需实名`、`先实名后购买` 和 `先购买后实名`
|
||||
- **AND** 页面 MUST display `实际H5流程由设备策略决定` 提示
|
||||
|
||||
#### Scenario: Submit selected device policy
|
||||
|
||||
- **GIVEN** 用户已选择一项实名认证策略
|
||||
- **WHEN** 用户确认批量修改
|
||||
- **THEN** 系统 MUST call `POST /api/admin/devices/batch-update-realname-policy`
|
||||
- **AND** 请求 MUST contain the selected device IDs as `asset_ids`
|
||||
- **AND** 请求 MUST contain the selected `realname_policy` as `none`、`before_order` 或 `after_order`
|
||||
|
||||
#### Scenario: Refresh devices after all-or-nothing success
|
||||
|
||||
- **WHEN** 设备批量实名认证策略接口成功返回
|
||||
- **THEN** 页面 MUST close the dialog
|
||||
- **AND** 页面 MUST refresh the current device list
|
||||
|
||||
#### Scenario: Show failed batch update reason
|
||||
|
||||
- **WHEN** 设备批量实名认证策略接口返回业务失败或请求失败
|
||||
- **THEN** 页面 MUST display the backend business reason when provided
|
||||
- **AND** 页面 MUST NOT refresh the list as a partial-success result
|
||||
|
||||
### Requirement: Device Batch Realname Policy Limit
|
||||
|
||||
The device management list MUST limit each realname policy batch submission to 500 selected devices.
|
||||
|
||||
#### Scenario: Prevent device batch submission over the limit
|
||||
|
||||
- **GIVEN** 用户在设备列表选择超过 500 台设备
|
||||
- **WHEN** 用户尝试确认批量修改实名认证策略
|
||||
- **THEN** 页面 MUST prevent the request from being sent
|
||||
- **AND** 页面 MUST display an explicit maximum-500-items error
|
||||
|
||||
### Requirement: Device Batch Policy Is User Selected
|
||||
|
||||
The device management list MUST submit the policy explicitly selected by the user and MUST NOT infer, override, or transform it from asset type, card type, or other asset fields.
|
||||
|
||||
#### Scenario: Preserve selected device policy value
|
||||
|
||||
- **GIVEN** 用户在批量配置弹框选择任一实名认证策略
|
||||
- **WHEN** 用户确认提交
|
||||
- **THEN** 请求中的 `realname_policy` MUST equal the selected option
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: IoT Card Batch Realname Policy Configuration
|
||||
|
||||
The IoT card management list SHALL provide a `批量修改实名顺序` action for selected cards. The action SHALL submit the selected card IDs and exactly one realname policy to `POST /api/admin/iot-cards/batch-update-realname-policy`.
|
||||
|
||||
#### Scenario: Open batch realname policy dialog for selected cards
|
||||
|
||||
- **GIVEN** 用户在卡列表勾选了一张或多张卡
|
||||
- **WHEN** 用户点击“批量修改实名顺序”
|
||||
- **THEN** 页面 MUST open a dialog that displays the selected card count
|
||||
- **AND** 页面 MUST provide mutually exclusive options `无需实名`、`先实名后购买` 和 `先购买后实名`
|
||||
|
||||
#### Scenario: Submit selected card policy
|
||||
|
||||
- **GIVEN** 用户已选择一项实名认证策略
|
||||
- **WHEN** 用户确认批量修改
|
||||
- **THEN** 系统 MUST call `POST /api/admin/iot-cards/batch-update-realname-policy`
|
||||
- **AND** 请求 MUST contain the selected card IDs as `asset_ids`
|
||||
- **AND** 请求 MUST contain the selected `realname_policy` as `none`、`before_order` 或 `after_order`
|
||||
|
||||
#### Scenario: Refresh cards after all-or-nothing success
|
||||
|
||||
- **WHEN** 卡批量实名认证策略接口成功返回
|
||||
- **THEN** 页面 MUST close the dialog
|
||||
- **AND** 页面 MUST refresh the current card list
|
||||
|
||||
#### Scenario: Show failed batch update reason
|
||||
|
||||
- **WHEN** 卡批量实名认证策略接口返回业务失败或请求失败
|
||||
- **THEN** 页面 MUST display the backend business reason when provided
|
||||
- **AND** 页面 MUST NOT refresh the list as a partial-success result
|
||||
|
||||
### Requirement: IoT Card Batch Realname Policy Limit
|
||||
|
||||
The IoT card management list MUST limit each realname policy batch submission to 500 selected cards.
|
||||
|
||||
#### Scenario: Prevent card batch submission over the limit
|
||||
|
||||
- **GIVEN** 用户在卡列表选择超过 500 张卡
|
||||
- **WHEN** 用户尝试确认批量修改实名认证策略
|
||||
- **THEN** 页面 MUST prevent the request from being sent
|
||||
- **AND** 页面 MUST display an explicit maximum-500-items error
|
||||
|
||||
### Requirement: IoT Card Batch Policy Is User Selected
|
||||
|
||||
The IoT card management list MUST submit the policy explicitly selected by the user and MUST NOT infer, override, or transform it from asset type, card type, or other asset fields.
|
||||
|
||||
#### Scenario: Preserve selected card policy value
|
||||
|
||||
- **GIVEN** 用户在批量配置弹框选择任一实名认证策略
|
||||
- **WHEN** 用户确认提交
|
||||
- **THEN** 请求中的 `realname_policy` MUST equal the selected option
|
||||
28
openspec/changes/add-asset-batch-realname-policy/tasks.md
Normal file
28
openspec/changes/add-asset-batch-realname-policy/tasks.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## 1. API Contract
|
||||
|
||||
- [x] 1.1 定义批量实名认证策略请求类型,包含 `asset_ids:int64[]` 与 `realname_policy:none|before_order|after_order`。
|
||||
- [x] 1.2 接入卡批量更新接口 `POST /api/admin/iot-cards/batch-update-realname-policy`。
|
||||
- [x] 1.3 接入设备批量更新接口 `POST /api/admin/devices/batch-update-realname-policy`。
|
||||
|
||||
## 2. IoT Card Batch Configuration
|
||||
|
||||
- [x] 2.1 在卡列表多选操作区增加“批量修改实名顺序”入口。
|
||||
- [x] 2.2 弹框展示已选卡数量并提供三种互斥实名认证策略。
|
||||
- [x] 2.3 超过 500 张卡时阻止提交并显示明确提示。
|
||||
- [x] 2.4 成功后关闭弹框并刷新卡列表;失败时保留选择并展示后端业务原因。
|
||||
|
||||
## 3. Device Batch Configuration
|
||||
|
||||
- [x] 3.1 在设备列表多选操作区增加“批量修改实名顺序”入口。
|
||||
- [x] 3.2 弹框展示已选设备数量并提供三种互斥实名认证策略。
|
||||
- [x] 3.3 展示“实际H5流程由设备策略决定”提示。
|
||||
- [x] 3.4 超过 500 台设备时阻止提交并显示明确提示。
|
||||
- [x] 3.5 成功后关闭弹框并刷新设备列表;失败时保留选择并展示后端业务原因。
|
||||
|
||||
## 4. Policy Integrity and Verification
|
||||
|
||||
- [x] 4.1 前端不根据资产类型、卡类型或其他字段覆盖或推导提交策略。
|
||||
- [ ] 4.2 验证卡和设备三种策略均按所选值提交,且单次 500 条以内成功。
|
||||
- [ ] 4.3 验证超过 500 条、后端全成全败失败和网络失败均显示明确错误且不部分刷新列表。
|
||||
- [ ] 4.4 验证设备弹框显示 H5 流程归属提示。
|
||||
- [x] 4.5 运行相关前端校验,并执行 `openspec validate add-asset-batch-realname-policy --strict`。
|
||||
23
openspec/changes/add-shop-contact-phone-search/proposal.md
Normal file
23
openspec/changes/add-shop-contact-phone-search/proposal.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Change: 新增店铺联系电话精确搜索
|
||||
|
||||
## Why
|
||||
|
||||
运营无法通过店铺联系电话快速定位目标店铺。店铺列表已支持名称、编号和层级等条件筛选,但缺少联系电话的精确检索入口。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在店铺列表筛选区增加“联系电话”输入框,限制输入为 11 位数字。
|
||||
- 将有效联系电话以 `contact_phone` 参数传递给现有 `GET /api/admin/shops` 查询,保持原店铺分页响应结构。
|
||||
- 空联系电话不传 `contact_phone` 参数;非法号码不发起查询并显示输入错误提示。
|
||||
- 使用搜索栏现有查询和清空能力:查询从第一页加载;清空联系电话后保留其他筛选条件并恢复不带联系电话条件的店铺列表。
|
||||
- 保持有效联系电话精确匹配;支持加载中和空结果状态。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `shop-management`
|
||||
- Affected code:
|
||||
- `src/types/api/shop.ts`
|
||||
- `src/api/modules/shop.ts`
|
||||
- `src/views/shop-management/list/index.vue`
|
||||
- API contract:
|
||||
- `GET /api/admin/shops?contact_phone=13800138000`
|
||||
@@ -0,0 +1,59 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Shop Contact Phone Search Input
|
||||
|
||||
The shop management list SHALL provide a `联系电话` search input in its filter area. The input MUST only accept an 11-digit numeric phone number for a contact phone search.
|
||||
|
||||
#### Scenario: Enter a valid contact phone
|
||||
|
||||
- **GIVEN** 用户正在查看店铺列表筛选区
|
||||
- **WHEN** 用户输入 11 位数字联系电话
|
||||
- **THEN** 页面 MUST retain the entered phone number as the `contact_phone` search value
|
||||
- **AND** 用户 MUST be able to use the existing query action to search
|
||||
|
||||
#### Scenario: Prevent invalid contact phone query
|
||||
|
||||
- **GIVEN** 用户输入的联系电话不是 11 位数字
|
||||
- **WHEN** 用户发起查询
|
||||
- **THEN** 页面 MUST NOT send a shop list request
|
||||
- **AND** 页面 MUST display an input validation error
|
||||
|
||||
### Requirement: Shop Contact Phone Query Contract
|
||||
|
||||
The shop management list SHALL use `contact_phone` as an optional exact-match query parameter of `GET /api/admin/shops` while preserving the existing shop pagination response structure.
|
||||
|
||||
#### Scenario: Query shops by exact contact phone
|
||||
|
||||
- **GIVEN** 用户输入有效的 11 位联系电话
|
||||
- **WHEN** 用户发起店铺列表查询
|
||||
- **THEN** 系统 MUST request `GET /api/admin/shops` with `contact_phone` equal to the entered value
|
||||
- **AND** 系统 MUST reset the list to the first page
|
||||
- **AND** 页面 MUST render the returned shop pagination result
|
||||
|
||||
#### Scenario: Omit empty contact phone from query
|
||||
|
||||
- **GIVEN** 联系电话筛选值为空
|
||||
- **WHEN** 系统加载店铺列表
|
||||
- **THEN** 请求 MUST NOT include `contact_phone`
|
||||
|
||||
### Requirement: Shop Contact Phone Search Reset and Result States
|
||||
|
||||
The shop management list SHALL clear the contact phone search through its existing reset or input clear interaction without affecting other selected filters. The page SHALL preserve existing loading and empty-result behavior.
|
||||
|
||||
#### Scenario: Clear contact phone while preserving other filters
|
||||
|
||||
- **GIVEN** 用户已按联系电话和其他店铺条件查询
|
||||
- **WHEN** 用户清空联系电话并重新查询
|
||||
- **THEN** 系统 MUST request the list without `contact_phone`
|
||||
- **AND** 系统 MUST retain the other selected filters
|
||||
|
||||
#### Scenario: Display no matching shop result
|
||||
|
||||
- **GIVEN** 用户输入有效的 11 位联系电话
|
||||
- **WHEN** 店铺接口返回空分页结果
|
||||
- **THEN** 页面 MUST render the existing empty table result state
|
||||
|
||||
#### Scenario: Display loading while querying contact phone
|
||||
|
||||
- **WHEN** 系统正在按有效联系电话请求店铺列表
|
||||
- **THEN** 页面 MUST render the existing list loading state until the request completes
|
||||
22
openspec/changes/add-shop-contact-phone-search/tasks.md
Normal file
22
openspec/changes/add-shop-contact-phone-search/tasks.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## 1. Query Contract
|
||||
|
||||
- [x] 1.1 在店铺列表查询参数类型中增加可选 `contact_phone`。
|
||||
- [x] 1.2 保持调用 `GET /api/admin/shops` 和原店铺分页响应结构。
|
||||
|
||||
## 2. Shop List Search UI
|
||||
|
||||
- [x] 2.1 在店铺列表筛选区新增“联系电话”输入框,限制为 11 位数字。
|
||||
- [x] 2.2 通过现有查询按钮发起联系电话筛选,并在查询时重置至第一页。
|
||||
- [x] 2.3 通过现有清空能力移除联系电话筛选,保留其他筛选条件并恢复列表。
|
||||
|
||||
## 3. Validation and Results
|
||||
|
||||
- [x] 3.1 空联系电话不传 `contact_phone` 参数。
|
||||
- [x] 3.2 联系电话不是 11 位数字时不发起请求并提示错误。
|
||||
- [x] 3.3 有效联系电话按精确值传递 `contact_phone`,并支持加载中与空结果状态。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 验证有效 11 位联系电话精确命中店铺。
|
||||
- [ ] 4.2 验证空值、清空、非法号码和无匹配结果行为正确。
|
||||
- [x] 4.3 运行相关前端校验,并执行 `openspec validate add-shop-contact-phone-search --strict`。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Change: 代理现金余额不足100元展示
|
||||
|
||||
## Why
|
||||
|
||||
当前代理资金概况缺少对现金可用余额不足的明确提示,运营人员无法快速识别需要关注的店铺资金状态。业务要求以固定阈值 100 元展示预警,不开放阈值配置,也不把信用额度计入预警文案。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在代理资金概况中展示现金余额、冻结金额和基于后端 `low_balance_warning` 的“现金余额不足100元”红色状态。
|
||||
- 复用 `GET /api/admin/shops/fund-summary`,读取 `balance`、`frozen_balance`、`cash_available`、`low_balance_warning` 字段,金额统一按分转元展示。
|
||||
- 明确前端不得自行计算 100 元阈值,也不得展示阈值输入框。
|
||||
- 不包含任何公共站内通知相关功能的实现或改造。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `commission-management`
|
||||
- Affected code:
|
||||
- `src/types/api/commission.ts`
|
||||
- `src/views/commission-management/agent-fund-overview/index.vue`
|
||||
- Out of scope:
|
||||
- 顶部通知抽屉、站内通知中心及其他公共站内通知功能的开发或改造
|
||||
- 任意余额预警阈值配置能力
|
||||
@@ -0,0 +1,47 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Agent Cash Available Low Balance Warning
|
||||
|
||||
The agent fund overview SHALL display cash balance data and a fixed-threshold low balance warning using the backend-provided `low_balance_warning` flag from `GET /api/admin/shops/fund-summary`. The frontend MUST NOT calculate the threshold itself and MUST NOT expose any threshold configuration input.
|
||||
|
||||
#### Scenario: Display normal balance state above threshold
|
||||
|
||||
- **GIVEN** 用户进入代理资金概况页面
|
||||
- **WHEN** `GET /api/admin/shops/fund-summary` 返回 `balance`、`frozen_balance`、`cash_available`
|
||||
- **AND** `low_balance_warning=false`
|
||||
- **THEN** 页面 MUST 展示现金余额和冻结金额
|
||||
- **AND** 所有金额 MUST 按分转元展示
|
||||
- **AND** 页面 MUST NOT 展示“现金余额不足100元”红色状态
|
||||
|
||||
#### Scenario: Display warning state at or below threshold
|
||||
|
||||
- **GIVEN** 用户进入代理资金概况页面
|
||||
- **WHEN** `GET /api/admin/shops/fund-summary` 返回 `low_balance_warning=true`
|
||||
- **THEN** 页面 MUST 展示“现金余额不足100元”红色状态
|
||||
- **AND** 该提示 MUST NOT 将信用额度计入文案
|
||||
|
||||
#### Scenario: Frontend does not expose threshold configuration
|
||||
|
||||
- **WHEN** 用户查看代理资金概况页面
|
||||
- **THEN** 页面 MUST NOT 展示余额预警阈值输入框或配置控件
|
||||
- **AND** 前端 MUST NOT 依据 `cash_available` 自行计算是否低于 `100` 元
|
||||
|
||||
### Requirement: Agent Fund Summary Low Balance Response Fields
|
||||
|
||||
The frontend SHALL read the low-balance display state exclusively from the `GET /api/admin/shops/fund-summary` response fields `balance:int64`, `frozen_balance:int64`, `cash_available:int64`, and `low_balance_warning:bool`.
|
||||
|
||||
#### Scenario: Read low balance response contract
|
||||
|
||||
- **WHEN** 前端请求 `GET /api/admin/shops/fund-summary`
|
||||
- **THEN** 前端 MUST 读取 `balance`、`frozen_balance`、`cash_available`、`low_balance_warning`
|
||||
- **AND** `low_balance_warning` MUST be the only source of truth for whether the warning is shown
|
||||
|
||||
### Requirement: Public Notification Features Are Out Of Scope
|
||||
|
||||
This change SHALL NOT add or modify top notification drawer, notification center, or any other public in-app notification feature.
|
||||
|
||||
#### Scenario: Deliver low balance overview without notification changes
|
||||
|
||||
- **WHEN** 本次变更落地
|
||||
- **THEN** 代理资金概况的低余额展示 MUST be available
|
||||
- **AND** 顶部通知抽屉、站内通知中心及其他公共站内通知功能 MUST NOT be changed by this proposal
|
||||
@@ -0,0 +1,15 @@
|
||||
## 1. API Contract
|
||||
|
||||
- [x] 1.1 更新代理资金概况相关数据模型,补充 `balance`、`frozen_balance`、`cash_available`、`low_balance_warning` 字段读取。
|
||||
|
||||
## 2. Agent Fund Overview UI
|
||||
|
||||
- [x] 2.1 在代理资金概况中展示现金余额、冻结金额,并统一按分转元格式化。
|
||||
- [x] 2.2 当 `low_balance_warning=true` 时展示“现金余额不足100元”红色状态。
|
||||
- [x] 2.3 不展示阈值输入框,不把信用额度计入预警文案,也不由前端自行计算余额阈值。
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 验证现金可用余额高于、等于、低于100元三种情况下的状态展示正确,且仅由 `low_balance_warning` 控制。
|
||||
- [x] 3.2 验证本次改动未引入公共站内通知相关功能。
|
||||
- [x] 3.3 运行相关前端校验,并执行 `openspec validate update-agent-fund-low-balance-warning --strict`。
|
||||
@@ -0,0 +1,26 @@
|
||||
# Change: 资产详情前代后代换货标识
|
||||
|
||||
## Why
|
||||
|
||||
资产详情虽然可展示业务状态和世代编号,但运营无法判断当前资产在换货链中的位置,也无法在具备权限时快速前往关联资产。需要基于后端返回的换货链路数据明确展示前代和后代资产关系。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 扩展资产解析响应,读取 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset`。
|
||||
- 前代资产存在时,在当前资产详情展示“换货新资产”标签与前代资产信息。
|
||||
- 后代资产存在时,在当前资产详情展示“已换出旧资产”标签与后代资产信息。
|
||||
- 中间资产同时展示前代和后代换货标识。
|
||||
- 当关联项 `can_view=true` 时允许跳转资产详情;为 `false` 时仅展示标识文本,不提供跳转操作。
|
||||
- 不由前端根据换货状态、世代编号或其他字段自行推导换货链;关联项为空时不展示对应区域。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `asset-information`
|
||||
- Related active changes: `add-asset-status-generation-display`(共享资产世代和详情展示区域)
|
||||
- Affected code:
|
||||
- `src/types/api/asset.ts`
|
||||
- `src/views/asset-management/asset-information/types.ts`
|
||||
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
|
||||
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
|
||||
- API contract:
|
||||
- `GET /api/admin/assets/resolve/{identifier}`
|
||||
@@ -0,0 +1,60 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Asset Exchange Generation Trace Display
|
||||
|
||||
The asset information page SHALL display exchange generation relationships exclusively from `exchange_trace.previous_asset` and `exchange_trace.next_asset` returned by `GET /api/admin/assets/resolve/{identifier}`. The frontend MUST NOT infer the exchange chain from asset status, generation, or exchange status.
|
||||
|
||||
#### Scenario: Display previous asset for a replacement asset
|
||||
|
||||
- **GIVEN** 用户正在查看资产信息页
|
||||
- **WHEN** 解析响应包含非空的 `exchange_trace.previous_asset`
|
||||
- **THEN** 页面 MUST 显示“换货新资产”标签
|
||||
- **AND** 页面 MUST 展示前代资产的标识符和换货单号
|
||||
|
||||
#### Scenario: Display next asset for a replaced asset
|
||||
|
||||
- **GIVEN** 用户正在查看资产信息页
|
||||
- **WHEN** 解析响应包含非空的 `exchange_trace.next_asset`
|
||||
- **THEN** 页面 MUST 显示“已换出旧资产”标签
|
||||
- **AND** 页面 MUST 展示后代资产的标识符和换货单号
|
||||
|
||||
#### Scenario: Display both relationships for an intermediate asset
|
||||
|
||||
- **GIVEN** 用户正在查看位于换货链中间的资产
|
||||
- **WHEN** 解析响应同时包含非空的 `previous_asset` 和 `next_asset`
|
||||
- **THEN** 页面 MUST 同时展示“换货新资产”和“已换出旧资产”两个关联区域
|
||||
|
||||
#### Scenario: Hide absent exchange relationship
|
||||
|
||||
- **GIVEN** 用户正在查看资产信息页
|
||||
- **WHEN** `previous_asset` 或 `next_asset` 为 `null`、空值或未返回
|
||||
- **THEN** 页面 MUST NOT 展示对应的换货关联区域
|
||||
- **AND** 前端 MUST NOT 根据资产状态、世代编号、换货单状态或其他字段推导该区域
|
||||
|
||||
### Requirement: Exchange Related Asset Navigation Authorization
|
||||
|
||||
The asset information page SHALL allow navigation to a related exchange asset only when that relationship item has `can_view=true`.
|
||||
|
||||
#### Scenario: Navigate to authorized related asset
|
||||
|
||||
- **GIVEN** 换货关联资产项的 `can_view=true`
|
||||
- **WHEN** 用户点击关联资产标识符
|
||||
- **THEN** 系统 MUST 跳转到该关联资产的资产详情
|
||||
- **AND** 跳转 MUST 使用关联项返回的 `identifier`
|
||||
|
||||
#### Scenario: Render unauthorized related asset as text
|
||||
|
||||
- **GIVEN** 换货关联资产项的 `can_view=false`
|
||||
- **WHEN** 页面渲染换货关联区域
|
||||
- **THEN** 页面 MUST 展示关联资产标识符和换货单号
|
||||
- **AND** 页面 MUST NOT 将关联资产标识符渲染为可点击跳转操作
|
||||
|
||||
### Requirement: Exchange Trace Response Contract
|
||||
|
||||
The frontend asset resolve contract SHALL support optional `exchange_trace.previous_asset` and `exchange_trace.next_asset` items, each with `asset_type`, `asset_id`, `identifier`, `exchange_no`, and `can_view`.
|
||||
|
||||
#### Scenario: Read exchange trace response fields
|
||||
|
||||
- **WHEN** 前端解析 `GET /api/admin/assets/resolve/{identifier}` 响应
|
||||
- **THEN** 前端类型 MUST 支持 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset`
|
||||
- **AND** 每个非空关联项 MUST 支持读取 `asset_type`、`asset_id`、`identifier`、`exchange_no`、`can_view`
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. API Contract
|
||||
|
||||
- [x] 1.1 更新资产解析响应类型,支持 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset`。
|
||||
- [x] 1.2 定义换货关联资产类型,读取 `asset_type`、`asset_id`、`identifier`、`exchange_no`、`can_view`。
|
||||
|
||||
## 2. Asset Detail Exchange Trace UI
|
||||
|
||||
- [x] 2.1 `previous_asset` 存在时展示“换货新资产”标签和前代资产信息。
|
||||
- [x] 2.2 `next_asset` 存在时展示“已换出旧资产”标签和后代资产信息。
|
||||
- [x] 2.3 当前资产同时存在前代和后代时,同时展示两个关联区域。
|
||||
- [x] 2.4 关联资产为空时隐藏对应区域,不通过换货状态或世代编号推导关联链路。
|
||||
|
||||
## 3. Exchange Trace Navigation
|
||||
|
||||
- [x] 3.1 `can_view=true` 时允许点击关联资产标识跳转资产详情。
|
||||
- [x] 3.2 `can_view=false` 时仅展示关联资产标识文本,不渲染跳转操作。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 验证旧资产、新资产、中间资产和无关联资产的展示正确。
|
||||
- [ ] 4.2 验证有权限和无权限关联资产的跳转行为正确。
|
||||
- [x] 4.3 运行相关前端校验,并执行 `openspec validate update-asset-exchange-generation-links --strict`。
|
||||
@@ -0,0 +1,37 @@
|
||||
# Change: 更新资产解析流量汇总展示
|
||||
|
||||
## Why
|
||||
|
||||
后台“资产信息”页需要展示当前世代下所有套餐的虚流量已用量和剩余量,方便运营人员在查看资产基础信息时快速判断整体流量消耗情况。
|
||||
|
||||
后端通过 `GET /api/admin/assets/resolve/:identifier` 新增可选查询参数控制是否返回汇总字段;前端需要明确请求契约、类型契约和展示位置,避免默认查询缺失汇总数据。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 更新资产解析接口请求契约:
|
||||
- `GET /api/admin/assets/resolve/:identifier` 支持可选 Query 参数 `include_usage_summary: boolean`
|
||||
- 参数含义为是否返回当前世代流量汇总字段 `total_virtual_used_mb` / `total_virtual_remaining_mb`
|
||||
- 后端未传时默认不返回汇总数据;后台“资产信息”页发起解析请求时默认传 `include_usage_summary=true`
|
||||
- 更新资产解析接口响应契约:
|
||||
- 新增 `total_virtual_used_mb?: number | null`,表示所有套餐已用量(MB),未请求时为 `null`
|
||||
- 新增 `total_virtual_remaining_mb?: number | null`,表示所有套餐剩余量(MB),未请求时为 `null`
|
||||
- 更新后台“资产信息”基础信息展示:
|
||||
- 卡资产在“运营商状态”字段后新增“所有套餐已用量”和“所有套餐剩余量”
|
||||
- 设备资产在“SN”字段后新增“所有套餐已用量”和“所有套餐剩余量”
|
||||
- 字段值使用上述两个响应字段,并按流量大小格式化展示
|
||||
- 字段为空或未请求时显示稳定占位内容
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `asset-information`
|
||||
- Affected code:
|
||||
- `src/api/modules/asset.ts`
|
||||
- `src/types/api/asset.ts`
|
||||
- `src/views/asset-management/asset-information/types.ts`
|
||||
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
|
||||
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
|
||||
- Dependencies:
|
||||
- `GET /api/admin/assets/resolve/:identifier` 后端支持 `include_usage_summary` Query 参数及响应字段
|
||||
- Breaking changes:
|
||||
- 无;新增请求参数和响应字段均为可选,前端资产信息页仅主动请求并展示新增数据
|
||||
@@ -0,0 +1,60 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin Asset Resolve Usage Summary Contract
|
||||
|
||||
The admin asset information integration SHALL request usage summary data from `GET /api/admin/assets/resolve/:identifier` by default and preserve the returned current-generation virtual usage summary fields.
|
||||
|
||||
#### Scenario: Asset information requests usage summary by default
|
||||
|
||||
- **GIVEN** 用户在后台“资产信息”页查询资产标识符
|
||||
- **WHEN** 前端调用 `GET /api/admin/assets/resolve/:identifier`
|
||||
- **THEN** 请求 Query MUST include `include_usage_summary=true`
|
||||
- **AND** `include_usage_summary` MUST be treated as a boolean query parameter
|
||||
|
||||
#### Scenario: Preserve usage summary fields from resolve response
|
||||
|
||||
- **GIVEN** `GET /api/admin/assets/resolve/:identifier?include_usage_summary=true` 返回 `total_virtual_used_mb` 和 `total_virtual_remaining_mb`
|
||||
- **WHEN** 前端解析资产详情响应
|
||||
- **THEN** 页面状态 MUST preserve `total_virtual_used_mb` as a `number | null` field
|
||||
- **AND** 页面状态 MUST preserve `total_virtual_remaining_mb` as a `number | null` field
|
||||
- **AND** `total_virtual_used_mb` MUST represent 所有套餐已用量(MB)
|
||||
- **AND** `total_virtual_remaining_mb` MUST represent 所有套餐剩余量(MB)
|
||||
|
||||
#### Scenario: Usage summary not requested
|
||||
|
||||
- **GIVEN** a caller invokes `GET /api/admin/assets/resolve/:identifier` without `include_usage_summary=true`
|
||||
- **WHEN** the response is parsed
|
||||
- **THEN** `total_virtual_used_mb` MAY be absent or `null`
|
||||
- **AND** `total_virtual_remaining_mb` MAY be absent or `null`
|
||||
|
||||
### Requirement: Admin Asset Usage Summary Display
|
||||
|
||||
The admin asset information view SHALL display current-generation virtual usage summary fields in both card and device asset information sections using asset-type-specific placement.
|
||||
|
||||
#### Scenario: Show card usage summary after carrier status
|
||||
|
||||
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **AND** 当前资产详情状态中存在 `total_virtual_used_mb` 和 `total_virtual_remaining_mb`
|
||||
- **WHEN** 页面渲染“卡信息”区域
|
||||
- **THEN** 页面 MUST keep `运营商状态` visible
|
||||
- **AND** 页面 MUST display `所有套餐已用量` immediately after `运营商状态`, using `total_virtual_used_mb`
|
||||
- **AND** 页面 MUST display `所有套餐剩余量` immediately after `所有套餐已用量`, using `total_virtual_remaining_mb`
|
||||
- **AND** both values MUST be formatted as traffic sizes based on MB values
|
||||
|
||||
#### Scenario: Show device usage summary after SN
|
||||
|
||||
- **GIVEN** 用户正在查看后台“资产信息”中的设备资产详情
|
||||
- **AND** 当前资产详情状态中存在 `total_virtual_used_mb` 和 `total_virtual_remaining_mb`
|
||||
- **WHEN** 页面渲染“设备信息”区域
|
||||
- **THEN** 页面 MUST keep `SN` visible
|
||||
- **AND** 页面 MUST display `所有套餐已用量` immediately after `SN`, using `total_virtual_used_mb`
|
||||
- **AND** 页面 MUST display `所有套餐剩余量` immediately after `所有套餐已用量`, using `total_virtual_remaining_mb`
|
||||
- **AND** both values MUST be formatted as traffic sizes based on MB values
|
||||
|
||||
#### Scenario: Show placeholder when usage summary is unavailable
|
||||
|
||||
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情或设备资产详情
|
||||
- **AND** `total_virtual_used_mb` or `total_virtual_remaining_mb` is `null` or absent
|
||||
- **WHEN** 页面渲染对应汇总字段
|
||||
- **THEN** 页面 MUST display `-` for the unavailable field
|
||||
- **AND** 页面 MUST NOT display `0` solely because the field is unavailable
|
||||
19
openspec/changes/update-asset-resolve-usage-summary/tasks.md
Normal file
19
openspec/changes/update-asset-resolve-usage-summary/tasks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## 1. Contract
|
||||
|
||||
- [x] 1.1 扩展资产解析接口方法,支持传入 `include_usage_summary` Query 参数。
|
||||
- [x] 1.2 扩展资产解析响应类型,新增 `total_virtual_used_mb?: number | null` 和 `total_virtual_remaining_mb?: number | null`。
|
||||
- [x] 1.3 确保后台“资产信息”页调用 `GET /api/admin/assets/resolve/:identifier` 时默认传 `include_usage_summary=true`。
|
||||
|
||||
## 2. State And Display
|
||||
|
||||
- [x] 2.1 在资产信息页面状态中保留 `total_virtual_used_mb` 和 `total_virtual_remaining_mb`。
|
||||
- [x] 2.2 在卡资产“卡信息”区域的“运营商状态”后新增“所有套餐已用量”和“所有套餐剩余量”。
|
||||
- [x] 2.3 在设备资产“设备信息”区域的“SN”后新增“所有套餐已用量”和“所有套餐剩余量”。
|
||||
- [x] 2.4 新增字段按流量大小格式化展示;当响应字段为 `null`、`undefined` 或未请求时显示 `-`。
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 验证资产信息页请求解析接口时 URL 包含 `include_usage_summary=true`。
|
||||
- [ ] 3.2 验证解析接口返回两个汇总字段时,卡资产详情在“运营商状态”后正确展示。
|
||||
- [ ] 3.3 验证解析接口返回两个汇总字段时,设备资产详情在“SN”后正确展示。
|
||||
- [ ] 3.4 验证两个汇总字段为 `null` 或缺失时,页面显示 `-` 且不报错。
|
||||
@@ -0,0 +1,25 @@
|
||||
# Change: 适配复机实名校验结果提示
|
||||
|
||||
## Why
|
||||
|
||||
复机是否允许取决于运营商的实名能力和后端实名规则,不能以行业卡类别作为前端放行依据。当前复机失败不会向运营清晰呈现后端返回的中文业务原因,无法区分同为行业卡但实名能力不同的放行或拦截结果。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 复用资产信息页现有复机入口和确认框,继续调用 `POST /api/admin/assets/{identifier}/start`。
|
||||
- 补齐复机成功响应类型,读取最新的 `status`、`status_name`、`real_name_status`、`real_name_status_name`。
|
||||
- 复机提交期间禁用入口,避免重复提交。
|
||||
- 复机被后端实名规则或其他业务规则拦截时,在当前复机操作上下文直接展示后端返回的中文业务原因,不以通用前端文案替代,也不只记录到控制台。
|
||||
- 复机成功后重新拉取资产详情,以最新资产数据更新页面。
|
||||
- 前端不得读取或根据 `card_category` 判断是否需要实名、是否允许复机,所有放行或拦截均以后端结果为准。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `asset-information`
|
||||
- Affected code:
|
||||
- `src/types/api/asset.ts`
|
||||
- `src/api/modules/asset.ts`
|
||||
- `src/views/asset-management/asset-information/composables/useAssetOperations.ts`
|
||||
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
|
||||
- API contract:
|
||||
- `POST /api/admin/assets/{identifier}/start`
|
||||
@@ -0,0 +1,71 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Asset Start Response Contract
|
||||
|
||||
The asset start API contract SHALL use `POST /api/admin/assets/{identifier}/start` and support a successful response containing the latest `status`, `status_name`, `real_name_status`, and `real_name_status_name` values.
|
||||
|
||||
#### Scenario: Read successful asset start result
|
||||
|
||||
- **WHEN** 用户对资产提交复机且后端成功处理请求
|
||||
- **THEN** 前端类型 MUST support `status`、`status_name`、`real_name_status` 和 `real_name_status_name`
|
||||
- **AND** 前端 MUST use the existing asset start endpoint
|
||||
|
||||
### Requirement: Asset Start Submission and Refresh
|
||||
|
||||
The asset information page SHALL retain the existing asset start entry and confirmation dialog. It MUST prevent duplicate submissions while the asset start request is pending and refresh the asset detail after a successful start.
|
||||
|
||||
#### Scenario: Keep existing asset start confirmation
|
||||
|
||||
- **GIVEN** 用户正在查看处于停机状态的资产信息页
|
||||
- **WHEN** 用户发起复机操作
|
||||
- **THEN** 页面 MUST display the existing start confirmation dialog
|
||||
- **AND** 页面 MUST NOT add a separate asset start entry
|
||||
|
||||
#### Scenario: Prevent duplicate asset start submissions
|
||||
|
||||
- **GIVEN** 用户已确认复机操作
|
||||
- **WHEN** 复机请求仍在提交中
|
||||
- **THEN** 复机按钮 MUST be disabled
|
||||
- **AND** 系统 MUST NOT send another asset start request for that interaction
|
||||
|
||||
#### Scenario: Refresh asset detail after a successful start
|
||||
|
||||
- **WHEN** 复机接口成功返回
|
||||
- **THEN** 页面 MUST reload the current asset detail
|
||||
- **AND** 页面 MUST render the refreshed asset status and real-name status data
|
||||
|
||||
### Requirement: Backend-Controlled Asset Start Realname Validation
|
||||
|
||||
The asset information page SHALL rely exclusively on the backend asset start result to decide whether an asset is allowed to start. The frontend MUST NOT read or use `card_category` to infer real-name requirements or to permit an asset start.
|
||||
|
||||
#### Scenario: Allow start based on backend result
|
||||
|
||||
- **GIVEN** 用户对行业卡资产发起复机
|
||||
- **WHEN** 后端允许复机
|
||||
- **THEN** 页面 MUST treat the operation as successful regardless of the asset `card_category`
|
||||
- **AND** 页面 MUST reload the asset detail
|
||||
|
||||
#### Scenario: Block start based on backend realname validation
|
||||
|
||||
- **GIVEN** 用户对行业卡资产发起复机
|
||||
- **WHEN** 后端因实名规则拒绝复机并返回中文业务原因
|
||||
- **THEN** 页面 MUST keep the asset start operation unsuccessful
|
||||
- **AND** 页面 MUST NOT use `card_category` to override or bypass the backend decision
|
||||
|
||||
### Requirement: Asset Start Business Failure Feedback
|
||||
|
||||
When the asset start API rejects an operation for real-name validation or another business rule, the asset information page SHALL show the backend-provided Chinese business reason in the current asset start operation context.
|
||||
|
||||
#### Scenario: Show backend realname rejection reason
|
||||
|
||||
- **GIVEN** 用户已在资产信息页确认复机
|
||||
- **WHEN** 后端返回实名校验失败及中文业务原因
|
||||
- **THEN** 页面 MUST display that backend Chinese business reason directly in the asset start operation context
|
||||
- **AND** 页面 MUST NOT replace it with a generic frontend failure message
|
||||
|
||||
#### Scenario: Show other backend business rejection reason
|
||||
|
||||
- **GIVEN** 用户已在资产信息页确认复机
|
||||
- **WHEN** 后端返回非实名相关的业务拦截及中文业务原因
|
||||
- **THEN** 页面 MUST display that backend Chinese business reason directly in the asset start operation context
|
||||
- **AND** 页面 MUST NOT only log the failure to the browser console
|
||||
@@ -0,0 +1,24 @@
|
||||
## 1. API Contract
|
||||
|
||||
- [x] 1.1 定义复机成功响应类型,支持 `status`、`status_name`、`real_name_status`、`real_name_status_name`。
|
||||
- [x] 1.2 更新复机接口返回类型,继续使用 `POST /api/admin/assets/{identifier}/start`。
|
||||
|
||||
## 2. Asset Start Interaction
|
||||
|
||||
- [x] 2.1 保持资产信息页现有复机入口和确认框。
|
||||
- [x] 2.2 复机请求提交期间禁用复机按钮,防止重复提交。
|
||||
- [x] 2.3 复机成功后重新拉取资产详情。
|
||||
|
||||
## 3. Backend Realname Validation Feedback
|
||||
|
||||
- [x] 3.1 后端返回实名或其他业务拦截结果时,在复机操作上下文显示后端中文业务原因。
|
||||
- [x] 3.2 不以通用前端错误文案覆盖后端中文业务原因,也不只将错误写入控制台。
|
||||
- [x] 3.3 不读取或根据 `card_category` 推断实名要求或复机放行结果。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 验证复机成功后页面重新加载并展示最新资产详情。
|
||||
- [ ] 4.2 验证后端实名校验拦截时显示其中文业务原因。
|
||||
- [ ] 4.3 验证同为行业卡但运营商实名能力不同的资产,页面按后端结果分别放行或拦截。
|
||||
- [ ] 4.4 验证提交期间无法重复发起复机请求。
|
||||
- [x] 4.5 运行相关前端校验,并执行 `openspec validate update-asset-start-realname-validation-feedback --strict`。
|
||||
30
openspec/changes/update-asset-sync-status-trail/proposal.md
Normal file
30
openspec/changes/update-asset-sync-status-trail/proposal.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Change: 调整资产同步状态与同步轨迹入口
|
||||
|
||||
## Why
|
||||
|
||||
运营需要通过统一审计查看资产业务事件、运营商回调和周期轮询的同步轨迹。新需求明确资产信息页只保留已有手动刷新和轮询展示,不新增用于推断轮询计划的状态字段。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 保留卡和设备资产信息页现有的手动刷新按钮、接口契约和轮询状态展示,不新增第二个同步或刷新按钮。
|
||||
- 不展示活跃级别、最后活跃场景或下次轮询时间,也不根据事件轨迹推算新的周期轮询时间。
|
||||
- 增加“查看同步轨迹”入口,跳转全局审计外部集成页并带入当前资产的 `resource_type` 和 `resource_key` 筛选参数。
|
||||
- 约定审计查询支持 `resource_type`、`resource_key`、`correlation_id`,以供目标页连续展示立即、3分钟、5分钟的同步结果,以及运营商回调和周期轮询的 Integration Log。
|
||||
- 对 `rate_limited` 手动刷新结果仅展示本次失败,不展示自动退避倒计时。
|
||||
- 全局审计外部集成页面自身暂不在本提案中开发或改造。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `asset-information`
|
||||
- Related active changes: `update-asset-realtime-sync-timestamps-display`(共享资产同步信息展示)
|
||||
- Affected code:
|
||||
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
|
||||
- `src/views/asset-management/asset-information/index.vue`
|
||||
- 资产信息页到全局审计外部集成页的路由跳转参数
|
||||
- API contracts:
|
||||
- `POST /api/admin/assets/{identifier}/refresh`(保持既有契约)
|
||||
- `GET /api/admin/audit/integrations`(复用 `resource_type`、`resource_key`、`correlation_id` 查询参数)
|
||||
- Out of scope:
|
||||
- 全局审计外部集成页面自身的开发或改造
|
||||
- 审计轨迹结果页的加载、空记录和失败状态界面实现
|
||||
- 新增轮询活跃级别、最后活跃场景、下次轮询时间或自动退避倒计时展示
|
||||
@@ -0,0 +1,73 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Existing Asset Sync Controls and Polling Display
|
||||
|
||||
The asset information page SHALL preserve the existing manual refresh control and existing polling status display for both card and device assets. The page MUST NOT add another synchronization or refresh control.
|
||||
|
||||
#### Scenario: Retain existing controls for a card asset
|
||||
|
||||
- **GIVEN** 用户正在查看卡资产信息页
|
||||
- **THEN** 页面 MUST 保留现有手动刷新按钮和现有轮询状态展示
|
||||
- **AND** 页面 MUST NOT 增加第二个同步或刷新按钮
|
||||
|
||||
#### Scenario: Retain existing controls for a device asset
|
||||
|
||||
- **GIVEN** 用户正在查看设备资产信息页
|
||||
- **THEN** 页面 MUST 保留现有手动刷新按钮和现有轮询状态展示
|
||||
- **AND** 页面 MUST NOT 增加第二个同步或刷新按钮
|
||||
|
||||
#### Scenario: Do not display new polling activity fields
|
||||
|
||||
- **GIVEN** 用户正在查看卡或设备资产信息页
|
||||
- **WHEN** 资产解析响应包含 `activity_level`、`last_activity_scene` 或 `next_poll_at`
|
||||
- **THEN** 页面 MUST NOT 展示活跃级别、最后活跃场景或下次轮询时间字段
|
||||
|
||||
### Requirement: Asset Manual Refresh Remains the Only Refresh Entry
|
||||
|
||||
The asset information page SHALL preserve the existing manual refresh button and interface contract as the only asset refresh entry. The frontend MUST NOT infer or display a future periodic polling time from refresh results or event traces.
|
||||
|
||||
#### Scenario: Preserve existing manual refresh behavior
|
||||
|
||||
- **WHEN** 用户在资产信息页发起手动刷新
|
||||
- **THEN** 系统 MUST 调用现有手动刷新接口
|
||||
- **AND** 页面 MUST NOT 新增第二个同步或刷新按钮
|
||||
|
||||
#### Scenario: Show rate limited result without countdown
|
||||
|
||||
- **WHEN** 手动刷新请求返回 `rate_limited` 或等价限流失败结果
|
||||
- **THEN** 系统 MUST 仅展示本次请求失败信息
|
||||
- **AND** 系统 MUST NOT 展示自动退避倒计时
|
||||
|
||||
#### Scenario: Do not infer a future polling schedule
|
||||
|
||||
- **WHEN** 用户查看刷新结果或同步事件轨迹
|
||||
- **THEN** 前端 MUST NOT 根据结果推算或展示新的周期轮询时间
|
||||
|
||||
### Requirement: Asset Sync Trail Navigation
|
||||
|
||||
The asset information page SHALL provide a `查看同步轨迹` entry that navigates to the global audit integration page with filters for the current asset. The target audit query contract SHALL support `resource_type`, `resource_key`, and `correlation_id` filters.
|
||||
|
||||
#### Scenario: Navigate to filtered sync trail from a card asset
|
||||
|
||||
- **GIVEN** 用户正在查看卡资产信息页
|
||||
- **WHEN** 用户点击“查看同步轨迹”
|
||||
- **THEN** 系统 MUST 跳转到全局审计外部集成页
|
||||
- **AND** 跳转参数 MUST 带入当前卡资产对应的 `resource_type` 和 `resource_key` 筛选信息
|
||||
|
||||
#### Scenario: Navigate to filtered sync trail from a device asset
|
||||
|
||||
- **GIVEN** 用户正在查看设备资产信息页
|
||||
- **WHEN** 用户点击“查看同步轨迹”
|
||||
- **THEN** 系统 MUST 跳转到全局审计外部集成页
|
||||
- **AND** 跳转参数 MUST 带入当前设备资产对应的 `resource_type` 和 `resource_key` 筛选信息
|
||||
|
||||
#### Scenario: Audit query filters a correlation group
|
||||
|
||||
- **WHEN** 全局审计外部集成页按一次业务事件的 `correlation_id` 查询同步轨迹
|
||||
- **THEN** 审计查询契约 MUST 支持该筛选参数
|
||||
|
||||
#### Scenario: Audit target page is outside this change
|
||||
|
||||
- **WHEN** 本次变更落地
|
||||
- **THEN** 资产信息页的同步轨迹跳转入口 MUST 可用
|
||||
- **AND** 全局审计外部集成页面的加载、空记录和失败状态界面 MUST NOT 被要求在本提案中开发或改造
|
||||
24
openspec/changes/update-asset-sync-status-trail/tasks.md
Normal file
24
openspec/changes/update-asset-sync-status-trail/tasks.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## 1. Asset Sync Controls
|
||||
|
||||
- [x] 1.1 保留卡和设备资产信息页现有手动刷新按钮及其接口调用方式,不新增同步或刷新按钮。
|
||||
- [x] 1.2 保留现有轮询状态展示,移除活跃级别、最后活跃场景和下次轮询时间展示。
|
||||
- [x] 1.3 `rate_limited` 时仅展示本次刷新失败,不展示自动退避倒计时或新的轮询时间。
|
||||
|
||||
## 2. Sync Trail Navigation
|
||||
|
||||
- [x] 2.1 在卡和设备资产信息页增加“查看同步轨迹”入口,跳转全局审计外部集成页。
|
||||
- [x] 2.2 跳转时带入当前资产的 `resource_type` 和 `resource_key` 筛选参数。
|
||||
- [x] 2.3 前端不根据事件轨迹推算或展示新的周期轮询时间。
|
||||
|
||||
## 3. Audit Integration Contract
|
||||
|
||||
- [ ] 3.1 与审计接口确认并联调 `resource_type`、`resource_key`、`correlation_id` 查询参数。
|
||||
- [ ] 3.2 确认目标查询可覆盖立即、3分钟、5分钟的连续同步结果,以及运营商回调和周期轮询的 Integration Log。
|
||||
- [x] 3.3 不开发或改造全局审计外部集成页面的加载、空记录和失败状态界面。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 验证卡和设备均保留既有手动刷新和轮询状态展示,且不显示新增轮询活跃字段。
|
||||
- [ ] 4.2 验证“查看同步轨迹”跳转携带当前资产筛选。
|
||||
- [ ] 4.3 待全局审计外部集成页就绪后,验证过滤后的轨迹加载、空记录、失败状态及连续同步结果完整。
|
||||
- [x] 4.4 运行相关前端校验,并执行 `openspec validate update-asset-sync-status-trail --strict`。
|
||||
@@ -0,0 +1,44 @@
|
||||
# Change: 更新资产信息账号类型字段可见性与运营商账户展示
|
||||
|
||||
## Why
|
||||
|
||||
资产信息页当前在卡/设备基础信息区域存在两类与最新业务口径不一致的问题:
|
||||
|
||||
- 卡资产“卡信息”中的 `实名认证策略` 仍会对 `user_type=3`(代理账号)和 `user_type=4`(企业账号)显示。
|
||||
- 设备资产“设备信息”中的 `实名认证策略` 和 `制造商` 仍会对 `user_type=3/4` 显示。
|
||||
- 卡资产“运营商账户”当前展示的是 `carrier_name`,而最新要求需要改为展示 `carrier_type`,并按运营商管理中的映射规则转成中文,例如 `CTCC -> 中国电信`。
|
||||
|
||||
如果不统一这些展示规则,不同账号在资产信息页会继续看到不应暴露的字段,同时“运营商账户”的显示口径也会与系统内其他模块不一致。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 更新卡资产“卡信息”区域:
|
||||
- 当当前登录账号 `user_type` 为 `3` 或 `4` 时,隐藏 `实名认证策略` 字段。
|
||||
- 当当前登录账号 `user_type` 为 `3` 或 `4` 时,`运营商账户` 字段改为展示 `carrier_type` 的中文映射值,而不是 `carrier_name`。
|
||||
- 当当前登录账号 `user_type` 不是 `3` 且不是 `4` 时,`运营商账户` 字段继续保持当前 `carrier_name` 展示口径。
|
||||
- 已知运营商类型映射沿用运营商管理口径:`CMCC -> 中国移动`、`CUCC -> 中国联通`、`CTCC -> 中国电信`、`CBN -> 中国广电`。
|
||||
- 当 `carrier_type` 为空时显示 `-`;若返回未知编码,则保留原编码展示,避免吞掉上游信息。
|
||||
- 更新设备资产“设备信息”区域:
|
||||
- 当当前登录账号 `user_type` 为 `3` 或 `4` 时,隐藏 `实名认证策略` 字段。
|
||||
- 当当前登录账号 `user_type` 为 `3` 或 `4` 时,隐藏 `制造商` 字段。
|
||||
- 范围约束:
|
||||
- 本提案仅调整资产信息基础描述区字段展示。
|
||||
- 本提案不改动实名认证策略相关操作按钮的权限和可见性。
|
||||
- 本提案不改动设备详情“绑定卡列表”中的列展示规则,除非后续另行提出变更。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `asset-information`
|
||||
- Affected code:
|
||||
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
|
||||
- `src/views/asset-management/asset-information/composables/useAssetFormatters.ts`
|
||||
- `src/views/asset-management/asset-information/types.ts`
|
||||
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
|
||||
- `src/config/constants/carrierTypes.ts`
|
||||
- Dependencies:
|
||||
- 依赖当前登录账号 `user_type` 可从用户状态中稳定读取
|
||||
- 依赖资产详情接口继续返回 `carrier_type`
|
||||
- 运营商类型中文映射应与运营商管理模块保持一致
|
||||
- Breaking changes:
|
||||
- 无外部接口破坏性变更;本次仅调整后台页面展示契约和字段可见性
|
||||
@@ -0,0 +1,123 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin Card Asset Carrier Type Contract
|
||||
|
||||
The admin asset information integration SHALL accept and preserve `carrier_type` from `GET /api/admin/assets/resolve/:identifier` for card assets so the card information section can render the carrier account value from the carrier type code.
|
||||
|
||||
#### Scenario: Card asset detail preserves carrier_type from resolve response
|
||||
|
||||
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **AND** `GET /api/admin/assets/resolve/:identifier` 返回了 `carrier_type`
|
||||
- **WHEN** 前端解析卡资产详情响应
|
||||
- **THEN** 页面状态 MUST 保留该 `carrier_type` 原始值
|
||||
- **AND** 后续“卡信息”区域的 `运营商账户` 展示 MUST 基于该状态值进行格式化
|
||||
|
||||
#### Scenario: Missing carrier_type does not break card asset detail parsing
|
||||
|
||||
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **AND** `GET /api/admin/assets/resolve/:identifier` 未返回 `carrier_type` 或该字段为空
|
||||
- **WHEN** 前端解析卡资产详情响应
|
||||
- **THEN** 页面 MUST 继续保留其他卡资产字段的解析行为
|
||||
- **AND** MUST NOT 因 `carrier_type` 缺失导致“卡信息”区域渲染失败
|
||||
|
||||
### Requirement: Admin Card Asset Carrier Account Display
|
||||
|
||||
The admin asset information view SHALL render the card information field `运营商账户` according to the current logged-in account type.
|
||||
|
||||
For logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号), the field MUST use `carrier_type` and the same carrier-type-to-Chinese mapping used by carrier management.
|
||||
|
||||
Known mappings MUST include `CMCC -> 中国移动`, `CUCC -> 中国联通`, `CTCC -> 中国电信`, and `CBN -> 中国广电`.
|
||||
|
||||
For other logged-in account types, the field MUST keep the existing `carrier_name` display behavior.
|
||||
|
||||
If `carrier_type` is empty or missing, the page MUST display `-`. If `carrier_type` contains an unknown code, the page MUST preserve and display the original code.
|
||||
|
||||
#### Scenario: Show China Telecom label for CTCC in card information for agent or enterprise account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 是 `3` 或 `4`
|
||||
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **AND** 当前卡资产详情状态中的 `carrier_type` 为 `CTCC`
|
||||
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
|
||||
- **THEN** 页面 MUST 显示 `中国电信`
|
||||
|
||||
#### Scenario: Show placeholder when carrier_type is empty for agent or enterprise account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 是 `3` 或 `4`
|
||||
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **AND** 当前卡资产详情状态中的 `carrier_type` 为空、缺失或仅包含空白字符
|
||||
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
|
||||
- **THEN** 页面 MUST 显示 `-`
|
||||
|
||||
#### Scenario: Preserve unknown carrier_type code for agent or enterprise account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 是 `3` 或 `4`
|
||||
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **AND** 当前卡资产详情状态中的 `carrier_type` 为未收录的编码
|
||||
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
|
||||
- **THEN** 页面 MUST 直接显示该原始编码
|
||||
- **AND** MUST NOT 错误映射为其他运营商中文名称
|
||||
|
||||
#### Scenario: Keep carrier_name display for non-agent non-enterprise account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
|
||||
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **AND** 当前卡资产详情状态中的 `carrier_name` 为有效值
|
||||
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
|
||||
- **THEN** 页面 MUST 显示该 `carrier_name`
|
||||
|
||||
### Requirement: Admin Card Realname Policy Visibility By Account Type
|
||||
|
||||
The admin asset information view SHALL hide the card information field `实名认证策略` for logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号).
|
||||
|
||||
For other logged-in account types, the page SHALL keep the existing card information field visibility behavior for `实名认证策略`.
|
||||
|
||||
#### Scenario: Hide card realname policy for agent account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 是 `3`
|
||||
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **WHEN** 页面渲染“卡信息”区域
|
||||
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
|
||||
|
||||
#### Scenario: Hide card realname policy for enterprise account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 是 `4`
|
||||
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **WHEN** 页面渲染“卡信息”区域
|
||||
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
|
||||
|
||||
#### Scenario: Keep card realname policy visible for non-agent non-enterprise account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
|
||||
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
|
||||
- **WHEN** 页面渲染“卡信息”区域
|
||||
- **THEN** 页面 MUST 继续按现有规则渲染 `实名认证策略` 字段
|
||||
|
||||
### Requirement: Admin Device Sensitive Field Visibility By Account Type
|
||||
|
||||
The admin asset information view SHALL hide the device information fields `实名认证策略` and `制造商` for logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号).
|
||||
|
||||
For other logged-in account types, the page SHALL keep the existing device information field visibility behavior for both fields.
|
||||
|
||||
#### Scenario: Hide device realname policy and manufacturer for agent account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 是 `3`
|
||||
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
|
||||
- **WHEN** 页面渲染“设备信息”区域
|
||||
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
|
||||
- **AND** 页面 MUST NOT 显示 `制造商` 字段
|
||||
|
||||
#### Scenario: Hide device realname policy and manufacturer for enterprise account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 是 `4`
|
||||
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
|
||||
- **WHEN** 页面渲染“设备信息”区域
|
||||
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
|
||||
- **AND** 页面 MUST NOT 显示 `制造商` 字段
|
||||
|
||||
#### Scenario: Keep device realname policy and manufacturer visible for non-agent non-enterprise account
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
|
||||
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
|
||||
- **WHEN** 页面渲染“设备信息”区域
|
||||
- **THEN** 页面 MUST 继续按现有规则渲染 `实名认证策略` 字段
|
||||
- **AND** 页面 MUST 继续按现有规则渲染 `制造商` 字段
|
||||
@@ -0,0 +1,20 @@
|
||||
## 1. Contract And Formatting
|
||||
|
||||
- [x] 1.1 补齐资产信息页面本地类型/状态中的 `carrier_type` 字段,确保卡资产详情可稳定保留该值。
|
||||
- [x] 1.2 复用运营商管理中的运营商类型映射规则,为资产信息页提供 `carrier_type` 到中文文案的格式化能力。
|
||||
|
||||
## 2. UI Visibility
|
||||
|
||||
- [x] 2.1 调整卡资产“卡信息”区域的 `运营商账户` 字段:`user_type=3/4` 展示 `carrier_type` 中文映射,其他账号继续展示原来的 `carrier_name`。
|
||||
- [x] 2.2 当当前登录账号 `user_type` 为 `3` 或 `4` 时,隐藏卡资产“卡信息”区域的 `实名认证策略` 字段。
|
||||
- [x] 2.3 当当前登录账号 `user_type` 为 `3` 或 `4` 时,隐藏设备资产“设备信息”区域的 `实名认证策略` 和 `制造商` 字段。
|
||||
- [x] 2.4 确认本次变更仅影响基础信息描述区,不改动实名认证策略操作按钮和设备绑定卡列表列。
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 验证 `user_type=3/4` 查看卡资产且返回 `carrier_type=CTCC` 时,“运营商账户”显示为 `中国电信`。
|
||||
- [ ] 3.2 验证 `user_type=3/4` 查看卡资产且返回 `carrier_type=CMCC/CUCC/CBN` 时,“运营商账户”分别显示与运营商管理一致的中文文案;空值显示 `-`。
|
||||
- [ ] 3.3 验证 `user_type=3/4` 查看卡资产且返回未知 `carrier_type` 编码时,“运营商账户”保留原编码展示。
|
||||
- [ ] 3.4 验证 `user_type=3` 和 `user_type=4` 查看卡资产时,不显示 `实名认证策略` 字段。
|
||||
- [ ] 3.5 验证 `user_type=3` 和 `user_type=4` 查看设备资产时,不显示 `实名认证策略` 和 `制造商` 字段。
|
||||
- [ ] 3.6 验证其他账号类型查看卡资产时,“运营商账户”继续显示原来的 `carrier_name`;其余上述字段继续按现有规则显示。
|
||||
@@ -0,0 +1,39 @@
|
||||
# Change: 允许企业账号登录后台管理系统
|
||||
|
||||
## Why
|
||||
|
||||
当前前端登录流程会在登录接口成功返回后,基于 `user_type=4` 对企业账号做前端拦截,并提示“企业账号无法登录后台管理系统,请使用平台/代理账号登录”。这与最新业务要求不一致,导致企业账号即使后端已返回有效 token、菜单和权限,也无法进入后台。
|
||||
|
||||
如果只保留现状,企业账号无法访问后台中已经按权限和菜单控制开放给它们的页面,也会与近期多项针对企业账号后台可见性和资产能力的变更方向相冲突。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 移除前端登录流程中对 `user_type=4` 企业账号的后台登录拦截。
|
||||
- 企业账号登录成功后,前端应与其他账号类型一致地:
|
||||
- 保存 token
|
||||
- 保存用户信息
|
||||
- 保存权限、菜单和按钮数据
|
||||
- 在存在可用后台菜单和权限时,继续进入后台默认首页/重定向目标页
|
||||
- 当登录接口成功但 `menus` 为空或 `permissions` 为空时:
|
||||
- 前端仅提示“当前账号暂无任何权限,请联系管理员分配角色和权限”
|
||||
- 不继续进入后台首页或动态路由注册流程
|
||||
- 不作为这条提示的后续动作调用退出登录接口
|
||||
- 登录成功后的页面访问边界继续由现有菜单、路由权限、按钮权限和后端接口权限共同控制。
|
||||
- 本提案不要求本次同步放开所有企业账号受限接口或页面;仅调整“企业账号能否进入后台”的前端准入逻辑。
|
||||
- 本提案不修改登录接口路径、请求参数或返回结构。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `admin-authentication`
|
||||
- Affected code:
|
||||
- `src/composables/useLogin.ts`
|
||||
- `src/router/guards/beforeEach.ts`
|
||||
- `src/router/guards/permission.ts`
|
||||
- `src/App.vue`
|
||||
- 其他依赖当前登录态、菜单和权限进行页面准入的后台入口逻辑
|
||||
- Dependencies:
|
||||
- 后端登录接口继续允许企业账号返回有效 `access_token`、`user`、`permissions`、`menus`、`buttons`
|
||||
- 企业账号需要至少具备一部分后台菜单或权限,才能进入可访问页面
|
||||
- Breaking changes:
|
||||
- 无外部接口破坏性变更;本次为前端登录准入行为放宽
|
||||
@@ -0,0 +1,70 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Enterprise Account Admin Login Admission
|
||||
|
||||
The admin login flow SHALL allow enterprise accounts whose `user_type` is `4` to sign in to the admin system when the login API returns a successful response with valid authentication data.
|
||||
|
||||
The frontend MUST NOT reject admin login solely because `user_type` equals `4`.
|
||||
|
||||
#### Scenario: Enterprise account is allowed to complete admin login
|
||||
|
||||
- **GIVEN** 用户使用企业账号凭证登录后台管理系统
|
||||
- **AND** 登录接口返回成功状态
|
||||
- **AND** 返回数据包含有效 `access_token`
|
||||
- **AND** 返回数据中的 `user.user_type` 为 `4`
|
||||
- **WHEN** 前端处理登录成功响应
|
||||
- **THEN** 前端 MUST 继续执行后台登录成功流程
|
||||
- **AND** MUST NOT 弹出“企业账号无法登录后台管理系统,请使用平台/代理账号登录”提示
|
||||
|
||||
#### Scenario: Enterprise account login still fails when login API itself fails
|
||||
|
||||
- **GIVEN** 用户使用企业账号凭证登录后台管理系统
|
||||
- **AND** 登录接口返回失败状态,或未返回有效 `access_token`
|
||||
- **WHEN** 前端处理登录响应
|
||||
- **THEN** 前端 MUST 继续按现有登录失败流程处理
|
||||
- **AND** MUST NOT 将“允许企业账号登录后台”解释为绕过登录接口失败
|
||||
|
||||
### Requirement: Enterprise Account Login State Persistence
|
||||
|
||||
The admin login flow SHALL persist enterprise-account login state using the same token, user, permissions, menus, and buttons handling used for other successful admin logins only when the login response provides usable admin-access data.
|
||||
|
||||
#### Scenario: Enterprise account saves auth state after successful login with usable admin access data
|
||||
|
||||
- **GIVEN** 用户使用企业账号成功登录后台管理系统
|
||||
- **AND** 登录返回中包含 `access_token`、`refresh_token`、`user`、`permissions`、`menus`、`buttons`
|
||||
- **AND** `permissions` 非空
|
||||
- **AND** `menus` 非空
|
||||
- **WHEN** 前端写入登录态
|
||||
- **THEN** 前端 MUST 保存 token
|
||||
- **AND** MUST 保存用户信息
|
||||
- **AND** MUST 保存权限、菜单和按钮数据
|
||||
|
||||
#### Scenario: Enterprise account shows no-permission warning without logout follow-up
|
||||
|
||||
- **GIVEN** 用户使用企业账号成功登录后台管理系统
|
||||
- **AND** 登录返回中的 `permissions` 为空,或 `menus` 为空
|
||||
- **WHEN** 前端处理登录成功响应
|
||||
- **THEN** 前端 MUST 继续显示现有“当前账号暂无任何权限,请联系管理员分配角色和权限”提示
|
||||
- **AND** MUST NOT 因为空权限或空菜单回退为企业账号登录拦截
|
||||
- **AND** MUST NOT 继续进入后台首页或动态路由注册流程
|
||||
- **AND** MUST NOT 因这条无权限提示而调用退出登录接口
|
||||
|
||||
### Requirement: Enterprise Account Admin Entry Uses Existing Authorization Boundaries
|
||||
|
||||
Allowing enterprise accounts to sign in to the admin system SHALL NOT by itself grant additional page or API access beyond the existing menu, route-permission, button-permission, and backend authorization boundaries.
|
||||
|
||||
#### Scenario: Enterprise account enters admin and follows existing route registration flow
|
||||
|
||||
- **GIVEN** 用户使用企业账号成功登录后台管理系统
|
||||
- **AND** 登录返回中的 `permissions` 非空
|
||||
- **AND** 登录返回中的 `menus` 非空
|
||||
- **WHEN** 前端进入登录后的路由注册和首页跳转流程
|
||||
- **THEN** 前端 MUST 继续按现有菜单和动态路由注册逻辑处理
|
||||
- **AND** MUST NOT 因 `user_type=4` 在该阶段额外拒绝用户进入后台
|
||||
|
||||
#### Scenario: Enterprise account page access remains permission-controlled
|
||||
|
||||
- **GIVEN** 用户已使用企业账号登录后台管理系统
|
||||
- **WHEN** 用户尝试访问某个后台页面或触发某个后台操作
|
||||
- **THEN** 页面访问和操作可用性 MUST 继续由现有菜单、路由权限、按钮权限和后端接口鉴权决定
|
||||
- **AND** 允许登录后台本身 MUST NOT 被解释为默认拥有全部后台页面和操作权限
|
||||
@@ -0,0 +1,17 @@
|
||||
## 1. Login Flow
|
||||
|
||||
- [x] 1.1 移除登录成功后对 `user_type=4` 企业账号的前端拒绝逻辑和错误提示。
|
||||
- [x] 1.2 确保企业账号在登录成功后与其他账号一样完成 token、用户信息、权限、菜单、按钮的保存流程。
|
||||
- [x] 1.3 当登录成功但 `menus` 或 `permissions` 为空时,仅提示“当前账号暂无任何权限,请联系管理员分配角色和权限”并停止后续登录流程,不调用退出登录接口。
|
||||
|
||||
## 2. Navigation And Access
|
||||
|
||||
- [x] 2.1 确保企业账号在具备可用后台菜单和权限时,继续走现有后台首页跳转和路由注册流程,不因账号类型被额外拦截。
|
||||
- [x] 2.2 确认企业账号后台访问范围仍由现有菜单、路由权限和后端接口权限控制,而不是在登录阶段一刀切拒绝。
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 验证企业账号登录成功后不再出现“企业账号无法登录后台管理系统,请使用平台/代理账号登录”提示。
|
||||
- [ ] 3.2 验证企业账号在具备可用后台菜单和权限时,能正常保存登录态并进入后台首页或重定向目标页。
|
||||
- [ ] 3.3 验证企业账号在菜单为空或权限为空时,仅提示“暂无任何权限”,且不会继续调用退出登录接口。
|
||||
- [ ] 3.4 验证平台账号和代理账号登录流程不受影响。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Change: 换货新资产归属继承提示
|
||||
|
||||
## Why
|
||||
|
||||
当前换货流程在选择新资产后,页面没有明确告知换货完成后的最终归属店铺,运营人员容易误以为可以手工选择目标店铺,进而对换货结果产生误判。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在换货创建入口、发货确认入口、换货详情页补充旧资产所属店铺和新资产继承归属店铺的展示。
|
||||
- 保持现有换货创建、发货、确认完成接口和入参语义不变,不新增目标店铺选择控件或目标店铺提交字段。
|
||||
- 扩展换货响应数据,返回 `inherited_shop_id` 和 `inherited_shop_name` 供前端统一展示最终归属店铺。
|
||||
- 在创建、发货、确认完成流程中补充提交中禁用、防重复提交、后端归属校验失败提示,以及成功后的详情刷新。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `exchange-management`
|
||||
- Related active changes: `update-exchange-flow-direct-type`(本提案建立在现有换货流程类型改造之上,需合并考虑)
|
||||
- Affected code:
|
||||
- `src/api/modules/exchange.ts`
|
||||
- `src/views/asset-management/exchange-management/index.vue`
|
||||
- `src/views/asset-management/exchange-management/detail.vue`
|
||||
- 相关旧资产/新资产搜索结果类型或映射逻辑(如需补充店铺名称展示数据)
|
||||
@@ -0,0 +1,88 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 换货归属继承上下文展示
|
||||
|
||||
系统 SHALL 在换货创建和发货确认流程中展示旧资产当前所属店铺,并在用户选定新资产后以只读方式提示换货完成后的继承归属店铺,避免用户误认为可以手工指定目标店铺。
|
||||
|
||||
#### Scenario: 创建入口展示旧资产所属店铺
|
||||
|
||||
- **GIVEN** 用户进入现有换货创建入口
|
||||
- **WHEN** 用户选择旧资产
|
||||
- **THEN** 系统 MUST 显示该旧资产当前所属店铺名称
|
||||
- **AND** 该所属店铺信息 MUST be read-only
|
||||
|
||||
#### Scenario: 选择新资产后展示继承归属提示
|
||||
|
||||
- **GIVEN** 用户正在换货创建或发货确认流程中操作
|
||||
- **WHEN** 用户选择新资产
|
||||
- **THEN** 系统 MUST 显示只读提示 `换货完成后将归属:{店铺名称}`
|
||||
- **AND** `{店铺名称}` MUST 表示该新资产换货完成后的最终归属店铺
|
||||
|
||||
#### Scenario: 页面不提供目标店铺选择控件
|
||||
|
||||
- **WHEN** 用户查看换货创建或发货确认入口
|
||||
- **THEN** 系统 MUST NOT 渲染目标店铺选择控件
|
||||
- **AND** 用户 MUST NOT 能够手工覆盖继承归属店铺
|
||||
|
||||
### Requirement: 换货归属继承接口契约
|
||||
|
||||
系统 SHALL 复用现有换货创建、发货和确认完成接口,并在换货数据模型中返回继承后的店铺标识和店铺名称,供创建结果、发货结果、完成结果和详情页统一展示。
|
||||
|
||||
#### Scenario: 创建请求保持既有字段语义
|
||||
|
||||
- **WHEN** 前端调用 `POST /api/admin/exchanges`
|
||||
- **THEN** 请求体 MUST 继续沿用旧资产、新资产、换货类型和资料迁移字段
|
||||
- **AND** 请求体 MUST NOT 新增目标店铺字段
|
||||
|
||||
#### Scenario: 发货和完成请求不新增目标店铺字段
|
||||
|
||||
- **WHEN** 前端调用 `POST /api/admin/exchanges/{id}/ship` 或 `POST /api/admin/exchanges/{id}/complete`
|
||||
- **THEN** 请求体 MUST NOT 新增目标店铺字段
|
||||
- **AND** 后端 MUST 继续按现有换货流程处理归属继承
|
||||
|
||||
#### Scenario: 换货响应返回继承店铺字段
|
||||
|
||||
- **WHEN** 前端收到换货创建、发货、确认完成结果或读取换货详情
|
||||
- **THEN** 返回的 `data` MUST 包含 `inherited_shop_id:int64` 和 `inherited_shop_name:string`
|
||||
- **AND** `inherited_shop_name` MUST 表示换货完成后新资产最终归属的店铺名称
|
||||
|
||||
### Requirement: 换货详情继承归属展示
|
||||
|
||||
系统 SHALL 在换货详情页展示继承后的店铺名称,并在关键操作成功后刷新详情数据,保证页面展示的归属信息与后端一致。
|
||||
|
||||
#### Scenario: 详情页展示继承后的店铺名称
|
||||
|
||||
- **GIVEN** 用户进入换货详情页
|
||||
- **WHEN** 详情数据加载成功
|
||||
- **THEN** 页面 MUST 展示继承后的店铺名称
|
||||
- **AND** 页面 MUST NOT 隐藏该归属字段
|
||||
|
||||
#### Scenario: 发货或确认完成成功后刷新详情
|
||||
|
||||
- **GIVEN** 用户完成换货发货或确认完成操作
|
||||
- **WHEN** 接口返回成功
|
||||
- **THEN** 系统 MUST 重新拉取该换货单详情
|
||||
- **AND** 刷新后的详情 MUST 展示最新的继承归属店铺信息
|
||||
|
||||
### Requirement: 换货归属继承错误与提交状态处理
|
||||
|
||||
系统 SHALL 在新资产归属校验失败时直接展示后端错误,并在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交,同时保证失败后可以重试。
|
||||
|
||||
#### Scenario: 新资产属于其他店铺时展示后端错误
|
||||
|
||||
- **GIVEN** 用户选择了不允许用于当前换货流程的新资产
|
||||
- **WHEN** 后端返回“新资产属于其他店铺”或等价归属校验错误
|
||||
- **THEN** 系统 MUST 直接展示后端错误信息
|
||||
- **AND** 系统 MUST NOT 提供手工修改目标店铺的替代入口
|
||||
|
||||
#### Scenario: 提交期间禁用按钮防止重复提交
|
||||
|
||||
- **WHEN** 用户提交创建、发货或确认完成操作
|
||||
- **THEN** 当前操作按钮 MUST 进入禁用或 loading 状态直到请求结束
|
||||
- **AND** 在请求完成前的重复点击 MUST NOT 触发第二次提交
|
||||
|
||||
#### Scenario: 请求失败后恢复可重试状态
|
||||
|
||||
- **WHEN** 创建、发货或确认完成请求失败
|
||||
- **THEN** 系统 MUST 恢复当前操作按钮可用状态
|
||||
- **AND** 页面 MUST 保留当前已展示的资产与归属提示,便于用户修正后重试
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. API Contract
|
||||
|
||||
- [x] 1.1 更新换货数据模型,补充 `inherited_shop_id` 和 `inherited_shop_name` 响应字段,并覆盖创建、发货、确认完成及详情读取场景。
|
||||
- [x] 1.2 保持 `POST /api/admin/exchanges`、`POST /api/admin/exchanges/{id}/ship`、`POST /api/admin/exchanges/{id}/complete` 的既有入参语义,不新增目标店铺字段。
|
||||
|
||||
## 2. Exchange Create And Ship UI
|
||||
|
||||
- [x] 2.1 在换货创建入口中,选择旧资产后展示旧资产所属店铺。
|
||||
- [x] 2.2 在换货创建入口和发货确认入口中,选择新资产后展示只读提示 `换货完成后将归属:{店铺名称}`。
|
||||
- [x] 2.3 确保上述页面不新增目标店铺选择控件,也不允许手工覆盖继承归属。
|
||||
|
||||
## 3. Exchange Detail And Submission States
|
||||
|
||||
- [x] 3.1 在换货详情页展示继承后的店铺名称,并在发货或确认完成成功后重新拉取详情。
|
||||
- [x] 3.2 在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交。
|
||||
- [x] 3.3 在新资产归属校验失败时展示后端错误,并在失败后恢复可重试状态。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 验证创建、发货确认、详情三个入口均能正确展示旧资产所属店铺和继承归属店铺。
|
||||
- [x] 4.2 验证加载中、失败提示、重复提交防护和成功后详情刷新的交互状态。
|
||||
- [x] 4.3 运行相关前端校验,并执行 `openspec validate update-exchange-inherited-shop-hint --strict`。
|
||||
67
openspec/changes/update-export-task-scene-pages/design.md
Normal file
67
openspec/changes/update-export-task-scene-pages/design.md
Normal file
@@ -0,0 +1,67 @@
|
||||
## Context
|
||||
|
||||
导出任务能力已经具备统一 API、通用列表页面、详情页面和创建弹窗。现有列表页面通过 `scene` 搜索项区分设备、IOT 卡和订单任务,但新的产品方案要求用三个固定场景页面替代该筛选方式,并让三个页面按钮权限相互独立。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals: 在导出管理下提供“导出设备”“导出IOT卡”“导出订单”三个页面。
|
||||
- Goals: 三个页面固定传入各自 `scene`,去掉页面中的“导出场景”筛选。
|
||||
- Goals: 三个页面及其按钮/操作使用场景级独立权限编码。
|
||||
- Goals: 尽量复用现有导出任务列表和详情实现,避免复制三份完整页面逻辑。
|
||||
- Non-Goals: 不新增导出任务后端 API。
|
||||
- Non-Goals: 不改变创建导出任务的 `scene` 值和 `query` 快照语义。
|
||||
- Non-Goals: 不调整导出任务状态流转、文件生成或下载地址生成逻辑。
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: 使用一个可复用导出任务列表实现,通过路由配置或组件 props 注入固定场景、页面标题和权限映射。
|
||||
- Rationale: 三个页面除固定 `scene` 和权限外,分页、状态筛选、时间筛选、下载、取消逻辑一致,复用可以减少重复代码和后续维护成本。
|
||||
|
||||
- Decision: 页面查询参数由页面固定注入 `scene`,不从搜索表单读取“导出场景”。
|
||||
- Rationale: 页面已经按场景拆分,继续暴露场景筛选会造成入口语义冲突,也可能让用户在“导出设备”页面查到 IOT 卡或订单任务。
|
||||
|
||||
- Decision: 权限编码按场景和操作拆分,默认建议如下。
|
||||
- Rationale: 用户明确要求三个界面的按钮权限编码不同;列表访问、详情、下载、取消均需要避免跨场景复用。
|
||||
|
||||
| 场景 | 页面访问 | 详情 | 下载 | 取消 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 导出设备 | `export_task:device:list` | `export_task:device:detail` | `export_task:device:download` | `export_task:device:cancel` |
|
||||
| 导出IOT卡 | `export_task:iot_card:list` | `export_task:iot_card:detail` | `export_task:iot_card:download` | `export_task:iot_card:cancel` |
|
||||
| 导出订单 | `export_task:order:list` | `export_task:order:detail` | `export_task:order:download` | `export_task:order:cancel` |
|
||||
|
||||
- Decision: 业务列表创建导出任务按钮继续使用来源业务权限,默认建议如下。
|
||||
- Rationale: 创建导出任务属于来源业务列表能力,不应因为拥有某个导出任务管理页面权限就获得业务列表导出入口。
|
||||
|
||||
| 入口 | 创建导出任务权限 | scene |
|
||||
| --- | --- | --- |
|
||||
| 设备管理 | `devices:export` | `device` |
|
||||
| IOT 卡管理 | `iot_card:export` | `iot_card` |
|
||||
| 订单列表 | `orders:export` | `order` |
|
||||
|
||||
- Decision: 详情页可继续复用同一组件,但必须根据任务场景或来源页面使用对应场景的详情、下载、取消权限。
|
||||
- Rationale: 详情页面本身也包含下载和取消按钮,若继续使用通用权限会绕过三个列表页面的按钮隔离要求。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Risk: 后端或权限配置已经发放了通用 `export_task:*` 权限,切换为场景级权限后用户可能暂时看不到页面或按钮。
|
||||
- Mitigation: 实施时同步更新权限种子/菜单配置,并在上线前完成角色权限迁移或重新授权。
|
||||
|
||||
- Risk: 订单导出场景提案正在独立推进,可能继续给旧“导出列表”增加订单筛选。
|
||||
- Mitigation: 实施本变更前先合并范围,保留 `scene=order` 和订单创建入口,移除旧列表中的订单场景筛选实现。
|
||||
|
||||
- Risk: 详情页直接通过 URL 打开时缺少来源页面上下文。
|
||||
- Mitigation: 详情页读取任务详情后按返回的 `scene` 计算对应权限,缺少该场景详情权限时展示无权限提示并阻止后续按钮操作。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 增加三条导出任务列表路由和菜单项,删除或隐藏旧通用“导出列表”入口。
|
||||
2. 将导出任务列表改造成固定场景列表,移除“导出场景”搜索项和冗余场景列。
|
||||
3. 为三页分别注入固定 `scene`、标题和场景级权限映射。
|
||||
4. 调整导出任务详情页和行操作的权限判断,改用任务场景对应权限。
|
||||
5. 同步订单导出入口,确保订单导出任务进入“导出订单”页面查看。
|
||||
6. 更新后端/菜单权限配置并完成角色授权迁移。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 最终权限编码是否采用本设计建议,还是需要对齐后端已有权限命名规范。
|
||||
- 旧通用导出列表路由是否直接移除,还是短期隐藏并重定向到用户有权限的第一个场景页面。
|
||||
39
openspec/changes/update-export-task-scene-pages/proposal.md
Normal file
39
openspec/changes/update-export-task-scene-pages/proposal.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Change: 拆分导出任务列表为固定场景页面
|
||||
|
||||
## Why
|
||||
|
||||
当前导出管理只有一个“导出列表”页面,需要用户通过“导出场景”筛选设备、IOT 卡和订单导出任务。产品希望把该页面拆成三个固定场景页面,入口更直接,并且三个页面及页面按钮使用不同权限编码。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将导出管理下的单个“导出列表”替换为三个页面:
|
||||
- 导出设备
|
||||
- 导出IOT卡
|
||||
- 导出订单
|
||||
- 三个页面分别固定查询 `scene=device`、`scene=iot_card`、`scene=order`,页面内不再展示或提交用户可选的“导出场景”筛选项。
|
||||
- 导出列表中的“场景”列可移除或默认不展示,因为页面本身已经代表固定场景。
|
||||
- 三个页面的菜单访问、详情、下载、取消等按钮/操作均使用场景级独立权限编码,不再复用同一组通用导出任务按钮权限。
|
||||
- 业务列表发起导出的按钮权限继续按来源业务隔离:设备导出、IOT 卡导出、订单导出使用不同权限编码。
|
||||
- 订单导出任务仍使用 `scene=order`,并与现有订单导出场景提案合并实施或调整其导出列表筛选相关内容。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `export-task-management`
|
||||
- Affected code:
|
||||
- `src/router/routesAlias.ts`
|
||||
- `src/router/routes/asyncRoutes.ts`
|
||||
- `src/views/asset-management/export-task-management/export-task-list/index.vue`
|
||||
- `src/views/asset-management/export-task-management/export-task-detail/index.vue`
|
||||
- `src/types/api/exportTask.ts`
|
||||
- `src/components/business/ExportTaskCreateDialog.vue`
|
||||
- `src/views/asset-management/device-list/index.vue`
|
||||
- `src/views/asset-management/iot-card-management/index.vue`
|
||||
- `src/views/order-management/order-list/index.vue`
|
||||
- 菜单、权限和国际化文案配置
|
||||
- Dependencies:
|
||||
- 后端导出任务列表接口继续支持 `scene=device`、`scene=iot_card`、`scene=order` 过滤。
|
||||
- 后端/权限配置需要提供三套场景级导出任务权限编码。
|
||||
- 现有 `add-order-export-scene` 提案与本提案存在重叠:其“在导出列表场景筛选中新增订单”应被本提案的三个固定页面方案替代。
|
||||
- Breaking changes:
|
||||
- 原通用“导出列表”菜单入口和 `export_task:list/detail/download/cancel` 这组通用页面操作权限将被场景级页面/按钮权限取代。
|
||||
@@ -0,0 +1,111 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Scene-Specific Export Task Pages
|
||||
|
||||
The admin frontend SHALL replace the single generic export task list entry with three scene-specific export task pages: 导出设备, 导出IOT卡, and 导出订单.
|
||||
|
||||
#### Scenario: Render scene-specific export task navigation
|
||||
|
||||
- **GIVEN** 用户打开资产管理下的导出管理菜单
|
||||
- **WHEN** 系统渲染导出任务导航
|
||||
- **THEN** 系统 MUST provide separate entries for 导出设备, 导出IOT卡, and 导出订单
|
||||
- **AND** 系统 MUST NOT expose the old generic 导出列表 menu entry as the primary navigation entry
|
||||
|
||||
#### Scenario: Query device export tasks
|
||||
|
||||
- **GIVEN** 用户打开导出设备页面
|
||||
- **WHEN** 页面查询导出任务列表
|
||||
- **THEN** 系统 MUST call `GET /api/admin/export-tasks` with `scene=device`
|
||||
- **AND** 用户 MUST NOT be able to change this page to query `iot_card` or `order` tasks through a scene selector
|
||||
|
||||
#### Scenario: Query IOT card export tasks
|
||||
|
||||
- **GIVEN** 用户打开导出IOT卡页面
|
||||
- **WHEN** 页面查询导出任务列表
|
||||
- **THEN** 系统 MUST call `GET /api/admin/export-tasks` with `scene=iot_card`
|
||||
- **AND** 用户 MUST NOT be able to change this page to query `device` or `order` tasks through a scene selector
|
||||
|
||||
#### Scenario: Query order export tasks
|
||||
|
||||
- **GIVEN** 用户打开导出订单页面
|
||||
- **WHEN** 页面查询导出任务列表
|
||||
- **THEN** 系统 MUST call `GET /api/admin/export-tasks` with `scene=order`
|
||||
- **AND** 用户 MUST NOT be able to change this page to query `device` or `iot_card` tasks through a scene selector
|
||||
|
||||
### Requirement: Remove Export Scene Filtering From Scene Pages
|
||||
|
||||
Scene-specific export task pages SHALL remove user-facing export scene filtering because each page has exactly one fixed scene.
|
||||
|
||||
#### Scenario: Hide scene search field
|
||||
|
||||
- **GIVEN** 用户打开任一导出任务场景页面
|
||||
- **WHEN** 页面渲染搜索表单
|
||||
- **THEN** 页面 MUST NOT display a 导出场景 search item
|
||||
- **AND** reset and search actions MUST keep the page's fixed `scene` unchanged
|
||||
|
||||
#### Scenario: Avoid redundant scene column
|
||||
|
||||
- **GIVEN** 用户打开任一导出任务场景页面
|
||||
- **WHEN** 页面渲染任务表格
|
||||
- **THEN** 页面 SHOULD remove or hide the 场景 column by default
|
||||
- **AND** 页面 MUST continue to display task number, status, progress, format, row counts, shard progress, error message, and created time
|
||||
|
||||
### Requirement: Scene-Specific Export Task Permissions
|
||||
|
||||
The admin frontend MUST gate each export task page and page action with scene-specific permission codes so that device, IOT card, and order export task permissions are independent.
|
||||
|
||||
#### Scenario: Gate scene pages independently
|
||||
|
||||
- **GIVEN** 用户 only has `export_task:device:list`
|
||||
- **WHEN** 系统渲染导出管理菜单
|
||||
- **THEN** 系统 MUST show 导出设备
|
||||
- **AND** 系统 MUST NOT show 导出IOT卡 or 导出订单 unless the user also has `export_task:iot_card:list` or `export_task:order:list`
|
||||
|
||||
#### Scenario: Gate device export task actions independently
|
||||
|
||||
- **GIVEN** 用户打开导出设备页面
|
||||
- **WHEN** 页面渲染详情、下载 or 取消操作
|
||||
- **THEN** 系统 MUST use `export_task:device:detail`, `export_task:device:download`, and `export_task:device:cancel` respectively
|
||||
- **AND** 系统 MUST NOT treat IOT card or order export task action permissions as sufficient for device actions
|
||||
|
||||
#### Scenario: Gate IOT card export task actions independently
|
||||
|
||||
- **GIVEN** 用户打开导出IOT卡页面
|
||||
- **WHEN** 页面渲染详情、下载 or 取消操作
|
||||
- **THEN** 系统 MUST use `export_task:iot_card:detail`, `export_task:iot_card:download`, and `export_task:iot_card:cancel` respectively
|
||||
- **AND** 系统 MUST NOT treat device or order export task action permissions as sufficient for IOT card actions
|
||||
|
||||
#### Scenario: Gate order export task actions independently
|
||||
|
||||
- **GIVEN** 用户打开导出订单页面
|
||||
- **WHEN** 页面渲染详情、下载 or 取消操作
|
||||
- **THEN** 系统 MUST use `export_task:order:detail`, `export_task:order:download`, and `export_task:order:cancel` respectively
|
||||
- **AND** 系统 MUST NOT treat device or IOT card export task action permissions as sufficient for order actions
|
||||
|
||||
#### Scenario: Keep business export buttons independent
|
||||
|
||||
- **GIVEN** 用户分别访问设备管理、IOT 卡管理 and 订单列表
|
||||
- **WHEN** 页面渲染创建导出任务按钮
|
||||
- **THEN** 设备管理 MUST use `devices:export`
|
||||
- **AND** IOT 卡管理 MUST use `iot_card:export`
|
||||
- **AND** 订单列表 MUST use `orders:export`
|
||||
- **AND** possessing one of these permissions MUST NOT reveal or enable the other two export buttons
|
||||
|
||||
### Requirement: Scene-Aware Export Task Detail Permissions
|
||||
|
||||
The export task detail page SHALL apply detail, download, and cancel permissions according to the task's scene.
|
||||
|
||||
#### Scenario: Enforce scene permissions on direct detail access
|
||||
|
||||
- **GIVEN** 用户直接打开某个导出任务详情 URL
|
||||
- **WHEN** 系统加载任务详情 and reads the returned `scene`
|
||||
- **THEN** 系统 MUST require the matching scene detail permission before showing the detail content
|
||||
- **AND** 系统 MUST use the matching scene download and cancel permissions before showing detail-page buttons
|
||||
|
||||
#### Scenario: Prevent cross-scene action leakage
|
||||
|
||||
- **GIVEN** 用户 has only order export task download permission
|
||||
- **AND** 用户打开 a completed device export task detail
|
||||
- **WHEN** 页面渲染详情操作区
|
||||
- **THEN** 页面 MUST NOT show the device task download button
|
||||
- **AND** 页面 MUST NOT allow downloading that device task through the order download permission
|
||||
46
openspec/changes/update-export-task-scene-pages/tasks.md
Normal file
46
openspec/changes/update-export-task-scene-pages/tasks.md
Normal file
@@ -0,0 +1,46 @@
|
||||
## 1. Proposal Review
|
||||
|
||||
- [x] 1.1 确认三个页面名称为“导出设备”“导出IOT卡”“导出订单”。
|
||||
- [x] 1.2 确认固定场景值分别为 `device`、`iot_card`、`order`。
|
||||
- [x] 1.3 确认三套页面和按钮权限编码,或按后端权限命名规范替换设计中的默认建议。
|
||||
- [x] 1.4 确认本提案与 `add-order-export-scene` 合并实施,订单场景不再通过旧导出场景筛选进入。
|
||||
|
||||
## 2. Routing And Menu
|
||||
|
||||
- [x] 2.1 新增三条导出任务列表路由别名和路由记录。
|
||||
- [x] 2.2 在导出管理菜单下新增“导出设备”“导出IOT卡”“导出订单”。
|
||||
- [x] 2.3 删除或隐藏旧“导出列表”菜单入口。
|
||||
- [x] 2.4 为三条路由分别配置独立页面访问权限。
|
||||
- [x] 2.5 更新菜单国际化文案和权限配置。
|
||||
|
||||
## 3. Export Task List Pages
|
||||
|
||||
- [x] 3.1 将现有导出任务列表改造成可复用固定场景列表,支持传入页面标题、固定 `scene` 和权限映射。
|
||||
- [x] 3.2 移除搜索表单中的“导出场景”筛选项。
|
||||
- [x] 3.3 列表查询始终携带当前页面固定 `scene`。
|
||||
- [x] 3.4 移除或默认隐藏列表中的“场景”列。
|
||||
- [x] 3.5 保留任务状态、创建时间、分页、刷新等现有列表能力。
|
||||
|
||||
## 4. Scene-Specific Actions
|
||||
|
||||
- [x] 4.1 详情入口按当前页面场景检查对应 `detail` 权限。
|
||||
- [x] 4.2 下载按钮按当前页面场景检查对应 `download` 权限。
|
||||
- [x] 4.3 取消按钮按当前页面场景检查对应 `cancel` 权限。
|
||||
- [x] 4.4 导出任务详情页根据任务 `scene` 使用对应详情、下载、取消权限。
|
||||
- [x] 4.5 验证只有某一场景权限时,不会看到其他场景页面或按钮。
|
||||
|
||||
## 5. Business Export Entrypoints
|
||||
|
||||
- [x] 5.1 设备管理导出按钮继续使用 `devices:export` 并创建 `scene=device` 任务。
|
||||
- [x] 5.2 IOT 卡管理导出按钮继续使用 `iot_card:export` 并创建 `scene=iot_card` 任务。
|
||||
- [x] 5.3 订单列表导出按钮使用 `orders:export` 并创建 `scene=order` 任务。
|
||||
- [x] 5.4 确认三个业务导出按钮权限编码互不复用。
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- [x] 6.1 验证“导出设备”页面请求 `GET /api/admin/export-tasks` 时固定携带 `scene=device`。
|
||||
- [x] 6.2 验证“导出IOT卡”页面请求固定携带 `scene=iot_card`。
|
||||
- [x] 6.3 验证“导出订单”页面请求固定携带 `scene=order`。
|
||||
- [x] 6.4 验证三个页面均不展示“导出场景”筛选项。
|
||||
- [x] 6.5 验证三页详情、下载、取消按钮按各自权限显示和拦截。
|
||||
- [x] 6.6 运行类型检查、lint 和相关页面手动回归。
|
||||
@@ -0,0 +1,29 @@
|
||||
# Change: 店铺平台业务员选择、展示与筛选
|
||||
|
||||
## Why
|
||||
|
||||
当前店铺没有记录负责的平台业务员,运营无法在新建、编辑、列表和详情中维护或查看业务归属。需要为店铺绑定可选的平台业务员,该绑定仅用于业务归属和通知关系,不承载分销、佣金或发展层级语义。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在店铺新建和编辑表单新增可清空、可搜索的“平台业务员”选择器。
|
||||
- 候选业务员复用启用的平台账号查询接口,展示账号名和手机号摘要。
|
||||
- 扩展店铺创建、更新、列表查询、列表返回和详情返回的数据契约,支持 `business_owner_account_id` 及业务员展示字段。
|
||||
- 在店铺列表新增业务员列和按业务员筛选项,在店铺详情展示业务员名称和手机号摘要。
|
||||
- 已停用的已绑定业务员仅用于历史列表和详情展示,不能作为新建或编辑表单的候选项。
|
||||
- 不新增分销、佣金、发展层级或其他业务结算行为。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `shop-management`
|
||||
- Affected code:
|
||||
- `src/api/modules/shop.ts`
|
||||
- `src/types/api/shop.ts`
|
||||
- 平台账号查询 API 类型或服务模块
|
||||
- `src/views/shop-management/list/index.vue`
|
||||
- 店铺详情页或详情抽屉的展示配置
|
||||
- API contracts:
|
||||
- `POST /api/admin/shops`
|
||||
- `PUT /api/admin/shops/{id}`
|
||||
- `GET /api/admin/shops`
|
||||
- `GET /api/admin/accounts?account_type=platform&status=1`
|
||||
@@ -0,0 +1,80 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Shop Platform Salesperson Assignment
|
||||
|
||||
The system SHALL allow users to assign or clear an optional platform salesperson for a shop during shop creation and editing. The assignment SHALL represent only business ownership and notification relationship and MUST NOT create distribution, commission, or hierarchy behavior.
|
||||
|
||||
#### Scenario: Assign an enabled platform account during shop creation
|
||||
|
||||
- **GIVEN** 用户进入店铺新建表单
|
||||
- **WHEN** 用户在“平台业务员”可搜索下拉中选择一个启用的平台账号
|
||||
- **THEN** 系统 MUST 提交该账号的 `business_owner_account_id`
|
||||
- **AND** 候选项 MUST 展示账号名和手机号摘要
|
||||
|
||||
#### Scenario: Edit a shop salesperson assignment
|
||||
|
||||
- **GIVEN** 用户进入已存在店铺的编辑表单
|
||||
- **WHEN** 用户选择不同的启用平台账号
|
||||
- **THEN** 系统 MUST 在 `PUT /api/admin/shops/{id}` 请求中提交新的 `business_owner_account_id`
|
||||
|
||||
#### Scenario: Clear a shop salesperson assignment
|
||||
|
||||
- **GIVEN** 店铺已绑定平台业务员
|
||||
- **WHEN** 用户清空“平台业务员”选择器并提交编辑表单
|
||||
- **THEN** 系统 MUST 提交 `business_owner_account_id: null`
|
||||
- **AND** 店铺不再关联该业务员
|
||||
|
||||
#### Scenario: Do not expose disabled accounts as editable candidates
|
||||
|
||||
- **WHEN** 用户打开或搜索“平台业务员”选择器
|
||||
- **THEN** 系统 MUST 仅查询 `account_type=platform` 且 `status=1` 的账号
|
||||
- **AND** 已停用平台账号 MUST NOT 作为新建或编辑候选项显示
|
||||
|
||||
#### Scenario: No eligible salesperson exists
|
||||
|
||||
- **GIVEN** 不存在启用的平台账号
|
||||
- **WHEN** 用户打开“平台业务员”选择器
|
||||
- **THEN** 系统 MUST 显示无可选账号状态
|
||||
- **AND** 用户仍 MUST 能够不选择业务员完成表单提交
|
||||
|
||||
### Requirement: Shop Salesperson List Filtering And Display
|
||||
|
||||
The shop list SHALL display the assigned salesperson and support filtering by `business_owner_account_id`.
|
||||
|
||||
#### Scenario: Display salesperson in shop list
|
||||
|
||||
- **WHEN** 用户查看店铺列表
|
||||
- **THEN** 列表 MUST 显示业务员列
|
||||
- **AND** 已绑定业务员的行 MUST 展示 `business_owner_name` 和 `business_owner_phone_masked`
|
||||
|
||||
#### Scenario: Filter shops by salesperson
|
||||
|
||||
- **WHEN** 用户在店铺列表选择一个平台业务员作为筛选条件
|
||||
- **THEN** 前端 MUST 在 `GET /api/admin/shops` 请求中提交该账号的 `business_owner_account_id`
|
||||
- **AND** 列表 MUST 仅展示后端返回的匹配店铺
|
||||
|
||||
#### Scenario: Display historical disabled salesperson in list
|
||||
|
||||
- **GIVEN** 店铺历史上已绑定的平台业务员后来被停用
|
||||
- **WHEN** 用户查看店铺列表
|
||||
- **THEN** 列表 MUST 继续展示该店铺返回的 `business_owner_name` 和 `business_owner_phone_masked`
|
||||
|
||||
### Requirement: Shop Detail Salesperson Display
|
||||
|
||||
The shop detail SHALL display the assigned salesperson name and masked phone from the shop detail response, including historical assignments to disabled accounts.
|
||||
|
||||
#### Scenario: Display salesperson in shop detail
|
||||
|
||||
- **WHEN** 用户查看已绑定平台业务员的店铺详情
|
||||
- **THEN** 页面 MUST 展示业务员名称和手机号摘要
|
||||
|
||||
#### Scenario: Display historical disabled salesperson in detail
|
||||
|
||||
- **GIVEN** 店铺绑定的业务员已停用
|
||||
- **WHEN** 用户查看该店铺详情
|
||||
- **THEN** 页面 MUST 继续使用详情响应中的 `business_owner_name` 和 `business_owner_phone_masked` 展示历史业务员信息
|
||||
|
||||
#### Scenario: Do not add distribution or commission semantics
|
||||
|
||||
- **WHEN** 用户查看店铺新建、编辑、列表或详情页面
|
||||
- **THEN** 页面 MUST NOT 将平台业务员描述为分销关系、佣金关系或发展层级
|
||||
27
openspec/changes/update-shop-salesperson-selection/tasks.md
Normal file
27
openspec/changes/update-shop-salesperson-selection/tasks.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## 1. API Contract
|
||||
|
||||
- [x] 1.1 更新店铺创建和编辑请求类型,支持可空的 `business_owner_account_id`。
|
||||
- [x] 1.2 更新店铺列表查询类型,支持 `business_owner_account_id` 筛选参数。
|
||||
- [x] 1.3 更新店铺列表和详情响应类型,读取业务员 ID、名称和脱敏手机号字段。
|
||||
- [x] 1.4 复用启用平台账号查询接口,读取 `account_id`、`account_name`、`phone_masked` 作为候选项。
|
||||
|
||||
## 2. Shop Form UI
|
||||
|
||||
- [x] 2.1 在店铺新建和编辑表单增加可搜索、可清空的“平台业务员”下拉选择。
|
||||
- [x] 2.2 候选项展示账号名和手机号摘要,仅查询并展示启用的平台账号。
|
||||
- [x] 2.3 支持清空已选择业务员,并按 `business_owner_account_id: null` 提交。
|
||||
- [x] 2.4 已停用业务员不出现在编辑候选项中。
|
||||
|
||||
## 3. Shop List And Detail UI
|
||||
|
||||
- [x] 3.1 在店铺列表增加业务员列,展示业务员名称和手机号摘要。
|
||||
- [x] 3.2 在店铺列表搜索区增加按业务员筛选项,并提交选中的 `business_owner_account_id`。
|
||||
- [x] 3.3 在店铺详情展示业务员名称和手机号摘要;已停用但历史绑定的业务员仍正常显示。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 验证创建、编辑、清空业务员和无可选账号状态。
|
||||
- [ ] 4.2 验证列表业务员展示和按业务员筛选正确。
|
||||
- [ ] 4.3 验证详情对历史停用业务员仍能展示名称和手机号摘要。
|
||||
- [x] 4.4 确认页面不引入分销、佣金或发展层级文案。
|
||||
- [x] 4.5 运行相关前端校验,并执行 `openspec validate update-shop-salesperson-selection --strict`。
|
||||
@@ -6,6 +6,8 @@ import { BaseService } from '../BaseService'
|
||||
import type {
|
||||
PlatformAccount,
|
||||
AccountQueryParams,
|
||||
PlatformSalesperson,
|
||||
PlatformSalespersonQueryParams,
|
||||
CreatePlatformAccountParams,
|
||||
BaseResponse,
|
||||
PaginationResponse
|
||||
@@ -23,6 +25,16 @@ export class AccountService extends BaseService {
|
||||
return this.getPage<PlatformAccount>('/api/admin/accounts', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的平台业务员候选
|
||||
* GET /api/admin/accounts?account_type=platform&status=1
|
||||
*/
|
||||
static getPlatformSalespeople(
|
||||
params: PlatformSalespersonQueryParams
|
||||
): Promise<PaginationResponse<PlatformSalesperson>> {
|
||||
return this.getPage<PlatformSalesperson>('/api/admin/accounts', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建账号
|
||||
* POST /api/admin/accounts
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/utils/business/apiRateLimit'
|
||||
import type {
|
||||
BaseResponse,
|
||||
AssetResolveParams,
|
||||
AssetResolveResponse,
|
||||
AssetRealtimeStatusResponse,
|
||||
AssetRefreshResponse,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
AssetPackageParams,
|
||||
AssetCurrentPackageResponse,
|
||||
DeviceStopResponse,
|
||||
AssetStartResponse,
|
||||
AssetWalletTransactionListResponse,
|
||||
AssetWalletTransactionParams,
|
||||
AssetWalletResponse,
|
||||
@@ -33,14 +35,13 @@ import type {
|
||||
} from '@/types/api'
|
||||
|
||||
const runRateLimitedAssetAction = async <T>(
|
||||
action: AssetRateLimitedAction,
|
||||
action: Exclude<AssetRateLimitedAction, 'refresh'>,
|
||||
identifier: string,
|
||||
request: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
assertAssetActionAllowed(action, identifier)
|
||||
markAssetActionCalled(action, identifier)
|
||||
const response = await request()
|
||||
return response
|
||||
return request()
|
||||
}
|
||||
|
||||
export class AssetService extends BaseService {
|
||||
@@ -49,9 +50,18 @@ export class AssetService extends BaseService {
|
||||
* 支持虚拟号、ICCID、IMEI、SN、MSISDN
|
||||
* GET /api/admin/assets/resolve/:identifier
|
||||
* @param identifier 资产标识符(虚拟号、ICCID、IMEI、SN、MSISDN)
|
||||
* @param params 查询参数
|
||||
*/
|
||||
static resolveAsset(identifier: string): Promise<BaseResponse<AssetResolveResponse>> {
|
||||
return this.getOne<AssetResolveResponse>(`/api/admin/assets/resolve/${identifier}`)
|
||||
static resolveAsset(
|
||||
identifier: string,
|
||||
params?: AssetResolveParams,
|
||||
config?: Record<string, any>
|
||||
): Promise<BaseResponse<AssetResolveResponse>> {
|
||||
return this.getOne<AssetResolveResponse>(
|
||||
`/api/admin/assets/resolve/${identifier}`,
|
||||
params,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,16 +84,13 @@ export class AssetService extends BaseService {
|
||||
/**
|
||||
* 主动调网关拉取最新数据后返回
|
||||
* POST /api/admin/assets/:identifier/refresh
|
||||
* 前端按资产标识限制 5 分钟内只能调用一次
|
||||
* @param identifier 资产标识符(ICCID 或 VirtualNo)
|
||||
*/
|
||||
static refreshAsset(identifier: string): Promise<BaseResponse<AssetRefreshResponse>> {
|
||||
return runRateLimitedAssetAction('refresh', identifier, () =>
|
||||
this.post<BaseResponse<AssetRefreshResponse>>(
|
||||
`/api/admin/assets/${identifier}/refresh`,
|
||||
{},
|
||||
{ timeout: 60000 }
|
||||
)
|
||||
return this.post<BaseResponse<AssetRefreshResponse>>(
|
||||
`/api/admin/assets/${identifier}/refresh`,
|
||||
{},
|
||||
{ timeout: 60000 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -176,9 +183,16 @@ export class AssetService extends BaseService {
|
||||
* 前端按资产标识限制 5 分钟内只能调用一次
|
||||
* @param identifier 资产标识符(ICCID 或 VirtualNo)
|
||||
*/
|
||||
static startAsset(identifier: string): Promise<BaseResponse<void>> {
|
||||
static startAsset(
|
||||
identifier: string,
|
||||
config?: Record<string, any>
|
||||
): Promise<BaseResponse<AssetStartResponse>> {
|
||||
return runRateLimitedAssetAction('start', identifier, () =>
|
||||
this.post<BaseResponse<void>>(`/api/admin/assets/${identifier}/start`, {})
|
||||
this.post<BaseResponse<AssetStartResponse>>(
|
||||
`/api/admin/assets/${identifier}/start`,
|
||||
{},
|
||||
config
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ import type {
|
||||
AssetAllocationRecord,
|
||||
AssetAllocationRecordDetail,
|
||||
BatchSetCardSeriesBindingRequest,
|
||||
BatchSetCardSeriesBindingResponse
|
||||
BatchSetCardSeriesBindingResponse,
|
||||
BatchUpdateAssetRealnamePolicyRequest
|
||||
} from '@/types/api'
|
||||
|
||||
type ApiQueryParams = PaginationParams & Record<string, unknown>
|
||||
@@ -411,6 +412,20 @@ export class CardService extends BaseService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新卡实名认证策略
|
||||
*/
|
||||
static batchUpdateRealnamePolicy(
|
||||
data: BatchUpdateAssetRealnamePolicyRequest,
|
||||
config?: Record<string, any>
|
||||
): Promise<BaseResponse<void>> {
|
||||
return this.post<BaseResponse<void>>(
|
||||
'/api/admin/iot-cards/batch-update-realname-policy',
|
||||
data,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
// ========== IoT卡网关操作相关 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
RecallDevicesResponse,
|
||||
BatchSetDeviceSeriesBindingRequest,
|
||||
BatchSetDeviceSeriesBindingResponse,
|
||||
BatchUpdateAssetRealnamePolicyRequest,
|
||||
ImportDeviceRequest,
|
||||
ImportDeviceResponse,
|
||||
DeviceImportTaskQueryParams,
|
||||
@@ -169,6 +170,20 @@ export class DeviceService extends BaseService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新设备实名认证策略
|
||||
*/
|
||||
static batchUpdateRealnamePolicy(
|
||||
data: BatchUpdateAssetRealnamePolicyRequest,
|
||||
config?: Record<string, any>
|
||||
): Promise<BaseResponse<void>> {
|
||||
return this.post<BaseResponse<void>>(
|
||||
'/api/admin/devices/batch-update-realname-policy',
|
||||
data,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 设备操作相关 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface ExchangeResponse {
|
||||
recipient_address?: string
|
||||
express_company?: string
|
||||
express_no?: string
|
||||
inherited_shop_id?: number | null
|
||||
inherited_shop_name?: string | null
|
||||
remark?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -114,8 +116,8 @@ export class ExchangeService extends BaseService {
|
||||
* POST /api/admin/exchanges/{id}/complete
|
||||
* @param id 换货单ID
|
||||
*/
|
||||
static completeExchange(id: number): Promise<BaseResponse> {
|
||||
return this.post<BaseResponse>(`/api/admin/exchanges/${id}/complete`, {})
|
||||
static completeExchange(id: number): Promise<BaseResponse<ExchangeResponse>> {
|
||||
return this.post<BaseResponse<ExchangeResponse>>(`/api/admin/exchanges/${id}/complete`, {})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ export { ShopSeriesGrantService } from './shopSeriesGrant'
|
||||
export { OrderService } from './order'
|
||||
export { AssetService } from './asset'
|
||||
export { AgentRechargeService } from './agentRecharge'
|
||||
export { WechatConfigService } from './wechatConfig'
|
||||
export { PaymentSettingsService } from './paymentSettings'
|
||||
export { ExchangeService } from './exchange'
|
||||
export { RefundService } from './refund'
|
||||
export { DataCleanupService } from './dataCleanup'
|
||||
|
||||
80
src/api/modules/paymentSettings.ts
Normal file
80
src/api/modules/paymentSettings.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 支付配置管理 API
|
||||
*/
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import type { BaseResponse } from '@/types/api'
|
||||
import type {
|
||||
PaymentSettings,
|
||||
PaymentSettingsQueryParams,
|
||||
PaymentSettingsListResponse,
|
||||
CreatePaymentSettingsRequest,
|
||||
UpdatePaymentSettingsRequest
|
||||
} from '@/types/api/paymentSettings'
|
||||
|
||||
const PAYMENT_SETTINGS_BASE_URL = '/api/admin/wechat-configs'
|
||||
|
||||
export class PaymentSettingsService extends BaseService {
|
||||
/**
|
||||
* 获取支付配置列表
|
||||
*/
|
||||
static getPaymentSettings(
|
||||
params?: PaymentSettingsQueryParams
|
||||
): Promise<BaseResponse<PaymentSettingsListResponse>> {
|
||||
return this.get<BaseResponse<PaymentSettingsListResponse>>(PAYMENT_SETTINGS_BASE_URL, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付配置详情
|
||||
*/
|
||||
static getPaymentSettingsById(id: number): Promise<BaseResponse<PaymentSettings>> {
|
||||
return this.get<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付配置
|
||||
*/
|
||||
static createPaymentSettings(
|
||||
data: CreatePaymentSettingsRequest
|
||||
): Promise<BaseResponse<PaymentSettings>> {
|
||||
return this.post<BaseResponse<PaymentSettings>>(PAYMENT_SETTINGS_BASE_URL, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付配置
|
||||
*/
|
||||
static updatePaymentSettings(
|
||||
id: number,
|
||||
data: UpdatePaymentSettingsRequest
|
||||
): Promise<BaseResponse<PaymentSettings>> {
|
||||
return this.put<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付配置
|
||||
*/
|
||||
static deletePaymentSettings(id: number): Promise<BaseResponse<void>> {
|
||||
return this.delete<BaseResponse<void>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活支付配置
|
||||
*/
|
||||
static activatePaymentSettings(id: number): Promise<BaseResponse<PaymentSettings>> {
|
||||
return this.post<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}/activate`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用支付配置
|
||||
*/
|
||||
static deactivatePaymentSettings(id: number): Promise<BaseResponse<PaymentSettings>> {
|
||||
return this.post<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/${id}/deactivate`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前生效的支付配置
|
||||
*/
|
||||
static getActivePaymentSettings(): Promise<BaseResponse<PaymentSettings>> {
|
||||
return this.get<BaseResponse<PaymentSettings>>(`${PAYMENT_SETTINGS_BASE_URL}/active`)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 支付配置管理 API
|
||||
*/
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import type { BaseResponse } from '@/types/api'
|
||||
import type {
|
||||
WechatConfig,
|
||||
WechatConfigQueryParams,
|
||||
WechatConfigListResponse,
|
||||
CreateWechatConfigRequest,
|
||||
UpdateWechatConfigRequest
|
||||
} from '@/types/api/wechatConfig'
|
||||
|
||||
const PAYMENT_CONFIG_BASE_URL = '/api/admin/wechat-configs'
|
||||
|
||||
export class WechatConfigService extends BaseService {
|
||||
/**
|
||||
* 获取支付配置列表
|
||||
*/
|
||||
static getWechatConfigs(
|
||||
params?: WechatConfigQueryParams
|
||||
): Promise<BaseResponse<WechatConfigListResponse>> {
|
||||
return this.get<BaseResponse<WechatConfigListResponse>>(PAYMENT_CONFIG_BASE_URL, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付配置详情
|
||||
*/
|
||||
static getWechatConfigById(id: number): Promise<BaseResponse<WechatConfig>> {
|
||||
return this.get<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付配置
|
||||
*/
|
||||
static createWechatConfig(data: CreateWechatConfigRequest): Promise<BaseResponse<WechatConfig>> {
|
||||
return this.post<BaseResponse<WechatConfig>>(PAYMENT_CONFIG_BASE_URL, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付配置
|
||||
*/
|
||||
static updateWechatConfig(
|
||||
id: number,
|
||||
data: UpdateWechatConfigRequest
|
||||
): Promise<BaseResponse<WechatConfig>> {
|
||||
return this.put<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付配置
|
||||
*/
|
||||
static deleteWechatConfig(id: number): Promise<BaseResponse<void>> {
|
||||
return this.delete<BaseResponse<void>>(`${PAYMENT_CONFIG_BASE_URL}/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活支付配置
|
||||
*/
|
||||
static activateWechatConfig(id: number): Promise<BaseResponse<WechatConfig>> {
|
||||
return this.post<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/${id}/activate`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用支付配置
|
||||
*/
|
||||
static deactivateWechatConfig(id: number): Promise<BaseResponse<WechatConfig>> {
|
||||
return this.post<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/${id}/deactivate`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前生效的支付配置
|
||||
*/
|
||||
static getActiveWechatConfig(): Promise<BaseResponse<WechatConfig>> {
|
||||
return this.get<BaseResponse<WechatConfig>>(`${PAYMENT_CONFIG_BASE_URL}/active`)
|
||||
}
|
||||
}
|
||||
93
src/components/business/BatchRealnamePolicyDialog.vue
Normal file
93
src/components/business/BatchRealnamePolicyDialog.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="批量修改实名顺序" width="520px" @closed="resetPolicy">
|
||||
<ElForm label-width="110px">
|
||||
<ElFormItem label="已选数量">
|
||||
<span class="selected-count">{{ selectedCount }} {{ assetUnit }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="实名认证策略">
|
||||
<ElRadioGroup v-model="policy" :disabled="loading">
|
||||
<ElRadio value="none">无需实名</ElRadio>
|
||||
<ElRadio value="before_order">先实名后购买</ElRadio>
|
||||
<ElRadio value="after_order">先购买后实名</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElAlert
|
||||
v-if="devicePolicyHint"
|
||||
title="实际H5流程由设备策略决定"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="error-alert"
|
||||
/>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<ElButton :disabled="loading" @click="visible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="loading" @click="emit('confirm', policy)">
|
||||
确认修改
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElRadio,
|
||||
ElRadioGroup
|
||||
} from 'element-plus'
|
||||
import type { AssetRealnamePolicy } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
selectedCount: number
|
||||
assetUnit: string
|
||||
loading?: boolean
|
||||
errorMessage?: string
|
||||
devicePolicyHint?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
devicePolicyHint: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: [policy: AssetRealnamePolicy]
|
||||
}>()
|
||||
|
||||
const policy = ref<AssetRealnamePolicy>('none')
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const resetPolicy = () => {
|
||||
policy.value = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.selected-count {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -37,6 +37,7 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ExportTaskService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { getExportTaskSceneName } from '@/config/constants'
|
||||
import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -73,15 +74,7 @@
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const sceneName = computed(() => {
|
||||
const sceneMap: Record<ExportTaskScene, string> = {
|
||||
device: '设备管理',
|
||||
iot_card: 'IoT卡管理',
|
||||
order: '订单管理'
|
||||
}
|
||||
|
||||
return sceneMap[props.scene]
|
||||
})
|
||||
const sceneName = computed(() => getExportTaskSceneName(props.scene))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElNotification, ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { HOME_PAGE, RoutesAlias } from '@/router/routesAlias'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import AppConfig from '@/config'
|
||||
import { AuthService } from '@/api/modules'
|
||||
import { ApiStatus } from '@/utils/http/status'
|
||||
import { saveCredentials, getRememberedCredentials } from '@/utils/auth/rememberPassword'
|
||||
import { usernameRules, passwordRules } from '@/utils/auth/loginValidation'
|
||||
import { getRedirectPath } from '@/router/guards/permission'
|
||||
|
||||
/**
|
||||
* 登录表单数据
|
||||
@@ -28,7 +27,6 @@ interface LoginForm {
|
||||
export function useLogin() {
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单引用
|
||||
@@ -108,9 +106,24 @@ export function useLogin() {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户类型 - 禁止企业账号登录
|
||||
if (response.data.user && response.data.user.user_type === 4) {
|
||||
ElMessage.error('企业账号无法登录后台管理系统,请使用平台/代理账号登录')
|
||||
if (!response.data.user) {
|
||||
ElMessage.error(t('login.error.getUserInfoFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// 登录成功但没有后台菜单或权限时,仅提示并停止后续流程。
|
||||
// 这里不保存登录态,也不触发后续跳转,避免进入路由守卫后再连续调用退出接口。
|
||||
const hasPermissions =
|
||||
Array.isArray(response.data.permissions) && response.data.permissions.length > 0
|
||||
const hasMenus = Array.isArray(response.data.menus) && response.data.menus.length > 0
|
||||
|
||||
if (!hasPermissions || !hasMenus) {
|
||||
ElMessage.warning({
|
||||
message: '当前账号暂无任何权限,请联系管理员分配角色和权限',
|
||||
duration: 4000,
|
||||
showClose: true
|
||||
})
|
||||
saveCredentials(formData.username, formData.password, formData.rememberPassword)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,13 +131,8 @@ export function useLogin() {
|
||||
userStore.setToken(response.data.access_token, response.data.refresh_token)
|
||||
|
||||
// 保存用户信息
|
||||
if (response.data.user) {
|
||||
userStore.setUserInfo(response.data.user)
|
||||
userStore.setLoginStatus(true)
|
||||
} else {
|
||||
ElMessage.error(t('login.error.getUserInfoFailed'))
|
||||
return
|
||||
}
|
||||
userStore.setUserInfo(response.data.user)
|
||||
userStore.setLoginStatus(true)
|
||||
|
||||
// 保存权限、菜单和按钮
|
||||
if (response.data.permissions) {
|
||||
@@ -137,19 +145,6 @@ export function useLogin() {
|
||||
userStore.setButtons(response.data.buttons)
|
||||
}
|
||||
|
||||
// 检查权限和菜单是否为空
|
||||
const hasPermissions = response.data.permissions && response.data.permissions.length > 0
|
||||
const hasMenus = response.data.menus && response.data.menus.length > 0
|
||||
|
||||
if (!hasPermissions || !hasMenus) {
|
||||
ElMessage.warning({
|
||||
message: '当前账号暂无任何权限,请联系管理员分配角色和权限',
|
||||
duration: 4000,
|
||||
showClose: true
|
||||
})
|
||||
// 虽然没有权限,但仍然允许登录,让用户看到空白页面而不是停留在登录页
|
||||
}
|
||||
|
||||
// 保存记住密码
|
||||
saveCredentials(formData.username, formData.password, formData.rememberPassword)
|
||||
|
||||
|
||||
55
src/config/constants/exportTask.ts
Normal file
55
src/config/constants/exportTask.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ExportTaskScene } from '@/types/api'
|
||||
|
||||
export interface ExportTaskScenePermissions {
|
||||
detail: string
|
||||
download: string
|
||||
cancel: string
|
||||
}
|
||||
|
||||
export interface ExportTaskSceneConfig {
|
||||
scene: ExportTaskScene
|
||||
sceneName: string
|
||||
pageTitle: string
|
||||
permissions: ExportTaskScenePermissions
|
||||
}
|
||||
|
||||
export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneConfig> = {
|
||||
device: {
|
||||
scene: 'device',
|
||||
sceneName: '设备管理',
|
||||
pageTitle: '导出设备',
|
||||
permissions: {
|
||||
detail: 'export_task:device_detail',
|
||||
download: 'export_task:device_download',
|
||||
cancel: 'export_task:device_cancel'
|
||||
}
|
||||
},
|
||||
iot_card: {
|
||||
scene: 'iot_card',
|
||||
sceneName: 'IOT卡管理',
|
||||
pageTitle: '导出IOT卡',
|
||||
permissions: {
|
||||
detail: 'export_task:iot_card_detail',
|
||||
download: 'export_task:iot_card_download',
|
||||
cancel: 'export_task:iot_card_cancel'
|
||||
}
|
||||
},
|
||||
order: {
|
||||
scene: 'order',
|
||||
sceneName: '订单管理',
|
||||
pageTitle: '导出订单',
|
||||
permissions: {
|
||||
detail: 'export_task:order_detail',
|
||||
download: 'export_task:order_download',
|
||||
cancel: 'export_task:order_cancel'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getExportTaskSceneConfig = (scene?: ExportTaskScene) => {
|
||||
return scene ? EXPORT_TASK_SCENE_CONFIG[scene] : undefined
|
||||
}
|
||||
|
||||
export const getExportTaskSceneName = (scene?: ExportTaskScene) => {
|
||||
return getExportTaskSceneConfig(scene)?.sceneName || '-'
|
||||
}
|
||||
@@ -31,3 +31,6 @@ export * from './roleTypes'
|
||||
|
||||
// 启用状态相关
|
||||
export * from './enableStatus'
|
||||
|
||||
// 导出任务相关
|
||||
export * from './exportTask'
|
||||
|
||||
@@ -449,12 +449,17 @@
|
||||
"taskManagement": "Task Management",
|
||||
"exportTaskManagement": "Export Management",
|
||||
"exportTaskList": "Export List",
|
||||
"exportDevice": "Export Devices",
|
||||
"exportIotCard": "Export IOT Cards",
|
||||
"exportOrder": "Export Orders",
|
||||
"exportTaskDetail": "Export Task Detail",
|
||||
"exchangeManagement": "Exchange Management",
|
||||
"exchangeDetail": "Exchange Order Detail"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings Management",
|
||||
"paymentSettings": "Payment Settings",
|
||||
"detailsOfPaymentConfiguration": "Payment Configuration Details",
|
||||
"paymentMerchant": "Payment Merchant",
|
||||
"developerApi": "Developer API",
|
||||
"commissionTemplate": "Commission Template"
|
||||
|
||||
@@ -383,6 +383,9 @@
|
||||
"taskManagement": "任务管理",
|
||||
"exportTaskManagement": "导出管理",
|
||||
"exportTaskList": "导出列表",
|
||||
"exportDevice": "导出设备",
|
||||
"exportIotCard": "导出IOT卡",
|
||||
"exportOrder": "导出订单",
|
||||
"exportTaskDetail": "导出任务详情",
|
||||
"exchangeManagement": "换货管理",
|
||||
"exchangeDetail": "换货单详情"
|
||||
@@ -407,7 +410,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置管理",
|
||||
"wechatPayConfiguration": "微信配置",
|
||||
"paymentSettings": "支付设置",
|
||||
"detailsOfPaymentConfiguration": "支付配置详情",
|
||||
"withdrawalSettings": "提现配置",
|
||||
"passwordSettings": "密码设置"
|
||||
|
||||
@@ -331,8 +331,7 @@ function buildRouteMap(routes: AppRouteRecord[], parentPath = ''): Map<string, A
|
||||
*/
|
||||
function convertBackendMenuToRoute(
|
||||
menu: any,
|
||||
routeMap: Map<string, AppRouteRecord>,
|
||||
parentPath = ''
|
||||
routeMap: Map<string, AppRouteRecord>
|
||||
): AppRouteRecord | null {
|
||||
const menuUrl = menu.url || '/'
|
||||
const matchedRoute = routeMap.get(menuUrl)
|
||||
@@ -343,7 +342,7 @@ function convertBackendMenuToRoute(
|
||||
// 如果当前菜单没有匹配的路由,但有 children,尝试递归处理 children
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const children = menu.children
|
||||
.map((child: any) => convertBackendMenuToRoute(child, routeMap, menuUrl))
|
||||
.map((child: any) => convertBackendMenuToRoute(child, routeMap))
|
||||
.filter((child: AppRouteRecord | null) => child !== null)
|
||||
|
||||
// 如果子菜单有有效的路由,返回一个包含子菜单的占位路由
|
||||
@@ -387,7 +386,7 @@ function convertBackendMenuToRoute(
|
||||
// 递归处理后端返回的 children
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const children = menu.children
|
||||
.map((child: any) => convertBackendMenuToRoute(child, routeMap, menuUrl))
|
||||
.map((child: any) => convertBackendMenuToRoute(child, routeMap))
|
||||
.filter((child: AppRouteRecord | null) => child !== null)
|
||||
|
||||
if (children.length > 0) {
|
||||
|
||||
@@ -451,12 +451,32 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'export-task-list',
|
||||
name: 'ExportTaskList',
|
||||
component: RoutesAlias.ExportTaskList,
|
||||
path: 'export-device',
|
||||
name: 'ExportDeviceTaskList',
|
||||
component: RoutesAlias.ExportDeviceTaskList,
|
||||
meta: {
|
||||
title: 'menus.assetManagement.exportTaskList',
|
||||
permissions: ['export_task:list'],
|
||||
title: 'menus.assetManagement.exportDevice',
|
||||
exportTaskScene: 'device',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'export-iot-card',
|
||||
name: 'ExportIotCardTaskList',
|
||||
component: RoutesAlias.ExportIotCardTaskList,
|
||||
meta: {
|
||||
title: 'menus.assetManagement.exportIotCard',
|
||||
exportTaskScene: 'iot_card',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'export-order',
|
||||
name: 'ExportOrderTaskList',
|
||||
component: RoutesAlias.ExportOrderTaskList,
|
||||
meta: {
|
||||
title: 'menus.assetManagement.exportOrder',
|
||||
exportTaskScene: 'order',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
@@ -466,7 +486,6 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
component: RoutesAlias.ExportTaskDetail,
|
||||
meta: {
|
||||
title: 'menus.assetManagement.exportTaskDetail',
|
||||
permissions: ['export_task:detail'],
|
||||
isHide: true,
|
||||
keepAlive: false
|
||||
}
|
||||
@@ -621,13 +640,13 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
icon: ''
|
||||
},
|
||||
children: [
|
||||
// 微信配置
|
||||
// 支付设置
|
||||
{
|
||||
path: 'wechat-config',
|
||||
name: 'WechatConfig',
|
||||
component: RoutesAlias.WechatConfig,
|
||||
path: 'payment-settings',
|
||||
name: 'PaymentSettings',
|
||||
component: RoutesAlias.PaymentSettings,
|
||||
meta: {
|
||||
title: 'menus.settings.wechatPayConfiguration',
|
||||
title: 'menus.settings.paymentSettings',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
@@ -642,11 +661,11 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
// 微信支付配置详情
|
||||
// 支付设置详情
|
||||
{
|
||||
path: 'wechat-config/detail/:id',
|
||||
name: 'WechatConfigDetailRoute',
|
||||
component: RoutesAlias.WechatConfigDetail,
|
||||
path: 'payment-settings/detail/:id',
|
||||
name: 'PaymentSettingsDetailRoute',
|
||||
component: RoutesAlias.PaymentSettingsDetail,
|
||||
meta: {
|
||||
title: 'menus.settings.detailsOfPaymentConfiguration',
|
||||
isHide: true,
|
||||
|
||||
@@ -60,7 +60,9 @@ export enum RoutesAlias {
|
||||
OrderPackageInvalidateTask = '/asset-management/task-management/order-package-invalidate-task', // 订单套餐批量作废任务
|
||||
OrderPackageInvalidateTaskDetail = '/asset-management/task-management/order-package-invalidate-task/detail', // 订单套餐作废任务详情
|
||||
TaskDetail = '/asset-management/task-management/task-detail', // 任务详情(IoT卡/设备任务详情)
|
||||
ExportTaskList = '/asset-management/export-task-management/export-task-list', // 导出列表
|
||||
ExportDeviceTaskList = '/asset-management/export-task-management/export-device', // 导出设备
|
||||
ExportIotCardTaskList = '/asset-management/export-task-management/export-iot-card', // 导出IOT卡
|
||||
ExportOrderTaskList = '/asset-management/export-task-management/export-order', // 导出订单
|
||||
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
|
||||
|
||||
// 订单管理
|
||||
@@ -80,8 +82,8 @@ export enum RoutesAlias {
|
||||
|
||||
// 设置管理
|
||||
WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置
|
||||
WechatConfig = '/settings/wechat-config', // 微信配置
|
||||
WechatConfigDetail = '/settings/wechat-config/detail', // 微信支付配置详情
|
||||
PaymentSettings = '/settings/payment-settings', // 支付设置
|
||||
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
|
||||
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
|
||||
|
||||
// 轮询管理
|
||||
|
||||
@@ -107,6 +107,20 @@ export interface AccountQueryParams extends PaginationParams {
|
||||
enterprise_id?: number // 按企业ID筛选
|
||||
}
|
||||
|
||||
// 平台业务员候选账号
|
||||
export interface PlatformSalesperson {
|
||||
account_id: number
|
||||
account_name: string
|
||||
phone_masked: string
|
||||
}
|
||||
|
||||
// 平台业务员候选查询参数
|
||||
export interface PlatformSalespersonQueryParams extends PaginationParams {
|
||||
account_type: 'platform'
|
||||
status: AccountStatus
|
||||
account_name?: string
|
||||
}
|
||||
|
||||
// 代理商查询参数
|
||||
export interface AgentQueryParams extends PaginationParams {
|
||||
keyword?: string
|
||||
|
||||
@@ -42,6 +42,51 @@ export type UsageType = 'single_card' | 'device'
|
||||
|
||||
// ========== 资产详情响应 ==========
|
||||
|
||||
export interface AssetResolveParams {
|
||||
include_usage_summary?: boolean // 是否返回当前世代流量汇总字段
|
||||
}
|
||||
|
||||
// 资产轮询状态
|
||||
export interface AssetPollingStatus {
|
||||
enabled: boolean // 是否启用轮询
|
||||
activity_level: string // 活跃级别
|
||||
last_activity_at?: string | null // 最后活跃时间
|
||||
last_activity_scene?: string | null // 最后活跃场景
|
||||
next_poll_at?: string | null // 下次轮询时间
|
||||
}
|
||||
|
||||
// 资产复机结果
|
||||
export interface AssetStartResponse {
|
||||
status: number // 最新资产状态
|
||||
status_name: string // 最新资产状态名称
|
||||
real_name_status: RealNameStatus // 最新实名状态
|
||||
real_name_status_name: string // 最新实名状态名称
|
||||
}
|
||||
|
||||
// 资产实名认证策略
|
||||
export type AssetRealnamePolicy = 'none' | 'before_order' | 'after_order'
|
||||
|
||||
// 批量更新资产实名认证策略请求
|
||||
export interface BatchUpdateAssetRealnamePolicyRequest {
|
||||
asset_ids: number[]
|
||||
realname_policy: AssetRealnamePolicy
|
||||
}
|
||||
|
||||
// 换货关联资产
|
||||
export interface AssetExchangeTraceAsset {
|
||||
asset_type: AssetType
|
||||
asset_id: number | null
|
||||
identifier: string
|
||||
exchange_no: string
|
||||
can_view: boolean
|
||||
}
|
||||
|
||||
// 换货前后代链路
|
||||
export interface AssetExchangeTrace {
|
||||
previous_asset?: AssetExchangeTraceAsset | null
|
||||
next_asset?: AssetExchangeTraceAsset | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产解析/详情响应
|
||||
* 对应接口:GET /api/admin/assets/resolve/:identifier
|
||||
@@ -67,6 +112,8 @@ export interface AssetResolveResponse {
|
||||
real_used_mb: number // 真流量已用
|
||||
virtual_total_mb: number // 业务停机阈值(展示用)
|
||||
virtual_used_mb: number // 展示已用量
|
||||
total_virtual_used_mb?: number | null // 所有套餐已用量(MB),未请求时为 null
|
||||
total_virtual_remaining_mb?: number | null // 所有套餐剩余量(MB),未请求时为 null
|
||||
reduction_pct: number // 展示增幅比例(小数,如 0.428571)
|
||||
device_protect_status?: DeviceProtectStatus // 保护期状态:none / stop / start(仅 device)
|
||||
activated_at: string // 激活时间
|
||||
@@ -74,6 +121,8 @@ export interface AssetResolveResponse {
|
||||
updated_at: string // 更新时间
|
||||
accumulated_recharge?: number // 累计充值金额(分)
|
||||
first_commission_paid?: boolean // 一次性佣金是否已发放
|
||||
polling?: AssetPollingStatus | null // 资产轮询状态
|
||||
exchange_trace?: AssetExchangeTrace | null // 换货前后代链路
|
||||
|
||||
// ===== 卡专属字段 (asset_type === 'card' 时) =====
|
||||
iccid?: string // ICCID
|
||||
|
||||
@@ -211,6 +211,10 @@ export interface ShopFundSummaryItem {
|
||||
phone?: string // 主账号手机号
|
||||
main_balance: number // 预充值余额(分)
|
||||
main_frozen_balance: number // 预充值冻结余额(分)
|
||||
balance: number // 现金余额(分)
|
||||
frozen_balance: number // 现金冻结金额(分)
|
||||
cash_available: number // 现金可用余额(分)
|
||||
low_balance_warning: boolean // 现金可用余额不足100元预警
|
||||
total_commission: number // 总佣金(分)
|
||||
available_commission: number // 可提现佣金(分)
|
||||
frozen_commission: number // 冻结中佣金(分)
|
||||
|
||||
@@ -75,8 +75,8 @@ export * from './asset'
|
||||
// 代理充值相关
|
||||
export * from './agentRecharge'
|
||||
|
||||
// 微信支付配置相关
|
||||
export * from './wechatConfig'
|
||||
// 支付设置相关
|
||||
export * from './paymentSettings'
|
||||
|
||||
// 退款管理相关
|
||||
export * from './refund'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 微信支付配置管理相关类型定义
|
||||
* 支付设置相关类型定义
|
||||
*/
|
||||
|
||||
// 支付渠道类型
|
||||
@@ -66,8 +66,8 @@ type PaymentConfigWritableFields = Partial<
|
||||
FuiouConfigFields
|
||||
>
|
||||
|
||||
// 支付配置
|
||||
export interface WechatConfig
|
||||
// 支付设置
|
||||
export interface PaymentSettings
|
||||
extends PaymentConfigBase,
|
||||
MiniappConfigFields,
|
||||
OfficialAccountConfigFields,
|
||||
@@ -76,7 +76,7 @@ export interface WechatConfig
|
||||
FuiouConfigFields {}
|
||||
|
||||
// 查询参数
|
||||
export interface WechatConfigQueryParams {
|
||||
export interface PaymentSettingsQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
provider_type?: PaymentProviderType
|
||||
@@ -84,23 +84,23 @@ export interface WechatConfigQueryParams {
|
||||
}
|
||||
|
||||
// 列表响应
|
||||
export interface WechatConfigListResponse {
|
||||
items: WechatConfig[]
|
||||
export interface PaymentSettingsListResponse {
|
||||
items: PaymentSettings[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
// 创建微信支付配置请求
|
||||
export interface CreateWechatConfigRequest extends PaymentConfigWritableFields {
|
||||
// 创建支付设置请求
|
||||
export interface CreatePaymentSettingsRequest extends PaymentConfigWritableFields {
|
||||
name: string
|
||||
provider_type: PaymentProviderType
|
||||
description?: string
|
||||
}
|
||||
|
||||
// 更新微信支付配置请求
|
||||
export interface UpdateWechatConfigRequest extends PaymentConfigWritableFields {
|
||||
// 更新支付设置请求
|
||||
export interface UpdatePaymentSettingsRequest extends PaymentConfigWritableFields {
|
||||
name?: string
|
||||
description?: string
|
||||
provider_type?: PaymentProviderType
|
||||
@@ -20,7 +20,7 @@ export interface PaymentMerchantSetting {
|
||||
publicKey: string
|
||||
notifyUrl?: string
|
||||
}
|
||||
// 微信配置
|
||||
// 微信支付配置
|
||||
wechat?: {
|
||||
enabled: boolean
|
||||
appId: string
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface ShopResponse {
|
||||
contact_name: string // 联系人姓名
|
||||
contact_phone: string // 联系人电话
|
||||
status: number // 状态 (0:禁用, 1:启用)
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
business_owner_name?: string // 平台业务员名称
|
||||
business_owner_phone_masked?: string // 平台业务员脱敏手机号
|
||||
}
|
||||
|
||||
// 店铺列表查询参数
|
||||
@@ -32,6 +35,8 @@ export interface ShopQueryParams extends PaginationParams {
|
||||
parent_id?: number | null // 上级店铺ID
|
||||
level?: number | null // 店铺层级 (1-7级)
|
||||
status?: number | null // 状态 (0:禁用, 1:启用)
|
||||
contact_phone?: string // 联系电话精确查询
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
page?: number // 页码
|
||||
page_size?: number // 每页数量
|
||||
}
|
||||
@@ -50,6 +55,7 @@ export interface CreateShopParams {
|
||||
address?: string // 详细地址
|
||||
contact_name?: string // 联系人姓名
|
||||
contact_phone?: string // 联系人电话
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
}
|
||||
|
||||
// 更新店铺参数
|
||||
@@ -62,6 +68,7 @@ export interface UpdateShopParams {
|
||||
address?: string // 详细地址
|
||||
contact_name?: string // 联系人姓名
|
||||
contact_phone?: string // 联系人电话
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
}
|
||||
|
||||
// 店铺列表分页响应
|
||||
|
||||
1
src/types/components.d.ts
vendored
1
src/types/components.d.ts
vendored
@@ -70,6 +70,7 @@ declare module 'vue' {
|
||||
ArtWorkTab: typeof import('./../components/core/layouts/art-work-tab/index.vue')['default']
|
||||
BasicSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BasicSettings.vue')['default']
|
||||
BatchOperationDialog: typeof import('./../components/business/BatchOperationDialog.vue')['default']
|
||||
BatchRealnamePolicyDialog: typeof import('./../components/business/BatchRealnamePolicyDialog.vue')['default']
|
||||
BoxStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default']
|
||||
CardOperationDialog: typeof import('./../components/business/CardOperationDialog.vue')['default']
|
||||
CardStatusTag: typeof import('./../components/business/CardStatusTag.vue')['default']
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { RouteRecordRaw } from 'vue-router'
|
||||
import type { ExportTaskScene } from '@/types/api'
|
||||
|
||||
// 路由元数据
|
||||
export interface RouteMeta extends Record<string | number | symbol, unknown> {
|
||||
@@ -33,6 +34,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
|
||||
isFirstLevel?: boolean
|
||||
/** 角色权限 */
|
||||
roles?: string[]
|
||||
/** 导出任务固定场景 */
|
||||
exportTaskScene?: ExportTaskScene
|
||||
/** 是否固定标签页 */
|
||||
fixedTab?: boolean
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PaymentProviderType, WechatConfig } from '@/types/api'
|
||||
import type { PaymentProviderType, PaymentSettings } from '@/types/api'
|
||||
|
||||
const PAYMENT_PROVIDER_LABELS: Record<PaymentProviderType, string> = {
|
||||
wechat: '微信直连',
|
||||
@@ -11,7 +11,7 @@ export const getPaymentProviderText = (type: PaymentProviderType): string => {
|
||||
}
|
||||
|
||||
export const getPaymentMerchantId = (
|
||||
config: Pick<WechatConfig, 'provider_type' | 'wx_mch_id' | 'fy_mchnt_cd'>
|
||||
config: Pick<PaymentSettings, 'provider_type' | 'wx_mch_id' | 'fy_mchnt_cd'>
|
||||
): string => {
|
||||
if (config.provider_type === 'wechat' || config.provider_type === 'wechat_v2') {
|
||||
return config.wx_mch_id || '-'
|
||||
@@ -25,7 +25,7 @@ export const getPaymentMerchantId = (
|
||||
}
|
||||
|
||||
export const getPaymentNotifyUrl = (
|
||||
config: Pick<WechatConfig, 'provider_type' | 'wx_notify_url' | 'fy_notify_url' | 'ali_notify_url'>
|
||||
config: Pick<PaymentSettings, 'provider_type' | 'wx_notify_url' | 'fy_notify_url' | 'ali_notify_url'>
|
||||
): string => {
|
||||
if (config.provider_type === 'wechat' || config.provider_type === 'wechat_v2') {
|
||||
return config.wx_notify_url || '-'
|
||||
|
||||
@@ -9,7 +9,7 @@ const axiosInstance = axios.create({
|
||||
baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理,其他模式使用完整URL
|
||||
withCredentials: false, // 异步请求携带cookie
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
(data) => {
|
||||
// 如果是 FormData,不进行转换
|
||||
if (data instanceof FormData) {
|
||||
return data
|
||||
@@ -72,6 +72,7 @@ let failedQueue: Array<{
|
||||
resolve: (value?: any) => void
|
||||
reject: (reason?: any) => void
|
||||
}> = [] // 失败队列,存储因 token 过期而失败的请求
|
||||
let isHandlingAuthExpiry = false
|
||||
|
||||
// 处理失败队列
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
@@ -222,6 +223,11 @@ function handleErrorMessage(
|
||||
const backendMessage = error.response?.data?.msg
|
||||
const httpStatus = error.response?.status
|
||||
|
||||
// 401 过期类错误由 clearLocalStateAndRedirect 统一提示,避免重复弹出后端报错。
|
||||
if (httpStatus === 401 || error.response?.data?.code === ApiStatus.unauthorized) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是404错误,且未配置显示404错误,则不显示任何提示
|
||||
if (httpStatus === 404 && !show404Error) {
|
||||
return
|
||||
@@ -306,9 +312,13 @@ const api = {
|
||||
|
||||
// 仅清理本地状态(不调用接口)- 用于401等情况
|
||||
const clearLocalStateAndRedirect = () => {
|
||||
if (isHandlingAuthExpiry) return
|
||||
isHandlingAuthExpiry = true
|
||||
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
setTimeout(() => {
|
||||
useUserStore().clearLocalState()
|
||||
isHandlingAuthExpiry = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
:icon="Refresh"
|
||||
:disabled="refreshDisabled"
|
||||
>
|
||||
{{ refreshButtonText }}
|
||||
同步
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElCard, ElInput, ElButton } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
@@ -63,19 +63,13 @@
|
||||
interface Props {
|
||||
hasCardInfo?: boolean
|
||||
refreshDisabled?: boolean
|
||||
refreshRemainingText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
hasCardInfo: false,
|
||||
refreshDisabled: false,
|
||||
refreshRemainingText: ''
|
||||
refreshDisabled: false
|
||||
})
|
||||
|
||||
const refreshButtonText = computed(() =>
|
||||
props.refreshRemainingText ? `同步(${props.refreshRemainingText})` : '同步'
|
||||
)
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
search: [payload: { identifier: string }]
|
||||
|
||||
@@ -32,15 +32,13 @@
|
||||
<ElDescriptionsItem label="IMEI">{{
|
||||
cardInfo?.gateway_card_imei || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="运营商账户">{{
|
||||
cardInfo?.carrier_name || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="运营商账户">{{ carrierAccountDisplay }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="实名状态">
|
||||
<ElTag :type="getRealNameStatusType(cardInfo?.real_name_status)" size="small">
|
||||
{{ getRealNameStatusName(cardInfo?.real_name_status) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="实名认证策略">
|
||||
<ElDescriptionsItem v-if="canShowSensitiveFields" label="实名认证策略">
|
||||
{{ getRealnamePolicyName(cardInfo?.realname_policy) }}
|
||||
</ElDescriptionsItem>
|
||||
|
||||
@@ -82,6 +80,12 @@
|
||||
<ElDescriptionsItem label="运营商状态" :span="1">
|
||||
<span class="gateway-extend-text">{{ cardInfo?.gateway_extend || '-' }}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="所有套餐已用量">
|
||||
{{ formatUsageSummaryMb(cardInfo?.total_virtual_used_mb) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="所有套餐剩余量">
|
||||
{{ formatUsageSummaryMb(cardInfo?.total_virtual_remaining_mb) }}
|
||||
</ElDescriptionsItem>
|
||||
|
||||
<template v-if="cardInfo?.bound_device_id">
|
||||
<ElDescriptionsItem label="绑定设备号">
|
||||
@@ -151,21 +155,96 @@
|
||||
<ElDescriptionsItem label="最大卡槽数">{{
|
||||
cardInfo?.max_sim_slots || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="实名认证策略">
|
||||
<ElDescriptionsItem v-if="canShowSensitiveFields" label="实名认证策略">
|
||||
{{ getRealnamePolicyName(cardInfo?.realname_policy) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="设备类型">{{ cardInfo?.device_type || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="制造商">{{ cardInfo?.manufacturer || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="canShowSensitiveFields" label="制造商">{{
|
||||
cardInfo?.manufacturer || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最后在线时间">{{
|
||||
formatDateTime(cardInfo?.last_online_time) || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="SN">{{ cardInfo?.sn || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="所有套餐已用量">
|
||||
{{ formatUsageSummaryMb(cardInfo?.total_virtual_used_mb) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="所有套餐剩余量">
|
||||
{{ formatUsageSummaryMb(cardInfo?.total_virtual_remaining_mb) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最后同步时间">{{
|
||||
formatDateTime(deviceRealtime?.last_sync_time) || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="SN">{{ cardInfo?.sn || '-' }}</ElDescriptionsItem>
|
||||
</template>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElDivider content-position="left">同步状态</ElDivider>
|
||||
<ElDescriptions :column="descriptionsColumn" border>
|
||||
<ElDescriptionsItem label="轮询状态">
|
||||
<ElTag
|
||||
v-if="cardInfo?.polling"
|
||||
:type="cardInfo.polling.enabled ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ cardInfo.polling.enabled ? '已启用' : '未启用' }}
|
||||
</ElTag>
|
||||
<span v-else>-</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最后活跃时间">
|
||||
{{ formatDateTime(cardInfo?.polling?.last_activity_at) || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="同步轨迹">
|
||||
<ElButton
|
||||
v-permission="'asset_info:view_sync_trail'"
|
||||
type="primary"
|
||||
link
|
||||
@click="emit('viewSyncTrail')"
|
||||
>
|
||||
查看同步轨迹
|
||||
</ElButton>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<template v-if="previousExchangeAsset || nextExchangeAsset">
|
||||
<ElDivider content-position="left">换货链路</ElDivider>
|
||||
<ElDescriptions :column="descriptionsColumn" border>
|
||||
<ElDescriptionsItem v-if="previousExchangeAsset" label="前代资产">
|
||||
<div class="exchange-asset">
|
||||
<ElTag type="success" size="small">换货新资产</ElTag>
|
||||
<ElButton
|
||||
v-if="previousExchangeAsset.can_view"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleNavigateToExchangeAsset(previousExchangeAsset)"
|
||||
>
|
||||
{{ previousExchangeAsset.identifier }}
|
||||
</ElButton>
|
||||
<span v-else>{{ previousExchangeAsset.identifier }}</span>
|
||||
<span v-if="previousExchangeAsset.exchange_no" class="exchange-no">
|
||||
换货单:{{ previousExchangeAsset.exchange_no }}
|
||||
</span>
|
||||
</div>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="nextExchangeAsset" label="后代资产">
|
||||
<div class="exchange-asset">
|
||||
<ElTag type="warning" size="small">已换出旧资产</ElTag>
|
||||
<ElButton
|
||||
v-if="nextExchangeAsset.can_view"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleNavigateToExchangeAsset(nextExchangeAsset)"
|
||||
>
|
||||
{{ nextExchangeAsset.identifier }}
|
||||
</ElButton>
|
||||
<span v-else>{{ nextExchangeAsset.identifier }}</span>
|
||||
<span v-if="nextExchangeAsset.exchange_no" class="exchange-no">
|
||||
换货单:{{ nextExchangeAsset.exchange_no }}
|
||||
</span>
|
||||
</div>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</template>
|
||||
|
||||
<!-- 设备绑定卡列表 -->
|
||||
<template v-if="cardInfo?.asset_type === 'device'">
|
||||
<ElDivider content-position="left">绑定卡列表</ElDivider>
|
||||
@@ -390,6 +469,14 @@
|
||||
</template>
|
||||
|
||||
<!-- IoT卡操作按钮 -->
|
||||
<ElAlert
|
||||
v-if="startErrorMessage"
|
||||
:title="startErrorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-top: 16px"
|
||||
/>
|
||||
<div
|
||||
v-if="cardInfo?.asset_type === 'card'"
|
||||
class="card-operations"
|
||||
@@ -479,11 +566,13 @@
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElButton,
|
||||
ElAlert,
|
||||
ElEmpty,
|
||||
ElMessageBox,
|
||||
ElMessage
|
||||
} from 'element-plus'
|
||||
import { CopyDocument } from '@element-plus/icons-vue'
|
||||
import type { AssetExchangeTrace, AssetExchangeTraceAsset, AssetPollingStatus } from '@/types/api'
|
||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
@@ -512,6 +601,7 @@
|
||||
iccid?: string
|
||||
imei?: string
|
||||
msisdn?: string
|
||||
carrier_type?: string
|
||||
carrier_name?: string
|
||||
real_name_status?: number
|
||||
activation_status_name?: string
|
||||
@@ -532,10 +622,14 @@
|
||||
supplier?: string
|
||||
gateway_card_imei?: string
|
||||
gateway_extend?: string
|
||||
total_virtual_used_mb?: number | null
|
||||
total_virtual_remaining_mb?: number | null
|
||||
last_sync_time?: string | null
|
||||
last_data_check_at?: string | null
|
||||
last_real_name_check_at?: string | null
|
||||
last_card_status_check_at?: string | null
|
||||
polling?: AssetPollingStatus | null
|
||||
exchange_trace?: AssetExchangeTrace | null
|
||||
bound_device_id?: number
|
||||
bound_device_no?: string
|
||||
bound_device_name?: string
|
||||
@@ -585,6 +679,7 @@
|
||||
pollingEnabled: boolean
|
||||
realtimeLoading?: boolean
|
||||
startDisabled?: boolean
|
||||
startErrorMessage?: string
|
||||
stopDisabled?: boolean
|
||||
startRemainingText?: string
|
||||
stopRemainingText?: string
|
||||
@@ -597,6 +692,7 @@
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
realtimeLoading: false,
|
||||
startDisabled: false,
|
||||
startErrorMessage: '',
|
||||
stopDisabled: false,
|
||||
startRemainingText: '',
|
||||
stopRemainingText: ''
|
||||
@@ -629,6 +725,22 @@
|
||||
return ![3, 4].includes(userType) && hasAuth(CARD_MONTH_USAGE_PERMISSION)
|
||||
})
|
||||
|
||||
const isAgentOrEnterpriseAccount = computed(() => {
|
||||
const userType = Number(userStore.info.user_type ?? 0)
|
||||
return [3, 4].includes(userType)
|
||||
})
|
||||
|
||||
const canShowSensitiveFields = computed(() => {
|
||||
return !isAgentOrEnterpriseAccount.value
|
||||
})
|
||||
|
||||
const carrierAccountDisplay = computed(() => {
|
||||
if (isAgentOrEnterpriseAccount.value) {
|
||||
return getCarrierTypeName(props.cardInfo?.carrier_type)
|
||||
}
|
||||
return props.cardInfo?.carrier_name || '-'
|
||||
})
|
||||
|
||||
const descriptionsColumn = computed(() => {
|
||||
if (width.value <= 640) return 1
|
||||
if (width.value <= 1024) return 2
|
||||
@@ -654,6 +766,7 @@
|
||||
(e: 'updateBindingCardRealnameStatus', payload: { card: BindingCard }): void
|
||||
(e: 'navigateToCard', iccid: string): void
|
||||
(e: 'navigateToDevice', deviceNo: string): void
|
||||
(e: 'viewSyncTrail'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -665,6 +778,7 @@
|
||||
getRealNameStatusName,
|
||||
getRealNameStatusType,
|
||||
getRealnamePolicyName,
|
||||
getCarrierTypeName,
|
||||
getAssetStatusName,
|
||||
getAssetStatusType,
|
||||
getOnlineStatusName,
|
||||
@@ -688,6 +802,11 @@
|
||||
})
|
||||
})
|
||||
|
||||
const previousExchangeAsset = computed(
|
||||
() => props.cardInfo.exchange_trace?.previous_asset ?? null
|
||||
)
|
||||
const nextExchangeAsset = computed(() => props.cardInfo.exchange_trace?.next_asset ?? null)
|
||||
|
||||
// 设备详情里的实名状态和实名时间从选中的绑定卡取值:
|
||||
// 只有一张卡时直接取该卡,多张卡时仅取 is_current=true 的卡。
|
||||
const selectedDeviceStatusCard = computed(() => {
|
||||
@@ -710,6 +829,11 @@
|
||||
props.cardInfo?.last_card_status_check_at !== undefined
|
||||
)
|
||||
|
||||
const formatUsageSummaryMb = (value?: number | null) => {
|
||||
if (value === undefined || value === null) return '-'
|
||||
return formatDataSize(value)
|
||||
}
|
||||
|
||||
// 处理轮询开关变化
|
||||
const handlePollingChange = (value: string | number | boolean) => {
|
||||
emit('update:pollingEnabled', Boolean(value))
|
||||
@@ -721,6 +845,17 @@
|
||||
emit('navigateToCard', iccid)
|
||||
}
|
||||
|
||||
const handleNavigateToExchangeAsset = (asset: AssetExchangeTraceAsset) => {
|
||||
if (!asset.can_view) return
|
||||
|
||||
if (asset.asset_type === 'card') {
|
||||
emit('navigateToCard', asset.identifier)
|
||||
return
|
||||
}
|
||||
|
||||
emit('navigateToDevice', asset.identifier)
|
||||
}
|
||||
|
||||
// 复制ICCID
|
||||
const handleCopyICCID = async (iccid: string) => {
|
||||
try {
|
||||
@@ -880,6 +1015,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.exchange-asset {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.exchange-no {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* 资产信息格式化工具函数
|
||||
*/
|
||||
|
||||
import { getCarrierTypeLabel } from '@/config/constants/carrierTypes'
|
||||
import type { CarrierType } from '@/types/api'
|
||||
|
||||
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
|
||||
|
||||
export function useAssetFormatters() {
|
||||
@@ -76,6 +79,13 @@ export function useAssetFormatters() {
|
||||
return policyMap[policy] || '未知'
|
||||
}
|
||||
|
||||
// 获取运营商类型名称
|
||||
const getCarrierTypeName = (carrierType?: string | null) => {
|
||||
const normalizedCarrierType = carrierType?.trim()
|
||||
if (!normalizedCarrierType) return '-'
|
||||
return getCarrierTypeLabel(normalizedCarrierType as CarrierType)
|
||||
}
|
||||
|
||||
// 获取资产状态名称
|
||||
const getAssetStatusName = (status?: number) => {
|
||||
if (status === undefined || status === null) return '未知'
|
||||
@@ -252,6 +262,7 @@ export function useAssetFormatters() {
|
||||
getRealNameStatusName,
|
||||
getRealNameStatusType,
|
||||
getRealnamePolicyName,
|
||||
getCarrierTypeName,
|
||||
getAssetStatusName,
|
||||
getAssetStatusType,
|
||||
getOnlineStatusName,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AssetService } from '@/api/modules'
|
||||
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
|
||||
import { FrontendRateLimitError } from '@/utils/business/apiRateLimit'
|
||||
import type {
|
||||
AssetBoundCard,
|
||||
AssetWalletResponse,
|
||||
@@ -92,7 +92,7 @@ export function useAssetInfo() {
|
||||
const fetchAssetDetail = async (identifier: string) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await AssetService.resolveAsset(identifier)
|
||||
const response = await AssetService.resolveAsset(identifier, { include_usage_summary: true })
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
const data = response.data
|
||||
@@ -123,6 +123,8 @@ export function useAssetInfo() {
|
||||
updated_at: data.updated_at,
|
||||
accumulated_recharge: data.accumulated_recharge,
|
||||
first_commission_paid: data.first_commission_paid,
|
||||
polling: data.polling ?? null,
|
||||
exchange_trace: data.exchange_trace ?? null,
|
||||
|
||||
// 卡专属字段 (asset_type === 'card')
|
||||
iccid: data.iccid || '',
|
||||
@@ -136,6 +138,8 @@ export function useAssetInfo() {
|
||||
gateway_card_imei: resolvedGatewayCardImei,
|
||||
resolved_gateway_card_imei: resolvedGatewayCardImei,
|
||||
gateway_extend: data.gateway_extend ?? '',
|
||||
total_virtual_used_mb: data.total_virtual_used_mb ?? null,
|
||||
total_virtual_remaining_mb: data.total_virtual_remaining_mb ?? null,
|
||||
bound_device_id: data.bound_device_id,
|
||||
bound_device_no: data.bound_device_no || '',
|
||||
bound_device_name: data.bound_device_name || '',
|
||||
@@ -428,11 +432,11 @@ export function useAssetInfo() {
|
||||
return true
|
||||
} catch (error: any) {
|
||||
if (error instanceof FrontendRateLimitError) {
|
||||
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
|
||||
ElMessage.warning('操作过于频繁,请稍后重试')
|
||||
return false
|
||||
}
|
||||
if (error?.response?.status === 429) {
|
||||
ElMessage.warning('操作过于频繁,请5分钟后再试')
|
||||
ElMessage.warning('操作过于频繁,请稍后重试')
|
||||
return false
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/ap
|
||||
export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Promise<void>) {
|
||||
// Loading states
|
||||
const enableCardLoading = ref(false)
|
||||
const startFailureMessage = ref('')
|
||||
const disableCardLoading = ref(false)
|
||||
const manualDeactivateCardLoading = ref(false)
|
||||
const rebootDeviceLoading = ref(false)
|
||||
@@ -29,22 +30,33 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
startFailureMessage.value = ''
|
||||
enableCardLoading.value = true
|
||||
const res = await AssetService.startAsset(cardInfo.value.iccid)
|
||||
const res = await AssetService.startAsset(cardInfo.value.iccid, {
|
||||
requestOptions: { errorMessageMode: 'none' }
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('启用成功')
|
||||
if (refreshAssetFn) {
|
||||
await refreshAssetFn()
|
||||
}
|
||||
return true
|
||||
}
|
||||
startFailureMessage.value = res.msg || '复机失败,请稍后重试'
|
||||
return false
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
if (error instanceof FrontendRateLimitError) {
|
||||
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
console.error('启用失败:', error)
|
||||
const backendMessage = error?.response?.data?.msg
|
||||
startFailureMessage.value =
|
||||
typeof backendMessage === 'string' && backendMessage.trim()
|
||||
? backendMessage
|
||||
: '复机失败,请稍后重试'
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
enableCardLoading.value = false
|
||||
}
|
||||
@@ -303,6 +315,7 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
return {
|
||||
// Loading states
|
||||
enableCardLoading,
|
||||
startFailureMessage,
|
||||
disableCardLoading,
|
||||
manualDeactivateCardLoading,
|
||||
rebootDeviceLoading,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
ref="assetSearchCardRef"
|
||||
:has-card-info="!!cardInfo"
|
||||
:refresh-disabled="refreshDisabled"
|
||||
:refresh-remaining-text="refreshRemainingText"
|
||||
@search="handleSearch"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
@@ -21,6 +20,7 @@
|
||||
v-model:polling-enabled="pollingEnabled"
|
||||
:realtime-loading="realtimeLoading"
|
||||
:start-disabled="startDisabled"
|
||||
:start-error-message="startFailureMessage"
|
||||
:stop-disabled="stopDisabled"
|
||||
:start-remaining-text="startRemainingText"
|
||||
:stop-remaining-text="stopRemainingText"
|
||||
@@ -43,6 +43,7 @@
|
||||
@update-binding-card-realname-status="handleUpdateBindingCardRealnameStatus"
|
||||
@navigate-to-card="handleNavigateToCard"
|
||||
@navigate-to-device="handleNavigateToDevice"
|
||||
@view-sync-trail="handleViewSyncTrail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -258,6 +259,7 @@
|
||||
// ========== 资产操作方法(使用 composable) ==========
|
||||
const {
|
||||
enableCardLoading,
|
||||
startFailureMessage,
|
||||
disableCardLoading,
|
||||
enableCard: enableCardOp,
|
||||
disableCard: disableCardOp,
|
||||
@@ -332,26 +334,7 @@
|
||||
const refreshSubmitting = ref(false)
|
||||
let rateLimitTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const refreshRateLimitState = computed(() => {
|
||||
const identifier = cardInfo.value?.identifier
|
||||
if (!identifier) {
|
||||
return {
|
||||
limited: false,
|
||||
remainingMs: 0,
|
||||
retryAt: 0
|
||||
}
|
||||
}
|
||||
return getAssetActionRateLimitState('refresh', identifier, rateLimitNow.value)
|
||||
})
|
||||
|
||||
const refreshDisabled = computed(
|
||||
() => refreshSubmitting.value || refreshRateLimitState.value.limited
|
||||
)
|
||||
const refreshRemainingText = computed(() =>
|
||||
refreshRateLimitState.value.limited
|
||||
? formatRemainingTime(refreshRateLimitState.value.remainingMs)
|
||||
: ''
|
||||
)
|
||||
const refreshDisabled = computed(() => refreshSubmitting.value)
|
||||
|
||||
const startRateLimitState = computed(() => {
|
||||
const identifier = cardInfo.value?.iccid || cardInfo.value?.identifier
|
||||
@@ -418,6 +401,7 @@
|
||||
const handleSearch = async ({ identifier }: { identifier: string }) => {
|
||||
// 更新搜索框的值
|
||||
assetSearchCardRef.value?.updateSearchValue(identifier)
|
||||
startFailureMessage.value = ''
|
||||
|
||||
// fetchAssetDetail 内部已经会并行加载所有相关数据,不需要重复调用
|
||||
await fetchAssetDetail(identifier)
|
||||
@@ -459,13 +443,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewSyncTrail = () => {
|
||||
if (!cardInfo.value) return
|
||||
|
||||
router.push({
|
||||
path: '/audit/integrations',
|
||||
query: {
|
||||
resource_type: cardInfo.value.asset_type,
|
||||
resource_key: cardInfo.value.identifier
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT卡操作 - 启用卡
|
||||
*/
|
||||
const handleEnableCard = async () => {
|
||||
if (startDisabled.value) return
|
||||
try {
|
||||
await enableCardOp()
|
||||
const started = await enableCardOp()
|
||||
if (started && cardInfo.value) {
|
||||
await fetchAssetDetail(cardInfo.value.identifier)
|
||||
}
|
||||
} finally {
|
||||
updateRateLimitNow()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* 资产信息页面类型定义
|
||||
*/
|
||||
|
||||
import type { AssetExchangeTrace, AssetPollingStatus } from '@/types/api'
|
||||
|
||||
// 资产类型枚举
|
||||
export type AssetType = 'card' | 'device'
|
||||
|
||||
@@ -14,6 +16,7 @@ export interface AssetInfo {
|
||||
iccid?: string
|
||||
imei?: string
|
||||
msisdn?: string
|
||||
carrier_type?: string
|
||||
carrier_name?: string
|
||||
real_name_status?: number
|
||||
activation_status_name?: string
|
||||
@@ -30,10 +33,14 @@ export interface AssetInfo {
|
||||
status?: number
|
||||
gateway_card_imei?: string
|
||||
gateway_extend?: string
|
||||
total_virtual_used_mb?: number | null
|
||||
total_virtual_remaining_mb?: number | null
|
||||
last_sync_time?: string | null
|
||||
last_data_check_at?: string | null
|
||||
last_real_name_check_at?: string | null
|
||||
last_card_status_check_at?: string | null
|
||||
polling?: AssetPollingStatus | null
|
||||
exchange_trace?: AssetExchangeTrace | null
|
||||
}
|
||||
|
||||
// 设备绑定卡信息
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
>
|
||||
批量设置套餐系列
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="!selectedDevices.length"
|
||||
@click="showBatchRealnamePolicyDialog"
|
||||
v-permission="'device:realname_policy'"
|
||||
>
|
||||
批量修改实名顺序
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="!selectedDevices.length"
|
||||
@@ -685,6 +693,15 @@
|
||||
:current-policy="currentRealnamePolicy"
|
||||
@confirm="handleConfirmRealnamePolicy"
|
||||
/>
|
||||
<BatchRealnamePolicyDialog
|
||||
v-model="batchRealnamePolicyDialogVisible"
|
||||
:selected-count="selectedDevices.length"
|
||||
asset-unit="台设备"
|
||||
:loading="batchRealnamePolicyLoading"
|
||||
:error-message="batchRealnamePolicyError"
|
||||
device-policy-hint
|
||||
@confirm="handleConfirmBatchRealnamePolicy"
|
||||
/>
|
||||
|
||||
<!-- 操作审计日志弹窗 -->
|
||||
<OperationLogsDialog
|
||||
@@ -985,8 +1002,10 @@
|
||||
EnterpriseRecallDevicesResponse
|
||||
} from '@/types/api/enterpriseDevice'
|
||||
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
|
||||
import BatchRealnamePolicyDialog from '@/components/business/BatchRealnamePolicyDialog.vue'
|
||||
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import type { AssetRealnamePolicy } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'DeviceList' })
|
||||
|
||||
@@ -1013,6 +1032,9 @@
|
||||
const allocateDialogVisible = ref(false)
|
||||
const recallDialogVisible = ref(false)
|
||||
const selectedDevices = ref<Device[]>([])
|
||||
const batchRealnamePolicyDialogVisible = ref(false)
|
||||
const batchRealnamePolicyLoading = ref(false)
|
||||
const batchRealnamePolicyError = ref('')
|
||||
const operationLogsDialogVisible = ref(false)
|
||||
const operationLogsIdentifier = ref('')
|
||||
const exportDialogVisible = ref(false)
|
||||
@@ -2201,6 +2223,50 @@
|
||||
selectedDevices.value = selection
|
||||
}
|
||||
|
||||
const showBatchRealnamePolicyDialog = () => {
|
||||
if (selectedDevices.value.length === 0) {
|
||||
ElMessage.warning('请先选择要修改的设备')
|
||||
return
|
||||
}
|
||||
|
||||
batchRealnamePolicyError.value = ''
|
||||
batchRealnamePolicyDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleConfirmBatchRealnamePolicy = async (realnamePolicy: AssetRealnamePolicy) => {
|
||||
const assetIds = selectedDevices.value.map((device) => device.id)
|
||||
if (assetIds.length === 0) {
|
||||
batchRealnamePolicyError.value = '请先选择要修改的设备'
|
||||
return
|
||||
}
|
||||
if (assetIds.length > 500) {
|
||||
batchRealnamePolicyError.value = '单次最多可修改500台设备'
|
||||
return
|
||||
}
|
||||
|
||||
batchRealnamePolicyError.value = ''
|
||||
batchRealnamePolicyLoading.value = true
|
||||
try {
|
||||
const res = await DeviceService.batchUpdateRealnamePolicy(
|
||||
{ asset_ids: assetIds, realname_policy: realnamePolicy },
|
||||
{ requestOptions: { errorMessageMode: 'none' } }
|
||||
)
|
||||
if (res.code !== 0) {
|
||||
batchRealnamePolicyError.value = res.msg || '批量修改实名顺序失败'
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('批量修改实名顺序成功')
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedDevices.value = []
|
||||
await getTableData()
|
||||
} catch (error: any) {
|
||||
batchRealnamePolicyError.value = error?.response?.data?.msg || '批量修改实名顺序失败'
|
||||
} finally {
|
||||
batchRealnamePolicyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
const deleteDevice = (row: Device) => {
|
||||
ElMessageBox.confirm(
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
const exchangeDetail = ref<ExchangeResponse | null>(null)
|
||||
const exchangeId = ref<number>(0)
|
||||
|
||||
const formatExchangeShopName = (shopName?: string | null, shopId?: number | null) => {
|
||||
if (shopName) return shopName
|
||||
if (shopId === null || shopId === 0) return '平台'
|
||||
return '--'
|
||||
}
|
||||
|
||||
const getStatusType = (status: number): TagProps['type'] => {
|
||||
const types: Record<number, TagProps['type']> = {
|
||||
1: 'warning',
|
||||
@@ -96,6 +102,11 @@
|
||||
label: '新资产标识符',
|
||||
formatter: (value) => value || '--',
|
||||
prop: 'new_asset_identifier'
|
||||
},
|
||||
{
|
||||
label: '继承归属店铺',
|
||||
formatter: (_, data) =>
|
||||
formatExchangeShopName(data.inherited_shop_name, data.inherited_shop_id)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -170,6 +170,9 @@
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="createOldAssetShopDisplay" label="旧资产所属店铺">
|
||||
<ElInput :model-value="createOldAssetShopDisplay" readonly disabled />
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="createForm.flow_type === 'direct' && createForm.old_asset_type === 'iot_card'"
|
||||
label="新资产标识符"
|
||||
@@ -232,6 +235,9 @@
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="createDirectInheritedShopHint" label="归属提示">
|
||||
<div class="exchange-shop-hint">{{ createDirectInheritedShopHint }}</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="createForm.flow_type === 'direct'" label="是否迁移数据">
|
||||
<ElSwitch v-model="createForm.migrate_data" />
|
||||
<div style="margin-top: 4px; margin-left: 8px; font-size: 12px; color: #909399">
|
||||
@@ -250,7 +256,9 @@
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="createDialogVisible = false">取消</ElButton>
|
||||
<ElButton @click="createDialogVisible = false" :disabled="createLoading"
|
||||
>取消</ElButton
|
||||
>
|
||||
<ElButton type="primary" @click="handleConfirmCreate" :loading="createLoading">
|
||||
确认创建
|
||||
</ElButton>
|
||||
@@ -267,11 +275,70 @@
|
||||
@closed="handleCloseShipDialog"
|
||||
>
|
||||
<ElForm ref="shipFormRef" :model="shipForm" :rules="shipRules" label-width="120px">
|
||||
<ElFormItem label="新资产标识符" prop="new_identifier">
|
||||
<ElInput
|
||||
<ElFormItem
|
||||
v-if="shipAssetType === 'iot_card'"
|
||||
label="新资产标识符"
|
||||
prop="new_identifier"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="shipForm.new_identifier"
|
||||
placeholder="请输入新资产标识符(ICCID/虚拟号/IMEI/SN)"
|
||||
/>
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="请输入ICCID搜索"
|
||||
:remote-method="searchShipNewIotCards"
|
||||
:loading="newCardSearchLoading"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
>
|
||||
<ElOption
|
||||
v-for="card in newIotCardOptions"
|
||||
:key="card.id"
|
||||
:label="`${card.iccid} (${card.msisdn || '无接入号'})`"
|
||||
:value="card.iccid"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<span>{{ card.iccid }}</span>
|
||||
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
|
||||
{{ card.msisdn || '无接入号' }}
|
||||
</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-else-if="shipAssetType === 'device'"
|
||||
label="新资产标识符"
|
||||
prop="new_identifier"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="shipForm.new_identifier"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="请输入虚拟号或IMEI搜索"
|
||||
:remote-method="searchShipNewDevices"
|
||||
:loading="newDeviceSearchLoading"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
>
|
||||
<ElOption
|
||||
v-for="device in newDeviceOptions"
|
||||
:key="device.id"
|
||||
:label="`${device.virtual_no} (${device.device_name || device.imei})`"
|
||||
:value="device.virtual_no"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<span>{{ device.virtual_no }}</span>
|
||||
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
|
||||
{{ device.device_name || device.imei }}
|
||||
</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="shipInheritedShopDisplayText" label="归属提示">
|
||||
<div class="exchange-shop-hint">{{ shipInheritedShopDisplayText }}</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="快递公司" prop="express_company">
|
||||
<ElInput v-model="shipForm.express_company" placeholder="请输入快递公司" />
|
||||
@@ -289,7 +356,7 @@
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="shipDialogVisible = false">取消</ElButton>
|
||||
<ElButton @click="shipDialogVisible = false" :disabled="shipLoading">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirmShip" :loading="shipLoading">
|
||||
确认发货
|
||||
</ElButton>
|
||||
@@ -411,6 +478,7 @@
|
||||
const newCardSearchLoading = ref(false)
|
||||
const newDeviceOptions = ref<Device[]>([])
|
||||
const newDeviceSearchLoading = ref(false)
|
||||
const completeLoadingId = ref<number | null>(null)
|
||||
|
||||
// 发货表单
|
||||
const shipForm = reactive({
|
||||
@@ -426,6 +494,88 @@
|
||||
express_no: [{ required: true, message: '请输入快递单号', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
type ShopSelectableOption = {
|
||||
shop_id?: number | null
|
||||
shop_name?: string | null
|
||||
}
|
||||
|
||||
const getShopDisplayName = (option?: ShopSelectableOption | null) => {
|
||||
if (!option) return ''
|
||||
if (option.shop_name) return option.shop_name
|
||||
if (option.shop_id === null || option.shop_id === 0) return '平台'
|
||||
return ''
|
||||
}
|
||||
|
||||
const createSelectedOldAsset = computed<ShopSelectableOption | null>(() => {
|
||||
if (!createForm.old_identifier) return null
|
||||
|
||||
if (createForm.old_asset_type === 'iot_card') {
|
||||
return (
|
||||
oldIotCardOptions.value.find((card) => card.iccid === createForm.old_identifier) || null
|
||||
)
|
||||
}
|
||||
|
||||
if (createForm.old_asset_type === 'device') {
|
||||
return (
|
||||
oldDeviceOptions.value.find((device) => device.virtual_no === createForm.old_identifier) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const createSelectedNewAsset = computed<ShopSelectableOption | null>(() => {
|
||||
if (!createForm.new_identifier) return null
|
||||
|
||||
if (createForm.old_asset_type === 'iot_card') {
|
||||
return (
|
||||
newIotCardOptions.value.find((card) => card.iccid === createForm.new_identifier) || null
|
||||
)
|
||||
}
|
||||
|
||||
if (createForm.old_asset_type === 'device') {
|
||||
return (
|
||||
newDeviceOptions.value.find((device) => device.virtual_no === createForm.new_identifier) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const shipAssetType = computed(() => currentExchangeRow.value?.old_asset_type || '')
|
||||
|
||||
const shipSelectedNewAsset = computed<ShopSelectableOption | null>(() => {
|
||||
if (!shipForm.new_identifier) return null
|
||||
|
||||
if (shipAssetType.value === 'iot_card') {
|
||||
return newIotCardOptions.value.find((card) => card.iccid === shipForm.new_identifier) || null
|
||||
}
|
||||
|
||||
if (shipAssetType.value === 'device') {
|
||||
return (
|
||||
newDeviceOptions.value.find((device) => device.virtual_no === shipForm.new_identifier) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const createOldAssetShopDisplay = computed(() => getShopDisplayName(createSelectedOldAsset.value))
|
||||
|
||||
const createDirectInheritedShopHint = computed(() => {
|
||||
const shopName = getShopDisplayName(createSelectedNewAsset.value)
|
||||
return shopName ? `换货完成后将归属:${shopName}` : ''
|
||||
})
|
||||
|
||||
const shipInheritedShopDisplayText = computed(() => {
|
||||
const shopName = getShopDisplayName(shipSelectedNewAsset.value)
|
||||
|
||||
return shopName ? `换货完成后将归属:${shopName}` : ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
@@ -732,11 +882,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
const refreshExchangeDetail = async (id: number) => {
|
||||
try {
|
||||
const res = await ExchangeService.getExchangeDetail(id)
|
||||
if (res.code === 0 && res.data) {
|
||||
return res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新换货详情失败:', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 加载默认IoT卡列表
|
||||
const loadDefaultIotCards = async () => {
|
||||
oldCardSearchLoading.value = true
|
||||
try {
|
||||
const res = await CardService.getStandaloneIotCards({
|
||||
is_standalone: true,
|
||||
page: 1,
|
||||
page_size: 10
|
||||
})
|
||||
@@ -787,6 +951,7 @@
|
||||
oldCardSearchLoading.value = true
|
||||
try {
|
||||
const res = await CardService.getStandaloneIotCards({
|
||||
is_standalone: true,
|
||||
keyword: query,
|
||||
page: 1,
|
||||
page_size: 20
|
||||
@@ -845,6 +1010,36 @@
|
||||
newCardSearchLoading.value = true
|
||||
try {
|
||||
const res = await CardService.getStandaloneIotCards({
|
||||
is_standalone: true,
|
||||
keyword: query,
|
||||
page: 1,
|
||||
page_size: 20
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
newIotCardOptions.value = res.data.items || []
|
||||
if (newIotCardOptions.value.length === 0) {
|
||||
ElMessage.info('未找到匹配的IoT卡')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索IoT卡失败:', error)
|
||||
newIotCardOptions.value = []
|
||||
} finally {
|
||||
newCardSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchShipNewIotCards = async (query: string) => {
|
||||
if (!query) {
|
||||
await loadDefaultShipNewIotCards()
|
||||
return
|
||||
}
|
||||
|
||||
newCardSearchLoading.value = true
|
||||
try {
|
||||
const res = await CardService.getStandaloneIotCards({
|
||||
is_standalone: true,
|
||||
status: 1,
|
||||
keyword: query,
|
||||
page: 1,
|
||||
page_size: 20
|
||||
@@ -868,6 +1063,27 @@
|
||||
newCardSearchLoading.value = true
|
||||
try {
|
||||
const res = await CardService.getStandaloneIotCards({
|
||||
is_standalone: true,
|
||||
page: 1,
|
||||
page_size: 10
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
newIotCardOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载IoT卡列表失败:', error)
|
||||
newIotCardOptions.value = []
|
||||
} finally {
|
||||
newCardSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDefaultShipNewIotCards = async () => {
|
||||
newCardSearchLoading.value = true
|
||||
try {
|
||||
const res = await CardService.getStandaloneIotCards({
|
||||
is_standalone: true,
|
||||
status: 1,
|
||||
page: 1,
|
||||
page_size: 10
|
||||
})
|
||||
@@ -911,6 +1127,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
const searchShipNewDevices = async (query: string) => {
|
||||
if (!query) {
|
||||
await loadDefaultShipNewDevices()
|
||||
return
|
||||
}
|
||||
|
||||
newDeviceSearchLoading.value = true
|
||||
try {
|
||||
const res = await DeviceService.getDevices({
|
||||
status: 1,
|
||||
keyword: query,
|
||||
page: 1,
|
||||
page_size: 20
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
newDeviceOptions.value = res.data.items || []
|
||||
if (newDeviceOptions.value.length === 0) {
|
||||
ElMessage.info('未找到匹配的设备')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索设备失败:', error)
|
||||
newDeviceOptions.value = []
|
||||
} finally {
|
||||
newDeviceSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载默认新设备列表
|
||||
const loadDefaultNewDevices = async () => {
|
||||
newDeviceSearchLoading.value = true
|
||||
@@ -930,10 +1174,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
const loadDefaultShipNewDevices = async () => {
|
||||
newDeviceSearchLoading.value = true
|
||||
try {
|
||||
const res = await DeviceService.getDevices({
|
||||
status: 1,
|
||||
page: 1,
|
||||
page_size: 10
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
newDeviceOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载设备列表失败:', error)
|
||||
newDeviceOptions.value = []
|
||||
} finally {
|
||||
newDeviceSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 确认创建
|
||||
const handleConfirmCreate = () => {
|
||||
createFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
if (!valid || createLoading.value) return
|
||||
|
||||
createLoading.value = true
|
||||
try {
|
||||
@@ -961,7 +1224,10 @@
|
||||
ElMessage.success('换货单创建成功')
|
||||
createDialogVisible.value = false
|
||||
await loadExchangeList()
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.error(res.msg || '换货单创建失败')
|
||||
} catch (error) {
|
||||
console.error('创建换货单失败:', error)
|
||||
} finally {
|
||||
@@ -1081,29 +1347,44 @@
|
||||
}
|
||||
|
||||
// 发货
|
||||
const handleShipExchange = (row: ExchangeResponse) => {
|
||||
const handleShipExchange = async (row: ExchangeResponse) => {
|
||||
currentExchangeRow.value = row
|
||||
// 重置表单
|
||||
shipForm.new_identifier = ''
|
||||
shipForm.express_company = ''
|
||||
shipForm.express_no = ''
|
||||
shipForm.migrate_data = true
|
||||
newIotCardOptions.value = []
|
||||
newDeviceOptions.value = []
|
||||
shipDialogVisible.value = true
|
||||
|
||||
if (row.old_asset_type === 'iot_card') {
|
||||
await loadDefaultShipNewIotCards()
|
||||
} else if (row.old_asset_type === 'device') {
|
||||
await loadDefaultShipNewDevices()
|
||||
}
|
||||
}
|
||||
|
||||
// 确认发货
|
||||
const handleConfirmShip = () => {
|
||||
shipFormRef.value?.validate(async (valid) => {
|
||||
if (!valid || !currentExchangeRow.value) return
|
||||
if (!valid || !currentExchangeRow.value || shipLoading.value) return
|
||||
|
||||
shipLoading.value = true
|
||||
try {
|
||||
const res = await ExchangeService.shipExchange(currentExchangeRow.value.id, shipForm)
|
||||
if (res.code === 0) {
|
||||
const latestDetail = await refreshExchangeDetail(currentExchangeRow.value.id)
|
||||
if (latestDetail) {
|
||||
currentExchangeRow.value = latestDetail
|
||||
}
|
||||
ElMessage.success('发货成功')
|
||||
shipDialogVisible.value = false
|
||||
await loadExchangeList()
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.error(res.msg || '发货失败')
|
||||
} catch (error) {
|
||||
console.error('发货失败:', error)
|
||||
} finally {
|
||||
@@ -1115,25 +1396,52 @@
|
||||
// 关闭发货对话框
|
||||
const handleCloseShipDialog = () => {
|
||||
shipFormRef.value?.resetFields()
|
||||
currentExchangeRow.value = null
|
||||
newIotCardOptions.value = []
|
||||
newDeviceOptions.value = []
|
||||
}
|
||||
|
||||
// 确认完成
|
||||
const handleCompleteExchange = (row: ExchangeResponse) => {
|
||||
if (completeLoadingId.value === row.id) return
|
||||
|
||||
completeLoadingId.value = row.id
|
||||
|
||||
ElMessageBox.confirm('确定要确认换货完成吗?', '确认操作', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
.then(async () => {
|
||||
type: 'warning',
|
||||
beforeClose: async (action, instance, done) => {
|
||||
if (action !== 'confirm') {
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
instance.confirmButtonLoading = true
|
||||
instance.confirmButtonText = '提交中...'
|
||||
|
||||
try {
|
||||
const res = await ExchangeService.completeExchange(row.id)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('操作成功')
|
||||
await refreshExchangeDetail(row.id)
|
||||
await loadExchangeList()
|
||||
ElMessage.success('操作成功')
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.error(res.msg || '确认完成失败')
|
||||
} catch (error) {
|
||||
console.error('确认完成失败:', error)
|
||||
} finally {
|
||||
instance.confirmButtonLoading = false
|
||||
instance.confirmButtonText = '确定'
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(() => {})
|
||||
.finally(() => {
|
||||
completeLoadingId.value = null
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -1168,5 +1476,15 @@
|
||||
<style lang="scss" scoped>
|
||||
.exchange-management-page {
|
||||
height: 100%;
|
||||
|
||||
.exchange-shop-hint {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportDeviceTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportIotCardTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportOrderTaskList' })
|
||||
</script>
|
||||
@@ -10,23 +10,11 @@
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">导出任务详情</h2>
|
||||
<div class="detail-actions" v-if="taskDetail">
|
||||
<ElButton
|
||||
v-if="
|
||||
taskDetail.status === ExportTaskStatus.COMPLETED && hasAuth('export_task:download')
|
||||
"
|
||||
type="primary"
|
||||
@click="downloadTask"
|
||||
>
|
||||
<div class="detail-actions" v-if="taskDetail && canViewDetail">
|
||||
<ElButton v-if="canDownloadTask" type="primary" @click="downloadTask">
|
||||
下载文件
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="canCancelTask && hasAuth('export_task:cancel')"
|
||||
type="danger"
|
||||
@click="cancelTask"
|
||||
>
|
||||
取消任务
|
||||
</ElButton>
|
||||
<ElButton v-if="canCancelTask" type="danger" @click="cancelTask"> 取消任务 </ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,8 +24,12 @@
|
||||
</ElIcon>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<DetailPage v-else-if="taskDetail" :sections="detailSections" :data="taskDetail" />
|
||||
<ElEmpty v-else description="暂无详情" />
|
||||
<DetailPage
|
||||
v-else-if="taskDetail && canViewDetail"
|
||||
:sections="detailSections"
|
||||
:data="taskDetail"
|
||||
/>
|
||||
<ElEmpty v-else :description="forbidden ? '暂无权限查看该导出任务详情' : '暂无详情'" />
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
@@ -52,8 +44,9 @@
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { ExportTaskService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { getExportTaskSceneConfig, getExportTaskSceneName } from '@/config/constants'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import type { ExportTaskDetail, ExportTaskScene } from '@/types/api'
|
||||
import type { ExportTaskDetail } from '@/types/api'
|
||||
import { ExportTaskStatus } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'ExportTaskDetail' })
|
||||
@@ -62,17 +55,22 @@
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const loading = ref(false)
|
||||
const forbidden = ref(false)
|
||||
const taskDetail = ref<ExportTaskDetail | null>(null)
|
||||
|
||||
const getSceneName = (scene?: ExportTaskScene) => {
|
||||
const sceneMap: Record<ExportTaskScene, string> = {
|
||||
device: '设备管理',
|
||||
iot_card: 'IoT卡管理',
|
||||
order: '订单管理'
|
||||
}
|
||||
|
||||
return scene ? sceneMap[scene] : '-'
|
||||
}
|
||||
const taskPermissions = computed(
|
||||
() => getExportTaskSceneConfig(taskDetail.value?.scene)?.permissions
|
||||
)
|
||||
const canViewDetail = computed(
|
||||
() => !!taskPermissions.value && hasAuth(taskPermissions.value.detail)
|
||||
)
|
||||
const canDownloadTask = computed(
|
||||
() =>
|
||||
canViewDetail.value &&
|
||||
taskDetail.value?.status === ExportTaskStatus.COMPLETED &&
|
||||
!!taskPermissions.value &&
|
||||
hasAuth(taskPermissions.value.download)
|
||||
)
|
||||
|
||||
const getStatusTagType = (status?: ExportTaskStatus) => {
|
||||
const statusTypeMap: Record<
|
||||
@@ -89,10 +87,14 @@
|
||||
return status ? statusTypeMap[status] : 'info'
|
||||
}
|
||||
|
||||
const canCancelTask = computed(() =>
|
||||
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
|
||||
taskDetail.value?.status as ExportTaskStatus
|
||||
)
|
||||
const canCancelTask = computed(
|
||||
() =>
|
||||
canViewDetail.value &&
|
||||
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
|
||||
taskDetail.value?.status as ExportTaskStatus
|
||||
) &&
|
||||
!!taskPermissions.value &&
|
||||
hasAuth(taskPermissions.value.cancel)
|
||||
)
|
||||
|
||||
const detailSections = computed((): DetailSection[] => [
|
||||
@@ -102,7 +104,7 @@
|
||||
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
|
||||
{
|
||||
label: '导出场景',
|
||||
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getSceneName(data.scene))
|
||||
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getExportTaskSceneName(data.scene))
|
||||
},
|
||||
{
|
||||
label: '任务状态',
|
||||
@@ -174,12 +176,13 @@
|
||||
fullWidth: true,
|
||||
render: (data: ExportTaskDetail) => {
|
||||
if (!data.download_url) return h('span', '-')
|
||||
if (!canDownloadTask.value) return h('span', '-')
|
||||
return h(
|
||||
ElButton,
|
||||
{
|
||||
type: 'primary',
|
||||
link: true,
|
||||
onClick: () => window.open(data.download_url, '_blank')
|
||||
onClick: () => downloadTask()
|
||||
},
|
||||
() => '打开下载地址'
|
||||
)
|
||||
@@ -209,10 +212,15 @@
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
forbidden.value = false
|
||||
try {
|
||||
const res = await ExportTaskService.getExportTaskDetail(taskId)
|
||||
if (res.code === 0) {
|
||||
taskDetail.value = res.data
|
||||
if (!canViewDetail.value) {
|
||||
forbidden.value = true
|
||||
ElMessage.warning('您没有查看该导出任务详情的权限')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取导出任务详情失败')
|
||||
}
|
||||
@@ -225,6 +233,11 @@
|
||||
}
|
||||
|
||||
const downloadTask = () => {
|
||||
if (!canDownloadTask.value) {
|
||||
ElMessage.warning('您没有下载该导出任务的权限')
|
||||
return
|
||||
}
|
||||
|
||||
if (!taskDetail.value?.download_url) {
|
||||
ElMessage.warning('当前任务暂无可用下载地址')
|
||||
return
|
||||
@@ -235,6 +248,10 @@
|
||||
|
||||
const cancelTask = () => {
|
||||
if (!taskDetail.value) return
|
||||
if (!canCancelTask.value) {
|
||||
ElMessage.warning('您没有取消该导出任务的权限')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessageBox.confirm(`确定取消导出任务 ${taskDetail.value.task_no} 吗?`, '取消确认', {
|
||||
confirmButtonText: '确定',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
label-width="110"
|
||||
:show-expand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
@@ -38,27 +39,36 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElProgress, ElTag } from 'element-plus'
|
||||
import { ExportTaskService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { getExportTaskSceneConfig } from '@/config/constants'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { ExportTaskItem, ExportTaskScene } from '@/types/api'
|
||||
import { ExportTaskStatus } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'ExportTaskList' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const loading = ref(false)
|
||||
const taskList = ref<ExportTaskItem[]>([])
|
||||
const defaultSceneConfig = getExportTaskSceneConfig('device')!
|
||||
|
||||
const routeScene = computed(() => route.meta.exportTaskScene as ExportTaskScene | undefined)
|
||||
const sceneConfig = computed(
|
||||
() => getExportTaskSceneConfig(routeScene.value) || defaultSceneConfig
|
||||
)
|
||||
const currentScene = computed(() => sceneConfig.value.scene)
|
||||
const currentPermissions = computed(() => sceneConfig.value.permissions)
|
||||
|
||||
const initialSearchState = {
|
||||
scene: undefined as ExportTaskScene | undefined,
|
||||
status: undefined as ExportTaskStatus | undefined,
|
||||
dateRange: [] as string[],
|
||||
start_time: '',
|
||||
@@ -72,12 +82,6 @@
|
||||
total: 0
|
||||
})
|
||||
|
||||
const sceneOptions = [
|
||||
{ label: '设备管理', value: 'device' },
|
||||
{ label: 'IoT卡管理', value: 'iot_card' },
|
||||
{ label: '订单管理', value: 'order' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待处理', value: ExportTaskStatus.PENDING },
|
||||
{ label: '处理中', value: ExportTaskStatus.PROCESSING },
|
||||
@@ -87,13 +91,6 @@
|
||||
]
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '导出场景',
|
||||
prop: 'scene',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '请选择导出场景' },
|
||||
options: () => sceneOptions
|
||||
},
|
||||
{
|
||||
label: '任务状态',
|
||||
prop: 'status',
|
||||
@@ -117,7 +114,6 @@
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '任务编号', prop: 'task_no' },
|
||||
{ label: '场景', prop: 'scene' },
|
||||
{ label: '状态', prop: 'status_name' },
|
||||
{ label: '进度', prop: 'progress' },
|
||||
{ label: '格式', prop: 'format' },
|
||||
@@ -144,12 +140,6 @@
|
||||
row.task_no || '-'
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'scene',
|
||||
label: '场景',
|
||||
width: 120,
|
||||
formatter: (row: ExportTaskItem) => getSceneName(row.scene)
|
||||
},
|
||||
{
|
||||
prop: 'status_name',
|
||||
label: '状态',
|
||||
@@ -181,11 +171,6 @@
|
||||
}
|
||||
])
|
||||
|
||||
const getSceneName = (scene?: ExportTaskScene) => {
|
||||
const option = sceneOptions.find((item) => item.value === scene)
|
||||
return option?.label || '-'
|
||||
}
|
||||
|
||||
const getStatusTagType = (status: ExportTaskStatus) => {
|
||||
const statusTypeMap: Record<
|
||||
ExportTaskStatus,
|
||||
@@ -218,7 +203,7 @@
|
||||
const res = await ExportTaskService.getExportTasks({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
scene: searchForm.scene,
|
||||
scene: currentScene.value,
|
||||
status: searchForm.status,
|
||||
start_time: searchForm.start_time || undefined,
|
||||
end_time: searchForm.end_time || undefined
|
||||
@@ -272,8 +257,12 @@
|
||||
return res.data
|
||||
}
|
||||
|
||||
const getRowPermissions = (row: ExportTaskItem) => {
|
||||
return getExportTaskSceneConfig(row.scene)?.permissions || currentPermissions.value
|
||||
}
|
||||
|
||||
const goDetail = (row: ExportTaskItem) => {
|
||||
if (!hasAuth('export_task:detail')) {
|
||||
if (!hasAuth(getRowPermissions(row).detail)) {
|
||||
ElMessage.warning('您没有查看导出任务详情的权限')
|
||||
return
|
||||
}
|
||||
@@ -285,6 +274,11 @@
|
||||
}
|
||||
|
||||
const downloadTask = async (row: ExportTaskItem) => {
|
||||
if (!hasAuth(getRowPermissions(row).download)) {
|
||||
ElMessage.warning('您没有下载导出任务的权限')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await getTaskDetail(row.id)
|
||||
if (!detail.download_url) {
|
||||
@@ -300,6 +294,11 @@
|
||||
}
|
||||
|
||||
const cancelTask = (row: ExportTaskItem) => {
|
||||
if (!hasAuth(getRowPermissions(row).cancel)) {
|
||||
ElMessage.warning('您没有取消导出任务的权限')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessageBox.confirm(`确定取消导出任务 ${row.task_no} 吗?`, '取消确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
@@ -322,14 +321,15 @@
|
||||
|
||||
const getActions = (row: ExportTaskItem) => {
|
||||
const actions: any[] = []
|
||||
const permissions = getRowPermissions(row)
|
||||
|
||||
if (row.status === ExportTaskStatus.COMPLETED && hasAuth('export_task:download')) {
|
||||
if (row.status === ExportTaskStatus.COMPLETED && hasAuth(permissions.download)) {
|
||||
actions.push({ label: '下载', handler: () => downloadTask(row), type: 'primary' })
|
||||
}
|
||||
|
||||
if (
|
||||
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(row.status) &&
|
||||
hasAuth('export_task:cancel')
|
||||
hasAuth(permissions.cancel)
|
||||
) {
|
||||
actions.push({ label: '取消', handler: () => cancelTask(row), type: 'danger' })
|
||||
}
|
||||
@@ -340,6 +340,12 @@
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
|
||||
watch(currentScene, () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
>
|
||||
批量设置套餐系列
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="selectedCards.length === 0"
|
||||
@click="showBatchRealnamePolicyDialog"
|
||||
v-permission="'iot_card:realname_policy'"
|
||||
>
|
||||
批量修改实名顺序
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="selectedCards.length === 0"
|
||||
@@ -976,6 +984,14 @@
|
||||
:current-policy="currentRealnamePolicy"
|
||||
@confirm="handleConfirmRealnamePolicy"
|
||||
/>
|
||||
<BatchRealnamePolicyDialog
|
||||
v-model="batchRealnamePolicyDialogVisible"
|
||||
:selected-count="selectedCards.length"
|
||||
asset-unit="张卡"
|
||||
:loading="batchRealnamePolicyLoading"
|
||||
:error-message="batchRealnamePolicyError"
|
||||
@confirm="handleConfirmBatchRealnamePolicy"
|
||||
/>
|
||||
|
||||
<!-- 更新实名状态对话框 -->
|
||||
<UpdateRealnameStatusDialog
|
||||
@@ -991,12 +1007,13 @@
|
||||
:identifier="operationLogsIdentifier"
|
||||
download-permission="iot_card:download_log_file"
|
||||
/>
|
||||
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
scene="iot_card"
|
||||
:query="exportQuery"
|
||||
confirm-permission="iot_card:export"
|
||||
title="导出IoT卡"
|
||||
title="导出IOT卡"
|
||||
/>
|
||||
</ElCard>
|
||||
</div>
|
||||
@@ -1018,6 +1035,7 @@
|
||||
import { ElMessage, ElTag, ElIcon, ElMessageBox } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
|
||||
import BatchRealnamePolicyDialog from '@/components/business/BatchRealnamePolicyDialog.vue'
|
||||
import UpdateRealnameStatusDialog from '@/components/business/UpdateRealnameStatusDialog.vue'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
@@ -1038,7 +1056,7 @@
|
||||
BatchSetCardSeriesBindingResponse
|
||||
} from '@/types/api/card'
|
||||
import { CardSelectionType } from '@/types/api/card'
|
||||
import type { PackageSeriesResponse } from '@/types/api'
|
||||
import type { AssetRealnamePolicy, PackageSeriesResponse } from '@/types/api'
|
||||
import type { EnterpriseItem } from '@/types/api/enterprise'
|
||||
import type { AllocateCardsResponse, RecallCardsResponse } from '@/types/api/enterpriseCard'
|
||||
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
|
||||
@@ -1061,6 +1079,9 @@
|
||||
const allocateFormRef = ref<FormInstance>()
|
||||
const recallFormRef = ref<FormInstance>()
|
||||
const selectedCards = ref<StandaloneIotCard[]>([])
|
||||
const batchRealnamePolicyDialogVisible = ref(false)
|
||||
const batchRealnamePolicyLoading = ref(false)
|
||||
const batchRealnamePolicyError = ref('')
|
||||
const allocationResult = ref<AllocateStandaloneCardsResponse>({
|
||||
allocation_no: '',
|
||||
total_count: 0,
|
||||
@@ -2363,6 +2384,50 @@
|
||||
selectedCards.value = selection
|
||||
}
|
||||
|
||||
const showBatchRealnamePolicyDialog = () => {
|
||||
if (selectedCards.value.length === 0) {
|
||||
ElMessage.warning('请先选择要修改的卡')
|
||||
return
|
||||
}
|
||||
|
||||
batchRealnamePolicyError.value = ''
|
||||
batchRealnamePolicyDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleConfirmBatchRealnamePolicy = async (realnamePolicy: AssetRealnamePolicy) => {
|
||||
const assetIds = selectedCards.value.map((card) => card.id)
|
||||
if (assetIds.length === 0) {
|
||||
batchRealnamePolicyError.value = '请先选择要修改的卡'
|
||||
return
|
||||
}
|
||||
if (assetIds.length > 500) {
|
||||
batchRealnamePolicyError.value = '单次最多可修改500张卡'
|
||||
return
|
||||
}
|
||||
|
||||
batchRealnamePolicyError.value = ''
|
||||
batchRealnamePolicyLoading.value = true
|
||||
try {
|
||||
const res = await CardService.batchUpdateRealnamePolicy(
|
||||
{ asset_ids: assetIds, realname_policy: realnamePolicy },
|
||||
{ requestOptions: { errorMessageMode: 'none' } }
|
||||
)
|
||||
if (res.code !== 0) {
|
||||
batchRealnamePolicyError.value = res.msg || '批量修改实名顺序失败'
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('批量修改实名顺序成功')
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedCards.value = []
|
||||
await getTableData()
|
||||
} catch (error: any) {
|
||||
batchRealnamePolicyError.value = error?.response?.data?.msg || '批量修改实名顺序失败'
|
||||
} finally {
|
||||
batchRealnamePolicyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示批量分配对话框
|
||||
const showAllocateDialog = () => {
|
||||
if (selectedCards.value.length === 0) {
|
||||
|
||||
@@ -550,7 +550,9 @@
|
||||
{ label: '店铺名称', prop: 'shop_name' },
|
||||
{ label: '用户名', prop: 'username' },
|
||||
{ label: '手机号', prop: 'phone' },
|
||||
{ label: '预充值余额', prop: 'main_balance' },
|
||||
{ label: '现金余额', prop: 'balance' },
|
||||
{ label: '冻结金额', prop: 'frozen_balance' },
|
||||
{ label: '余额预警', prop: 'low_balance_warning' },
|
||||
{ label: '总佣金', prop: 'total_commission' },
|
||||
{ label: '可提现', prop: 'available_commission' },
|
||||
{ label: '冻结中', prop: 'frozen_commission' },
|
||||
@@ -637,17 +639,33 @@
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
prop: 'main_balance',
|
||||
label: '预充值余额',
|
||||
prop: 'balance',
|
||||
label: '现金余额',
|
||||
minWidth: 130,
|
||||
formatter: (row: ShopFundSummaryItem) => {
|
||||
return h(
|
||||
'span',
|
||||
{ style: 'color: var(--el-color-primary); font-weight: 500' },
|
||||
formatMoney(row.main_balance)
|
||||
formatMoney(row.balance)
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'frozen_balance',
|
||||
label: '冻结金额',
|
||||
minWidth: 120,
|
||||
formatter: (row: ShopFundSummaryItem) => formatMoney(row.frozen_balance)
|
||||
},
|
||||
{
|
||||
prop: 'low_balance_warning',
|
||||
label: '余额预警',
|
||||
minWidth: 170,
|
||||
formatter: (row: ShopFundSummaryItem) => {
|
||||
if (!row.low_balance_warning) return '-'
|
||||
|
||||
return h(ElTag, { type: 'danger' }, () => '现金余额不足100元')
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'total_commission',
|
||||
label: '总佣金',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<CommissionSummary v-if="hasPermission('dashboard_analysis:commission_summary')" />
|
||||
<CommissionStats v-if="hasPermission('dashboard_analysis:commission_stats')" />
|
||||
<WithdrawalSettings v-if="hasPermission('dashboard_analysis:withdrawal_settings')" />
|
||||
<ActiveWechatConfig v-if="hasPermission('dashboard_analysis:wechat_config')" />
|
||||
<ActivePaymentSettings v-if="hasPermission('dashboard_analysis:payment_settings')" />
|
||||
|
||||
<!--<el-row :gutter="20">-->
|
||||
<!-- <el-col :xl="14" :lg="15" :xs="24">-->
|
||||
@@ -46,7 +46,7 @@
|
||||
import CommissionSummary from './widget/CommissionSummary.vue'
|
||||
import CommissionStats from './widget/CommissionStats.vue'
|
||||
import WithdrawalSettings from './widget/WithdrawalSettings.vue'
|
||||
import ActiveWechatConfig from './widget/ActiveWechatConfig.vue'
|
||||
import ActivePaymentSettings from './widget/ActivePaymentSettings.vue'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
|
||||
defineOptions({ name: 'Analysis' })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="active-wechat-config-widget">
|
||||
<ElCard shadow="never" class="active-payment-settings-widget">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
@@ -94,9 +94,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { WechatConfigService } from '@/api/modules'
|
||||
import { PaymentSettingsService } from '@/api/modules'
|
||||
import { ElTag } from 'element-plus'
|
||||
import type { WechatConfig } from '@/types/api'
|
||||
import type { PaymentSettings } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
getPaymentConfigStatus,
|
||||
@@ -106,26 +106,26 @@
|
||||
} from '@/utils/business/paymentConfig'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
|
||||
defineOptions({ name: 'ActiveWechatConfigWidget' })
|
||||
defineOptions({ name: 'ActivePaymentSettingsWidget' })
|
||||
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
// 当前生效的支付配置
|
||||
const activeConfig = ref<WechatConfig | null>(null)
|
||||
const activeConfig = ref<PaymentSettings | null>(null)
|
||||
|
||||
const getProviderTypeText = (type: WechatConfig['provider_type']): string => {
|
||||
const getProviderTypeText = (type: PaymentSettings['provider_type']): string => {
|
||||
return getPaymentProviderText(type)
|
||||
}
|
||||
|
||||
const getMerchantId = (config: WechatConfig): string => {
|
||||
const getMerchantId = (config: PaymentSettings): string => {
|
||||
return getPaymentMerchantId(config)
|
||||
}
|
||||
|
||||
const getNotifyUrl = (config: WechatConfig): string => {
|
||||
const getNotifyUrl = (config: PaymentSettings): string => {
|
||||
return getPaymentNotifyUrl(config)
|
||||
}
|
||||
|
||||
const getCredentialStatus = (config: WechatConfig): string => {
|
||||
const getCredentialStatus = (config: PaymentSettings): string => {
|
||||
if (config.provider_type === 'wechat' || config.provider_type === 'wechat_v2') {
|
||||
const certStatus = getPaymentConfigStatus(config.wx_cert_content)
|
||||
const keyStatus = getPaymentConfigStatus(config.wx_key_content)
|
||||
@@ -156,12 +156,12 @@
|
||||
// 加载当前生效配置
|
||||
const loadActiveConfig = async () => {
|
||||
// 检查权限,没有权限不调用接口
|
||||
if (!hasPermission('dashboard_analysis:wechat_config')) {
|
||||
if (!hasPermission('dashboard_analysis:payment_settings')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await WechatConfigService.getActiveWechatConfig()
|
||||
const res = await PaymentSettingsService.getActivePaymentSettings()
|
||||
if (res.code === 0 && res.data) {
|
||||
activeConfig.value = res.data
|
||||
}
|
||||
@@ -176,7 +176,7 @@
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.active-wechat-config-widget {
|
||||
.active-payment-settings-widget {
|
||||
:deep(.el-card__header) {
|
||||
padding: 18px 20px;
|
||||
background: var(--el-fill-color-light);
|
||||
@@ -276,7 +276,7 @@
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.active-wechat-config-widget {
|
||||
.active-payment-settings-widget {
|
||||
.card-header {
|
||||
.header-left,
|
||||
.header-right {
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="grant-package-list-page">
|
||||
<div class="grant-package-list-page" id="table-full-screen">
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
@@ -27,9 +27,9 @@
|
||||
:loading="loading"
|
||||
:data="packageList"
|
||||
:marginTop="10"
|
||||
:pagination="false"
|
||||
:actions="getActions"
|
||||
:actionsWidth="150"
|
||||
height="70vh"
|
||||
:inlineActionsCount="2"
|
||||
>
|
||||
<template #default>
|
||||
@@ -134,7 +134,6 @@
|
||||
ElCard,
|
||||
ElButton,
|
||||
ElIcon,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
ElDialog,
|
||||
@@ -429,8 +428,23 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.grant-package-list-page {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
|
||||
:deep(.art-table-card) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.el-card__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="wechat-config-detail-page">
|
||||
<div class="payment-settings-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<!-- 页面头部 -->
|
||||
<div class="detail-header">
|
||||
@@ -31,18 +31,18 @@
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { WechatConfigService } from '@/api/modules'
|
||||
import type { WechatConfig } from '@/types/api'
|
||||
import { PaymentSettingsService } from '@/api/modules'
|
||||
import type { PaymentSettings } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getPaymentConfigStatus, getPaymentProviderText } from '@/utils/business/paymentConfig'
|
||||
|
||||
defineOptions({ name: 'WechatConfigDetail' })
|
||||
defineOptions({ name: 'PaymentSettingsDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detailData = ref<WechatConfig | null>(null)
|
||||
const detailData = ref<PaymentSettings | null>(null)
|
||||
|
||||
const configId = computed(() => Number(route.params.id))
|
||||
const pageTitle = computed(() => `支付配置详情 #${configId.value}`)
|
||||
@@ -176,9 +176,9 @@
|
||||
]
|
||||
}
|
||||
|
||||
// 微信配置
|
||||
// 微信支付配置
|
||||
const wechatPaySection: DetailSection = {
|
||||
title: '微信配置',
|
||||
title: '微信支付配置',
|
||||
fields: [
|
||||
{
|
||||
label: '微信商户号',
|
||||
@@ -289,7 +289,7 @@
|
||||
const fetchDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await WechatConfigService.getWechatConfigById(configId.value)
|
||||
const res = await PaymentSettingsService.getPaymentSettingsById(configId.value)
|
||||
if (res.code === 0) {
|
||||
detailData.value = res.data
|
||||
}
|
||||
@@ -306,7 +306,7 @@
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wechat-config-detail-page {
|
||||
.payment-settings-detail-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="wechat-config-page" id="table-full-screen">
|
||||
<div class="payment-settings-page" id="table-full-screen">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
@@ -22,7 +22,7 @@
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="showCreateDialog"
|
||||
v-if="hasAuth('wechat_config:create')"
|
||||
v-if="hasAuth('payment_settings:create')"
|
||||
>新增支付配置</ElButton
|
||||
>
|
||||
</template>
|
||||
@@ -226,7 +226,7 @@
|
||||
|
||||
<template v-if="form.provider_type === 'wechat' || form.provider_type === 'wechat_v2'">
|
||||
<ElDivider content-position="left">
|
||||
<span class="divider-title">微信配置</span>
|
||||
<span class="divider-title">微信支付配置</span>
|
||||
</ElDivider>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
@@ -361,15 +361,15 @@
|
||||
<script setup lang="ts">
|
||||
import { h, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { WechatConfigService } from '@/api/modules'
|
||||
import { PaymentSettingsService } from '@/api/modules'
|
||||
import { ElMessage, ElMessageBox, ElSwitch, ElTag } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type {
|
||||
CreateWechatConfigRequest,
|
||||
CreatePaymentSettingsRequest,
|
||||
PaymentProviderType,
|
||||
UpdateWechatConfigRequest,
|
||||
WechatConfig,
|
||||
WechatConfigQueryParams
|
||||
UpdatePaymentSettingsRequest,
|
||||
PaymentSettings,
|
||||
PaymentSettingsQueryParams
|
||||
} from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
@@ -383,7 +383,7 @@
|
||||
isMaskedPaymentConfigValue
|
||||
} from '@/utils/business/paymentConfig'
|
||||
|
||||
defineOptions({ name: 'WechatConfigList' })
|
||||
defineOptions({ name: 'PaymentSettingsList' })
|
||||
|
||||
type PaymentConfigFormModel = {
|
||||
id: number
|
||||
@@ -455,7 +455,7 @@
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const router = useRouter()
|
||||
const canUpdateWechatConfigStatus = hasAuth('wechat_config:status')
|
||||
const canUpdatePaymentSettingsStatus = hasAuth('payment_settings:status')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
@@ -463,7 +463,7 @@
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref<'add' | 'edit'>('add')
|
||||
const formRef = ref<FormInstance>()
|
||||
const configList = ref<WechatConfig[]>([])
|
||||
const configList = ref<PaymentSettings[]>([])
|
||||
const originalFormState = ref<PaymentConfigFormModel | null>(null)
|
||||
|
||||
const createInitialFormState = (): PaymentConfigFormModel => ({
|
||||
@@ -501,7 +501,7 @@
|
||||
fy_public_key: ''
|
||||
})
|
||||
|
||||
const mapConfigToForm = (detail: WechatConfig): PaymentConfigFormModel => ({
|
||||
const mapConfigToForm = (detail: PaymentSettings): PaymentConfigFormModel => ({
|
||||
...createInitialFormState(),
|
||||
...detail,
|
||||
description: detail.description || '',
|
||||
@@ -666,7 +666,7 @@
|
||||
label: '配置名称',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: WechatConfig) => {
|
||||
formatter: (row: PaymentSettings) => {
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
@@ -685,20 +685,20 @@
|
||||
label: '配置描述',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: WechatConfig) => row.description || '-'
|
||||
formatter: (row: PaymentSettings) => row.description || '-'
|
||||
},
|
||||
{
|
||||
prop: 'provider_type',
|
||||
label: '支付渠道类型',
|
||||
width: 130,
|
||||
formatter: (row: WechatConfig) => getPaymentProviderText(row.provider_type)
|
||||
formatter: (row: PaymentSettings) => getPaymentProviderText(row.provider_type)
|
||||
},
|
||||
{
|
||||
prop: 'is_active',
|
||||
label: '激活状态',
|
||||
width: 110,
|
||||
formatter: (row: WechatConfig) => {
|
||||
if (canUpdateWechatConfigStatus) {
|
||||
formatter: (row: PaymentSettings) => {
|
||||
if (canUpdatePaymentSettingsStatus) {
|
||||
return h(ElSwitch, {
|
||||
modelValue: row.is_active,
|
||||
activeText: '已激活',
|
||||
@@ -718,47 +718,47 @@
|
||||
label: '商户号',
|
||||
width: 150,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: WechatConfig) => getPaymentMerchantId(row)
|
||||
formatter: (row: PaymentSettings) => getPaymentMerchantId(row)
|
||||
},
|
||||
{
|
||||
prop: 'miniapp_app_id',
|
||||
label: '小程序AppID',
|
||||
width: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: WechatConfig) => row.miniapp_app_id || '-'
|
||||
formatter: (row: PaymentSettings) => row.miniapp_app_id || '-'
|
||||
},
|
||||
{
|
||||
prop: 'oa_app_id',
|
||||
label: '公众号AppID',
|
||||
width: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: WechatConfig) => row.oa_app_id || '-'
|
||||
formatter: (row: PaymentSettings) => row.oa_app_id || '-'
|
||||
},
|
||||
{
|
||||
prop: 'ali_app_id',
|
||||
label: '支付宝AppID',
|
||||
width: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: WechatConfig) => row.ali_app_id || '-'
|
||||
formatter: (row: PaymentSettings) => row.ali_app_id || '-'
|
||||
},
|
||||
{
|
||||
prop: 'notify_url',
|
||||
label: '支付回调地址',
|
||||
minWidth: 200,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: WechatConfig) => getPaymentNotifyUrl(row)
|
||||
formatter: (row: PaymentSettings) => getPaymentNotifyUrl(row)
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 180,
|
||||
formatter: (row: WechatConfig) => formatDateTime(row.created_at)
|
||||
formatter: (row: PaymentSettings) => formatDateTime(row.created_at)
|
||||
},
|
||||
{
|
||||
prop: 'updated_at',
|
||||
label: '更新时间',
|
||||
width: 180,
|
||||
formatter: (row: WechatConfig) => formatDateTime(row.updated_at)
|
||||
formatter: (row: PaymentSettings) => formatDateTime(row.updated_at)
|
||||
}
|
||||
])
|
||||
|
||||
@@ -770,14 +770,14 @@
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: WechatConfigQueryParams = {
|
||||
const params: PaymentSettingsQueryParams = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
provider_type: searchForm.provider_type,
|
||||
is_active: searchForm.is_active !== undefined ? searchForm.is_active === 1 : undefined
|
||||
}
|
||||
|
||||
const res = await WechatConfigService.getWechatConfigs(params)
|
||||
const res = await PaymentSettingsService.getPaymentSettings(params)
|
||||
if (res.code === 0) {
|
||||
configList.value = res.data.items || []
|
||||
pagination.page = res.data.page || pagination.page
|
||||
@@ -825,9 +825,9 @@
|
||||
})
|
||||
}
|
||||
|
||||
const showEditDialog = async (row: WechatConfig) => {
|
||||
const showEditDialog = async (row: PaymentSettings) => {
|
||||
try {
|
||||
const res = await WechatConfigService.getWechatConfigById(row.id)
|
||||
const res = await PaymentSettingsService.getPaymentSettingsById(row.id)
|
||||
if (res.code === 0 && res.data) {
|
||||
const mappedForm = mapConfigToForm(res.data)
|
||||
Object.assign(form, mappedForm)
|
||||
@@ -853,8 +853,8 @@
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const buildCreatePayload = (): CreateWechatConfigRequest => {
|
||||
const payload: CreateWechatConfigRequest = {
|
||||
const buildCreatePayload = (): CreatePaymentSettingsRequest => {
|
||||
const payload: CreatePaymentSettingsRequest = {
|
||||
name: trimString(form.name),
|
||||
provider_type: form.provider_type,
|
||||
ali_pay_expire_minutes: form.ali_pay_expire_minutes,
|
||||
@@ -878,11 +878,11 @@
|
||||
return payload
|
||||
}
|
||||
|
||||
const buildUpdatePayload = (): UpdateWechatConfigRequest => {
|
||||
const buildUpdatePayload = (): UpdatePaymentSettingsRequest => {
|
||||
const initial = originalFormState.value
|
||||
if (!initial) return {}
|
||||
|
||||
const payload: UpdateWechatConfigRequest = {}
|
||||
const payload: UpdatePaymentSettingsRequest = {}
|
||||
const currentName = trimString(form.name)
|
||||
const initialName = trimString(initial.name)
|
||||
if (currentName !== initialName) {
|
||||
@@ -936,7 +936,7 @@
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (dialogType.value === 'add') {
|
||||
await WechatConfigService.createWechatConfig(buildCreatePayload())
|
||||
await PaymentSettingsService.createPaymentSettings(buildCreatePayload())
|
||||
ElMessage.success('创建成功')
|
||||
} else {
|
||||
const payload = buildUpdatePayload()
|
||||
@@ -945,7 +945,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
await WechatConfigService.updateWechatConfig(form.id, payload)
|
||||
await PaymentSettingsService.updatePaymentSettings(form.id, payload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
|
||||
@@ -958,8 +958,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row: WechatConfig, newStatus: string | number | boolean) => {
|
||||
if (!hasAuth('wechat_config:status')) {
|
||||
const handleStatusChange = async (row: PaymentSettings, newStatus: string | number | boolean) => {
|
||||
if (!hasAuth('payment_settings:status')) {
|
||||
ElMessage.warning('您没有修改激活状态的权限')
|
||||
return
|
||||
}
|
||||
@@ -969,10 +969,10 @@
|
||||
row.is_active = newBoolStatus
|
||||
try {
|
||||
if (newBoolStatus) {
|
||||
await WechatConfigService.activateWechatConfig(row.id)
|
||||
await PaymentSettingsService.activatePaymentSettings(row.id)
|
||||
ElMessage.success('激活成功')
|
||||
} else {
|
||||
await WechatConfigService.deactivateWechatConfig(row.id)
|
||||
await PaymentSettingsService.deactivatePaymentSettings(row.id)
|
||||
ElMessage.success('停用成功')
|
||||
}
|
||||
await getTableData()
|
||||
@@ -982,7 +982,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: WechatConfig) => {
|
||||
const handleDelete = async (row: PaymentSettings) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除支付配置"${row.name}"吗?`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
@@ -990,7 +990,7 @@
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await WechatConfigService.deleteWechatConfig(row.id)
|
||||
await PaymentSettingsService.deletePaymentSettings(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await getTableData()
|
||||
} catch (error) {
|
||||
@@ -1000,24 +1000,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = (row: WechatConfig) => {
|
||||
const handleViewDetail = (row: PaymentSettings) => {
|
||||
router.push({
|
||||
path: `${RoutesAlias.WechatConfig}/detail/${row.id}`
|
||||
path: `${RoutesAlias.PaymentSettings}/detail/${row.id}`
|
||||
})
|
||||
}
|
||||
|
||||
const handleNameClick = (row: WechatConfig) => {
|
||||
if (hasAuth('wechat_config:detail')) {
|
||||
const handleNameClick = (row: PaymentSettings) => {
|
||||
if (hasAuth('payment_settings:detail')) {
|
||||
handleViewDetail(row)
|
||||
} else {
|
||||
ElMessage.warning('您没有查看详情的权限')
|
||||
}
|
||||
}
|
||||
|
||||
const getActions = (row: WechatConfig) => {
|
||||
const getActions = (row: PaymentSettings) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (hasAuth('wechat_config:edit')) {
|
||||
if (hasAuth('payment_settings:edit')) {
|
||||
actions.push({
|
||||
label: '编辑',
|
||||
handler: () => showEditDialog(row),
|
||||
@@ -1025,7 +1025,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (hasAuth('wechat_config:delete')) {
|
||||
if (hasAuth('payment_settings:delete')) {
|
||||
actions.push({
|
||||
label: '删除',
|
||||
handler: () => handleDelete(row),
|
||||
@@ -1042,7 +1042,7 @@
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wechat-config-page {
|
||||
.payment-settings-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,37 @@
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="平台业务员">
|
||||
<ElSelect
|
||||
v-model="formData.business_owner_account_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
placeholder="请选择或搜索平台业务员"
|
||||
:remote-method="searchPlatformSalespeople"
|
||||
:loading="salespersonLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElOption
|
||||
v-for="salesperson in salespersonOptions"
|
||||
:key="salesperson.account_id"
|
||||
:label="salesperson.account_name"
|
||||
:value="salesperson.account_id"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<span>{{ salesperson.account_name }}</span>
|
||||
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
|
||||
{{ salesperson.phone_masked }}
|
||||
</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<!-- 新增店铺时的初始账号信息 -->
|
||||
<template v-if="dialogType === 'add'">
|
||||
<div class="form-section-title">
|
||||
@@ -207,6 +238,16 @@
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog v-model="detailDialogVisible" title="店铺详情" width="500px">
|
||||
<ElDescriptions v-if="detailShop" :column="1" border>
|
||||
<ElDescriptionsItem label="店铺名称">{{ detailShop.shop_name }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="店铺编号">{{ detailShop.shop_code }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="平台业务员">
|
||||
{{ formatBusinessOwner(detailShop) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 店铺默认角色管理对话框 -->
|
||||
<ElDialog
|
||||
v-model="defaultRolesDialogVisible"
|
||||
@@ -287,10 +328,10 @@
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { generateShopCode } from '@/utils/codeGenerator'
|
||||
import { ShopService, RoleService } from '@/api/modules'
|
||||
import { ShopService, RoleService, AccountService } from '@/api/modules'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { ShopResponse, ShopRoleResponse } from '@/types/api'
|
||||
import { RoleType, RoleStatus } from '@/types/api'
|
||||
import type { PlatformSalesperson, ShopResponse, ShopRoleResponse } from '@/types/api'
|
||||
import { AccountStatus, RoleType, RoleStatus } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
|
||||
import { regionData } from '@/utils/constants/regionData'
|
||||
@@ -307,6 +348,10 @@
|
||||
const submitLoading = ref(false)
|
||||
const defaultRoleLoading = ref(false)
|
||||
const defaultRoleList = ref<any[]>([])
|
||||
const salespersonLoading = ref(false)
|
||||
const salespersonOptions = ref<PlatformSalesperson[]>([])
|
||||
const detailDialogVisible = ref(false)
|
||||
const detailShop = ref<ShopResponse | null>(null)
|
||||
|
||||
// 级联选择相关
|
||||
const parentShopCascadeOptions = ref<any[]>([])
|
||||
@@ -370,14 +415,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
const searchPlatformSalespeople = async (query: string) => {
|
||||
salespersonLoading.value = true
|
||||
try {
|
||||
const res = await AccountService.getPlatformSalespeople({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
account_type: 'platform',
|
||||
status: AccountStatus.ENABLED,
|
||||
account_name: query || undefined
|
||||
})
|
||||
if (res.code === 0) {
|
||||
salespersonOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取平台业务员列表失败:', error)
|
||||
salespersonOptions.value = []
|
||||
} finally {
|
||||
salespersonLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatBusinessOwner = (shop: ShopResponse) => {
|
||||
if (!shop.business_owner_name) return '-'
|
||||
return shop.business_owner_phone_masked
|
||||
? `${shop.business_owner_name} (${shop.business_owner_phone_masked})`
|
||||
: shop.business_owner_name
|
||||
}
|
||||
|
||||
const showDetail = (row: ShopResponse) => {
|
||||
detailShop.value = row
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 定义表单搜索初始值
|
||||
const initialSearchState = {
|
||||
shop_name: '',
|
||||
shop_code: '',
|
||||
contact_phone: '',
|
||||
parent_shop_name: '',
|
||||
parent_id: undefined as number | undefined,
|
||||
level: undefined as number | undefined,
|
||||
status: undefined as number | undefined
|
||||
status: undefined as number | undefined,
|
||||
business_owner_account_id: undefined as number | undefined
|
||||
}
|
||||
|
||||
// 响应式表单数据
|
||||
@@ -407,6 +487,10 @@
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
if (searchForm.contact_phone && !/^\d{11}$/.test(searchForm.contact_phone)) {
|
||||
ElMessage.warning('联系电话必须为11位数字')
|
||||
return
|
||||
}
|
||||
pagination.currentPage = 1
|
||||
getShopList()
|
||||
}
|
||||
@@ -431,6 +515,21 @@
|
||||
placeholder: '请输入店铺编号'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '联系电话',
|
||||
prop: 'contact_phone',
|
||||
type: 'input',
|
||||
config: {
|
||||
clearable: true,
|
||||
maxlength: 11,
|
||||
inputmode: 'numeric',
|
||||
placeholder: '请输入11位联系电话',
|
||||
onInput: (event: InputEvent) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
searchForm.contact_phone = input.value.replace(/\D/g, '').slice(0, 11)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '上级店铺',
|
||||
prop: 'parent_id',
|
||||
@@ -457,6 +556,23 @@
|
||||
clearable: true,
|
||||
placeholder: '请选择状态'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '平台业务员',
|
||||
prop: 'business_owner_account_id',
|
||||
type: 'select',
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
remoteMethod: (query: string) => searchPlatformSalespeople(query),
|
||||
placeholder: '请选择或搜索平台业务员'
|
||||
},
|
||||
options: () =>
|
||||
salespersonOptions.value.map((salesperson) => ({
|
||||
label: `${salesperson.account_name} (${salesperson.phone_masked})`,
|
||||
value: salesperson.account_id
|
||||
}))
|
||||
}
|
||||
])
|
||||
|
||||
@@ -468,6 +584,7 @@
|
||||
{ label: '所在地区', prop: 'region' },
|
||||
{ label: '联系人', prop: 'contact_name' },
|
||||
{ label: '联系电话', prop: 'contact_phone' },
|
||||
{ label: '平台业务员', prop: 'business_owner_name' },
|
||||
...(canModifyShopStatus ? [{ label: '状态', prop: 'status' }] : []),
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
{ label: '操作', prop: 'operation' }
|
||||
@@ -497,6 +614,7 @@
|
||||
formData.address = row.address || ''
|
||||
formData.contact_name = row.contact_name || ''
|
||||
formData.contact_phone = row.contact_phone || ''
|
||||
formData.business_owner_account_id = row.business_owner_account_id ?? null
|
||||
formData.status = row.status
|
||||
formData.init_username = ''
|
||||
formData.init_password = ''
|
||||
@@ -514,6 +632,7 @@
|
||||
formData.address = ''
|
||||
formData.contact_name = ''
|
||||
formData.contact_phone = ''
|
||||
formData.business_owner_account_id = null
|
||||
formData.status = CommonStatus.ENABLED
|
||||
formData.init_username = ''
|
||||
formData.init_password = ''
|
||||
@@ -597,6 +716,13 @@
|
||||
label: '联系电话',
|
||||
width: 130
|
||||
},
|
||||
{
|
||||
prop: 'business_owner_name',
|
||||
label: '平台业务员',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ShopResponse) => formatBusinessOwner(row)
|
||||
},
|
||||
...(canModifyShopStatus
|
||||
? [
|
||||
{
|
||||
@@ -630,6 +756,12 @@
|
||||
const getActions = (row: ShopResponse) => {
|
||||
const actions: any[] = []
|
||||
|
||||
actions.push({
|
||||
label: '详情',
|
||||
handler: () => showDetail(row),
|
||||
type: 'primary'
|
||||
})
|
||||
|
||||
if (hasAuth('shop:look_customer')) {
|
||||
actions.push({
|
||||
label: '账号列表',
|
||||
@@ -685,7 +817,8 @@
|
||||
init_username: '',
|
||||
init_password: '',
|
||||
init_phone: '',
|
||||
default_role_id: undefined as number | undefined
|
||||
default_role_id: undefined as number | undefined,
|
||||
business_owner_account_id: null as number | null
|
||||
})
|
||||
|
||||
// 处理编码生成
|
||||
@@ -766,6 +899,7 @@
|
||||
loadTopLevelShops() // 加载顶级店铺用于级联选择
|
||||
searchDefaultRoles('') // 加载初始默认角色列表
|
||||
searchParentShops('') // 加载上级店铺选项
|
||||
searchPlatformSalespeople('') // 加载启用的平台业务员选项
|
||||
})
|
||||
|
||||
// 获取店铺列表
|
||||
@@ -777,10 +911,12 @@
|
||||
page_size: pagination.pageSize,
|
||||
shop_name: searchForm.shop_name || undefined,
|
||||
shop_code: searchForm.shop_code || undefined,
|
||||
contact_phone: searchForm.contact_phone || undefined,
|
||||
parent_shop_name: searchForm.parent_shop_name || undefined,
|
||||
parent_id: searchForm.parent_id,
|
||||
level: searchForm.level,
|
||||
status: searchForm.status
|
||||
status: searchForm.status,
|
||||
business_owner_account_id: searchForm.business_owner_account_id
|
||||
}
|
||||
const res = await ShopService.getShops(params)
|
||||
if (res.code === 0) {
|
||||
@@ -850,7 +986,8 @@
|
||||
init_username: formData.init_username,
|
||||
init_password: formData.init_password,
|
||||
init_phone: formData.init_phone,
|
||||
default_role_id: formData.default_role_id
|
||||
default_role_id: formData.default_role_id,
|
||||
business_owner_account_id: formData.business_owner_account_id
|
||||
}
|
||||
|
||||
// 可选字段 - parent_id 可能是数组(级联选择器)或数字
|
||||
@@ -871,7 +1008,8 @@
|
||||
} else {
|
||||
const data: any = {
|
||||
shop_name: formData.shop_name,
|
||||
status: formData.status
|
||||
status: formData.status,
|
||||
business_owner_account_id: formData.business_owner_account_id
|
||||
}
|
||||
|
||||
// 可选字段
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
:data="permissionList"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
:default-expand-all="false"
|
||||
:pagination="false"
|
||||
:marginTop="10"
|
||||
:show-pagination="false"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
|
||||
Reference in New Issue
Block a user