Compare commits

...

34 Commits

Author SHA1 Message Date
sexygoat
a805c27902 修改资产详情
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m22s
2026-03-20 14:18:47 +08:00
sexygoat
62f828f314 修改资产详情
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m35s
2026-03-20 10:26:57 +08:00
sexygoat
f06d8c9133 新增
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m7s
2026-03-19 18:32:02 +08:00
sexygoat
407287f538 删除资产信息的进度条
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 16m32s
2026-03-18 15:33:39 +08:00
sexygoat
9fb3367fd7 完善充值代理
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m6s
2026-03-18 10:47:25 +08:00
sexygoat
ff67681c29 修改布局
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m49s
2026-03-17 14:27:17 +08:00
sexygoat
e975e6af4b 新增: 微信配置-代理充值
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m58s
2026-03-17 14:06:38 +08:00
sexygoat
f4ccf9ed24 修改bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m33s
2026-03-17 09:31:37 +08:00
sexygoat
8f31526499 fix: bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m43s
2026-03-14 15:16:21 +08:00
sexygoat
d43de4cd06 修改bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 8m39s
2026-03-11 17:09:35 +08:00
sexygoat
bd45f7a224 详情添加权限
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 3m35s
2026-03-07 11:59:16 +08:00
sexygoat
e73992d253 弹窗改为跳转链接
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m41s
2026-03-07 11:41:15 +08:00
sexygoat
8fbc321a5e 详情修改
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m48s
2026-03-06 17:51:27 +08:00
sexygoat
1ebc0b8929 新增代理系列授权
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 4m36s
2026-03-06 16:28:58 +08:00
sexygoat
4d94f7efa6 套餐系列:梯度佣金,代理授权
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m47s
2026-03-06 14:00:22 +08:00
sexygoat
08d5043b3f 将单套餐分配和套餐系列分配改成代理系列授权
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m23s
2026-03-04 17:22:47 +08:00
sexygoat
237eeed87a 修复:原来的统一去了404, 现在加了403, 和部分bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m54s
2026-03-03 16:26:04 +08:00
sexygoat
7e9acda1ab 修改订单管理
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m12s
2026-02-28 17:46:42 +08:00
sexygoat
7b459b5c8d 修改订单管理
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m28s
2026-02-28 16:49:28 +08:00
sexygoat
ce1032c7f9 完善按钮和详情权限
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m29s
2026-02-28 11:04:32 +08:00
sexygoat
4470a4ef04 修改: bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m47s
2026-02-27 17:40:02 +08:00
sexygoat
f1cb1e53c8 修改: 权限重复
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m27s
2026-02-26 17:34:11 +08:00
sexygoat
3570b062a1 修改工单: 右键 给角色分配权限
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m45s
2026-02-26 10:06:11 +08:00
sexygoat
dccad819cf 修改工单
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m26s
2026-02-25 16:14:38 +08:00
sexygoat
ca3184857e fetch(modify):账号列表合并
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m31s
2026-02-09 10:49:52 +08:00
sexygoat
e8700c2585 fetch(modify):修改账号列表中的分配角色弹窗
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m33s
2026-02-09 10:22:23 +08:00
sexygoat
b94c043a56 fetch(modify):修改bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m45s
2026-02-05 17:22:41 +08:00
sexygoat
d97dc5f007 fetch(modify):修改套餐接口
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m40s
2026-02-04 18:14:52 +08:00
sexygoat
20e8c13e61 fetch(modify):修改分为元
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m22s
2026-02-03 17:58:58 +08:00
sexygoat
192c6f1d26 fetch(modify):完善按钮权限
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m25s
2026-02-03 17:20:50 +08:00
sexygoat
de9753f42d fetch(modify):修复BUG
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 3m27s
2026-02-03 14:39:45 +08:00
sexygoat
2c6fe4375b fetch(modify):修复API的URL
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m1s
2026-02-03 10:04:59 +08:00
sexygoat
06cde977ad fetch(modify):修复角色分配权限
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m36s
2026-02-02 17:08:49 +08:00
sexygoat
f62437379d fetch(modify):隐藏换卡申请
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m30s
2026-02-02 14:35:33 +08:00
140 changed files with 25610 additions and 13536 deletions

View File

@@ -310,6 +310,12 @@
"whenever": true,
"ElMessage": true,
"ElTag": true,
"ElMessageBox": true
"ElMessageBox": true,
"ElButton": true,
"ElDropdown": true,
"ElButton2": true,
"ElDropdown2": true,
"ElDropdownItem": true,
"ElDropdownMenu": true
}
}

View File

@@ -14,7 +14,9 @@
"Bash(npm run build:*)",
"Bash(tree:*)",
"Bash(npm run dev:*)",
"Bash(timeout:*)"
"Bash(timeout:*)",
"Read(//d/**)",
"Bash(findstr:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,162 @@
# Proposal: Add Button Permissions
## Summary
为系统中的所有页面添加按钮级权限控制,根据用户权限动态显示或隐藏按钮和操作。权限系统已实现(`hasAuth``v-permission` 指令),本提案专注于将权限控制应用到所有相关页面。
## Motivation
当前系统已经实现了基于角色的权限控制(RBAC)但大多数页面的按钮和操作缺少权限控制。这意味着所有用户都能看到所有按钮即使他们没有权限执行相应操作。需要在UI层面根据用户权限隐藏无权限的按钮提升用户体验和系统安全性。
### Problems Solved
1. **安全性提升**: 防止用户看到或尝试执行无权限操作
2. **用户体验优化**: 只显示用户有权限的功能,避免混淆
3. **权限一致性**: 统一所有页面的权限控制实现方式
### Related Work
- 权限系统基础设施已实现 (`useAuth.ts`, `permission.ts`)
- 系统/角色页面 (`/system/role`) 已实现权限控制,作为最佳实践参考
- 权限配置已由后端API提供见文档中的权限JSON结构
## Proposed Solution
### Approach
按业务模块逐步为所有页面添加按钮权限控制,使用已有的权限基础设施:
1. **表格操作按钮**: 使用 `v-permission` 指令
2. **表格内操作**: 使用 `hasAuth()` 函数动态渲染
3. **状态切换控件**: 使用 `disabled: !hasAuth()` 控制禁用状态
### Affected Modules
根据提供的权限JSON需要添加权限控制的模块包括
1. **套餐管理** (`/package-management`)
- 套餐系列 (`package-series`)
- 套餐列表 (`package-list`)
- 单套餐分配 (`package-assign`)
- 套餐系列分配 (`series-assign`)
2. **店铺管理** (`/shop-management`)
- 店铺列表 (`list`)
3. **账号管理** (`/account-management`)
- 账号列表 (`account`)
- 平台账号 (`platform-account`)
- 代理账号 (`shop-account`)
4. **资产管理** (`/asset-management`)
- IoT卡管理 (`iot-card-management`)
- IoT卡任务 (`iot-card-task`)
- 设备任务 (`device-task`)
- 设备管理 (`devices`)
- 授权记录 (`authorization-records`)
5. **账户管理** (`/account`)
- 企业客户 (`enterprise-customer`)
- 运营商管理 (`carrier-management`)
- 订单管理 (`orders`)
- 佣金管理 (`commission/*`)
### Implementation Pattern
基于 `/system/role` 页面的实现,统一使用以下模式:
```vue
<script setup>
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
</script>
<template>
<!-- 1. 表格头部按钮 -->
<template #left>
<ElButton v-permission="'module:add'">新增</ElButton>
</template>
<!-- 2. 表格列中的状态切换 -->
<ElSwitch
:disabled="!hasAuth('module:update_status')"
v-model="row.status"
/>
<!-- 3. 表格列中的操作按钮 -->
<script>
const columns = [{
prop: 'operation',
formatter: (row) => {
const buttons = []
if (hasAuth('module:edit')) {
buttons.push(h(ArtButtonTable, { type: 'edit', onClick: () => edit(row) }))
}
if (hasAuth('module:delete')) {
buttons.push(h(ArtButtonTable, { type: 'delete', onClick: () => del(row) }))
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}]
</script>
</template>
```
### Permission Mapping
权限代码 (`perm_code`) 与操作的映射关系:
- `module:add` -> 新增按钮
- `module:edit` -> 编辑按钮
- `module:delete` -> 删除按钮
- `module:update_status` -> 状态切换
- `module:update_xxx` -> 特定更新操作(如修改密码、修改成本价等)
- `module:xxx` -> 特殊操作(如分配权限、查看客户、卡授权等)
## Impact Analysis
### Benefits
1. **提升安全性**: UI层面防止无权限操作
2. **改善UX**: 用户只看到有权限的功能
3. **统一体验**: 所有页面使用相同的权限控制模式
### Risks
1. **向后兼容性**: 现有页面需要更新,可能影响用户使用习惯
2. **测试覆盖**: 需要测试各种权限组合的显示效果
### Mitigations
1. 分模块逐步rollout优先完成核心模块
2. 权限配置由后端控制,前端只负责显示/隐藏
3. 保持API层面的权限检查前端权限控制仅为UI优化
## Success Criteria
- [ ] 所有模块的按钮根据权限正确显示/隐藏
- [ ] 状态切换控件根据权限正确启用/禁用
- [ ] 表格操作列根据权限动态渲染按钮
- [ ] 权限变更后UI立即响应
- [ ] 无权限用户看不到任何无权限操作
## Dependencies
- 依赖后端 API 返回正确的权限数据
- 依赖 `useUserStore` 中的权限数据管理
- 依赖已实现的 `useAuth``v-permission` 指令
## Timeline
预计需要 5-7 个工作日完成所有模块的权限控制集成。
## References
- 权限文档: `docs/在页面上新增按钮权限.md`
- 参考实现: `src/views/system/role/index.vue`
- 权限指令: `src/directives/permission.ts`
- 权限组合式函数: `src/composables/useAuth.ts`

View File

@@ -0,0 +1,190 @@
# Tasks: Add Button Permissions
## Overview
将按钮权限控制系统地应用到所有相关页面。每个任务包含引入 `useAuth`、添加 `v-permission` 指令,以及在表格操作列中使用 `hasAuth()` 进行权限判断。
## Tasks
### 1. 套餐管理模块 (Package Management) ✅
- [x] **套餐系列页面** (`/package-management/package-series`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增套餐系列"按钮添加 `v-permission="'package_series:add'"`
- ✅ 为状态切换添加 `disabled: !hasAuth('package_series:update_status')`
- ✅ 在操作列中使用 `hasAuth()` 判断编辑权限 (`package_series:edit`)和删除权限 (`package_series:delete`)
- [x] **套餐列表页面** (`/package-management/package-list`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增套餐"按钮添加 `v-permission="'package:add'"`
- ✅ 为上/下架切换添加 `disabled: !hasAuth('package:update_away')`
- ✅ 为状态切换添加 `disabled: !hasAuth('package:update_status')`
- ✅ 在操作列中使用 `hasAuth()` 判断编辑 (`package:edit`) 和删除 (`package:delete`) 权限
- [x] **单套餐分配页面** (`/package-management/package-assign`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增分配"按钮添加 `v-permission="'package_assign:add'"`
- ✅ 为状态切换添加 `disabled: !hasAuth('package_assign:update_status')`
- ✅ 在操作列中使用 `hasAuth()` 判断以下权限:
- 修改成本价: `package_assign:update_cost`
- 编辑: `package_assign:edit`
- 删除: `package_assign:delete`
- [x] **套餐系列分配页面** (`/package-management/series-assign`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增系列分配"按钮添加 `v-permission="'series_assign:add'"`
- ✅ 为状态切换添加 `disabled: !hasAuth('series_assign:update_status')`
- ✅ 在操作列中使用 `hasAuth()` 判断编辑 (`series_assign:edit`) 和删除 (`series_assign:delete`) 权限
### 2. 店铺管理模块 (Shop Management) ✅
- [x] **店铺列表页面** (`/product/shop`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增店铺"按钮添加 `v-permission="'shop:add'"`
- ✅ 在操作列中使用 `hasAuth()` 判断以下权限:
- 查看客户: `shop:look_customer`
- 编辑: `shop:edit`
- 删除: `shop:delete`
- ✅ 在右键菜单中使用 `hasAuth()` 动态生成菜单项
### 3. 账号管理模块 (Account Management) ✅
- [x] **账号列表页面** (`/account-management/account`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增账号"按钮添加 `v-permission="'account:add'"`
- ✅ 在操作列中使用 `hasAuth()` 判断以下权限:
- 分配角色: `account:patch_role`
- 编辑: `account:edit`
- 删除: `account:delete`
- [x] **平台账号页面** (`/account-management/platform-account`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增平台账号"按钮添加 `v-permission="'platform_account:add'"`
- ✅ 在操作列中使用 `hasAuth()` 判断以下权限:
- 分配角色: `platform_account:patch_role`
- 修改密码: `platform_account:update_psd`
- 编辑: `platform_account:edit`
- 删除: `platform_account:delete`
- [x] **代理账号页面** (`/account-management/shop-account`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增代理账号"按钮添加 `v-permission="'shop_account:add'"`
- ✅ 在操作列中使用 `hasAuth()` 判断以下权限:
- 修改密码: `shop_account:update_psd`
- 编辑: `shop_account:edit`
### 4. 资产管理模块 (Asset Management) ✅
- [x] **IoT卡管理页面** (`/asset-management/iot-card-management`)
- ✅ 引入 `useAuth` composable
- ✅ 为表格头部操作按钮添加权限控制:
- 批量分配: `v-permission="'iot_card:batch_allocation'"`
- 批量回收: `v-permission="'iot_card:batch_recycle'"`
- 批量设置套餐系列: `v-permission="'iot_card:batch_setting'"`
- 批量充值: `v-permission="'iot_card:batch_recharge'"`
- ✅ 在操作列中使用 `hasAuth()` 判断以下权限:
- 网卡分销: `iot_card:network_distribution`
- 网卡回收: `iot_card:network_recycle`
- 变更套餐: `iot_card:change_package`
- ✅ 验证权限控制正确工作
- [x] **IoT卡任务页面** (`/asset-management/iot-card-task`)
- ✅ 引入 `useAuth` composable
- ✅ 为"批量导入IoT卡"按钮添加 `v-permission="''"`
- ✅ 验证权限控制正确工作
- [x] **设备任务页面** (`/asset-management/device-task`)
- ✅ 引入 `useAuth` composable
- ✅ 为"批量导入设备"按钮添加 `v-permission="'device_task:bulk_import'"`
- ✅ 验证权限控制正确工作
- [x] **设备管理页面** (`/asset-management/device-list`)
- ✅ 引入 `useAuth` composable
- ✅ 为表格头部操作按钮添加权限控制:
- 批量分配: `v-permission="'devices:batch_allocation'"`
- 批量回收: `v-permission="'devices:batch_recycle'"`
- 批量设置套餐系列: `v-permission="'devices:batch_setting'"`
- ✅ 在操作列中使用 `hasAuth()` 判断以下权限:
- 查看绑定的卡: `devices:look_binding`
- 删除设备: `devices:delete`
- ✅ 验证权限控制正确工作
- [x] **授权记录页面** (`/asset-management/authorization-records`)
- ✅ 引入 `useAuth` composable
- ✅ 在操作列中使用 `hasAuth()` 判断修改备注权限 (`authorization_records:update_remark`)
- ✅ 验证权限控制正确工作
### 5. 账户管理模块 (Account Module) ✅
- [x] **企业客户页面** (`/account/enterprise-customer`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增企业客户"按钮添加 `v-permission="'enterprise_customer:add'"`
- ✅ 在操作列中使用 `hasAuth()` 判断以下权限:
- 编辑: `enterprise_customer:edit`
- 查看客户: `enterprise_customer:look_customer`
- 卡授权: `enterprise_customer:card_authorization`
- 修改密码: `enterprise_customer:update_pwd`
- ✅ 验证权限控制正确工作
- [x] **运营商管理页面** (`/account/carrier-management`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增运营商"按钮添加 `v-permission="'carrier:add'"`
- ✅ 为状态切换添加 `disabled: !hasAuth('carrier:update_status')`
- ✅ 在操作列中使用 `hasAuth()` 判断编辑 (`carrier:edit`) 和删除 (`carrier:delete`) 权限
- ✅ 验证权限控制正确工作
- [x] **订单管理页面** (`/account/orders`)
- ✅ 引入 `useAuth` composable
- ✅ 为"创建订单"按钮添加 `v-permission="'orders:add'"`
- ✅ 验证权限控制正确工作
- [x] **提现配置页面** (`/account/commission/withdrawal-settings`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增配置"按钮添加 `v-permission="'withdrawal_settings:add'"`
- ✅ 验证权限控制正确工作
- [x] **我的佣金页面** (`/account/commission/my-commission`)
- ✅ 引入 `useAuth` composable
- ✅ 为"发起提现"按钮添加 `v-permission="'my_commission:add'"`
- ✅ 验证权限控制正确工作
- [x] **代理商佣金管理页面** (`/account/commission/agent-commission`)
- ✅ 引入 `useAuth` composable
- ✅ 在操作列中使用 `hasAuth()` 判断查看详情权限 (`agent_commission:detail`)
- ✅ 验证权限控制正确工作
### 6. 系统管理模块 (System Management) ✅
- [x] **权限管理页面** (`/system/permission`)
- ✅ 引入 `useAuth` composable
- ✅ 为"新增权限"按钮添加 `v-permission="'permission:add'"`
- ✅ 在操作列中使用 `hasAuth()` 判断编辑 (`permission:edit`) 和删除 (`permission:delete`) 权限
- ✅ 验证权限控制正确工作
### 7. 测试与文档
- [ ] **权限控制测试**
- 创建不同权限组合的测试用户
- 验证每个页面的按钮显示/隐藏符合预期
- 验证状态切换的启用/禁用符合预期
- 测试权限动态变更后UI的响应
- [ ] **文档更新**
- 更新开发文档,说明权限控制的实现方式
- 添加权限代码与功能的映射表
- 提供新页面添加权限控制的指南
## Validation
每个任务完成后需验证:
1. **功能正确性**: 有权限时按钮/操作可见且可用
2. **权限隔离**: 无权限时按钮/操作不可见或禁用
3. **用户体验**: 权限控制不影响正常操作流程
4. **性能**: 权限检查不影响页面渲染性能
## Dependencies
- 所有任务依赖权限基础设施 (`useAuth`, `v-permission`)
- 任务可并行执行,建议按模块分批进行
- 优先级: 核心模块 (账号、套餐、资产) > 其他模块

View File

@@ -0,0 +1,56 @@
# Change: Add IoT Card and Device Operations
## Why
运营人员和代理商需要更丰富的物联网卡和设备管理功能包括查询流量使用情况、实名认证状态、卡片状态、启停操作以及设备的重启、重置、限速、换卡、WiFi设置等操作。这些功能是物联网卡全生命周期管理的核心能力。
## What Changes
### IoT Card Operations (6 new APIs)
- 查询流量使用情况 (GET /api/admin/iot-cards/{iccid}/gateway-flow)
- 查询实名认证状态 (GET /api/admin/iot-cards/{iccid}/gateway-realname)
- 查询卡片状态 (GET /api/admin/iot-cards/{iccid}/gateway-status)
- 获取实名认证链接 (GET /api/admin/iot-cards/{iccid}/realname-link)
- 启用物联网卡 (POST /api/admin/iot-cards/{iccid}/start)
- 停用物联网卡 (POST /api/admin/iot-cards/{iccid}/stop)
### Device Operations (6 new APIs)
- 重启设备 (POST /api/admin/devices/by-imei/{imei}/reboot)
- 重置设备 (POST /api/admin/devices/by-imei/{imei}/reset)
- 设置限速 (PUT /api/admin/devices/by-imei/{imei}/speed-limit)
- 切换SIM卡 (POST /api/admin/devices/by-imei/{imei}/switch-card)
- 设置WiFi (PUT /api/admin/devices/by-imei/{imei}/wifi)
### UI Changes
-`/asset-management/iot-card-management` 页面添加操作按钮:
- "查询流量"按钮(主要操作)
- "更多操作"下拉菜单包含其他5个操作
-`/asset-management/devices` 页面添加操作按钮:
- "重启设备"按钮(主要操作)
- "更多操作"下拉菜单包含其他5个操作
## Impact
### Affected Specs
- **NEW**: `specs/iot-card-operations/spec.md` - IoT卡操作相关的所有需求
- **NEW**: `specs/device-operations/spec.md` - 设备操作相关的所有需求
### Affected Code
- **API层**:
- 新增 `src/api/modules/iotCard.ts` - IoT卡操作API方法
- 新增 `src/api/modules/device.ts` - 设备操作API方法
- **类型定义**:
- 新增 `src/types/api/iotCard.ts` - IoT卡相关类型
- 新增 `src/types/api/device.ts` - 设备相关类型
- **页面组件**:
- 修改 `src/views/asset-management/iot-card-management/index.vue` - 添加操作按钮和对话框
- 修改 `src/views/asset-management/devices/index.vue` - 添加操作按钮和对话框
### Breaking Changes
无破坏性变更。所有变更都是增量式的新功能添加。
## Dependencies
- 后端API已实现`docs/2-3新增接口.md`
- Element Plus UI组件库已在项目中
- Axios HTTP客户端已在项目中

View File

@@ -0,0 +1,194 @@
# Device Operations Specification
## ADDED Requirements
### Requirement: Reboot Device
The system SHALL provide the ability to reboot an IoT device via its IMEI.
#### Scenario: Successfully reboot device
- **WHEN** an authenticated user initiates a reboot operation on a device with valid IMEI
- **THEN** the system sends the reboot command to the device
- **AND** returns a success response with operation confirmation
#### Scenario: Reboot operation with confirmation
- **WHEN** a user clicks the reboot device button
- **THEN** the system prompts for confirmation with a warning message
- **AND** only proceeds if the user confirms the action
#### Scenario: Reboot with invalid IMEI
- **WHEN** a user attempts to reboot a device with invalid or non-existent IMEI
- **THEN** the system returns a 400 error with appropriate error message
### Requirement: Reset Device to Factory Settings
The system SHALL provide the ability to reset an IoT device to factory settings via its IMEI.
#### Scenario: Successfully reset device
- **WHEN** an authenticated user initiates a factory reset operation on a device
- **THEN** the system sends the reset command to the device
- **AND** returns a success response
#### Scenario: Reset operation with strong confirmation
- **WHEN** a user clicks the factory reset button
- **THEN** the system displays a warning dialog explaining data loss
- **AND** requires user confirmation before proceeding
- **AND** only executes if the user explicitly confirms
#### Scenario: Insufficient permissions for reset
- **WHEN** a user without proper permissions attempts to reset a device
- **THEN** the system returns a 403 forbidden error
### Requirement: Set Device Speed Limit
The system SHALL provide the ability to configure upload and download speed limits for an IoT device.
#### Scenario: Successfully set speed limit
- **WHEN** an authenticated user submits valid speed limit parameters
- **THEN** the system applies the upload and download speed limits to the device
- **AND** returns a success response
#### Scenario: Configure speed limits via dialog
- **WHEN** a user selects the speed limit option
- **THEN** the system displays a dialog with input fields for upload_speed and download_speed (KB/s)
- **AND** validates that both values are integers >= 1
#### Scenario: Invalid speed limit parameters
- **WHEN** a user submits speed limits less than 1 KB/s
- **THEN** the system returns a 400 parameter error
- **AND** displays validation error messages
### Requirement: Switch Device SIM Card
The system SHALL provide the ability to switch the active SIM card on a multi-SIM device.
#### Scenario: Successfully switch to target card
- **WHEN** an authenticated user initiates a card switch with valid target ICCID
- **THEN** the system switches the device to use the specified card
- **AND** returns a success response
#### Scenario: Switch card via dialog
- **WHEN** a user selects the switch card option
- **THEN** the system displays a dialog prompting for target_iccid
- **AND** validates the ICCID format
#### Scenario: Switch to non-existent card
- **WHEN** a user attempts to switch to an ICCID that doesn't exist or isn't available
- **THEN** the system returns a 400 error with descriptive message
### Requirement: Configure Device WiFi Settings
The system SHALL provide the ability to configure WiFi settings including SSID, password, and enabled status.
#### Scenario: Successfully configure WiFi
- **WHEN** an authenticated user submits valid WiFi configuration
- **THEN** the system applies the WiFi settings to the device
- **AND** returns a success response
#### Scenario: Configure WiFi via dialog
- **WHEN** a user selects the WiFi configuration option
- **THEN** the system displays a dialog with fields for:
- enabled (启用状态: 0=禁用, 1=启用)
- ssid (WiFi名称, 1-32 characters)
- password (WiFi密码, 8-63 characters)
- **AND** validates all field constraints
#### Scenario: Invalid WiFi parameters
- **WHEN** a user submits WiFi configuration with invalid parameters
- **THEN** the system returns a 400 error
- **AND** displays specific validation errors (e.g., "SSID too long", "Password too short")
### Requirement: Device Management UI Integration
The system SHALL integrate device operations into the device management page at `/asset-management/devices`.
#### Scenario: Display primary operation button
- **WHEN** a user views the device management page
- **THEN** the system displays a "重启设备" (Reboot Device) button as the primary operation
#### Scenario: Display dropdown menu for additional operations
- **WHEN** a user views the device management page
- **THEN** the system displays a "更多操作" (More Operations) dropdown menu
- **AND** the dropdown contains: 恢复出厂, 设置限速, 切换SIM卡, 设置WiFi
#### Scenario: Show loading indicator during operations
- **WHEN** a device operation is in progress
- **THEN** the system displays a loading indicator
- **AND** disables the operation buttons to prevent duplicate requests
### Requirement: Authentication and Authorization
The system SHALL enforce JWT-based authentication for all device operations.
#### Scenario: Access with valid JWT token
- **WHEN** a user makes a request with a valid Bearer token
- **THEN** the system processes the request normally
#### Scenario: Access with expired token
- **WHEN** a user makes a request with an expired JWT token
- **THEN** the system returns a 401 unauthorized error
- **AND** redirects to the login page
### Requirement: Error Handling and User Feedback
The system SHALL provide clear error messages and success notifications for all operations.
#### Scenario: Display success message
- **WHEN** a device operation completes successfully
- **THEN** the system displays a success message notification
- **AND** automatically closes the operation dialog
#### Scenario: Handle network errors
- **WHEN** a network error occurs during a device operation
- **THEN** the system displays a user-friendly error message
- **AND** allows the user to retry the operation
#### Scenario: Handle server errors
- **WHEN** a 500 server error occurs
- **THEN** the system displays an error message with timestamp
- **AND** logs the error for debugging
### Requirement: Operation Confirmation Dialogs
The system SHALL require user confirmation for destructive operations.
#### Scenario: Confirm reboot operation
- **WHEN** a user initiates a reboot
- **THEN** the system shows a confirmation dialog stating "确定要重启该设备吗?"
- **AND** requires explicit confirmation
#### Scenario: Confirm factory reset operation
- **WHEN** a user initiates a factory reset
- **THEN** the system shows a strong warning dialog stating "确定要恢复出厂设置吗?此操作将清除所有数据!"
- **AND** requires explicit confirmation
#### Scenario: No confirmation for query operations
- **WHEN** a user initiates speed limit, card switch, or WiFi configuration
- **THEN** the system displays input dialogs without confirmation prompts
- **AND** only executes after user submits valid parameters

View File

@@ -0,0 +1,151 @@
# IoT Card Operations Specification
## ADDED Requirements
### Requirement: Query IoT Card Flow Usage
The system SHALL provide the ability to query real-time flow usage for an IoT card via its ICCID.
#### Scenario: Successfully query flow usage
- **WHEN** an authenticated user requests flow usage for a valid ICCID
- **THEN** the system returns flow usage data including used flow amount and unit
#### Scenario: Query with invalid ICCID
- **WHEN** a user requests flow usage for an invalid or non-existent ICCID
- **THEN** the system returns a 400 error with appropriate error message
### Requirement: Query IoT Card Realname Status
The system SHALL provide the ability to query the realname authentication status for an IoT card via its ICCID.
#### Scenario: Successfully query realname status
- **WHEN** an authenticated user requests realname status for a valid ICCID
- **THEN** the system returns the current realname authentication status
#### Scenario: Query status for unauthenticated user
- **WHEN** an unauthenticated user attempts to query realname status
- **THEN** the system returns a 401 unauthorized error
### Requirement: Query IoT Card Real-time Status
The system SHALL provide the ability to query the real-time operational status of an IoT card via its ICCID.
#### Scenario: Successfully query card status
- **WHEN** an authenticated user requests card status for a valid ICCID
- **THEN** the system returns card status information including ICCID and current status (准备/正常/停机)
#### Scenario: Query with unauthorized access
- **WHEN** a user without proper permissions attempts to query card status
- **THEN** the system returns a 403 forbidden error
### Requirement: Get IoT Card Realname Link
The system SHALL provide the ability to generate and retrieve a realname authentication link for an IoT card.
#### Scenario: Successfully retrieve realname link
- **WHEN** an authenticated user requests the realname link for a valid ICCID
- **THEN** the system returns an HTTPS URL that can be used for realname authentication
#### Scenario: Display realname link as QR code
- **WHEN** the system returns a realname authentication link
- **THEN** the UI displays the link as a QR code for easy mobile scanning
### Requirement: Start IoT Card (复机)
The system SHALL provide the ability to start (restore service) an IoT card via its ICCID.
#### Scenario: Successfully start a stopped card
- **WHEN** an authenticated user initiates a start operation on a stopped card
- **THEN** the system processes the request and restores card service
- **AND** displays a success message to the user
#### Scenario: Start operation with confirmation
- **WHEN** a user clicks the start card button
- **THEN** the system prompts for confirmation before executing the operation
- **AND** only proceeds if the user confirms the action
#### Scenario: Insufficient permissions for start operation
- **WHEN** a user without proper permissions attempts to start a card
- **THEN** the system returns a 403 forbidden error
### Requirement: Stop IoT Card (停机)
The system SHALL provide the ability to stop (suspend service) an IoT card via its ICCID.
#### Scenario: Successfully stop an active card
- **WHEN** an authenticated user initiates a stop operation on an active card
- **THEN** the system processes the request and suspends card service
- **AND** displays a success message to the user
#### Scenario: Stop operation with confirmation
- **WHEN** a user clicks the stop card button
- **THEN** the system prompts for confirmation before executing the operation
- **AND** only proceeds if the user confirms the action
#### Scenario: Server error during stop operation
- **WHEN** a server error occurs during the stop operation
- **THEN** the system returns a 500 error with error details
- **AND** displays an appropriate error message to the user
### Requirement: IoT Card Management UI Integration
The system SHALL integrate IoT card operations into the card management page at `/asset-management/iot-card-management`.
#### Scenario: Display primary operation button
- **WHEN** a user views the IoT card management page
- **THEN** the system displays a "查询流量使用" (Query Flow Usage) button as the primary operation
#### Scenario: Display dropdown menu for additional operations
- **WHEN** a user views the IoT card management page
- **THEN** the system displays a "更多操作" (More Operations) dropdown menu
- **AND** the dropdown contains: 查询实名状态, 查询卡状态, 获取实名链接, 启用卡片, 停用卡片
#### Scenario: Show operation results in dialog
- **WHEN** a user executes a query operation (flow, realname status, or card status)
- **THEN** the system displays results in a modal dialog with properly formatted data
### Requirement: Authentication and Authorization
The system SHALL enforce JWT-based authentication for all IoT card operations.
#### Scenario: Access with valid JWT token
- **WHEN** a user makes a request with a valid Bearer token
- **THEN** the system processes the request normally
#### Scenario: Access with expired token
- **WHEN** a user makes a request with an expired JWT token
- **THEN** the system returns a 401 unauthorized error
### Requirement: Error Handling
The system SHALL provide clear error messages for all failure scenarios.
#### Scenario: Handle 400 parameter errors
- **WHEN** a request contains invalid parameters
- **THEN** the system returns a 400 error with specific validation failure details
#### Scenario: Handle 500 server errors
- **WHEN** an internal server error occurs
- **THEN** the system returns a 500 error with error timestamp
- **AND** logs the error for debugging purposes

View File

@@ -0,0 +1,86 @@
# Implementation Tasks
## 1. API Layer - IoT Card Operations
- [ ] 1.1 创建 `src/api/modules/iotCard.ts`
- [ ] 1.2 实现 `getGatewayFlow(iccid)` - 查询流量使用
- [ ] 1.3 实现 `getGatewayRealname(iccid)` - 查询实名状态
- [ ] 1.4 实现 `getGatewayStatus(iccid)` - 查询卡片状态
- [ ] 1.5 实现 `getRealnameLink(iccid)` - 获取实名链接
- [ ] 1.6 实现 `startCard(iccid)` - 启用卡片
- [ ] 1.7 实现 `stopCard(iccid)` - 停用卡片
## 2. API Layer - Device Operations
- [ ] 2.1 创建 `src/api/modules/device.ts`
- [ ] 2.2 实现 `rebootDevice(imei)` - 重启设备
- [ ] 2.3 实现 `resetDevice(imei)` - 重置设备
- [ ] 2.4 实现 `setSpeedLimit(imei, params)` - 设置限速
- [ ] 2.5 实现 `switchCard(imei, params)` - 切换SIM卡
- [ ] 2.6 实现 `setWifi(imei, params)` - 设置WiFi
## 3. Type Definitions - IoT Card
- [ ] 3.1 创建 `src/types/api/iotCard.ts`
- [ ] 3.2 定义流量使用响应类型 `GatewayFlowResponse`
- [ ] 3.3 定义实名状态响应类型 `GatewayRealnameResponse`
- [ ] 3.4 定义卡片状态响应类型 `GatewayStatusResponse`
- [ ] 3.5 定义实名链接响应类型 `RealnameUrlResponse`
- [ ] 3.6 定义启停操作请求类型 `StartStopCardRequest`
## 4. Type Definitions - Device
- [ ] 4.1 创建 `src/types/api/device.ts`
- [ ] 4.2 定义限速参数类型 `SpeedLimitParams`
- [ ] 4.3 定义换卡参数类型 `SwitchCardParams`
- [ ] 4.4 定义WiFi参数类型 `WifiParams`
- [ ] 4.5 定义操作响应类型 `DeviceOperationResponse`
## 5. UI - IoT Card Management Page
- [ ] 5.1 在表格操作列添加"查询流量"按钮
- [ ] 5.2 在表格操作列添加"更多操作"下拉菜单
- [ ] 5.3 创建"流量使用查询"对话框组件
- [ ] 5.4 创建"实名状态查询"对话框组件
- [ ] 5.5 创建"卡片状态查询"对话框组件
- [ ] 5.6 创建"获取实名链接"对话框组件(显示二维码)
- [ ] 5.7 实现"启用卡片"操作(带确认提示)
- [ ] 5.8 实现"停用卡片"操作(带确认提示)
## 6. UI - Device Management Page
- [ ] 6.1 在表格操作列添加"重启设备"按钮
- [ ] 6.2 在表格操作列添加"更多操作"下拉菜单
- [ ] 6.3 实现"重启设备"操作(带确认提示)
- [ ] 6.4 实现"重置设备"操作(带确认提示)
- [ ] 6.5 创建"设置限速"对话框组件(包含上下行速率输入)
- [ ] 6.6 创建"切换SIM卡"对话框组件(选择卡槽)
- [ ] 6.7 创建"设置WiFi"对话框组件SSID、密码、频段等
## 7. Error Handling & User Feedback
- [ ] 7.1 为所有API调用添加错误处理
- [ ] 7.2 添加操作成功提示消息
- [ ] 7.3 添加操作失败错误提示
- [ ] 7.4 添加加载状态指示器
## 8. Permission Control
- [ ] 8.1 检查IoT卡操作权限配置
- [ ] 8.2 检查设备操作权限配置
- [ ] 8.3 根据权限显示/隐藏操作按钮
## 9. Testing & Validation
- [ ] 9.1 测试所有IoT卡操作API调用
- [ ] 9.2 测试所有设备操作API调用
- [ ] 9.3 测试UI交互和对话框显示
- [ ] 9.4 测试错误处理场景
- [ ] 9.5 测试权限控制
- [ ] 9.6 验证响应数据格式和显示
## 10. Documentation
- [ ] 10.1 更新API文档如有需要
- [ ] 10.2 添加操作说明注释
- [ ] 10.3 更新用户手册(如有需要)

View File

@@ -0,0 +1,22 @@
# Change: Add Shop Default Roles Management
## Why
店铺管理模块需要支持为每个店铺配置默认角色的功能。当新账号加入店铺时,系统需要自动分配这些默认角色,以简化账号管理流程并确保权限一致性。
## What Changes
- 新增查询店铺默认角色列表的接口和UI
- 新增为店铺分配默认角色的接口和UI
- 新增删除店铺默认角色的接口和UI
- 在店铺管理列表页(`/shop-management/list`)添加"设置默认角色"操作入口
- 添加店铺默认角色管理对话框组件
## Impact
- 影响的规范: shop-management (新增)
- 影响的代码:
- `src/api/modules/shop.ts` - 新增3个API方法
- `src/types/api/shop.ts` - 新增类型定义
- `src/views/shop-management/list/index.vue` - 添加默认角色管理UI (假设该页面存在,如不存在需创建)
- 依赖的现有功能: 角色管理系统

View File

@@ -0,0 +1,172 @@
# Shop Management - Specification Delta
## ADDED Requirements
### Requirement: Query Shop Default Roles
系统SHALL支持查询指定店铺的默认角色列表。
**API端点**: `GET /api/admin/shops/{shop_id}/roles`
**路径参数**:
- `shop_id` (integer, required): 店铺ID
**响应数据**:
```typescript
{
shop_id: number // 店铺ID
roles: ShopRole[] | null // 角色列表
}
interface ShopRole {
role_id: number // 角色ID
role_name: string // 角色名称
role_desc: string // 角色描述
shop_id: number // 店铺ID
status: number // 状态 (0:禁用, 1:启用)
}
```
#### Scenario: 成功查询店铺默认角色
- **WHEN** 用户请求获取店铺ID为123的默认角色列表
- **THEN** 系统返回该店铺配置的所有默认角色
- **AND** 响应包含角色ID、名称、描述、状态等完整信息
#### Scenario: 查询不存在的店铺
- **WHEN** 用户请求获取不存在的店铺的默认角色
- **THEN** 系统返回400错误
- **AND** 错误消息说明店铺不存在
#### Scenario: 店铺没有配置默认角色
- **WHEN** 用户请求获取未配置默认角色的店铺
- **THEN** 系统返回成功响应
- **AND** `roles` 字段为空数组或null
---
### Requirement: Assign Shop Default Roles
系统SHALL支持为指定店铺分配一个或多个默认角色。
**API端点**: `POST /api/admin/shops/{shop_id}/roles`
**路径参数**:
- `shop_id` (integer, required): 店铺ID
**请求体**:
```typescript
{
role_ids: number[] | null // 角色ID列表
}
```
**响应数据**: 与"Query Shop Default Roles"相同的数据结构
#### Scenario: 成功分配单个默认角色
- **WHEN** 用户为店铺分配一个新的默认角色
- **THEN** 系统成功保存该角色配置
- **AND** 返回更新后的店铺默认角色列表
- **AND** 新分配的角色出现在列表中
#### Scenario: 成功分配多个默认角色
- **WHEN** 用户为店铺分配多个默认角色
- **THEN** 系统批量保存所有角色配置
- **AND** 返回完整的默认角色列表
- **AND** 所有新分配的角色都出现在列表中
#### Scenario: 分配已存在的角色
- **WHEN** 用户尝试分配店铺已配置的默认角色
- **THEN** 系统应当优雅处理(不重复添加或返回明确提示)
- **AND** 不影响其他正常角色的分配
#### Scenario: 分配不存在的角色
- **WHEN** 用户尝试分配不存在的角色ID
- **THEN** 系统返回400错误
- **AND** 错误消息说明哪些角色ID无效
#### Scenario: 空角色列表
- **WHEN** 用户提交空的角色ID列表
- **THEN** 系统接受请求
- **AND** 可能清空当前默认角色或保持不变(取决于业务需求)
---
### Requirement: Delete Shop Default Role
系统SHALL支持删除指定店铺的某个默认角色配置。
**API端点**: `DELETE /api/admin/shops/{shop_id}/roles/{role_id}`
**路径参数**:
- `shop_id` (integer, required): 店铺ID
- `role_id` (integer, required): 角色ID
**响应**: 标准成功/错误响应
#### Scenario: 成功删除默认角色
- **WHEN** 用户删除店铺的一个默认角色配置
- **THEN** 系统成功移除该角色配置
- **AND** 返回成功响应(code: 0)
- **AND** 后续查询该店铺默认角色列表时不再包含该角色
#### Scenario: 删除不存在的角色配置
- **WHEN** 用户尝试删除店铺未配置的角色
- **THEN** 系统返回400错误
- **AND** 错误消息说明该角色不在店铺的默认角色列表中
#### Scenario: 删除不存在的店铺或角色
- **WHEN** 用户使用无效的shop_id或role_id
- **THEN** 系统返回400错误
- **AND** 错误消息说明参数无效
---
### Requirement: Shop Default Roles UI
店铺管理页面SHALL提供默认角色管理的用户界面。
#### Scenario: 在店铺列表中访问默认角色设置
- **WHEN** 用户在店铺管理列表页查看店铺列表
- **THEN** 每个店铺的操作列应当包含"设置默认角色"按钮或菜单项
- **AND** 点击后打开默认角色管理对话框
#### Scenario: 查看店铺当前默认角色
- **WHEN** 用户打开某店铺的默认角色管理对话框
- **THEN** 对话框显示该店铺当前已配置的所有默认角色
- **AND** 每个角色显示名称、描述和状态
- **AND** 提供删除按钮用于移除默认角色
#### Scenario: 添加默认角色
- **WHEN** 用户在对话框中选择要添加的角色
- **THEN** 系统提供角色选择器,展示可用的系统角色列表
- **AND** 支持多选
- **AND** 点击确认后调用分配接口
- **AND** 成功后刷新默认角色列表
#### Scenario: 删除默认角色
- **WHEN** 用户点击某个默认角色的删除按钮
- **THEN** 系统显示确认对话框
- **AND** 用户确认后调用删除接口
- **AND** 成功后从列表中移除该角色
#### Scenario: 加载状态和错误处理
- **WHEN** 进行任何API操作时
- **THEN** 界面应当显示加载状态(loading indicator)
- **AND** 如果操作失败,应当显示友好的错误消息
- **AND** 允许用户重试或关闭对话框

View File

@@ -0,0 +1,62 @@
# Implementation Tasks
## 1. 类型定义
- [x] 1.1 在 `src/types/api/shop.ts` 中添加 `ShopRoleResponse` 接口
- [x] 1.2 在 `src/types/api/shop.ts` 中添加 `ShopRolesResponse` 接口
- [x] 1.3 在 `src/types/api/shop.ts` 中添加 `AssignShopRolesRequest` 接口
## 2. API 服务层
- [x] 2.1 在 `ShopService` 中添加 `getShopRoles(shopId)` 方法
- [x] 2.2 在 `ShopService` 中添加 `assignShopRoles(shopId, roleIds)` 方法
- [x] 2.3 在 `ShopService` 中添加 `deleteShopRole(shopId, roleId)` 方法
## 3. UI 组件开发
- [x] 3.1 检查店铺管理列表页面 (`src/views/product/shop/index.vue` 已存在)
- [x] 3.2 在店铺列表操作列中添加"默认角色"按钮
- [x] 3.3 创建店铺默认角色管理对话框组件
- [x] 3.4 实现查询店铺默认角色列表功能
- [x] 3.5 实现添加默认角色功能(支持多选角色)
- [x] 3.6 实现删除默认角色功能
- [x] 3.7 添加角色选择器(从系统角色列表中选择)
- [x] 3.8 添加加载状态和错误处理
## 4. 验证和测试
- [ ] 4.1 测试查询店铺默认角色列表
- [ ] 4.2 测试添加默认角色(单个和多个)
- [ ] 4.3 测试删除默认角色
- [ ] 4.4 测试边界情况(角色已存在、店铺不存在等)
- [ ] 4.5 测试权限控制(只有有权限的用户才能操作)
## 实现说明
### 完成的功能
1. **类型定义** - 已在 `src/types/api/shop.ts` 添加了三个接口:
- `ShopRoleResponse`: 店铺角色响应实体
- `ShopRolesResponse`: 店铺角色列表响应
- `AssignShopRolesRequest`: 分配角色请求
2. **API 服务层** - 已在 `src/api/modules/shop.ts``ShopService` 类中添加:
- `getShopRoles(shopId)`: 获取店铺默认角色列表
- `assignShopRoles(shopId, data)`: 分配店铺默认角色
- `deleteShopRole(shopId, roleId)`: 删除店铺默认角色
3. **UI 实现** - 已在 `src/views/product/shop/index.vue` 中实现:
- 操作列新增"默认角色"按钮 (宽度从220改为280)
- 默认角色管理对话框,显示当前默认角色列表,支持删除操作
- 添加角色对话框,支持多选角色,已分配的角色显示为禁用状态
- 完整的加载状态(loading indicators)
- 错误处理和友好的提示消息
- 使用 `ShopService``RoleService` 调用后端API
### 待测试项
所有开发任务已完成,现在需要进行功能测试以确保:
- API 调用正常工作
- UI 交互符合预期
- 边界情况处理正确
- 权限控制生效

View File

@@ -0,0 +1,32 @@
# Change: 重命名 API 字段 series_allocation_id 为 series_id
## Why
后端 API 字段命名不一致。前端表单使用 `series_id`套餐系列ID但 API 参数仍使用 `series_allocation_id`套餐系列分配ID。这导致命名混淆且不符合实际业务含义现在直接指向套餐系列 ID而非分配记录 ID
## What Changes
- **BREAKING**: 重命名 4 个 API 端点的请求/响应字段
- `PATCH /api/admin/iot-cards/series-binding` - 请求参数 `series_allocation_id``series_id`
- `PATCH /api/admin/devices/series-binding` - 请求参数 `series_allocation_id``series_id`
- `GET /api/admin/iot-cards/standalone` - 查询参数和响应字段 `series_allocation_id``series_id`
- `GET /api/admin/devices` - 查询参数和响应字段 `series_allocation_id``series_id`
- 前端同步修改:
- TypeScript 类型定义
- API 方法参数
- 页面组件调用代码
- 移除临时注释
## Impact
- **Affected specs**: iot-card-api, device-api
- **Affected code**:
- `src/types/api/device.ts` (BatchSetDeviceSeriesBindingRequest)
- `src/types/api/card.ts` (BatchSetCardSeriesBindingRequest)
- `src/api/modules/device.ts` (batchSetDeviceSeriesBinding)
- `src/api/modules/card.ts` (batchSetCardSeriesBinding)
- `src/views/asset-management/device-list/index.vue`
- `src/views/asset-management/iot-card-management/index.vue`
- **Breaking change**: 需要后端先部署更新,前端再部署
- **Migration**: 更新所有使用这些字段的 API 调用

View File

@@ -0,0 +1,100 @@
# Device API Specification Delta
## ADDED Requirements
### Requirement: Device Series ID Query Parameter
The system SHALL support filtering devices by package series ID in query parameters.
**Query Parameter**: `series_id` (number, optional)
#### Scenario: Filter devices by series
- **WHEN** admin queries devices with series_id parameter
- **THEN** system returns only devices associated with that package series
- **AND** returns empty list if no matches found
#### Scenario: Query without series filter
- **WHEN** admin queries devices without series_id parameter
- **THEN** system returns all devices matching other filter criteria
- **AND** series association is not considered
### Requirement: Device Series ID Response Field
The system SHALL include package series ID in device response data.
**Response Field**: `series_id` (number | null)
- Value is package series ID if device is bound to a series
- Value is null if device has no series binding
#### Scenario: Display series association
- **WHEN** system returns device in list response
- **THEN** each device includes series_id field
- **AND** field shows current series binding or null
#### Scenario: Null series binding
- **WHEN** device has no series binding
- **THEN** series_id field is null
- **AND** device can still be displayed in results
## RENAMED Requirements
### API Field Renaming
- FROM: `series_allocation_id` (in batch series binding endpoints)
- TO: `series_id`
**Reason**: Field name `series_allocation_id` implied a reference to an allocation record ID, but it actually stores the package series ID directly. Renaming to `series_id` clarifies this relationship and aligns with frontend naming conventions.
## MODIFIED Requirements
### Requirement: Batch Set Device Series Binding
The system SHALL provide an endpoint to batch set package series binding for devices.
**Endpoint**: `PATCH /api/admin/devices/series-binding`
**Request Body**:
```json
{
"device_ids": [1, 2, 3],
"series_id": 123 // Changed from series_allocation_id
}
```
**Field Definitions**:
- `device_ids`: Array of device IDs (max 500 items)
- `series_id`: Package series ID (tb_package_series.id), 0 means clear association
**Response**:
```json
{
"code": 0,
"data": {
"success_count": 3,
"fail_count": 0,
"failed_items": null
}
}
```
#### Scenario: Successful batch series binding
- **WHEN** admin calls batch series binding API with valid device IDs and series_id
- **THEN** system updates series association for all valid devices
- **AND** returns success count and failure details
#### Scenario: Clear series association
- **WHEN** admin calls batch series binding API with series_id = 0
- **THEN** system clears series association for specified devices
- **AND** returns success confirmation
#### Scenario: Partial failure handling
- **WHEN** some devices in the batch cannot be updated
- **THEN** system processes valid devices successfully
- **AND** returns failed_items list with reasons for failures

View File

@@ -0,0 +1,100 @@
# IoT Card API Specification Delta
## ADDED Requirements
### Requirement: IoT Card Series ID Query Parameter
The system SHALL support filtering standalone IoT cards by package series ID in query parameters.
**Query Parameter**: `series_id` (number, optional)
#### Scenario: Filter cards by series
- **WHEN** admin queries standalone cards with series_id parameter
- **THEN** system returns only cards associated with that package series
- **AND** returns empty list if no matches found
#### Scenario: Query without series filter
- **WHEN** admin queries cards without series_id parameter
- **THEN** system returns all cards matching other filter criteria
- **AND** series association is not considered
### Requirement: IoT Card Series ID Response Field
The system SHALL include package series ID in standalone IoT card response data.
**Response Field**: `series_id` (number | null)
- Value is package series ID if card is bound to a series
- Value is null if card has no series binding
#### Scenario: Display series association
- **WHEN** system returns IoT card in list response
- **THEN** each card includes series_id field
- **AND** field shows current series binding or null
#### Scenario: Null series binding
- **WHEN** card has no series binding
- **THEN** series_id field is null
- **AND** card can still be displayed in results
## RENAMED Requirements
### API Field Renaming
- FROM: `series_allocation_id` (in batch series binding endpoints)
- TO: `series_id`
**Reason**: Field name `series_allocation_id` implied a reference to an allocation record ID, but it actually stores the package series ID directly. Renaming to `series_id` clarifies this relationship and aligns with frontend naming conventions.
## MODIFIED Requirements
### Requirement: Batch Set Card Series Binding
The system SHALL provide an endpoint to batch set package series binding for IoT cards.
**Endpoint**: `PATCH /api/admin/iot-cards/series-binding`
**Request Body**:
```json
{
"iccids": ["898600..."],
"series_id": 123 // Changed from series_allocation_id
}
```
**Field Definitions**:
- `iccids`: Array of ICCIDs (max 500 items)
- `series_id`: Package series ID (tb_package_series.id), 0 means clear association
**Response**:
```json
{
"code": 0,
"data": {
"success_count": 100,
"fail_count": 0,
"failed_items": null
}
}
```
#### Scenario: Successful batch series binding
- **WHEN** admin calls batch series binding API with valid ICCIDs and series_id
- **THEN** system updates series association for all valid cards
- **AND** returns success count and failure details
#### Scenario: Clear series association
- **WHEN** admin calls batch series binding API with series_id = 0
- **THEN** system clears series association for specified cards
- **AND** returns success confirmation
#### Scenario: Partial failure handling
- **WHEN** some cards in the batch cannot be updated
- **THEN** system processes valid cards successfully
- **AND** returns failed_items list with reasons for failures

View File

@@ -0,0 +1,34 @@
# Implementation Tasks
## 1. 类型定义更新
### 1.1 批量设置接口参数重命名
- [x] 1.1.1 更新 `src/types/api/device.ts` 中 BatchSetDeviceSeriesBindingRequest 接口,将 `series_allocation_id` 改为 `series_id`
- [x] 1.1.2 更新 `src/types/api/card.ts` 中 BatchSetCardSeriesBindingRequest 接口,将 `series_allocation_id` 改为 `series_id`
### 1.2 查询参数新增字段
- [x] 1.2.1 在 `src/types/api/device.ts` 的 DeviceQueryParams 接口中添加 `series_id?: number` 查询参数
- [x] 1.2.2 在 `src/types/api/card.ts` 的 StandaloneCardQueryParams 接口中添加 `series_id?: number` 查询参数
### 1.3 响应类型新增字段
- [x] 1.3.1 在 `src/types/api/device.ts` 的 Device 接口中添加 `series_id?: number | null` 响应字段(在 updated_at 后面)
- [x] 1.3.2 在 `src/types/api/card.ts` 的 StandaloneIotCard 接口中添加 `series_id?: number | null` 响应字段(在 updated_at 后面)
## 2. API 方法更新
- [x] 2.1 更新 `src/api/modules/device.ts` 中 batchSetDeviceSeriesBinding 方法参数,将 `series_allocation_id` 改为 `series_id`
- [x] 2.2 更新 `src/api/modules/card.ts` 中 batchSetCardSeriesBinding 方法参数,将 `series_allocation_id` 改为 `series_id`
## 3. 页面组件更新
- [x] 3.1 更新 `src/views/asset-management/device-list/index.vue` 中调用 batchSetDeviceSeriesBinding 的代码,将参数名从 `series_allocation_id` 改为 `series_id`
- [x] 3.2 移除 device-list/index.vue 中第 1191 行的临时注释
- [x] 3.3 更新 `src/views/asset-management/iot-card-management/index.vue` 中调用 batchSetCardSeriesBinding 的代码,将参数名从 `series_allocation_id` 改为 `series_id`
- [x] 3.4 移除 iot-card-management/index.vue 中第 1326 行的临时注释
## 4. 验证
- [x] 4.1 运行 TypeScript 类型检查确认无类型错误
- [ ] 4.2 本地测试批量设置 IoT 卡系列绑定功能
- [ ] 4.3 本地测试批量设置设备系列绑定功能
- [ ] 4.4 确认所有相关功能正常工作

View File

@@ -0,0 +1,82 @@
# Change: 更新套餐管理API以支持新的佣金配置模型
## Why
根据最新的API文档`docs/修改原来的套餐管理.md`后端API规范发生了重大变更主要涉及:
1. **套餐系列** - 新增一次性佣金配置支持(包括固定/梯度佣金、强制充值、时效类型等复杂配置)
2. **系列分配** - 佣金配置模型完全重构从简单的base_commission改为更细粒度的配置
3. **单套餐分配** - 新增系列关联字段和分配者信息字段
当前前端实现的类型定义(`src/types/api/packageManagement.ts`和API服务与新规范不匹配需要更新以支持新的数据结构。
## What Changes
### 1. 类型定义重构
**修改**: `src/types/api/packageManagement.ts`
**套餐系列相关类型**:
- `PackageSeriesResponse` - 新增 `enable_one_time_commission``one_time_commission_config`
- `CreatePackageSeriesRequest` - 新增一次性佣金配置字段
- `UpdatePackageSeriesRequest` - 新增一次性佣金配置字段
- 新增 `SeriesOneTimeCommissionConfig` - 一次性佣金配置(支持固定/梯度模式)
- 新增 `OneTimeCommissionTier` - 梯度佣金档位配置
**系列分配相关类型**:
- **BREAKING**: `ShopSeriesAllocationResponse` - 完全重构字段结构
- 移除: `base_commission` (BaseCommissionConfig)
- 新增: `series_code`, `enable_force_recharge`, `enable_one_time_commission`, `force_recharge_amount`, `force_recharge_trigger_type`, `one_time_commission_amount`, `one_time_commission_threshold`, `one_time_commission_trigger`
- **BREAKING**: `CreateShopSeriesAllocationRequest` - 重构请求字段
- **BREAKING**: `UpdateShopSeriesAllocationRequest` - 重构请求字段
- 移除不再使用的类型: `BaseCommissionConfig`, `TierEntry`, `TierCommissionConfig`, `OneTimeCommissionTierEntry`, `OneTimeCommissionConfig`(旧版)
**单套餐分配相关类型**:
- `ShopPackageAllocationResponse` - 字段调整
- 新增: `series_id`, `series_name`, `series_allocation_id`, `allocator_shop_id`, `allocator_shop_name`
- 移除: `allocation_id` (重命名为 `series_allocation_id`), `calculated_cost_price`
- `ShopPackageAllocationQueryParams` - 新增 `series_allocation_id`, `allocator_shop_id` 筛选参数
### 2. API服务层无需修改
现有的API服务类`PackageSeriesService`, `ShopSeriesAllocationService`, `ShopPackageAllocationService`)的方法签名保持不变,只是底层数据类型发生变化。
### 3. 页面/组件适配(实施阶段处理)
以下文件需要适配新的数据结构(本提案阶段不编写代码):
- 套餐系列管理页面 - 需支持一次性佣金配置表单
- 系列分配页面 - 需重构佣金配置表单UI
- 单套餐分配页面 - 需展示新增的关联字段
## Impact
### 受影响的规范
- `package-series-management` - MODIFIED (新增一次性佣金配置能力)
- `shop-series-allocation` - MODIFIED (佣金配置模型重构)
- `shop-package-allocation` - MODIFIED (新增系列关联和分配者字段)
### 受影响的代码
- `src/types/api/packageManagement.ts` - **BREAKING CHANGE** 类型定义重构
- 所有使用 `ShopSeriesAllocationResponse` 的页面/组件 - 需适配新字段结构
- 所有使用 `PackageSeriesResponse` 的页面/组件 - 需处理新增的一次性佣金配置
### 依赖关系
- 后端API已按新规范实现`docs/修改原来的套餐管理.md`
- 前端需要先完成类型定义更新再更新UI组件
### 风险评估
- **高风险**: 这是一个BREAKING CHANGE会影响所有使用系列分配的功能
- **兼容性**: 需要确保后端API已更新否则会导致运行时错误
- **迁移成本**: 现有页面中的表单组件需要重写以支持新的数据结构
- **测试需求**: 需要全面测试套餐系列、系列分配、单套餐分配的所有CRUD操作
### 建议迁移策略
1. 先更新类型定义确保TypeScript编译通过
2. 逐个页面/组件适配新数据结构
3. 与后端联调确认新API规范工作正常
4. 完成后删除旧的、不再使用的类型定义

View File

@@ -0,0 +1,140 @@
# Package Series Management Specification Delta
## MODIFIED Requirements
### Requirement: Package Series Data Model
套餐系列的数据模型SHALL支持一次性佣金配置包括固定佣金、梯度佣金、强制充值、时效类型等高级功能。
**字段定义**:
- `id` (number) - 系列ID
- `series_code` (string) - 系列编码,唯一标识
- `series_name` (string) - 系列名称
- `description` (string, optional) - 系列描述
- `enable_one_time_commission` (boolean) - 是否启用一次性佣金
- `one_time_commission_config` (SeriesOneTimeCommissionConfig, optional) - 一次性佣金配置对象
- `status` (number) - 状态 (1:启用, 2:禁用)
- `created_at` (string) - 创建时间
- `updated_at` (string) - 更新时间
**一次性佣金配置结构** (`SeriesOneTimeCommissionConfig`):
- `enable` (boolean) - 是否启用一次性佣金
- `commission_type` (string) - 佣金类型: "fixed"(固定) | "tiered"(梯度)
- `commission_amount` (number, optional) - 固定佣金金额commission_type=fixed时使用
- `threshold` (number) - 触发阈值(分)
- `trigger_type` (string) - 触发类型: "first_recharge"(首次充值) | "accumulated_recharge"(累计充值)
- `tiers` (OneTimeCommissionTier[], optional) - 梯度配置列表commission_type=tiered时使用
- `enable_force_recharge` (boolean) - 是否启用强充
- `force_amount` (number, optional) - 强充金额(分)
- `force_calc_type` (string, optional) - 强充计算类型: "fixed"(固定) | "dynamic"(动态)
- `validity_type` (string) - 时效类型: "permanent"(永久) | "fixed_date"(固定日期) | "relative"(相对时长)
- `validity_value` (string, optional) - 时效值(日期字符串或月数)
**梯度档位结构** (`OneTimeCommissionTier`):
- `threshold` (number) - 达标阈值
- `dimension` (string) - 统计维度: "sales_count"(销量) | "sales_amount"(销售额)
- `amount` (number) - 佣金金额(分)
- `stat_scope` (string, optional) - 统计范围: "self"(仅自己) | "self_and_sub"(自己+下级)
#### Scenario: 创建套餐系列并启用固定一次性佣金
- **GIVEN** 管理员登录系统
- **WHEN** 创建套餐系列时提供以下配置:
- `series_code`: "SERIES001"
- `series_name`: "标准流量系列"
- `enable_one_time_commission`: true
- `one_time_commission_config`:
- `enable`: true
- `commission_type`: "fixed"
- `commission_amount`: 5000 (50元)
- `threshold`: 10000 (100元)
- `trigger_type`: "first_recharge"
- `validity_type`: "permanent"
- **THEN** 系统SHALL创建套餐系列并保存一次性佣金配置
#### Scenario: 创建套餐系列并启用梯度一次性佣金
- **GIVEN** 管理员登录系统
- **WHEN** 创建套餐系列时提供以下配置:
- `series_code`: "SERIES002"
- `series_name`: "高级流量系列"
- `enable_one_time_commission`: true
- `one_time_commission_config`:
- `enable`: true
- `commission_type`: "tiered"
- `threshold`: 5000
- `trigger_type`: "accumulated_recharge"
- `tiers`: [
{ threshold: 10, dimension: "sales_count", amount: 3000 },
{ threshold: 50, dimension: "sales_count", amount: 8000 }
]
- `validity_type`: "relative"
- `validity_value`: "12" (12个月)
- **THEN** 系统SHALL创建套餐系列并保存梯度佣金配置
#### Scenario: 查询启用一次性佣金的套餐系列
- **GIVEN** 系统中存在多个套餐系列,部分启用了一次性佣金
- **WHEN** 使用查询参数 `enable_one_time_commission=true` 查询列表
- **THEN** 系统SHALL返回所有启用一次性佣金的套餐系列
#### Scenario: 更新套餐系列的一次性佣金配置
- **GIVEN** 系统中存在ID为1的套餐系列
- **WHEN** 更新该系列时提供新的一次性佣金配置:
- `one_time_commission_config.commission_amount`: 8000 (从5000更新到8000)
- `one_time_commission_config.enable_force_recharge`: true
- `one_time_commission_config.force_amount`: 20000
- `one_time_commission_config.force_calc_type`: "fixed"
- **THEN** 系统SHALL更新套餐系列的一次性佣金配置并返回更新后的数据
#### Scenario: 获取套餐系列详情时包含完整的一次性佣金配置
- **GIVEN** 系统中存在ID为1的套餐系列已配置一次性佣金
- **WHEN** 请求获取该系列的详情 (GET /api/admin/package-series/1)
- **THEN** 系统SHALL返回包含完整 `one_time_commission_config` 对象的响应数据
## ADDED Requirements
### Requirement: One-Time Commission Query Filter
系统SHALL支持按一次性佣金启用状态筛选套餐系列列表。
#### Scenario: 按一次性佣金启用状态筛选
- **GIVEN** 系统中存在多个套餐系列
- **WHEN** 使用查询参数 `enable_one_time_commission=true`
- **THEN** 系统SHALL只返回 `enable_one_time_commission` 为 true 的套餐系列
- **AND** 分页信息正确反映筛选后的结果数量
### Requirement: One-Time Commission Configuration Validation
系统SHALL验证一次性佣金配置的完整性和逻辑一致性。
#### Scenario: 固定佣金模式的验证
- **GIVEN** 管理员创建套餐系列
- **WHEN** `commission_type` 为 "fixed"
- **THEN** 系统SHALL要求 `commission_amount` 字段必填
- **AND** `tiers` 字段不应被使用
#### Scenario: 梯度佣金模式的验证
- **GIVEN** 管理员创建套餐系列
- **WHEN** `commission_type` 为 "tiered"
- **THEN** 系统SHALL要求 `tiers` 数组不为空
- **AND** 每个梯度档位的 `threshold`, `dimension`, `amount` 字段必填
#### Scenario: 强制充值配置的验证
- **GIVEN** 管理员创建套餐系列
- **WHEN** `enable_force_recharge` 为 true
- **THEN** 系统SHALL要求 `force_amount``force_calc_type` 字段必填
#### Scenario: 时效配置的验证
- **GIVEN** 管理员创建套餐系列
- **WHEN** `validity_type` 为 "fixed_date"
- **THEN** 系统SHALL要求 `validity_value` 字段必填,且格式为有效的日期字符串
- **WHEN** `validity_type` 为 "relative"
- **THEN** 系统SHALL要求 `validity_value` 字段必填,且为表示月数的数字字符串

View File

@@ -0,0 +1,158 @@
# Shop Package Allocation Specification Delta
## MODIFIED Requirements
### Requirement: Shop Package Allocation Data Model
单套餐分配的数据模型SHALL新增系列关联字段和分配者信息字段以支持更完整的分配关系追溯。
**字段定义**:
- `id` (number) - 分配ID
- `package_id` (number) - 套餐ID
- `package_code` (string) - 套餐编码
- `package_name` (string) - 套餐名称
- `series_id` (number) - 套餐系列ID
- `series_name` (string) - 套餐系列名称
- `shop_id` (number) - 被分配的店铺ID
- `shop_name` (string) - 被分配的店铺名称
- `allocator_shop_id` (number) - 分配者店铺ID0表示平台分配
- `allocator_shop_name` (string) - 分配者店铺名称
- `series_allocation_id` (number, nullable) - 关联的系列分配ID可选
- `cost_price` (number) - 该代理的成本价(分)
- `status` (number) - 状态 (1:启用, 2:禁用)
- `created_at` (string) - 创建时间
- `updated_at` (string) - 更新时间
**查询参数** (`ShopPackageAllocationQueryParams`):
- `page` (number, optional) - 页码
- `page_size` (number, optional) - 每页数量
- `shop_id` (number, optional) - 被分配的店铺ID
- `package_id` (number, optional) - 套餐ID
- `series_allocation_id` (number, optional) - 系列分配ID
- `allocator_shop_id` (number, optional) - 分配者店铺ID
- `status` (number, optional) - 状态
#### Scenario: 创建单套餐分配时包含系列信息
- **GIVEN** 平台管理员登录系统
- **AND** 系统中存在ID为10的套餐该套餐属于系列ID为2系列名称为"标准流量系列"
- **AND** 系统中存在ID为100的店铺
- **WHEN** 创建单套餐分配:
- `package_id`: 10
- `shop_id`: 100
- `cost_price`: 15000
- **THEN** 系统SHALL创建单套餐分配记录
- **AND** 响应数据中自动填充 `series_id` 为 2
- **AND** 响应数据中自动填充 `series_name` 为 "标准流量系列"
- **AND** 响应数据中 `allocator_shop_id` 为 0平台分配
#### Scenario: 创建单套餐分配并关联系列分配
- **GIVEN** 系统中存在ID为20的系列分配关联系列ID为2店铺ID为100
- **AND** 系列2下存在套餐ID为10
- **WHEN** 为店铺100创建套餐10的分配
- **THEN** 系统MAY自动关联系列分配设置 `series_allocation_id` 为 20
#### Scenario: 查询单套餐分配列表并显示完整关联信息
- **GIVEN** 系统中存在多个单套餐分配
- **WHEN** 查询单套餐分配列表 (GET /api/admin/shop-package-allocations)
- **THEN** 系统SHALL返回所有分配记录
- **AND** 每条记录包含 `series_id`, `series_name`, `allocator_shop_id`, `allocator_shop_name`
- **AND** 如果存在关联的系列分配,`series_allocation_id` 不为空
#### Scenario: 按系列分配ID筛选单套餐分配
- **GIVEN** 系统中存在多个单套餐分配部分关联了系列分配ID为20的系列分配
- **WHEN** 使用查询参数 `series_allocation_id=20`
- **THEN** 系统SHALL返回所有 `series_allocation_id` 为 20 的单套餐分配记录
#### Scenario: 按分配者店铺ID筛选单套餐分配
- **GIVEN** 系统中存在多个单套餐分配
- **WHEN** 使用查询参数 `allocator_shop_id=50`
- **THEN** 系统SHALL返回所有由店铺ID为50的代理商创建的单套餐分配记录
#### Scenario: 获取单套餐分配详情时显示完整信息
- **GIVEN** 系统中存在ID为100的单套餐分配
- **WHEN** 请求获取该分配的详情 (GET /api/admin/shop-package-allocations/100)
- **THEN** 响应数据SHALL包含所有字段包括:
- `series_id`, `series_name` - 套餐所属系列信息
- `allocator_shop_id`, `allocator_shop_name` - 分配者信息
- `series_allocation_id` - 关联的系列分配ID如果存在
## RENAMED Requirements
- FROM: `allocation_id`
- TO: `series_allocation_id`
**说明**: 将字段名从 `allocation_id` 重命名为 `series_allocation_id`使其语义更加明确表示关联的系列分配ID。
## REMOVED Requirements
### Requirement: Calculated Cost Price Field
旧版的 `calculated_cost_price` 字段已被移除。
**Reason**: 该字段仅用于参考,增加了数据模型的复杂性,且前端很少使用。成本价信息应直接使用 `cost_price` 字段。
**Migration**: 移除所有使用 `calculated_cost_price` 字段的代码。如果需要查看原始成本价信息,可以通过关联的系列分配或套餐系列获取。
## ADDED Requirements
### Requirement: Series Information in Package Allocation
系统SHALL在单套餐分配响应数据中包含套餐所属系列的ID和名称。
#### Scenario: 创建分配时自动关联系列信息
- **GIVEN** 系统中存在套餐,且该套餐属于某个系列
- **WHEN** 创建单套餐分配
- **THEN** 系统SHALL自动从套餐数据中获取 `series_id``series_name`
- **AND** 填充到分配响应数据中
### Requirement: Allocator Information Tracking in Package Allocation
系统SHALL记录单套餐分配的创建者分配者信息包括分配者店铺ID和名称。
#### Scenario: 平台创建的单套餐分配标记为平台分配
- **GIVEN** 平台管理员登录系统
- **WHEN** 创建单套餐分配
- **THEN** 系统SHALL自动设置 `allocator_shop_id` 为 0
- **AND** `allocator_shop_name` 为 "平台" 或相应的平台标识
#### Scenario: 代理商创建的单套餐分配记录代理商信息
- **GIVEN** 代理商A登录系统shop_id为50shop_name为"代理商A"
- **WHEN** 创建单套餐分配给下级代理
- **THEN** 系统SHALL自动设置 `allocator_shop_id` 为 50
- **AND** `allocator_shop_name` 为 "代理商A"
### Requirement: Query by Allocator Shop ID
系统SHALL支持按分配者店铺ID筛选单套餐分配列表。
#### Scenario: 查看平台创建的所有分配
- **GIVEN** 系统中存在多个单套餐分配
- **WHEN** 使用查询参数 `allocator_shop_id=0`
- **THEN** 系统SHALL返回所有由平台创建的单套餐分配记录
#### Scenario: 查看特定代理商创建的所有分配
- **GIVEN** 系统中存在多个单套餐分配
- **WHEN** 使用查询参数 `allocator_shop_id=50`
- **THEN** 系统SHALL返回所有由店铺ID为50的代理商创建的单套餐分配记录
### Requirement: Query by Series Allocation ID
系统SHALL支持按系列分配ID筛选单套餐分配列表以便查找由特定系列分配派生的所有单套餐分配。
#### Scenario: 查找系列分配的所有派生单套餐分配
- **GIVEN** 系统中存在系列分配ID为20的系列分配
- **AND** 该系列分配下有多个单套餐分配
- **WHEN** 使用查询参数 `series_allocation_id=20`
- **THEN** 系统SHALL返回所有关联该系列分配的单套餐分配记录

View File

@@ -0,0 +1,162 @@
# Shop Series Allocation Specification Delta
## MODIFIED Requirements
### Requirement: Shop Series Allocation Data Model
套餐系列分配的数据模型SHALL重构为更细粒度的佣金配置结构移除旧的 `base_commission` 模式,采用独立的一次性佣金和强制充值配置字段。
**字段定义**:
- `id` (number) - 分配ID
- `series_id` (number) - 套餐系列ID
- `series_name` (string) - 套餐系列名称
- `series_code` (string) - 套餐系列编码
- `shop_id` (number) - 被分配的店铺ID
- `shop_name` (string) - 被分配的店铺名称
- `allocator_shop_id` (number) - 分配者店铺ID0表示平台分配
- `allocator_shop_name` (string) - 分配者店铺名称
- `enable_one_time_commission` (boolean) - 是否启用一次性佣金
- `one_time_commission_amount` (number) - 该代理能拿的一次性佣金金额上限(分)
- `one_time_commission_threshold` (number, optional) - 一次性佣金触发阈值(分)
- `one_time_commission_trigger` (string, optional) - 一次性佣金触发类型: "first_recharge" | "accumulated_recharge"
- `enable_force_recharge` (boolean) - 是否启用强制充值
- `force_recharge_amount` (number, optional) - 强制充值金额(分)
- `force_recharge_trigger_type` (number, optional) - 强充触发类型: 1(单次充值) | 2(累计充值)
- `status` (number) - 状态 (1:启用, 2:禁用)
- `created_at` (string) - 创建时间
- `updated_at` (string) - 更新时间
**创建请求字段** (`CreateShopSeriesAllocationRequest`):
- `series_id` (number, required) - 套餐系列ID
- `shop_id` (number, required) - 被分配的店铺ID
- `one_time_commission_amount` (number, required) - 一次性佣金金额上限(分)
- `enable_one_time_commission` (boolean, optional) - 是否启用一次性佣金
- `one_time_commission_threshold` (number, optional) - 一次性佣金触发阈值(分)
- `one_time_commission_trigger` (string, optional) - 一次性佣金触发类型
- `enable_force_recharge` (boolean, optional) - 是否启用强制充值
- `force_recharge_amount` (number, optional) - 强制充值金额(分)
- `force_recharge_trigger_type` (number, optional) - 强充触发类型
**更新请求字段** (`UpdateShopSeriesAllocationRequest`):
- 所有字段均为可选,允许部分更新
- `enable_one_time_commission` (boolean, optional)
- `one_time_commission_amount` (number, optional)
- `one_time_commission_threshold` (number, optional)
- `one_time_commission_trigger` (string, optional)
- `enable_force_recharge` (boolean, optional)
- `force_recharge_amount` (number, optional)
- `force_recharge_trigger_type` (number, optional)
- `status` (number, optional)
#### Scenario: 创建系列分配并启用一次性佣金
- **GIVEN** 平台管理员登录系统
- **AND** 系统中存在ID为1的套餐系列和ID为100的店铺
- **WHEN** 创建系列分配时提供以下配置:
- `series_id`: 1
- `shop_id`: 100
- `one_time_commission_amount`: 5000
- `enable_one_time_commission`: true
- `one_time_commission_threshold`: 10000
- `one_time_commission_trigger`: "first_recharge"
- **THEN** 系统SHALL创建系列分配记录
- **AND** 响应数据中 `allocator_shop_id` 为 0平台分配
- **AND** 响应数据中包含所有一次性佣金配置字段
#### Scenario: 创建系列分配并启用强制充值
- **GIVEN** 代理商A登录系统shop_id为50
- **AND** 系统中存在ID为2的套餐系列和ID为101的下级代理店铺
- **WHEN** 创建系列分配时提供以下配置:
- `series_id`: 2
- `shop_id`: 101
- `one_time_commission_amount`: 3000
- `enable_force_recharge`: true
- `force_recharge_amount`: 15000
- `force_recharge_trigger_type`: 1 (单次充值)
- **THEN** 系统SHALL创建系列分配记录
- **AND** 响应数据中 `allocator_shop_id` 为 50代理商A
- **AND** 响应数据中包含所有强制充值配置字段
#### Scenario: 更新系列分配的一次性佣金配置
- **GIVEN** 系统中存在ID为10的系列分配
- **WHEN** 更新该分配时提供以下字段:
- `one_time_commission_amount`: 8000 (从5000更新到8000)
- `one_time_commission_threshold`: 20000
- **THEN** 系统SHALL更新指定字段
- **AND** 其他字段保持不变
- **AND** 响应数据中 `updated_at` 字段更新为当前时间
#### Scenario: 更新系列分配的强制充值配置
- **GIVEN** 系统中存在ID为10的系列分配
- **WHEN** 更新该分配时提供以下字段:
- `enable_force_recharge`: true
- `force_recharge_amount`: 25000
- `force_recharge_trigger_type`: 2 (累计充值)
- **THEN** 系统SHALL更新强制充值配置
- **AND** 响应数据中包含更新后的强制充值字段
#### Scenario: 查询系列分配列表并显示分配者信息
- **GIVEN** 系统中存在多个系列分配,部分由平台创建,部分由代理商创建
- **WHEN** 查询系列分配列表 (GET /api/admin/shop-series-allocations)
- **THEN** 系统SHALL返回所有分配记录
- **AND** 每条记录包含 `allocator_shop_id``allocator_shop_name`
- **AND** 平台创建的分配记录中 `allocator_shop_id` 为 0
#### Scenario: 按分配者店铺ID筛选系列分配
- **GIVEN** 系统中存在多个系列分配
- **WHEN** 使用查询参数 `allocator_shop_id=50`
- **THEN** 系统SHALL返回所有由店铺ID为50的代理商创建的分配记录
## REMOVED Requirements
### Requirement: Base Commission Configuration
旧版的 `base_commission` 配置模式已被移除,不再使用 `BaseCommissionConfig` 类型。
**Reason**: 新的佣金配置模型更加灵活和细粒度,将一次性佣金配置分散到独立字段中,便于查询和管理。
**Migration**: 现有使用 `base_commission` 字段的代码需要迁移到新的字段结构。如果有历史数据,需要后端提供数据迁移方案。
### Requirement: Tiered Commission Configuration
旧版的 `TierCommissionConfig` (周期性梯度返佣) 配置已被移除。
**Reason**: 系列分配层面不再支持复杂的梯度返佣配置,改为在系列级别统一配置一次性佣金梯度。
**Migration**: 梯度佣金配置现在在套餐系列级别 (`PackageSeries.one_time_commission_config.tiers`) 进行管理。
## ADDED Requirements
### Requirement: Allocator Information Tracking
系统SHALL记录系列分配的创建者分配者信息包括分配者店铺ID和名称。
#### Scenario: 平台创建的分配标记为平台分配
- **GIVEN** 平台管理员登录系统
- **WHEN** 创建系列分配
- **THEN** 系统SHALL自动设置 `allocator_shop_id` 为 0
- **AND** `allocator_shop_name` 为 "平台" 或相应的平台标识
#### Scenario: 代理商创建的分配记录代理商信息
- **GIVEN** 代理商A登录系统shop_id为50shop_name为"代理商A"
- **WHEN** 创建系列分配给下级代理
- **THEN** 系统SHALL自动设置 `allocator_shop_id` 为 50
- **AND** `allocator_shop_name` 为 "代理商A"
### Requirement: Series Code in Allocation Response
系统SHALL在系列分配响应数据中包含套餐系列编码 (`series_code`)。
#### Scenario: 获取系列分配详情时包含系列编码
- **GIVEN** 系统中存在ID为10的系列分配关联系列编码为"SERIES001"
- **WHEN** 请求获取该分配的详情 (GET /api/admin/shop-series-allocations/10)
- **THEN** 响应数据中SHALL包含 `series_code` 字段
- **AND** 其值为 "SERIES001"

View File

@@ -0,0 +1,62 @@
# Implementation Tasks
## 1. 类型定义更新
- [ ] 1.1 更新 `PackageSeriesResponse` - 新增一次性佣金字段
- [ ] 1.2 新增 `SeriesOneTimeCommissionConfig` 类型定义
- [ ] 1.3 新增 `OneTimeCommissionTier` 类型定义
- [ ] 1.4 更新 `CreatePackageSeriesRequest` - 新增一次性佣金配置参数
- [ ] 1.5 更新 `UpdatePackageSeriesRequest` - 新增一次性佣金配置参数
- [ ] 1.6 更新 `PackageSeriesQueryParams` - 新增 `enable_one_time_commission` 筛选参数
- [ ] 1.7 重构 `ShopSeriesAllocationResponse` - 替换为新的字段结构
- [ ] 1.8 重构 `CreateShopSeriesAllocationRequest` - 更新为新的请求结构
- [ ] 1.9 重构 `UpdateShopSeriesAllocationRequest` - 更新为新的请求结构
- [ ] 1.10 更新 `ShopPackageAllocationResponse` - 新增系列关联和分配者字段
- [ ] 1.11 更新 `ShopPackageAllocationQueryParams` - 新增筛选参数
- [ ] 1.12 移除废弃的类型定义 (`BaseCommissionConfig`, 旧版 `OneTimeCommissionConfig` 等)
## 2. 套餐系列管理页面适配
- [ ] 2.1 更新套餐系列列表 - 显示一次性佣金启用状态
- [ ] 2.2 更新套餐系列表单 - 新增一次性佣金配置区块
- [ ] 2.3 实现一次性佣金配置表单组件 (固定模式)
- [ ] 2.4 实现一次性佣金配置表单组件 (梯度模式)
- [ ] 2.5 实现强制充值配置表单组件
- [ ] 2.6 实现时效配置表单组件 (永久/固定日期/相对时长)
- [ ] 2.7 添加一次性佣金配置的表单验证逻辑
- [ ] 2.8 更新套餐系列详情展示 - 显示完整的一次性佣金配置
## 3. 系列分配管理页面适配
- [ ] 3.1 更新系列分配列表 - 适配新的响应字段结构
- [ ] 3.2 移除基础返佣配置表单(旧版)
- [ ] 3.3 实现新的系列分配表单 - 强制充值配置
- [ ] 3.4 实现新的系列分配表单 - 一次性佣金配置
- [ ] 3.5 更新系列分配详情展示 - 显示所有新字段
- [ ] 3.6 更新系列分配编辑表单 - 支持修改所有可选字段
- [ ] 3.7 添加表单验证逻辑 - 确保必填字段和逻辑一致性
## 4. 单套餐分配页面适配
- [ ] 4.1 更新单套餐分配列表 - 显示系列信息和分配者信息
- [ ] 4.2 更新列表筛选条件 - 新增系列分配ID和分配者店铺ID筛选
- [ ] 4.3 更新单套餐分配详情展示 - 显示所有新字段
- [ ] 4.4 移除列表中的"计算成本价"字段显示(已废弃)
## 5. 测试与验证
- [ ] 5.1 测试套餐系列的创建 - 包含一次性佣金配置(固定模式)
- [ ] 5.2 测试套餐系列的创建 - 包含一次性佣金配置(梯度模式)
- [ ] 5.3 测试套餐系列的编辑 - 修改一次性佣金配置
- [ ] 5.4 测试系列分配的创建 - 使用新的字段结构
- [ ] 5.5 测试系列分配的编辑 - 修改强制充值和一次性佣金参数
- [ ] 5.6 测试单套餐分配 - 验证系列关联字段正确显示
- [ ] 5.7 测试单套餐分配筛选 - 验证新增筛选参数工作正常
- [ ] 5.8 回归测试 - 确保所有现有功能仍然正常工作
## 6. 文档与清理
- [ ] 6.1 更新类型定义文件的注释 - 标注新增/修改的字段
- [ ] 6.2 删除废弃的类型定义和相关代码
- [ ] 6.3 更新相关组件的注释文档
- [ ] 6.4 记录API变更影响和迁移说明如需要

View File

@@ -64,6 +64,7 @@
"nprogress": "^0.2.0",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.3.0",
"qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"vue": "^3.5.12",
"vue-draggable-plus": "^0.6.0",

5589
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
import zh from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import { systemUpgrade } from '@/utils'
import { UserService } from './api/usersApi'
import { AuthService } from '@/api/modules'
import { ApiStatus } from './utils/http/status'
import { setThemeTransitionClass } from '@/utils'
import { checkStorageCompatibility } from '@/utils'
@@ -41,7 +41,7 @@
const getUserInfo = async () => {
if (userStore.isLogin && userStore.accessToken) {
try {
const res = await UserService.getUserInfo()
const res = await AuthService.getUserInfo()
if (res.code === ApiStatus.success && res.data) {
// API 返回的是 { user, permissions },我们需要保存 user 和 permissions
userStore.setUserInfo(res.data.user)

View File

@@ -1,54 +0,0 @@
/**
* 认证相关 API
*/
import request from '@/utils/http'
import {
BaseResponse,
LoginParams,
LoginData,
UserInfoResponse,
RefreshTokenData
} from '@/types/api'
export class AuthService {
/**
* 用户登录
* @param params 登录参数
*/
static login(params: LoginParams): Promise<BaseResponse<LoginData>> {
return request.post<BaseResponse<LoginData>>({
url: '/api/admin/login',
data: params
})
}
/**
* 获取用户信息
* GET /api/admin/me
*/
static getUserInfo(): Promise<BaseResponse<UserInfoResponse>> {
return request.get<BaseResponse<UserInfoResponse>>({
url: '/api/admin/me'
})
}
/**
* 用户登出
*/
static logout(): Promise<BaseResponse<void>> {
return request.post<BaseResponse<void>>({
url: '/api/admin/logout'
})
}
/**
* 刷新 Token
* @param refreshToken 刷新令牌
*/
static refreshToken(refreshToken: string): Promise<BaseResponse<RefreshTokenData>> {
return request.post<BaseResponse<RefreshTokenData>>({
url: '/api/auth/refresh',
data: { refreshToken }
})
}
}

View File

@@ -92,4 +92,24 @@ export class AccountService extends BaseService {
static removeRoleFromAccount(accountId: number, roleId: number): Promise<BaseResponse> {
return this.delete<BaseResponse>(`/api/admin/accounts/${accountId}/roles/${roleId}`)
}
/**
* 修改账号密码
* PUT /api/admin/accounts/{id}/password
* @param id 账号ID
* @param password 新密码
*/
static updateAccountPassword(id: number, password: string): Promise<BaseResponse> {
return this.put<BaseResponse>(`/api/admin/accounts/${id}/password`, { password })
}
/**
* 修改账号状态
* PUT /api/admin/accounts/{id}/status
* @param id 账号ID
* @param status 状态 (0:禁用, 1:启用)
*/
static updateAccountStatus(id: number, status: 0 | 1): Promise<BaseResponse> {
return this.put<BaseResponse>(`/api/admin/accounts/${id}/status`, { status })
}
}

View File

@@ -0,0 +1,61 @@
/**
* 代理充值相关 API
*/
import { BaseService } from '../BaseService'
import type {
AgentRecharge,
AgentRechargeQueryParams,
AgentRechargeListResponse,
CreateAgentRechargeRequest,
ConfirmOfflinePaymentRequest,
BaseResponse
} from '@/types/api'
export class AgentRechargeService extends BaseService {
/**
* 获取代理充值订单列表
* @param params 查询参数
*/
static getAgentRecharges(
params?: AgentRechargeQueryParams
): Promise<BaseResponse<AgentRechargeListResponse>> {
return this.get<BaseResponse<AgentRechargeListResponse>>(
'/api/admin/agent-recharges',
params
)
}
/**
* 获取代理充值订单详情
* @param id 充值订单ID
*/
static getAgentRechargeById(id: number): Promise<BaseResponse<AgentRecharge>> {
return this.getOne<AgentRecharge>(`/api/admin/agent-recharges/${id}`)
}
/**
* 创建代理充值订单
* @param data 创建充值订单请求参数
*/
static createAgentRecharge(
data: CreateAgentRechargeRequest
): Promise<BaseResponse<AgentRecharge>> {
return this.post<BaseResponse<AgentRecharge>>('/api/admin/agent-recharges', data)
}
/**
* 确认线下充值
* @param id 充值订单ID
* @param data 确认线下充值请求参数
*/
static confirmOfflinePayment(
id: number,
data: ConfirmOfflinePaymentRequest
): Promise<BaseResponse<AgentRecharge>> {
return this.post<BaseResponse<AgentRecharge>>(
`/api/admin/agent-recharges/${id}/offline-pay`,
data
)
}
}

179
src/api/modules/asset.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* 资产管理 API 服务
* 对应文档asset-detail-refactor-api-changes.md
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
AssetType,
AssetResolveResponse,
AssetRealtimeStatusResponse,
AssetRefreshResponse,
AssetPackageUsageRecord,
AssetCurrentPackageResponse,
DeviceStopResponse,
DeviceStartResponse,
CardStopResponse,
CardStartResponse,
AssetWalletTransactionListResponse,
AssetWalletTransactionParams,
AssetWalletResponse
} from '@/types/api'
export class AssetService extends BaseService {
/**
* 通过任意标识符查询设备或卡的完整详情
* 支持虚拟号、ICCID、IMEI、SN、MSISDN
* GET /api/admin/assets/resolve/:identifier
* @param identifier 资产标识符虚拟号、ICCID、IMEI、SN、MSISDN
*/
static resolveAsset(identifier: string): Promise<BaseResponse<AssetResolveResponse>> {
return this.getOne<AssetResolveResponse>(`/api/admin/assets/resolve/${identifier}`)
}
/**
* 读取资产实时状态(直接读 DB/Redis不调网关
* GET /api/admin/assets/:asset_type/:id/realtime-status
* @param assetType 资产类型 (card 或 device)
* @param id 资产ID
*/
static getRealtimeStatus(
assetType: AssetType,
id: number
): Promise<BaseResponse<AssetRealtimeStatusResponse>> {
return this.getOne<AssetRealtimeStatusResponse>(
`/api/admin/assets/${assetType}/${id}/realtime-status`
)
}
/**
* 主动调网关拉取最新数据后返回
* POST /api/admin/assets/:asset_type/:id/refresh
* 注意:设备有 30 秒冷却期,冷却中调用返回 429
* @param assetType 资产类型 (card 或 device)
* @param id 资产ID
*/
static refreshAsset(
assetType: AssetType,
id: number
): Promise<BaseResponse<AssetRefreshResponse>> {
return this.post<BaseResponse<AssetRefreshResponse>>(
`/api/admin/assets/${assetType}/${id}/refresh`,
{}
)
}
/**
* 查询该资产所有套餐记录,含虚流量换算字段
* GET /api/admin/assets/:asset_type/:id/packages
* @param assetType 资产类型 (card 或 device)
* @param id 资产ID
*/
static getAssetPackages(
assetType: AssetType,
id: number
): Promise<BaseResponse<AssetPackageUsageRecord[]>> {
return this.get<BaseResponse<AssetPackageUsageRecord[]>>(
`/api/admin/assets/${assetType}/${id}/packages`
)
}
/**
* 查询当前生效中的主套餐
* GET /api/admin/assets/:asset_type/:id/current-package
* 无生效套餐时返回 404
* @param assetType 资产类型 (card 或 device)
* @param id 资产ID
*/
static getCurrentPackage(
assetType: AssetType,
id: number
): Promise<BaseResponse<AssetCurrentPackageResponse>> {
return this.getOne<AssetCurrentPackageResponse>(
`/api/admin/assets/${assetType}/${id}/current-package`
)
}
// ========== 设备停复机操作 ==========
/**
* 批量停机设备下所有已实名卡
* POST /api/admin/assets/device/:device_id/stop
* 停机成功后设置 1 小时停机保护期(保护期内禁止复机)
* @param deviceId 设备ID
*/
static stopDevice(deviceId: number): Promise<BaseResponse<DeviceStopResponse>> {
return this.post<BaseResponse<DeviceStopResponse>>(
`/api/admin/assets/device/${deviceId}/stop`,
{}
)
}
/**
* 批量复机设备下所有已实名卡
* POST /api/admin/assets/device/:device_id/start
* 复机成功后设置 1 小时复机保护期(保护期内禁止停机)
* @param deviceId 设备ID
*/
static startDevice(deviceId: number): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/assets/device/${deviceId}/start`, {})
}
// ========== 单卡停复机操作 ==========
/**
* 手动停机单张卡(通过 ICCID
* POST /api/admin/assets/card/:iccid/stop
* 若卡绑定的设备在复机保护期内,返回 403
* @param iccid ICCID
*/
static stopCard(iccid: string): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/assets/card/${iccid}/stop`, {})
}
/**
* 手动复机单张卡(通过 ICCID
* POST /api/admin/assets/card/:iccid/start
* 若卡绑定的设备在停机保护期内,返回 403
* @param iccid ICCID
*/
static startCard(iccid: string): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/assets/card/${iccid}/start`, {})
}
// ========== 钱包查询 ==========
/**
* 查询指定卡或设备的钱包余额概况
* GET /api/admin/assets/:asset_type/:id/wallet
* 企业账号禁止调用
* @param assetType 资产类型 (card 或 device)
* @param id 资产ID
*/
static getAssetWallet(
assetType: AssetType,
id: number
): Promise<BaseResponse<AssetWalletResponse>> {
return this.getOne<AssetWalletResponse>(`/api/admin/assets/${assetType}/${id}/wallet`)
}
/**
* 分页查询指定资产的钱包收支流水
* GET /api/admin/assets/:asset_type/:id/wallet/transactions
* 企业账号禁止调用
* @param assetType 资产类型 (card 或 device)
* @param id 资产ID
* @param params 查询参数
*/
static getWalletTransactions(
assetType: AssetType,
id: number,
params?: AssetWalletTransactionParams
): Promise<BaseResponse<AssetWalletTransactionListResponse>> {
return this.get<BaseResponse<AssetWalletTransactionListResponse>>(
`/api/admin/assets/${assetType}/${id}/wallet/transactions`,
params
)
}
}

View File

@@ -16,42 +16,42 @@ import type {
export class AuthService extends BaseService {
/**
* 用户登录
* 用户登录(统一认证接口)
* @param params 登录参数
*/
static login(params: LoginParams): Promise<BaseResponse<LoginData>> {
return this.post<BaseResponse<LoginData>>('/api/admin/login', params)
return this.post<BaseResponse<LoginData>>('/api/auth/login', params)
}
/**
* 退出登录
* 退出登录(统一认证接口)
*/
static logout(): Promise<BaseResponse> {
return this.post<BaseResponse>('/api/admin/logout')
return this.post<BaseResponse>('/api/auth/logout')
}
/**
* 获取当前用户信息
* GET /api/admin/me
* 获取当前用户信息(统一认证接口)
* GET /api/auth/me
*/
static getUserInfo(): Promise<BaseResponse<UserInfoResponse>> {
return this.get<BaseResponse<UserInfoResponse>>('/api/admin/me')
return this.get<BaseResponse<UserInfoResponse>>('/api/auth/me')
}
/**
* 刷新 Token
* 刷新 Token(统一认证接口)
* @param params 刷新参数
*/
static refreshToken(params: RefreshTokenParams): Promise<BaseResponse<RefreshTokenData>> {
return this.post<BaseResponse<RefreshTokenData>>('/api/auth/refresh', params)
return this.post<BaseResponse<RefreshTokenData>>('/api/auth/refresh-token', params)
}
/**
* 修改密码
* 修改密码(统一认证接口)
* @param params 修改密码参数
*/
static changePassword(params: ChangePasswordParams): Promise<BaseResponse> {
return this.post<BaseResponse>('/api/auth/change-password', params)
return this.put<BaseResponse>('/api/auth/password', params)
}
/**

View File

@@ -18,7 +18,8 @@ import type {
CardOrder,
BaseResponse,
PaginationResponse,
ListResponse
ListResponse,
GatewayRealnameLinkResponse
} from '@/types/api'
export class CardService extends BaseService {
@@ -87,7 +88,8 @@ export class CardService extends BaseService {
}
/**
* 通过ICCID查询单卡详情接口,用于单卡查询页面
* 通过ICCID查询单卡详情接口,已废弃
* @deprecated 使用 AssetService.resolveAsset 替代
* @param iccid ICCID
*/
static getIotCardDetailByIccid(iccid: string): Promise<BaseResponse<any>> {
@@ -363,8 +365,70 @@ export class CardService extends BaseService {
*/
static batchSetCardSeriesBinding(data: {
iccids: string[]
series_allocation_id: number
series_id: number
}): Promise<BaseResponse<any>> {
return this.patch('/api/admin/iot-cards/series-binding', data)
}
// ========== IoT卡网关操作相关 ==========
/**
* 获取实名认证链接
* @param iccid ICCID
*/
static getRealnameLink(iccid: string): Promise<BaseResponse<GatewayRealnameLinkResponse>> {
return this.get<BaseResponse<GatewayRealnameLinkResponse>>(
`/api/admin/iot-cards/${iccid}/realname-link`
)
}
/**
* 启用 IoT 卡
* @param id IoT卡ID
*/
static enableIotCard(id: number): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/iot-cards/${id}/enable`, {})
}
/**
* 停用 IoT 卡
* @param id IoT卡ID
*/
static disableIotCard(id: number): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/iot-cards/${id}/disable`, {})
}
/**
* 手动停用 IoT 卡
* @param id IoT卡ID
*/
static deactivateIotCard(id: number): Promise<BaseResponse> {
return this.patch<BaseResponse>(`/api/admin/iot-cards/${id}/deactivate`, {})
}
/**
* 获取套餐流量详单(每日流量记录)
* @param packageUsageId 套餐使用记录ID
*/
static getPackageDailyRecords(packageUsageId: number): Promise<BaseResponse<{
package_name: string
package_usage_id: number
total_usage_mb: number
records: Array<{
date: string
daily_usage_mb: number
cumulative_usage_mb: number
}>
}>> {
return this.get<BaseResponse<{
package_name: string
package_usage_id: number
total_usage_mb: number
records: Array<{
date: string
daily_usage_mb: number
cumulative_usage_mb: number
}>
}>>(`/api/admin/package-usage/${packageUsageId}/daily-records`)
}
}

View File

@@ -1,5 +1,14 @@
/**
* 客户账号管理 API
* 客户账号管理 API (企业账号)
*
* @deprecated 此 API 已废弃,请使用统一的 AccountService 代替
* @see AccountService - 统一账号管理接口 (/api/admin/accounts)
*
* 迁移说明:
* - 所有账号类型统一使用 /api/admin/accounts 接口
* - 通过 user_type 参数区分账号类型 (2=平台, 3=代理, 4=企业)
* - customer-accounts 已改名为 enterprise企业账号
* - 详见docs/迁移指南.md
*/
import { BaseService } from '../BaseService'

View File

@@ -20,7 +20,11 @@ import type {
DeviceImportTaskQueryParams,
DeviceImportTaskListResponse,
DeviceImportTaskDetail,
BaseResponse
BaseResponse,
SetSpeedLimitRequest,
SwitchCardRequest,
SetWiFiRequest,
DeviceOperationResponse
} from '@/types/api'
export class DeviceService extends BaseService {
@@ -43,11 +47,11 @@ export class DeviceService extends BaseService {
}
/**
* 通过设备号查询设备详情
* @param imei 设备号(IMEI)
* 通过ICCID查询设备详情
* @param iccid ICCID
*/
static getDeviceByImei(imei: string): Promise<BaseResponse<Device>> {
return this.getOne<Device>(`/api/admin/devices/by-imei/${imei}`)
static getDeviceByIccid(iccid: string): Promise<BaseResponse<any>> {
return this.getOne<any>(`/api/admin/devices/by-iccid/${iccid}`)
}
/**
@@ -153,8 +157,85 @@ export class DeviceService extends BaseService {
*/
static batchSetDeviceSeriesBinding(data: {
device_ids: number[]
series_allocation_id: number
series_id: number
}): Promise<BaseResponse<any>> {
return this.patch('/api/admin/devices/series-binding', data)
}
// ========== 设备操作相关 ==========
/**
* 重启设备
* @param imei 设备号(IMEI)
*/
static rebootDevice(imei: string): Promise<BaseResponse<DeviceOperationResponse>> {
return this.post<BaseResponse<DeviceOperationResponse>>(
`/api/admin/devices/by-imei/${imei}/reboot`,
{}
)
}
/**
* 恢复出厂设置
* @param imei 设备号(IMEI)
*/
static resetDevice(imei: string): Promise<BaseResponse<DeviceOperationResponse>> {
return this.post<BaseResponse<DeviceOperationResponse>>(
`/api/admin/devices/by-imei/${imei}/reset`,
{}
)
}
/**
* 设置限速
* @param imei 设备号(IMEI)
* @param data 限速参数
*/
static setSpeedLimit(
imei: string,
data: SetSpeedLimitRequest
): Promise<BaseResponse<DeviceOperationResponse>> {
return this.put<BaseResponse<DeviceOperationResponse>>(
`/api/admin/devices/by-imei/${imei}/speed-limit`,
data
)
}
/**
* 切换SIM卡
* @param imei 设备号(IMEI)
* @param data 切卡参数
*/
static switchCard(
imei: string,
data: SwitchCardRequest
): Promise<BaseResponse<DeviceOperationResponse>> {
return this.post<BaseResponse<DeviceOperationResponse>>(
`/api/admin/devices/by-imei/${imei}/switch-card`,
data
)
}
/**
* 设置WiFi
* @param imei 设备号(IMEI)
* @param data WiFi参数
*/
static setWiFi(
imei: string,
data: SetWiFiRequest
): Promise<BaseResponse<DeviceOperationResponse>> {
return this.put<BaseResponse<DeviceOperationResponse>>(
`/api/admin/devices/by-imei/${imei}/wifi`,
data
)
}
/**
* 手动停用设备
* @param id 设备ID
*/
static deactivateDevice(id: number): Promise<BaseResponse> {
return this.patch<BaseResponse>(`/api/admin/devices/${id}/deactivate`, {})
}
}

129
src/api/modules/exchange.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* 换货管理 API 服务
*/
import { BaseService } from '../BaseService'
import type { BaseResponse, PaginationResponse } from '@/types/api'
// 换货单查询参数
export interface ExchangeQueryParams {
page?: number
page_size?: number
status?: number // 换货状态
identifier?: string // 资产标识符(模糊匹配)
created_at_start?: string // 创建时间起始
created_at_end?: string // 创建时间结束
}
// 创建换货单请求
export interface CreateExchangeRequest {
exchange_reason: string // 换货原因
old_asset_type: string // 旧资产类型 (iot_card 或 device)
old_identifier: string // 旧资产标识符(ICCID/虚拟号/IMEI/SN)
remark?: string // 备注(可选)
}
// 换货单响应
export interface ExchangeResponse {
id: number
exchange_no: string
exchange_reason: string
old_asset_type: string
old_asset_identifier: string
new_asset_type: string
new_asset_identifier: string
status: number // 换货状态1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
status_text: string
recipient_name?: string
recipient_phone?: string
recipient_address?: string
express_company?: string
express_no?: string
remark?: string
created_at: string
updated_at: string
}
// 换货发货请求
export interface ShipExchangeRequest {
express_company: string // 快递公司
express_no: string // 快递单号
migrate_data: boolean // 是否迁移数据
new_identifier: string // 新资产标识符(ICCID/虚拟号/IMEI/SN)
}
// 取消换货请求
export interface CancelExchangeRequest {
remark?: string // 取消备注(可选)
}
export class ExchangeService extends BaseService {
/**
* 获取换货单列表
* GET /api/admin/exchanges
* @param params 查询参数
*/
static getExchanges(
params?: ExchangeQueryParams
): Promise<BaseResponse<{ list: ExchangeResponse[]; page: number; page_size: number; total: number }>> {
return this.get<
BaseResponse<{ list: ExchangeResponse[]; page: number; page_size: number; total: number }>
>('/api/admin/exchanges', params)
}
/**
* 创建换货单
* POST /api/admin/exchanges
* @param data 创建参数
*/
static createExchange(data: CreateExchangeRequest): Promise<BaseResponse<ExchangeResponse>> {
return this.create<ExchangeResponse>('/api/admin/exchanges', data)
}
/**
* 获取换货单详情
* GET /api/admin/exchanges/{id}
* @param id 换货单ID
*/
static getExchangeDetail(id: number): Promise<BaseResponse<ExchangeResponse>> {
return this.getOne<ExchangeResponse>(`/api/admin/exchanges/${id}`)
}
/**
* 取消换货
* POST /api/admin/exchanges/{id}/cancel
* @param id 换货单ID
* @param data 取消参数
*/
static cancelExchange(id: number, data?: CancelExchangeRequest): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/exchanges/${id}/cancel`, data || {})
}
/**
* 确认换货完成
* POST /api/admin/exchanges/{id}/complete
* @param id 换货单ID
*/
static completeExchange(id: number): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/exchanges/${id}/complete`, {})
}
/**
* 旧资产转新
* POST /api/admin/exchanges/{id}/renew
* @param id 换货单ID
*/
static renewExchange(id: number): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/exchanges/${id}/renew`, {})
}
/**
* 换货发货
* POST /api/admin/exchanges/{id}/ship
* @param id 换货单ID
* @param data 发货参数
*/
static shipExchange(id: number, data: ShipExchangeRequest): Promise<BaseResponse<ExchangeResponse>> {
return this.post<BaseResponse<ExchangeResponse>>(`/api/admin/exchanges/${id}/ship`, data)
}
}

View File

@@ -23,9 +23,12 @@ export { DeviceService } from './device'
export { CarrierService } from './carrier'
export { PackageSeriesService } from './packageSeries'
export { PackageManageService } from './packageManage'
export { ShopPackageAllocationService } from './shopPackageAllocation'
export { ShopSeriesAllocationService } from './shopSeriesAllocation'
export { ShopSeriesGrantService } from './shopSeriesGrant'
export { OrderService } from './order'
export { AssetService } from './asset'
export { AgentRechargeService } from './agentRecharge'
export { WechatConfigService } from './wechatConfig'
export { ExchangeService } from './exchange'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -9,6 +9,8 @@ import type {
OrderListResponse,
CreateOrderRequest,
CreateOrderResponse,
PurchaseCheckRequest,
PurchaseCheckResponse,
BaseResponse
} from '@/types/api'
@@ -44,4 +46,17 @@ export class OrderService extends BaseService {
static cancelOrder(id: number): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/orders/${id}/cancel`, {})
}
/**
* 套餐购买预检
* @param data 预检请求参数
*/
static purchaseCheck(
data: PurchaseCheckRequest
): Promise<BaseResponse<PurchaseCheckResponse>> {
return this.post<BaseResponse<PurchaseCheckResponse>>(
'/api/admin/orders/purchase-check',
data
)
}
}

View File

@@ -74,7 +74,7 @@ export class PackageManageService extends BaseService {
*/
static updatePackageStatus(id: number, status: number): Promise<BaseResponse> {
const data: UpdatePackageStatusRequest = { status }
return this.put<BaseResponse>(`/api/admin/packages/${id}/status`, data)
return this.patch<BaseResponse>(`/api/admin/packages/${id}/status`, data)
}
/**
@@ -87,4 +87,14 @@ export class PackageManageService extends BaseService {
const data: UpdatePackageShelfStatusRequest = { shelf_status }
return this.patch<BaseResponse>(`/api/admin/packages/${id}/shelf`, data)
}
/**
* 修改套餐零售价(代理)
* PATCH /api/admin/packages/{id}/retail-price
* @param id 套餐ID
* @param retail_price 零售价(分)
*/
static updateRetailPrice(id: number, retail_price: number): Promise<BaseResponse> {
return this.patch<BaseResponse>(`/api/admin/packages/${id}/retail-price`, { retail_price })
}
}

View File

@@ -75,6 +75,6 @@ export class PackageSeriesService extends BaseService {
*/
static updatePackageSeriesStatus(id: number, status: number): Promise<BaseResponse> {
const data: UpdatePackageSeriesStatusRequest = { status }
return this.put<BaseResponse>(`/api/admin/package-series/${id}/status`, data)
return this.patch<BaseResponse>(`/api/admin/package-series/${id}/status`, data)
}
}

View File

@@ -1,5 +1,13 @@
/**
* 平台账号相关 API - 匹配后端实际接口
*
* @deprecated 此 API 已废弃,请使用统一的 AccountService 代替
* @see AccountService - 统一账号管理接口 (/api/admin/accounts)
*
* 迁移说明:
* - 所有账号类型统一使用 /api/admin/accounts 接口
* - 通过 user_type 参数区分账号类型 (2=平台, 3=代理, 4=企业)
* - 详见docs/迁移指南.md
*/
import { BaseService } from '../BaseService'

View File

@@ -8,6 +8,8 @@ import type {
ShopQueryParams,
CreateShopParams,
UpdateShopParams,
ShopRolesResponse,
AssignShopRolesRequest,
BaseResponse,
PaginationResponse
} from '@/types/api'
@@ -49,4 +51,38 @@ export class ShopService extends BaseService {
static deleteShop(id: number): Promise<BaseResponse> {
return this.delete<BaseResponse>(`/api/admin/shops/${id}`)
}
// ========== 店铺默认角色管理 ==========
/**
* 获取店铺默认角色列表
* GET /api/admin/shops/{shop_id}/roles
* @param shopId 店铺ID
*/
static getShopRoles(shopId: number): Promise<BaseResponse<ShopRolesResponse>> {
return this.get<BaseResponse<ShopRolesResponse>>(`/api/admin/shops/${shopId}/roles`)
}
/**
* 分配店铺默认角色
* POST /api/admin/shops/{shop_id}/roles
* @param shopId 店铺ID
* @param data 角色ID列表
*/
static assignShopRoles(
shopId: number,
data: AssignShopRolesRequest
): Promise<BaseResponse<ShopRolesResponse>> {
return this.post<BaseResponse<ShopRolesResponse>>(`/api/admin/shops/${shopId}/roles`, data)
}
/**
* 删除店铺默认角色
* DELETE /api/admin/shops/{shop_id}/roles/{role_id}
* @param shopId 店铺ID
* @param roleId 角色ID
*/
static deleteShopRole(shopId: number, roleId: number): Promise<BaseResponse> {
return this.delete<BaseResponse>(`/api/admin/shops/${shopId}/roles/${roleId}`)
}
}

View File

@@ -1,5 +1,13 @@
/**
* 代理账号相关 API - 匹配后端实际接口
*
* @deprecated 此 API 已废弃,请使用统一的 AccountService 代替
* @see AccountService - 统一账号管理接口 (/api/admin/accounts)
*
* 迁移说明:
* - 所有账号类型统一使用 /api/admin/accounts 接口
* - 通过 user_type 参数区分账号类型 (2=平台, 3=代理, 4=企业)
* - 详见docs/迁移指南.md
*/
import { BaseService } from '../BaseService'

View File

@@ -1,104 +0,0 @@
/**
* 单套餐分配 API 服务
*/
import { BaseService } from '../BaseService'
import type {
ShopPackageAllocationResponse,
ShopPackageAllocationQueryParams,
CreateShopPackageAllocationRequest,
UpdateShopPackageAllocationRequest,
UpdateShopPackageAllocationStatusRequest,
BaseResponse,
PaginationResponse
} from '@/types/api'
export class ShopPackageAllocationService extends BaseService {
/**
* 获取单套餐分配列表
* GET /api/admin/shop-package-allocations
* @param params 查询参数
*/
static getShopPackageAllocations(
params?: ShopPackageAllocationQueryParams
): Promise<PaginationResponse<ShopPackageAllocationResponse>> {
return this.getPage<ShopPackageAllocationResponse>(
'/api/admin/shop-package-allocations',
params
)
}
/**
* 创建单套餐分配
* POST /api/admin/shop-package-allocations
* @param data 分配数据
*/
static createShopPackageAllocation(
data: CreateShopPackageAllocationRequest
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.create<ShopPackageAllocationResponse>('/api/admin/shop-package-allocations', data)
}
/**
* 获取单套餐分配详情
* GET /api/admin/shop-package-allocations/{id}
* @param id 分配ID
*/
static getShopPackageAllocationDetail(
id: number
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.getOne<ShopPackageAllocationResponse>(`/api/admin/shop-package-allocations/${id}`)
}
/**
* 更新单套餐分配
* PUT /api/admin/shop-package-allocations/{id}
* @param id 分配ID
* @param data 分配数据(只允许修改成本价)
*/
static updateShopPackageAllocation(
id: number,
data: UpdateShopPackageAllocationRequest
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.update<ShopPackageAllocationResponse>(
`/api/admin/shop-package-allocations/${id}`,
data
)
}
/**
* 删除单套餐分配
* DELETE /api/admin/shop-package-allocations/{id}
* @param id 分配ID
*/
static deleteShopPackageAllocation(id: number): Promise<BaseResponse> {
return this.remove(`/api/admin/shop-package-allocations/${id}`)
}
/**
* 更新单套餐分配成本价
* PUT /api/admin/shop-package-allocations/{id}/cost-price
* @param id 分配ID
* @param costPrice 成本价(分)
*/
static updateShopPackageAllocationCostPrice(
id: number,
costPrice: number
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.put<BaseResponse<ShopPackageAllocationResponse>>(
`/api/admin/shop-package-allocations/${id}/cost-price`,
{ cost_price: costPrice }
)
}
/**
* 更新单套餐分配状态
* PUT /api/admin/shop-package-allocations/{id}/status
* @param id 分配ID
* @param status 状态 (1:启用, 2:禁用)
*/
static updateShopPackageAllocationStatus(id: number, status: number): Promise<BaseResponse> {
const data: UpdateShopPackageAllocationStatusRequest = { status }
return this.put<BaseResponse>(`/api/admin/shop-package-allocations/${id}/status`, data)
}
}

View File

@@ -1,85 +0,0 @@
/**
* 套餐系列分配 API 服务
*/
import { BaseService } from '../BaseService'
import type {
ShopSeriesAllocationResponse,
ShopSeriesAllocationQueryParams,
CreateShopSeriesAllocationRequest,
UpdateShopSeriesAllocationRequest,
UpdateShopSeriesAllocationStatusRequest,
BaseResponse,
PaginationResponse
} from '@/types/api'
export class ShopSeriesAllocationService extends BaseService {
/**
* 获取套餐系列分配分页列表
* GET /api/admin/shop-series-allocations
* @param params 查询参数
*/
static getShopSeriesAllocations(
params?: ShopSeriesAllocationQueryParams
): Promise<PaginationResponse<ShopSeriesAllocationResponse>> {
return this.getPage<ShopSeriesAllocationResponse>('/api/admin/shop-series-allocations', params)
}
/**
* 创建套餐系列分配
* POST /api/admin/shop-series-allocations
* @param data 分配数据
*/
static createShopSeriesAllocation(
data: CreateShopSeriesAllocationRequest
): Promise<BaseResponse<ShopSeriesAllocationResponse>> {
return this.create<ShopSeriesAllocationResponse>('/api/admin/shop-series-allocations', data)
}
/**
* 获取套餐系列分配详情
* GET /api/admin/shop-series-allocations/{id}
* @param id 分配ID
*/
static getShopSeriesAllocationDetail(
id: number
): Promise<BaseResponse<ShopSeriesAllocationResponse>> {
return this.getOne<ShopSeriesAllocationResponse>(`/api/admin/shop-series-allocations/${id}`)
}
/**
* 更新套餐系列分配
* PUT /api/admin/shop-series-allocations/{id}
* @param id 分配ID
* @param data 分配数据
*/
static updateShopSeriesAllocation(
id: number,
data: UpdateShopSeriesAllocationRequest
): Promise<BaseResponse<ShopSeriesAllocationResponse>> {
return this.update<ShopSeriesAllocationResponse>(
`/api/admin/shop-series-allocations/${id}`,
data
)
}
/**
* 删除套餐系列分配
* DELETE /api/admin/shop-series-allocations/{id}
* @param id 分配ID
*/
static deleteShopSeriesAllocation(id: number): Promise<BaseResponse> {
return this.remove(`/api/admin/shop-series-allocations/${id}`)
}
/**
* 更新套餐系列分配状态
* PUT /api/admin/shop-series-allocations/{id}/status
* @param id 分配ID
* @param status 状态 (1:启用, 2:禁用)
*/
static updateShopSeriesAllocationStatus(id: number, status: number): Promise<BaseResponse> {
const data: UpdateShopSeriesAllocationStatusRequest = { status }
return this.put<BaseResponse>(`/api/admin/shop-series-allocations/${id}/status`, data)
}
}

View File

@@ -0,0 +1,90 @@
/**
* 代理系列授权 API 服务
*/
import { BaseService } from '../BaseService'
import type {
ShopSeriesGrantResponse,
ShopSeriesGrantQueryParams,
CreateShopSeriesGrantRequest,
UpdateShopSeriesGrantRequest,
ManageGrantPackagesRequest,
BaseResponse,
PaginationResponse
} from '@/types/api'
export class ShopSeriesGrantService extends BaseService {
/**
* 获取代理系列授权分页列表
* GET /api/admin/shop-series-grants
* @param params 查询参数
*/
static getShopSeriesGrants(
params?: ShopSeriesGrantQueryParams
): Promise<PaginationResponse<ShopSeriesGrantResponse>> {
return this.getPage<ShopSeriesGrantResponse>('/api/admin/shop-series-grants', params)
}
/**
* 创建代理系列授权
* POST /api/admin/shop-series-grants
* @param data 授权数据
*/
static createShopSeriesGrant(
data: CreateShopSeriesGrantRequest
): Promise<BaseResponse<ShopSeriesGrantResponse>> {
return this.create<ShopSeriesGrantResponse>('/api/admin/shop-series-grants', data)
}
/**
* 获取代理系列授权详情
* GET /api/admin/shop-series-grants/{id}
* @param id 授权ID
*/
static getShopSeriesGrantDetail(
id: number
): Promise<BaseResponse<ShopSeriesGrantResponse>> {
return this.getOne<ShopSeriesGrantResponse>(`/api/admin/shop-series-grants/${id}`)
}
/**
* 更新代理系列授权
* PUT /api/admin/shop-series-grants/{id}
* @param id 授权ID
* @param data 授权数据
*/
static updateShopSeriesGrant(
id: number,
data: UpdateShopSeriesGrantRequest
): Promise<BaseResponse<ShopSeriesGrantResponse>> {
return this.update<ShopSeriesGrantResponse>(
`/api/admin/shop-series-grants/${id}`,
data
)
}
/**
* 删除代理系列授权
* DELETE /api/admin/shop-series-grants/{id}
* @param id 授权ID
*/
static deleteShopSeriesGrant(id: number): Promise<BaseResponse> {
return this.remove(`/api/admin/shop-series-grants/${id}`)
}
/**
* 管理代理系列授权的套餐
* PUT /api/admin/shop-series-grants/{id}/packages
* @param id 授权ID
* @param data 套餐管理数据
*/
static manageGrantPackages(
id: number,
data: ManageGrantPackagesRequest
): Promise<BaseResponse<ShopSeriesGrantResponse>> {
return this.put<BaseResponse<ShopSeriesGrantResponse>>(
`/api/admin/shop-series-grants/${id}/packages`,
data
)
}
}

View File

@@ -0,0 +1,78 @@
/**
* 微信支付配置管理 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
WechatConfig,
WechatConfigQueryParams,
WechatConfigListResponse,
CreateWechatConfigRequest,
UpdateWechatConfigRequest
} from '@/types/api/wechatConfig'
export class WechatConfigService extends BaseService {
/**
* 获取支付配置列表
*/
static getWechatConfigs(
params?: WechatConfigQueryParams
): Promise<BaseResponse<WechatConfigListResponse>> {
return this.get<BaseResponse<WechatConfigListResponse>>('/api/admin/wechat-configs', params)
}
/**
* 获取支付配置详情
*/
static getWechatConfigById(id: number): Promise<BaseResponse<WechatConfig>> {
return this.get<BaseResponse<WechatConfig>>(`/api/admin/wechat-configs/${id}`)
}
/**
* 创建支付配置
*/
static createWechatConfig(
data: CreateWechatConfigRequest
): Promise<BaseResponse<WechatConfig>> {
return this.post<BaseResponse<WechatConfig>>('/api/admin/wechat-configs', data)
}
/**
* 更新支付配置
*/
static updateWechatConfig(
id: number,
data: UpdateWechatConfigRequest
): Promise<BaseResponse<WechatConfig>> {
return this.put<BaseResponse<WechatConfig>>(`/api/admin/wechat-configs/${id}`, data)
}
/**
* 删除支付配置
*/
static deleteWechatConfig(id: number): Promise<BaseResponse<void>> {
return this.delete<BaseResponse<void>>(`/api/admin/wechat-configs/${id}`)
}
/**
* 激活支付配置
*/
static activateWechatConfig(id: number): Promise<BaseResponse<WechatConfig>> {
return this.post<BaseResponse<WechatConfig>>(`/api/admin/wechat-configs/${id}/activate`)
}
/**
* 停用支付配置
*/
static deactivateWechatConfig(id: number): Promise<BaseResponse<WechatConfig>> {
return this.post<BaseResponse<WechatConfig>>(`/api/admin/wechat-configs/${id}/deactivate`)
}
/**
* 获取当前生效的支付配置
*/
static getActiveWechatConfig(): Promise<BaseResponse<WechatConfig>> {
return this.get<BaseResponse<WechatConfig>>('/api/admin/wechat-configs/active')
}
}

View File

@@ -1,39 +0,0 @@
import request from '@/utils/http'
import { BaseResponse, UserInfoResponse } from '@/types/api'
interface LoginParams {
username: string
password: string
device?: string
}
interface UserListParams {
current?: number
size?: number
}
export class UserService {
// 登录
static login(params: LoginParams) {
return request.post<BaseResponse>({
url: '/api/admin/login',
params
})
}
// 获取用户信息
// GET /api/admin/me
static getUserInfo() {
return request.get<BaseResponse<UserInfoResponse>>({
url: '/api/admin/me'
})
}
// 获取用户列表
static getUserList(params?: UserListParams) {
return request.get<BaseResponse>({
url: '/api/user/list',
params
})
}
}

View File

@@ -193,3 +193,16 @@ body {
align-items: center;
gap: 20px;
}
// 表单分段标题样式 - 用于对话框中的表单分段
.form-section-title {
margin: 24px 0 16px 0;
padding-left: 12px;
border-left: 3px solid var(--el-color-primary);
.title-text {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}

View File

@@ -0,0 +1,59 @@
<template>
<ElButton @click="handleGenerate">
{{ buttonText }}
</ElButton>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import {
generateSeriesCode,
generatePackageCode,
generateShopCode
} from '@/utils/codeGenerator'
interface Props {
// 编码类型: series(套餐系列) | package(套餐) | shop(店铺)
codeType: 'series' | 'package' | 'shop'
// 按钮文字
buttonText?: string
// 表单字段名,用于清除验证
fieldName?: string
}
const props = withDefaults(defineProps<Props>(), {
buttonText: '生成编码'
})
const emit = defineEmits<{
(e: 'generated', code: string): void
}>()
// 生成编码
const handleGenerate = () => {
let code = ''
switch (props.codeType) {
case 'series':
code = generateSeriesCode()
break
case 'package':
code = generatePackageCode()
break
case 'shop':
code = generateShopCode()
break
default:
ElMessage.error('未知的编码类型')
return
}
// 触发生成事件,将编码传递给父组件
emit('generated', code)
ElMessage.success('编码生成成功')
}
</script>
<style lang="scss" scoped>
// 可以添加特定样式
</style>

View File

@@ -12,7 +12,7 @@
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
row-key="ID"
:loading="loading"
:data="accountList"
height="60vh"
@@ -24,7 +24,7 @@
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
<ElTableColumn v-for="col in columns" :key="col.prop || (col as any).type" v-bind="col" />
</template>
</ArtTable>
</ElDialog>
@@ -33,23 +33,25 @@
<script setup lang="ts">
import { h } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { CustomerAccountService } from '@/api/modules'
import { AccountService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { PlatformAccount } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
interface CustomerAccount {
id: number
username: string
phone: string
user_type: number
user_type_name: string
shop_id: number | null
shop_name: string
enterprise_id: number | null
enterprise_name: string
status: number
status_name: string
created_at: string
// 用户类型映射
const getUserTypeName = (type: number): string => {
const typeMap: Record<number, string> = {
1: '超级管理员',
2: '平台用户',
3: '代理账号',
4: '企业账号'
}
return typeMap[type] || '未知'
}
// 状态名称映射
const getStatusName = (status: number): string => {
return status === 1 ? '启用' : '禁用'
}
const props = defineProps<{
@@ -69,7 +71,7 @@
const loading = ref(false)
const tableRef = ref()
const accountList = ref<CustomerAccount[]>([])
const accountList = ref<PlatformAccount[]>([])
// 搜索表单初始值
const initialSearchState = {
@@ -160,40 +162,38 @@
width: 130
},
{
prop: 'user_type_name',
prop: 'user_type',
label: '用户类型',
width: 110,
formatter: (row: CustomerAccount) => {
return h(ElTag, { type: getUserTypeTag(row.user_type) }, () => row.user_type_name)
formatter: (row: PlatformAccount) => {
return h(ElTag, { type: getUserTypeTag(row.user_type) }, () => getUserTypeName(row.user_type))
}
},
{
prop: 'shop_name',
label: '店铺名称',
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: CustomerAccount) => row.shop_name || '-'
prop: 'shop_id',
label: '店铺ID',
width: 100,
formatter: (row: PlatformAccount) => row.shop_id || '-'
},
{
prop: 'enterprise_name',
label: '企业名称',
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: CustomerAccount) => row.enterprise_name || '-'
prop: 'enterprise_id',
label: '企业ID',
width: 100,
formatter: (row: PlatformAccount) => row.enterprise_id || '-'
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: CustomerAccount) => {
return h(ElTag, { type: getStatusTag(row.status) }, () => row.status_name)
formatter: (row: PlatformAccount) => {
return h(ElTag, { type: getStatusTag(row.status) }, () => getStatusName(row.status))
}
},
{
prop: 'created_at',
prop: 'CreatedAt',
label: '创建时间',
width: 180,
formatter: (row: CustomerAccount) => formatDateTime(row.created_at)
formatter: (row: PlatformAccount) => formatDateTime(row.CreatedAt)
}
])
@@ -213,7 +213,7 @@
try {
const params: any = {
page: pagination.page,
page_size: pagination.pageSize,
pageSize: pagination.pageSize,
username: searchForm.username || undefined,
phone: searchForm.phone || undefined,
user_type: searchForm.user_type,
@@ -235,7 +235,7 @@
}
})
const res = await CustomerAccountService.getCustomerAccounts(params)
const res = await AccountService.getAccounts(params)
if (res.code === 0) {
accountList.value = res.data.items || []
pagination.total = res.data.total || 0

View File

@@ -0,0 +1,159 @@
<template>
<div class="detail-container">
<ElCard v-for="(section, index) in sections" :key="index" class="detail-section">
<template #header>
<div class="section-title">{{ section.title }}</div>
</template>
<div :class="['section-fields', section.columns === 1 ? 'single-column' : 'double-column']">
<div
v-for="(field, fieldIndex) in section.fields"
:key="fieldIndex"
class="field-item"
:class="{ 'full-width': field.fullWidth }"
>
<div class="field-label">{{ field.label }}:</div>
<div class="field-value">
<!-- 自定义渲染 -->
<template v-if="field.render">
<component :is="field.render(data)" />
</template>
<!-- 默认渲染 -->
<template v-else>
{{ formatFieldValue(field, data) }}
</template>
</div>
</div>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { ElCard } from 'element-plus'
export interface DetailField {
label: string // 字段标签
prop?: string // 数据属性路径,支持点号分隔的嵌套路径,如 'one_time_commission_config.enable'
formatter?: (value: any, data: any) => string // 自定义格式化函数
render?: (data: any) => any // 自定义渲染函数,返回 VNode
fullWidth?: boolean // 是否占据整行
}
export interface DetailSection {
title: string // 分组标题
fields: DetailField[] // 字段列表
columns?: 1 | 2 // 列数默认2列
}
interface Props {
sections: DetailSection[] // 详情页分组配置
data: any // 详情数据
}
const props = defineProps<Props>()
/**
* 根据点号分隔的路径获取嵌套对象的值
*/
const getNestedValue = (obj: any, path: string): any => {
if (!path) return obj
return path.split('.').reduce((acc, part) => acc?.[part], obj)
}
/**
* 格式化字段值
*/
const formatFieldValue = (field: DetailField, data: any): string => {
const value = field.prop ? getNestedValue(data, field.prop) : data
// 如果有自定义格式化函数
if (field.formatter) {
return field.formatter(value, data)
}
// 默认处理
if (value === null || value === undefined || value === '') {
return '-'
}
return String(value)
}
</script>
<style scoped lang="scss">
.detail-container {
max-height: 70vh;
overflow-y: auto;
padding: 4px;
}
.detail-section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.section-fields {
display: grid;
gap: 16px 24px;
&.single-column {
grid-template-columns: 1fr;
}
&.double-column {
grid-template-columns: repeat(2, 1fr);
}
}
.field-item {
display: flex;
align-items: flex-start;
min-height: 32px;
&.full-width {
grid-column: 1 / -1;
}
.field-label {
flex-shrink: 0;
width: 140px;
font-weight: 500;
color: var(--el-text-color-regular);
line-height: 32px;
}
.field-value {
flex: 1;
color: var(--el-text-color-primary);
line-height: 32px;
word-break: break-word;
}
}
@media (max-width: 768px) {
.section-fields {
&.double-column {
grid-template-columns: 1fr;
}
}
.field-item {
flex-direction: column;
.field-label {
width: 100%;
margin-bottom: 4px;
}
}
}
</style>

View File

@@ -13,7 +13,7 @@
const props = withDefaults(
defineProps<{
text?: string
type?: 'add' | 'edit' | 'delete' | 'more'
type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
icon?: string // 自定义图标
iconClass?: BgColorEnum // 自定义按钮背景色、文字颜色
iconColor?: string // 外部传入的图标文字颜色
@@ -31,7 +31,8 @@
{ type: 'add', icon: '&#xe602;', color: BgColorEnum.PRIMARY },
{ type: 'edit', icon: '&#xe642;', color: BgColorEnum.SECONDARY },
{ type: 'delete', icon: '&#xe783;', color: BgColorEnum.ERROR },
{ type: 'more', icon: '&#xe6df;', color: '' }
{ type: 'more', icon: '&#xe6df;', color: '' },
{ type: 'view', icon: '&#xe6df;', color: BgColorEnum.SECONDARY }
] as const
// 计算最终使用的图标:优先使用外部传入的 icon否则根据 type 获取默认图标

View File

@@ -75,6 +75,7 @@
import ArtSearchSelect from './widget/art-search-select/index.vue'
import ArtSearchRadio from './widget/art-search-radio/index.vue'
import ArtSearchDate from './widget/art-search-date/index.vue'
import ArtSearchTreeSelect from './widget/art-search-tree-select/index.vue'
import { SearchComponentType, SearchFormItem } from '@/types'
const { width } = useWindowSize()
@@ -131,6 +132,7 @@
const componentsMap: Record<string, any> = {
input: ArtSearchInput,
select: ArtSearchSelect,
'tree-select': ArtSearchTreeSelect,
radio: ArtSearchRadio,
datetime: ArtSearchDate,
date: ArtSearchDate,

View File

@@ -0,0 +1,46 @@
<template>
<el-tree-select v-model="value" v-bind="config" @change="(val) => changeValue(val)" />
</template>
<script setup lang="ts">
import { SearchFormItem } from '@/types'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 定义组件值类型
export type ValueVO = unknown
// 定义组件props
const prop = defineProps<{
value: ValueVO // 树形选择框的值
item: SearchFormItem // 表单项配置
}>()
// 定义emit事件
const emit = defineEmits<{
(e: 'update:value', value: ValueVO): void // 更新树形选择框值的事件
}>()
// 计算属性:处理v-model双向绑定
const value = computed({
get: () => prop.value,
set: (value: ValueVO) => emit('update:value', value)
})
// 合并默认配置和自定义配置
const config = reactive({
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${prop.item.label}`,
...(prop.item.config || {})
})
// 树形选择框值变化处理函数
const changeValue = (val: unknown): void => {
if (prop.item.onChange) {
prop.item.onChange({
prop: prop.item.prop,
val
})
}
}
</script>

View File

@@ -4,7 +4,13 @@
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
<div v-show="visible" :style="menuStyle" class="context-menu">
<ul class="menu-list" :style="menuListStyle">
<template v-for="item in menuItems" :key="item.key">
<!-- 无权限提示 -->
<li v-if="menuItems.length === 0" class="menu-item no-permission" :style="menuItemStyle">
<span class="menu-label">您暂无更多权限</span>
</li>
<!-- 菜单项 -->
<template v-else v-for="item in menuItems" :key="item.key">
<!-- 普通菜单项 -->
<li
v-if="!item.children"
@@ -249,10 +255,23 @@
user-select: none;
transition: background-color 0.15s ease;
&:hover:not(.is-disabled) {
&:hover:not(.is-disabled):not(.no-permission) {
background-color: rgba(var(--art-gray-200-rgb), 0.7);
}
&.no-permission {
justify-content: center;
color: var(--el-text-color-secondary);
cursor: default;
.menu-label {
color: var(--el-text-color-secondary);
text-align: center;
white-space: normal;
word-break: break-all;
}
}
&.has-line {
margin-bottom: 10px;

View File

@@ -0,0 +1,38 @@
<!-- 表格右键菜单悬浮提示组件 -->
<template>
<div
v-show="visible"
class="table-context-menu-hint"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
>
{{ text }}
</div>
</template>
<script setup lang="ts">
interface Props {
visible: boolean
position: { x: number; y: number }
text?: string
}
withDefaults(defineProps<Props>(), {
text: '右键查看更多操作'
})
</script>
<style scoped lang="scss">
.table-context-menu-hint {
position: fixed;
padding: 4px 10px;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 12px;
border-radius: 4px;
pointer-events: none;
white-space: nowrap;
z-index: 9999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: opacity 0.2s ease;
}
</style>

View File

@@ -14,6 +14,7 @@
v-loading="loading"
:data="tableData"
:row-key="rowKey"
:row-class-name="rowClassName"
:height="height"
:max-height="maxHeight"
:show-header="showHeader"
@@ -28,7 +29,10 @@
fontWeight: '500'
}"
@row-click="handleRowClick"
@row-contextmenu="handleRowContextmenu"
@selection-change="handleSelectionChange"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<!-- 序号列 -->
<el-table-column
@@ -85,6 +89,8 @@
loading?: boolean
/** 行数据的 Key用于标识每一行数据 */
rowKey?: string
/** 行的 className 的回调方法 */
rowClassName?: ((data: { row: any; rowIndex: number }) => string) | string
/** 是否显示边框 */
border?: boolean | null
/** 是否使用斑马纹样式 */
@@ -135,6 +141,7 @@
data: () => [],
loading: false,
rowKey: 'id',
rowClassName: undefined,
border: null,
stripe: null,
index: false,
@@ -174,9 +181,12 @@
'update:currentPage',
'update:pageSize',
'row-click',
'row-contextmenu',
'size-change',
'current-change',
'selection-change'
'selection-change',
'cell-mouse-enter',
'cell-mouse-leave'
])
const tableStore = useTableStore()
@@ -274,6 +284,21 @@
emit('row-click', row, column, event)
}
// 行右键事件
const handleRowContextmenu = (row: any, column: any, event: any) => {
emit('row-contextmenu', row, column, event)
}
// 单元格鼠标进入事件
const handleCellMouseEnter = (row: any, column: any, cell: any, event: any) => {
emit('cell-mouse-enter', row, column, cell, event)
}
// 单元格鼠标离开事件
const handleCellMouseLeave = (row: any, column: any, cell: any, event: any) => {
emit('cell-mouse-leave', row, column, cell, event)
}
// 选择变化事件
const handleSelectionChange = (selection: any) => {
emit('selection-change', selection)

View File

@@ -0,0 +1,134 @@
<template>
<ElDialog v-model="visible" title="设置WiFi" width="500px" @close="handleClose">
<ElForm
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
>
<ElFormItem label="设备信息">
<span style="font-weight: bold; color: #409eff">{{ deviceInfo }}</span>
</ElFormItem>
<ElFormItem label="WiFi状态" prop="enabled">
<ElRadioGroup v-model="formData.enabled">
<ElRadio :value="1">启用</ElRadio>
<ElRadio :value="0">禁用</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="WiFi名称" prop="ssid">
<ElInput
v-model="formData.ssid"
placeholder="请输入WiFi名称1-32个字符"
maxlength="32"
show-word-limit
clearable
/>
</ElFormItem>
<ElFormItem label="WiFi密码" prop="password">
<ElInput
v-model="formData.password"
type="password"
placeholder="请输入WiFi密码8-63个字符"
maxlength="63"
show-word-limit
show-password
clearable
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="confirmLoading">
确认设置
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import {
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElButton,
ElRadioGroup,
ElRadio
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
deviceInfo: string
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { enabled: number; ssid: string; password: string }): void
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const emit = defineEmits<Emits>()
const visible = ref(props.modelValue)
const confirmLoading = ref(props.loading)
const formRef = ref<FormInstance>()
const formData = reactive({
enabled: 1,
ssid: '',
password: ''
})
const rules = reactive<FormRules>({
ssid: [
{ required: true, message: '请输入WiFi名称', trigger: 'blur' },
{ min: 1, max: 32, message: 'WiFi名称长度为1-32个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入WiFi密码', trigger: 'blur' },
{ min: 8, max: 63, message: 'WiFi密码长度为8-63个字符', trigger: 'blur' }
]
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
// 重置表单
formData.enabled = 1
formData.ssid = ''
formData.password = ''
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
watch(() => props.loading, (val) => {
confirmLoading.value = val
})
const handleClose = () => {
formRef.value?.resetFields()
}
const handleConfirm = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
emit('confirm', {
enabled: formData.enabled,
ssid: formData.ssid,
password: formData.password
})
}
})
}
</script>

View File

@@ -0,0 +1,111 @@
<template>
<ElDialog v-model="visible" title="设置限速" width="500px" @close="handleClose">
<ElForm
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
>
<ElFormItem label="设备信息">
<span style="font-weight: bold; color: #409eff">{{ deviceInfo }}</span>
</ElFormItem>
<ElFormItem label="下行速率" prop="download_speed">
<ElInputNumber
v-model="formData.download_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="color: #909399; font-size: 12px; margin-top: 4px">单位: KB/s</div>
</ElFormItem>
<ElFormItem label="上行速率" prop="upload_speed">
<ElInputNumber
v-model="formData.upload_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="color: #909399; font-size: 12px; margin-top: 4px">单位: KB/s</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="confirmLoading">
确认设置
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElInputNumber, ElButton, ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
deviceInfo: string
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { download_speed: number; upload_speed: number }): void
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const emit = defineEmits<Emits>()
const visible = ref(props.modelValue)
const confirmLoading = ref(props.loading)
const formRef = ref<FormInstance>()
const formData = reactive({
download_speed: 1024,
upload_speed: 512
})
const rules = reactive<FormRules>({
download_speed: [{ required: true, message: '请输入下行速率', trigger: 'blur' }],
upload_speed: [{ required: true, message: '请输入上行速率', trigger: 'blur' }]
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
// 重置表单
formData.download_speed = 1024
formData.upload_speed = 512
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
watch(() => props.loading, (val) => {
confirmLoading.value = val
})
const handleClose = () => {
formRef.value?.resetFields()
}
const handleConfirm = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
emit('confirm', {
download_speed: formData.download_speed,
upload_speed: formData.upload_speed
})
}
})
}
</script>

View File

@@ -0,0 +1,153 @@
<template>
<ElDialog v-model="visible" title="切换SIM卡" width="600px" @close="handleClose">
<div v-if="loading" style="text-align: center; padding: 40px">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
<div style="margin-top: 16px">加载设备绑定的卡列表中...</div>
</div>
<template v-else>
<ElAlert
v-if="cards.length === 0"
title="该设备暂无绑定的SIM卡"
type="warning"
:closable="false"
style="margin-bottom: 20px"
>
<template #default>
<div>当前设备没有绑定任何SIM卡,无法进行切换操作</div>
<div>请先为设备绑定SIM卡后再进行切换</div>
</template>
</ElAlert>
<ElForm
v-if="cards.length > 0"
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
>
<ElFormItem label="设备信息">
<span style="font-weight: bold; color: #409eff">{{ deviceInfo }}</span>
</ElFormItem>
<ElFormItem label="目标ICCID" prop="target_iccid">
<ElSelect
v-model="formData.target_iccid"
placeholder="请选择要切换到的目标ICCID"
style="width: 100%"
clearable
>
<ElOption
v-for="card in cards"
:key="card.iccid"
:label="`${card.iccid} - 插槽${card.slot_position} - ${card.carrier_name}`"
:value="card.iccid"
>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ card.iccid }}</span>
<ElTag size="small" style="margin-left: 10px">插槽{{ card.slot_position }}</ElTag>
</div>
</ElOption>
</ElSelect>
<div style="margin-top: 8px; font-size: 12px; color: #909399">
当前设备共绑定 {{ cards.length }} 张SIM卡
</div>
</ElFormItem>
</ElForm>
</template>
<template #footer>
<ElButton @click="visible = false">取消</ElButton>
<ElButton
v-if="cards.length > 0"
type="primary"
@click="handleConfirm"
:loading="confirmLoading"
>
确认切换
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import {
ElDialog,
ElForm,
ElFormItem,
ElSelect,
ElOption,
ElButton,
ElAlert,
ElIcon,
ElTag
} from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
interface CardBinding {
id: number
iccid: string
slot_position: number
carrier_name: string
}
interface Props {
modelValue: boolean
deviceInfo: string
cards: CardBinding[]
loading?: boolean
confirmLoading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { target_iccid: string }): void
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
confirmLoading: false
})
const emit = defineEmits<Emits>()
const visible = ref(props.modelValue)
const formRef = ref<FormInstance>()
const formData = reactive({
target_iccid: ''
})
const rules = reactive<FormRules>({
target_iccid: [{ required: true, message: '请选择目标ICCID', trigger: 'change' }]
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
// 重置表单
formData.target_iccid = ''
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleClose = () => {
formRef.value?.resetFields()
}
const handleConfirm = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
emit('confirm', {
target_iccid: formData.target_iccid
})
}
})
}
</script>

View File

@@ -2,6 +2,23 @@
import { ref, computed } from 'vue'
/**
* 格式化空值为 "-"
* @param value 要格式化的值
* @returns 格式化后的值
*/
const formatEmptyValue = (value: any): any => {
// 判断值是否为空
if (value === null || value === undefined || value === '') {
return '-'
}
// 如果是数字0,返回0
if (value === 0) {
return '0'
}
return value
}
// 定义列配置接口
export interface ColumnOption {
type?: 'selection' | 'expand' | 'index'
@@ -76,6 +93,27 @@ export function useCheckedColumns(columnsFactory: () => ColumnOption[]) {
const columnMap = new Map<string, ColumnOption>()
cols.forEach((column) => {
// 为普通列添加空值处理
if (!column.type || (column.type !== 'selection' && column.type !== 'expand' && column.type !== 'index')) {
const originalFormatter = column.formatter
// 包装原有的 formatter,在其基础上添加空值处理
column.formatter = (row: any, ...args: any[]) => {
let value
// 如果有自定义 formatter,先执行
if (originalFormatter) {
value = originalFormatter(row, ...args)
} else {
// 没有自定义 formatter,直接取字段值
value = row[column.prop as string]
}
// 对结果进行空值处理
return formatEmptyValue(value)
}
}
if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column)
} else if (column.type === 'expand') {

View File

@@ -10,7 +10,7 @@ import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { HOME_PAGE } from '@/router/routesAlias'
import AppConfig from '@/config'
import { AuthService } from '@/api/authApi'
import { AuthService } from '@/api/modules'
import { ApiStatus } from '@/utils/http/status'
import { MOCK_ACCOUNTS, mockLogin, mockGetUserInfo, type MockAccount } from '@/mock/auth'
import { saveCredentials, getRememberedCredentials } from '@/utils/auth/rememberPassword'

View File

@@ -0,0 +1,63 @@
/**
* 表格右键菜单的组合式函数
* 提供右键菜单功能和鼠标悬浮提示
*/
import { ref, reactive } from 'vue'
export function useTableContextMenu() {
// 鼠标悬浮提示相关
const showContextMenuHint = ref(false)
const hintPosition = reactive({ x: 0, y: 0 })
let hintTimer: any = null
/**
* 为表格行添加类名
*/
const getRowClassName = ({ row, rowIndex }: { row: any; rowIndex: number }) => {
return 'table-row-with-context-menu'
}
/**
* 单元格鼠标进入事件处理
*/
const handleCellMouseEnter = (
row: any,
column: any,
cell: HTMLElement,
event: MouseEvent
) => {
// 清除之前的定时器
if (hintTimer) {
clearTimeout(hintTimer)
}
// 延迟显示提示,避免快速划过时闪烁
hintTimer = setTimeout(() => {
hintPosition.x = event.clientX + 15
hintPosition.y = event.clientY + 10
showContextMenuHint.value = true
}, 300)
}
/**
* 单元格鼠标离开事件处理
*/
const handleCellMouseLeave = () => {
if (hintTimer) {
clearTimeout(hintTimer)
}
showContextMenuHint.value = false
}
return {
// 状态
showContextMenuHint,
hintPosition,
// 方法
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
}
}

View File

@@ -390,10 +390,14 @@
"packageCreate": "Create Package",
"packageBatch": "Batch Management",
"packageList": "My Packages",
"packageDetail": "Package Detail",
"packageChange": "Package Change",
"packageAssign": "Package Assignment",
"packageAssignDetail": "Package Assignment Detail",
"seriesAssign": "Series Assignment",
"seriesAssignDetail": "Series Assignment Detail",
"packageSeries": "Package Series",
"packageSeriesDetail": "Package Series Detail",
"packageCommission": "Package Commission Cards"
},
"accountManagement": {
@@ -406,6 +410,7 @@
"agent": "Agent Management",
"customerAccount": "Customer Account",
"enterpriseCustomer": "Enterprise Customer",
"enterpriseCustomerAccounts": "Enterprise Customer Accounts",
"enterpriseCards": "Enterprise Card Management",
"customerCommission": "Customer Commission"
},
@@ -416,7 +421,8 @@
"withdrawalSettings": "Withdrawal Settings",
"myAccount": "My Account",
"carrierManagement": "Carrier Management",
"orders": "Order Management"
"orders": "Order Management",
"orderDetail": "Order Details"
},
"deviceManagement": {
"title": "Device Management",
@@ -429,7 +435,8 @@
"packageSeries": "Package Series Management",
"packageList": "Package Management",
"packageAssign": "Package Assignment",
"shop": "Shop Management"
"shop": "Shop Management",
"shopAccounts": "Shop Accounts"
},
"assetManagement": {
"title": "Asset Management",
@@ -443,11 +450,17 @@
"devices": "Device Management",
"deviceDetail": "Device Details",
"assetAssign": "Allocation Records",
"assetAssignDetail": "Asset Allocation Details",
"allocationRecordDetail": "Allocation Record Details",
"cardReplacementRequest": "Card Replacement Request",
"authorizationRecords": "Authorization Records",
"authorizationDetail": "Authorization Details",
"enterpriseDevices": "Enterprise Devices"
"authorizationRecordDetail": "Authorization Record Details",
"enterpriseDevices": "Enterprise Devices",
"recordsManagement": "Records Management",
"taskManagement": "Task Management",
"exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail"
},
"settings": {
"title": "Settings Management",

View File

@@ -385,7 +385,7 @@
},
"cardManagement": {
"title": "我的网卡",
"singleCard": "单卡信息",
"singleCard": "资产信息",
"cardList": "网卡管理",
"cardDetail": "网卡明细",
"cardAssign": "网卡分配",
@@ -402,10 +402,12 @@
"packageCreate": "新建套餐",
"packageBatch": "批量管理",
"packageList": "套餐管理",
"packageDetail": "套餐详情",
"packageChange": "套餐变更",
"packageAssign": "单套餐分配",
"seriesAssign": "套餐系列分配",
"seriesGrants": "代理系列授权",
"seriesGrantsDetail": "代理系列授权详情",
"packageSeries": "套餐系列",
"packageSeriesDetail": "套餐系列详情",
"packageCommission": "套餐佣金网卡"
},
"accountManagement": {
@@ -418,13 +420,10 @@
"agent": "代理商管理",
"customerAccount": "客户账号",
"enterpriseCustomer": "企业客户",
"enterpriseCustomerAccounts": "关联账号列表",
"enterpriseCards": "企业卡管理",
"customerCommission": "客户账号佣金"
},
"deviceManagement": {
"title": "设备管理",
"devices": "设备管理"
},
"product": {
"title": "商品管理",
"simCard": "号卡管理",
@@ -432,7 +431,8 @@
"packageSeries": "套餐系列管理",
"packageList": "套餐管理",
"packageAssign": "套餐分配",
"shop": "店铺管理"
"shop": "店铺管理",
"shopAccounts": "店铺账号列表"
},
"assetManagement": {
"title": "资产管理",
@@ -440,23 +440,32 @@
"deviceSearch": "设备查询",
"singleCard": "单卡信息",
"standaloneCardList": "IoT卡管理",
"iotCardDetail": "IoT卡详情",
"iotCardTask": "IoT卡任务",
"deviceTask": "设备任务",
"taskDetail": "任务详情",
"devices": "设备管理",
"deviceDetail": "设备详情",
"assetAssign": "分配记录",
"assetAssignDetail": "资产分配详情",
"allocationRecordDetail": "分配记录详情",
"cardReplacementRequest": "换卡申请",
"authorizationRecords": "授权记录",
"authorizationDetail": "授权记录详情",
"enterpriseDevices": "企业设备列表"
"authorizationRecordDetail": "授权记录详情",
"enterpriseDevices": "企业设备列表",
"recordsManagement": "记录管理",
"taskManagement": "任务管理",
"exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情"
},
"account": {
"title": "账户管理",
"customerAccount": "客户账号",
"myAccount": "我的账户",
"carrierManagement": "运营商管理",
"orders": "订单管理"
"orders": "订单管理",
"orderDetail": "订单详情"
},
"commission": {
"menu": {
@@ -464,7 +473,7 @@
"withdrawal": "提现审批",
"withdrawalSettings": "提现配置",
"myCommission": "我的佣金",
"agentCommission": "代理商佣金管理"
"agentCommission": "代理商佣金"
}
},
"settings": {
@@ -599,7 +608,7 @@
"withdrawal": "提现审批",
"withdrawalSettings": "提现配置",
"myCommission": "我的佣金",
"agentCommission": "代理商佣金管理"
"agentCommission": "代理商佣金"
},
"table": {
"withdrawalNo": "提现单号",

View File

@@ -110,14 +110,22 @@ async function handleRouteGuard(
return
}
// 尝试刷新路由重新注册
// 路由未匹配时,判断是 404 还是 403
if (userStore.isLogin) {
isRouteRegistered.value = false
await handleDynamicRoutes(to, router, next)
// 检查路由是否存在于 asyncRoutes 中
const routeExistsInAsync = checkRouteExistsInAsyncRoutes(to.path, asyncRoutes)
console.log(`[路由检查] 目标路径: ${to.path}, 是否存在于asyncRoutes: ${routeExistsInAsync}`)
if (routeExistsInAsync) {
// 路由存在于 asyncRoutes 但未注册,说明用户没有权限
console.warn('路由存在但用户无权限访问:', to.path)
next(RoutesAlias.Exception403 || '/exception/403')
return
}
}
// 如果以上都不匹配跳转到404
// 路由不存在,跳转到 404
console.log(`[路由检查] 路径 ${to.path} 不存在于asyncRoutes跳转404`)
next(RoutesAlias.Exception404)
}
@@ -317,6 +325,7 @@ function buildRouteMap(routes: AppRouteRecord[], parentPath = ''): Map<string, A
/**
* 将后端菜单数据转换为路由格式
* 递归处理所有层级的 children
*/
function convertBackendMenuToRoute(
menu: any,
@@ -328,6 +337,32 @@ function convertBackendMenuToRoute(
if (!matchedRoute) {
console.warn(`未找到与菜单 URL "${menuUrl}" 匹配的路由定义: ${menu.name}`)
// 如果当前菜单没有匹配的路由,但有 children尝试递归处理 children
if (menu.children && menu.children.length > 0) {
const children = menu.children
.map((child: any) => convertBackendMenuToRoute(child, routeMap, menuUrl))
.filter((child: AppRouteRecord | null) => child !== null)
// 如果子菜单有有效的路由,返回一个包含子菜单的占位路由
if (children.length > 0) {
return {
path: menuUrl,
name: menu.name || menuUrl.replace(/\//g, '_'),
meta: {
title: menu.name,
permission: menu.perm_code,
sort: menu.sort || 0,
icon: menu.icon,
isHide: false,
roles: undefined,
permissions: undefined
},
children: children
} as AppRouteRecord
}
}
return null
}
@@ -347,6 +382,7 @@ function convertBackendMenuToRoute(
}
}
// 递归处理后端返回的 children
if (menu.children && menu.children.length > 0) {
const children = menu.children
.map((child: any) => convertBackendMenuToRoute(child, routeMap, menuUrl))
@@ -425,6 +461,88 @@ function isValidMenuList(menuList: AppRouteRecord[]): boolean {
return Array.isArray(menuList) && menuList.length > 0
}
/**
* 检查路由是否存在于 asyncRoutes 中
* 支持动态参数匹配,如 /account-management/enterprise-customer/customer-accounts/3000 匹配 enterprise-customer/customer-accounts/:id
* @param targetPath 目标路由路径,如 /account-management/enterprise-customer/customer-accounts/3000
* @param routes 路由配置数组
* @param parentPath 父路由路径
*/
function checkRouteExistsInAsyncRoutes(
targetPath: string,
routes: AppRouteRecord[],
parentPath = ''
): boolean {
for (const route of routes) {
// 构建完整路径
const fullPath = route.path.startsWith('/')
? route.path
: parentPath
? `${parentPath}/${route.path}`.replace(/\/+/g, '/')
: `/${route.path}`
// 检查当前路由是否匹配(支持动态参数)
const isMatch = matchRoutePath(targetPath, fullPath)
if (isMatch) {
console.log(`[路由匹配成功] 目标: ${targetPath}, 匹配到: ${fullPath}`)
return true
}
// 递归检查子路由
if (route.children && route.children.length > 0) {
if (checkRouteExistsInAsyncRoutes(targetPath, route.children, fullPath)) {
return true
}
}
}
return false
}
/**
* 匹配路由路径,支持动态参数
* @param targetPath 实际路径,如 /account-management/enterprise-customer/customer-accounts/3000
* @param routePath 路由定义路径,如 /account-management/enterprise-customer/customer-accounts/:id
*/
function matchRoutePath(targetPath: string, routePath: string): boolean {
// 移除查询参数
const cleanTargetPath = targetPath.split('?')[0]
const cleanRoutePath = routePath.split('?')[0]
// 如果完全匹配,直接返回
if (cleanTargetPath === cleanRoutePath) {
return true
}
// 将路径分割成段
const targetSegments = cleanTargetPath.split('/').filter(Boolean)
const routeSegments = cleanRoutePath.split('/').filter(Boolean)
// 段数必须相同
if (targetSegments.length !== routeSegments.length) {
return false
}
// 逐段比较
for (let i = 0; i < routeSegments.length; i++) {
const routeSegment = routeSegments[i]
const targetSegment = targetSegments[i]
// 如果是动态参数(以 : 开头),跳过比较
if (routeSegment.startsWith(':')) {
continue
}
// 如果不是动态参数,必须完全匹配
if (routeSegment !== targetSegment) {
return false
}
}
return true
}
/**
* 重置路由相关状态
*/

View File

@@ -107,10 +107,12 @@ export const hasRoutePermission = (
*/
function checkMenuAccess(path: string, menus: any[]): boolean {
for (const menu of menus) {
// 检查当前菜单的 URL 是否匹配
if (menu.url && (path === menu.url || path.startsWith(menu.url + '/'))) {
// 检查当前菜单的 URL 是否匹配(支持动态参数)
if (menu.url && matchMenuPath(path, menu.url)) {
console.log(`[菜单权限检查] 路径 ${path} 匹配菜单 ${menu.url}`)
return true
}
// 递归检查子菜单
if (menu.children && menu.children.length > 0) {
if (checkMenuAccess(path, menu.children)) {
@@ -118,9 +120,53 @@ function checkMenuAccess(path: string, menus: any[]): boolean {
}
}
}
console.log(`[菜单权限检查] 路径 ${path} 未找到匹配的菜单`)
return false
}
/**
* 匹配菜单路径,支持动态参数
* @param actualPath 实际访问路径,如 /account-management/enterprise-customer/customer-accounts/3000
* @param menuPath 菜单定义路径,如 /account-management/enterprise-customer/customer-accounts/:id
*/
function matchMenuPath(actualPath: string, menuPath: string): boolean {
// 移除查询参数
const cleanActualPath = actualPath.split('?')[0]
const cleanMenuPath = menuPath.split('?')[0]
// 如果完全匹配,直接返回
if (cleanActualPath === cleanMenuPath) {
return true
}
// 将路径分割成段
const actualSegments = cleanActualPath.split('/').filter(Boolean)
const menuSegments = cleanMenuPath.split('/').filter(Boolean)
// 段数必须相同
if (actualSegments.length !== menuSegments.length) {
return false
}
// 逐段比较
for (let i = 0; i < menuSegments.length; i++) {
const menuSegment = menuSegments[i]
const actualSegment = actualSegments[i]
// 如果是动态参数(以 : 开头),跳过比较
if (menuSegment.startsWith(':')) {
continue
}
// 如果不是动态参数,必须完全匹配
if (menuSegment !== actualSegment) {
return false
}
}
return true
}
/**
* 检查 Token 是否有效
* 简单检查,真实项目中应该验证 JWT 或者调用后端接口

View File

@@ -51,6 +51,7 @@ export const asyncRoutes: AppRouteRecord[] = [
}
]
},
// {
// path: '/widgets',
// name: 'Widgets',
@@ -266,6 +267,7 @@ export const asyncRoutes: AppRouteRecord[] = [
// }
// ]
// },
{
path: '/system',
name: 'System',
@@ -296,6 +298,15 @@ export const asyncRoutes: AppRouteRecord[] = [
roles: ['R_SUPER']
}
},
{
path: 'carrier-management',
name: 'CarrierManagement',
component: RoutesAlias.CarrierManagement,
meta: {
title: 'menus.account.carrierManagement',
keepAlive: true
}
},
{
path: 'user-center',
name: 'UserCenter',
@@ -420,6 +431,7 @@ export const asyncRoutes: AppRouteRecord[] = [
// }
]
},
// {
// path: '/article',
// name: 'Article',
@@ -620,15 +632,24 @@ export const asyncRoutes: AppRouteRecord[] = [
// icon: '&#xe81a;'
// },
// children: [
// // {
// // path: 'card-detail',
// // name: 'CardDetail',
// // component: RoutesAlias.CardDetail,
// // meta: {
// // title: 'menus.cardManagement.cardDetail',
// // keepAlive: true
// // }
// // },
// {
// path: 'card-detail',
// name: 'CardDetail',
// component: RoutesAlias.CardDetail,
// meta: {
// title: 'menus.cardManagement.cardDetail',
// keepAlive: true
// }
// },
// {
// path: 'single-card',
// name: 'SingleCard',
// component: RoutesAlias.SingleCard,
// meta: {
// title: 'menus.cardManagement.singleCard',
// keepAlive: true
// }
// },
// {
// path: 'card-assign',
// name: 'CardAssign',
@@ -694,6 +715,7 @@ export const asyncRoutes: AppRouteRecord[] = [
// }
// ]
// },
{
path: '/package-management',
name: 'PackageManagement',
@@ -712,6 +734,16 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'package-series/detail/:id',
name: 'PackageSeriesDetail',
component: RoutesAlias.PackageSeriesDetail,
meta: {
title: 'menus.packageManagement.packageSeriesDetail',
isHide: true,
keepAlive: false
}
},
{
path: 'package-list',
name: 'PackageList',
@@ -722,25 +754,37 @@ export const asyncRoutes: AppRouteRecord[] = [
}
},
{
path: 'package-assign',
name: 'PackageAssign',
component: RoutesAlias.PackageAssign,
path: 'package-list/detail/:id',
name: 'PackageDetail',
component: RoutesAlias.PackageDetail,
meta: {
title: 'menus.packageManagement.packageAssign',
title: 'menus.packageManagement.packageDetail',
isHide: true,
keepAlive: false
}
},
{
path: 'series-grants',
name: 'SeriesGrants',
component: RoutesAlias.SeriesGrants,
meta: {
title: 'menus.packageManagement.seriesGrants',
keepAlive: true
}
},
{
path: 'series-assign',
name: 'SeriesAssign',
component: RoutesAlias.SeriesAssign,
path: 'series-grants/detail/:id',
name: 'SeriesGrantsDetail',
component: RoutesAlias.SeriesGrantsDetail,
meta: {
title: 'menus.packageManagement.seriesAssign',
keepAlive: true
title: 'menus.packageManagement.seriesGrantsDetail',
isHide: true,
keepAlive: false
}
}
]
},
{
path: '/shop-management',
name: 'ShopManagement',
@@ -761,6 +805,7 @@ export const asyncRoutes: AppRouteRecord[] = [
}
]
},
{
path: '/account-management',
name: 'AccountManagement',
@@ -779,24 +824,24 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'platform-account',
name: 'PlatformAccount',
component: RoutesAlias.PlatformAccount,
meta: {
title: 'menus.accountManagement.platformAccount',
keepAlive: true
}
},
{
path: 'shop-account',
name: 'ShopAccount',
component: RoutesAlias.ShopAccount,
meta: {
title: 'menus.accountManagement.shopAccount',
keepAlive: true
}
},
// {
// path: 'platform-account',
// name: 'PlatformAccount',
// component: RoutesAlias.PlatformAccount,
// meta: {
// title: 'menus.accountManagement.platformAccount',
// keepAlive: true
// }
// },
// {
// path: 'shop-account',
// name: 'ShopAccount',
// component: RoutesAlias.ShopAccount,
// meta: {
// title: 'menus.accountManagement.shopAccount',
// keepAlive: true
// }
// },
// {
// path: 'customer-role',
// name: 'CustomerRole',
@@ -815,6 +860,25 @@ export const asyncRoutes: AppRouteRecord[] = [
// keepAlive: true
// }
// },
{
path: 'enterprise-customer',
name: 'EnterpriseCustomer',
component: RoutesAlias.EnterpriseCustomer,
meta: {
title: 'menus.accountManagement.enterpriseCustomer',
keepAlive: true
}
},
{
path: 'enterprise-customer/customer-accounts/:id',
name: 'EnterpriseCustomerAccounts',
component: RoutesAlias.EnterpriseCustomerAccounts,
meta: {
title: 'menus.accountManagement.enterpriseCustomerAccounts',
isHide: true,
keepAlive: false
}
},
{
path: 'enterprise-cards',
name: 'EnterpriseCards',
@@ -827,6 +891,7 @@ export const asyncRoutes: AppRouteRecord[] = [
}
]
},
// {
// path: '/product',
// name: 'Product',
@@ -892,6 +957,7 @@ export const asyncRoutes: AppRouteRecord[] = [
// }
// ]
// },
{
path: '/asset-management',
name: 'AssetManagement',
@@ -901,6 +967,15 @@ export const asyncRoutes: AppRouteRecord[] = [
icon: '&#xe816;'
},
children: [
{
path: 'single-card',
name: 'SingleCard',
component: RoutesAlias.SingleCard,
meta: {
title: 'menus.cardManagement.singleCard',
keepAlive: true
}
},
{
path: 'iot-card-management',
name: 'StandaloneCardList',
@@ -910,6 +985,105 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'devices',
name: 'DeviceList',
component: RoutesAlias.DeviceList,
meta: {
title: 'menus.assetManagement.devices',
keepAlive: true
}
},
{
path: 'enterprise-devices',
name: 'EnterpriseDevices',
component: RoutesAlias.EnterpriseDevices,
meta: {
title: 'menus.assetManagement.enterpriseDevices',
isHide: true,
keepAlive: false
}
},
// 换货管理
{
path: 'exchange-management',
name: 'ExchangeManagement',
component: RoutesAlias.ExchangeManagement,
meta: {
title: 'menus.assetManagement.exchangeManagement',
keepAlive: true
}
},
{
path: 'exchange-management/detail/:id',
name: 'ExchangeDetail',
component: RoutesAlias.ExchangeDetail,
meta: {
title: 'menus.assetManagement.exchangeDetail',
isHide: true,
keepAlive: false
}
},
// 记录管理
{
path: 'records',
name: 'RecordsManagement',
component: '',
meta: {
title: 'menus.assetManagement.recordsManagement',
keepAlive: true
},
children: [
{
path: 'asset-assign',
name: 'AssetAssign',
component: RoutesAlias.AssetAssign,
meta: {
title: 'menus.assetManagement.assetAssign',
keepAlive: true
}
},
{
path: 'asset-assign/detail/:id',
name: 'AssetAssignDetail',
component: RoutesAlias.AssetAssignDetail,
meta: {
title: 'menus.assetManagement.assetAssignDetail',
isHide: true,
keepAlive: false
}
},
{
path: 'authorization-records',
name: 'AuthorizationRecords',
component: RoutesAlias.AuthorizationRecords,
meta: {
title: 'menus.assetManagement.authorizationRecords',
keepAlive: true
}
},
{
path: 'authorization-records/detail/:id',
name: 'AuthorizationRecordDetail',
component: RoutesAlias.AuthorizationRecordDetail,
meta: {
title: 'menus.assetManagement.authorizationRecordDetail',
isHide: true,
keepAlive: false
}
}
]
},
// 任务管理
{
path: 'tasks',
name: 'TaskManagement',
component: '',
meta: {
title: 'menus.assetManagement.taskManagement',
keepAlive: true
},
children: [
{
path: 'iot-card-task',
name: 'IotCardTask',
@@ -929,63 +1103,20 @@ export const asyncRoutes: AppRouteRecord[] = [
}
},
{
path: 'devices',
name: 'DeviceList',
component: RoutesAlias.DeviceList,
path: 'task-detail',
name: 'TaskDetail',
component: RoutesAlias.TaskDetail,
meta: {
title: 'menus.assetManagement.devices',
keepAlive: true
}
},
{
path: 'device-detail',
name: 'DeviceDetail',
component: RoutesAlias.DeviceDetail,
meta: {
title: 'menus.assetManagement.deviceDetail',
isHide: true,
keepAlive: false
}
},
{
path: 'asset-assign',
name: 'AssetAssign',
component: RoutesAlias.AssetAssign,
meta: {
title: 'menus.assetManagement.assetAssign',
keepAlive: true
}
},
{
path: 'card-replacement-request',
name: 'CardReplacementRequest',
component: RoutesAlias.CardReplacementRequest,
meta: {
title: 'menus.assetManagement.cardReplacementRequest',
keepAlive: true
}
},
{
path: 'authorization-records',
name: 'AuthorizationRecords',
component: RoutesAlias.AuthorizationRecords,
meta: {
title: 'menus.assetManagement.authorizationRecords',
keepAlive: true
}
},
{
path: 'enterprise-devices',
name: 'EnterpriseDevices',
component: RoutesAlias.EnterpriseDevices,
meta: {
title: 'menus.assetManagement.enterpriseDevices',
title: 'menus.assetManagement.taskDetail',
isHide: true,
keepAlive: false
}
}
]
}
]
},
{
path: '/account',
name: 'Finance',
@@ -1004,33 +1135,25 @@ export const asyncRoutes: AppRouteRecord[] = [
// keepAlive: true
// }
// },
{
path: 'enterprise-customer',
name: 'EnterpriseCustomer',
component: RoutesAlias.EnterpriseCustomer,
meta: {
title: 'menus.accountManagement.enterpriseCustomer',
keepAlive: true
}
},
{
path: 'carrier-management',
name: 'CarrierManagement',
component: RoutesAlias.CarrierManagement,
meta: {
title: 'menus.account.carrierManagement',
keepAlive: true
}
},
{
path: 'orders',
name: 'OrderManagement',
component: RoutesAlias.OrderList,
component: '/order-management/order-list',
meta: {
title: 'menus.account.orders',
keepAlive: true
}
},
{
path: 'orders/detail/:id',
name: 'OrderDetail',
component: '/order-management/order-list/detail',
meta: {
title: 'menus.account.orderDetail',
isHide: true,
keepAlive: false
}
}
// {
// path: 'my-account',
// name: 'MyAccount',
@@ -1040,10 +1163,44 @@ export const asyncRoutes: AppRouteRecord[] = [
// keepAlive: true
// }
// }
]
},
{
path: 'commission',
path: '/finance',
name: 'FinanceManagement',
component: RoutesAlias.Home,
meta: {
title: '财务管理',
icon: '&#xe816;'
},
children: [
{
path: 'agent-recharge',
name: 'AgentRecharge',
component: RoutesAlias.AgentRecharge,
meta: {
title: '代理充值',
keepAlive: true
}
},
{
path: 'agent-recharge/detail/:id',
name: 'AgentRechargeDetailRoute',
component: RoutesAlias.AgentRechargeDetail,
meta: {
title: '充值详情',
isHide: true,
keepAlive: false
}
}
]
},
{
path: '/commission',
name: 'CommissionManagement',
component: '',
component: RoutesAlias.Home,
meta: {
title: 'menus.commission.menu.management',
icon: '&#xe816;'
@@ -1090,18 +1247,36 @@ export const asyncRoutes: AppRouteRecord[] = [
}
}
]
},
{
path: '/settings',
name: 'Settings',
component: RoutesAlias.Home,
meta: {
title: '设置管理',
icon: '&#xe715;'
},
children: [
{
path: 'wechat-config',
name: 'WechatConfig',
component: RoutesAlias.WechatConfig,
meta: {
title: '微信支付配置',
keepAlive: true
}
},
{
path: 'wechat-config/detail/:id',
name: 'WechatConfigDetailRoute',
component: RoutesAlias.WechatConfigDetail,
meta: {
title: '支付配置详情',
isHide: true,
keepAlive: false
}
]
}
// {
// path: '/settings',
// name: 'Settings',
// component: RoutesAlias.Home,
// meta: {
// title: 'menus.settings.title',
// icon: '&#xe715;'
// },
// children: [
// {
// path: 'payment-merchant',
// name: 'PaymentMerchant',
@@ -1129,6 +1304,6 @@ export const asyncRoutes: AppRouteRecord[] = [
// keepAlive: true
// }
// }
// ]
// }
]
}
]

View File

@@ -68,43 +68,55 @@ export enum RoutesAlias {
PackageCreate = '/package-management/package-create', // 新建套餐
PackageBatch = '/package-management/package-batch', // 批量管理
PackageList = '/package-management/package-list', // 套餐管理
PackageDetail = '/package-management/package-list/detail', // 套餐详情
PackageChange = '/package-management/package-change', // 套餐变更
PackageAssign = '/package-management/package-assign', // 单套餐分配
SeriesAssign = '/package-management/series-assign', // 套餐系列分配
SeriesGrants = '/package-management/series-grants', // 代理系列授权
SeriesGrantsDetail = '/package-management/series-grants/detail', // 代理系列授权详情
PackageSeries = '/package-management/package-series', // 套餐系列
PackageSeriesDetail = '/package-management/package-series/detail', // 套餐系列详情
PackageCommission = '/package-management/package-commission', // 套餐佣金网卡
// 账号管理
Account = '/account-management/account', // 账号管理
PlatformAccount = '/account-management/platform-account', // 平台账号管理
// PlatformAccount = '/account-management/platform-account', // 平台账号管理
CustomerManagement = '/account-management/customer', // 客户管理
CustomerRole = '/account-management/customer-role', // 客户角色
AgentManagement = '/account-management/agent', // 代理商管理
ShopAccount = '/account-management/shop-account', // 代理账号管理
EnterpriseCustomer = '/account-management/enterprise-customer', // 企业客户管理
EnterpriseCustomerAccounts = '/common/account-list', // 企业客户账号列表(通用)
EnterpriseCards = '/account-management/enterprise-cards', // 企业卡管理
CustomerCommission = '/account-management/customer-commission', // 客户账号佣金
// 产品管理
SimCardManagement = '/product/sim-card', // 网卡产品管理
SimCardAssign = '/product/sim-card-assign', // 号卡分配
ShopAccounts = '/common/account-list', // 店铺账号列表(通用)
// 资产管理
StandaloneCardList = '/asset-management/iot-card-management', // IoT卡管理
IotCardTask = '/asset-management/iot-card-task', // IoT卡任务
DeviceTask = '/asset-management/device-task', // 设备任务
TaskDetail = '/asset-management/task-detail', // 任务详情IoT卡/设备任务详情)
DeviceList = '/asset-management/device-list', // 设备列表
DeviceDetail = '/asset-management/device-detail', // 设备详情
AssetAssign = '/asset-management/asset-assign', // 资产分配(分配记录)
AssetAssignDetail = '/asset-management/asset-assign/detail', // 资产分配详情
CardReplacementRequest = '/asset-management/card-replacement-request', // 换卡申请
AuthorizationRecords = '/asset-management/authorization-records', // 授权记录
AuthorizationRecordDetail = '/asset-management/authorization-records/detail', // 授权记录详情
EnterpriseDevices = '/asset-management/enterprise-devices', // 企业设备列表
ExchangeManagement = '/asset-management/exchange-management', // 换货管理
ExchangeDetail = '/asset-management/exchange-management/detail', // 换货单详情
// 账户管理
CustomerAccountList = '/finance/customer-account', // 客户账号
MyAccount = '/finance/my-account', // 我的账户
CarrierManagement = '/finance/carrier-management', // 运营商管理
OrderList = '/order-management/order-list', // 订单管理
OrderList = '/account/orders', // 订单管理
OrderDetail = '/account/orders/detail', // 订单详情
AgentRecharge = '/finance/agent-recharge', // 代理充值
AgentRechargeDetail = '/finance/agent-recharge/detail', // 代理充值详情
// 佣金管理
WithdrawalApproval = '/finance/commission/withdrawal-approval', // 提现审批
@@ -115,7 +127,9 @@ export enum RoutesAlias {
// 设置管理
PaymentMerchant = '/settings/payment-merchant', // 支付商户
DeveloperApi = '/settings/developer-api', // 开发者API
CommissionTemplate = '/settings/commission-template' // 分佣模板
CommissionTemplate = '/settings/commission-template', // 分佣模板
WechatConfig = '/settings/wechat-config', // 微信支付配置
WechatConfigDetail = '/settings/wechat-config/detail' // 微信支付配置详情
}
// 主页路由

View File

@@ -173,7 +173,11 @@ function convertRouteComponent(
// 基础路由配置
const converted: ConvertedRoute = {
...routeConfig,
component: undefined
component: undefined,
meta: {
...routeConfig.meta,
dynamic: true // 标记为动态路由,用于退出时清理
}
}
// 是否为一级菜单
@@ -229,7 +233,10 @@ function handleLayoutRoute(
path: route.path,
name: route.name,
component: loadComponent(component as string, String(route.name)),
meta: route.meta
meta: {
...route.meta,
dynamic: true // 标记为动态路由,用于退出时清理
}
}
]
}

View File

@@ -22,7 +22,9 @@ export interface PlatformAccount {
phone: string
user_type: number // 用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)
enterprise_id?: number | null // 关联企业ID
enterprise_name?: string // ⭐ 新增:企业名称
shop_id?: number | null // 关联店铺ID
shop_name?: string // ⭐ 新增:店铺名称
status: AccountStatus // 状态 (0:禁用, 1:启用)
}
@@ -97,10 +99,12 @@ export interface CustomerAccount {
// 账号查询参数
export interface AccountQueryParams extends PaginationParams {
keyword?: string // 关键词(用户名、姓名、手机号)
roleId?: string | number
status?: AccountStatus
createTimeRange?: [string, string]
username?: string // 用户名模糊查询
phone?: string // 手机号模糊查询
user_type?: number // 用户类型 (1-4)
status?: AccountStatus // 状态 (0:禁用, 1:启用)
shop_id?: number // 按店铺ID筛选
enterprise_id?: number // 按企业ID筛选
}
// 代理商查询参数

View File

@@ -0,0 +1,65 @@
/**
* 代理充值相关类型定义
*/
// 充值状态
export enum AgentRechargeStatus {
PENDING = 1, // 待支付
COMPLETED = 2, // 已完成
CANCELLED = 3 // 已取消
}
// 支付方式
export type AgentRechargePaymentMethod = 'wechat' | 'offline'
// 支付通道
export type AgentRechargePaymentChannel = 'wechat_direct' | 'fuyou' | 'offline'
// 代理充值订单
export interface AgentRecharge {
id: number
recharge_no: string
agent_wallet_id: number
shop_id: number
shop_name: string
amount: number // 充值金额(单位:分)
status: AgentRechargeStatus
payment_method: AgentRechargePaymentMethod
payment_channel: AgentRechargePaymentChannel
payment_config_id: number | null
payment_transaction_id: string
created_at: string
paid_at: string | null
completed_at: string | null
updated_at: string
}
// 查询代理充值订单列表参数
export interface AgentRechargeQueryParams {
page?: number
page_size?: number
shop_id?: number
status?: AgentRechargeStatus
start_date?: string
end_date?: string
}
// 代理充值订单列表响应
export interface AgentRechargeListResponse {
page: number
page_size: number
total: number
list: AgentRecharge[]
}
// 创建代理充值订单请求
export interface CreateAgentRechargeRequest {
amount: number // 充值金额(单位:分),范围 10000 ~ 100000000
payment_method: AgentRechargePaymentMethod
shop_id: number
}
// 确认线下充值请求
export interface ConfirmOfflinePaymentRequest {
operation_password: string
}

277
src/types/api/asset.ts Normal file
View File

@@ -0,0 +1,277 @@
/**
* 资产管理相关类型定义
* 对应文档asset-detail-refactor-api-changes.md
*/
// ========== 资产类型枚举 ==========
// 资产类型
export type AssetType = 'card' | 'device'
// 网络状态
export enum NetworkStatus {
OFFLINE = 0, // 停机
ONLINE = 1 // 开机
}
// 实名状态
export enum RealNameStatus {
NOT_VERIFIED = 0, // 未实名
VERIFYING = 1, // 实名中
VERIFIED = 2 // 已实名
}
// 保护期状态
export type DeviceProtectStatus = 'none' | 'stop' | 'start'
// 套餐使用状态
export enum PackageUsageStatus {
PENDING = 0, // 待生效
ACTIVE = 1, // 生效中
USED_UP = 2, // 已用完
EXPIRED = 3, // 已过期
INVALID = 4 // 已失效
}
// 套餐类型
export type PackageType = 'formal' | 'addon'
// 套餐使用类型
export type UsageType = 'single_card' | 'device'
// ========== 资产详情响应 ==========
/**
* 资产解析/详情响应
* 对应接口GET /api/admin/assets/resolve/:identifier
*/
export interface AssetResolveResponse {
asset_type: AssetType // 资产类型card 或 device
asset_id: number // 数据库 ID
virtual_no: string // 虚拟号
status: number // 资产状态
batch_no: string // 批次号
shop_id: number // 所属店铺 ID
shop_name: string // 所属店铺名称
series_id: number // 套餐系列 ID
series_name: string // 套餐系列名称
real_name_status: RealNameStatus // 实名状态0 未实名 / 1 实名中 / 2 已实名
network_status?: NetworkStatus // 网络状态0 停机 / 1 开机(仅 card
current_package: string // 当前套餐名称(无则空)
package_total_mb: number // 当前套餐总虚流量 MB
package_used_mb: number // 已用虚流量 MB
package_remain_mb: number // 剩余虚流量 MB
device_protect_status?: DeviceProtectStatus // 保护期状态none / stop / start仅 device
activated_at: string // 激活时间
created_at: string // 创建时间
updated_at: string // 更新时间
accumulated_recharge?: number // 累计充值金额(分)
first_commission_paid?: boolean // 一次性佣金是否已发放
// ===== 卡专属字段 (asset_type === 'card' 时) =====
iccid?: string // 卡 ICCID
bound_device_id?: number // 绑定设备 ID
bound_device_no?: string // 绑定设备虚拟号
bound_device_name?: string // 绑定设备名称
carrier_id?: number // 运营商ID
carrier_type?: string // 运营商类型
carrier_name?: string // 运营商名称
msisdn?: string // 手机号
imsi?: string // IMSI
card_category?: string // 卡业务类型
supplier?: string // 供应商
activation_status?: number // 激活状态
enable_polling?: boolean // 是否参与轮询
// ===== 设备专属字段 (asset_type === 'device' 时) =====
bound_card_count?: number // 绑定卡数量
cards?: AssetBoundCard[] // 绑定卡列表
device_name?: string // 设备名称
imei?: string // IMEI
sn?: string // 序列号
device_model?: string // 设备型号
device_type?: string // 设备类型
max_sim_slots?: number // 最大插槽数
manufacturer?: string // 制造商
}
/**
* 设备绑定的卡信息
*/
export interface AssetBoundCard {
card_id: number // 卡 ID
iccid: string // ICCID
msisdn: string // 手机号
network_status: NetworkStatus // 网络状态
real_name_status: RealNameStatus // 实名状态
slot_position: number // 插槽位置
}
// ========== 资产实时状态响应 ==========
/**
* 资产实时状态响应
* 对应接口GET /api/admin/assets/:asset_type/:id/realtime-status
*/
export interface AssetRealtimeStatusResponse {
asset_type: AssetType // 资产类型
asset_id: number // 资产 ID
// ===== 卡专属字段 =====
network_status?: NetworkStatus // 网络状态(仅 card
real_name_status?: RealNameStatus // 实名状态(仅 card
current_month_usage_mb?: number // 本月已用流量 MB仅 card
last_sync_time?: string // 最后同步时间(仅 card
// ===== 设备专属字段 =====
device_protect_status?: DeviceProtectStatus // 保护期(仅 device
cards?: AssetBoundCard[] // 所有绑定卡的状态(仅 device
}
/**
* 资产刷新响应(结构与 realtime-status 完全相同)
* 对应接口POST /api/admin/assets/:asset_type/:id/refresh
*/
export type AssetRefreshResponse = AssetRealtimeStatusResponse
// ========== 资产套餐查询响应 ==========
/**
* 资产套餐使用记录
* 对应接口GET /api/admin/assets/:asset_type/:id/packages
*/
export interface AssetPackageUsageRecord {
package_usage_id?: number // 套餐使用记录 ID
package_id?: number // 套餐 ID
package_name?: string // 套餐名称
package_type?: PackageType // formal正式套餐/ addon加油包
usage_type?: UsageType // 使用类型single_card/device
status?: PackageUsageStatus // 0 待生效 / 1 生效中 / 2 已用完 / 3 已过期 / 4 已失效
status_name?: string // 状态中文名
data_limit_mb?: number // 真流量总量 MB
virtual_limit_mb?: number // 虚流量总量 MB已按 virtual_ratio 换算)
data_usage_mb?: number // 已用真流量 MB
virtual_used_mb?: number // 已用虚流量 MB
virtual_remain_mb?: number // 剩余虚流量 MB
virtual_ratio?: number // 虚流量比例real/virtual
activated_at?: string // 激活时间
expires_at?: string // 到期时间
master_usage_id?: number | null // 主套餐 ID加油包时有值
priority?: number // 优先级
created_at?: string // 创建时间
}
/**
* 当前套餐响应(结构同套餐使用记录单项)
* 对应接口GET /api/admin/assets/:asset_type/:id/current-package
*/
export type AssetCurrentPackageResponse = AssetPackageUsageRecord
// ========== 设备停复机响应 ==========
/**
* 设备批量停机响应
* 对应接口POST /api/admin/assets/device/:device_id/stop
*/
export interface DeviceStopResponse {
message: string // 操作结果描述
success_count: number // 成功停机的卡数量
failed_cards: DeviceStopFailedCard[] // 停机失败列表
}
/**
* 停机失败的卡信息
*/
export interface DeviceStopFailedCard {
iccid: string // ICCID
reason: string // 失败原因
}
/**
* 设备批量复机响应HTTP 200 即成功,无 body
* 对应接口POST /api/admin/assets/device/:device_id/start
*/
export type DeviceStartResponse = void
/**
* 单卡停机响应HTTP 200 即成功,无 body
* 对应接口POST /api/admin/assets/card/:iccid/stop
*/
export type CardStopResponse = void
/**
* 单卡复机响应HTTP 200 即成功,无 body
* 对应接口POST /api/admin/assets/card/:iccid/start
*/
export type CardStartResponse = void
// ========== 钱包流水响应 ==========
/**
* 交易类型
*/
export type TransactionType = 'recharge' | 'deduct' | 'refund'
/**
* 关联业务类型
*/
export type ReferenceType = 'recharge' | 'order'
/**
* 钱包流水单项
*/
export interface AssetWalletTransactionItem {
id?: number // 流水记录ID
transaction_type?: TransactionType // 交易类型recharge/deduct/refund
transaction_type_text?: string // 交易类型文本:充值/扣款/退款
amount?: number // 变动金额(分),充值为正数,扣款/退款为负数
balance_before?: number // 变动前余额(分)
balance_after?: number // 变动后余额(分)
reference_type?: ReferenceType | null // 关联业务类型recharge 或 order可空
reference_no?: string | null // 关联业务编号充值单号CRCH…或订单号ORD…可空
remark?: string | null // 备注(可空)
created_at?: string // 流水创建时间RFC3339
}
/**
* 钱包流水列表响应
* 对应接口GET /api/admin/assets/:asset_type/:id/wallet/transactions
*/
export interface AssetWalletTransactionListResponse {
list?: AssetWalletTransactionItem[] | null // 流水列表
page?: number // 当前页码
page_size?: number // 每页数量
total?: number // 总记录数
total_pages?: number // 总页数
}
/**
* 钱包流水查询参数
*/
export interface AssetWalletTransactionParams {
page?: number // 页码默认1
page_size?: number // 每页数量默认20最大100
transaction_type?: TransactionType | null // 交易类型过滤recharge/deduct/refund
start_time?: string | null // 开始时间RFC3339
end_time?: string | null // 结束时间RFC3339
}
// ========== 钱包概况响应 ==========
/**
* 资产钱包概况响应
* 对应接口GET /api/admin/assets/:asset_type/:id/wallet
*/
export interface AssetWalletResponse {
wallet_id?: number // 钱包数据库ID
resource_id?: number // 对应卡或设备的数据库ID
resource_type?: string // 资源类型iot_card 或 device
balance?: number // 总余额(分)
frozen_balance?: number // 冻结余额(分)
available_balance?: number // 可用余额 = balance - frozen_balance
currency?: string // 币种,目前固定 CNY
status?: number // 钱包状态1-正常 2-冻结 3-关闭
status_text?: string // 状态文本
created_at?: string // 创建时间RFC3339
updated_at?: string // 更新时间RFC3339
}

View File

@@ -312,12 +312,14 @@ export interface StandaloneCardQueryParams extends PaginationParams {
shop_id?: number // 分销商ID
iccid?: string // ICCID(模糊查询)
msisdn?: string // 卡接入号(模糊查询)
virtual_no?: string // 虚拟号(模糊查询)
batch_no?: string // 批次号
package_id?: number // 套餐ID
is_distributed?: boolean // 是否已分销
is_replaced?: boolean // 是否有换卡记录
iccid_start?: string // ICCID起始号
iccid_end?: string // ICCID结束号
series_id?: number // 套餐系列ID
}
// 单卡信息
@@ -326,6 +328,7 @@ export interface StandaloneIotCard {
iccid: string // ICCID
imsi?: string // IMSI (可选)
msisdn?: string // 卡接入号 (可选)
virtual_no?: string // 虚拟号(可空)
carrier_id: number // 运营商ID
carrier_type: string // 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
carrier_name: string // 运营商名称
@@ -347,6 +350,8 @@ export interface StandaloneIotCard {
activated_at?: string | null // 激活时间 (可选)
created_at: string // 创建时间
updated_at: string // 更新时间
series_id?: number | null // 套餐系列ID
series_name?: string // 套餐系列名称
}
// ========== 单卡批量分配和回收相关 ==========
@@ -462,6 +467,7 @@ export interface IotCardDetailResponse {
iccid: string // ICCID
imsi: string // IMSI
msisdn: string // 卡接入号
virtual_no?: string // 虚拟号(可空)
carrier_id: number // 运营商ID
carrier_name: string // 运营商名称
carrier_type: string // 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
@@ -488,7 +494,7 @@ export interface IotCardDetailResponse {
// 批量设置卡的套餐系列绑定请求参数
export interface BatchSetCardSeriesBindingRequest {
iccids: string[] // ICCID列表最多500个
series_allocation_id: number // 套餐系列分配ID0表示清除关联
series_id: number // 套餐系列ID0表示清除关联
}
// 卡套餐系列绑定失败项
@@ -503,3 +509,31 @@ export interface BatchSetCardSeriesBindingResponse {
fail_count: number // 失败数量
failed_items: CardSeriesBindingFailedItem[] | null // 失败详情列表
}
// ========== IoT卡网关操作相关 ==========
// 查询流量使用响应
export interface GatewayFlowUsageResponse {
extend?: string // 扩展字段(广电国网特殊参数)
unit?: string // 流量单位MB
usedFlow?: number // 已用流量
}
// 查询实名认证状态响应
export interface GatewayRealnameStatusResponse {
extend?: string // 扩展字段(广电国网特殊参数)
status?: string // 实名认证状态
}
// 查询卡实时状态响应
export interface GatewayCardStatusResponse {
cardStatus?: string // 卡状态(准备、正常、停机)
extend?: string // 扩展字段(广电国网特殊参数)
iccid?: string // ICCID
}
// 获取实名认证链接响应
export interface GatewayRealnameLinkResponse {
extend?: string // 扩展字段(广电国网特殊参数)
link?: string // 实名认证跳转链接HTTPS URL
}

View File

@@ -184,7 +184,7 @@ export interface ShopCommissionRecordItem {
order_no?: string // 订单号
order_created_at?: string // 订单创建时间
iccid?: string // ICCID
device_no?: string // 设备号
virtual_no?: string // 虚拟号(原 device_no
created_at: string // 佣金入账时间
}

View File

@@ -22,19 +22,10 @@ export interface PaginationParams {
// 分页响应数据
export interface PaginationData<T> {
items: T[] // 后端实际返回的是 items不是 records
total: number
size: number
page: number // 后端返回的是 page不是 current
pages?: number
}
// 新版分页响应数据(使用 list 字段)
export interface PaginationDataV2<T> {
list: T[] // 新版API使用 list 字段
total: number
page_size: number
page: number
items: T[] // 数据列表
total: number // 总数
page_size: number // 每页数量
page: number // 当前页
total_pages: number // 总页数
}
@@ -43,11 +34,6 @@ export interface PaginationResponse<T = any> extends BaseResponse {
data: PaginationData<T>
}
// 新版分页响应(使用 list 字段)
export interface PaginationResponseV2<T = any> extends BaseResponse {
data: PaginationDataV2<T>
}
// 列表响应
export interface ListResponse<T = any> extends BaseResponse {
data: T[]

View File

@@ -19,7 +19,7 @@ export enum DeviceStatus {
// 设备信息
export interface Device {
id: number // 设备ID
device_no: string // 设备号
virtual_no: string // 虚拟号(原 device_no
device_name: string // 设备名称
device_model: string // 设备型号
device_type: string // 设备类型
@@ -34,11 +34,12 @@ export interface Device {
activated_at: string | null // 激活时间
created_at: string // 创建时间
updated_at: string // 更新时间
series_id?: number | null // 套餐系列ID
}
// 设备查询参数
export interface DeviceQueryParams extends PaginationParams {
device_no?: string // 设备号(模糊查询)
virtual_no?: string // 虚拟号(模糊查询,原 device_no)
device_name?: string // 设备名称(模糊查询)
status?: DeviceStatus // 状态
shop_id?: number | null // 店铺ID (NULL表示平台库存)
@@ -47,6 +48,7 @@ export interface DeviceQueryParams extends PaginationParams {
manufacturer?: string // 制造商(模糊查询)
created_at_start?: string // 创建时间起始
created_at_end?: string // 创建时间结束
series_id?: number // 套餐系列ID
}
// 设备列表响应
@@ -105,7 +107,7 @@ export interface AllocateDevicesRequest {
// 分配失败项
export interface AllocationDeviceFailedItem {
device_id: number // 设备ID
device_no: string // 设备号
virtual_no: string // 虚拟号(原 device_no
reason: string // 失败原因
}
@@ -192,7 +194,7 @@ export interface DeviceImportTaskListResponse {
// 导入结果详细项
export interface DeviceImportResultItem {
line: number // 行号
device_no: string // 设备号
virtual_no: string // 虚拟号(原 device_no
reason: string // 原因
}
@@ -207,13 +209,13 @@ export interface DeviceImportTaskDetail extends DeviceImportTask {
// 批量设置设备的套餐系列绑定请求参数
export interface BatchSetDeviceSeriesBindingRequest {
device_ids: number[] // 设备ID列表最多500个
series_allocation_id: number // 套餐系列分配ID0表示清除关联
series_id: number // 套餐系列ID0表示清除关联
}
// 设备套餐系列绑定失败项
export interface DeviceSeriesBindingFailedItem {
device_id: number // 设备ID
device_no: string // 设备号
virtual_no: string // 虚拟号(原 device_no
reason: string // 失败原因
}
@@ -223,3 +225,28 @@ export interface BatchSetDeviceSeriesBindingResponse {
fail_count: number // 失败数量
failed_items: DeviceSeriesBindingFailedItem[] | null // 失败详情列表
}
// ========== 设备操作相关 ==========
// 设置限速请求参数
export interface SetSpeedLimitRequest {
download_speed: number // 下行速率KB/s
upload_speed: number // 上行速率KB/s
}
// 切换SIM卡请求参数
export interface SwitchCardRequest {
target_iccid: string // 目标卡 ICCID
}
// 设置WiFi请求参数
export interface SetWiFiRequest {
enabled: number // 启用状态0:禁用, 1:启用)
ssid: string // WiFi 名称1-32字符
password: string // WiFi 密码8-63字符
}
// 空响应(设备操作成功)
export interface DeviceOperationResponse {
message?: string // 提示信息
}

View File

@@ -18,8 +18,8 @@ export interface FailedItem {
export interface AllocatedDevice {
/** 设备ID */
device_id: number
/** 设备号 */
device_no: string
/** 虚拟号(原 device_no */
virtual_no: string
/** 卡数量 */
card_count: number
/** 卡ICCID列表 */
@@ -92,8 +92,8 @@ export interface DeviceBundleCard {
export interface DeviceBundle {
/** 设备ID */
device_id: number
/** 设备号 */
device_no: string
/** 虚拟号(原 device_no */
virtual_no: string
/** 触发卡(用户选择的卡) */
trigger_card: DeviceBundleCard
/** 连带卡(同设备的其他卡) */
@@ -158,8 +158,8 @@ export interface EnterpriseCardItem {
package_name?: string
/** 设备ID */
device_id?: number | null
/** 设备号 */
device_no?: string
/** 虚拟号(原 device_no */
virtual_no?: string
}
/**
@@ -176,8 +176,8 @@ export interface EnterpriseCardListParams {
carrier_id?: number
/** ICCID模糊查询 */
iccid?: string
/** 设备号(模糊查询) */
device_no?: string
/** 虚拟号(模糊查询,原 device_no */
virtual_no?: string
}
/**
@@ -200,8 +200,8 @@ export interface EnterpriseCardPageResult {
export interface RecalledDevice {
/** 设备ID */
device_id: number
/** 设备号 */
device_no: string
/** 虚拟号(原 device_no */
virtual_no: string
/** 卡数量 */
card_count: number
/** 卡ICCID列表 */

View File

@@ -11,7 +11,7 @@ import { PaginationParams } from '@/types'
*/
export interface EnterpriseDeviceItem {
device_id: number // 设备ID
device_no: string // 设备号
virtual_no: string // 虚拟号(原 device_no
device_name: string // 设备名称
device_model: string // 设备型号
card_count: number // 绑定卡数量
@@ -22,7 +22,7 @@ export interface EnterpriseDeviceItem {
* 企业设备列表查询参数
*/
export interface EnterpriseDeviceListParams extends PaginationParams {
device_no?: string // 设备号(模糊搜索)
virtual_no?: string // 虚拟号(模糊搜索,原 device_no
}
/**
@@ -39,7 +39,7 @@ export interface EnterpriseDevicePageResult {
* 授权设备请求参数
*/
export interface AllocateDevicesRequest {
device_nos: string[] | null // 设备号列表最多100个
virtual_nos: string[] | null // 虚拟号列表最多100个,原 device_nos
remark?: string // 授权备注
}
@@ -48,7 +48,7 @@ export interface AllocateDevicesRequest {
*/
export interface AuthorizedDeviceItem {
device_id: number // 设备ID
device_no: string // 设备号
virtual_no: string // 虚拟号(原 device_no
card_count: number // 绑定卡数量
}
@@ -56,7 +56,7 @@ export interface AuthorizedDeviceItem {
* 失败设备项
*/
export interface FailedDeviceItem {
device_no: string // 设备号
virtual_no: string // 虚拟号(原 device_no
reason: string // 失败原因
}
@@ -76,7 +76,7 @@ export interface AllocateDevicesResponse {
* 撤销设备授权请求参数
*/
export interface RecallDevicesRequest {
device_nos: string[] | null // 设备号列表最多100个
virtual_nos: string[] | null // 虚拟号列表最多100个,原 device_nos
}
/**

View File

@@ -76,3 +76,12 @@ export * from './carrier'
// 订单相关
export * from './order'
// 资产管理相关
export * from './asset'
// 代理充值相关
export * from './agentRecharge'
// 微信支付配置相关
export * from './wechatConfig'

View File

@@ -17,7 +17,7 @@ export type OrderType = 'single_card' | 'device'
export type BuyerType = 'personal' | 'agent'
// 订单支付方式
export type OrderPaymentMethod = 'wallet' | 'wechat' | 'alipay'
export type OrderPaymentMethod = 'wallet' | 'wechat' | 'alipay' | 'offline'
// 订单佣金状态
export enum OrderCommissionStatus {
@@ -84,7 +84,25 @@ export interface CreateOrderRequest {
package_ids: number[]
iot_card_id?: number | null
device_id?: number | null
payment_method: OrderPaymentMethod // 支付方式
}
// 创建订单响应 (返回订单详情)
export type CreateOrderResponse = Order
// 套餐购买预检请求
export interface PurchaseCheckRequest {
order_type: OrderType // 订单类型 (single_card:单卡购买, device:设备购买)
package_ids: number[] // 套餐ID列表
resource_id: number // 资源ID (IoT卡ID或设备ID)
}
// 套餐购买预检响应
export interface PurchaseCheckResponse {
need_force_recharge: boolean // 是否需要强充
force_recharge_amount?: number // 强充金额(分)
total_package_amount?: number // 套餐总价(分)
actual_payment?: number // 实际支付金额(分)
wallet_credit?: number // 钱包到账金额(分)
message?: string // 提示信息
}

View File

@@ -7,6 +7,34 @@ import { PaginationParams } from './common'
// ==================== 套餐系列管理 ====================
/**
* 一次性佣金梯度档位配置(套餐系列级别)
*/
export interface OneTimeCommissionTier {
threshold: number // 达标阈值
dimension: 'sales_count' | 'sales_amount' // 统计维度:销量或销售额
amount: number // 佣金金额(分)
stat_scope?: 'self' | 'self_and_sub' // 统计范围:仅自己或自己+下级
operator?: '>=' | '>' | '<=' | '<' // 阈值比较运算符,空值时计算引擎默认 >=
}
/**
* 套餐系列一次性佣金配置
*/
export interface SeriesOneTimeCommissionConfig {
enable?: boolean // 是否启用一次性佣金
commission_type?: 'fixed' | 'tiered' // 佣金类型:固定或梯度
commission_amount?: number // 固定佣金金额commission_type=fixed时使用
threshold?: number // 触发阈值(分)
trigger_type?: 'first_recharge' | 'accumulated_recharge' // 触发类型:首充或累计充值
tiers?: OneTimeCommissionTier[] | null // 梯度配置列表commission_type=tiered时使用
enable_force_recharge?: boolean // 是否启用强充
force_amount?: number // 强充金额(分)
force_calc_type?: 'fixed' | 'dynamic' // 强充计算类型:固定或动态
validity_type?: 'permanent' | 'fixed_date' | 'relative' // 时效类型:永久、固定日期或相对时长
validity_value?: string // 时效值(日期或月数)
}
/**
* 套餐系列响应
*/
@@ -15,6 +43,8 @@ export interface PackageSeriesResponse {
series_code: string
series_name: string
description?: string
enable_one_time_commission: boolean // 是否启用一次性佣金
one_time_commission_config?: SeriesOneTimeCommissionConfig // 一次性佣金配置
status: number // 1:启用, 2:禁用
created_at: string
updated_at: string
@@ -26,6 +56,7 @@ export interface PackageSeriesResponse {
export interface PackageSeriesQueryParams extends PaginationParams {
series_name?: string // 系列名称(模糊搜索)
status?: number // 状态筛选
enable_one_time_commission?: boolean // 是否启用一次性佣金筛选
}
/**
@@ -35,15 +66,16 @@ export interface CreatePackageSeriesRequest {
series_code: string // 系列编码,必填
series_name: string // 系列名称,必填
description?: string // 描述,可选
one_time_commission_config?: SeriesOneTimeCommissionConfig // 一次性佣金配置,可选
}
/**
* 更新套餐系列请求
*/
export interface UpdatePackageSeriesRequest {
series_code?: string
series_name?: string
description?: string
one_time_commission_config?: SeriesOneTimeCommissionConfig // 一次性佣金配置,可选
}
/**
@@ -55,6 +87,15 @@ export interface UpdatePackageSeriesStatusRequest {
// ==================== 套餐管理 ====================
/**
* 返佣档位信息
*/
export interface CommissionTierInfo {
current_rate?: string // 当前返佣比例
next_rate?: string // 下一档位返佣比例
next_threshold?: number | null // 下一档位阈值
}
/**
* 套餐响应
*/
@@ -62,19 +103,29 @@ export interface PackageResponse {
id: number
package_code: string
package_name: string
series_id: number
series_name?: string
package_type: string // 'formal':正式套餐, 'addon':加油包
data_type: string // 'real':真实流量, 'virtual':虚拟流量
real_data_mb: number // 真流量额度MB
virtual_data_mb: number // 虚流量额度MB
duration_months: number // 有效期(月)
price: number // 价格(分
shelf_status: number // 上架状态 (1:上架, 2:下架)
status: number // 状态 (1:启用, 2:禁用)
series_id: number | null
series_name?: string | null
package_type: string // 'formal':正式套餐, 'addon':附加套餐
calendar_type?: string // 套餐周期类型 (natural_month:自然月, by_day:按天)
duration_days?: number | null // 套餐天数(calendar_type=by_day时有值)
duration_months?: number // 套餐时长(月数)
data_reset_cycle?: string // 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)
real_data_mb?: number // 真流量额度MB
virtual_data_mb?: number // 虚流量额度MB
virtual_ratio?: number // 虚流量比例real_data_mb / virtual_data_mb。启用虚流量时计算否则为 1.0
enable_virtual_data?: boolean // 是否启用虚流量
enable_realname_activation?: boolean // 是否启用实名激活 (true:需实名后激活, false:立即激活)
cost_price?: number // 成本价(分)
suggested_retail_price?: number // 建议零售价(分)
current_commission_rate?: string // 当前返佣比例(仅代理用户可见)
one_time_commission_amount?: number | null // 一次性佣金金额(分,代理视角)
profit_margin?: number | null // 利润空间(分,仅代理用户可见)
tier_info?: CommissionTierInfo // 返佣档位信息
shelf_status?: number // 上架状态 (1:上架, 2:下架)
status?: number // 状态 (1:启用, 2:禁用)
description?: string
created_at: string
updated_at: string
created_at?: string
updated_at?: string
}
/**
@@ -95,30 +146,37 @@ export interface PackageQueryParams extends PaginationParams {
export interface CreatePackageRequest {
package_code: string // 套餐编码,必填
package_name: string // 套餐名称,必填
series_id: number // 所属系列ID必填
package_type: string // 套餐类型,必填
data_type: string // 流量类型,必填
real_data_mb: number // 真流量额度MB必填
virtual_data_mb: number // 虚流量额度MB,必填
duration_months: number // 有效期(月),必填
price: number // 价格(分),必填
description?: string // 描述,可选
series_id?: number | null // 所属系列ID可选
package_type: string // 套餐类型,必填 (formal:正式套餐, addon:附加套餐)
calendar_type?: string | null // 套餐周期类型 (natural_month:自然月, by_day:按天),可选
duration_days?: number | null // 套餐天数(calendar_type=by_day时必填),可选
duration_months: number // 套餐时长(月数),必填
data_reset_cycle?: string | null // 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置),可选
real_data_mb?: number | null // 真流量额度MB可选
virtual_data_mb?: number | null // 虚流量额度MB,可选
enable_virtual_data?: boolean // 是否启用虚流量,可选
enable_realname_activation?: boolean | null // 是否启用实名激活 (true:需实名后激活, false:立即激活),可选
cost_price: number // 成本价(分),必填
suggested_retail_price?: number | null // 建议零售价(分),可选
}
/**
* 更新套餐请求
*/
export interface UpdatePackageRequest {
package_code?: string
package_name?: string
series_id?: number
package_type?: string
data_type?: string
real_data_mb?: number
virtual_data_mb?: number
duration_months?: number
price?: number
description?: string
package_name?: string | null // 套餐名称,可选
series_id?: number | null // 所属系列ID可选
package_type?: string | null // 套餐类型,可选 (formal:正式套餐, addon:附加套餐)
calendar_type?: string | null // 套餐周期类型 (natural_month:自然月, by_day:按天),可选
duration_days?: number | null // 套餐天数(calendar_type=by_day时必填),可选
duration_months?: number | null // 套餐时长(月数),可选
data_reset_cycle?: string | null // 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置),可选
real_data_mb?: number | null // 真流量额度MB可选
virtual_data_mb?: number | null // 虚流量额度MB可选
enable_virtual_data?: boolean | null // 是否启用虚流量,可选
enable_realname_activation?: boolean | null // 是否启用实名激活,可选
cost_price?: number | null // 成本价(分),可选
suggested_retail_price?: number | null // 建议零售价(分),可选
}
/**
@@ -144,159 +202,101 @@ export interface SeriesSelectOption {
series_code: string
}
// ==================== 单套餐分配 ====================
// ==================== 代理系列授权 (新接口) ====================
/**
* 单套餐分配响应
* 佣金梯度配置
*/
export interface ShopPackageAllocationResponse {
id: number
export interface CommissionTier {
operator?: '>=' | '>' | '<=' | '<' // 阈值比较运算符,空值时计算引擎默认 >=
threshold: number // 阈值
amount: number // 佣金金额(分)
dimension?: 'sales_count' | 'sales_amount' // 统计维度 (sales_count:销量, sales_amount:销售额)
stat_scope?: 'self' | 'self_and_sub' // 统计范围 (self:仅自己, self_and_sub:自己+下级)
}
/**
* 套餐信息(用于系列授权)
*/
export interface GrantPackageInfo {
package_id: number
package_code: string
package_name: string
package_name?: string
package_code?: string
cost_price: number // 成本价(分)
shelf_status?: number // 上架状态
status?: number // 状态
}
/**
* 代理系列授权响应
*/
export interface ShopSeriesGrantResponse {
id: number
shop_id: number
shop_name: string
cost_price: number // 覆盖的成本价(分)
calculated_cost_price: number // 原计算成本价(分,供参考)
allocation_id?: number // 关联的系列分配ID
status: number // 1:启用, 2:禁用
created_at: string
updated_at: string
}
/**
* 单套餐分配查询参数
*/
export interface ShopPackageAllocationQueryParams extends PaginationParams {
shop_id?: number // 店铺ID筛选
package_id?: number // 套餐ID筛选
status?: number // 状态筛选
}
/**
* 创建单套餐分配请求
*/
export interface CreateShopPackageAllocationRequest {
package_id: number // 套餐ID必填
shop_id: number // 店铺ID必填
cost_price: number // 覆盖的成本价(分),必填
}
/**
* 更新单套餐分配请求
*/
export interface UpdateShopPackageAllocationRequest {
cost_price: number // 只允许修改成本价
}
/**
* 更新单套餐分配状态请求
*/
export interface UpdateShopPackageAllocationStatusRequest {
status: number // 1:启用, 2:禁用
}
// ==================== 套餐系列分配 ====================
/**
* 基础返佣配置
*/
export interface BaseCommissionConfig {
mode: 'fixed' | 'percent' // 返佣模式:'fixed':固定金额, 'percent':百分比
value: number // 返佣值:固定金额(分)或百分比的千分比(如200表示20%)
}
/**
* 梯度档位配置
*/
export interface TierEntry {
threshold: number // 阈值(销量或销售额)
mode: 'fixed' | 'percent' // 返佣模式
value: number // 返佣值
}
/**
* 梯度返佣配置
*/
export interface TierCommissionConfig {
period_type: 'monthly' | 'quarterly' | 'yearly' // 周期类型
tier_type: 'sales_count' | 'sales_amount' // 梯度类型:销量或销售额
tiers: TierEntry[] // 梯度档位数组
}
/**
* 一次性佣金梯度档位配置
*/
export interface OneTimeCommissionTierEntry {
tier_type: 'sales_count' | 'sales_amount' // 梯度类型:销量或销售额
threshold: number // 阈值
mode: 'fixed' | 'percent' // 返佣模式
value: number // 返佣值
}
/**
* 一次性佣金配置
*/
export interface OneTimeCommissionConfig {
type: 'fixed' | 'tiered' // 佣金类型:'fixed':固定佣金, 'tiered':梯度佣金
trigger: 'single_recharge' | 'accumulated_recharge' // 触发方式:'single_recharge':单笔充值, 'accumulated_recharge':累计充值
threshold: number // 最低阈值(分)
mode?: 'fixed' | 'percent' // 返佣模式(固定佣金时必填)
value?: number // 返佣值(固定佣金时必填)
tiers?: OneTimeCommissionTierEntry[] | null // 梯度档位数组(梯度佣金时必填)
}
/**
* 套餐系列分配响应
*/
export interface ShopSeriesAllocationResponse {
id: number
series_id: number
series_name: string
shop_id: number
shop_name: string
allocator_shop_id: number // 分配者店铺ID
series_code?: string
commission_type: 'fixed' | 'tiered' // 佣金类型:固定或梯度
one_time_commission_amount: number // 固定佣金金额(分)
commission_tiers: CommissionTier[] // 梯度配置列表
force_recharge_locked: boolean // 是否被平台锁定true时前端只读
force_recharge_enabled: boolean // 是否启用强充
force_recharge_amount: number // 强充金额(分)
allocator_shop_id: number // 分配者店铺ID0表示平台
allocator_shop_name: string // 分配者店铺名称
base_commission: BaseCommissionConfig // 基础返佣配置
enable_one_time_commission: boolean // 是否启用一次性佣金
one_time_commission_config?: OneTimeCommissionConfig // 一次性佣金配置(可选)
package_count?: number // 套餐数量
packages?: GrantPackageInfo[] // 套餐列表
status: number // 1:启用, 2:禁用
created_at: string
updated_at: string
updated_at?: string
}
/**
* 套餐系列分配查询参数
* 代理系列授权查询参数
*/
export interface ShopSeriesAllocationQueryParams extends PaginationParams {
export interface ShopSeriesGrantQueryParams extends PaginationParams {
shop_id?: number // 店铺ID筛选
series_id?: number // 系列ID筛选
allocator_shop_id?: number // 分配者店铺ID筛选
status?: number // 状态筛选
}
/**
* 创建套餐系列分配请求
* 创建代理系列授权请求
*/
export interface CreateShopSeriesAllocationRequest {
series_id: number // 套餐系列ID必填
export interface CreateShopSeriesGrantRequest {
shop_id: number // 店铺ID必填
base_commission: BaseCommissionConfig // 基础返佣配置,必填
enable_one_time_commission?: boolean // 是否启用一次性佣金可选默认false
one_time_commission_config?: OneTimeCommissionConfig // 一次性佣金配置当enable_one_time_commission为true时必填
series_id: number // 系列ID,必填
one_time_commission_amount?: number // 固定佣金金额(分),固定模式时必填
commission_tiers?: CommissionTier[] // 梯度配置列表,梯度模式时必填
enable_force_recharge?: boolean // 是否启用强充
force_recharge_amount?: number // 强充金额(分)
packages?: GrantPackageInfo[] // 套餐列表
}
/**
* 更新套餐系列分配请求
* 更新代理系列授权请求
*/
export interface UpdateShopSeriesAllocationRequest {
base_commission?: BaseCommissionConfig // 基础返佣配置
enable_one_time_commission?: boolean // 是否启用一次性佣金
one_time_commission_config?: OneTimeCommissionConfig // 一次性佣金配置
export interface UpdateShopSeriesGrantRequest {
one_time_commission_amount?: number // 固定佣金金额(分)
commission_tiers?: CommissionTier[] // 梯度配置列表
enable_force_recharge?: boolean // 是否启用强充force_recharge_locked=true时忽略
force_recharge_amount?: number // 强充金额(分)
}
/**
* 更新套餐系列分配状态请求
* 管理套餐请求中的套餐项
*/
export interface UpdateShopSeriesAllocationStatusRequest {
status: number // 1:启用, 2:禁用
export interface GrantPackageItem {
package_id?: number // 套餐ID
cost_price?: number // 成本价(分)
remove?: boolean | null // 是否删除该套餐授权true=删除)
}
/**
* 管理套餐请求
*/
export interface ManageGrantPackagesRequest {
packages?: GrantPackageItem[] | null // 套餐操作列表
}

View File

@@ -45,6 +45,7 @@ export interface PermissionTreeNode {
url?: string // 请求路径
platform?: string // 适用端口 (all:全部, web:Web后台, h5:H5端)
sort?: number // 排序值
status?: number // 状态 (0:禁用, 1:启用)
available_for_role_types?: string // 可用角色类型 (1:平台角色, 2:客户角色)
children?: PermissionTreeNode[] // 子权限列表
}

View File

@@ -44,11 +44,3 @@ export interface PlatformRoleFormData {
role_type: RoleType
status: RoleStatus
}
// 权限树节点
export interface PermissionTreeNode {
id: number
label: string
value: string
children?: PermissionTreeNode[]
}

View File

@@ -68,3 +68,26 @@ export interface ShopPageResult {
size?: number // 每页数量
total?: number // 总记录数
}
// ========== 店铺默认角色相关 ==========
// 店铺角色响应
export interface ShopRoleResponse {
role_id: number // 角色ID
role_name: string // 角色名称
role_desc: string // 角色描述
shop_id: number // 店铺ID
status: number // 状态 (0:禁用, 1:启用)
role_type?: number // 角色类型 (1:平台角色, 2:客户角色) - 可选,用于UI显示
}
// 店铺角色列表响应
export interface ShopRolesResponse {
shop_id: number // 店铺ID
roles: ShopRoleResponse[] | null // 角色列表
}
// 分配店铺角色请求
export interface AssignShopRolesRequest {
role_ids: number[] | null // 角色ID列表
}

View File

@@ -0,0 +1,135 @@
/**
* 微信支付配置管理相关类型定义
*/
// 支付渠道类型
export type PaymentProviderType = 'wechat' | 'fuiou'
// 微信支付配置
export interface WechatConfig {
id: number
name: string
description: string
provider_type: PaymentProviderType
is_active: boolean
// 小程序配置
miniapp_app_id: string
miniapp_app_secret: string
// 公众号配置
oa_app_id: string
oa_app_secret: string
oa_token: string
oa_aes_key: string
oa_oauth_redirect_url: string
// 微信支付配置
wx_mch_id: string
wx_api_v2_key: string
wx_api_v3_key: string
wx_notify_url: string
wx_serial_no: string
wx_cert_content: string
wx_key_content: string
// 富友支付配置
fy_api_url: string
fy_ins_cd: string
fy_mchnt_cd: string
fy_term_id: string
fy_notify_url: string
fy_private_key: string
fy_public_key: string
created_at: string
updated_at: string
}
// 查询参数
export interface WechatConfigQueryParams {
page?: number
page_size?: number
provider_type?: PaymentProviderType
is_active?: boolean
}
// 列表响应
export interface WechatConfigListResponse {
items: WechatConfig[]
page: number
page_size: number
total: number
}
// 创建微信支付配置请求
export interface CreateWechatConfigRequest {
name: string
provider_type: PaymentProviderType
description?: string
// 小程序配置
miniapp_app_id?: string
miniapp_app_secret?: string
// 公众号配置
oa_app_id?: string
oa_app_secret?: string
oa_token?: string
oa_aes_key?: string
oa_oauth_redirect_url?: string
// 微信支付配置
wx_mch_id?: string
wx_api_v2_key?: string
wx_api_v3_key?: string
wx_notify_url?: string
wx_serial_no?: string
wx_cert_content?: string
wx_key_content?: string
// 富友支付配置
fy_api_url?: string
fy_ins_cd?: string
fy_mchnt_cd?: string
fy_term_id?: string
fy_notify_url?: string
fy_private_key?: string
fy_public_key?: string
}
// 更新微信支付配置请求
export interface UpdateWechatConfigRequest {
name?: string
description?: string
provider_type?: PaymentProviderType
// 小程序配置
miniapp_app_id?: string
miniapp_app_secret?: string
// 公众号配置
oa_app_id?: string
oa_app_secret?: string
oa_token?: string
oa_aes_key?: string
oa_oauth_redirect_url?: string
// 微信支付配置
wx_mch_id?: string
wx_api_v2_key?: string
wx_api_v3_key?: string
wx_notify_url?: string
wx_serial_no?: string
wx_cert_content?: string
wx_key_content?: string
// 富友支付配置
fy_api_url?: string
fy_ins_cd?: string
fy_mchnt_cd?: string
fy_term_id?: string
fy_notify_url?: string
fy_private_key?: string
fy_public_key?: string
}

View File

@@ -7,7 +7,12 @@
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElButton: (typeof import('element-plus/es'))['ElButton']
const ElButton: typeof import('element-plus/es')['ElButton']
const ElButton2: typeof import('element-plus/es')['ElButton2']
const ElDropdown: typeof import('element-plus/es')['ElDropdown']
const ElDropdown2: typeof import('element-plus/es')['ElDropdown2']
const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
const ElMessage: (typeof import('element-plus/es'))['ElMessage']
const ElMessageBox: (typeof import('element-plus/es'))['ElMessageBox']
const ElNotification: (typeof import('element-plus/es'))['ElNotification']

View File

@@ -8,6 +8,7 @@ import { Option } from '../common'
export type SearchComponentType =
| 'input'
| 'select'
| 'tree-select'
| 'radio'
| 'checkbox'
| 'date'

View File

@@ -56,10 +56,12 @@ declare module 'vue' {
ArtSearchInput: typeof import('./../components/core/forms/art-search-bar/widget/art-search-input/index.vue')['default']
ArtSearchRadio: typeof import('./../components/core/forms/art-search-bar/widget/art-search-radio/index.vue')['default']
ArtSearchSelect: typeof import('./../components/core/forms/art-search-bar/widget/art-search-select/index.vue')['default']
ArtSearchTreeSelect: typeof import('./../components/core/forms/art-search-bar/widget/art-search-tree-select/index.vue')['default']
ArtSettingsPanel: typeof import('./../components/core/layouts/art-settings-panel/index.vue')['default']
ArtSidebarMenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/index.vue')['default']
ArtStatsCard: typeof import('./../components/core/cards/ArtStatsCard.vue')['default']
ArtTable: typeof import('./../components/core/tables/ArtTable.vue')['default']
ArtTableColumn: typeof import('./../components/core/tables/ArtTableColumn.vue')['default']
ArtTableFullScreen: typeof import('./../components/core/tables/ArtTableFullScreen.vue')['default']
ArtTableHeader: typeof import('./../components/core/tables/ArtTableHeader.vue')['default']
ArtTextScroll: typeof import('./../components/core/text-effect/ArtTextScroll.vue')['default']
@@ -73,18 +75,21 @@ declare module 'vue' {
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']
CodeGeneratorButton: typeof import('./../components/business/CodeGeneratorButton.vue')['default']
ColorSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default']
CommentItem: typeof import('./../components/custom/comment-widget/widget/CommentItem.vue')['default']
CommentWidget: typeof import('./../components/custom/comment-widget/index.vue')['default']
CommissionDisplay: typeof import('./../components/business/CommissionDisplay.vue')['default']
ContainerSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default']
CustomerAccountDialog: typeof import('./../components/business/CustomerAccountDialog.vue')['default']
DetailPage: typeof import('./../components/common/DetailPage.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCalendar: typeof import('element-plus/es')['ElCalendar']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
@@ -108,6 +113,7 @@ declare module 'vue' {
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
@@ -120,6 +126,8 @@ declare module 'vue' {
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
@@ -131,6 +139,7 @@ declare module 'vue' {
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTransfer: typeof import('element-plus/es')['ElTransfer']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
@@ -148,7 +157,11 @@ declare module 'vue' {
SettingDrawer: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingDrawer.vue')['default']
SettingHeader: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingHeader.vue')['default']
SettingItem: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default']
SetWiFiDialog: typeof import('./../components/device/SetWiFiDialog.vue')['default']
SidebarSubmenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default']
SpeedLimitDialog: typeof import('./../components/device/SpeedLimitDialog.vue')['default']
SwitchCardDialog: typeof import('./../components/device/SwitchCardDialog.vue')['default']
TableContextMenuHint: typeof import('./../components/core/others/TableContextMenuHint.vue')['default']
ThemeSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default']
}
export interface ComponentCustomProperties {

View File

@@ -0,0 +1,69 @@
/**
* 编码生成工具
*/
/**
* 生成套餐系列编码
* 规则: PS + 年月日时分秒 + 4位随机数
* 示例: PS20260203143025ABCD
*/
export function generateSeriesCode(): string {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
// 生成4位随机大写字母
const randomChars = Array.from({ length: 4 }, () =>
String.fromCharCode(65 + Math.floor(Math.random() * 26))
).join('')
return `PS${year}${month}${day}${hours}${minutes}${seconds}${randomChars}`
}
/**
* 生成套餐编码
* 规则: PKG + 年月日时分秒 + 4位随机数
* 示例: PKG20260203143025ABCD
*/
export function generatePackageCode(): string {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
// 生成4位随机大写字母
const randomChars = Array.from({ length: 4 }, () =>
String.fromCharCode(65 + Math.floor(Math.random() * 26))
).join('')
return `PKG${year}${month}${day}${hours}${minutes}${seconds}${randomChars}`
}
/**
* 生成店铺编码
* 规则: SHOP + 年月日时分秒 + 4位随机数
* 示例: SHOP20260314143025ABCD
*/
export function generateShopCode(): string {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
// 生成4位随机大写字母
const randomChars = Array.from({ length: 4 }, () =>
String.fromCharCode(65 + Math.floor(Math.random() * 26))
).join('')
return `SHOP${year}${month}${day}${hours}${minutes}${seconds}${randomChars}`
}

View File

@@ -0,0 +1,220 @@
/**
* 省市区三级联动数据
* 简化版数据,包含主要省市区
*/
export interface RegionItem {
value: string
label: string
children?: RegionItem[]
}
export const regionData: RegionItem[] = [
{
value: '北京市',
label: '北京市',
children: [
{
value: '北京市',
label: '北京市',
children: [
{ value: '东城区', label: '东城区' },
{ value: '西城区', label: '西城区' },
{ value: '朝阳区', label: '朝阳区' },
{ value: '丰台区', label: '丰台区' },
{ value: '石景山区', label: '石景山区' },
{ value: '海淀区', label: '海淀区' },
{ value: '门头沟区', label: '门头沟区' },
{ value: '房山区', label: '房山区' },
{ value: '通州区', label: '通州区' },
{ value: '顺义区', label: '顺义区' },
{ value: '昌平区', label: '昌平区' },
{ value: '大兴区', label: '大兴区' },
{ value: '怀柔区', label: '怀柔区' },
{ value: '平谷区', label: '平谷区' },
{ value: '密云区', label: '密云区' },
{ value: '延庆区', label: '延庆区' }
]
}
]
},
{
value: '上海市',
label: '上海市',
children: [
{
value: '上海市',
label: '上海市',
children: [
{ value: '黄浦区', label: '黄浦区' },
{ value: '徐汇区', label: '徐汇区' },
{ value: '长宁区', label: '长宁区' },
{ value: '静安区', label: '静安区' },
{ value: '普陀区', label: '普陀区' },
{ value: '虹口区', label: '虹口区' },
{ value: '杨浦区', label: '杨浦区' },
{ value: '闵行区', label: '闵行区' },
{ value: '宝山区', label: '宝山区' },
{ value: '嘉定区', label: '嘉定区' },
{ value: '浦东新区', label: '浦东新区' },
{ value: '金山区', label: '金山区' },
{ value: '松江区', label: '松江区' },
{ value: '青浦区', label: '青浦区' },
{ value: '奉贤区', label: '奉贤区' },
{ value: '崇明区', label: '崇明区' }
]
}
]
},
{
value: '广东省',
label: '广东省',
children: [
{
value: '广州市',
label: '广州市',
children: [
{ value: '荔湾区', label: '荔湾区' },
{ value: '越秀区', label: '越秀区' },
{ value: '海珠区', label: '海珠区' },
{ value: '天河区', label: '天河区' },
{ value: '白云区', label: '白云区' },
{ value: '黄埔区', label: '黄埔区' },
{ value: '番禺区', label: '番禺区' },
{ value: '花都区', label: '花都区' },
{ value: '南沙区', label: '南沙区' },
{ value: '从化区', label: '从化区' },
{ value: '增城区', label: '增城区' }
]
},
{
value: '深圳市',
label: '深圳市',
children: [
{ value: '罗湖区', label: '罗湖区' },
{ value: '福田区', label: '福田区' },
{ value: '南山区', label: '南山区' },
{ value: '宝安区', label: '宝安区' },
{ value: '龙岗区', label: '龙岗区' },
{ value: '盐田区', label: '盐田区' },
{ value: '龙华区', label: '龙华区' },
{ value: '坪山区', label: '坪山区' },
{ value: '光明区', label: '光明区' }
]
},
{
value: '东莞市',
label: '东莞市',
children: [
{ value: '莞城区', label: '莞城区' },
{ value: '南城区', label: '南城区' },
{ value: '东城区', label: '东城区' },
{ value: '万江区', label: '万江区' }
]
},
{
value: '佛山市',
label: '佛山市',
children: [
{ value: '禅城区', label: '禅城区' },
{ value: '南海区', label: '南海区' },
{ value: '顺德区', label: '顺德区' },
{ value: '三水区', label: '三水区' },
{ value: '高明区', label: '高明区' }
]
}
]
},
{
value: '浙江省',
label: '浙江省',
children: [
{
value: '杭州市',
label: '杭州市',
children: [
{ value: '上城区', label: '上城区' },
{ value: '拱墅区', label: '拱墅区' },
{ value: '西湖区', label: '西湖区' },
{ value: '滨江区', label: '滨江区' },
{ value: '萧山区', label: '萧山区' },
{ value: '余杭区', label: '余杭区' },
{ value: '临平区', label: '临平区' },
{ value: '钱塘区', label: '钱塘区' },
{ value: '富阳区', label: '富阳区' },
{ value: '临安区', label: '临安区' }
]
},
{
value: '宁波市',
label: '宁波市',
children: [
{ value: '海曙区', label: '海曙区' },
{ value: '江北区', label: '江北区' },
{ value: '北仑区', label: '北仑区' },
{ value: '镇海区', label: '镇海区' },
{ value: '鄞州区', label: '鄞州区' },
{ value: '奉化区', label: '奉化区' }
]
}
]
},
{
value: '江苏省',
label: '江苏省',
children: [
{
value: '南京市',
label: '南京市',
children: [
{ value: '玄武区', label: '玄武区' },
{ value: '秦淮区', label: '秦淮区' },
{ value: '建邺区', label: '建邺区' },
{ value: '鼓楼区', label: '鼓楼区' },
{ value: '浦口区', label: '浦口区' },
{ value: '栖霞区', label: '栖霞区' },
{ value: '雨花台区', label: '雨花台区' },
{ value: '江宁区', label: '江宁区' },
{ value: '六合区', label: '六合区' },
{ value: '溧水区', label: '溧水区' },
{ value: '高淳区', label: '高淳区' }
]
},
{
value: '苏州市',
label: '苏州市',
children: [
{ value: '姑苏区', label: '姑苏区' },
{ value: '虎丘区', label: '虎丘区' },
{ value: '吴中区', label: '吴中区' },
{ value: '相城区', label: '相城区' },
{ value: '吴江区', label: '吴江区' }
]
}
]
},
{
value: '四川省',
label: '四川省',
children: [
{
value: '成都市',
label: '成都市',
children: [
{ value: '锦江区', label: '锦江区' },
{ value: '青羊区', label: '青羊区' },
{ value: '金牛区', label: '金牛区' },
{ value: '武侯区', label: '武侯区' },
{ value: '成华区', label: '成华区' },
{ value: '龙泉驿区', label: '龙泉驿区' },
{ value: '青白江区', label: '青白江区' },
{ value: '新都区', label: '新都区' },
{ value: '温江区', label: '温江区' },
{ value: '双流区', label: '双流区' },
{ value: '郫都区', label: '郫都区' },
{ value: '新津区', label: '新津区' }
]
}
]
}
]

View File

@@ -5,7 +5,6 @@
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -18,35 +17,50 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增账号</ElButton>
<ElButton @click="showDialog('add')" v-permission="'account:add'">新增账号</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="ID"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '添加账号' : '编辑账号'"
width="500px"
width="30%"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="100px">
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="80px">
<ElFormItem label="账号名称" prop="username">
<ElInput v-model="formData.username" placeholder="请输入账号名称" />
</ElFormItem>
@@ -61,7 +75,7 @@
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="formData.phone" placeholder="请输入手机号" maxlength="11" />
</ElFormItem>
<ElFormItem label="账号类型" prop="user_type">
<ElFormItem v-if="dialogType === 'add'" label="账号类型" prop="user_type">
<ElSelect
v-model="formData.user_type"
placeholder="请选择账号类型"
@@ -69,8 +83,6 @@
>
<ElOption label="超级管理员" :value="1" />
<ElOption label="平台用户" :value="2" />
<ElOption label="代理账号" :value="3" />
<ElOption label="企业账号" :value="4" />
</ElSelect>
</ElFormItem>
</ElForm>
@@ -83,27 +95,92 @@
</ElDialog>
<!-- 分配角色对话框 -->
<ElDialog v-model="roleDialogVisible" title="分配角色" width="500px">
<ElCheckboxGroup v-model="selectedRoles">
<div v-for="role in allRoles" :key="role.ID" style="margin-bottom: 12px">
<ElCheckbox :label="role.ID">
{{ role.role_name }}
<ElTag
:type="role.role_type === 1 ? 'primary' : 'success'"
<ElDialog v-model="roleDialogVisible" width="900px">
<template #header>
<div class="dialog-header">
<span class="dialog-title">分配角色</span>
<div class="account-info">
<span class="account-name">{{ currentAccountName }}</span>
</div>
</div>
</template>
<div class="role-transfer-container">
<!-- 左侧可分配角色列表 -->
<div class="transfer-panel">
<div class="panel-header">
<span class="panel-title">可分配角色</span>
<ElInput
v-model="leftRoleFilter"
placeholder="搜索角色"
clearable
size="small"
style="margin-left: 8px"
>
style="width: 180px"
/>
</div>
<div class="panel-body">
<ElCheckboxGroup v-model="rolesToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="role.ID" class="role-item">
<ElCheckbox :label="role.ID" :disabled="selectedRoles.includes(role.ID)">
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag :type="role.role_type === 1 ? 'primary' : 'success'" size="small">
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</span>
</ElCheckbox>
</div>
</ElCheckboxGroup>
</div>
</div>
<!-- 中间操作按钮 -->
<div class="transfer-buttons">
<ElButton
type="primary"
:icon="'ArrowRight'"
@click="addRoles"
:disabled="rolesToAdd.length === 0"
>
添加
</ElButton>
</div>
<!-- 右侧已分配角色列表 -->
<div class="transfer-panel">
<div class="panel-header">
<span class="panel-title">已分配角色</span>
<ElInput
v-model="rightRoleFilter"
placeholder="搜索角色"
clearable
size="small"
style="width: 180px"
/>
</div>
<div class="panel-body">
<div class="role-list">
<div
v-for="role in filteredAssignedRoles"
:key="role.ID"
class="role-item assigned-role-item"
>
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag :type="role.role_type === 1 ? 'primary' : 'success'" size="small">
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</span>
<ElButton type="danger" size="small" link @click="removeSingleRole(role.ID)">
移除
</ElButton>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="roleDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAssignRoles" :loading="roleSubmitLoading"
>提交</ElButton
>
<ElButton @click="roleDialogVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
@@ -114,38 +191,72 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRoute } from 'vue-router'
import { FormInstance, ElSwitch, ElCheckbox, ElCheckboxGroup, ElTag } from 'element-plus'
import { ElMessageBox, ElMessage } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import { AccountService } from '@/api/modules/account'
import { RoleService } from '@/api/modules/role'
import { ShopService, EnterpriseService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { PlatformRole } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText } from '@/config/constants'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
const { hasAuth } = useAuth()
const route = useRoute()
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
const dialogType = ref('add')
const dialogVisible = ref(false)
const roleDialogVisible = ref(false)
const loading = ref(false)
const roleSubmitLoading = ref(false)
const currentAccountId = ref<number>(0)
const currentAccountName = ref<string>('')
const currentAccountType = ref<number>(0)
const contextMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentRow = ref<any | null>(null)
const selectedRoles = ref<number[]>([])
const allRoles = ref<PlatformRole[]>([])
const rolesToAdd = ref<number[]>([])
const leftRoleFilter = ref('')
const rightRoleFilter = ref('')
// 定义表单搜索初始值
const initialSearchState = {
name: '',
phone: ''
phone: '',
user_type: undefined as number | undefined,
shop_id: undefined as number | undefined,
enterprise_id: undefined as number | undefined,
status: undefined as number | undefined
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
// 店铺和企业列表
const shopList = ref<any[]>([])
const enterpriseList = ref<any[]>([])
const pagination = reactive({
currentPage: 1,
pageSize: 20,
@@ -176,7 +287,7 @@
}
// 表单配置项
const formItems: SearchFormItem[] = [
const formItems = computed<SearchFormItem[]>(() => [
{
label: '账号名称',
prop: 'name',
@@ -194,18 +305,75 @@
clearable: true,
placeholder: '请输入手机号'
}
},
{
label: '账号类型',
prop: 'user_type',
type: 'select',
options: [
{ label: '超级管理员', value: 1 },
{ label: '平台用户', value: 2 },
{ label: '代理账号', value: 3 },
{ label: '企业账号', value: 4 }
],
config: {
clearable: true,
placeholder: '请选择账号类型'
}
]
},
{
label: '关联店铺',
prop: 'shop_id',
type: 'select',
options: shopList.value.map((shop) => ({
label: shop.shop_name,
value: shop.id
})),
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: handleShopSearch,
placeholder: '请输入店铺名称搜索'
}
},
{
label: '关联企业',
prop: 'enterprise_id',
type: 'select',
options: enterpriseList.value.map((enterprise) => ({
label: enterprise.enterprise_name,
value: enterprise.id
})),
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: handleEnterpriseSearch,
placeholder: '请输入企业名称搜索'
}
},
{
label: '状态',
prop: 'status',
type: 'select',
options: STATUS_SELECT_OPTIONS,
config: {
clearable: true,
placeholder: '请选择状态'
}
}
])
// 列配置
const columnOptions = [
{ label: 'ID', prop: 'ID' },
{ label: '账号名称', prop: 'username' },
{ label: '手机号', prop: 'phone' },
{ label: '账号类型', prop: 'user_type_name' },
{ label: '账号类型', prop: 'user_type' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '企业名称', prop: 'enterprise_name' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'CreatedAt' },
{ label: '操作', prop: 'operation' }
{ label: '创建时间', prop: 'created_at' }
]
// 显示对话框
@@ -219,16 +387,20 @@
}
if (type === 'edit' && row) {
formData.id = row.ID
formData.id = row.id
formData.username = row.username
formData.phone = row.phone
formData.user_type = row.user_type
formData.shop_id = row.shop_id || null
formData.enterprise_id = row.enterprise_id || null
formData.password = ''
} else {
formData.id = ''
formData.username = ''
formData.phone = ''
formData.user_type = 2
formData.shop_id = null
formData.enterprise_id = null
formData.password = ''
}
}
@@ -242,7 +414,7 @@
})
.then(async () => {
try {
await AccountService.deleteAccount(row.ID)
await AccountService.deleteAccount(row.id)
ElMessage.success('删除成功')
getAccountList()
} catch (error) {
@@ -256,21 +428,20 @@
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'ID',
label: 'ID'
},
{
prop: 'username',
label: '账号名称'
label: '账号名称',
minWidth: 120
},
{
prop: 'phone',
label: '手机号'
label: '手机号',
width: 130
},
{
prop: 'user_type',
label: '账号类型',
width: 120,
formatter: (row: any) => {
const typeMap: Record<number, string> = {
1: '超级管理员',
@@ -281,9 +452,26 @@
return typeMap[row.user_type] || '-'
}
},
{
prop: 'shop_name',
label: '店铺名称',
minWidth: 150,
formatter: (row: any) => {
return row.shop_name || '-'
}
},
{
prop: 'enterprise_name',
label: '企业名称',
minWidth: 150,
formatter: (row: any) => {
return row.enterprise_name || '-'
}
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: any) => {
return h(ElSwitch, {
modelValue: row.status,
@@ -292,36 +480,17 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
disabled: !hasAuth('account:modify_status'),
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},
{
prop: 'CreatedAt',
prop: 'created_at',
label: '创建时间',
formatter: (row: any) => formatDateTime(row.CreatedAt)
},
{
prop: 'operation',
label: '操作',
fixed: 'right',
formatter: (row: any) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
icon: '&#xe72b;',
onClick: () => showRoleDialog(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteAccount(row)
})
])
}
width: 180,
formatter: (row: any) => formatDateTime(row.created_at)
}
])
@@ -334,12 +503,22 @@
username: '',
password: '',
phone: '',
user_type: 2
user_type: 2,
shop_id: null as number | null,
enterprise_id: null as number | null
})
onMounted(() => {
// 从 URL 查询参数中读取 shop_id
const shopIdParam = route.query.shop_id
if (shopIdParam) {
formFilters.shop_id = Number(shopIdParam)
}
getAccountList()
loadAllRoles()
loadShopList()
loadEnterpriseList()
})
// 加载所有角色列表
@@ -354,21 +533,60 @@
}
}
// 计算属性:过滤后的可分配角色(根据账号类型过滤)
const filteredAvailableRoles = computed(() => {
let roles = allRoles.value
// 根据账号类型过滤角色
if (currentAccountType.value === 1) {
// 超级管理员:不能分配任何角色
return []
} else if (currentAccountType.value === 3) {
// 代理账号:只显示客户角色
roles = roles.filter((role) => role.role_type === 2)
} else if (currentAccountType.value === 4) {
// 企业账号:只显示客户角色
roles = roles.filter((role) => role.role_type === 2)
} else if (currentAccountType.value === 2) {
// 平台用户:只显示平台角色
roles = roles.filter((role) => role.role_type === 1)
}
// 根据搜索关键词过滤
if (!leftRoleFilter.value) return roles
const keyword = leftRoleFilter.value.toLowerCase()
return roles.filter((role) => role.role_name.toLowerCase().includes(keyword))
})
// 计算属性:过滤后的已分配角色
const filteredAssignedRoles = computed(() => {
const assignedRolesList = allRoles.value.filter((role) => selectedRoles.value.includes(role.ID))
if (!rightRoleFilter.value) return assignedRolesList
const keyword = rightRoleFilter.value.toLowerCase()
return assignedRolesList.filter((role) => role.role_name.toLowerCase().includes(keyword))
})
// 显示分配角色对话框
const showRoleDialog = async (row: any) => {
currentAccountId.value = row.ID
currentAccountId.value = row.id
currentAccountName.value = row.username
currentAccountType.value = row.user_type
selectedRoles.value = []
rolesToAdd.value = []
leftRoleFilter.value = ''
rightRoleFilter.value = ''
try {
// 每次打开对话框时重新加载最新的角色列表
await loadAllRoles()
// 先加载当前账号的角色,再打开对话框
const res = await AccountService.getAccountRoles(row.ID)
const res = await AccountService.getAccountRoles(row.id)
if (res.code === 0) {
// 提取角色ID数组
const roles = res.data || []
selectedRoles.value = roles.map((role: any) => role.ID)
// 兼容 ID 和 id 两种字段名
selectedRoles.value = roles.map((role: any) => role.ID || role.id)
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}
@@ -377,19 +595,48 @@
}
}
// 提交分配角色
const handleAssignRoles = async () => {
roleSubmitLoading.value = true
// 批量添加角色
const addRoles = async () => {
if (rolesToAdd.value.length === 0) return
try {
await AccountService.assignRolesToAccount(currentAccountId.value, selectedRoles.value)
ElMessage.success('分配角色成功')
roleDialogVisible.value = false
// 所有账号只能分配一个角色
if (rolesToAdd.value.length > 1) {
ElMessage.warning('只能分配一个角色')
return
}
// 新角色会替换之前的角色
const newRoles = rolesToAdd.value
await AccountService.assignRolesToAccount(currentAccountId.value, newRoles)
selectedRoles.value = newRoles
rolesToAdd.value = []
ElMessage.success('角色分配成功')
// 刷新列表以更新角色显示
await getAccountList()
} catch (error) {
console.error(error)
} finally {
roleSubmitLoading.value = false
console.error('分配角色失败:', error)
}
}
// 移除单个角色
const removeSingleRole = async (roleId: number) => {
try {
// 从已分配列表中移除该角色
const newRoles = selectedRoles.value.filter((id) => id !== roleId)
await AccountService.assignRolesToAccount(currentAccountId.value, newRoles)
selectedRoles.value = newRoles
ElMessage.success('角色移除成功')
// 刷新列表以更新角色显示
await getAccountList()
} catch (error) {
console.error('移除角色失败:', error)
ElMessage.error('角色移除失败')
}
}
@@ -400,7 +647,12 @@
const params = {
page: pagination.currentPage,
pageSize: pagination.pageSize,
keyword: formFilters.name || formFilters.phone || undefined
username: formFilters.name || undefined,
phone: formFilters.phone || undefined,
user_type: formFilters.user_type,
shop_id: formFilters.shop_id,
enterprise_id: formFilters.enterprise_id,
status: formFilters.status
}
const res = await AccountService.getAccounts(params)
if (res.code === 0) {
@@ -438,7 +690,31 @@
{ len: 11, message: '手机号必须为 11 位', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
user_type: [{ required: true, message: '请选择账号类型', trigger: 'change' }]
user_type: [{ required: true, message: '请选择账号类型', trigger: 'change' }],
shop_id: [
{
validator: (rule: any, value: any, callback: any) => {
if (formData.user_type === 3 && !value) {
callback(new Error('代理账号必须关联店铺'))
} else {
callback()
}
},
trigger: 'change'
}
],
enterprise_id: [
{
validator: (rule: any, value: any, callback: any) => {
if (formData.user_type === 4 && !value) {
callback(new Error('企业账号必须关联企业'))
} else {
callback()
}
},
trigger: 'change'
}
]
})
// 提交表单
@@ -448,17 +724,31 @@
await formRef.value.validate(async (valid) => {
if (valid) {
try {
if (dialogType.value === 'add') {
// 创建账号
const data: any = {
username: formData.username,
phone: formData.phone,
password: formData.password,
user_type: formData.user_type
}
if (dialogType.value === 'add') {
data.password = formData.password
// 根据账号类型添加相应的字段
if (formData.user_type === 3) {
data.shop_id = formData.shop_id
} else if (formData.user_type === 4) {
data.enterprise_id = formData.enterprise_id
}
await AccountService.createAccount(data)
ElMessage.success('添加成功')
} else {
// 编辑账号 - 只提交username和phone
const data: any = {
username: formData.username,
phone: formData.phone
}
await AccountService.updateAccount(Number(formData.id), data)
ElMessage.success('更新成功')
}
@@ -489,7 +779,7 @@
// 先更新UI
row.status = newStatus
try {
await AccountService.updateAccount(row.ID, { status: newStatus })
await AccountService.updateAccountStatus(row.id, newStatus as 0 | 1)
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
@@ -497,10 +787,226 @@
console.error(error)
}
}
// 加载店铺列表
const loadShopList = async (keyword: string = '') => {
try {
const res = await ShopService.getShops({
page: 1,
page_size: 20, // 默认加载20条
status: 1, // 只加载启用的店铺
shop_name: keyword || undefined // 根据店铺名称搜索
})
if (res.code === 0) {
shopList.value = res.data.items || []
}
} catch (error) {
console.error('获取店铺列表失败:', error)
}
}
// 店铺搜索处理
const handleShopSearch = (query: string) => {
loadShopList(query)
}
// 加载企业列表
const loadEnterpriseList = async (keyword: string = '') => {
try {
const res = await EnterpriseService.getEnterprises({
page: 1,
page_size: 20, // 默认加载20条
status: 1, // 只加载启用的企业
enterprise_name: keyword || undefined // 根据企业名称搜索
})
if (res.code === 0) {
enterpriseList.value = res.data.items || []
}
} catch (error) {
console.error('获取企业列表失败:', error)
}
}
// 企业搜索处理
const handleEnterpriseSearch = (query: string) => {
loadEnterpriseList(query)
}
// 右键菜单项配置
const contextMenuItems = computed((): MenuItemType[] => {
const items: MenuItemType[] = []
if (hasAuth('account:patch_role')) {
items.push({ key: 'assignRole', label: '分配角色' })
}
if (hasAuth('account:edit')) {
items.push({ key: 'edit', label: '编辑' })
}
if (hasAuth('account:delete')) {
items.push({ key: 'delete', label: '删除' })
}
return items
})
// 处理表格行右键菜单
const handleRowContextMenu = (row: any, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentRow.value) return
switch (item.key) {
case 'assignRole':
showRoleDialog(currentRow.value)
break
case 'edit':
showDialog('edit', currentRow.value)
break
case 'delete':
deleteAccount(currentRow.value)
break
}
}
</script>
<style lang="scss" scoped>
.account-page {
// 账号管理页面样式
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.dialog-title {
font-size: 18px;
font-weight: 600;
}
.account-info {
display: flex;
align-items: center;
gap: 8px;
.account-name {
font-size: 14px;
color: var(--el-text-color-regular);
}
}
}
.role-transfer-container {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 20px;
padding: 20px 0;
min-height: 500px;
.transfer-panel {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--el-border-color);
border-radius: 4px;
overflow: hidden;
max-width: 380px;
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color);
.panel-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 12px;
.role-list {
display: flex;
flex-direction: column;
gap: 8px;
.role-item {
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
transition: all 0.2s;
&:hover {
background: var(--el-fill-color-light);
border-color: var(--el-border-color);
}
.role-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
:deep(.el-checkbox) {
width: 100%;
.el-checkbox__label {
width: 100%;
}
}
}
.assigned-role-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
.role-info {
flex: 1;
}
.el-button {
flex-shrink: 0;
}
}
}
}
}
.transfer-buttons {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
padding: 0 10px;
.el-button {
width: 100px;
}
}
}
</style>

View File

@@ -36,11 +36,18 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="showAllocateDialog">授权卡</ElButton>
<ElButton
type="primary"
@click="showAllocateDialog"
v-permission="'enterprise_cards:allocate'"
>
授权卡
</ElButton>
<ElButton
type="warning"
:disabled="selectedCards.length === 0"
@click="showRecallDialog"
v-permission="'enterprise_cards:batch_recall'"
>
批量回收
</ElButton>
@@ -57,9 +64,13 @@
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@selection-change="handleSelectionChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<ElTableColumn type="selection" width="55" />
@@ -67,11 +78,14 @@
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 授权卡对话框 -->
<ElDialog
v-model="allocateDialogVisible"
title="授权卡给企业"
width="85%"
width="75%"
@close="handleAllocateDialogClose"
>
<!-- 搜索过滤条件 -->
@@ -99,7 +113,7 @@
@selection-change="handleAvailableCardsSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn type="selection" width="55" :selectable="checkCardSelectable" />
<ElTableColumn
v-for="col in availableCardColumns"
:key="col.prop || col.type"
@@ -202,6 +216,14 @@
</div>
</template>
</ElDialog>
<!-- 表格行操作右键菜单 -->
<ArtMenuRight
ref="cardOperationMenuRef"
:menu-items="cardOperationMenuItems"
:menu-width="140"
@select="handleCardOperationMenuSelect"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -215,7 +237,12 @@
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { formatDateTime } from '@/utils/business/format'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { BgColorEnum } from '@/enums/appEnum'
import type {
@@ -228,8 +255,20 @@
defineOptions({ name: 'EnterpriseCards' })
const { hasAuth } = useAuth()
const route = useRoute()
const router = useRouter()
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
const loading = ref(false)
const allocateDialogVisible = ref(false)
const allocateLoading = ref(false)
@@ -254,6 +293,11 @@
const availableCardsLoading = ref(false)
const availableCardsList = ref<StandaloneIotCard[]>([])
const selectedAvailableCards = ref<StandaloneIotCard[]>([])
const allocatedCardIccids = ref<Set<string>>(new Set())
// 右键菜单相关
const cardOperationMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentOperatingCard = ref<EnterpriseCardItem | null>(null)
// 卡搜索表单初始值
const initialCardSearchState = {
@@ -274,7 +318,7 @@
// 搜索表单初始值
const initialSearchState = {
iccid: '',
device_no: '',
virtual_no: '',
carrier_id: undefined as number | undefined,
status: undefined as number | undefined
}
@@ -310,7 +354,7 @@
},
{
label: '设备号',
prop: 'device_no',
prop: 'virtual_no',
type: 'input',
config: {
clearable: true,
@@ -416,15 +460,14 @@
const columnOptions = [
{ label: 'ICCID', prop: 'iccid' },
{ label: '卡接入号', prop: 'msisdn' },
{ label: '设备号', prop: 'device_no' },
{ label: '设备号', prop: 'virtual_no' },
{ label: '运营商ID', prop: 'carrier_id' },
{ label: '运营商', prop: 'carrier_name' },
{ label: '套餐名称', prop: 'package_name' },
{ label: '状态', prop: 'status' },
{ label: '状态名称', prop: 'status_name' },
{ label: '网络状态', prop: 'network_status' },
{ label: '网络状态名称', prop: 'network_status_name' },
{ label: '操作', prop: 'operation' }
{ label: '网络状态名称', prop: 'network_status_name' }
]
const cardList = ref<EnterpriseCardItem[]>([])
@@ -484,7 +527,7 @@
width: 130
},
{
prop: 'device_no',
prop: 'virtual_no',
label: '设备号',
width: 150
},
@@ -530,27 +573,6 @@
prop: 'network_status_name',
label: '网络状态名称',
width: 130
},
{
prop: 'operation',
label: '操作',
width: 100,
fixed: 'right',
formatter: (row: EnterpriseCardItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
row.network_status === 0
? h(ArtButtonTable, {
text: '复机',
iconClass: BgColorEnum.SUCCESS,
onClick: () => handleResume(row)
})
: h(ArtButtonTable, {
text: '停机',
iconClass: BgColorEnum.ERROR,
onClick: () => handleSuspend(row)
})
])
}
}
])
@@ -590,7 +612,7 @@
page: pagination.page,
page_size: pagination.pageSize,
iccid: searchForm.iccid || undefined,
device_no: searchForm.device_no || undefined,
virtual_no: searchForm.virtual_no || undefined,
carrier_id: searchForm.carrier_id,
status: searchForm.status
}
@@ -703,18 +725,6 @@
label: '运营商',
width: 100
},
{
prop: 'cost_price',
label: '成本价',
width: 100,
formatter: (row: StandaloneIotCard) => `¥${(row.cost_price / 100).toFixed(2)}`
},
{
prop: 'distribute_price',
label: '分销价',
width: 100,
formatter: (row: StandaloneIotCard) => `¥${(row.distribute_price / 100).toFixed(2)}`
},
{
prop: 'status',
label: '状态',
@@ -837,10 +847,17 @@
getAvailableCardsList()
}
// 检查卡是否可选(已授权的卡不可选)
const checkCardSelectable = (row: StandaloneIotCard) => {
return !allocatedCardIccids.value.has(row.iccid)
}
// 显示授权对话框
const showAllocateDialog = () => {
allocateDialogVisible.value = true
selectedAvailableCards.value = []
// 收集已授权的卡的ICCID
allocatedCardIccids.value = new Set(cardList.value.map((card) => card.iccid))
// 重置搜索条件
Object.assign(cardSearchForm, { ...initialCardSearchState })
cardPagination.page = 1
@@ -942,6 +959,56 @@
getTableData()
}
// 右键菜单项配置
const cardOperationMenuItems = computed((): MenuItemType[] => {
const items: MenuItemType[] = []
if (!currentOperatingCard.value) return items
if (currentOperatingCard.value.network_status === 0) {
// 停机状态 - 显示复机
if (hasAuth('enterprise_cards:resume')) {
items.push({
key: 'resume',
label: '复机'
})
}
} else {
// 开机状态 - 显示停机
if (hasAuth('enterprise_cards:suspend')) {
items.push({
key: 'suspend',
label: '停机'
})
}
}
return items
})
// 处理右键菜单
const handleRowContextMenu = (row: EnterpriseCardItem, column: any, event: MouseEvent) => {
event.preventDefault()
currentOperatingCard.value = row
nextTick(() => {
cardOperationMenuRef.value?.show(event)
})
}
// 处理菜单选择
const handleCardOperationMenuSelect = (item: MenuItemType) => {
if (!currentOperatingCard.value) return
switch (item.key) {
case 'suspend':
handleSuspend(currentOperatingCard.value)
break
case 'resume':
handleResume(currentOperatingCard.value)
break
}
}
// 停机卡
const handleSuspend = (row: EnterpriseCardItem) => {
ElMessageBox.confirm('确定要停机该卡吗?', '停机卡', {
@@ -1015,4 +1082,8 @@
margin-top: 20px;
}
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
</style>

View File

@@ -6,7 +6,7 @@
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
:label-width="90"
label-width="90"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -19,7 +19,9 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增企业客户</ElButton>
<ElButton @click="showDialog('add')" v-permission="'enterprise_customer:add'"
>新增企业客户</ElButton
>
</template>
</ArtTableHeader>
@@ -33,14 +35,21 @@
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
@@ -78,41 +87,36 @@
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="省份" prop="province">
<ElInput v-model="form.province" placeholder="请输入省份" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="城市" prop="city">
<ElInput v-model="form.city" placeholder="请输入城市" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="区县" prop="district">
<ElInput v-model="form.district" placeholder="请输入区县" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="归属店铺" prop="owner_shop_id">
<ElSelect
v-model="form.owner_shop_id"
placeholder="请选择店铺"
filterable
remote
:remote-method="searchShops"
:loading="shopLoading"
<ElFormItem label="所在地区" prop="region">
<ElCascader
v-model="form.region"
:options="regionData"
placeholder="请选择省/市/区"
clearable
filterable
style="width: 100%"
@change="handleRegionChange"
/>
</ElFormItem>
</ElCol>
<!-- 只有非代理账号才显示归属店铺选择 -->
<ElCol :span="12" v-if="!isAgentAccount">
<ElFormItem label="归属店铺" prop="owner_shop_id">
<ElTreeSelect
v-model="form.owner_shop_id"
:data="shopTreeData"
placeholder="请选择归属店铺"
filterable
clearable
check-strictly
:render-after-expand="false"
:props="{
label: 'shop_name',
value: 'id',
children: 'children'
}"
style="width: 100%"
>
<ElOption
v-for="shop in shopList"
:key="shop.id"
:label="shop.shop_name"
:value="shop.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
@@ -168,12 +172,6 @@
</template>
</ElDialog>
<!-- 客户账号列表弹窗 -->
<CustomerAccountDialog
v-model="customerAccountDialogVisible"
:enterprise-id="currentEnterpriseId"
/>
<!-- 修改密码对话框 -->
<ElDialog v-model="passwordDialogVisible" title="修改密码" width="400px">
<ElForm ref="passwordFormRef" :model="passwordForm" :rules="passwordRules">
@@ -199,6 +197,14 @@
</div>
</template>
</ElDialog>
<!-- 企业客户操作右键菜单 -->
<ArtMenuRight
ref="enterpriseOperationMenuRef"
:menu-items="enterpriseOperationMenuItems"
:menu-width="140"
@select="handleEnterpriseOperationMenuSelect"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -208,30 +214,53 @@
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { EnterpriseService, ShopService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import { ElMessage, ElSwitch, ElCascader, ElTreeSelect } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { EnterpriseItem, ShopResponse } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import CustomerAccountDialog from '@/components/business/CustomerAccountDialog.vue'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import { formatDateTime } from '@/utils/business/format'
import { BgColorEnum } from '@/enums/appEnum'
import { regionData } from '@/utils/constants/regionData'
defineOptions({ name: 'EnterpriseCustomer' })
const { hasAuth } = useAuth()
const userStore = useUserStore()
const router = useRouter()
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
// 判断是否是代理账号 (user_type === 3)
const isAgentAccount = computed(() => userStore.info.user_type === 3)
const dialogVisible = ref(false)
const passwordDialogVisible = ref(false)
const customerAccountDialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const passwordSubmitLoading = ref(false)
const shopLoading = ref(false)
const tableRef = ref()
const currentEnterpriseId = ref<number>(0)
const shopList = ref<ShopResponse[]>([])
const shopTreeData = ref<ShopResponse[]>([])
// 右键菜单
const enterpriseOperationMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentOperatingEnterprise = ref<EnterpriseItem | null>(null)
// 搜索表单初始值
const initialSearchState = {
@@ -354,6 +383,7 @@
enterprise_name: '',
business_license: '',
legal_person: '',
region: [] as string[],
province: '',
city: '',
district: '',
@@ -433,6 +463,7 @@
activeText: '启用',
inactiveText: '禁用',
inlinePrompt: true,
disabled: !hasAuth('enterprise_customer:status'),
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
@@ -447,54 +478,55 @@
{
prop: 'operation',
label: '操作',
width: 340,
width: 190,
fixed: 'right',
formatter: (row: EnterpriseItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
const buttons = []
if (hasAuth('enterprise_customer:look_customer')) {
buttons.push(
h(ArtButtonTable, {
text: '编辑',
iconClass: BgColorEnum.SECONDARY,
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
text: '查看客户',
iconClass: BgColorEnum.PRIMARY,
text: '账号列表',
onClick: () => viewCustomerAccounts(row)
}),
})
)
}
if (hasAuth('enterprise_customer:card_authorization')) {
buttons.push(
h(ArtButtonTable, {
text: '卡授权',
iconClass: BgColorEnum.PRIMARY,
onClick: () => manageCards(row)
}),
h(ArtButtonTable, {
text: '修改密码',
iconClass: BgColorEnum.WARNING,
onClick: () => showPasswordDialog(row)
})
])
)
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
onMounted(() => {
getTableData()
// 只有非代理账号才需要加载店铺列表
if (!isAgentAccount.value) {
loadShopList()
}
})
// 加载店铺列表(默认加载20条)
const loadShopList = async (shopName?: string) => {
// 加载店铺列表(获取所有店铺构建树形结构)
const loadShopList = async () => {
shopLoading.value = true
try {
const params: any = {
page: 1,
pageSize: 20
}
if (shopName) {
params.shop_name = shopName
page_size: 9999 // 获取所有数据用于构建树形结构
}
const res = await ShopService.getShops(params)
if (res.code === 0) {
shopList.value = res.data.items || []
const items = res.data.items || []
// 构建树形数据
shopTreeData.value = buildTreeData(items)
}
} catch (error) {
console.error('获取店铺列表失败:', error)
@@ -503,13 +535,31 @@
}
}
// 搜索店铺
const searchShops = (query: string) => {
if (query) {
loadShopList(query)
// 构建树形数据
const buildTreeData = (items: ShopResponse[]) => {
const map = new Map<number, ShopResponse & { children?: ShopResponse[] }>()
const tree: ShopResponse[] = []
// 先将所有项放入 map
items.forEach((item) => {
map.set(item.id, { ...item, children: [] })
})
// 构建树形结构
items.forEach((item) => {
const node = map.get(item.id)!
if (item.parent_id && map.has(item.parent_id)) {
// 有父节点,添加到父节点的 children 中
const parent = map.get(item.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
loadShopList()
// 没有父节点或父节点不存在,作为根节点
tree.push(node)
}
})
return tree
}
// 获取企业客户列表
@@ -565,6 +615,19 @@
getTableData()
}
// 处理地区选择变化
const handleRegionChange = (value: string[]) => {
if (value && value.length === 3) {
form.province = value[0]
form.city = value[1]
form.district = value[2]
} else {
form.province = ''
form.city = ''
form.district = ''
}
}
const dialogType = ref('add')
// 显示新增/编辑对话框
@@ -580,6 +643,12 @@
form.province = row.province
form.city = row.city
form.district = row.district
// 设置地区级联选择器的值
if (row.province && row.city && row.district) {
form.region = [row.province, row.city, row.district]
} else {
form.region = []
}
form.address = row.address
form.contact_name = row.contact_name
form.contact_phone = row.contact_phone
@@ -591,6 +660,7 @@
form.enterprise_name = ''
form.business_license = ''
form.legal_person = ''
form.region = []
form.province = ''
form.city = ''
form.district = ''
@@ -599,7 +669,8 @@
form.contact_phone = ''
form.login_phone = ''
form.password = ''
form.owner_shop_id = null
// 如果是代理账号,自动设置归属店铺为当前登录用户的店铺
form.owner_shop_id = isAgentAccount.value ? userStore.info.shop_id : null
}
// 重置表单验证状态
@@ -725,8 +796,10 @@
// 查看客户账号
const viewCustomerAccounts = (row: EnterpriseItem) => {
currentEnterpriseId.value = row.id
customerAccountDialogVisible.value = true
router.push({
path: `/account-management/enterprise-customer/customer-accounts/${row.id}`,
query: { type: 'enterprise' }
})
}
// 卡管理
@@ -736,10 +809,61 @@
query: { id: row.id }
})
}
// 企业客户操作菜单项配置
const enterpriseOperationMenuItems = computed((): MenuItemType[] => {
const items: MenuItemType[] = []
if (hasAuth('enterprise_customer:edit')) {
items.push({
key: 'edit',
label: '编辑'
})
}
if (hasAuth('enterprise_customer:update_pwd')) {
items.push({
key: 'updatePassword',
label: '修改密码'
})
}
return items
})
// 显示企业客户操作右键菜单
const showEnterpriseOperationMenu = (e: MouseEvent, row: EnterpriseItem) => {
e.preventDefault()
e.stopPropagation()
currentOperatingEnterprise.value = row
enterpriseOperationMenuRef.value?.show(e)
}
// 处理企业客户操作菜单选择
const handleEnterpriseOperationMenuSelect = (item: MenuItemType) => {
if (!currentOperatingEnterprise.value) return
switch (item.key) {
case 'edit':
showDialog('edit', currentOperatingEnterprise.value)
break
case 'updatePassword':
showPasswordDialog(currentOperatingEnterprise.value)
break
}
}
// 处理表格行右键菜单
const handleRowContextMenu = (row: EnterpriseItem, column: any, event: MouseEvent) => {
// 如果用户有编辑或修改密码权限,显示右键菜单
if (hasAuth('enterprise_customer:edit') || hasAuth('enterprise_customer:update_pwd')) {
showEnterpriseOperationMenu(event, row)
}
}
</script>
<style lang="scss" scoped>
.enterprise-customer-page {
// 可以在这里添加企业客户页面特定样式
<style scoped lang="scss">
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
</style>

View File

@@ -18,7 +18,7 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增平台账号</ElButton>
<ElButton @click="showDialog('add')" v-permission="'platform_account:add'">新增平台账号</ElButton>
</template>
</ArtTableHeader>
@@ -45,9 +45,9 @@
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增平台账号' : '编辑平台账号'"
width="500px"
width="30%"
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="120px">
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="80px">
<ElFormItem label="账号名称" prop="username">
<ElInput
v-model="formData.username"
@@ -79,21 +79,41 @@
<ElOption label="企业账号" :value="4" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="formData.user_type === 3" label="关联店铺ID" prop="shop_id">
<ElInputNumber
<ElFormItem v-if="formData.user_type === 3" label="关联店铺" prop="shop_id">
<ElSelect
v-model="formData.shop_id"
:min="1"
placeholder="请输入店铺ID"
placeholder="请输入店铺名称搜索"
style="width: 100%"
filterable
remote
:remote-method="handleShopSearch"
clearable
>
<ElOption
v-for="shop in shopList"
:key="shop.id"
:label="shop.shop_name"
:value="shop.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="formData.user_type === 4" label="关联企业ID" prop="enterprise_id">
<ElInputNumber
<ElFormItem v-if="formData.user_type === 4" label="关联企业" prop="enterprise_id">
<ElSelect
v-model="formData.enterprise_id"
:min="1"
placeholder="请输入企业ID"
placeholder="请输入企业名称搜索"
style="width: 100%"
filterable
remote
:remote-method="handleEnterpriseSearch"
clearable
>
<ElOption
v-for="enterprise in enterpriseList"
:key="enterprise.id"
:label="enterprise.enterprise_name"
:value="enterprise.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="dialogType === 'edit'" label="状态">
<ElSwitch
@@ -114,18 +134,107 @@
</ElDialog>
<!-- 分配角色对话框 -->
<ElDialog v-model="roleDialogVisible" title="分配角色" width="500px">
<ElCheckboxGroup v-model="selectedRoles">
<div v-for="role in allRoles" :key="role.ID" style="margin-bottom: 12px">
<ElCheckbox :label="role.ID">{{ role.role_name }}</ElCheckbox>
<ElDialog v-model="roleDialogVisible" width="900px">
<template #header>
<div class="dialog-header">
<span class="dialog-title">分配角色</span>
<div class="account-info">
<span class="account-name">{{ currentAccountName }}</span>
</div>
</div>
</template>
<div class="role-transfer-container">
<!-- 左侧可分配角色列表 -->
<div class="transfer-panel">
<div class="panel-header">
<span class="panel-title">可分配角色</span>
<ElInput
v-model="leftRoleFilter"
placeholder="搜索角色"
clearable
size="small"
style="width: 180px"
/>
</div>
<div class="panel-body">
<ElCheckboxGroup v-model="rolesToAdd" class="role-list">
<div
v-for="role in filteredAvailableRoles"
:key="role.ID"
class="role-item"
>
<ElCheckbox :label="role.ID" :disabled="selectedRoles.includes(role.ID)">
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag
:type="role.role_type === 1 ? 'primary' : 'success'"
size="small"
>
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</span>
</ElCheckbox>
</div>
</ElCheckboxGroup>
</div>
</div>
<!-- 中间操作按钮 -->
<div class="transfer-buttons">
<ElButton
type="primary"
:icon="'ArrowRight'"
@click="addRoles"
:disabled="rolesToAdd.length === 0"
>
添加
</ElButton>
</div>
<!-- 右侧已分配角色列表 -->
<div class="transfer-panel">
<div class="panel-header">
<span class="panel-title">已分配角色</span>
<ElInput
v-model="rightRoleFilter"
placeholder="搜索角色"
clearable
size="small"
style="width: 180px"
/>
</div>
<div class="panel-body">
<div class="role-list">
<div
v-for="role in filteredAssignedRoles"
:key="role.ID"
class="role-item assigned-role-item"
>
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag
:type="role.role_type === 1 ? 'primary' : 'success'"
size="small"
>
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</span>
<ElButton
type="danger"
size="small"
link
@click="removeSingleRole(role.ID)"
>
移除
</ElButton>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="roleDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleAssignRoles" :loading="roleSubmitLoading"
>提交</ElButton
>
<ElButton @click="roleDialogVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
@@ -164,15 +273,18 @@
import { FormInstance, ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { PlatformAccountService, RoleService } from '@/api/modules'
import { AccountService, RoleService, ShopService, EnterpriseService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { PlatformRole, PlatformAccountResponse } from '@/types/api'
import type { PlatformRole, PlatformAccount } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
defineOptions({ name: 'PlatformAccount' }) // 定义组件名称,用于 KeepAlive 缓存控制
const { hasAuth } = useAuth()
const dialogType = ref('add')
const dialogVisible = ref(false)
const roleDialogVisible = ref(false)
@@ -182,19 +294,30 @@
const roleSubmitLoading = ref(false)
const passwordSubmitLoading = ref(false)
const currentAccountId = ref<number>(0)
const currentAccountName = ref<string>('')
const selectedRoles = ref<number[]>([])
const allRoles = ref<PlatformRole[]>([])
const rolesToAdd = ref<number[]>([])
const leftRoleFilter = ref('')
const rightRoleFilter = ref('')
// 定义表单搜索初始值
const initialSearchState = {
username: '',
phone: '',
user_type: undefined as number | undefined,
shop_id: undefined as number | undefined,
enterprise_id: undefined as number | undefined,
status: undefined as number | undefined
}
// 响应式表单数据
const searchForm = reactive({ ...initialSearchState })
// 店铺和企业列表
const shopList = ref<any[]>([])
const enterpriseList = ref<any[]>([])
const pagination = reactive({
currentPage: 1,
pageSize: 20,
@@ -202,7 +325,7 @@
})
// 表格数据
const tableData = ref<PlatformAccountResponse[]>([])
const tableData = ref<PlatformAccount[]>([])
// 表格实例引用
const tableRef = ref()
@@ -225,7 +348,7 @@
}
// 表单配置项
const searchFormItems: SearchFormItem[] = [
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '账号名称',
prop: 'username',
@@ -244,6 +367,35 @@
placeholder: '请输入手机号'
}
},
{
label: '账号类型',
prop: 'user_type',
type: 'select',
options: [
{ label: '超级管理员', value: 1 },
{ label: '平台用户', value: 2 },
{ label: '代理账号', value: 3 },
{ label: '企业账号', value: 4 }
],
config: {
clearable: true,
placeholder: '请选择账号类型'
}
},
{
label: '关联店铺',
prop: 'shop_id',
type: 'select',
options: shopList.value.map((shop) => ({
label: shop.shop_name,
value: shop.id
})),
config: {
clearable: true,
filterable: true,
placeholder: '请选择店铺'
}
},
{
label: '状态',
prop: 'status',
@@ -254,21 +406,23 @@
placeholder: '请选择状态'
}
}
]
])
// 列配置
const columnOptions = [
{ label: 'ID', prop: 'ID' },
{ label: 'ID', prop: 'id' },
{ label: '账号名称', prop: 'username' },
{ label: '手机号', prop: 'phone' },
{ label: '账号类型', prop: 'user_type' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '企业名称', prop: 'enterprise_name' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'CreatedAt' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
// 显示对话框
const showDialog = (type: string, row?: PlatformAccountResponse) => {
const showDialog = (type: string, row?: PlatformAccount) => {
dialogVisible.value = true
dialogType.value = type
@@ -278,13 +432,13 @@
}
if (type === 'edit' && row) {
formData.id = row.ID
formData.id = row.id
formData.username = row.username
formData.phone = row.phone
formData.user_type = row.user_type
formData.enterprise_id = row.enterprise_id || null
formData.shop_id = row.shop_id || null
formData.status = row.status
formData.status = row.status as unknown as CommonStatus
formData.password = ''
} else {
formData.id = 0
@@ -299,7 +453,7 @@
}
// 删除账号
const deleteAccount = (row: PlatformAccountResponse) => {
const deleteAccount = (row: PlatformAccount) => {
ElMessageBox.confirm(`确定要删除平台账号 ${row.username} 吗?`, '删除平台账号', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@@ -307,7 +461,7 @@
})
.then(async () => {
try {
await PlatformAccountService.deletePlatformAccount(row.ID)
await AccountService.deleteAccount(row.id)
ElMessage.success('删除成功')
getAccountList()
} catch (error) {
@@ -320,8 +474,8 @@
}
// 显示修改密码对话框
const showPasswordDialog = (row: PlatformAccountResponse) => {
currentAccountId.value = row.ID
const showPasswordDialog = (row: PlatformAccount) => {
currentAccountId.value = row.id
passwordForm.new_password = ''
passwordDialogVisible.value = true
if (passwordFormRef.value) {
@@ -332,21 +486,25 @@
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'ID',
label: 'ID'
prop: 'id',
label: 'id',
width: 80
},
{
prop: 'username',
label: '账号名称'
label: '账号名称',
minWidth: 120
},
{
prop: 'phone',
label: '手机号'
label: '手机号',
width: 130
},
{
prop: 'user_type',
label: '账号类型',
formatter: (row: PlatformAccountResponse) => {
width: 120,
formatter: (row: PlatformAccount) => {
const typeMap: Record<number, string> = {
1: '超级管理员',
2: '平台用户',
@@ -356,10 +514,27 @@
return typeMap[row.user_type] || '-'
}
},
{
prop: 'shop_name',
label: '店铺名称',
minWidth: 150,
formatter: (row: PlatformAccount) => {
return row.shop_name || '-'
}
},
{
prop: 'enterprise_name',
label: '企业名称',
minWidth: 150,
formatter: (row: PlatformAccount) => {
return row.enterprise_name || '-'
}
},
{
prop: 'status',
label: '状态',
formatter: (row: PlatformAccountResponse) => {
width: 100,
formatter: (row: PlatformAccount) => {
return h(ElSwitch, {
modelValue: row.status,
activeValue: CommonStatus.ENABLED,
@@ -373,34 +548,56 @@
}
},
{
prop: 'CreatedAt',
prop: 'created_at',
label: '创建时间',
formatter: (row: PlatformAccountResponse) => formatDateTime(row.CreatedAt)
width: 180,
formatter: (row: PlatformAccount) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 240,
fixed: 'right',
formatter: (row: PlatformAccountResponse) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
formatter: (row: PlatformAccount) => {
const buttons = []
if (hasAuth('platform_account:patch_role')) {
buttons.push(
h(ArtButtonTable, {
icon: '&#xe72b;',
onClick: () => showRoleDialog(row)
}),
})
)
}
if (hasAuth('platform_account:update_psd')) {
buttons.push(
h(ArtButtonTable, {
icon: '&#xe722;',
onClick: () => showPasswordDialog(row)
}),
})
)
}
if (hasAuth('platform_account:edit')) {
buttons.push(
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
})
)
}
if (hasAuth('platform_account:delete')) {
buttons.push(
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteAccount(row)
})
])
)
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
@@ -429,6 +626,8 @@
onMounted(() => {
getAccountList()
loadAllRoles()
loadShopList()
loadEnterpriseList()
})
// 加载所有角色列表
@@ -443,21 +642,41 @@
}
}
// 计算属性:过滤后的可分配角色
const filteredAvailableRoles = computed(() => {
if (!leftRoleFilter.value) return allRoles.value
const keyword = leftRoleFilter.value.toLowerCase()
return allRoles.value.filter((role) => role.role_name.toLowerCase().includes(keyword))
})
// 计算属性:过滤后的已分配角色
const filteredAssignedRoles = computed(() => {
const assignedRolesList = allRoles.value.filter((role) => selectedRoles.value.includes(role.ID))
if (!rightRoleFilter.value) return assignedRolesList
const keyword = rightRoleFilter.value.toLowerCase()
return assignedRolesList.filter((role) => role.role_name.toLowerCase().includes(keyword))
})
// 显示分配角色对话框
const showRoleDialog = async (row: PlatformAccountResponse) => {
currentAccountId.value = row.ID
const showRoleDialog = async (row: PlatformAccount) => {
currentAccountId.value = row.id
currentAccountName.value = row.username
selectedRoles.value = []
rolesToAdd.value = []
leftRoleFilter.value = ''
rightRoleFilter.value = ''
try {
// 每次打开对话框时重新加载最新的角色列表
await loadAllRoles()
// 先加载当前账号的角色,再打开对话框
const res = await PlatformAccountService.getPlatformAccountRoles(row.ID)
const res = await AccountService.getAccountRoles(row.id)
if (res.code === 0) {
// 提取角色ID数组
const roles = res.data || []
selectedRoles.value = roles.map((role: any) => role.ID)
// 兼容 ID 和 id 两种字段名
selectedRoles.value = roles.map((role: any) => role.ID || role.id)
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}
@@ -466,21 +685,42 @@
}
}
// 提交分配角色
const handleAssignRoles = async () => {
roleSubmitLoading.value = true
// 批量添加角色
const addRoles = async () => {
if (rolesToAdd.value.length === 0) return
try {
await PlatformAccountService.assignRolesToPlatformAccount(currentAccountId.value, {
role_ids: selectedRoles.value
})
ElMessage.success('分配角色成功')
roleDialogVisible.value = false
// 将选中的角色添加到已分配列表
const newRoles = [...new Set([...selectedRoles.value, ...rolesToAdd.value])]
await AccountService.assignRolesToAccount(currentAccountId.value, newRoles)
selectedRoles.value = newRoles
rolesToAdd.value = []
ElMessage.success('角色添加成功')
// 刷新列表以更新角色显示
await getAccountList()
} catch (error) {
console.error(error)
} finally {
roleSubmitLoading.value = false
console.error('添加角色失败:', error)
ElMessage.error('角色添加失败')
}
}
// 移除单个角色
const removeSingleRole = async (roleId: number) => {
try {
// 从已分配列表中移除该角色
const newRoles = selectedRoles.value.filter((id) => id !== roleId)
await AccountService.assignRolesToAccount(currentAccountId.value, newRoles)
selectedRoles.value = newRoles
ElMessage.success('角色移除成功')
// 刷新列表以更新角色显示
await getAccountList()
} catch (error) {
console.error('移除角色失败:', error)
ElMessage.error('角色移除失败')
}
}
@@ -492,9 +732,10 @@
if (valid) {
passwordSubmitLoading.value = true
try {
await PlatformAccountService.changePlatformAccountPassword(currentAccountId.value, {
new_password: passwordForm.new_password
})
await AccountService.updateAccountPassword(
currentAccountId.value,
passwordForm.new_password
)
ElMessage.success('修改密码成功')
passwordDialogVisible.value = false
} catch (error) {
@@ -512,12 +753,15 @@
try {
const params = {
page: pagination.currentPage,
page_size: pagination.pageSize,
pageSize: pagination.pageSize,
username: searchForm.username || undefined,
phone: searchForm.phone || undefined,
user_type: searchForm.user_type, // 账号类型筛选(可选)
shop_id: searchForm.shop_id, // 店铺筛选
enterprise_id: searchForm.enterprise_id, // 企业筛选
status: searchForm.status
}
const res = await PlatformAccountService.getPlatformAccounts(params)
const res = await AccountService.getAccounts(params)
if (res.code === 0) {
tableData.value = res.data.items || []
pagination.total = res.data.total || 0
@@ -611,16 +855,15 @@
data.enterprise_id = formData.enterprise_id
}
await PlatformAccountService.createPlatformAccount(data)
await AccountService.createAccount(data)
ElMessage.success('新增成功')
} else {
const data: any = {
username: formData.username,
phone: formData.phone,
status: formData.status
phone: formData.phone
}
await PlatformAccountService.updatePlatformAccount(formData.id, data)
await AccountService.updateAccount(formData.id, data)
ElMessage.success('更新成功')
}
@@ -652,7 +895,7 @@
// 先更新UI
row.status = newStatus
try {
await PlatformAccountService.updatePlatformAccount(row.ID, { status: newStatus })
await AccountService.updateAccountStatus(row.id, newStatus as 0 | 1)
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
@@ -660,10 +903,178 @@
console.error(error)
}
}
// 加载店铺列表
const loadShopList = async (keyword: string = '') => {
try {
const res = await ShopService.getShops({
page: 1,
page_size: 20, // 默认加载20条
status: 1, // 只加载启用的店铺
shop_name: keyword || undefined // 根据店铺名称搜索
})
if (res.code === 0) {
shopList.value = res.data.items || []
}
} catch (error) {
console.error('获取店铺列表失败:', error)
}
}
// 店铺搜索处理
const handleShopSearch = (query: string) => {
loadShopList(query)
}
// 加载企业列表
const loadEnterpriseList = async (keyword: string = '') => {
try {
const res = await EnterpriseService.getEnterprises({
page: 1,
page_size: 20, // 默认加载20条
status: 1, // 只加载启用的企业
enterprise_name: keyword || undefined // 根据企业名称搜索
})
if (res.code === 0) {
enterpriseList.value = res.data.items || []
}
} catch (error) {
console.error('获取企业列表失败:', error)
}
}
// 企业搜索处理
const handleEnterpriseSearch = (query: string) => {
loadEnterpriseList(query)
}
</script>
<style lang="scss" scoped>
.platform-account-page {
// 平台账号管理页面样式
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.dialog-title {
font-size: 18px;
font-weight: 600;
}
.account-info {
display: flex;
align-items: center;
gap: 8px;
.account-name {
font-size: 14px;
color: var(--el-text-color-regular);
}
}
}
.role-transfer-container {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 20px;
padding: 20px 0;
min-height: 500px;
.transfer-panel {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--el-border-color);
border-radius: 4px;
overflow: hidden;
max-width: 380px;
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color);
.panel-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 12px;
.role-list {
display: flex;
flex-direction: column;
gap: 8px;
.role-item {
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
transition: all 0.2s;
&:hover {
background: var(--el-fill-color-light);
border-color: var(--el-border-color);
}
.role-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
:deep(.el-checkbox) {
width: 100%;
.el-checkbox__label {
width: 100%;
}
}
}
.assigned-role-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
.role-info {
flex: 1;
}
.el-button {
flex-shrink: 0;
}
}
}
}
}
.transfer-buttons {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
padding: 0 10px;
.el-button {
width: 100px;
}
}
}
</style>

View File

@@ -17,7 +17,7 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增代理账号</ElButton>
<ElButton @click="showDialog('add')" v-permission="'shop_account:add'">新增代理账号</ElButton>
</template>
</ArtTableHeader>
@@ -125,15 +125,18 @@
import { FormInstance, ElMessage, ElMessageBox, ElSwitch, ElSelect, ElOption } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { ShopAccountService, ShopService } from '@/api/modules'
import { AccountService, ShopService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { ShopAccountResponse, ShopResponse } from '@/types/api'
import type { PlatformAccount, ShopResponse } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
defineOptions({ name: 'ShopAccount' }) // 定义组件名称,用于 KeepAlive 缓存控制
const { hasAuth } = useAuth()
const dialogType = ref('add')
const dialogVisible = ref(false)
const passwordDialogVisible = ref(false)
@@ -163,7 +166,7 @@
})
// 表格数据
const tableData = ref<ShopAccountResponse[]>([])
const tableData = ref<PlatformAccount[]>([])
// 表格实例引用
const tableRef = ref()
@@ -243,14 +246,14 @@
]
// 显示对话框
const showDialog = (type: string, row?: ShopAccountResponse) => {
const showDialog = (type: string, row?: PlatformAccount) => {
dialogType.value = type
if (type === 'edit' && row) {
formData.id = row.id
formData.username = row.username
formData.phone = row.phone
formData.shop_id = row.shop_id
formData.shop_id = row.shop_id || 0
formData.password = ''
} else {
formData.id = 0
@@ -269,7 +272,7 @@
}
// 显示修改密码对话框
const showPasswordDialog = (row: ShopAccountResponse) => {
const showPasswordDialog = (row: PlatformAccount) => {
currentAccountId.value = row.id
passwordForm.new_password = ''
@@ -282,12 +285,12 @@
}
// 状态切换处理
const handleStatusChange = async (row: ShopAccountResponse, newStatus: number) => {
const handleStatusChange = async (row: PlatformAccount, newStatus: number) => {
const oldStatus = row.status
// 先更新UI
row.status = newStatus
try {
await ShopAccountService.updateShopAccountStatus(row.id, { status: newStatus })
await AccountService.updateAccountStatus(row.id, newStatus as 0 | 1)
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
@@ -318,7 +321,7 @@
{
prop: 'status',
label: '状态',
formatter: (row: ShopAccountResponse) => {
formatter: (row: PlatformAccount) => {
return h(ElSwitch, {
modelValue: row.status,
activeValue: CommonStatus.ENABLED,
@@ -334,24 +337,35 @@
{
prop: 'created_at',
label: '创建时间',
formatter: (row: ShopAccountResponse) => formatDateTime(row.created_at)
formatter: (row: PlatformAccount) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 120,
fixed: 'right',
formatter: (row: ShopAccountResponse) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
formatter: (row: PlatformAccount) => {
const buttons = []
if (hasAuth('shop_account:update_psd')) {
buttons.push(
h(ArtButtonTable, {
icon: '&#xe722;',
onClick: () => showPasswordDialog(row)
}),
})
)
}
if (hasAuth('shop_account:edit')) {
buttons.push(
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
})
])
)
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
@@ -447,9 +461,10 @@
if (valid) {
passwordSubmitLoading.value = true
try {
await ShopAccountService.updateShopAccountPassword(currentAccountId.value, {
new_password: passwordForm.new_password
})
await AccountService.updateAccountPassword(
currentAccountId.value,
passwordForm.new_password
)
ElMessage.success('重置密码成功')
passwordDialogVisible.value = false
} catch (error) {
@@ -467,13 +482,14 @@
try {
const params = {
page: pagination.currentPage,
page_size: pagination.pageSize,
pageSize: pagination.pageSize,
user_type: 3, // 筛选代理账号
username: searchForm.username || undefined,
phone: searchForm.phone || undefined,
shop_id: searchForm.shop_id,
status: searchForm.status
}
const res = await ShopAccountService.getShopAccounts(params)
const res = await AccountService.getAccounts(params)
if (res.code === 0) {
tableData.value = res.data.items || []
pagination.total = res.data.total || 0
@@ -536,17 +552,18 @@
username: formData.username,
password: formData.password,
phone: formData.phone,
user_type: 3, // 代理账号
shop_id: formData.shop_id
}
await ShopAccountService.createShopAccount(data)
await AccountService.createAccount(data)
ElMessage.success('新增成功')
} else {
const data = {
username: formData.username
}
await ShopAccountService.updateShopAccount(formData.id, data)
await AccountService.updateAccount(formData.id, data)
ElMessage.success('更新成功')
}

View File

@@ -0,0 +1,135 @@
<template>
<div class="asset-assign-detail">
<ElCard shadow="never">
<!-- 页面头部 -->
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">资产分配详情</h2>
</div>
<!-- 详情内容 -->
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElCard, ElButton, ElIcon, ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { CardService } from '@/api/modules'
import type { AssetAllocationRecord } from '@/types/api/card'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'AssetAssignDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detailData = ref<AssetAllocationRecord | null>(null)
// 详情页配置
const detailSections: DetailSection[] = [
{
title: '基本信息',
fields: [
{ label: '分配单号', prop: 'allocation_no' },
{
label: '分配类型',
formatter: (_, data) => data.allocation_name || '-'
},
{
label: '资产类型',
formatter: (_, data) => data.asset_type_name || '-'
},
{ label: '资产标识符', prop: 'asset_identifier' },
{ label: '来源所有者', prop: 'from_owner_name' },
{ label: '目标所有者', prop: 'to_owner_name' },
{ label: '操作人', prop: 'operator_name' },
{ label: '关联卡数量', prop: 'related_card_count' },
{ label: '创建时间', prop: 'created_at', formatter: (value) => formatDateTime(value) },
{ label: '备注', prop: 'remark', formatter: (value) => value || '-' }
]
}
]
// 返回上一页
const handleBack = () => {
router.back()
}
// 获取详情数据
const fetchDetail = async () => {
const id = Number(route.params.id)
if (!id) {
ElMessage.error('缺少ID参数')
return
}
loading.value = true
try {
const res = await CardService.getAssetAllocationRecordDetail(id)
if (res.code === 0) {
detailData.value = res.data
}
} catch (error) {
console.error(error)
ElMessage.error('获取资产分配详情失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped lang="scss">
.asset-assign-detail {
padding: 20px;
}
.detail-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
color: var(--el-text-color-secondary);
.el-icon {
font-size: 32px;
}
}
</style>

View File

@@ -28,47 +28,28 @@
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
<ElTableColumn label="操作" width="120" fixed="right">
<template #default="scope">
<ElButton type="primary" link @click="viewDetail(scope.row)">查看详情</ElButton>
</template>
</ElTableColumn>
</template>
</ArtTable>
<!-- 分配详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="分配详情" width="700px">
<ElDescriptions v-if="currentRecord" :column="2" border>
<ElDescriptionsItem label="分配单号" :span="2">{{ currentRecord.allocation_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="分配类型">
<ElTag :type="getAllocationTypeType(currentRecord.allocation_type)">
{{ currentRecord.allocation_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="资产类型">
<ElTag :type="getAssetTypeType(currentRecord.asset_type)">
{{ currentRecord.asset_type_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="资产标识符" :span="2">{{ currentRecord.asset_identifier }}</ElDescriptionsItem>
<ElDescriptionsItem label="来源所有者">{{ currentRecord.from_owner_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="目标所有者">{{ currentRecord.to_owner_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="操作人">{{ currentRecord.operator_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="关联卡数量">{{ currentRecord.related_card_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间" :span="2">{{ formatDateTime(currentRecord.created_at) }}</ElDescriptionsItem>
<ElDescriptionsItem label="备注" :span="2">{{ currentRecord.remark || '--' }}</ElDescriptionsItem>
</ElDescriptions>
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="detailDialogVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -81,16 +62,33 @@
import { ElMessage, ElTag } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { formatDateTime } from '@/utils/business/format'
import type { AssetAllocationRecord, AllocationTypeEnum, AssetTypeEnum } from '@/types/api/card'
import { RoutesAlias } from '@/router/routesAlias'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
defineOptions({ name: 'AssetAllocationRecords' })
const { hasAuth } = useAuth()
const router = useRouter()
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
const loading = ref(false)
const detailDialogVisible = ref(false)
const tableRef = ref()
const currentRecord = ref<AssetAllocationRecord | null>(null)
const contextMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentRow = ref<AssetAllocationRecord | null>(null)
// 搜索表单初始值
const initialSearchState = {
@@ -205,7 +203,20 @@
{
prop: 'allocation_no',
label: '分配单号',
minWidth: 180
minWidth: 200,
formatter: (row: AssetAllocationRecord) => {
return h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleNameClick(row)
}
},
row.allocation_no
)
}
},
{
prop: 'allocation_name',
@@ -338,8 +349,42 @@
// 查看详情
const viewDetail = (row: AssetAllocationRecord) => {
currentRecord.value = row
detailDialogVisible.value = true
router.push({
path: `${RoutesAlias.AssetAssign}/detail/${row.id}`
})
}
// 处理名称点击
const handleNameClick = (row: AssetAllocationRecord) => {
if (hasAuth('asset_assign:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 右键菜单项配置
const contextMenuItems = computed((): MenuItemType[] => {
const items: MenuItemType[] = []
return items
})
// 处理表格行右键菜单
const handleRowContextMenu = (row: AssetAllocationRecord, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentRow.value) return
switch (item.key) {
// No cases available
}
}
</script>
@@ -347,4 +392,8 @@
.asset-allocation-records-page {
// Allocation records page styles
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="authorization-record-detail">
<ElCard shadow="never">
<!-- 页面头部 -->
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">授权记录详情</h2>
</div>
<!-- 详情内容 -->
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElCard, ElButton, ElIcon, ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { AuthorizationService } from '@/api/modules'
import type { AuthorizationItem } from '@/types/api/authorization'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'AuthorizationRecordDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detailData = ref<AuthorizationItem | null>(null)
// 详情页配置
const detailSections: DetailSection[] = [
{
title: '基本信息',
fields: [
{ label: '企业ID', prop: 'enterprise_id' },
{ label: '企业名称', prop: 'enterprise_name' },
{ label: '卡ID', prop: 'card_id' },
{ label: 'ICCID', prop: 'iccid' },
{ label: '手机号', prop: 'msisdn', formatter: (value) => value || '-' },
{ label: '授权人ID', prop: 'authorized_by' },
{ label: '授权人', prop: 'authorizer_name' },
{
label: '授权人类型',
formatter: (_, data) => {
return data.authorizer_type === 2 ? '平台' : '代理'
}
},
{ label: '授权时间', prop: 'authorized_at', formatter: (value) => formatDateTime(value) },
{
label: '状态',
formatter: (_, data) => {
return data.status === 1 ? '有效' : '已回收'
}
},
{ label: '备注', prop: 'remark', formatter: (value) => value || '-' }
]
}
]
// 返回上一页
const handleBack = () => {
router.back()
}
// 获取详情数据
const fetchDetail = async () => {
const id = Number(route.params.id)
if (!id) {
ElMessage.error('缺少ID参数')
return
}
loading.value = true
try {
const res = await AuthorizationService.getAuthorizationDetail(id)
if (res.code === 0) {
detailData.value = res.data
}
} catch (error) {
console.error(error)
ElMessage.error('获取授权记录详情失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped lang="scss">
.authorization-record-detail {
padding: 20px;
}
.detail-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
color: var(--el-text-color-secondary);
.el-icon {
font-size: 32px;
}
}
</style>

View File

@@ -28,43 +28,28 @@
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 授权详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="授权详情" width="700px">
<ElDescriptions v-if="currentRecord" :column="2" border>
<ElDescriptionsItem label="企业ID">{{ currentRecord.enterprise_id }}</ElDescriptionsItem>
<ElDescriptionsItem label="企业名称">{{ currentRecord.enterprise_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡ID">{{ currentRecord.card_id }}</ElDescriptionsItem>
<ElDescriptionsItem label="ICCID">{{ currentRecord.iccid }}</ElDescriptionsItem>
<ElDescriptionsItem label="手机号">{{ currentRecord.msisdn || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="授权人ID">{{ currentRecord.authorized_by }}</ElDescriptionsItem>
<ElDescriptionsItem label="授权人">{{ currentRecord.authorizer_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="授权人类型">
<ElTag :type="getAuthorizerTypeTag(currentRecord.authorizer_type)">
{{ getAuthorizerTypeText(currentRecord.authorizer_type) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="授权时间" :span="2">{{ formatDateTime(currentRecord.authorized_at) }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态" :span="2">
<ElTag :type="getStatusTag(currentRecord.status)">
{{ getStatusText(currentRecord.status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="备注" :span="2">{{ currentRecord.remark || '--' }}</ElDescriptionsItem>
</ElDescriptions>
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="detailDialogVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
<!-- 修改备注对话框 -->
<ElDialog v-model="remarkDialogVisible" title="修改备注" width="500px">
@@ -102,26 +87,43 @@
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import type {
AuthorizationItem,
AuthorizationStatus,
AuthorizerType
} from '@/types/api/authorization'
import { CommonStatus } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'AuthorizationRecords' })
const { hasAuth } = useAuth()
const router = useRouter()
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
const loading = ref(false)
const detailDialogVisible = ref(false)
const remarkDialogVisible = ref(false)
const remarkLoading = ref(false)
const tableRef = ref()
const remarkFormRef = ref<FormInstance>()
const currentRecordId = ref<number>(0)
const currentRecord = ref<AuthorizationItem | null>(null)
const contextMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentRow = ref<AuthorizationItem | null>(null)
// 搜索表单初始值
const initialSearchState = {
@@ -211,8 +213,7 @@
{ label: '授权人类型', prop: 'authorizer_type' },
{ label: '授权时间', prop: 'authorized_at' },
{ label: '状态', prop: 'status' },
{ label: '备注', prop: 'remark' },
{ label: '操作', prop: 'operation' }
{ label: '备注', prop: 'remark' }
]
const authorizationList = ref<AuthorizationItem[]>([])
@@ -242,7 +243,20 @@
{
prop: 'iccid',
label: 'ICCID',
minWidth: 200
minWidth: 200,
formatter: (row: AuthorizationItem) => {
return h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleNameClick(row)
}
},
row.iccid
)
}
},
{
prop: 'msisdn',
@@ -290,24 +304,6 @@
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: AuthorizationItem) => row.remark || '-'
},
{
prop: 'operation',
label: '操作',
width: 150,
fixed: 'right',
formatter: (row: AuthorizationItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showRemarkDialog(row)
})
])
}
}
])
@@ -384,8 +380,18 @@
// 查看详情
const viewDetail = (row: AuthorizationItem) => {
currentRecord.value = row
detailDialogVisible.value = true
router.push({
path: `${RoutesAlias.AuthorizationRecords}/detail/${row.id}`
})
}
// 处理名称点击
const handleNameClick = (row: AuthorizationItem) => {
if (hasAuth('authorization_records:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 显示修改备注对话框
@@ -418,10 +424,44 @@
}
})
}
// 右键菜单项配置
const contextMenuItems = computed((): MenuItemType[] => {
const items: MenuItemType[] = []
if (hasAuth('authorization_records:update_remark')) {
items.push({ key: 'edit', label: '编辑' })
}
return items
})
// 处理表格行右键菜单
const handleRowContextMenu = (row: AuthorizationItem, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentRow.value) return
switch (item.key) {
case 'edit':
showRemarkDialog(currentRow.value)
break
}
}
</script>
<style lang="scss" scoped>
.authorization-records-page {
// Authorization records page styles
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
</style>

View File

@@ -1,352 +0,0 @@
<template>
<div class="device-detail-page">
<ElPageHeader @back="handleBack" title="设备详情" />
<ElCard shadow="never" style="margin-top: 20px" v-loading="loading">
<template #header>
<div style="display: flex; align-items: center; justify-content: space-between">
<span style="font-weight: bold">基本信息</span>
<ElTag :type="statusTypeMap[deviceInfo?.status || 1]">
{{ deviceInfo?.status_name || '-' }}
</ElTag>
</div>
</template>
<ElDescriptions :column="3" border v-if="deviceInfo">
<ElDescriptionsItem label="设备ID">{{ deviceInfo.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号">{{ deviceInfo.device_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{ deviceInfo.device_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{ deviceInfo.device_model }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{ deviceInfo.device_type }}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{ deviceInfo.manufacturer }}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{ deviceInfo.max_sim_slots }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数">
<span style="font-weight: bold; color: #67c23a">{{ deviceInfo.bound_card_count }}</span>
/ {{ deviceInfo.max_sim_slots }}
</ElDescriptionsItem>
<ElDescriptionsItem label="所属店铺">
{{ deviceInfo.shop_name || '平台库存' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">
{{ deviceInfo.batch_no || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">
{{ deviceInfo.activated_at ? formatDateTime(deviceInfo.activated_at) : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(deviceInfo.created_at) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never" style="margin-top: 20px" v-loading="cardsLoading">
<template #header>
<div style="display: flex; align-items: center; justify-content: space-between">
<span style="font-weight: bold">绑定的卡列表</span>
<ElButton
type="primary"
size="small"
@click="showBindDialog"
:disabled="!deviceInfo || deviceInfo.bound_card_count >= deviceInfo.max_sim_slots"
>
绑定新卡
</ElButton>
</div>
</template>
<ElTable :data="cardList" border>
<ElTableColumn prop="slot_position" label="插槽位置" width="100" align="center">
<template #default="{ row }">
<ElTag type="info" size="small">插槽 {{ row.slot_position }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="iccid" label="ICCID" minWidth="180" />
<ElTableColumn prop="msisdn" label="接入号" width="150">
<template #default="{ row }">
{{ row.msisdn || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="carrier_name" label="运营商" width="120" />
<ElTableColumn prop="status" label="卡状态" width="100">
<template #default="{ row }">
<ElTag :type="cardStatusTypeMap[row.status]" size="small">
{{ cardStatusTextMap[row.status] || '未知' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="bind_time" label="绑定时间" width="180">
<template #default="{ row }">
{{ row.bind_time ? formatDateTime(row.bind_time) : '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<ElButton type="danger" text size="small" @click="handleUnbindCard(row)">
解绑
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-if="!cardList.length" description="暂无绑定的卡" />
</ElCard>
<!-- 绑定卡对话框 -->
<ElDialog v-model="bindDialogVisible" title="绑定卡到设备" width="500px">
<ElForm ref="bindFormRef" :model="bindForm" :rules="bindRules" label-width="100px">
<ElFormItem label="选择卡" prop="iot_card_id">
<ElSelect
v-model="bindForm.iot_card_id"
placeholder="请搜索并选择卡"
filterable
remote
:remote-method="searchCards"
:loading="searchCardsLoading"
style="width: 100%"
>
<ElOption
v-for="card in availableCards"
:key="card.id"
:label="`${card.iccid} - ${card.carrier_name}`"
:value="card.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ card.iccid }}</span>
<ElTag size="small" type="info">{{ card.carrier_name }}</ElTag>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="插槽位置" prop="slot_position">
<ElSelect
v-model="bindForm.slot_position"
placeholder="请选择插槽位置"
style="width: 100%"
>
<ElOption
v-for="slot in availableSlots"
:key="slot"
:label="`插槽 ${slot}`"
:value="slot"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="bindDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleConfirmBind" :loading="bindLoading">
确认绑定
</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { DeviceService, CardService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { Device, DeviceCardBinding } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'DeviceDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const cardsLoading = ref(false)
const bindLoading = ref(false)
const searchCardsLoading = ref(false)
const bindDialogVisible = ref(false)
const bindFormRef = ref<FormInstance>()
const deviceInfo = ref<Device | null>(null)
const cardList = ref<DeviceCardBinding[]>([])
const availableCards = ref<any[]>([])
// 状态类型映射
const statusTypeMap: Record<number, any> = {
1: 'info',
2: 'primary',
3: 'success',
4: 'danger'
}
// 卡状态类型映射
const cardStatusTypeMap: Record<number, any> = {
1: 'info',
2: 'primary',
3: 'success',
4: 'danger'
}
// 卡状态文本映射
const cardStatusTextMap: Record<number, string> = {
1: '在库',
2: '已分销',
3: '已激活',
4: '已停用'
}
// 绑定表单
const bindForm = reactive({
iot_card_id: undefined as number | undefined,
slot_position: undefined as number | undefined
})
// 绑定表单验证规则
const bindRules = reactive<FormRules>({
iot_card_id: [{ required: true, message: '请选择卡', trigger: 'change' }],
slot_position: [{ required: true, message: '请选择插槽位置', trigger: 'change' }]
})
// 可用插槽
const availableSlots = computed(() => {
if (!deviceInfo.value) return []
const occupiedSlots = cardList.value.map((card) => card.slot_position)
const allSlots = Array.from({ length: deviceInfo.value.max_sim_slots }, (_, i) => i + 1)
return allSlots.filter((slot) => !occupiedSlots.includes(slot))
})
onMounted(() => {
const deviceId = route.query.id
if (deviceId) {
loadDeviceInfo(Number(deviceId))
loadDeviceCards(Number(deviceId))
} else {
ElMessage.error('设备ID不存在')
handleBack()
}
})
// 加载设备信息
const loadDeviceInfo = async (id: number) => {
loading.value = true
try {
const res = await DeviceService.getDeviceById(id)
if (res.code === 0) {
deviceInfo.value = res.data
}
} catch (error) {
console.error(error)
ElMessage.error('获取设备信息失败')
} finally {
loading.value = false
}
}
// 加载设备绑定的卡列表
const loadDeviceCards = async (id: number) => {
cardsLoading.value = true
try {
const res = await DeviceService.getDeviceCards(id)
if (res.code === 0) {
cardList.value = res.data.bindings || []
}
} catch (error) {
console.error(error)
ElMessage.error('获取绑定卡列表失败')
} finally {
cardsLoading.value = false
}
}
// 搜索可用的卡
const searchCards = async (query: string) => {
if (!query) {
availableCards.value = []
return
}
searchCardsLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
iccid: query,
page: 1,
page_size: 20
})
if (res.code === 0) {
availableCards.value = res.data.list || []
}
} catch (error) {
console.error(error)
} finally {
searchCardsLoading.value = false
}
}
// 显示绑定对话框
const showBindDialog = () => {
bindForm.iot_card_id = undefined
bindForm.slot_position = undefined
availableCards.value = []
bindDialogVisible.value = true
}
// 确认绑定
const handleConfirmBind = async () => {
if (!bindFormRef.value || !deviceInfo.value) return
await bindFormRef.value.validate(async (valid) => {
if (valid) {
bindLoading.value = true
try {
const data = {
iot_card_id: bindForm.iot_card_id!,
slot_position: bindForm.slot_position!
}
const res = await DeviceService.bindCard(deviceInfo.value!.id, data)
if (res.code === 0) {
ElMessage.success('绑定成功')
bindDialogVisible.value = false
await loadDeviceInfo(deviceInfo.value!.id)
await loadDeviceCards(deviceInfo.value!.id)
if (bindFormRef.value) {
bindFormRef.value.resetFields()
}
}
} catch (error) {
console.error(error)
ElMessage.error('绑定失败')
} finally {
bindLoading.value = false
}
}
})
}
// 解绑卡
const handleUnbindCard = (card: DeviceCardBinding) => {
ElMessageBox.confirm(`确定解绑卡 ${card.iccid} 吗?`, '解绑确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
await DeviceService.unbindCard(deviceInfo.value!.id, card.iot_card_id)
ElMessage.success('解绑成功')
await loadDeviceInfo(deviceInfo.value!.id)
await loadDeviceCards(deviceInfo.value!.id)
} catch (error) {
console.error(error)
ElMessage.error('解绑失败')
}
})
.catch(() => {
// 用户取消
})
}
// 返回
const handleBack = () => {
router.back()
}
</script>
<style scoped lang="scss">
.device-detail-page {
padding: 20px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="设备ID">{{ deviceDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{
deviceDetail.device_no
deviceDetail.virtual_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{
@@ -83,7 +83,7 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { DeviceService } from '@/api/modules/device'
import { AssetService } from '@/api/modules'
defineOptions({ name: 'DeviceSearch' })
@@ -106,12 +106,12 @@
deviceDetail.value = null
try {
const res = await DeviceService.getDeviceByImei(searchForm.imei.trim())
const res = await AssetService.resolveAsset(searchForm.imei.trim())
if (res.code === 0 && res.data) {
deviceDetail.value = res.data
ElMessage.success('查询成功')
} else {
ElMessage.error(res.message || '查询失败')
ElMessage.error(res.msg || '查询失败')
}
} catch (error: any) {
console.error('查询设备详情失败:', error)

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