fix: 代理系列授权店铺
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 2m0s

This commit is contained in:
sexygoat
2026-05-09 16:13:11 +08:00
parent 3c71ae0004
commit 73ce9af11a
26 changed files with 833 additions and 807 deletions

14
.env
View File

@@ -1,21 +1,21 @@
# 【通用】环境变量
# 版本号
VITE_VERSION = 2.4.1.1
VITE_VERSION=2.4.1.1
# 端口号
VITE_PORT = 3006
VITE_PORT=3006
# 网站地址前缀
VITE_BASE_URL =
VITE_BASE_URL=
# API 地址前缀
VITE_API_URL = https://cmp-api.boss160.cn
VITE_API_URL=https://cmp-api.xm-iot.cn
# 权限模式( frontend backend
VITE_ACCESS_MODE = backend
VITE_ACCESS_MODE=backend
# 是否打开路由信息
VITE_OPEN_ROUTE_INFO = false
VITE_OPEN_ROUTE_INFO=false
# 锁屏加密密钥
VITE_LOCK_ENCRYPT_KEY = jfsfjk1938jfj
VITE_LOCK_ENCRYPT_KEY=jfsfjk1938jfj

View File

@@ -1,10 +1,10 @@
# 【开发】环境变量
# 网站地址前缀
VITE_BASE_URL = /
VITE_BASE_URL=/
# API 地址前缀
VITE_API_URL = https://cmp-api.boss160.cn
VITE_API_URL=https://cmp-api.xm-iot.cn
# Delete console
VITE_DROP_CONSOLE = false

View File

@@ -4,7 +4,7 @@
VITE_BASE_URL=
# API 地址前缀
VITE_API_URL=https://cmp-api.boss160.cn
VITE_API_URL=https://cmp-api.xm-iot.cn
# Delete console
VITE_DROP_CONSOLE=true

View File

@@ -0,0 +1,36 @@
# Change: 更新资产详情套餐流量展示口径
## Why
当前资产详情页中的“当前生效套餐”和“套餐列表”已经接入 `real_total_mb``real_used_mb``virtual_used_mb``enable_virtual_data`,但非平台账号的展示口径与最新业务说明不一致:
- 总量应始终使用 `real_total_mb`
- 已使用应按 `enable_virtual_data``virtual_used_mb``real_used_mb` 之间切换
- 剩余与进度条应基于同一套已使用值重新计算
如果不统一这套规则,代理账号和企业账号会在同一资产详情页内看到错误的剩余流量和进度占比,影响运营判断。
## What Changes
- 新增资产详情套餐流量展示规范,覆盖:
- “当前生效套餐”卡片
- “套餐列表”中的流量列
- 明确非平台/非超级管理员账号(`user_type` 不为 `1`/`2`)的展示规则:
- 总量使用 `real_total_mb`
- 已使用在 `enable_virtual_data=true` 时使用 `virtual_used_mb`,否则使用 `real_used_mb`
- 剩余 = 总量 - 已使用
- 进度条 = 已使用 / 总量
- 明确超级管理员和平台用户(`user_type``1`/`2`)继续保留当前的真流量/虚流量双口径展示
- 不修改后端接口路径、响应字段或权限模型
## Impact
- Affected specs:
- `asset-package-traffic-display`
- Affected code:
- `src/views/asset-management/asset-information/components/CurrentPackageCard.vue`
- `src/views/asset-management/asset-information/components/PackageListCard.vue`
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
- Dependencies:
- `GET /api/admin/assets/:identifier/current-package`
- 资产详情页套餐列表接口继续返回 `real_total_mb``real_used_mb``virtual_used_mb``enable_virtual_data`

View File

@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Non-Platform Asset Package Traffic Display
The asset information page SHALL use a unified traffic display formula for non-platform users in both the current package card and the package list.
#### Scenario: Current package uses virtual usage when virtual data is enabled
- **GIVEN** 当前登录账号的 `user_type` 不是 `1``2`
- **AND** “当前生效套餐”返回的 `enable_virtual_data``true`
- **WHEN** 页面渲染“当前生效套餐”的流量信息
- **THEN** 系统 MUST 使用 `real_total_mb` 作为总量
- **AND** 系统 MUST 使用 `virtual_used_mb` 作为已使用
- **AND** 系统 MUST 按 `real_total_mb - virtual_used_mb` 计算剩余
- **AND** 系统 MUST 按 `virtual_used_mb / real_total_mb` 计算进度条百分比
#### Scenario: Current package uses real usage when virtual data is disabled
- **GIVEN** 当前登录账号的 `user_type` 不是 `1``2`
- **AND** “当前生效套餐”返回的 `enable_virtual_data``false`
- **WHEN** 页面渲染“当前生效套餐”的流量信息
- **THEN** 系统 MUST 使用 `real_total_mb` 作为总量
- **AND** 系统 MUST 使用 `real_used_mb` 作为已使用
- **AND** 系统 MUST 按 `real_total_mb - real_used_mb` 计算剩余
- **AND** 系统 MUST 按 `real_used_mb / real_total_mb` 计算进度条百分比
#### Scenario: Package list uses virtual usage when virtual data is enabled
- **GIVEN** 当前登录账号的 `user_type` 不是 `1``2`
- **AND** 套餐列表中的某条记录 `enable_virtual_data``true`
- **WHEN** 页面渲染该条记录的流量信息
- **THEN** 系统 MUST 使用 `real_total_mb` 作为总量
- **AND** 系统 MUST 使用 `virtual_used_mb` 作为已使用
- **AND** 系统 MUST 按 `real_total_mb - virtual_used_mb` 计算剩余
- **AND** 系统 MUST 按 `virtual_used_mb / real_total_mb` 计算进度条百分比
#### Scenario: Package list uses real usage when virtual data is disabled
- **GIVEN** 当前登录账号的 `user_type` 不是 `1``2`
- **AND** 套餐列表中的某条记录 `enable_virtual_data``false`
- **WHEN** 页面渲染该条记录的流量信息
- **THEN** 系统 MUST 使用 `real_total_mb` 作为总量
- **AND** 系统 MUST 使用 `real_used_mb` 作为已使用
- **AND** 系统 MUST 按 `real_total_mb - real_used_mb` 计算剩余
- **AND** 系统 MUST 按 `real_used_mb / real_total_mb` 计算进度条百分比
### Requirement: Platform And Super Admin Package Traffic Detail
The asset information page SHALL preserve the existing dual-track traffic display for super admins and platform users.
#### Scenario: Platform-side users keep real and virtual traffic breakdown
- **GIVEN** 当前登录账号的 `user_type``1``2`
- **WHEN** 页面渲染“当前生效套餐”或“套餐列表”的流量信息
- **THEN** 系统 MUST 继续展示真流量统计
- **AND** 系统 MUST 继续展示虚流量统计
- **AND** 系统 MUST NOT 将该类账号改为单一的“总量 / 已使用 / 剩余”展示口径

View File

@@ -0,0 +1,8 @@
## 1. Implementation
- [x] 1.1 调整 `CurrentPackageCard.vue` 中非平台/非超级管理员视角的流量展示与进度计算。
- [x] 1.2 调整 `PackageListCard.vue` 中非平台/非超级管理员视角的流量展示与进度计算。
- [x] 1.3 对齐 `useAssetInfo.ts` 中由当前生效套餐推导出的流量统计,避免同页展示口径不一致。
- [x] 1.4 验证 `enable_virtual_data=true` 时,代理账号和企业账号的总量、已使用、剩余、进度条均符合文档。
- [x] 1.5 验证 `enable_virtual_data=false` 时,代理账号和企业账号的总量、已使用、剩余、进度条均符合文档。
- [x] 1.6 验证超级管理员与平台用户继续保留真流量/虚流量双口径展示。

View File

@@ -0,0 +1,36 @@
# Change: 优化资产信息钱包流水的默认加载与展示稳定性
## Why
当前资产信息页的钱包流水区域存在三个连续问题:默认进入资产详情时没有稳定触发钱包流水查询;前端按 `list/page_size` 解析响应,但后端实际返回 `items/size`,导致接口成功返回后表格仍然不显示;当结果为空时,重复查询还会出现“暂无钱包流水数据”先消失再出现的闪烁。
这些问题叠加后,会让用户误以为钱包流水没有被加载、接口无返回,或页面发生异常刷新。需要将默认加载、数据展示和空态刷新体验统一规范。
## What Changes
- 为资产信息页的钱包流水区域新增默认加载规范:
- 资产详情加载完成后应自动请求钱包流水
- 默认请求参数为 `page=1``page_size=20`
- 明确钱包流水区域需兼容后端当前返回结构:
- 使用 `items` 作为流水列表
- 使用 `page``size``total` 作为分页信息
- 明确有数据时必须展示钱包流水表格,不能因为前端字段映射不一致而落入空态
- 为空结果场景新增稳定性展示规范:
- 当当前已是空态且新结果仍为空时,空态区域应保持连续可见
- 加载反馈不应通过“先移除空态再重新渲染”的方式呈现
- 覆盖以下触发路径:
- 资产信息页首次进入或首次加载当前资产
- 资产信息页顶部再次搜索资产
- 钱包流水区域内的查询、重置、分页切换、分页尺寸重置等再次拉取
## Impact
- Affected specs:
- `asset-wallet-transaction-display`
- Affected code:
- `src/views/asset-management/asset-information/components/WalletTransactionCard.vue`
- `src/views/asset-management/asset-information/index.vue`
- `src/types/api/asset.ts`
- Dependencies:
- `GET /api/admin/assets/:asset_type/:id/wallet/transactions`
- 钱包流水分页响应以接口返回 `items``page``size``total` 为准

View File

@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Wallet Transactions Shall Load And Render Automatically
The asset information wallet transaction panel SHALL automatically request and render wallet transaction data for the current asset by using the backend response contract returned from the wallet transaction API.
#### Scenario: Opening an asset information page triggers the default wallet transaction request
- **GIVEN** 用户进入某个资产的信息页,且当前资产支持直接查看钱包流水
- **WHEN** 资产详情上下文已完成加载并显示钱包流水区域
- **THEN** 系统 MUST 自动请求当前资产的钱包流水接口
- **AND** 请求参数 MUST 默认为 `page=1``page_size=20`
- **AND** 系统 MUST NOT 要求用户先点击“查询”后才首次拉取钱包流水
#### Scenario: Asset search refreshes wallet transactions with default pagination
- **GIVEN** 用户已经位于资产信息页
- **WHEN** 用户通过页面顶部搜索重新加载当前资产或切换到另一个资产
- **THEN** 系统 MUST 为新解析出的当前资产重新请求钱包流水接口
- **AND** 首次展示该资产的钱包流水时 MUST 使用 `page=1``page_size=20`
#### Scenario: Successful wallet transaction response with items renders table rows
- **GIVEN** 钱包流水接口成功返回 `data.items`,且其中包含一条或多条流水记录
- **WHEN** 前端接收到该响应
- **THEN** 系统 MUST 按 `data.items` 渲染钱包流水表格
- **AND** 系统 MUST 使用 `data.total` 作为总记录数
- **AND** 系统 MUST 使用 `data.page``data.size` 同步当前分页状态
- **AND** 系统 MUST NOT 展示“暂无钱包流水数据”空态
### Requirement: Stable Wallet Empty State During Refresh
The asset information wallet transaction panel SHALL preserve a stable empty-state presentation when repeated requests continue to return no wallet transaction data.
#### Scenario: Re-searching the same asset still returns no wallet transactions
- **GIVEN** 用户已经在资产信息页的钱包流水区域看到“暂无钱包流水数据”
- **AND** 当前钱包流水响应数据为 `items=[]``page=1``size=20``total=0`
- **WHEN** 用户再次触发资产搜索,且新的钱包流水响应仍然为 `items=[]``page=1``size=20``total=0`
- **THEN** 系统 MUST 继续在同一钱包流水区域保持空态展示
- **AND** 系统 MUST NOT 通过先移除空态内容再重新插入的方式呈现刷新过程
- **AND** 系统 MUST 将刷新反馈限制在不破坏当前空态布局稳定性的范围内
#### Scenario: Wallet filter refresh still returns an empty result
- **GIVEN** 用户已经在资产信息页的钱包流水区域看到“暂无钱包流水数据”
- **WHEN** 用户在钱包流水区域执行查询、重置或其他会重新拉取流水的操作
- **AND** 新的响应仍然为 `items=[]``page=1``size=20``total=0`
- **THEN** 系统 MUST 保持空态区域连续可见
- **AND** 系统 MUST NOT 在刷新过程中出现钱包内容区域短暂空白再恢复空态的视觉跳变
#### Scenario: First empty response shows the wallet empty state
- **GIVEN** 用户首次打开某个资产的信息页或首次加载钱包流水
- **WHEN** 钱包流水响应为 `items=[]``page=1``size=20``total=0`
- **THEN** 系统 MUST 展示“暂无钱包流水数据”空态
- **AND** 系统 MUST 不显示分页控件

View File

@@ -0,0 +1,7 @@
## 1. Implementation
- [x] 1.1 调整资产信息页钱包流水卡片的初始化逻辑,确保资产详情首次进入和资产切换后会自动请求钱包流水,默认参数为 `page=1``page_size=20`
- [x] 1.2 对齐钱包流水接口的实际响应结构,使用 `items` 渲染表格数据,并使用 `page``size``total` 同步分页状态。
- [x] 1.3 确保钱包流水接口返回非空 `items` 时,页面正确展示流水表格而不是错误落入空态。
- [x] 1.4 确保重复搜索同一资产,或在钱包区域内执行查询、重置、分页切换、分页尺寸变化后,如果结果仍为空,空态不会出现“隐藏再显示”的闪烁。
- [x] 1.5 验证空结果与非空结果两类场景下的钱包流水表格、空态和分页展示行为都符合规范。

View File

@@ -0,0 +1,32 @@
# Change: 更新套餐管理中的角色化流量展示与零售价约束
## Why
当前套餐管理列表会向所有账号展示真流量、虚流量和虚流量比例,但最新业务要求代理账号和企业账号不再看到虚流量拆分口径,只保留总量视图,避免暴露不必要的内部计量细节。
同时,代理侧“修改零售价”弹窗缺少成本价展示和建议售价上限约束,前端当前也没有把“建议售价的 2 倍”作为输入上限,会导致代理录入超出业务允许范围的零售价。
## What Changes
- 为套餐管理列表补充按账号类型展示流量字段的规则:
-`user_type``3`(代理账号)或 `4`(企业账号)时,隐藏“虚流量”和“虚流量比例”列
- 同一视角下,将列表中的“真流量”列文案改为“总流量”
- 该“总流量”继续使用接口返回的 `real_data_mb` 展示
- 为代理侧“修改零售价”弹窗补充价格展示和校验规则:
- 在“单位:元”提示前展示当前套餐的成本价
- 展示提示文案:`零售价不能大于建议售价 * 2 = xxx`
- 将零售价输入框最大值设置为同一上限
- 表单校验阻止提交超过该上限的零售价
- 不修改套餐管理相关后端接口路径、字段结构或权限模型
## Impact
- Affected specs:
- `package-management`
- Affected code:
- `src/views/package-management/package-list/index.vue`
- Dependencies:
- 套餐列表接口继续返回 `real_data_mb``virtual_data_mb``suggested_retail_price``cost_price`
- 当前登录用户信息继续提供 `user_type`
- Notes:
- 本提案默认“可修改零售价”的套餐记录会返回 `suggested_retail_price`;如果后端允许为空,实施阶段需要补充兜底交互

View File

@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: 套餐列表流量字段按账号类型展示
系统 SHALL 根据当前登录账号类型控制套餐管理列表中的流量相关字段展示。
#### Scenario: 代理账号和企业账号隐藏虚流量拆分信息
- **GIVEN** 当前登录账号的 `user_type``3``4`
- **WHEN** 用户访问套餐管理列表
- **THEN** 系统显示标签为 `总流量` 的列表列
- **AND** 该列使用接口返回的 `real_data_mb` 作为展示值
- **AND** 系统不显示 `虚流量`
- **AND** 系统不显示 `虚流量比例`
#### Scenario: 其他账号继续显示虚流量拆分信息
- **GIVEN** 当前登录账号的 `user_type` 不为 `3` 且不为 `4`
- **WHEN** 用户访问套餐管理列表
- **THEN** 系统继续显示标签为 `真流量` 的列表列
- **AND** 系统继续显示 `虚流量`
- **AND** 系统继续显示 `虚流量比例`
### Requirement: 修改零售价时展示成本价并限制上限
系统 SHALL 在“修改零售价”弹窗中展示成本价,并基于建议售价限制可输入和可提交的零售价。
#### Scenario: 打开弹窗时展示成本价和上限提示
- **GIVEN** 用户具备修改零售价权限
- **AND** 所选套餐的 `cost_price``1000`
- **AND** 所选套餐的 `suggested_retail_price``2000`
- **WHEN** 用户打开“修改零售价”弹窗
- **THEN** 系统显示该套餐的成本价为 `¥10.00`
- **AND** 系统在“单位:元”提示前展示成本价信息
- **AND** 系统显示提示 `零售价不能大于建议售价 * 2 = 40.00`
- **AND** 零售价输入框的最大值为 `40.00`
#### Scenario: 超过建议售价两倍时阻止提交
- **GIVEN** 用户具备修改零售价权限
- **AND** 当前套餐零售价上限为 `40.00`
- **WHEN** 用户输入大于 `40.00` 的零售价并提交
- **THEN** 系统阻止提交
- **AND** 系统显示“零售价不能大于建议售价 * 2”对应的校验提示
- **AND** 系统不得调用修改零售价接口

View File

@@ -0,0 +1,6 @@
## 1. Implementation
- [x] 1.1 调整套餐管理列表列配置,对 `user_type``3``4` 的账号隐藏“虚流量”“虚流量比例”,并将“真流量”列改名为“总流量”
- [x] 1.2 扩展“修改零售价”弹窗状态,携带并展示成本价与建议售价计算出的零售价上限
- [x] 1.3 为零售价输入框增加 `suggested_retail_price * 2` 的最大值约束,并在表单校验中阻止超过上限的提交
- [x] 1.4 验证代理账号、企业账号与其他账号在套餐列表中的流量列展示差异,以及零售价弹窗的上限提示与提交流程

View File

@@ -0,0 +1,30 @@
# Change: 修正代理系列授权套餐编辑中的成本价回填与提交一致性
## Why
当前“代理系列授权套餐列表”页面在编辑已授权套餐时,成本价输入框没有稳定使用列表行中的当前成本价作为初始值;同时,用户修改输入框后,前端提交给 `manageGrantPackages` 接口的 `cost_price` 可能退化为默认值 `0`,会直接把代理授权套餐的成本价错误覆盖。
这类问题会导致代理系列授权价格被误改,属于高风险的业务数据错误,需要明确前端在编辑态下的回填来源和提交流程。
## What Changes
- 为代理系列授权中的“编辑授权套餐”交互补充统一规范:
- 编辑弹窗打开时,成本价必须使用当前被编辑套餐记录中的 `cost_price` 回填
- 该回填行为不能依赖“可选套餐列表”是否已预先加载
- 用户在输入框中修改后的值,保存时必须按“元转分”写入 `manageGrantPackages` 请求体中的 `cost_price`
- 保存时不得回退为表单默认值 `0`,除非用户实际输入的就是 `0` 且通过业务校验
- 约束同一能力下多个编辑入口保持一致行为,包括:
- 代理系列授权套餐列表页
- 代理系列授权页面中的套餐管理弹窗
- 不修改后端接口路径、字段命名或请求结构
## Impact
- Affected specs:
- `shop-series-grant-packages`
- Affected code:
- `src/views/package-management/series-grants/packages.vue`
- `src/views/package-management/series-grants/index.vue`
- Dependencies:
- `GET /api/admin/shop-series-grants/{id}` 继续返回授权套餐的 `cost_price`
- `PUT /api/admin/shop-series-grants/{id}/packages` 继续接收 `packages[].package_id``packages[].cost_price`

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: 编辑授权套餐弹窗必须回填当前成本价
系统 SHALL 在编辑代理系列授权中的已授权套餐时,使用当前套餐记录中的成本价作为编辑弹窗的初始值。
#### Scenario: 从套餐列表页编辑时回填列表中的成本价
- **GIVEN** 用户进入代理系列授权套餐列表页
- **AND** 某条已授权套餐记录的 `package_id``10`
- **AND** 该记录当前 `cost_price``1234`
- **WHEN** 用户点击该记录的“编辑”操作
- **THEN** 系统打开编辑弹窗
- **AND** 成本价输入框显示 `12.34`
- **AND** 该回填值来自当前列表行数据而不是表单默认值
#### Scenario: 编辑弹窗回填不依赖可选套餐列表预加载
- **GIVEN** 用户进入代理系列授权中的套餐编辑入口
- **AND** 当前页面尚未预先加载 `availablePackages`
- **AND** 被编辑套餐记录当前 `cost_price``2560`
- **WHEN** 用户打开编辑弹窗
- **THEN** 系统仍然显示成本价输入框初始值为 `25.60`
- **AND** 系统不得因为 `availablePackages` 为空而将初始值退回为 `0`
### Requirement: 编辑授权套餐时必须提交用户修改后的成本价
系统 SHALL 在保存代理系列授权中的已授权套餐时,将用户当前输入的成本价按“元转分”写入请求体。
#### Scenario: 保存编辑后的成本价时按元转分提交
- **GIVEN** 用户正在编辑 `package_id``10` 的已授权套餐
- **AND** 编辑弹窗中的成本价初始值为 `12.34`
- **WHEN** 用户将成本价修改为 `15.80` 元并点击保存
- **THEN** 系统调用 `PUT /api/admin/shop-series-grants/{id}/packages`
- **AND** 请求体中的 `packages[0].package_id``10`
- **AND** 请求体中的 `packages[0].cost_price``1580`
#### Scenario: 未清空输入时保存不得提交默认零值
- **GIVEN** 用户正在编辑一条已有成本价的授权套餐
- **AND** 成本价输入框中存在通过表单校验的非零值
- **WHEN** 用户点击保存
- **THEN** 系统提交的 `packages[0].cost_price` 必须等于当前输入值换算后的分值
- **AND** 系统不得提交表单初始化默认值 `0`

View File

@@ -0,0 +1,6 @@
## 1. Implementation
- [x] 1.1 调整代理系列授权套餐编辑弹窗的初始化逻辑,确保编辑态优先使用当前授权套餐行中的成本价回填
- [x] 1.2 消除对 `availablePackages` 预加载状态的错误依赖,保证编辑弹窗在任意入口下都能拿到正确的初始成本价
- [x] 1.3 调整保存逻辑,确保提交给 `manageGrantPackages``cost_price` 来自用户当前输入值,并按元转分传递而不是默认 `0`
- [x] 1.4 验证套餐列表页和代理系列授权管理弹窗两个入口在编辑已授权套餐时都能正确回填并提交成本价

View File

@@ -324,9 +324,11 @@ export interface AssetWalletTransactionItem {
* 对应接口GET /api/admin/assets/:asset_type/:id/wallet/transactions
*/
export interface AssetWalletTransactionListResponse {
list?: AssetWalletTransactionItem[] | null // 流水列表
items?: AssetWalletTransactionItem[] | null // 流水列表(当前接口结构)
list?: AssetWalletTransactionItem[] | null // 流水列表(兼容旧结构)
page?: number // 当前页码
page_size?: number // 每页数量
size?: number // 每页数量(当前接口结构)
page_size?: number // 每页数量(兼容旧结构)
total?: number // 总记录数
total_pages?: number // 总页数
}

View File

@@ -147,49 +147,32 @@
<div class="flow-stat-item">
<span class="flow-stat-label">已使用</span>
<span class="flow-stat-value">
{{
formatDataSize(
currentPackage.enable_virtual_data
? currentPackage.virtual_used_mb || 0
: currentPackage.real_used_mb || 0
)
}}
{{ formatDataSize(getDisplayedUsedMb(currentPackage)) }}
</span>
</div>
<div class="flow-stat-item">
<span class="flow-stat-label">总量</span>
<span class="flow-stat-value">
{{ formatDataSize(currentPackage.real_total_mb || 0) }}
{{ formatDataSize(getDisplayedTotalMb(currentPackage)) }}
</span>
</div>
<div class="flow-stat-item">
<span class="flow-stat-label">剩余</span>
<span class="flow-stat-value">
{{
formatDataSize(
(currentPackage.real_total_mb || 0) - (currentPackage.real_used_mb || 0)
)
}}
{{ formatDataSize(getDisplayedRemainingMb(currentPackage)) }}
</span>
</div>
</div>
<div class="flow-progress-bar" style="margin-top: 16px">
<ElProgress
:percentage="
getUsageProgress(
currentPackage.enable_virtual_data
? currentPackage.virtual_used_mb || 0
: currentPackage.real_used_mb || 0,
currentPackage.real_total_mb || 0
)
getUsageProgress(getDisplayedUsedMb(currentPackage), getDisplayedTotalMb(currentPackage))
"
:color="
getProgressColorByPercentage(
getUsageProgress(
currentPackage.enable_virtual_data
? currentPackage.virtual_used_mb || 0
: currentPackage.real_used_mb || 0,
currentPackage.real_total_mb || 0
getDisplayedUsedMb(currentPackage),
getDisplayedTotalMb(currentPackage)
)
)
"
@@ -286,6 +269,14 @@
navigateToDevice: [deviceNo: string]
}>()
const getDisplayedTotalMb = (pkg: AssetPackageUsageRecord) => pkg.real_total_mb || 0
const getDisplayedUsedMb = (pkg: AssetPackageUsageRecord) =>
pkg.enable_virtual_data ? pkg.virtual_used_mb || 0 : pkg.real_used_mb || 0
const getDisplayedRemainingMb = (pkg: AssetPackageUsageRecord) =>
getDisplayedTotalMb(pkg) - getDisplayedUsedMb(pkg)
const getPackageTypeName = (type?: string) => {
const typeMap: Record<string, string> = {
formal: '正式套餐',

View File

@@ -148,9 +148,7 @@
>已使用:
{{
formatDataSize(
scope.row.enable_virtual_data
? scope.row.virtual_used_mb || 0
: scope.row.real_used_mb || 0
getDisplayedUsedMb(scope.row)
)
}}</span
>
@@ -161,7 +159,7 @@
>剩余:
{{
formatDataSize(
(scope.row.real_total_mb || 0) - (scope.row.real_used_mb || 0)
getDisplayedRemainingMb(scope.row)
)
}}</span
>
@@ -170,19 +168,15 @@
style="margin-top: 8px"
:percentage="
getUsageProgress(
scope.row.enable_virtual_data
? scope.row.virtual_used_mb || 0
: scope.row.real_used_mb || 0,
scope.row.real_total_mb || 0
getDisplayedUsedMb(scope.row),
getDisplayedTotalMb(scope.row)
)
"
:color="
getProgressColorByPercentage(
getUsageProgress(
scope.row.enable_virtual_data
? scope.row.virtual_used_mb || 0
: scope.row.real_used_mb || 0,
scope.row.real_total_mb || 0
getDisplayedUsedMb(scope.row),
getDisplayedTotalMb(scope.row)
)
)
"
@@ -409,6 +403,14 @@
}
// 页码变化
const getDisplayedTotalMb = (pkg: PackageInfo) => pkg.real_total_mb || 0
const getDisplayedUsedMb = (pkg: PackageInfo) =>
pkg.enable_virtual_data ? pkg.virtual_used_mb || 0 : pkg.real_used_mb || 0
const getDisplayedRemainingMb = (pkg: PackageInfo) =>
getDisplayedTotalMb(pkg) - getDisplayedUsedMb(pkg)
const handlePageChange = (page: number) => {
emit('page-change', page)
}

View File

@@ -43,7 +43,7 @@
style="width: 360px"
/>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
<ElButton @click="handleReset()">重置</ElButton>
<ElTag type="info" size="small"> {{ total }} </ElTag>
</div>
</div>
@@ -64,14 +64,8 @@
</template>
</ElEmpty>
<div v-else class="wallet-table-wrapper">
<ElTable
v-if="transactionList && transactionList.length > 0"
:data="transactionList"
class="wallet-table"
border
v-loading="loading"
>
<div v-else v-loading="loading" class="wallet-table-wrapper">
<ElTable v-if="hasTransactions" :data="transactionList" class="wallet-table" border>
<ElTableColumn label="交易类型" width="100" align="center">
<template #default="scope">
<ElTag :type="getTransactionTypeTag(scope.row.transaction_type)" size="small">
@@ -128,7 +122,9 @@
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-else-if="!loading" description="暂无钱包流水数据" :image-size="100" />
<div v-else class="wallet-empty-state">
<ElEmpty v-if="hasLoadedTransactions" description="暂无钱包流水数据" :image-size="100" />
</div>
<!-- 分页器 -->
<div v-if="total > 0" class="pagination-wrapper">
@@ -147,7 +143,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import {
ElCard,
ElTable,
@@ -166,6 +162,8 @@
import { formatDateTime } from '@/utils/business/format'
import { AssetService } from '@/api/modules'
import type {
AssetWalletTransactionItem,
AssetWalletTransactionListResponse,
AssetWalletResponse,
AssetWalletTransactionParams,
TransactionType
@@ -173,6 +171,7 @@
interface Props {
assetIdentifier: string
searchToken?: number
walletInfo: AssetWalletResponse | null
walletLoading?: boolean
boundDeviceId?: number | null
@@ -190,39 +189,80 @@
}>()
const { formatAmount, getTransactionTypeTag, getWalletStatusType } = useAssetFormatters()
const DEFAULT_PAGE_SIZE = 20
const createDefaultPagination = () => ({ page: 1, page_size: DEFAULT_PAGE_SIZE })
const createDefaultFilterForm = () => ({
transaction_type: null as TransactionType | null,
date_range: null as [Date, Date] | null
})
const createDefaultQueryParams = (): AssetWalletTransactionParams => ({
page: 1,
page_size: DEFAULT_PAGE_SIZE,
transaction_type: null,
start_time: null,
end_time: null
})
// State
const loading = ref(false)
const transactionList = ref<any[]>([])
const hasLoadedTransactions = ref(false)
const transactionList = ref<AssetWalletTransactionItem[]>([])
const total = ref(0)
const pagination = ref({ page: 1, page_size: 20 })
const filterForm = ref<{
transaction_type: TransactionType | null
date_range: [Date, Date] | null
}>({
transaction_type: null,
date_range: null
})
const queryParams = ref<AssetWalletTransactionParams>({
page: 1,
page_size: 20,
transaction_type: null as TransactionType | null,
start_time: null as string | null,
end_time: null as string | null
const hasTransactions = computed(() => transactionList.value.length > 0)
const pagination = ref(createDefaultPagination())
const filterForm = ref(createDefaultFilterForm())
const queryParams = ref<AssetWalletTransactionParams>(createDefaultQueryParams())
const clearTransactions = () => {
hasLoadedTransactions.value = false
transactionList.value = []
total.value = 0
pagination.value = createDefaultPagination()
}
const normalizeTransactionResponse = (data?: AssetWalletTransactionListResponse | null) => ({
items: data?.items ?? data?.list ?? [],
total: data?.total ?? 0,
page: data?.page ?? 1,
pageSize: data?.size ?? data?.page_size ?? queryParams.value.page_size ?? DEFAULT_PAGE_SIZE
})
const loadTransactions = async () => {
if (!props.assetIdentifier) return
const loadTransactions = async (preserveDisplayState: boolean = true) => {
if (!props.assetIdentifier || props.boundDeviceId) {
clearTransactions()
return
}
if (!preserveDisplayState) {
clearTransactions()
}
try {
loading.value = true
const response = await AssetService.getWalletTransactions(
props.assetIdentifier,
queryParams.value
)
if (response.code === 0 && response.data) {
transactionList.value = response.data.list || []
total.value = response.data.total || 0
const normalized = normalizeTransactionResponse(response.data)
transactionList.value = normalized.items
total.value = normalized.total
pagination.value = {
page: normalized.page,
page_size: normalized.pageSize
}
queryParams.value.page = normalized.page
queryParams.value.page_size = normalized.pageSize
} else {
transactionList.value = []
total.value = 0
pagination.value = createDefaultPagination()
}
hasLoadedTransactions.value = true
} catch (error: any) {
if (error?.response?.status === 403) {
ElMessage.warning('企业账号禁止查看钱包流水')
@@ -234,6 +274,12 @@
}
}
const resetQueryState = () => {
filterForm.value = createDefaultFilterForm()
queryParams.value = createDefaultQueryParams()
pagination.value = createDefaultPagination()
}
const handleQuery = () => {
queryParams.value.page = 1
pagination.value.page = 1
@@ -245,41 +291,45 @@
queryParams.value.start_time = null
queryParams.value.end_time = null
}
loadTransactions()
void loadTransactions(true)
}
const handleFilterChange = () => handleQuery()
const handleReset = () => {
filterForm.value = { transaction_type: null, date_range: null }
queryParams.value = {
page: 1,
page_size: 20,
transaction_type: null,
start_time: null,
end_time: null
const handleReset = (preserveDisplayState: boolean = true) => {
resetQueryState()
if (props.boundDeviceId) {
clearTransactions()
return
}
pagination.value = { page: 1, page_size: 20 }
loadTransactions()
void loadTransactions(preserveDisplayState)
}
const handlePageSizeChange = (size: number) => {
queryParams.value.page_size = size
queryParams.value.page = 1
pagination.value.page = 1
loadTransactions()
void loadTransactions(true)
}
const handlePageChange = (page: number) => {
queryParams.value.page = page
loadTransactions()
void loadTransactions(true)
}
// 仅监听 assetIdentifier 变化加载数据(去掉 onMounted 避免重复调用)
watch(
() => props.assetIdentifier,
(newVal) => {
if (newVal) handleReset()
() => [props.assetIdentifier, props.searchToken, props.boundDeviceId] as const,
([assetIdentifier], previousValue) => {
if (!assetIdentifier) {
resetQueryState()
clearTransactions()
return
}
const previousAssetIdentifier = previousValue?.[0]
const preserveDisplayState =
Boolean(previousAssetIdentifier) && assetIdentifier === previousAssetIdentifier
handleReset(preserveDisplayState)
},
{ immediate: true }
)
@@ -326,6 +376,13 @@
}
.wallet-table-wrapper {
.wallet-empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
}
.wallet-table {
:deep(.el-table__header) {
th {
@@ -334,18 +391,6 @@
}
}
.reference-no-link {
color: var(--el-color-primary);
text-decoration: underline;
text-decoration-style: dashed;
text-underline-offset: 2px;
cursor: pointer;
&:hover {
color: var(--el-color-primary-light-3);
text-decoration-style: solid;
}
}
}
.pagination-wrapper {

View File

@@ -8,6 +8,13 @@ import { ElMessage } from 'element-plus'
import { AssetService } from '@/api/modules'
import type { AssetWalletResponse, DeviceGatewayInfo, AssetPackageUsageRecord } from '@/types/api'
type TrafficDisplaySource = {
real_total_mb?: number | null
real_used_mb?: number | null
virtual_used_mb?: number | null
enable_virtual_data?: boolean | null
}
/**
* 格式化数据大小(MB -> GB/MB)
*/
@@ -18,6 +25,20 @@ const formatDataSize = (mb: number) => {
return `${mb.toFixed(2)}MB`
}
const getDisplayedTotalMb = (traffic?: TrafficDisplaySource | null) => traffic?.real_total_mb || 0
const getDisplayedUsedMb = (traffic?: TrafficDisplaySource | null) =>
traffic?.enable_virtual_data ? traffic?.virtual_used_mb || 0 : traffic?.real_used_mb || 0
const getDisplayedRemainingMb = (traffic?: TrafficDisplaySource | null) =>
getDisplayedTotalMb(traffic) - getDisplayedUsedMb(traffic)
const getDisplayedUsagePercentage = (traffic?: TrafficDisplaySource | null) => {
const totalMb = getDisplayedTotalMb(traffic)
const usedMb = getDisplayedUsedMb(traffic)
return totalMb > 0 ? `${((usedMb / totalMb) * 100).toFixed(2)}%` : '0.00%'
}
export function useAssetInfo() {
// 状态管理
const loading = ref(false)
@@ -105,13 +126,10 @@ export function useAssetInfo() {
// 流量/套餐信息
current_package: data.current_package || '',
packageSeries: data.series_name || '-',
packageTotalFlow: formatDataSize(data.real_total_mb || 0),
usedFlow: formatDataSize(data.real_used_mb || 0),
remainFlow: formatDataSize((data.real_total_mb || 0) - (data.real_used_mb || 0)),
usedFlowPercentage:
data.real_total_mb > 0
? `${((data.real_used_mb / data.real_total_mb) * 100).toFixed(2)}%`
: '0.00%',
packageTotalFlow: formatDataSize(getDisplayedTotalMb(data)),
usedFlow: formatDataSize(getDisplayedUsedMb(data)),
remainFlow: formatDataSize(getDisplayedRemainingMb(data)),
usedFlowPercentage: getDisplayedUsagePercentage(data),
packageList: [] // 套餐列表需要单独调用API获取
}
@@ -209,23 +227,11 @@ export function useAssetInfo() {
}
// 同时更新 cardInfo 中的流量显示信息(根据是否启用虚流量选择数据源)
const useVirtual = pkg.enable_virtual_data
cardInfo.value.current_package = pkg.package_name
cardInfo.value.packageTotalFlow = formatDataSize(
useVirtual ? pkg.virtual_total_mb || 0 : pkg.real_total_mb || 0
)
cardInfo.value.usedFlow = formatDataSize(
useVirtual ? pkg.virtual_used_mb || 0 : pkg.real_used_mb || 0
)
cardInfo.value.remainFlow = formatDataSize(
useVirtual
? (pkg.virtual_total_mb || 0) - (pkg.virtual_used_mb || 0)
: (pkg.real_total_mb || 0) - (pkg.real_used_mb || 0)
)
const totalMb = useVirtual ? pkg.virtual_total_mb || 0 : pkg.real_total_mb || 0
const usedMb = useVirtual ? pkg.virtual_used_mb || 0 : pkg.real_used_mb || 0
cardInfo.value.usedFlowPercentage =
totalMb > 0 ? `${((usedMb / totalMb) * 100).toFixed(2)}%` : '0.00%'
cardInfo.value.packageTotalFlow = formatDataSize(getDisplayedTotalMb(pkg))
cardInfo.value.usedFlow = formatDataSize(getDisplayedUsedMb(pkg))
cardInfo.value.remainFlow = formatDataSize(getDisplayedRemainingMb(pkg))
cardInfo.value.usedFlowPercentage = getDisplayedUsagePercentage(pkg)
} else {
// code 为 0 但 data 为空,表示暂无当前生效套餐(正常情况)
currentPackage.value = null

View File

@@ -76,6 +76,7 @@
<div class="row full-width">
<WalletTransactionCard
:asset-identifier="cardInfo.identifier"
:search-token="walletTransactionSearchToken"
:wallet-info="walletInfo"
:wallet-loading="walletLoading"
:bound-device-id="cardInfo.bound_device_id"
@@ -295,6 +296,7 @@
pageSize: 5,
total: 0
})
const walletTransactionSearchToken = ref(0)
// ========== 轮询状态 ==========
const pollingEnabled = ref(false)
@@ -325,6 +327,10 @@
await fetchAssetDetail(deviceIdentifier)
}
if (cardInfo.value) {
walletTransactionSearchToken.value += 1
}
// 从接口数据初始化轮询开关,不触发更新接口
pollingInitializing = true
pollingEnabled.value = cardInfo.value?.enable_polling ?? false

View File

@@ -43,7 +43,9 @@
const router = useRouter()
const userStore = useUserStore()
const isAgentAccount = computed(() => userStore.info.user_type === 3)
const currentUserType = computed(() => Number(userStore.info.user_type ?? 0))
const isAgentAccount = computed(() => currentUserType.value === 3)
const shouldHideVirtualTrafficFields = computed(() => [3, 4].includes(currentUserType.value))
const loading = ref(false)
const detailData = ref<PackageResponse | null>(null)
@@ -58,7 +60,7 @@
{ label: '套餐系列', prop: 'series_name' },
{
label: '套餐类型',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
const typeMap: Record<string, string> = {
formal: '正式套餐',
addon: '附加套餐'
@@ -68,13 +70,13 @@
},
{
label: '成本价',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
return `¥${(data.cost_price / 100).toFixed(2)}`
}
},
{
label: '建议售价',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (data.suggested_retail_price === null || data.suggested_retail_price === undefined)
return '-'
return `¥${(data.suggested_retail_price / 100).toFixed(2)}`
@@ -84,14 +86,14 @@
? [
{
label: '代理零售价',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (data.retail_price === null || data.retail_price === undefined) return '-'
return `¥${(data.retail_price / 100).toFixed(2)}`
}
},
{
label: '利润空间',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (data.profit_margin === null || data.profit_margin === undefined) return '-'
return `¥${(data.profit_margin / 100).toFixed(2)}`
}
@@ -100,13 +102,13 @@
: [
{
label: '价格配置状态',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
return data.price_config_status_name || '-'
}
},
{
label: '生效零售价',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (
data.effective_retail_price === null ||
data.effective_retail_price === undefined
@@ -117,20 +119,20 @@
},
{
label: '是否赠送',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
return data.is_gift ? '是' : '否'
}
}
]),
{
label: '套餐时长',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
return `${data.duration_months} 个月`
}
},
{
label: '套餐周期类型',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (!data.calendar_type) return '-'
const typeMap: Record<string, string> = {
natural_month: '自然月',
@@ -141,14 +143,14 @@
},
{
label: '套餐天数',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (data.calendar_type !== 'by_day' || !data.duration_days) return '-'
return `${data.duration_days}`
}
},
{
label: '流量重置周期',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (!data.data_reset_cycle) return '-'
const cycleMap: Record<string, string> = {
daily: '每日',
@@ -161,20 +163,20 @@
},
{
label: '到期计时基准',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (!data.expiry_base) return '--'
return data.expiry_base === 'from_activation' ? '实名激活时起算' : '购买时起算'
}
},
{
label: '状态',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
return getStatusLabel(data.status ?? 0)
}
},
{
label: '上架状态',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
return getShelfStatusText(data.shelf_status ?? 0)
}
},
@@ -186,30 +188,34 @@
title: '流量配置',
fields: [
{
label: '流量额度',
formatter: (_, data) => {
label: '流量',
formatter: (_: unknown, data: PackageResponse) => {
if (data.real_data_mb >= 1024) {
return `${(data.real_data_mb / 1024).toFixed(2)} GB`
}
return `${data.real_data_mb} MB`
}
},
{
label: '启用虚流量',
formatter: (_, data) => {
return data.enable_virtual_data ? '是' : '否'
}
},
{
label: '虚流量额度',
formatter: (_, data) => {
if (!data.enable_virtual_data) return '-'
if (data.virtual_data_mb >= 1024) {
return `${(data.virtual_data_mb / 1024).toFixed(2)} GB`
}
return `${data.virtual_data_mb} MB`
}
}
...(shouldHideVirtualTrafficFields.value
? []
: [
{
label: '启用虚流量',
formatter: (_: unknown, data: PackageResponse) => {
return data.enable_virtual_data ? '是' : '否'
}
},
{
label: '虚流量额度',
formatter: (_: unknown, data: PackageResponse) => {
if (!data.enable_virtual_data) return '-'
if (data.virtual_data_mb >= 1024) {
return `${(data.virtual_data_mb / 1024).toFixed(2)} GB`
}
return `${data.virtual_data_mb} MB`
}
}
])
]
},
{
@@ -217,7 +223,7 @@
fields: [
{
label: '一次性佣金金额',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (
data.one_time_commission_amount === null ||
data.one_time_commission_amount === undefined
@@ -228,21 +234,21 @@
},
{
label: '当前返佣比例',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (!data.tier_info || !data.tier_info.current_rate) return '-'
return data.tier_info.current_rate
}
},
{
label: '下一档位比例',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (!data.tier_info || !data.tier_info.next_rate) return '-'
return data.tier_info.next_rate
}
},
{
label: '下一档位阈值',
formatter: (_, data) => {
formatter: (_: unknown, data: PackageResponse) => {
if (
!data.tier_info ||
data.tier_info.next_threshold === null ||

View File

@@ -353,14 +353,18 @@
<ElInputNumber
v-model="retailPriceForm.retail_price"
:min="0"
:max="999999"
:max="retailPriceUpperLimit"
:step="0.01"
:precision="2"
placeholder="请输入零售价"
style="width: 100%"
/>
<!--<div style="margin-top: 5px; font-size: 12px; color: #909399">-->
<!-- 成本价{{ formatRetailPriceYuan(retailPriceForm.cost_price) }}单位-->
<!--</div>-->
<div style="margin-top: 5px; font-size: 12px; color: #909399">
单位 | 存储值{{ Math.round(retailPriceForm.retail_price * 100) }}
<!--零售价不能大于建议售价 * 2 = {{ formatRetailPriceYuan(retailPriceUpperLimit) }}-->
销售价格不能大于官方建议销售价格的2倍
</div>
</ElFormItem>
</ElForm>
@@ -417,7 +421,9 @@
const userStore = useUserStore()
const router = useRouter()
const isAgentAccount = computed(() => userStore.info.user_type === 3)
const currentUserType = computed(() => Number(userStore.info.user_type ?? 0))
const isAgentAccount = computed(() => currentUserType.value === 3)
const shouldHideVirtualTrafficColumns = computed(() => [3, 4].includes(currentUserType.value))
const dialogVisible = ref(false)
const loading = ref(false)
@@ -435,16 +441,40 @@
const retailPriceForm = reactive({
id: 0,
package_name: '',
retail_price: 0
retail_price: 0,
cost_price: 0,
suggested_retail_price: 0
})
const retailPriceUpperLimit = computed(() =>
Number((Math.max(retailPriceForm.suggested_retail_price, 0) * 2).toFixed(2))
)
const formatRetailPriceYuan = (price: number | null | undefined) =>
`¥${Number(price ?? 0).toFixed(2)}`
const retailPriceRules = reactive<FormRules>({
retail_price: [
{ required: true, message: '请输入零售价', trigger: 'blur' },
{ type: 'number', message: '零售价必须为数字', trigger: 'blur' }
{ type: 'number', message: '零售价必须为数字', trigger: 'blur' },
{
validator: (_rule: any, value: number, callback: any) => {
if (value === undefined || value === null) {
callback()
return
}
if (value > retailPriceUpperLimit.value) {
callback(
new Error(`零售价不能大于建议售价 * 2 = ${formatRetailPriceYuan(retailPriceUpperLimit.value)}`)
)
return
}
callback()
},
trigger: ['blur', 'change']
}
]
})
// 使用表格右键菜单功能
// 搜索表单初始值
const initialSearchState = {
package_name: '',
@@ -549,9 +579,13 @@
]),
{ label: '套餐周期类型', prop: 'calendar_type' },
{ label: '有效期', prop: 'duration_months' },
{ label: '流量', prop: 'real_data_mb' },
{ label: '虚流量', prop: 'virtual_data_mb' },
{ label: '虚流量比例', prop: 'virtual_ratio' },
{ label: '流量', prop: 'real_data_mb' },
...(!shouldHideVirtualTrafficColumns.value
? [
{ label: '虚流量', prop: 'virtual_data_mb' },
{ label: '虚流量比例', prop: 'virtual_ratio' }
]
: []),
{
label: '启用虚流量',
prop: 'enable_virtual_data',
@@ -692,7 +726,7 @@
})
// GB 转 MB 处理
const handleRealDataChange = (value: number | null) => {
const handleRealDataChange = (value: number | null | undefined) => {
if (value === null || value === undefined) {
form.real_data_mb = 0
return
@@ -703,7 +737,7 @@
}
// 虚流量比例变化处理
const handleVirtualRatioChange = (value: number | null) => {
const handleVirtualRatioChange = (value: number | null | undefined) => {
if (value === null || value === undefined || realDataGb.value === 0) {
form.virtual_data_mb = 0
return
@@ -863,7 +897,7 @@
},
{
prop: 'real_data_mb',
label: '流量',
label: '流量',
width: 120,
formatter: (row: PackageResponse) => {
const mb = row.real_data_mb ?? 0
@@ -873,40 +907,44 @@
return `${mb}MB`
}
},
{
prop: 'virtual_data_mb',
label: '虚流量',
width: 120,
formatter: (row: PackageResponse) => {
// 如果没有启用虚流量,显示 "-"
if (!row.enable_virtual_data) {
return '-'
}
const mb = row.virtual_data_mb ?? 0
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)}GB`
}
return `${mb}MB`
}
},
{
prop: 'virtual_ratio',
label: '虚流量比例',
width: 120,
formatter: (row: PackageResponse) => {
// 如果启用虚流量且真流量大于0计算虚量百分比
const virtualData = row.virtual_data_mb ?? 0
const realData = row.real_data_mb ?? 0
if (row.enable_virtual_data && realData > 0 && virtualData > 0) {
// 虚量百分比 = (1 - 虚流量/真流量) * 100%
// 例如真流量100G虚流量70G则虚量百分比 = (1 - 70/100) * 100% = 30%
const ratio = (1 - virtualData / realData) * 100
return `${ratio.toFixed(2)}%`
}
// 否则返回 0%
return '0%'
}
},
...(!shouldHideVirtualTrafficColumns.value
? [
{
prop: 'virtual_data_mb',
label: '虚流量',
width: 120,
formatter: (row: PackageResponse) => {
// 如果没有启用虚流量,显示 "-"
if (!row.enable_virtual_data) {
return '-'
}
const mb = row.virtual_data_mb ?? 0
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)}GB`
}
return `${mb}MB`
}
},
{
prop: 'virtual_ratio',
label: '虚流量比例',
width: 120,
formatter: (row: PackageResponse) => {
// 如果启用虚流量且真流量大于0计算虚量百分比
const virtualData = row.virtual_data_mb ?? 0
const realData = row.real_data_mb ?? 0
if (row.enable_virtual_data && realData > 0 && virtualData > 0) {
// 虚量百分比 = (1 - 虚流量/真流量) * 100%
// 例如真流量100G虚流量70G则虚量百分比 = (1 - 70/100) * 100% = 30%
const ratio = (1 - virtualData / realData) * 100
return `${ratio.toFixed(2)}%`
}
// 否则返回 0%
return '0%'
}
}
]
: []),
{
prop: 'duration_months',
label: '有效期',
@@ -922,7 +960,9 @@
natural_month: '自然月',
by_day: '按天'
}
return map[row.calendar_type] || row.calendar_type || '-'
const calendarType = row.calendar_type
if (!calendarType) return '-'
return map[calendarType] || calendarType
}
},
{
@@ -936,7 +976,9 @@
monthly: '每月',
none: '不重置'
}
return map[row.data_reset_cycle] || row.data_reset_cycle || '-'
const dataResetCycle = row.data_reset_cycle
if (!dataResetCycle) return '-'
return map[dataResetCycle] || dataResetCycle
}
},
{
@@ -948,7 +990,9 @@
from_activation: '实名激活',
from_purchase: '购买时'
}
return map[row.expiry_base] || row.expiry_base || '-'
const expiryBase = row.expiry_base
if (!expiryBase) return '-'
return map[expiryBase] || expiryBase
}
},
{
@@ -968,7 +1012,7 @@
}
// 只有代理账号(user_type=3)才显示修改零售价
if (hasAuth('package:update_retail_price') && userStore.getUserInfo.user_type === 3) {
if (hasAuth('package:update_retail_price') && isAgentAccount.value) {
actions.push({
label: '修改零售价',
handler: () => showRetailPriceDialog(row),
@@ -1022,8 +1066,8 @@
)
// 监听 is_gift 变化,自动设置 suggested_retail_price
const handleIsGiftChange = (isGift: boolean) => {
if (isGift) {
const handleIsGiftChange = (isGift: string | number | boolean) => {
if (Boolean(isGift)) {
form.suggested_retail_price = 0
} else {
form.suggested_retail_price = undefined
@@ -1189,10 +1233,11 @@
virtualRatioPercent.value = null
}
form.cost_price = (data.cost_price ?? 0) / 100 // 分转换为元显示
form.suggested_retail_price = data.suggested_retail_price
? data.suggested_retail_price / 100
: undefined
originalSuggestedRetailPrice.value = data.suggested_retail_price
form.suggested_retail_price =
data.suggested_retail_price !== null && data.suggested_retail_price !== undefined
? data.suggested_retail_price / 100
: undefined
originalSuggestedRetailPrice.value = data.suggested_retail_price ?? null
form.is_gift = data.is_gift || false
form.description = data.description || ''
@@ -1417,8 +1462,10 @@
const showRetailPriceDialog = (row: PackageResponse) => {
retailPriceForm.id = row.id
retailPriceForm.package_name = row.package_name
retailPriceForm.cost_price = (row.cost_price ?? 0) / 100
retailPriceForm.suggested_retail_price = (row.suggested_retail_price ?? 0) / 100
// 零售价值来自代理零售价
retailPriceForm.retail_price = row.retail_price ? row.retail_price / 100 : 0
retailPriceForm.retail_price = (row.retail_price ?? 0) / 100
retailPriceDialogVisible.value = true
}
@@ -1449,6 +1496,11 @@
const handleCloseRetailPriceDialog = () => {
retailPriceDialogVisible.value = false
retailPriceFormRef.value?.resetFields()
retailPriceForm.id = 0
retailPriceForm.package_name = ''
retailPriceForm.retail_price = 0
retailPriceForm.cost_price = 0
retailPriceForm.suggested_retail_price = 0
}
</script>

View File

@@ -256,13 +256,16 @@
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:min="0"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
请参考{{ packageForm.package_name || '套餐' }} - 套餐成本价:
¥{{ packageForm.original_cost_price.toFixed(2) }}
</div>
</ElFormItem>
</ElForm>
@@ -280,7 +283,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElCard,
@@ -335,6 +338,7 @@
package_id?: number
package_name?: string
package_code?: string
original_cost_price?: number
cost_price_yuan: number
}>({
cost_price_yuan: 0
@@ -352,6 +356,26 @@
}))
// 梯度配置表格数据(合并授权数据和系列数据)
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value !== 'add' || !packageId) return
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
if (!selectedPackage) return
const originalCostPrice =
selectedPackage.cost_price !== undefined && selectedPackage.cost_price !== null
? selectedPackage.cost_price / 100
: 0
packageForm.value.package_name = selectedPackage.package_name
packageForm.value.package_code = selectedPackage.package_code
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
)
const tierTableData = computed(() => {
if (
!detailData.value?.commission_tiers ||
@@ -435,6 +459,9 @@
packageDialogType.value = 'add'
packageForm.value = {
package_id: undefined,
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
cost_price_yuan: 0
}
@@ -453,6 +480,7 @@
package_id: row.package_id,
package_name: row.package_name,
package_code: row.package_code,
original_cost_price: row.cost_price / 100,
cost_price_yuan: row.cost_price / 100
}
@@ -581,6 +609,9 @@
const handlePackageDialogClosed = () => {
packageFormRef.value?.resetFields()
packageForm.value = {
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
cost_price_yuan: 0
}
availablePackages.value = []

View File

@@ -45,166 +45,6 @@
</template>
</ArtTable>
<!-- 套餐列表对话框 -->
<ElDialog
v-model="packageListDialogVisible"
title="套餐列表"
width="65%"
:close-on-click-modal="false"
@closed="handlePackageListDialogClosed"
>
<div class="package-list-dialog-content">
<!-- 添加授权套餐按钮 -->
<div class="package-list-header">
<ElButton
type="primary"
@click="showAddPackageDialog"
v-permission="'series_grants:manage_packages'"
>
添加授权套餐
</ElButton>
</div>
<!-- 套餐列表 -->
<ElTable
v-if="currentGrantPackages.length > 0"
:data="currentGrantPackages"
border
stripe
style="margin-top: 12px"
>
<ElTableColumn prop="package_name" label="套餐名称" />
<ElTableColumn prop="package_code" label="套餐编码" />
<ElTableColumn label="成本价">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<ElButton
type="primary"
size="small"
link
@click="showEditPackageDialog(row)"
v-permission="'series_grants:edit_packages'"
>
编辑
</ElButton>
<ElButton
type="danger"
size="small"
link
@click="handleDeletePackage(row)"
v-permission="'series_grants:delete_packages'"
>
删除
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-else description="暂无套餐" :image-size="80" />
</div>
</ElDialog>
<!-- 添加/编辑套餐对话框 -->
<ElDialog
v-model="packageDialogVisible"
:title="packageDialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="500px"
:close-on-click-modal="false"
@closed="handlePackageDialogClosed"
>
<ElForm
ref="packageFormRef"
:model="packageForm"
:rules="packageFormRules"
label-width="100px"
>
<!-- 添加模式选择套餐 -->
<ElFormItem label="选择套餐" prop="package_id" v-if="packageDialogType === 'add'">
<ElSelect
v-model="packageForm.package_id"
placeholder="请选择套餐"
style="width: 100%"
filterable
remote
:remote-method="searchAvailablePackages"
:loading="packageLoading"
clearable
>
<template
v-if="availablePackages.length === 0 && !packageLoading && currentGrantSeriesId"
>
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in availablePackages"
:key="pkg.id"
:label="`${pkg.package_name} (${pkg.package_code})`"
:value="pkg.id"
:disabled="isPackageAlreadyAdded(pkg.id)"
/>
</ElSelect>
</ElFormItem>
<!-- 编辑模式显示套餐信息 -->
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_name }}</span>
</ElFormItem>
<ElFormItem label="套餐编码" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_code }}</span>
</ElFormItem>
<!-- 成本价 -->
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:min="packageForm.original_cost_price || 0"
:max="
packageForm.original_cost_price
? packageForm.original_cost_price * 1.5
: undefined
"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
原价: ¥{{ packageForm.original_cost_price.toFixed(2) }}, 最高: ¥{{
(packageForm.original_cost_price * 1.5).toFixed(2)
}}
(50%溢价)
</div>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="packageDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSavePackage" :loading="packageSubmitLoading">
保存
</ElButton>
</div>
</template>
</ElDialog>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
@@ -318,9 +158,7 @@
<ElInputNumber
v-model="pkg.cost_price"
:min="pkg.original_cost_price || 0"
:max="
pkg.original_cost_price ? pkg.original_cost_price * 1.5 : undefined
"
:max="getPackageCostPriceMax(pkg)"
:precision="2"
:step="0.01"
:controls="false"
@@ -670,66 +508,6 @@
</ElFormItem>
</ElCol>
</ElRow>
<!-- 套餐配置(编辑模式下禁用) -->
<!-- <div class="form-section-title">
<span class="title-text">套餐配置(可选)</span>
</div> -->
<!-- 编辑模式下不显示套餐配置区域,请使用右键菜单中的"添加授权套餐"功能 -->
<!-- <ElFormItem label="选择套餐">
<ElSelect
v-model="selectedPackageIds"
placeholder="请选择套餐"
style="width: 100%"
multiple
filterable
remote
:remote-method="searchPackages"
:loading="packageLoading"
clearable
>
<template v-if="packageOptions.length === 0 && !packageLoading && form.series_id">
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in packageOptions"
:key="pkg.id"
:label="pkg.package_name"
:value="pkg.id"
/>
</ElSelect>
<div class="form-tip">选择该授权下包含的套餐</div>
</ElFormItem> -->
<!-- 套餐成本价 -->
<!-- <ElFormItem label="套餐成本价" v-if="form.packages.length > 0">
<div class="package-list">
<div
v-for="(pkg, index) in form.packages"
:key="pkg.package_id"
class="package-item"
>
<span class="package-name">{{ getPackageName(pkg.package_id) }}</span>
<div class="cost-price-input-wrapper">
<ElInputNumber
v-model="pkg.cost_price"
:min="pkg.original_cost_price || 0"
:precision="2"
:step="0.01"
:controls="false"
placeholder="成本价(元)"
style="width: 150px"
/>
<span v-if="pkg.original_cost_price" class="min-cost-hint">
(成本价: ¥{{ pkg.original_cost_price.toFixed(2) }})
</span>
</div>
<ElButton type="danger" size="small" @click="removePackage(index)">删除</ElButton>
</div>
</div>
<div class="form-tip">设置每个套餐的成本价(单位:元),不能低于套餐原始成本价</div>
</ElFormItem> -->
</ElForm>
<template #footer>
@@ -789,9 +567,7 @@
PackageSeriesResponse,
ShopResponse,
PackageResponse,
CommissionTier,
GrantPackageItem,
GrantPackageInfo
CommissionTier
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -826,68 +602,6 @@
const formRef = ref<FormInstance>()
const currentStep = ref(0) // 当前步骤0 为第一步1 为第二步
// 套餐列表对话框相关
const packageListDialogVisible = ref(false)
const currentGrantId = ref<number>(0)
const currentGrantSeriesId = ref<number>(0)
const currentGrantPackages = ref<GrantPackageInfo[]>([])
// 套餐管理相关
const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add')
const packageSubmitLoading = ref(false)
const availablePackages = ref<PackageResponse[]>([])
const packageFormRef = ref<FormInstance>()
// 套餐表单
const packageForm = ref<{
package_id?: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number // 原始成本价(元)
}>({
cost_price_yuan: 0
})
// 套餐表单验证规则
const packageFormRules = computed<FormRules>(() => ({
package_id: [
{ required: packageDialogType.value === 'add', message: '请选择套餐', trigger: 'change' }
],
cost_price_yuan: [
{ required: true, message: '请输入成本价', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback(new Error('请输入成本价'))
} else if (value < 0) {
callback(new Error('成本价不能小于0'))
} else if (
packageForm.value.original_cost_price &&
value < packageForm.value.original_cost_price
) {
callback(
new Error(`成本价不能低于原价 ¥${packageForm.value.original_cost_price.toFixed(2)}`)
)
} else if (
packageForm.value.original_cost_price &&
value > packageForm.value.original_cost_price * 1.5
) {
callback(
new Error(
`成本价不能高于原价的50%溢价(最高:¥${(packageForm.value.original_cost_price * 1.5).toFixed(2)}`
)
)
} else {
callback()
}
},
trigger: 'blur'
}
]
}))
const seriesOptions = ref<PackageSeriesResponse[]>([])
const shopOptions = ref<ShopResponse[]>([])
const shopTreeData = ref<ShopResponse[]>([])
@@ -907,7 +621,10 @@
const parentId = level === 0 ? undefined : value
try {
const res = await ShopService.getShopsCascade({ parent_id: parentId })
const res = await ShopService.getShopsCascade({
parent_id: parentId,
exclude_self: true
} as any)
if (res.code === 0) {
const nodes = (res.data || []).map((item: any) => ({
value: item.id,
@@ -1401,6 +1118,19 @@
return pkg ? pkg.package_name : `套餐ID: ${packageId}`
}
const getPackageCostPriceMax = (pkg?: {
original_cost_price?: number
suggested_retail_price?: number | null
}) => {
if (pkg?.suggested_retail_price !== undefined && pkg?.suggested_retail_price !== null) {
return Number((pkg.suggested_retail_price * 1.5).toFixed(2))
}
if (pkg?.original_cost_price !== undefined && pkg.original_cost_price !== null) {
return Number((pkg.original_cost_price * 1.5).toFixed(2))
}
return undefined
}
const formatSuggestedRetailPrice = (suggestedRetailPrice?: number | null) => {
if (suggestedRetailPrice === undefined || suggestedRetailPrice === null) {
return '-'
@@ -1536,24 +1266,6 @@
}
)
// 监听套餐选择,自动设置原始成本价和成本价
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value === 'add' && packageId) {
// 从 availablePackages 中查找选中的套餐
const selectedPackage = availablePackages.value.find((p) => p.id === packageId)
if (selectedPackage) {
const originalCostPrice = selectedPackage.cost_price
? selectedPackage.cost_price / 100
: 0
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
}
}
)
// 加载系列选项(用于新增对话框,默认加载10条
const loadSeriesOptions = async (seriesName?: string) => {
seriesLoading.value = true
@@ -1630,7 +1342,10 @@
const loadShopListForDialog = async () => {
shopLoading.value = true
try {
const res = await ShopService.getShopsCascade({ parent_id: undefined })
const res = await ShopService.getShopsCascade({
parent_id: undefined,
exclude_self: true
} as any)
if (res.code === 0) {
shopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
@@ -2034,10 +1749,11 @@
for (const pkg of form.packages) {
const costPrice = pkg.cost_price ?? 0
const originalCostPrice = pkg.original_cost_price ?? 0
const maxCostPrice = getPackageCostPriceMax(pkg)
if (originalCostPrice > 0 && costPrice > originalCostPrice * 1.5) {
if (maxCostPrice !== undefined && costPrice > maxCostPrice) {
ElMessage.error(
`套餐 ${getPackageName(pkg.package_id)} 的成本价不能高于原价的50%溢价(最高:¥${(originalCostPrice * 1.5).toFixed(2)}`
`套餐 ${getPackageName(pkg.package_id)} 的成本价不能高于当前上限(最高:¥${maxCostPrice.toFixed(2)}`
)
submitLoading.value = false
return
@@ -2095,212 +1811,6 @@
}
}
// 显示套餐列表对话框
const showPackageListDialog = async (row: ShopSeriesGrantResponse) => {
currentGrantId.value = row.id
currentGrantSeriesId.value = row.series_id
// 获取该授权的详细信息,包括套餐列表
loading.value = true
try {
const res = await ShopSeriesGrantService.getShopSeriesGrantDetail(row.id)
if (res.code === 0) {
currentGrantPackages.value = res.data.packages || []
packageListDialogVisible.value = true
}
} catch (error) {
console.error('获取套餐列表失败:', error)
console.log('获取套餐列表失败')
} finally {
loading.value = false
}
}
// 显示添加套餐对话框
const showAddPackageDialog = () => {
packageDialogType.value = 'add'
packageForm.value = {
package_id: undefined,
cost_price_yuan: 0
}
// 加载可用套餐
if (currentGrantSeriesId.value) {
loadAvailablePackages()
}
packageDialogVisible.value = true
}
// 显示编辑套餐对话框
const showEditPackageDialog = (row: GrantPackageInfo) => {
packageDialogType.value = 'edit'
// 从 packageOptions 中查找原始成本价
const pkgOption = packageOptions.value.find((p) => p.id === row.package_id)
const originalCostPrice = pkgOption?.cost_price ? pkgOption.cost_price / 100 : 0
packageForm.value = {
package_id: row.package_id,
package_name: row.package_name,
package_code: row.package_code,
cost_price_yuan: row.cost_price / 100,
original_cost_price: originalCostPrice
}
packageDialogVisible.value = true
}
// 加载可用套餐
const loadAvailablePackages = async (packageName?: string) => {
if (!currentGrantSeriesId.value) return
packageLoading.value = true
try {
const params: any = {
page: 1,
page_size: 50,
series_id: currentGrantSeriesId.value
}
if (packageName) {
params.package_name = packageName
}
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
// 过滤掉赠送套餐
availablePackages.value = res.data.items.filter((pkg) => !pkg.is_gift)
}
} catch (error) {
console.error('加载套餐选项失败:', error)
} finally {
packageLoading.value = false
}
}
// 搜索可用套餐
const searchAvailablePackages = (query: string) => {
if (query) {
loadAvailablePackages(query)
} else {
loadAvailablePackages()
}
}
// 检查套餐是否已添加
const isPackageAlreadyAdded = (packageId: number) => {
return currentGrantPackages.value.some((p) => p.package_id === packageId)
}
// 保存套餐
const handleSavePackage = async () => {
if (!packageFormRef.value || !currentGrantId.value) return
await packageFormRef.value.validate(async (valid) => {
if (!valid) return
packageSubmitLoading.value = true
try {
const packages: GrantPackageItem[] = []
if (packageDialogType.value === 'add') {
// 添加模式:添加新套餐
packages.push({
package_id: packageForm.value.package_id,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
})
} else {
// 编辑模式:更新套餐成本价
packages.push({
package_id: packageForm.value.package_id,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
})
}
await ShopSeriesGrantService.manageGrantPackages(currentGrantId.value, { packages })
ElMessage.success(packageDialogType.value === 'add' ? '添加成功' : '更新成功')
packageDialogVisible.value = false
// 刷新套餐列表
await refreshPackageList()
} catch (error) {
console.error(error)
console.log(packageDialogType.value === 'add' ? '添加失败' : '更新失败')
} finally {
packageSubmitLoading.value = false
}
})
}
// 删除套餐
const handleDeletePackage = (row: GrantPackageInfo) => {
ElMessageBox.confirm(`确定删除套餐 ${row.package_name} 的授权吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
if (!currentGrantId.value) return
packageSubmitLoading.value = true
try {
const packages: GrantPackageItem[] = [
{
package_id: row.package_id,
remove: true
}
]
await ShopSeriesGrantService.manageGrantPackages(currentGrantId.value, { packages })
ElMessage.success('删除成功')
// 刷新套餐列表
await refreshPackageList()
} catch (error) {
console.error(error)
console.log('删除失败')
} finally {
packageSubmitLoading.value = false
}
})
.catch(() => {
// 用户取消
})
}
// 刷新套餐列表
const refreshPackageList = async () => {
if (!currentGrantId.value) return
try {
const res = await ShopSeriesGrantService.getShopSeriesGrantDetail(currentGrantId.value)
if (res.code === 0) {
currentGrantPackages.value = res.data.packages || []
}
} catch (error) {
console.error('刷新套餐列表失败:', error)
}
}
// 关闭套餐对话框
const handlePackageDialogClosed = () => {
packageFormRef.value?.resetFields()
packageForm.value = {
cost_price_yuan: 0
}
availablePackages.value = []
}
// 关闭套餐列表对话框
const handlePackageListDialogClosed = () => {
currentGrantId.value = 0
currentGrantSeriesId.value = 0
currentGrantPackages.value = []
availablePackages.value = []
}
// 使用表格右键菜单功能
// 下一步
const handleNextStep = async () => {
if (!formRef.value) return
@@ -2483,16 +1993,4 @@
color: var(--el-text-color-primary);
}
.package-list-dialog-content {
.package-list-header {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.amount-value {
font-weight: 600;
color: var(--el-color-warning);
}
}
</style>

View File

@@ -29,6 +29,7 @@
:marginTop="10"
:actions="getActions"
:actionsWidth="150"
height="70vh"
:inlineActionsCount="2"
>
<template #default>
@@ -99,10 +100,6 @@
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:min="packageForm.original_cost_price || 0"
:max="
packageForm.original_cost_price ? packageForm.original_cost_price * 1.5 : undefined
"
:precision="2"
:step="0.01"
:controls="false"
@@ -110,10 +107,8 @@
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
原价: ¥{{ packageForm.original_cost_price.toFixed(2) }}, 最高: ¥{{
(packageForm.original_cost_price * 1.5).toFixed(2)
}}
(50%溢价)
请参考{{ packageForm.package_name || '套餐' }} - 套餐成本价:
¥{{ packageForm.original_cost_price.toFixed(2) }}
</div>
</ElFormItem>
</ElForm>
@@ -132,7 +127,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElCard,
@@ -176,15 +171,42 @@
const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add')
const packageForm = ref<{
type PackageFormState = {
package_id?: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
}>({
suggested_retail_price?: number
}
const createDefaultPackageForm = (): PackageFormState => ({
package_id: undefined,
package_name: undefined,
package_code: undefined,
cost_price_yuan: 0
})
const packageForm = ref<PackageFormState>(createDefaultPackageForm())
const assignPackageForm = (values: Partial<PackageFormState> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values)
}
const resolvePackagePricing = (row?: GrantPackageInfo) => {
const pkgOption = availablePackages.value.find((p) => p.id === row?.package_id)
const currentCostPrice = row?.cost_price !== undefined ? row.cost_price / 100 : 0
return {
original_cost_price:
pkgOption?.cost_price !== undefined && pkgOption?.cost_price !== null
? pkgOption.cost_price / 100
: currentCostPrice,
suggested_retail_price:
pkgOption?.suggested_retail_price !== undefined && pkgOption?.suggested_retail_price !== null
? pkgOption.suggested_retail_price / 100
: undefined
}
}
const packageRules = computed<FormRules>(() => ({
package_id: [
@@ -198,22 +220,6 @@
callback(new Error('请输入成本价'))
} else if (value < 0) {
callback(new Error('成本价不能小于0'))
} else if (
packageForm.value.original_cost_price &&
value < packageForm.value.original_cost_price
) {
callback(
new Error(`成本价不能低于原价 ¥${packageForm.value.original_cost_price.toFixed(2)}`)
)
} else if (
packageForm.value.original_cost_price &&
value > packageForm.value.original_cost_price * 1.5
) {
callback(
new Error(
`成本价不能高于原价的50%溢价(最高:¥${(packageForm.value.original_cost_price * 1.5).toFixed(2)}`
)
)
} else {
callback()
}
@@ -270,28 +276,26 @@
const showAddPackageDialog = () => {
packageDialogType.value = 'add'
packageForm.value = {
package_id: undefined,
cost_price_yuan: 0
}
assignPackageForm()
loadAvailablePackages()
packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
}
const showEditPackageDialog = (row: GrantPackageInfo) => {
packageDialogType.value = 'edit'
const packagePricing = resolvePackagePricing(row)
const pkgOption = availablePackages.value.find((p) => p.id === row.package_id)
const originalCostPrice = pkgOption?.cost_price ? pkgOption.cost_price / 100 : 0
packageForm.value = {
assignPackageForm({
package_id: row.package_id,
package_name: row.package_name,
package_code: row.package_code,
cost_price_yuan: row.cost_price / 100,
original_cost_price: originalCostPrice
}
original_cost_price: packagePricing.original_cost_price,
suggested_retail_price: packagePricing.suggested_retail_price
})
packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
}
const loadAvailablePackages = async (packageName?: string) => {
@@ -330,6 +334,28 @@
return packageList.value.some((p) => p.package_id === packageId)
}
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value === 'add' && packageId) {
const selectedPackage = availablePackages.value.find((p) => p.id === packageId)
if (selectedPackage) {
const originalCostPrice = selectedPackage.cost_price
? selectedPackage.cost_price / 100
: 0
const suggestedRetailPrice =
selectedPackage.suggested_retail_price !== undefined &&
selectedPackage.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.suggested_retail_price = suggestedRetailPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
}
}
)
const handleSavePackage = async () => {
if (!packageFormRef.value || !grantId.value) return
@@ -338,19 +364,16 @@
submitLoading.value = true
try {
const packages: GrantPackageItem[] = []
if (packageDialogType.value === 'add') {
packages.push({
package_id: packageForm.value.package_id,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
})
} else {
packages.push({
package_id: packageForm.value.package_id,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
})
const currentCostPriceYuan = Number(packageForm.value.cost_price_yuan)
if (Number.isNaN(currentCostPriceYuan)) {
throw new Error('当前成本价无效,无法保存')
}
const packages: GrantPackageItem[] = [
{
package_id: packageForm.value.package_id,
cost_price: Math.round(currentCostPriceYuan * 100)
}
]
await ShopSeriesGrantService.manageGrantPackages(grantId.value, { packages })
packageDialogVisible.value = false
@@ -392,10 +415,8 @@
}
const handlePackageDialogClosed = () => {
packageFormRef.value?.resetFields()
packageForm.value = {
cost_price_yuan: 0
}
packageFormRef.value?.clearValidate()
assignPackageForm()
availablePackages.value = []
}