Compare commits
49 Commits
c69124a819
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a805c27902 | ||
|
|
62f828f314 | ||
|
|
f06d8c9133 | ||
|
|
407287f538 | ||
|
|
9fb3367fd7 | ||
|
|
ff67681c29 | ||
|
|
e975e6af4b | ||
|
|
f4ccf9ed24 | ||
|
|
8f31526499 | ||
|
|
d43de4cd06 | ||
|
|
bd45f7a224 | ||
|
|
e73992d253 | ||
|
|
8fbc321a5e | ||
|
|
1ebc0b8929 | ||
|
|
4d94f7efa6 | ||
|
|
08d5043b3f | ||
|
|
237eeed87a | ||
|
|
7e9acda1ab | ||
|
|
7b459b5c8d | ||
|
|
ce1032c7f9 | ||
|
|
4470a4ef04 | ||
|
|
f1cb1e53c8 | ||
|
|
3570b062a1 | ||
|
|
dccad819cf | ||
|
|
ca3184857e | ||
|
|
e8700c2585 | ||
|
|
b94c043a56 | ||
|
|
d97dc5f007 | ||
|
|
20e8c13e61 | ||
|
|
192c6f1d26 | ||
|
|
de9753f42d | ||
|
|
2c6fe4375b | ||
|
|
06cde977ad | ||
|
|
f62437379d | ||
|
|
c31fdaa748 | ||
|
|
4d2f38c75b | ||
|
|
78bd9fba85 | ||
|
|
e08c962c40 | ||
|
|
882feaf3ff | ||
|
|
ecb79dae43 | ||
|
|
16d53709ef | ||
|
|
31440b2904 | ||
|
|
8a1388608c | ||
|
|
841cf0442b | ||
|
|
1812b7a6c4 | ||
|
|
6127b21c2c | ||
|
|
c07e481b5b | ||
|
|
5c6312c407 | ||
|
|
0eed8244e5 |
@@ -310,6 +310,12 @@
|
|||||||
"whenever": true,
|
"whenever": true,
|
||||||
"ElMessage": true,
|
"ElMessage": true,
|
||||||
"ElTag": true,
|
"ElTag": true,
|
||||||
"ElMessageBox": true
|
"ElMessageBox": true,
|
||||||
|
"ElButton": true,
|
||||||
|
"ElDropdown": true,
|
||||||
|
"ElButton2": true,
|
||||||
|
"ElDropdown2": true,
|
||||||
|
"ElDropdownItem": true,
|
||||||
|
"ElDropdownMenu": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"Bash(npm run build:*)",
|
"Bash(npm run build:*)",
|
||||||
"Bash(tree:*)",
|
"Bash(tree:*)",
|
||||||
"Bash(npm run dev:*)",
|
"Bash(npm run dev:*)",
|
||||||
"Bash(timeout:*)"
|
"Bash(timeout:*)",
|
||||||
|
"Read(//d/**)",
|
||||||
|
"Bash(findstr:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
2
.env
2
.env
@@ -12,7 +12,7 @@ VITE_BASE_URL =
|
|||||||
VITE_API_URL = https://cmp-api.boss160.cn
|
VITE_API_URL = https://cmp-api.boss160.cn
|
||||||
|
|
||||||
# 权限模式( frontend | backend )
|
# 权限模式( frontend | backend )
|
||||||
VITE_ACCESS_MODE = frontend
|
VITE_ACCESS_MODE = backend
|
||||||
|
|
||||||
# 是否打开路由信息
|
# 是否打开路由信息
|
||||||
VITE_OPEN_ROUTE_INFO = false
|
VITE_OPEN_ROUTE_INFO = false
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<title>君鸿卡管系统</title>
|
<title>君鸿卡管系统</title>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
|
<!-- 引入小米字体 CSS 文件 -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/misans@4.1.0/lib/Normal/MiSans-Regular.min.css"
|
||||||
|
/>
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="src/assets/img/logo.png" />
|
<link rel="shortcut icon" type="image/x-icon" href="src/assets/img/logo.png" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
162
openspec/changes/add-button-permissions/proposal.md
Normal file
162
openspec/changes/add-button-permissions/proposal.md
Normal 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`
|
||||||
190
openspec/changes/add-button-permissions/tasks.md
Normal file
190
openspec/changes/add-button-permissions/tasks.md
Normal 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`)
|
||||||
|
- 任务可并行执行,建议按模块分批进行
|
||||||
|
- 优先级: 核心模块 (账号、套餐、资产) > 其他模块
|
||||||
38
openspec/changes/add-carrier-management/proposal.md
Normal file
38
openspec/changes/add-carrier-management/proposal.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Change: 新增运营商管理功能
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
当前系统缺少对运营商基础信息的统一管理能力。需要提供一个集中的运营商管理模块,用于维护运营商的基础信息(名称、编码、类型、描述等),以便后续在网卡、套餐等业务模块中关联使用。
|
||||||
|
|
||||||
|
运营商管理是物联网卡管理系统的基础数据模块,需要支持对运营商的 CRUD 操作以及状态管理,方便运营人员统一维护运营商信息。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 新增运营商管理 API 服务层(CarrierService)
|
||||||
|
- 新增运营商相关 TypeScript 类型定义
|
||||||
|
- 新增运营商管理页面,支持以下功能:
|
||||||
|
- 运营商列表查询(支持按名称、类型、状态筛选)
|
||||||
|
- 创建运营商
|
||||||
|
- 编辑运营商信息
|
||||||
|
- 删除运营商
|
||||||
|
- 状态切换(启用/禁用)
|
||||||
|
- 查看运营商详情
|
||||||
|
- 新增运营商类型常量配置(CMCC/CUCC/CTCC/CBN)
|
||||||
|
- 在账户管理菜单下新增运营商管理入口
|
||||||
|
- 新增路由配置
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **新增文件**:
|
||||||
|
- `src/api/modules/carrier.ts` - API 服务层
|
||||||
|
- `src/types/api/carrier.ts` - TypeScript 类型定义
|
||||||
|
- `src/views/finance/carrier-management/index.vue` - 运营商管理页面
|
||||||
|
- `src/config/constants/carrierTypes.ts` - 运营商类型常量
|
||||||
|
- **修改文件**:
|
||||||
|
- `src/api/modules/index.ts` - 导出 CarrierService
|
||||||
|
- `src/types/api/index.ts` - 导出 carrier 类型
|
||||||
|
- `src/router/routesAlias.ts` - 新增路由别名
|
||||||
|
- `src/router/routes/asyncRoutes.ts` - 新增路由配置
|
||||||
|
- `src/config/constants/index.ts` - 导出运营商类型常量
|
||||||
|
- **受影响的业务模块**: 账户管理
|
||||||
|
- **不涉及破坏性变更**
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Carrier Management Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 运营商列表查询
|
||||||
|
|
||||||
|
系统 SHALL 提供运营商列表查询功能,支持分页和多条件筛选。
|
||||||
|
|
||||||
|
#### Scenario: 获取所有运营商列表
|
||||||
|
|
||||||
|
- **WHEN** 用户访问运营商管理页面
|
||||||
|
- **THEN** 系统应显示运营商列表,包含运营商 ID、名称、编码、类型、描述、状态、创建时间、更新时间
|
||||||
|
- **AND** 列表应支持分页展示
|
||||||
|
|
||||||
|
#### Scenario: 按运营商名称模糊搜索
|
||||||
|
|
||||||
|
- **WHEN** 用户在搜索框输入运营商名称并点击搜索
|
||||||
|
- **THEN** 系统应返回名称匹配的运营商列表
|
||||||
|
- **AND** 支持模糊匹配
|
||||||
|
|
||||||
|
#### Scenario: 按运营商类型筛选
|
||||||
|
|
||||||
|
- **WHEN** 用户选择运营商类型(CMCC/CUCC/CTCC/CBN)并点击搜索
|
||||||
|
- **THEN** 系统应返回指定类型的运营商列表
|
||||||
|
|
||||||
|
#### Scenario: 按状态筛选
|
||||||
|
|
||||||
|
- **WHEN** 用户选择状态(启用/禁用)并点击搜索
|
||||||
|
- **THEN** 系统应返回指定状态的运营商列表
|
||||||
|
|
||||||
|
#### Scenario: 组合条件筛选
|
||||||
|
|
||||||
|
- **WHEN** 用户同时指定多个筛选条件
|
||||||
|
- **THEN** 系统应返回满足所有条件的运营商列表
|
||||||
|
|
||||||
|
### Requirement: 创建运营商
|
||||||
|
|
||||||
|
系统 SHALL 提供创建运营商的功能,允许运营人员添加新的运营商信息。
|
||||||
|
|
||||||
|
#### Scenario: 成功创建运营商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击"新增运营商"按钮并填写完整的必填信息(运营商编码、运营商名称、运营商类型)
|
||||||
|
- **THEN** 系统应创建新的运营商记录
|
||||||
|
- **AND** 显示成功消息
|
||||||
|
- **AND** 刷新运营商列表
|
||||||
|
|
||||||
|
#### Scenario: 创建运营商时必填字段验证
|
||||||
|
|
||||||
|
- **WHEN** 用户提交创建表单但缺少必填字段
|
||||||
|
- **THEN** 系统应显示验证错误消息
|
||||||
|
- **AND** 阻止表单提交
|
||||||
|
|
||||||
|
#### Scenario: 运营商编码长度验证
|
||||||
|
|
||||||
|
- **WHEN** 用户输入的运营商编码长度不在 1-50 个字符之间
|
||||||
|
- **THEN** 系统应显示验证错误消息
|
||||||
|
|
||||||
|
#### Scenario: 运营商名称长度验证
|
||||||
|
|
||||||
|
- **WHEN** 用户输入的运营商名称长度不在 1-100 个字符之间
|
||||||
|
- **THEN** 系统应显示验证错误消息
|
||||||
|
|
||||||
|
#### Scenario: 运营商描述长度验证
|
||||||
|
|
||||||
|
- **WHEN** 用户输入的运营商描述超过 500 个字符
|
||||||
|
- **THEN** 系统应显示验证错误消息
|
||||||
|
|
||||||
|
### Requirement: 编辑运营商
|
||||||
|
|
||||||
|
系统 SHALL 允许用户编辑现有运营商的信息。
|
||||||
|
|
||||||
|
#### Scenario: 成功编辑运营商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击编辑按钮并修改运营商信息后提交
|
||||||
|
- **THEN** 系统应更新运营商记录
|
||||||
|
- **AND** 显示成功消息
|
||||||
|
- **AND** 刷新运营商列表
|
||||||
|
|
||||||
|
#### Scenario: 编辑时只能修改名称和描述
|
||||||
|
|
||||||
|
- **WHEN** 用户编辑运营商时
|
||||||
|
- **THEN** 系统应只允许修改运营商名称和描述
|
||||||
|
- **AND** 运营商编码、运营商类型应不可修改
|
||||||
|
|
||||||
|
#### Scenario: 编辑时表单验证
|
||||||
|
|
||||||
|
- **WHEN** 用户修改运营商信息但不符合验证规则
|
||||||
|
- **THEN** 系统应显示验证错误消息
|
||||||
|
- **AND** 阻止表单提交
|
||||||
|
|
||||||
|
### Requirement: 删除运营商
|
||||||
|
|
||||||
|
系统 SHALL 允许用户删除运营商记录。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除运营商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击删除按钮并确认删除操作
|
||||||
|
- **THEN** 系统应删除该运营商记录
|
||||||
|
- **AND** 显示成功消息
|
||||||
|
- **AND** 刷新运营商列表
|
||||||
|
|
||||||
|
#### Scenario: 删除前二次确认
|
||||||
|
|
||||||
|
- **WHEN** 用户点击删除按钮
|
||||||
|
- **THEN** 系统应显示确认对话框
|
||||||
|
- **AND** 提示用户确认删除操作
|
||||||
|
|
||||||
|
#### Scenario: 取消删除操作
|
||||||
|
|
||||||
|
- **WHEN** 用户在确认对话框中点击取消
|
||||||
|
- **THEN** 系统应取消删除操作
|
||||||
|
- **AND** 保留运营商记录
|
||||||
|
|
||||||
|
### Requirement: 运营商状态管理
|
||||||
|
|
||||||
|
系统 SHALL 提供运营商状态切换功能(启用/禁用)。
|
||||||
|
|
||||||
|
#### Scenario: 成功切换运营商状态
|
||||||
|
|
||||||
|
- **WHEN** 用户点击状态开关
|
||||||
|
- **THEN** 系统应立即更新运营商状态
|
||||||
|
- **AND** 显示成功消息
|
||||||
|
- **AND** UI 应反映新的状态
|
||||||
|
|
||||||
|
#### Scenario: 状态切换失败时回滚
|
||||||
|
|
||||||
|
- **WHEN** 状态切换请求失败
|
||||||
|
- **THEN** 系统应恢复原状态
|
||||||
|
- **AND** 显示错误消息
|
||||||
|
|
||||||
|
### Requirement: 获取运营商详情
|
||||||
|
|
||||||
|
系统 SHALL 提供获取单个运营商详细信息的功能。
|
||||||
|
|
||||||
|
#### Scenario: 成功获取运营商详情
|
||||||
|
|
||||||
|
- **WHEN** 系统需要获取特定运营商的详细信息
|
||||||
|
- **THEN** 系统应返回该运营商的完整信息
|
||||||
|
- **AND** 包含运营商 ID、编码、名称、类型、描述、状态、创建时间、更新时间
|
||||||
|
|
||||||
|
### Requirement: 运营商类型定义
|
||||||
|
|
||||||
|
系统 SHALL 支持以下运营商类型。
|
||||||
|
|
||||||
|
#### Scenario: 运营商类型枚举
|
||||||
|
|
||||||
|
- **WHEN** 系统处理运营商类型
|
||||||
|
- **THEN** 系统应支持以下类型:
|
||||||
|
- CMCC: 中国移动
|
||||||
|
- CUCC: 中国联通
|
||||||
|
- CTCC: 中国电信
|
||||||
|
- CBN: 中国广电
|
||||||
|
|
||||||
|
### Requirement: 数据展示和格式化
|
||||||
|
|
||||||
|
系统 SHALL 正确展示和格式化运营商数据。
|
||||||
|
|
||||||
|
#### Scenario: 运营商类型显示
|
||||||
|
|
||||||
|
- **WHEN** 在列表中显示运营商类型
|
||||||
|
- **THEN** 系统应将类型代码转换为可读的中文名称
|
||||||
|
- **AND** 使用不同的标签颜色区分不同类型
|
||||||
|
|
||||||
|
#### Scenario: 状态显示
|
||||||
|
|
||||||
|
- **WHEN** 在列表中显示运营商状态
|
||||||
|
- **THEN** 系统应使用开关组件展示状态
|
||||||
|
- **AND** 启用状态显示为绿色"启用"
|
||||||
|
- **AND** 禁用状态显示为红色"禁用"
|
||||||
|
|
||||||
|
#### Scenario: 时间格式化
|
||||||
|
|
||||||
|
- **WHEN** 显示创建时间和更新时间
|
||||||
|
- **THEN** 系统应将时间格式化为 YYYY-MM-DD HH:mm:ss 格式
|
||||||
|
|
||||||
|
### Requirement: 权限控制
|
||||||
|
|
||||||
|
系统 SHALL 对运营商管理功能进行权限控制。
|
||||||
|
|
||||||
|
#### Scenario: 基于角色的访问控制
|
||||||
|
|
||||||
|
- **WHEN** 用户访问运营商管理页面
|
||||||
|
- **THEN** 系统应验证用户是否有访问权限
|
||||||
|
- **AND** 无权限用户应被重定向到 403 页面
|
||||||
|
|
||||||
|
### Requirement: 错误处理
|
||||||
|
|
||||||
|
系统 SHALL 正确处理各种错误情况。
|
||||||
|
|
||||||
|
#### Scenario: API 请求失败处理
|
||||||
|
|
||||||
|
- **WHEN** API 请求失败
|
||||||
|
- **THEN** 系统应显示友好的错误消息
|
||||||
|
- **AND** 不应中断用户操作流程
|
||||||
|
|
||||||
|
#### Scenario: 网络错误处理
|
||||||
|
|
||||||
|
- **WHEN** 发生网络错误
|
||||||
|
- **THEN** 系统应提示用户检查网络连接
|
||||||
|
- **AND** 允许用户重试操作
|
||||||
48
openspec/changes/add-carrier-management/tasks.md
Normal file
48
openspec/changes/add-carrier-management/tasks.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Implementation Tasks
|
||||||
|
|
||||||
|
## 1. 类型定义和常量配置
|
||||||
|
|
||||||
|
- [x] 1.1 创建运营商类型定义文件 `src/types/api/carrier.ts`
|
||||||
|
- [x] 1.2 在 `src/types/api/index.ts` 中导出 carrier 类型
|
||||||
|
- [x] 1.3 创建运营商类型常量 `src/config/constants/carrierTypes.ts`
|
||||||
|
- [x] 1.4 在 `src/config/constants/index.ts` 中导出运营商类型常量
|
||||||
|
|
||||||
|
## 2. API 服务层
|
||||||
|
|
||||||
|
- [x] 2.1 创建 CarrierService 类 `src/api/modules/carrier.ts`
|
||||||
|
- [x] 2.2 实现获取运营商列表接口(支持分页和筛选)
|
||||||
|
- [x] 2.3 实现创建运营商接口
|
||||||
|
- [x] 2.4 实现更新运营商接口
|
||||||
|
- [x] 2.5 实现删除运营商接口
|
||||||
|
- [x] 2.6 实现获取运营商详情接口
|
||||||
|
- [x] 2.7 实现更新运营商状态接口
|
||||||
|
- [x] 2.8 在 `src/api/modules/index.ts` 中导出 CarrierService
|
||||||
|
|
||||||
|
## 3. 路由配置
|
||||||
|
|
||||||
|
- [x] 3.1 在 `src/router/routesAlias.ts` 添加运营商管理路由别名
|
||||||
|
- [x] 3.2 在 `src/router/routes/asyncRoutes.ts` 添加运营商管理路由配置
|
||||||
|
|
||||||
|
## 4. 运营商管理页面
|
||||||
|
|
||||||
|
- [x] 4.1 创建运营商管理页面组件 `src/views/finance/carrier-management/index.vue`
|
||||||
|
- [x] 4.2 实现搜索栏(运营商名称、运营商类型、状态筛选)
|
||||||
|
- [x] 4.3 实现运营商列表表格展示
|
||||||
|
- [x] 4.4 实现列筛选和表格头部工具栏
|
||||||
|
- [x] 4.5 实现分页功能
|
||||||
|
- [x] 4.6 实现新增运营商对话框
|
||||||
|
- [x] 4.7 实现编辑运营商对话框
|
||||||
|
- [x] 4.8 实现删除运营商确认
|
||||||
|
- [x] 4.9 实现状态开关切换
|
||||||
|
- [x] 4.10 实现表单验证规则
|
||||||
|
- [x] 4.11 实现数据加载和错误处理
|
||||||
|
|
||||||
|
## 5. 集成测试
|
||||||
|
|
||||||
|
- [x] 5.1 测试运营商列表查询功能(包括筛选和分页)
|
||||||
|
- [x] 5.2 测试创建运营商功能
|
||||||
|
- [x] 5.3 测试编辑运营商功能
|
||||||
|
- [x] 5.4 测试删除运营商功能
|
||||||
|
- [x] 5.5 测试状态切换功能
|
||||||
|
- [x] 5.6 验证表单验证规则是否正确
|
||||||
|
- [x] 5.7 验证权限控制是否正确
|
||||||
309
openspec/changes/add-device-management/design.md
Normal file
309
openspec/changes/add-device-management/design.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# Device Management Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The system currently has a basic device-import page but lacks comprehensive device management capabilities. Devices are critical assets that need to be tracked throughout their lifecycle - from import, allocation to shops, binding with SIM cards, to eventual recall. This change adds full device management to complement the existing card management features.
|
||||||
|
|
||||||
|
Key integration points:
|
||||||
|
|
||||||
|
- **Existing Card System**: Devices must bind with cards from the existing card-list module
|
||||||
|
- **Shop System**: Devices are allocated to shops using the existing ShopService
|
||||||
|
- **Object Storage**: Imports use the existing StorageService for large file uploads
|
||||||
|
- **Task System**: Import tasks must integrate with the existing task-management infrastructure
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Complete Lifecycle Management**: Support all device operations from import to deletion
|
||||||
|
2. **Seamless Integration**: Reuse existing UI patterns, services, and components
|
||||||
|
3. **Scalable Import**: Handle large CSV imports efficiently using object storage
|
||||||
|
4. **Operation Traceability**: Track all device operations through task management
|
||||||
|
5. **Consistent UX**: Match the look and feel of existing card-list and enterprise-customer pages
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
### Decision 1: Three-Step Object Storage Import
|
||||||
|
|
||||||
|
**Choice**: Use StorageService.getUploadUrl → uploadFile → DeviceService.importDevices
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
- Handles large files (thousands of devices) without timeout issues
|
||||||
|
- Decouples upload from processing (backend can process asynchronously)
|
||||||
|
- Consistent with modern cloud architecture patterns
|
||||||
|
- Allows progress tracking through task management
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
|
||||||
|
- Direct multipart upload to backend (rejected: not scalable for large files)
|
||||||
|
- Two-step process without pre-signed URL (rejected: less secure, more backend load)
|
||||||
|
|
||||||
|
**Implementation Notes**:
|
||||||
|
|
||||||
|
- Frontend only handles upload to object storage, not file parsing
|
||||||
|
- Backend processes the file asynchronously and creates task records
|
||||||
|
- Task management provides visibility into import progress
|
||||||
|
|
||||||
|
### Decision 2: Device-Card Binding Model
|
||||||
|
|
||||||
|
**Choice**: Store binding with explicit slot_position (1-4) in device_cards table
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
- IoT devices have physical SIM slots that need explicit identification
|
||||||
|
- Each device can have 1-4 slots (max_sim_slots)
|
||||||
|
- One card can only bind to one device slot (enforced by backend)
|
||||||
|
- Slot position is critical for physical device configuration
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
|
||||||
|
- Auto-assign slot positions (rejected: operators need to know physical slot numbers)
|
||||||
|
- Allow one card to bind to multiple devices (rejected: not realistic for physical SIMs)
|
||||||
|
|
||||||
|
**Implementation Notes**:
|
||||||
|
|
||||||
|
- Device detail page shows a 4-slot grid (empty slots show "Bind Card" button)
|
||||||
|
- Binding dialog requires explicit slot selection
|
||||||
|
- Unbinding updates bound_card_count and frees the slot
|
||||||
|
|
||||||
|
### Decision 3: Unified Task Management
|
||||||
|
|
||||||
|
**Choice**: Extend existing task-management to support device import tasks
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
- Reuses existing task infrastructure
|
||||||
|
- Provides consistent UX for all import operations
|
||||||
|
- Avoids duplicate task tracking logic
|
||||||
|
- Allows unified search/filter across task types
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
|
||||||
|
- Separate device task management page (rejected: creates UX fragmentation)
|
||||||
|
- Embed task tracking only in device-import page (rejected: limited visibility)
|
||||||
|
|
||||||
|
**Implementation Notes**:
|
||||||
|
|
||||||
|
- Add task_type field to distinguish ICCID vs Device imports
|
||||||
|
- Task detail page renders different content based on task_type
|
||||||
|
- Device tasks show device_no and bound ICCIDs instead of just ICCID
|
||||||
|
|
||||||
|
### Decision 4: Batch Operation Result Display
|
||||||
|
|
||||||
|
**Choice**: Show detailed results in dialog after batch allocation/recall
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
- Operations may partially succeed (some devices succeed, others fail)
|
||||||
|
- Operators need to know exactly which devices failed and why
|
||||||
|
- Allows retry of failed operations without re-selecting all devices
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
|
||||||
|
- Just show toast notification (rejected: insufficient detail for partial failures)
|
||||||
|
- Navigate to separate results page (rejected: disrupts workflow)
|
||||||
|
|
||||||
|
**Implementation Notes**:
|
||||||
|
|
||||||
|
- Dialog shows summary: total, success count, failure count
|
||||||
|
- Expandable table shows failed devices with error messages
|
||||||
|
- Success closes dialog, partial failure keeps it open for review
|
||||||
|
|
||||||
|
### Decision 5: Component Reuse Strategy
|
||||||
|
|
||||||
|
**Choice**: Use existing Art\* components (ArtTableFullScreen, ArtSearchBar, ArtTable, ArtButtonTable)
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
- Maintains UI consistency across the application
|
||||||
|
- Reduces development time
|
||||||
|
- Leverages tested, stable components
|
||||||
|
- Easier for users familiar with other pages
|
||||||
|
|
||||||
|
**Reference Implementation**:
|
||||||
|
|
||||||
|
- Device-list follows role/index.vue pattern
|
||||||
|
- Device-detail follows card-list detail modal pattern
|
||||||
|
- Search form follows enterprise-customer search pattern
|
||||||
|
|
||||||
|
**Implementation Notes**:
|
||||||
|
|
||||||
|
- Use CommonStatus for status values (ENABLED/DISABLED)
|
||||||
|
- Use ElSwitch for status toggles
|
||||||
|
- Use ArtButtonTable for action buttons
|
||||||
|
- Follow ElDescriptions layout for detail pages
|
||||||
|
|
||||||
|
## Architecture Diagrams
|
||||||
|
|
||||||
|
### Device Import Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ 1. Select CSV ┌──────────────┐
|
||||||
|
│ Admin │ ──────────────────> │ device-import│
|
||||||
|
│ │ │ (Vue) │
|
||||||
|
└─────────┘ └──────┬───────┘
|
||||||
|
│
|
||||||
|
│ 2. getUploadUrl()
|
||||||
|
v
|
||||||
|
┌──────────────┐
|
||||||
|
│ Storage │
|
||||||
|
│ Service │
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
│ 3. Upload to OSS
|
||||||
|
v
|
||||||
|
┌──────────────┐
|
||||||
|
│ Object │
|
||||||
|
│ Storage │
|
||||||
|
└──────────────┘
|
||||||
|
│
|
||||||
|
│ 4. importDevices(file_key)
|
||||||
|
v
|
||||||
|
┌──────────────┐
|
||||||
|
│ Device │
|
||||||
|
│ Service │
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
│ 5. Create Task
|
||||||
|
v
|
||||||
|
┌──────────────┐
|
||||||
|
│ Task │
|
||||||
|
│ Management │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Device-Card Binding
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌──────────────┐
|
||||||
|
│ Device │ ───── bound to ────> │ Card │
|
||||||
|
│ │ (1 to N) │ │
|
||||||
|
└─────────┘ └──────────────┘
|
||||||
|
│ │
|
||||||
|
│ has_many bindings │ has_one binding
|
||||||
|
v v
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ DeviceCardBinding │
|
||||||
|
│ - device_id │
|
||||||
|
│ - card_id │
|
||||||
|
│ - slot_position (1-4) │
|
||||||
|
│ - bound_at │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Device List Page Load
|
||||||
|
|
||||||
|
1. User navigates to /asset-management/device-list
|
||||||
|
2. Vue component mounts, calls DeviceService.getDevices(params)
|
||||||
|
3. Backend returns paginated device list with bound_card_count
|
||||||
|
4. Table renders with status switches and action buttons
|
||||||
|
|
||||||
|
### Batch Allocation Flow
|
||||||
|
|
||||||
|
1. User selects devices, clicks "Batch Allocate"
|
||||||
|
2. Dialog opens with shop selector
|
||||||
|
3. User selects shop, adds remarks, confirms
|
||||||
|
4. Call DeviceService.allocateDevices({ device_ids, shop_id, remarks })
|
||||||
|
5. Backend processes each device, returns results array
|
||||||
|
6. Dialog shows summary and failed device details
|
||||||
|
|
||||||
|
### Card Binding Flow
|
||||||
|
|
||||||
|
1. User opens device detail page
|
||||||
|
2. Clicks "Bind Card" for an empty slot
|
||||||
|
3. Dialog shows card search and slot selection
|
||||||
|
4. User selects card and slot (e.g., slot 3)
|
||||||
|
5. Call DeviceService.bindCard(device_id, { card_id, slot_position: 3 })
|
||||||
|
6. Backend validates slot is empty, creates binding
|
||||||
|
7. Refresh device detail to show new binding
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### Updating Existing Device Import Page
|
||||||
|
|
||||||
|
**Current State** (`src/views/batch/device-import/index.vue`):
|
||||||
|
|
||||||
|
- Uses ElUpload with drag-and-drop
|
||||||
|
- Mock data for import records
|
||||||
|
- No real API integration
|
||||||
|
|
||||||
|
**Migration Steps**:
|
||||||
|
|
||||||
|
1. Replace ElUpload with three-step upload logic
|
||||||
|
- Add getUploadUrl call
|
||||||
|
- Add uploadFile to object storage
|
||||||
|
- Add importDevices call with file_key
|
||||||
|
2. Remove mock import records
|
||||||
|
3. Link to task-management for tracking
|
||||||
|
4. Update CSV format instructions
|
||||||
|
|
||||||
|
**Backward Compatibility**:
|
||||||
|
|
||||||
|
- This is a new feature area with no existing production data
|
||||||
|
- No API breaking changes
|
||||||
|
- UI changes are additive (improve existing page)
|
||||||
|
|
||||||
|
### Extending Task Management
|
||||||
|
|
||||||
|
**Current State**:
|
||||||
|
|
||||||
|
- Only handles ICCID import tasks
|
||||||
|
- Single task type rendering
|
||||||
|
|
||||||
|
**Migration Steps**:
|
||||||
|
|
||||||
|
1. Add task_type filter dropdown (default: show all)
|
||||||
|
2. Add task_type badge in task list
|
||||||
|
3. Task detail page: switch rendering based on task_type
|
||||||
|
4. Add device-specific fields to task detail view
|
||||||
|
|
||||||
|
**Backward Compatibility**:
|
||||||
|
|
||||||
|
- Existing ICCID tasks continue to work unchanged
|
||||||
|
- Filter defaults to showing all types
|
||||||
|
- No database schema changes required (task_type already exists)
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
- DeviceService API methods with mock responses
|
||||||
|
- Form validation logic
|
||||||
|
- Utility functions (formatters, validators)
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
- Device list search and pagination
|
||||||
|
- Batch allocation with partial failures
|
||||||
|
- Card binding/unbinding workflow
|
||||||
|
- Import task creation and status tracking
|
||||||
|
|
||||||
|
### E2E Testing Scenarios
|
||||||
|
|
||||||
|
1. Import devices via CSV → verify task created → check task detail
|
||||||
|
2. Search devices → select multiple → allocate to shop → verify shop assignment
|
||||||
|
3. View device detail → bind card to slot 2 → unbind → verify empty slot
|
||||||
|
4. Batch recall devices → verify shop cleared → check operation history
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
|
||||||
|
- Device list pagination (default 20 per page)
|
||||||
|
- Debounced search input (300ms delay)
|
||||||
|
- Batch operation result pagination (if >100 results)
|
||||||
|
- Large CSV imports handled asynchronously (no frontend timeout)
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Authorization**: All device operations require admin role
|
||||||
|
2. **Input Validation**:
|
||||||
|
- Device number format validation
|
||||||
|
- Slot position range (1 to max_sim_slots)
|
||||||
|
- CSV file size limits
|
||||||
|
3. **Object Storage Security**: Pre-signed URLs expire after 15 minutes
|
||||||
|
4. **Batch Operation Limits**: Backend enforces max 1000 devices per batch
|
||||||
|
5. **XSS Prevention**: All user inputs sanitized before rendering
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None at this time. All major design decisions have been made based on existing system patterns and API specifications.
|
||||||
67
openspec/changes/add-device-management/proposal.md
Normal file
67
openspec/changes/add-device-management/proposal.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Change: 添加设备管理功能
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
当前系统只有设备导入页面,缺少完整的设备管理能力。需要提供设备的全生命周期管理,包括:
|
||||||
|
|
||||||
|
- 查看和搜索设备列表
|
||||||
|
- 查看设备详情和绑定的卡信息
|
||||||
|
- 管理设备与卡的绑定关系
|
||||||
|
- 批量分配和回收设备
|
||||||
|
- 完善设备导入流程和任务追踪
|
||||||
|
|
||||||
|
这是物联网卡管理系统的核心资产管理功能之一。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
|
||||||
|
- **设备列表页面**: 支持多条件搜索、分页、列筛选、批量操作(分配、回收、删除)
|
||||||
|
- **设备详情页面**: 展示设备基本信息、绑定的卡列表、操作历史
|
||||||
|
- **卡绑定管理**: 在设备详情页绑定/解绑卡
|
||||||
|
- **批量分配对话框**: 选择设备批量分配给店铺
|
||||||
|
- **批量回收对话框**: 从店铺批量回收设备
|
||||||
|
- **设备导入改进**: 基于对象存储的三步导入流程
|
||||||
|
- **导入任务管理**: 任务列表和详情查看
|
||||||
|
|
||||||
|
### API 集成
|
||||||
|
|
||||||
|
- DeviceService: 11个API接口
|
||||||
|
- 类型定义: Device, DeviceBinding, ImportTask 等
|
||||||
|
|
||||||
|
### UI 组件
|
||||||
|
|
||||||
|
- 遵循现有组件模式 (ArtTableFullScreen, ArtSearchBar等)
|
||||||
|
- 复用 CommonStatus 统一状态变量
|
||||||
|
- 使用 ElDescriptions、ElTag、ElSwitch 等组件
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### 影响的功能模块
|
||||||
|
|
||||||
|
- **新增**: 资产管理 / 设备列表(主列表页)
|
||||||
|
- **新增**: 资产管理 / 设备详情
|
||||||
|
- **改进**: 批量操作 / 设备导入(改为对象存储模式)
|
||||||
|
- **新增**: 批量操作 / 导入任务列表(独立页面)
|
||||||
|
|
||||||
|
### 影响的代码
|
||||||
|
|
||||||
|
- `src/api/modules/device.ts` (新增)
|
||||||
|
- `src/types/api/device.ts` (新增)
|
||||||
|
- `src/views/asset-management/device-list/index.vue` (新增)
|
||||||
|
- `src/views/asset-management/device-detail/index.vue` (新增)
|
||||||
|
- `src/views/asset-management/task-management/index.vue` (修改:支持设备导入任务)
|
||||||
|
- `src/views/asset-management/task-detail/index.vue` (修改:支持设备导入任务详情)
|
||||||
|
- `src/views/batch/device-import/index.vue` (修改:使用对象存储)
|
||||||
|
- `src/router/routes/asyncRoutes.ts` (新增路由)
|
||||||
|
- `src/router/routesAlias.ts` (新增路由别名)
|
||||||
|
- `src/locales/langs/zh.json` (新增翻译)
|
||||||
|
- `src/api/modules/index.ts` (导出 DeviceService)
|
||||||
|
- `src/types/api/index.ts` (导出 Device 类型)
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 依赖现有的 StorageService (对象存储)
|
||||||
|
- 依赖现有的 ShopService (店铺选择)
|
||||||
|
- 设备与卡的关联管理
|
||||||
|
- 任务管理页面需要支持多种任务类型(ICCID导入 + 设备导入)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Device Management Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Device List Management
|
||||||
|
|
||||||
|
The system SHALL provide a searchable device list with multi-condition filtering, pagination, and batch operations.
|
||||||
|
|
||||||
|
#### Scenario: Search devices by multiple criteria
|
||||||
|
|
||||||
|
**WHEN** an administrator enters search criteria (device number, device name, status, shop, batch number, device type, manufacturer, creation time range) **THEN** the system shall display only devices matching all specified criteria with pagination support
|
||||||
|
|
||||||
|
#### Scenario: View device list with bound card count
|
||||||
|
|
||||||
|
**WHEN** the device list is displayed **THEN** each device row shall show: ID, device number, device name, device model, device type, manufacturer, max slots, bound card count, status, shop, batch number, activation time, creation time
|
||||||
|
|
||||||
|
#### Scenario: Toggle device status
|
||||||
|
|
||||||
|
**WHEN** an administrator clicks the status switch for a device **THEN** the system shall update the device status between ENABLED and DISABLED and refresh the display
|
||||||
|
|
||||||
|
#### Scenario: Delete a device
|
||||||
|
|
||||||
|
**WHEN** an administrator clicks the delete button and confirms the deletion **THEN** the system shall delete the device and refresh the list
|
||||||
|
|
||||||
|
#### Scenario: Select multiple devices for batch operations
|
||||||
|
|
||||||
|
**WHEN** an administrator checks multiple device rows **THEN** the system shall enable batch operation buttons (Allocate, Recall) and track selected device IDs
|
||||||
|
|
||||||
|
### Requirement: Device Detail Viewing
|
||||||
|
|
||||||
|
The system SHALL display comprehensive device information including basic properties and bound SIM cards.
|
||||||
|
|
||||||
|
#### Scenario: View device basic information
|
||||||
|
|
||||||
|
**WHEN** an administrator navigates to a device detail page **THEN** the system shall display all device properties in a descriptive layout: device number, name, model, type, manufacturer, max SIM slots, status, shop, batch number, activation time, creation time
|
||||||
|
|
||||||
|
#### Scenario: View bound SIM cards with slot positions
|
||||||
|
|
||||||
|
**WHEN** viewing device details **THEN** the system shall display a table of bound cards showing: slot position (1-4), ICCID, phone number, carrier, card status, and binding time
|
||||||
|
|
||||||
|
#### Scenario: Empty slots display
|
||||||
|
|
||||||
|
**WHEN** a device has fewer than max_sim_slots cards bound **THEN** empty slot positions shall be clearly indicated with "Bind Card" action buttons
|
||||||
|
|
||||||
|
### Requirement: Device-Card Binding Management
|
||||||
|
|
||||||
|
The system SHALL allow administrators to bind and unbind SIM cards to specific device slots.
|
||||||
|
|
||||||
|
#### Scenario: Bind a card to a device slot
|
||||||
|
|
||||||
|
**WHEN** an administrator selects a card and slot position (1-4) in the binding dialog **THEN** the system shall create the binding, update the bound card count, and refresh the card list
|
||||||
|
|
||||||
|
#### Scenario: Prevent duplicate slot binding
|
||||||
|
|
||||||
|
**WHEN** an administrator attempts to bind a card to an already occupied slot **THEN** the system shall show an error message and prevent the binding
|
||||||
|
|
||||||
|
#### Scenario: Unbind a card from device
|
||||||
|
|
||||||
|
**WHEN** an administrator clicks unbind for a bound card and confirms **THEN** the system shall remove the binding, decrement the bound card count, and refresh the card list
|
||||||
|
|
||||||
|
#### Scenario: Validate slot range
|
||||||
|
|
||||||
|
**WHEN** an administrator selects a slot position **THEN** the system shall only allow values from 1 to the device's max_sim_slots value
|
||||||
|
|
||||||
|
### Requirement: Batch Device Allocation
|
||||||
|
|
||||||
|
The system SHALL support batch allocation of devices to shops with result tracking.
|
||||||
|
|
||||||
|
#### Scenario: Allocate multiple devices to a shop
|
||||||
|
|
||||||
|
**WHEN** an administrator selects multiple devices, chooses a target shop, adds optional remarks, and confirms allocation **THEN** the system shall allocate all selected devices to the shop and display success/failure results for each device
|
||||||
|
|
||||||
|
#### Scenario: Show allocation results
|
||||||
|
|
||||||
|
**WHEN** the batch allocation completes **THEN** the system shall display: total count, success count, failure count, and detailed failure reasons for each failed device
|
||||||
|
|
||||||
|
#### Scenario: Prevent allocation of already allocated devices
|
||||||
|
|
||||||
|
**WHEN** the batch allocation includes devices already allocated to a shop **THEN** the system shall show warnings for those devices but proceed with allocating unallocated devices
|
||||||
|
|
||||||
|
### Requirement: Batch Device Recall
|
||||||
|
|
||||||
|
The system SHALL support batch recall of devices from shops with result tracking.
|
||||||
|
|
||||||
|
#### Scenario: Recall multiple devices
|
||||||
|
|
||||||
|
**WHEN** an administrator selects multiple devices, adds optional remarks, and confirms recall **THEN** the system shall recall all selected devices from their shops and display success/failure results
|
||||||
|
|
||||||
|
#### Scenario: Show recall results
|
||||||
|
|
||||||
|
**WHEN** the batch recall completes **THEN** the system shall display: total count, success count, failure count, and detailed failure reasons for each failed device
|
||||||
|
|
||||||
|
#### Scenario: Prevent recall of unallocated devices
|
||||||
|
|
||||||
|
**WHEN** the batch recall includes devices not allocated to any shop **THEN** the system shall show warnings for those devices but proceed with recalling allocated devices
|
||||||
|
|
||||||
|
### Requirement: Device Import via Object Storage
|
||||||
|
|
||||||
|
The system SHALL support CSV-based device import using a three-step object storage upload process.
|
||||||
|
|
||||||
|
#### Scenario: Import devices with three-step process
|
||||||
|
|
||||||
|
**WHEN** an administrator uploads a CSV file **THEN** the system shall:
|
||||||
|
|
||||||
|
1. Get upload URL from StorageService.getUploadUrl
|
||||||
|
2. Upload file to object storage using StorageService.uploadFile
|
||||||
|
3. Call DeviceService.importDevices with the file_key **AND** display the task number for tracking
|
||||||
|
|
||||||
|
#### Scenario: Validate CSV format
|
||||||
|
|
||||||
|
**WHEN** the CSV file is uploaded **THEN** the system shall validate the presence of required columns: device_no, device_name, device_model, device_type, max_sim_slots, manufacturer
|
||||||
|
|
||||||
|
#### Scenario: Import devices with card bindings
|
||||||
|
|
||||||
|
**WHEN** the CSV includes iccid_1, iccid_2, iccid_3, iccid_4 columns **THEN** the system shall attempt to bind the specified cards to slots 1-4 respectively during import
|
||||||
|
|
||||||
|
#### Scenario: Handle import errors
|
||||||
|
|
||||||
|
**WHEN** the import process encounters errors (duplicate device_no, invalid ICCID, etc.) **THEN** the system shall record failed rows in the import task with detailed error messages
|
||||||
|
|
||||||
|
### Requirement: Import Task Management
|
||||||
|
|
||||||
|
The system SHALL track device import tasks with detailed status and record information.
|
||||||
|
|
||||||
|
#### Scenario: List import tasks with type filter
|
||||||
|
|
||||||
|
**WHEN** an administrator views the task management page **THEN** the system shall display both ICCID import tasks and Device import tasks with a type filter dropdown
|
||||||
|
|
||||||
|
#### Scenario: View device import task details
|
||||||
|
|
||||||
|
**WHEN** an administrator clicks on a device import task **THEN** the system shall display: task ID, status, total count, success count, failed count, skipped count, created time, completed time
|
||||||
|
|
||||||
|
#### Scenario: View successful import records
|
||||||
|
|
||||||
|
**WHEN** viewing a device import task detail **THEN** the system shall show a table of successfully imported devices with: device_no, device_name, bound ICCIDs
|
||||||
|
|
||||||
|
#### Scenario: View failed import records
|
||||||
|
|
||||||
|
**WHEN** viewing a device import task detail with failures **THEN** the system shall show a table of failed records with: row number, device_no, failure reason
|
||||||
|
|
||||||
|
#### Scenario: Navigate from import page to task detail
|
||||||
|
|
||||||
|
**WHEN** a device import completes successfully **THEN** the system shall display the task number with a link to view task details
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Task Type Support
|
||||||
|
|
||||||
|
Task management SHALL support multiple task types including ICCID import and Device import with type-specific detail views.
|
||||||
|
|
||||||
|
**Previous behavior**: Task management only supported ICCID import tasks.
|
||||||
|
|
||||||
|
#### Scenario: Filter tasks by type
|
||||||
|
|
||||||
|
**WHEN** an administrator selects a task type filter (ICCID Import / Device Import) **THEN** the system shall display only tasks of the selected type
|
||||||
|
|
||||||
|
#### Scenario: Display task type badges
|
||||||
|
|
||||||
|
**WHEN** viewing the task list **THEN** each task shall display a type badge indicating whether it's an ICCID import or Device import task
|
||||||
126
openspec/changes/add-device-management/tasks.md
Normal file
126
openspec/changes/add-device-management/tasks.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Implementation Tasks
|
||||||
|
|
||||||
|
## 1. 类型定义和API服务 (Foundation)
|
||||||
|
|
||||||
|
- [ ] 1.1 创建 `src/types/api/device.ts` 定义所有设备相关类型
|
||||||
|
- DeviceItem, DeviceQueryParams, DevicePageResult
|
||||||
|
- DeviceCardBinding, DeviceCardBindingList
|
||||||
|
- AllocateDevicesRequest/Response, RecallDevicesRequest/Response
|
||||||
|
- BindCardRequest/Response, UnbindCardResponse
|
||||||
|
- ImportDeviceRequest/Response, DeviceImportTask相关类型
|
||||||
|
- [ ] 1.2 创建 `src/api/modules/device.ts` 实现 DeviceService
|
||||||
|
- getDevices (列表)
|
||||||
|
- getDeviceById (详情)
|
||||||
|
- deleteDevice (删除)
|
||||||
|
- getDeviceCards (绑定的卡列表)
|
||||||
|
- bindCard (绑定卡)
|
||||||
|
- unbindCard (解绑卡)
|
||||||
|
- allocateDevices (批量分配)
|
||||||
|
- recallDevices (批量回收)
|
||||||
|
- importDevices (批量导入)
|
||||||
|
- getImportTasks (导入任务列表)
|
||||||
|
- getImportTaskDetail (导入任务详情)
|
||||||
|
- [ ] 1.3 在 `src/api/modules/index.ts` 导出 DeviceService
|
||||||
|
- [ ] 1.4 在 `src/types/api/index.ts` 导出 Device 相关类型
|
||||||
|
|
||||||
|
## 2. 设备列表页面 (Core UI)
|
||||||
|
|
||||||
|
- [ ] 2.1 创建 `src/views/asset-management/device-list/index.vue`
|
||||||
|
- 搜索栏:设备号、设备名称、状态、店铺、批次号、设备类型、制造商、创建时间范围
|
||||||
|
- 表格:展示设备信息,支持勾选、列筛选
|
||||||
|
- 表格列:ID、设备号、设备名称、设备型号、设备类型、制造商、最大插槽数、已绑定卡数、状态、店铺、批次号、激活时间、创建时间
|
||||||
|
- 状态使用 CommonStatus + ElSwitch 显示
|
||||||
|
- 操作列:查看详情、删除按钮(使用 ArtButtonTable)
|
||||||
|
- [ ] 2.2 实现批量操作按钮
|
||||||
|
- 批量分配:弹出对话框选择目标店铺
|
||||||
|
- 批量回收:弹出对话框确认回收
|
||||||
|
- 导入设备:跳转到设备导入页面
|
||||||
|
- [ ] 2.3 实现批量分配对话框
|
||||||
|
- 选择目标店铺(下拉搜索)
|
||||||
|
- 显示已选设备数量
|
||||||
|
- 备注输入
|
||||||
|
- 展示分配结果(成功/失败)
|
||||||
|
- [ ] 2.4 实现批量回收对话框
|
||||||
|
- 确认回收设备列表
|
||||||
|
- 备注输入
|
||||||
|
- 展示回收结果(成功/失败)
|
||||||
|
|
||||||
|
## 3. 设备详情页面 (Detail View)
|
||||||
|
|
||||||
|
- [ ] 3.1 创建 `src/views/asset-management/device-detail/index.vue`
|
||||||
|
- 设备基本信息卡片(ElDescriptions)
|
||||||
|
- 绑定的卡列表表格(展示4个插槽位置)
|
||||||
|
- 返回按钮
|
||||||
|
- [ ] 3.2 实现卡绑定管理
|
||||||
|
- 绑定卡按钮:弹出对话框选择卡和插槽位置
|
||||||
|
- 解绑按钮:每个绑定记录旁边
|
||||||
|
- 显示插槽位置(1-4)
|
||||||
|
- 展示卡的基本信息(ICCID、手机号、运营商、状态)
|
||||||
|
|
||||||
|
## 4. 任务管理改进 (Task Management)
|
||||||
|
|
||||||
|
- [ ] 4.1 修改 `src/views/asset-management/task-management/index.vue`
|
||||||
|
- 添加任务类型筛选:ICCID导入 / 设备导入
|
||||||
|
- 支持展示设备导入任务
|
||||||
|
- 任务列表显示任务类型标签
|
||||||
|
- [ ] 4.2 修改 `src/views/asset-management/task-detail/index.vue`
|
||||||
|
- 根据任务类型展示不同的详情内容
|
||||||
|
- 设备导入任务:显示设备号、绑定的ICCID、失败原因
|
||||||
|
- 失败和跳过记录展示
|
||||||
|
|
||||||
|
## 5. 设备导入改进 (Import Enhancement)
|
||||||
|
|
||||||
|
- [ ] 5.1 修改 `src/views/batch/device-import/index.vue`
|
||||||
|
- 改用对象存储三步上传流程
|
||||||
|
1. 获取上传URL (StorageService.getUploadUrl)
|
||||||
|
2. 上传文件到对象存储 (StorageService.uploadFile)
|
||||||
|
3. 调用导入接口 (DeviceService.importDevices)
|
||||||
|
- CSV格式说明:device_no, device_name, device_model, device_type, max_sim_slots, manufacturer, iccid_1~iccid_4, batch_no
|
||||||
|
- 导入成功后显示任务编号,引导去任务管理查看
|
||||||
|
- [ ] 5.2 优化导入记录展示
|
||||||
|
- 移除mock数据,改为真实API调用
|
||||||
|
- 点击"查看详情"跳转到任务详情页
|
||||||
|
|
||||||
|
## 6. 路由和导航 (Routing)
|
||||||
|
|
||||||
|
- [ ] 6.1 在 `src/router/routes/asyncRoutes.ts` 添加路由
|
||||||
|
- /asset-management/device-list (设备列表)
|
||||||
|
- /asset-management/device-detail (设备详情,带query参数id)
|
||||||
|
- [ ] 6.2 在 `src/router/routesAlias.ts` 添加路由别名
|
||||||
|
- DeviceList = '/asset-management/device-list'
|
||||||
|
- DeviceDetail = '/asset-management/device-detail'
|
||||||
|
- [ ] 6.3 在 `src/locales/langs/zh.json` 添加翻译
|
||||||
|
- assetManagement.deviceList: "设备列表"
|
||||||
|
- assetManagement.deviceDetail: "设备详情"
|
||||||
|
|
||||||
|
## 7. 测试和优化 (Testing & Polish)
|
||||||
|
|
||||||
|
- [ ] 7.1 功能测试
|
||||||
|
- 设备列表的搜索、分页、排序
|
||||||
|
- 批量分配和回收流程
|
||||||
|
- 设备与卡的绑定/解绑
|
||||||
|
- 设备导入完整流程
|
||||||
|
- 任务状态查看
|
||||||
|
- [ ] 7.2 边界情况处理
|
||||||
|
- 空列表状态
|
||||||
|
- 加载失败提示
|
||||||
|
- 删除确认提示
|
||||||
|
- 表单验证
|
||||||
|
- [ ] 7.3 性能优化
|
||||||
|
- 列表数据懒加载
|
||||||
|
- 防抖搜索
|
||||||
|
- 批量操作结果分页展示
|
||||||
|
- [ ] 7.4 代码审查
|
||||||
|
- TypeScript类型完整性
|
||||||
|
- 错误处理
|
||||||
|
- 代码复用性
|
||||||
|
- 注释完整性
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- 1.x 必须先完成才能开始 2.x-6.x
|
||||||
|
- 2.1-2.2 可以并行开发
|
||||||
|
- 3.x 依赖 1.x,可与 2.x 并行
|
||||||
|
- 4.x 和 5.x 依赖 1.x,可与其他任务并行
|
||||||
|
- 6.x 可在对应页面开发完成后立即进行
|
||||||
|
- 7.x 在所有功能开发完成后进行
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Change: 企业设备授权管理
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
当前系统中已存在企业客户管理和企业卡授权功能,但缺少企业设备授权管理能力。企业客户需要能够查看和管理被授权的设备列表,运营人员需要能够将设备授权给企业客户使用,并支持撤销授权操作。
|
||||||
|
|
||||||
|
根据业务需求文档 (`docs/企业设备授权.md`),需要在资产管理模块下新增"企业设备列表"功能,实现以下核心能力:
|
||||||
|
|
||||||
|
1. **授权设备给企业** - 支持批量授权,最多100个设备号
|
||||||
|
2. **查看企业设备列表** - 支持分页和按设备号搜索
|
||||||
|
3. **撤销设备授权** - 支持批量撤销授权
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
新增企业设备授权管理功能,包括:
|
||||||
|
|
||||||
|
### 类型定义
|
||||||
|
|
||||||
|
- 新增 `src/types/api/enterpriseDevice.ts` 文件
|
||||||
|
- 定义设备列表项、查询参数、分页结果类型
|
||||||
|
- 定义授权/撤销请求和响应类型
|
||||||
|
- 在 `src/types/api/index.ts` 中导出新类型
|
||||||
|
|
||||||
|
### API 服务层
|
||||||
|
|
||||||
|
- 扩展 `EnterpriseService` 类,新增3个方法:
|
||||||
|
- `allocateDevices(enterpriseId, data)` - POST 授权设备
|
||||||
|
- `getEnterpriseDevices(enterpriseId, params)` - GET 设备列表
|
||||||
|
- `recallDevices(enterpriseId, data)` - POST 撤销授权
|
||||||
|
|
||||||
|
### 视图层
|
||||||
|
|
||||||
|
- 新增 `src/views/asset-management/enterprise-devices/index.vue` 页面
|
||||||
|
- 实现设备列表展示 (表格、分页、搜索)
|
||||||
|
- 实现授权设备对话框 (支持批量输入设备号)
|
||||||
|
- 实现撤销授权功能 (二次确认)
|
||||||
|
- 实现操作结果展示 (成功/失败统计)
|
||||||
|
|
||||||
|
### 路由配置
|
||||||
|
|
||||||
|
- 在 `src/router/routesAlias.ts` 添加路由别名
|
||||||
|
- 在 `src/router/routes/asyncRoutes.ts` 的资产管理模块下添加子路由
|
||||||
|
|
||||||
|
### 国际化
|
||||||
|
|
||||||
|
- 在 `src/locales/langs/zh.json` 和 `en.json` 添加中英文翻译
|
||||||
|
- 包含菜单、表单、表格、对话框、提示消息等所有文案
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **新增模块**: 企业设备授权管理
|
||||||
|
- **影响范围**:
|
||||||
|
- 新增文件: `enterpriseDevice.ts`, `enterprise-devices/index.vue`
|
||||||
|
- 扩展文件: `enterprise.ts` (API), `routesAlias.ts`, `asyncRoutes.ts`, `zh.json`, `en.json`, `index.ts` (types)
|
||||||
|
- **依赖关系**:
|
||||||
|
- 依赖现有的 `EnterpriseService` 基础设施
|
||||||
|
- 依赖已实现的设备管理模块
|
||||||
|
- 后端 API 已就绪
|
||||||
|
- **向后兼容性**: 完全兼容,不影响现有功能
|
||||||
|
- **数据迁移**: 无需数据迁移
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
无破坏性变更。这是一个纯新增功能,不修改现有代码逻辑。
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **低风险**: 功能独立,不影响现有企业卡授权功能
|
||||||
|
- **低风险**: API 已定义清晰,类型安全有保障
|
||||||
|
- **中风险**: 需要确保批量操作时的用户体验良好 (处理大量设备号输入和结果展示)
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
1. **复用企业卡授权页面** - 不可行,设备和卡的数据结构和操作逻辑不同
|
||||||
|
2. **在设备管理页面添加企业授权功能** - 不符合业务流程,企业授权属于资产管理范畴
|
||||||
|
3. **使用单个设备号输入而非批量** - 不满足业务需求,运营人员需要批量授权能力
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. ✅ 设备号输入格式 - 支持换行或逗号分隔
|
||||||
|
2. ✅ 最大批量数量 - API 限制最多100个设备号
|
||||||
|
3. ✅ 失败情况处理 - API 返回成功/失败统计及失败原因列表
|
||||||
|
4. ✅ 设备列表排序 - 按授权时间倒序
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- API 文档: `docs/企业设备授权.md`
|
||||||
|
- 相关 OpenSpec 变更: `add-device-management`
|
||||||
|
- 参考实现: 企业卡授权功能 (`enterprise-cards/index.vue`)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Spec: Enterprise Device Authorization
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
企业设备授权功能允许运营人员将设备授权给企业客户使用,并支持查看授权设备列表和撤销授权操作。
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: System SHALL define enterprise device types
|
||||||
|
|
||||||
|
系统必须提供完整的企业设备授权相关类型定义,确保类型安全。
|
||||||
|
|
||||||
|
#### Scenario: 定义企业设备列表项类型
|
||||||
|
|
||||||
|
**Given** 需要展示企业设备列表 **When** 定义 `EnterpriseDeviceItem` 接口 **Then** 接口必须包含以下字段:
|
||||||
|
|
||||||
|
- `device_id: number` - 设备ID
|
||||||
|
- `device_no: string` - 设备号
|
||||||
|
- `device_name: string` - 设备名称
|
||||||
|
- `device_model: string` - 设备型号
|
||||||
|
- `card_count: number` - 绑定卡数量
|
||||||
|
- `authorized_at: string` - 授权时间
|
||||||
|
|
||||||
|
#### Scenario: 定义设备列表查询参数
|
||||||
|
|
||||||
|
**Given** 需要查询和搜索企业设备 **When** 定义 `EnterpriseDeviceListParams` 接口 **Then** 接口必须包含以下可选字段:
|
||||||
|
|
||||||
|
- `page?: number` - 页码
|
||||||
|
- `page_size?: number` - 每页数量
|
||||||
|
- `device_no?: string` - 设备号模糊搜索
|
||||||
|
|
||||||
|
#### Scenario: 定义授权设备请求类型
|
||||||
|
|
||||||
|
**Given** 需要授权设备给企业 **When** 定义 `AllocateDevicesRequest` 接口 **Then** 接口必须包含:
|
||||||
|
|
||||||
|
- `device_nos: string[]` - 设备号列表 (nullable, 最多100个)
|
||||||
|
- `remark?: string` - 授权备注
|
||||||
|
|
||||||
|
#### Scenario: 定义授权设备响应类型
|
||||||
|
|
||||||
|
**Given** 授权操作需要返回详细结果 **When** 定义 `AllocateDevicesResponse` 接口 **Then** 接口必须包含:
|
||||||
|
|
||||||
|
- `success_count: number` - 成功数量
|
||||||
|
- `fail_count: number` - 失败数量
|
||||||
|
- `authorized_devices: AuthorizedDeviceItem[]` - 已授权设备列表 (nullable)
|
||||||
|
- `failed_items: FailedDeviceItem[]` - 失败项列表 (nullable)
|
||||||
|
|
||||||
|
**And** `AuthorizedDeviceItem` 包含:
|
||||||
|
|
||||||
|
- `device_id: number` - 设备ID
|
||||||
|
- `device_no: string` - 设备号
|
||||||
|
- `card_count: number` - 绑定卡数量
|
||||||
|
|
||||||
|
**And** `FailedDeviceItem` 包含:
|
||||||
|
|
||||||
|
- `device_no: string` - 设备号
|
||||||
|
- `reason: string` - 失败原因
|
||||||
|
|
||||||
|
#### Scenario: 定义撤销授权请求类型
|
||||||
|
|
||||||
|
**Given** 需要撤销设备授权 **When** 定义 `RecallDevicesRequest` 接口 **Then** 接口必须包含:
|
||||||
|
|
||||||
|
- `device_nos: string[]` - 设备号列表 (nullable, 最多100个)
|
||||||
|
|
||||||
|
#### Scenario: 定义撤销授权响应类型
|
||||||
|
|
||||||
|
**Given** 撤销操作需要返回结果统计 **When** 定义 `RecallDevicesResponse` 接口 **Then** 接口必须包含:
|
||||||
|
|
||||||
|
- `success_count: number` - 成功数量
|
||||||
|
- `fail_count: number` - 失败数量
|
||||||
|
- `failed_items: FailedDeviceItem[]` - 失败项列表 (nullable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: System SHALL provide enterprise device authorization API services
|
||||||
|
|
||||||
|
系统必须提供企业设备授权相关的 API 服务方法。
|
||||||
|
|
||||||
|
#### Scenario: 授权设备给企业
|
||||||
|
|
||||||
|
**Given** 运营人员需要授权设备给企业客户 **When** 调用 `EnterpriseService.allocateDevices(enterpriseId, data)` **Then** 必须发送 POST 请求到 `/api/admin/enterprises/{id}/allocate-devices` **And** 请求体必须包含设备号列表和可选备注 **And** 返回授权结果,包含成功/失败统计和详细列表
|
||||||
|
|
||||||
|
#### Scenario: 获取企业设备列表
|
||||||
|
|
||||||
|
**Given** 需要查看企业的设备列表 **When** 调用 `EnterpriseService.getEnterpriseDevices(enterpriseId, params)` **Then** 必须发送 GET 请求到 `/api/admin/enterprises/{id}/devices` **And** 支持分页参数 (page, page_size) **And** 支持设备号模糊搜索 **And** 返回设备列表和总数
|
||||||
|
|
||||||
|
#### Scenario: 撤销设备授权
|
||||||
|
|
||||||
|
**Given** 需要撤销企业的设备授权 **When** 调用 `EnterpriseService.recallDevices(enterpriseId, data)` **Then** 必须发送 POST 请求到 `/api/admin/enterprises/{id}/recall-devices` **And** 请求体必须包含设备号列表 **And** 返回撤销结果,包含成功/失败统计和失败原因
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: System SHALL provide enterprise device list page
|
||||||
|
|
||||||
|
系统必须提供企业设备列表管理页面,支持查询、授权和撤销操作。
|
||||||
|
|
||||||
|
#### Scenario: 显示企业设备列表
|
||||||
|
|
||||||
|
**Given** 用户访问企业设备列表页面 **When** 页面加载完成 **Then** 必须显示设备列表表格 **And** 表格必须包含以下列:
|
||||||
|
|
||||||
|
- 设备ID
|
||||||
|
- 设备号
|
||||||
|
- 设备名称
|
||||||
|
- 设备型号
|
||||||
|
- 绑定卡数量
|
||||||
|
- 授权时间
|
||||||
|
|
||||||
|
**And** 必须支持分页功能 **And** 必须显示加载状态
|
||||||
|
|
||||||
|
#### Scenario: 搜索企业设备
|
||||||
|
|
||||||
|
**Given** 设备列表已加载 **When** 用户在搜索框输入设备号 **And** 点击搜索按钮 **Then** 必须根据设备号模糊查询设备 **And** 必须更新设备列表显示 **And** 必须重置到第一页
|
||||||
|
|
||||||
|
#### Scenario: 授权设备对话框
|
||||||
|
|
||||||
|
**Given** 用户点击"授权设备"按钮 **When** 授权设备对话框打开 **Then** 必须显示设备号输入框 (textarea) **And** 必须显示备注输入框 (可选) **And** 必须提示支持的输入格式 (换行或逗号分隔) **And** 必须提示最多100个设备号限制 **And** 必须有表单验证 (设备号必填)
|
||||||
|
|
||||||
|
#### Scenario: 提交授权设备
|
||||||
|
|
||||||
|
**Given** 用户在对话框中输入了设备号列表 **When** 用户点击提交按钮 **Then** 必须解析设备号列表 (支持换行和逗号分隔) **And** 必须去除空白字符和空行 **And** 必须验证设备号数量不超过100个 **And** 必须调用授权 API **And** 必须显示加载状态 **And** 授权完成后必须展示结果:
|
||||||
|
|
||||||
|
- 成功数量
|
||||||
|
- 失败数量
|
||||||
|
- 失败设备列表及原因
|
||||||
|
|
||||||
|
**And** 如果有成功授权的设备,必须刷新设备列表 **And** 必须关闭对话框
|
||||||
|
|
||||||
|
#### Scenario: 撤销设备授权
|
||||||
|
|
||||||
|
**Given** 用户选中了要撤销的设备 **When** 用户点击"撤销授权"按钮 **Then** 必须显示二次确认对话框 **And** 确认对话框必须显示将要撤销的设备数量
|
||||||
|
|
||||||
|
**When** 用户确认撤销 **Then** 必须调用撤销 API **And** 必须显示加载状态 **And** 撤销完成后必须展示结果:
|
||||||
|
|
||||||
|
- 成功数量
|
||||||
|
- 失败数量
|
||||||
|
- 失败设备列表及原因
|
||||||
|
|
||||||
|
**And** 如果有成功撤销的设备,必须刷新设备列表
|
||||||
|
|
||||||
|
#### Scenario: 错误处理
|
||||||
|
|
||||||
|
**Given** API 调用可能失败 **When** API 返回错误 **Then** 必须显示友好的错误提示消息 **And** 必须在控制台记录错误详情 **And** 必须停止加载状态
|
||||||
|
|
||||||
|
#### Scenario: 分页切换
|
||||||
|
|
||||||
|
**Given** 设备列表超过一页 **When** 用户切换页码或每页数量 **Then** 必须保持当前的搜索条件 **And** 必须重新加载设备列表 **And** 必须显示加载状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: System SHALL configure routing for enterprise device list
|
||||||
|
|
||||||
|
系统必须为企业设备列表配置正确的路由。
|
||||||
|
|
||||||
|
#### Scenario: 注册企业设备列表路由
|
||||||
|
|
||||||
|
**Given** 需要访问企业设备列表页面 **When** 配置路由 **Then** 必须在资产管理模块 (`/asset-management`) 下添加子路由 **And** 路由路径必须为 `enterprise-devices` **And** 路由名称必须为 `EnterpriseDevices` **And** 必须使用路由别名 `RoutesAlias.EnterpriseDevices` **And** 必须配置 meta 信息:
|
||||||
|
|
||||||
|
- `title: 'menus.assetManagement.enterpriseDevices'`
|
||||||
|
- `keepAlive: true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: System SHALL provide internationalization support
|
||||||
|
|
||||||
|
系统必须提供中英文翻译支持。
|
||||||
|
|
||||||
|
#### Scenario: 中文翻译
|
||||||
|
|
||||||
|
**Given** 系统语言设置为中文 **When** 访问企业设备相关页面 **Then** 所有文本必须显示中文,包括:
|
||||||
|
|
||||||
|
- 菜单标题: "企业设备列表"
|
||||||
|
- 搜索表单标签和占位符
|
||||||
|
- 表格列名
|
||||||
|
- 按钮文本
|
||||||
|
- 对话框标题和内容
|
||||||
|
- 提示消息
|
||||||
|
|
||||||
|
#### Scenario: 英文翻译
|
||||||
|
|
||||||
|
**Given** 系统语言设置为英文 **When** 访问企业设备相关页面 **Then** 所有文本必须显示英文,包括:
|
||||||
|
|
||||||
|
- 菜单标题: "Enterprise Devices"
|
||||||
|
- 搜索表单标签和占位符
|
||||||
|
- 表格列名
|
||||||
|
- 按钮文本
|
||||||
|
- 对话框标题和内容
|
||||||
|
- 提示消息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: System SHALL optimize user experience
|
||||||
|
|
||||||
|
系统必须提供良好的用户体验。
|
||||||
|
|
||||||
|
#### Scenario: 批量输入设备号
|
||||||
|
|
||||||
|
**Given** 用户需要授权多个设备 **When** 在设备号输入框中输入 **Then** 必须支持以下输入方式:
|
||||||
|
|
||||||
|
- 每行一个设备号
|
||||||
|
- 逗号分隔的设备号
|
||||||
|
- 混合使用换行和逗号
|
||||||
|
|
||||||
|
**And** 系统必须能正确解析所有格式 **And** 必须自动去除首尾空白字符 **And** 必须过滤空行
|
||||||
|
|
||||||
|
#### Scenario: 操作结果展示
|
||||||
|
|
||||||
|
**Given** 批量操作完成 **When** 显示操作结果 **Then** 必须清晰展示:
|
||||||
|
|
||||||
|
- 总共处理的数量
|
||||||
|
- 成功的数量
|
||||||
|
- 失败的数量
|
||||||
|
- 每个失败项的设备号和失败原因
|
||||||
|
|
||||||
|
**And** 如果全部成功,必须显示成功提示 **And** 如果部分失败,必须显示警告提示 **And** 如果全部失败,必须显示错误提示
|
||||||
|
|
||||||
|
#### Scenario: 表格列管理
|
||||||
|
|
||||||
|
**Given** 设备列表表格已显示 **When** 用户点击列管理按钮 **Then** 必须能够选择显示/隐藏的列 **And** 列配置必须被保存
|
||||||
|
|
||||||
|
## Related Specs
|
||||||
|
|
||||||
|
- 参考现有的企业卡授权功能 (enterpriseCard.ts)
|
||||||
|
- 依赖现有的企业客户管理功能 (enterprise.ts)
|
||||||
|
- 关联设备管理模块 (add-device-management)
|
||||||
169
openspec/changes/add-enterprise-device-authorization/tasks.md
Normal file
169
openspec/changes/add-enterprise-device-authorization/tasks.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Tasks: Add Enterprise Device Authorization
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
按照从底层到上层的顺序实现企业设备授权功能,确保每一步都可测试和验证。
|
||||||
|
|
||||||
|
## Task List
|
||||||
|
|
||||||
|
### Phase 1: Type Definitions (Foundation)
|
||||||
|
|
||||||
|
1. **创建企业设备类型定义文件**
|
||||||
|
|
||||||
|
- 创建 `src/types/api/enterpriseDevice.ts`
|
||||||
|
- 定义 `EnterpriseDeviceItem` 接口 (设备列表项)
|
||||||
|
- 定义 `EnterpriseDeviceListParams` 接口 (查询参数)
|
||||||
|
- 定义 `EnterpriseDevicePageResult` 接口 (分页结果)
|
||||||
|
- 定义 `AllocateDevicesRequest` 接口 (授权请求)
|
||||||
|
- 定义 `AllocateDevicesResponse` 接口 (授权响应)
|
||||||
|
- 定义 `AuthorizedDeviceItem` 接口 (已授权设备项)
|
||||||
|
- 定义 `FailedDeviceItem` 接口 (失败设备项)
|
||||||
|
- 定义 `RecallDevicesRequest` 接口 (撤销请求)
|
||||||
|
- 定义 `RecallDevicesResponse` 接口 (撤销响应)
|
||||||
|
- **验证**: 运行 `npm run type-check` 确保无类型错误
|
||||||
|
|
||||||
|
2. **导出类型定义**
|
||||||
|
- 在 `src/types/api/index.ts` 中导出新增的类型
|
||||||
|
- **验证**: 确认其他模块可以正确导入类型
|
||||||
|
|
||||||
|
### Phase 2: API Service Layer
|
||||||
|
|
||||||
|
3. **扩展 EnterpriseService API 方法**
|
||||||
|
- 在 `src/api/modules/enterprise.ts` 中添加:
|
||||||
|
- `allocateDevices(enterpriseId, data)` - 授权设备
|
||||||
|
- `getEnterpriseDevices(enterpriseId, params)` - 获取设备列表
|
||||||
|
- `recallDevices(enterpriseId, data)` - 撤销授权
|
||||||
|
- 导入新增的类型定义
|
||||||
|
- **验证**: 运行 `npm run type-check` 确保类型正确
|
||||||
|
|
||||||
|
### Phase 3: Internationalization
|
||||||
|
|
||||||
|
4. **添加中文翻译**
|
||||||
|
|
||||||
|
- 在 `src/locales/langs/zh.json` 的 `menus.assetManagement` 下添加 `enterpriseDevices` 条目
|
||||||
|
- 在 `src/locales/langs/zh.json` 添加 `enterpriseDevices` 模块的所有中文文案:
|
||||||
|
- 页面标题和搜索表单
|
||||||
|
- 表格列名
|
||||||
|
- 操作按钮和对话框标题
|
||||||
|
- 表单标签和提示文本
|
||||||
|
- 成功/错误消息
|
||||||
|
- **验证**: 检查 JSON 格式正确性
|
||||||
|
|
||||||
|
5. **添加英文翻译**
|
||||||
|
- 在 `src/locales/langs/en.json` 添加对应的英文翻译
|
||||||
|
- 确保 key 与中文版本一致
|
||||||
|
- **验证**: 检查 JSON 格式正确性,切换语言测试
|
||||||
|
|
||||||
|
### Phase 4: Routing
|
||||||
|
|
||||||
|
6. **添加路由别名**
|
||||||
|
|
||||||
|
- 在 `src/router/routesAlias.ts` 添加 `EnterpriseDevices = '/asset-management/enterprise-devices'`
|
||||||
|
- **验证**: 确认导出正确
|
||||||
|
|
||||||
|
7. **配置路由**
|
||||||
|
- 在 `src/router/routes/asyncRoutes.ts` 的资产管理 (`/asset-management`) 模块下添加企业设备列表路由
|
||||||
|
- 配置路由 meta 信息 (title, keepAlive)
|
||||||
|
- **验证**: 运行应用,检查路由注册成功
|
||||||
|
|
||||||
|
### Phase 5: UI Components
|
||||||
|
|
||||||
|
8. **创建企业设备列表页面**
|
||||||
|
|
||||||
|
- 创建 `src/views/asset-management/enterprise-devices/index.vue`
|
||||||
|
- 实现页面基础结构:
|
||||||
|
- 使用 `ArtTableFullScreen` 布局
|
||||||
|
- 使用 `ArtSearchBar` 实现搜索表单 (企业ID,设备号搜索)
|
||||||
|
- 使用 `ArtTable` 展示设备列表
|
||||||
|
- 使用 `ArtTableHeader` 添加"授权设备"和"撤销授权"按钮
|
||||||
|
- **验证**: 页面能正常渲染,无控制台错误
|
||||||
|
|
||||||
|
9. **实现设备列表查询功能**
|
||||||
|
|
||||||
|
- 实现 `loadDeviceList()` 方法调用 API
|
||||||
|
- 实现搜索和重置功能
|
||||||
|
- 实现分页功能
|
||||||
|
- 添加 loading 状态
|
||||||
|
- **验证**: 能正确展示设备列表数据,分页工作正常
|
||||||
|
|
||||||
|
10. **实现授权设备对话框**
|
||||||
|
|
||||||
|
- 创建授权设备对话框
|
||||||
|
- 使用 `ElForm` + `ElInput` (textarea) 输入设备号列表
|
||||||
|
- 支持多行输入或逗号分隔
|
||||||
|
- 添加备注输入框 (可选)
|
||||||
|
- 实现表单验证 (必填项,格式校验)
|
||||||
|
- **验证**: 对话框显示正常,表单验证工作
|
||||||
|
|
||||||
|
11. **实现授权设备提交逻辑**
|
||||||
|
|
||||||
|
- 实现 `handleAllocateDevices()` 方法
|
||||||
|
- 解析设备号列表 (处理换行和逗号分隔)
|
||||||
|
- 调用 `EnterpriseService.allocateDevices()` API
|
||||||
|
- 展示授权结果 (成功数量,失败数量,失败列表)
|
||||||
|
- 使用 `ElMessageBox` 或 `ElDialog` 展示详细结果
|
||||||
|
- 授权成功后刷新列表
|
||||||
|
- **验证**: 能成功授权设备,正确处理部分成功/失败情况
|
||||||
|
|
||||||
|
12. **实现撤销授权对话框**
|
||||||
|
|
||||||
|
- 创建撤销授权对话框
|
||||||
|
- 使用表格多选模式选择要撤销的设备
|
||||||
|
- 或者使用输入框输入设备号列表
|
||||||
|
- 实现二次确认逻辑
|
||||||
|
- **验证**: 对话框显示正常
|
||||||
|
|
||||||
|
13. **实现撤销授权提交逻辑**
|
||||||
|
- 实现 `handleRecallDevices()` 方法
|
||||||
|
- 收集选中的设备号
|
||||||
|
- 调用 `EnterpriseService.recallDevices()` API
|
||||||
|
- 展示撤销结果 (成功数量,失败数量,失败列表)
|
||||||
|
- 撤销成功后刷新列表
|
||||||
|
- **验证**: 能成功撤销授权,正确处理部分成功/失败情况
|
||||||
|
|
||||||
|
### Phase 6: Polish & Testing
|
||||||
|
|
||||||
|
14. **完善表格列配置**
|
||||||
|
|
||||||
|
- 配置表格列 (设备ID,设备号,设备名称,设备型号,绑定卡数量,授权时间)
|
||||||
|
- 实现列显示/隐藏功能
|
||||||
|
- 添加时间格式化
|
||||||
|
- **验证**: 表格数据展示完整美观
|
||||||
|
|
||||||
|
15. **添加错误处理**
|
||||||
|
|
||||||
|
- 为所有 API 调用添加 try-catch
|
||||||
|
- 添加友好的错误提示消息
|
||||||
|
- 处理网络错误和业务错误
|
||||||
|
- **验证**: 各种错误场景都有适当提示
|
||||||
|
|
||||||
|
16. **样式调整**
|
||||||
|
|
||||||
|
- 确保页面样式与系统其他页面一致
|
||||||
|
- 响应式布局适配
|
||||||
|
- 对话框尺寸和布局优化
|
||||||
|
- **验证**: 在不同屏幕尺寸下显示正常
|
||||||
|
|
||||||
|
17. **最终验证**
|
||||||
|
- 运行 `npm run type-check` 确保无类型错误
|
||||||
|
- 运行 `npm run build` 确保能成功构建
|
||||||
|
- 运行 `openspec validate add-enterprise-device-authorization --strict` 验证 spec
|
||||||
|
- 手动测试所有功能点
|
||||||
|
- 测试中英文切换
|
||||||
|
- **验证**: 所有功能正常,无控制台错误
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Task 3 依赖 Task 1 (API 需要类型定义)
|
||||||
|
- Task 8-13 依赖 Task 1-7 (UI 需要 API 和路由)
|
||||||
|
- Task 14-16 是并行任务,可以同时进行
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
- Phase 1-2: 30 分钟 (类型和 API)
|
||||||
|
- Phase 3: 30 分钟 (国际化)
|
||||||
|
- Phase 4: 15 分钟 (路由)
|
||||||
|
- Phase 5: 2-3 小时 (UI 实现)
|
||||||
|
- Phase 6: 30 分钟 (测试和优化)
|
||||||
|
|
||||||
|
**Total**: 约 4-4.5 小时
|
||||||
56
openspec/changes/add-iot-device-operations/proposal.md
Normal file
56
openspec/changes/add-iot-device-operations/proposal.md
Normal 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客户端(已在项目中)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
86
openspec/changes/add-iot-device-operations/tasks.md
Normal file
86
openspec/changes/add-iot-device-operations/tasks.md
Normal 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 更新用户手册(如有需要)
|
||||||
45
openspec/changes/add-order-management/proposal.md
Normal file
45
openspec/changes/add-order-management/proposal.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Change: Add Order Management System
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The IoT management platform currently lacks order management capabilities. Users need to:
|
||||||
|
|
||||||
|
- View and query orders created by customers (personal and agent)
|
||||||
|
- Track order payment status and details
|
||||||
|
- Create orders for single card or device purchases
|
||||||
|
- Cancel pending orders
|
||||||
|
- View order history with filtering and search
|
||||||
|
|
||||||
|
This capability is essential for financial tracking, commission calculation, and overall business operations transparency.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **NEW**: Order list page with search, filtering, and pagination
|
||||||
|
- **NEW**: Order API service with full CRUD operations
|
||||||
|
- **NEW**: TypeScript types for order entities and API contracts
|
||||||
|
- **NEW**: i18n keys for order management UI
|
||||||
|
- **NEW**: Router configuration for order management module
|
||||||
|
|
||||||
|
The order management module will support:
|
||||||
|
|
||||||
|
- Listing orders with filters (payment status, order type, date range, order number)
|
||||||
|
- Viewing order details including buyer information, order items (packages), and payment details
|
||||||
|
- Creating orders for single card or device purchases with package selection
|
||||||
|
- Canceling orders that are in pending payment status
|
||||||
|
- Displaying payment method, commission status, and order totals
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Affected specs**: `order-management` (new capability)
|
||||||
|
- **Affected code**:
|
||||||
|
- `src/types/api/order.ts` (new file - TypeScript types)
|
||||||
|
- `src/api/modules/order.ts` (new file - API service)
|
||||||
|
- `src/views/order-management/order-list/index.vue` (new file - order list page)
|
||||||
|
- `src/router/routes/asyncRoutes.ts` (route configuration)
|
||||||
|
- `src/router/routesAlias.ts` (route alias)
|
||||||
|
- `src/locales/langs/zh.json` (Chinese i18n)
|
||||||
|
- `src/locales/langs/en.json` (English i18n)
|
||||||
|
- `src/types/api/index.ts` (exports)
|
||||||
|
- `src/api/modules/index.ts` (exports)
|
||||||
|
- **Dependencies**: Requires backend APIs at `/api/admin/orders` endpoints
|
||||||
|
- **Breaking changes**: None (this is a new feature)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Order Management Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Order List Display
|
||||||
|
|
||||||
|
The system SHALL display a paginated list of orders with comprehensive filtering and search capabilities.
|
||||||
|
|
||||||
|
#### Scenario: Display all orders with pagination
|
||||||
|
|
||||||
|
- **GIVEN** the user navigates to the order management page
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** the system displays a table of orders with pagination controls
|
||||||
|
- **AND** default page size is 20 items
|
||||||
|
- **AND** table shows columns: ID, order number, buyer type, buyer ID, order type, payment status, total amount, created date, and actions
|
||||||
|
|
||||||
|
#### Scenario: Filter orders by payment status
|
||||||
|
|
||||||
|
- **GIVEN** the user is on the order list page
|
||||||
|
- **WHEN** the user selects a payment status filter (pending=1, paid=2, cancelled=3, refunded=4)
|
||||||
|
- **AND** clicks the search button
|
||||||
|
- **THEN** the system displays only orders matching the selected payment status
|
||||||
|
- **AND** pagination resets to page 1
|
||||||
|
|
||||||
|
#### Scenario: Filter orders by order type
|
||||||
|
|
||||||
|
- **GIVEN** the user is on the order list page
|
||||||
|
- **WHEN** the user selects an order type filter (single_card or device)
|
||||||
|
- **AND** clicks the search button
|
||||||
|
- **THEN** the system displays only orders matching the selected order type
|
||||||
|
|
||||||
|
#### Scenario: Search by order number
|
||||||
|
|
||||||
|
- **GIVEN** the user is on the order list page
|
||||||
|
- **WHEN** the user enters an order number in the search field
|
||||||
|
- **AND** clicks the search button
|
||||||
|
- **THEN** the system performs an exact match search
|
||||||
|
- **AND** displays the matching order if found
|
||||||
|
|
||||||
|
#### Scenario: Filter by date range
|
||||||
|
|
||||||
|
- **GIVEN** the user is on the order list page
|
||||||
|
- **WHEN** the user selects a start date and/or end date
|
||||||
|
- **AND** clicks the search button
|
||||||
|
- **THEN** the system displays orders created within the specified date range
|
||||||
|
|
||||||
|
### Requirement: Order Details Viewing
|
||||||
|
|
||||||
|
The system SHALL allow users to view detailed information about each order including order items and payment information.
|
||||||
|
|
||||||
|
#### Scenario: View order details
|
||||||
|
|
||||||
|
- **GIVEN** the user is viewing the order list
|
||||||
|
- **WHEN** the user clicks the view/detail action for an order
|
||||||
|
- **THEN** the system displays comprehensive order information including:
|
||||||
|
- **AND** order basic info (order_no, order_type, buyer_id, buyer_type)
|
||||||
|
- **AND** payment info (payment_status, payment_method, paid_at, total_amount)
|
||||||
|
- **AND** order items list (package_id, package_name, quantity, unit_price, amount)
|
||||||
|
- **AND** commission info (commission_status, commission_config_version)
|
||||||
|
- **AND** timestamps (created_at, updated_at)
|
||||||
|
|
||||||
|
### Requirement: Order Cancellation
|
||||||
|
|
||||||
|
The system SHALL allow authorized users to cancel orders that are in pending payment status.
|
||||||
|
|
||||||
|
#### Scenario: Cancel a pending order
|
||||||
|
|
||||||
|
- **GIVEN** the user is viewing an order with payment_status = 1 (pending)
|
||||||
|
- **WHEN** the user clicks the cancel action
|
||||||
|
- **AND** confirms the cancellation in the confirmation dialog
|
||||||
|
- **THEN** the system sends a cancel request to POST `/api/admin/orders/{id}/cancel`
|
||||||
|
- **AND** updates the order status to cancelled (3)
|
||||||
|
- **AND** displays a success message
|
||||||
|
- **AND** refreshes the order list
|
||||||
|
|
||||||
|
#### Scenario: Cannot cancel paid order
|
||||||
|
|
||||||
|
- **GIVEN** the user is viewing an order with payment_status = 2 (paid)
|
||||||
|
- **WHEN** the cancel action is clicked
|
||||||
|
- **THEN** the system displays an error message "Cannot cancel a paid order"
|
||||||
|
- **AND** does not send the cancellation request
|
||||||
|
|
||||||
|
### Requirement: Order Creation
|
||||||
|
|
||||||
|
The system SHALL provide an interface to create orders for single card or device purchases.
|
||||||
|
|
||||||
|
#### Scenario: Create single card order
|
||||||
|
|
||||||
|
- **GIVEN** the user clicks the create order button
|
||||||
|
- **WHEN** the user selects order_type = "single_card"
|
||||||
|
- **AND** selects an IoT card (iot_card_id)
|
||||||
|
- **AND** selects one or more packages (package_ids)
|
||||||
|
- **AND** submits the form
|
||||||
|
- **THEN** the system sends a POST request to `/api/admin/orders` with the order data
|
||||||
|
- **AND** displays the newly created order details
|
||||||
|
- **AND** refreshes the order list
|
||||||
|
|
||||||
|
#### Scenario: Create device order
|
||||||
|
|
||||||
|
- **GIVEN** the user clicks the create order button
|
||||||
|
- **WHEN** the user selects order_type = "device"
|
||||||
|
- **AND** selects a device (device_id)
|
||||||
|
- **AND** selects one or more packages (package_ids, max 10)
|
||||||
|
- **AND** submits the form
|
||||||
|
- **THEN** the system creates the order
|
||||||
|
- **AND** displays a success message
|
||||||
|
|
||||||
|
### Requirement: Order Status Display
|
||||||
|
|
||||||
|
The system SHALL display order payment status and order type using color-coded badges and human-readable text.
|
||||||
|
|
||||||
|
#### Scenario: Display payment status badge
|
||||||
|
|
||||||
|
- **GIVEN** an order is displayed in the table
|
||||||
|
- **WHEN** the payment_status is 1 (pending)
|
||||||
|
- **THEN** the system displays a warning-type badge with text "待支付"
|
||||||
|
- **WHEN** the payment_status is 2 (paid)
|
||||||
|
- **THEN** the system displays a success-type badge with text "已支付"
|
||||||
|
- **WHEN** the payment_status is 3 (cancelled)
|
||||||
|
- **THEN** the system displays an info-type badge with text "已取消"
|
||||||
|
- **WHEN** the payment_status is 4 (refunded)
|
||||||
|
- **THEN** the system displays a danger-type badge with text "已退款"
|
||||||
|
|
||||||
|
#### Scenario: Display order type badge
|
||||||
|
|
||||||
|
- **GIVEN** an order is displayed in the table
|
||||||
|
- **WHEN** the order_type is "single_card"
|
||||||
|
- **THEN** the system displays text "单卡购买"
|
||||||
|
- **WHEN** the order_type is "device"
|
||||||
|
- **THEN** the system displays text "设备购买"
|
||||||
|
|
||||||
|
### Requirement: Currency Formatting
|
||||||
|
|
||||||
|
The system SHALL display monetary amounts in yuan (元) with proper formatting and conversion from cents.
|
||||||
|
|
||||||
|
#### Scenario: Format order total amount
|
||||||
|
|
||||||
|
- **GIVEN** an order has total_amount = 50000 (in cents)
|
||||||
|
- **WHEN** the order is displayed in the table
|
||||||
|
- **THEN** the system displays "¥500.00" or "500.00 元"
|
||||||
|
|
||||||
|
### Requirement: Data Refresh and Real-time Updates
|
||||||
|
|
||||||
|
The system SHALL provide manual refresh capabilities and update data after mutations.
|
||||||
|
|
||||||
|
#### Scenario: Manual refresh
|
||||||
|
|
||||||
|
- **GIVEN** the user is viewing the order list
|
||||||
|
- **WHEN** the user clicks the refresh button in the table header
|
||||||
|
- **THEN** the system reloads the current page of orders with current filters
|
||||||
|
- **AND** maintains the current pagination state
|
||||||
|
|
||||||
|
#### Scenario: Auto-refresh after order cancellation
|
||||||
|
|
||||||
|
- **GIVEN** the user successfully cancels an order
|
||||||
|
- **WHEN** the cancellation is confirmed
|
||||||
|
- **THEN** the system automatically refreshes the order list
|
||||||
|
- **AND** displays the updated order status
|
||||||
|
|
||||||
|
### Requirement: Internationalization Support
|
||||||
|
|
||||||
|
The system SHALL provide full internationalization support for order management UI in Chinese and English.
|
||||||
|
|
||||||
|
#### Scenario: Display Chinese text
|
||||||
|
|
||||||
|
- **GIVEN** the user's language is set to Chinese (zh)
|
||||||
|
- **WHEN** the order management page is viewed
|
||||||
|
- **THEN** all UI text displays in Chinese including menu titles, table headers, status labels, and messages
|
||||||
|
|
||||||
|
#### Scenario: Display English text
|
||||||
|
|
||||||
|
- **GIVEN** the user's language is set to English (en)
|
||||||
|
- **WHEN** the order management page is viewed
|
||||||
|
- **THEN** all UI text displays in English including menu titles, table headers, status labels, and messages
|
||||||
57
openspec/changes/add-order-management/tasks.md
Normal file
57
openspec/changes/add-order-management/tasks.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Implementation Tasks
|
||||||
|
|
||||||
|
## 1. Type Definitions
|
||||||
|
|
||||||
|
- [ ] 1.1 Create `src/types/api/order.ts` with order types, enums, and interfaces
|
||||||
|
- [ ] 1.2 Add order type exports to `src/types/api/index.ts`
|
||||||
|
|
||||||
|
## 2. API Service Layer
|
||||||
|
|
||||||
|
- [ ] 2.1 Create `src/api/modules/order.ts` with OrderService class
|
||||||
|
- [ ] 2.2 Implement `getOrders()` - list orders with pagination and filters
|
||||||
|
- [ ] 2.3 Implement `getOrderById()` - fetch single order details
|
||||||
|
- [ ] 2.4 Implement `createOrder()` - create new order
|
||||||
|
- [ ] 2.5 Implement `cancelOrder()` - cancel pending order
|
||||||
|
- [ ] 2.6 Add OrderService export to `src/api/modules/index.ts`
|
||||||
|
|
||||||
|
## 3. Internationalization
|
||||||
|
|
||||||
|
- [ ] 3.1 Add Chinese translations to `src/locales/langs/zh.json` under `orderManagement` namespace
|
||||||
|
- [ ] 3.2 Add English translations to `src/locales/langs/en.json` under `orderManagement` namespace
|
||||||
|
- [ ] 3.3 Include keys for: menu titles, page titles, table columns, search fields, statuses, actions, messages
|
||||||
|
|
||||||
|
## 4. Routing Configuration
|
||||||
|
|
||||||
|
- [ ] 4.1 Add `OrderList` route alias to `src/router/routesAlias.ts`
|
||||||
|
- [ ] 4.2 Add order management route group to `src/router/routes/asyncRoutes.ts`
|
||||||
|
- [ ] 4.3 Configure route with proper icon, title, and keepAlive settings
|
||||||
|
|
||||||
|
## 5. UI Components
|
||||||
|
|
||||||
|
- [ ] 5.1 Create `src/views/order-management/order-list/index.vue` component skeleton
|
||||||
|
- [ ] 5.2 Implement search bar with filters (order_no, payment_status, order_type, date range)
|
||||||
|
- [ ] 5.3 Implement data table with columns (ID, order_no, buyer info, order type, payment status, total amount, created_at, actions)
|
||||||
|
- [ ] 5.4 Add pagination controls
|
||||||
|
- [ ] 5.5 Implement view details action (navigate to detail view or show in dialog)
|
||||||
|
- [ ] 5.6 Implement cancel order action with confirmation dialog
|
||||||
|
- [ ] 5.7 Add status badges and formatters for payment status and order type
|
||||||
|
- [ ] 5.8 Format currency amounts (分 to 元 conversion)
|
||||||
|
- [ ] 5.9 Implement create order button and dialog (optional - can be phase 2)
|
||||||
|
|
||||||
|
## 6. Business Logic
|
||||||
|
|
||||||
|
- [ ] 6.1 Implement order list data fetching with loading states
|
||||||
|
- [ ] 6.2 Implement search and filter logic
|
||||||
|
- [ ] 6.3 Implement pagination handlers
|
||||||
|
- [ ] 6.4 Implement cancel order with optimistic UI updates
|
||||||
|
- [ ] 6.5 Add error handling and user feedback (ElMessage)
|
||||||
|
- [ ] 6.6 Implement date/time formatting using project utilities
|
||||||
|
|
||||||
|
## 7. Validation & Polish
|
||||||
|
|
||||||
|
- [ ] 7.1 Test search and filtering functionality
|
||||||
|
- [ ] 7.2 Test pagination and data refresh
|
||||||
|
- [ ] 7.3 Test cancel order flow
|
||||||
|
- [ ] 7.4 Verify i18n coverage (switch language and check all text)
|
||||||
|
- [ ] 7.5 Verify responsive layout and table column configuration
|
||||||
|
- [ ] 7.6 Code review and cleanup (remove console logs, verify TypeScript types)
|
||||||
371
openspec/changes/add-package-management-system/design.md
Normal file
371
openspec/changes/add-package-management-system/design.md
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
# Design Document: 套餐管理系统实现
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
实现完整的套餐管理系统,包括4个核心模块。该系统需要支持多级代理商体系的套餐分配和定价管理。
|
||||||
|
|
||||||
|
**背景**:
|
||||||
|
|
||||||
|
- 项目已有类型定义(src/types/api/package.ts),但使用不同的字段命名和枚举值
|
||||||
|
- 后端 API 已实现,使用下划线命名(如 `series_name`)
|
||||||
|
- 前端项目统一使用 CommonStatus 枚举(0:禁用, 1:启用)
|
||||||
|
- 参考实现:`/system/role` 页面使用了组件化架构
|
||||||
|
|
||||||
|
**约束**:
|
||||||
|
|
||||||
|
- 必须保留现有类型定义文件,不能破坏现有代码
|
||||||
|
- 需要兼容后端 API 的字段命名规范
|
||||||
|
- 需要适配项目的状态枚举规范
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
1. 实现4个核心模块的完整 CRUD 功能
|
||||||
|
2. 建立统一的 API 服务层,封装后端接口
|
||||||
|
3. 实现组件化的页面结构,参考 `/system/role`
|
||||||
|
4. 支持复杂的定价规则(系列加价 vs 单套餐覆盖)
|
||||||
|
5. 确保数据隔离和权限控制
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
1. 不重构现有的 package.ts 类型定义
|
||||||
|
2. 不实现套餐的实时统计和报表功能(后续迭代)
|
||||||
|
3. 不实现套餐批量导入功能(后续迭代)
|
||||||
|
4. 不实现套餐的版本管理功能
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: API 字段命名策略
|
||||||
|
|
||||||
|
**问题**:后端使用下划线命名(snake_case),前端类型通常使用驼峰命名(camelCase)。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
|
||||||
|
- API 请求/响应保持下划线命名,与后端保持一致
|
||||||
|
- 创建新的类型文件 `packageManagement.ts`,使用下划线命名
|
||||||
|
- 在表单提交和响应处理时不做转换,直接使用下划线字段
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 减少转换层的复杂性和错误风险
|
||||||
|
- 与后端 API 文档保持一致,便于对照
|
||||||
|
- TypeScript 支持下划线字段名,不影响类型安全
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface PackageSeriesResponse {
|
||||||
|
id: number
|
||||||
|
series_code: string // 下划线命名
|
||||||
|
series_name: string
|
||||||
|
status: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision 2: 状态值映射
|
||||||
|
|
||||||
|
**问题**:文档中状态是 `1:启用, 2:禁用`,但项目 CommonStatus 是 `0:禁用, 1:启用`。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
|
||||||
|
- **在常量配置中定义套餐专用的状态枚举**
|
||||||
|
- **前端页面使用项目统一的 CommonStatus(0/1)**
|
||||||
|
- **在 API 服务层进行状态值映射转换**
|
||||||
|
|
||||||
|
**映射规则**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 前端 -> 后端
|
||||||
|
CommonStatus.ENABLED (1) -> API Status (1)
|
||||||
|
CommonStatus.DISABLED (0) -> API Status (2)
|
||||||
|
|
||||||
|
// 后端 -> 前端
|
||||||
|
API Status (1) -> CommonStatus.ENABLED (1)
|
||||||
|
API Status (2) -> CommonStatus.DISABLED (0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 保持前端 UI 的一致性
|
||||||
|
- 避免混淆项目开发者
|
||||||
|
- 集中在 API 服务层处理差异
|
||||||
|
|
||||||
|
### Decision 3: 模块拆分策略
|
||||||
|
|
||||||
|
**问题**:是创建单个 package.ts 服务,还是拆分为多个服务文件?
|
||||||
|
|
||||||
|
**决策**:拆分为4个独立的服务文件:
|
||||||
|
|
||||||
|
1. `packageSeries.ts` - 套餐系列管理
|
||||||
|
2. `package.ts` - 套餐管理
|
||||||
|
3. `myPackage.ts` - 代理可售套餐
|
||||||
|
4. `shopPackageAllocation.ts` - 单套餐分配
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 每个模块功能独立,职责清晰
|
||||||
|
- 便于维护和扩展
|
||||||
|
- 符合单一职责原则
|
||||||
|
- 便于团队协作(不同开发者负责不同模块)
|
||||||
|
|
||||||
|
**替代方案**:
|
||||||
|
|
||||||
|
- 单个 package.ts 文件 - **拒绝**,文件过大,难以维护
|
||||||
|
|
||||||
|
### Decision 4: 定价规则实现
|
||||||
|
|
||||||
|
**问题**:代理商的套餐成本价有两种计算方式:系列加价和单套餐覆盖。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
|
||||||
|
- **后端负责成本价计算**,前端只展示结果
|
||||||
|
- 前端接收 `price_source` 字段,标识价格来源
|
||||||
|
- 单套餐分配创建时,保存 `calculated_cost_price`(系列规则计算的价格)供参考
|
||||||
|
|
||||||
|
**数据流**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 系列分配:pricing_mode + pricing_value -> 后端计算 -> cost_price
|
||||||
|
2. 单套餐分配:直接设置 cost_price(覆盖系列规则)
|
||||||
|
3. 前端展示:price_source 标识使用了哪种规则
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 计算逻辑复杂,集中在后端便于维护
|
||||||
|
- 前端只负责展示,降低复杂度
|
||||||
|
- 保留 calculated_cost_price 便于调试和审计
|
||||||
|
|
||||||
|
### Decision 5: 表单验证策略
|
||||||
|
|
||||||
|
**问题**:客户端验证 vs 服务端验证。
|
||||||
|
|
||||||
|
**决策**:**双重验证**
|
||||||
|
|
||||||
|
- 客户端:使用 Element Plus 的 FormRules 进行基础验证
|
||||||
|
- 服务端:后端 API 进行完整验证并返回详细错误
|
||||||
|
|
||||||
|
**客户端验证规则**:
|
||||||
|
|
||||||
|
- 必填字段检查
|
||||||
|
- 长度限制(如系列名称 1-255 字符)
|
||||||
|
- 数值范围(如套餐时长 1-120 月)
|
||||||
|
- 格式验证(如价格必须为正整数)
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 客户端验证提升用户体验,即时反馈
|
||||||
|
- 服务端验证保证数据安全性和完整性
|
||||||
|
- 符合 Web 应用最佳实践
|
||||||
|
|
||||||
|
### Decision 6: 页面组件化结构
|
||||||
|
|
||||||
|
**问题**:页面结构如何组织?
|
||||||
|
|
||||||
|
**决策**:参考 `/system/role` 页面,使用组件化结构:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<ArtTableFullScreen>
|
||||||
|
<ArtSearchBar />
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<ElCard>
|
||||||
|
<ArtTableHeader />
|
||||||
|
<!-- 表格头部:刷新、列设置、操作按钮 -->
|
||||||
|
<ArtTable />
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<ElDialog />
|
||||||
|
<!-- 新增/编辑对话框 -->
|
||||||
|
</ElCard>
|
||||||
|
</ArtTableFullScreen>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 与项目现有页面风格一致
|
||||||
|
- 复用成熟的组件,减少开发工作量
|
||||||
|
- 便于维护和扩展
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
### Risk 1: 后端 API 未完成
|
||||||
|
|
||||||
|
**风险**:后端接口可能尚未实现或与文档不一致。
|
||||||
|
|
||||||
|
**缓解措施**:
|
||||||
|
|
||||||
|
1. 先实现 API 服务层,使用 TypeScript 类型约束
|
||||||
|
2. 使用 Mock 数据进行前端开发(已有示例)
|
||||||
|
3. 与后端团队确认 API 规范和联调时间
|
||||||
|
4. 预留 API 调试和修正时间
|
||||||
|
|
||||||
|
### Risk 2: 状态值映射可能遗漏
|
||||||
|
|
||||||
|
**风险**:在某些地方忘记转换状态值,导致显示错误。
|
||||||
|
|
||||||
|
**缓解措施**:
|
||||||
|
|
||||||
|
1. 在 API 服务层统一处理转换
|
||||||
|
2. 创建工具函数封装映射逻辑
|
||||||
|
3. 编写单元测试覆盖映射函数
|
||||||
|
4. Code Review 时重点检查状态相关代码
|
||||||
|
|
||||||
|
### Risk 3: 定价规则理解偏差
|
||||||
|
|
||||||
|
**风险**:对定价规则的理解与实际业务需求有偏差。
|
||||||
|
|
||||||
|
**缓解措施**:
|
||||||
|
|
||||||
|
1. 在实现前与产品确认定价规则
|
||||||
|
2. 编写测试用例覆盖各种定价场景
|
||||||
|
3. 在 UI 上清晰展示价格来源和计算方式
|
||||||
|
4. 预留调整空间,避免硬编码
|
||||||
|
|
||||||
|
### Trade-off 1: 类型定义冗余
|
||||||
|
|
||||||
|
**取舍**:保留旧的 package.ts 类型定义,新增 packageManagement.ts。
|
||||||
|
|
||||||
|
**代价**:
|
||||||
|
|
||||||
|
- 存在两套类型定义,可能造成混淆
|
||||||
|
- 占用额外的代码空间
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
|
||||||
|
- 不影响现有代码,向后兼容
|
||||||
|
- 新旧系统可以并存,降低迁移风险
|
||||||
|
- 未来可以逐步迁移到新类型
|
||||||
|
|
||||||
|
### Trade-off 2: 状态值映射增加复杂度
|
||||||
|
|
||||||
|
**取舍**:在 API 服务层进行状态值转换。
|
||||||
|
|
||||||
|
**代价**:
|
||||||
|
|
||||||
|
- 增加一层转换逻辑
|
||||||
|
- 可能影响性能(微小)
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
|
||||||
|
- 前端 UI 保持一致性
|
||||||
|
- 业务逻辑更清晰
|
||||||
|
- 便于后续维护
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### Phase 1: 基础设施(1-2天)
|
||||||
|
|
||||||
|
1. 创建类型定义文件
|
||||||
|
2. 创建常量配置文件
|
||||||
|
3. 设置状态映射工具函数
|
||||||
|
|
||||||
|
### Phase 2: API 服务层(2-3天)
|
||||||
|
|
||||||
|
1. 实现4个 API 服务模块
|
||||||
|
2. 编写单元测试(可选)
|
||||||
|
3. 使用 Mock 数据测试
|
||||||
|
|
||||||
|
### Phase 3: 页面实现(4-5天)
|
||||||
|
|
||||||
|
1. 套餐系列管理页面(1天)
|
||||||
|
2. 套餐管理页面(1.5天)
|
||||||
|
3. 代理可售套餐页面(1天)
|
||||||
|
4. 单套餐分配页面(1.5天)
|
||||||
|
|
||||||
|
### Phase 4: 集成测试(1-2天)
|
||||||
|
|
||||||
|
1. 与后端 API 联调
|
||||||
|
2. 端到端功能测试
|
||||||
|
3. 修复 Bug 和优化
|
||||||
|
|
||||||
|
### Phase 5: 上线(1天)
|
||||||
|
|
||||||
|
1. Code Review
|
||||||
|
2. 合并代码
|
||||||
|
3. 部署到测试环境
|
||||||
|
4. 部署到生产环境
|
||||||
|
|
||||||
|
**总计**:9-13 个工作日
|
||||||
|
|
||||||
|
### Rollback Plan
|
||||||
|
|
||||||
|
如果出现严重问题,回滚步骤:
|
||||||
|
|
||||||
|
1. 从 Git 回滚到上一个稳定版本
|
||||||
|
2. 移除新增的路由配置
|
||||||
|
3. 移除新增的 API 服务导出
|
||||||
|
4. 通知用户功能暂时不可用
|
||||||
|
|
||||||
|
### Decision 7: 错误处理策略
|
||||||
|
|
||||||
|
**问题**:如何统一处理各类错误和异常?
|
||||||
|
|
||||||
|
**决策**:分层错误处理机制
|
||||||
|
|
||||||
|
- **网络错误**:axios 拦截器统一捕获,显示通用错误提示
|
||||||
|
- **401 未认证**:自动跳转到登录页面
|
||||||
|
- **403 无权限**:显示权限不足提示,不跳转
|
||||||
|
- **400 业务错误**:根据错误信息显示具体提示(ElMessage.error)
|
||||||
|
- **表单验证错误**:在表单字段下显示错误提示
|
||||||
|
|
||||||
|
**错误提示方式**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 网络错误或服务器错误
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
|
||||||
|
// 业务错误(后端返回的具体错误)
|
||||||
|
ElMessage.error(res.message || '操作失败')
|
||||||
|
|
||||||
|
// 操作成功
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 统一的错误处理提升用户体验
|
||||||
|
- 分层处理避免重复代码
|
||||||
|
- 清晰的错误提示帮助用户理解问题
|
||||||
|
|
||||||
|
### Decision 8: Loading 状态管理
|
||||||
|
|
||||||
|
**问题**:如何管理各种操作的加载状态?
|
||||||
|
|
||||||
|
**决策**:细粒度的 loading 状态管理
|
||||||
|
|
||||||
|
**Loading 状态分类**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const loading = ref(false) // 表格数据加载
|
||||||
|
const submitLoading = ref(false) // 表单提交
|
||||||
|
const deleteLoading = ref<Record<number, boolean>>({}) // 删除操作(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
**状态管理规则**:
|
||||||
|
|
||||||
|
- **列表查询**:表格显示 loading 遮罩
|
||||||
|
- **新增/编辑提交**:提交按钮显示 loading,禁用表单
|
||||||
|
- **删除操作**:可选择在按钮上显示 loading 或全局 loading
|
||||||
|
- **状态切换**:ElSwitch 自带 loading 效果,先更新 UI 再调用 API
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 细粒度控制提供更好的交互反馈
|
||||||
|
- 防止重复提交
|
||||||
|
- 清晰标识正在进行的操作
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Q**: 套餐被删除后,历史订单如何处理? **A**: 待产品确认,可能需要软删除机制
|
||||||
|
|
||||||
|
2. **Q**: 代理商可以自行调整套餐售价吗? **A**: 待产品确认,当前设计只展示建议售价
|
||||||
|
|
||||||
|
3. **Q**: 套餐系列和套餐是否支持批量操作(批量启用/禁用)? **A**: 当前不支持,后续迭代考虑
|
||||||
|
|
||||||
|
4. **Q**: 是否需要套餐变更历史记录? **A**: 后端可能有审计日志,前端暂不展示
|
||||||
|
|
||||||
|
5. **Q**: 单套餐分配的"原计算成本价"是否需要实时更新? **A**: 待确认,当前设计是创建时计算一次,不自动更新
|
||||||
159
openspec/changes/add-package-management-system/proposal.md
Normal file
159
openspec/changes/add-package-management-system/proposal.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Change: 套餐管理系统实现
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
根据业务需求文档(docs/套餐.md),需要实现完整的套餐管理系统,包括4个核心模块:
|
||||||
|
|
||||||
|
1. **套餐系列管理** - 管理套餐的分类和组织
|
||||||
|
2. **套餐管理** - 管理具体的套餐产品(流量、价格、时长等)
|
||||||
|
3. **代理可售套餐** - 代理商查看被分配的可售套餐及定价
|
||||||
|
4. **单套餐分配** - 为代理商分配特定套餐并设置成本价
|
||||||
|
|
||||||
|
当前系统虽然有套餐相关的类型定义(src/types/api/package.ts)和部分页面骨架,但缺少完整的 API 对接和业务逻辑实现。此变更将实现完整的套餐管理能力。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### 1. API 层实现
|
||||||
|
|
||||||
|
采用模块化设计,拆分为 4 个独立的 API 服务文件:
|
||||||
|
|
||||||
|
- **新增**: `src/api/modules/packageSeries.ts` - 套餐系列 API 服务
|
||||||
|
|
||||||
|
- 套餐系列列表查询(分页、筛选)
|
||||||
|
- 创建套餐系列
|
||||||
|
- 获取套餐系列详情
|
||||||
|
- 更新套餐系列
|
||||||
|
- 删除套餐系列
|
||||||
|
- 更新套餐系列状态
|
||||||
|
|
||||||
|
- **新增**: `src/api/modules/package.ts` - 套餐管理 API 服务
|
||||||
|
|
||||||
|
- 套餐列表查询(分页、多条件筛选)
|
||||||
|
- 创建套餐
|
||||||
|
- 获取套餐详情
|
||||||
|
- 更新套餐
|
||||||
|
- 删除套餐
|
||||||
|
- 更新套餐状态
|
||||||
|
- 更新套餐上架状态
|
||||||
|
- 获取系列下拉选项(用于表单选择)
|
||||||
|
|
||||||
|
- **新增**: `src/api/modules/myPackage.ts` - 代理可售套餐 API 服务
|
||||||
|
|
||||||
|
- 我的可售套餐列表查询
|
||||||
|
- 获取可售套餐详情
|
||||||
|
- 我的被分配系列列表
|
||||||
|
|
||||||
|
- **新增**: `src/api/modules/shopPackageAllocation.ts` - 单套餐分配 API 服务
|
||||||
|
|
||||||
|
- 单套餐分配列表查询
|
||||||
|
- 创建单套餐分配
|
||||||
|
- 获取单套餐分配详情
|
||||||
|
- 更新单套餐分配
|
||||||
|
- 删除单套餐分配
|
||||||
|
- 更新单套餐分配状态
|
||||||
|
|
||||||
|
- **修改**: `src/api/modules/index.ts` - 导出新增的服务模块
|
||||||
|
- 导出 PackageSeriesService
|
||||||
|
- 导出 PackageService
|
||||||
|
- 导出 MyPackageService
|
||||||
|
- 导出 ShopPackageAllocationService
|
||||||
|
|
||||||
|
### 2. 类型定义增强
|
||||||
|
|
||||||
|
- **新增**: `src/types/api/packageManagement.ts` - 完整的套餐管理类型定义
|
||||||
|
- 匹配文档的 API 字段(下划线命名)
|
||||||
|
- 包含所有请求/响应类型
|
||||||
|
- 包含分页结果类型
|
||||||
|
|
||||||
|
### 3. 页面实现
|
||||||
|
|
||||||
|
**套餐系列管理** (`src/views/package-management/package-series/index.vue`)
|
||||||
|
|
||||||
|
- 列表展示(支持名称搜索、状态筛选)
|
||||||
|
- 新增/编辑套餐系列
|
||||||
|
- 删除套餐系列
|
||||||
|
- 状态开关(启用/禁用)
|
||||||
|
|
||||||
|
**套餐管理** (`src/views/package-management/package-list/index.vue`)
|
||||||
|
|
||||||
|
- 列表展示(支持多条件筛选:名称、系列、状态、上架状态、套餐类型)
|
||||||
|
- 新增/编辑套餐
|
||||||
|
- 删除套餐
|
||||||
|
- 状态开关(启用/禁用)
|
||||||
|
- 上架状态开关(上架/下架)
|
||||||
|
|
||||||
|
**代理可售套餐** (`src/views/package-management/my-packages/index.vue`)
|
||||||
|
|
||||||
|
- 查看被分配的套餐列表(支持系列、类型筛选)
|
||||||
|
- 查看套餐详情(成本价、建议售价、利润空间等)
|
||||||
|
- 查看被分配系列列表
|
||||||
|
|
||||||
|
**单套餐分配** (`src/views/package-management/package-assign/index.vue`)
|
||||||
|
|
||||||
|
- 分配列表(支持店铺、套餐、状态筛选)
|
||||||
|
- 创建分配(选择套餐、店铺、设置成本价)
|
||||||
|
- 编辑分配(修改成本价)
|
||||||
|
- 删除分配
|
||||||
|
- 状态管理(启用/禁用)
|
||||||
|
|
||||||
|
### 4. 常量配置
|
||||||
|
|
||||||
|
- **新增**: `src/config/constants/package.ts` - 套餐相关常量
|
||||||
|
- 套餐类型枚举(formal/addon)
|
||||||
|
- 流量类型枚举(real/virtual)
|
||||||
|
- 上架状态枚举(1:上架, 2:下架)
|
||||||
|
- 定价模式枚举(fixed/percent)
|
||||||
|
- 价格来源枚举(series_pricing/package_override)
|
||||||
|
|
||||||
|
### 5. 路由配置
|
||||||
|
|
||||||
|
已存在的路由(无需修改):
|
||||||
|
|
||||||
|
- `/package-management/package-series` - 套餐系列管理
|
||||||
|
- `/package-management/package-list` - 套餐管理
|
||||||
|
- `/package-management/package-assign` - 单套餐分配
|
||||||
|
|
||||||
|
需要新增的路由:
|
||||||
|
|
||||||
|
- **新增**: `src/router/routesAlias.ts` - 添加路由别名
|
||||||
|
|
||||||
|
- `MyPackages = '/package-management/my-packages'` - 代理可售套餐
|
||||||
|
|
||||||
|
- **新增**: `src/router/routes/asyncRoutes.ts` - 添加路由配置
|
||||||
|
- `/package-management/my-packages` - 代理可售套餐页面路由
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### 受影响的规范
|
||||||
|
|
||||||
|
- `package-series-management` - 新增能力
|
||||||
|
- `package-management` - 新增能力
|
||||||
|
- `my-packages` - 新增能力
|
||||||
|
- `shop-package-allocation` - 新增能力
|
||||||
|
|
||||||
|
### 受影响的代码
|
||||||
|
|
||||||
|
- `src/api/modules/*` - 新增 4 个 API 服务模块
|
||||||
|
- `src/types/api/*` - 新增类型定义文件
|
||||||
|
- `src/views/package-management/*` - 4 个页面完整实现
|
||||||
|
- `src/config/constants/*` - 新增常量配置
|
||||||
|
- `src/router/routes/asyncRoutes.ts` - 路由配置
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 依赖现有的组件库(ArtTable、ArtSearchBar、ArtTableHeader 等)
|
||||||
|
- 依赖现有的 HTTP 请求工具(request.ts)
|
||||||
|
- 依赖现有的权限控制和路由守卫
|
||||||
|
- 依赖 ShopService(用于单套餐分配页面的店铺选择器)
|
||||||
|
- 后端 API 需已实现(docs/套餐.md 中定义的接口)
|
||||||
|
|
||||||
|
**注意事项**:
|
||||||
|
|
||||||
|
- ShopService 应该已经存在于 src/api/modules/shop.ts
|
||||||
|
- 如果不存在,需要先实现或使用 Mock 数据
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
|
||||||
|
- **低风险**: 独立模块,不影响现有功能
|
||||||
|
- **API 依赖**: 需确保后端接口已实现并联调
|
||||||
|
- **权限控制**: 需配置对应的菜单和按钮权限
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# My Packages (代理可售套餐) Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 我的可售套餐列表查询
|
||||||
|
|
||||||
|
系统 SHALL 提供代理商查询被分配套餐的功能。
|
||||||
|
|
||||||
|
#### Scenario: 查询当前代理商的可售套餐
|
||||||
|
|
||||||
|
- **WHEN** 代理商用户访问可售套餐页面
|
||||||
|
- **THEN** 系统显示被分配给该代理商的套餐列表
|
||||||
|
- **AND** 每个套餐包含:套餐ID、套餐编码、套餐名称、套餐类型、系列信息
|
||||||
|
- **AND** 显示成本价(cost_price,单位:分)
|
||||||
|
- **AND** 显示建议售价(suggested_retail_price,单位:分)
|
||||||
|
- **AND** 显示利润空间(profit_margin = 建议售价 - 成本价)
|
||||||
|
- **AND** 显示价格来源(series_pricing:系列加价 / package_override:单套餐覆盖)
|
||||||
|
- **AND** 显示套餐状态和上架状态
|
||||||
|
- **AND** 支持分页,每页最多100条记录
|
||||||
|
|
||||||
|
#### Scenario: 按系列筛选
|
||||||
|
|
||||||
|
- **WHEN** 代理商选择特定系列ID筛选
|
||||||
|
- **THEN** 系统返回该系列下的所有可售套餐
|
||||||
|
|
||||||
|
#### Scenario: 按套餐类型筛选
|
||||||
|
|
||||||
|
- **WHEN** 代理商选择套餐类型(formal/addon)筛选
|
||||||
|
- **THEN** 系统返回该类型的所有可售套餐
|
||||||
|
|
||||||
|
### Requirement: 可售套餐详情查询
|
||||||
|
|
||||||
|
系统 SHALL 允许代理商查看单个可售套餐的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询套餐详情
|
||||||
|
|
||||||
|
- **WHEN** 代理商点击查看套餐详情
|
||||||
|
- **THEN** 系统显示套餐完整信息
|
||||||
|
- **AND** 包含套餐描述、流量信息、时长等
|
||||||
|
- **AND** 显示定价详情(成本价、建议售价、利润空间、价格来源)
|
||||||
|
- **AND** 显示系列信息
|
||||||
|
|
||||||
|
#### Scenario: 查询未分配的套餐
|
||||||
|
|
||||||
|
- **WHEN** 代理商查询未被分配的套餐ID
|
||||||
|
- **THEN** 系统返回404错误或无权访问错误
|
||||||
|
|
||||||
|
### Requirement: 我的被分配系列列表
|
||||||
|
|
||||||
|
系统 SHALL 提供代理商查询被分配系列的功能。
|
||||||
|
|
||||||
|
#### Scenario: 查询被分配系列列表
|
||||||
|
|
||||||
|
- **WHEN** 代理商访问被分配系列列表
|
||||||
|
- **THEN** 系统显示分配给该代理商的系列列表
|
||||||
|
- **AND** 每个系列包含:分配ID、系列ID、系列编码、系列名称
|
||||||
|
- **AND** 显示定价模式(fixed:固定金额 / percent:百分比)
|
||||||
|
- **AND** 显示定价值(pricing_value)
|
||||||
|
- **AND** 显示分配者店铺名称
|
||||||
|
- **AND** 显示可售套餐数量
|
||||||
|
- **AND** 显示状态
|
||||||
|
- **AND** 支持分页
|
||||||
|
|
||||||
|
### Requirement: 成本价计算规则
|
||||||
|
|
||||||
|
系统 SHALL 根据价格来源计算代理商的成本价。
|
||||||
|
|
||||||
|
#### Scenario: 系列加价模式(series_pricing)
|
||||||
|
|
||||||
|
- **WHEN** 套餐通过系列分配获得定价
|
||||||
|
- **AND** 定价模式为 fixed(固定金额)
|
||||||
|
- **THEN** 成本价 = 套餐价格 + 固定加价金额
|
||||||
|
|
||||||
|
- **WHEN** 定价模式为 percent(百分比)
|
||||||
|
- **THEN** 成本价 = 套餐价格 × (1 + 加价百分比)
|
||||||
|
|
||||||
|
#### Scenario: 单套餐覆盖模式(package_override)
|
||||||
|
|
||||||
|
- **WHEN** 套餐被单独分配并设置了成本价
|
||||||
|
- **THEN** 成本价 = 单套餐分配中设置的成本价
|
||||||
|
- **AND** 价格来源显示为 package_override
|
||||||
|
|
||||||
|
### Requirement: 数据隔离
|
||||||
|
|
||||||
|
系统 SHALL 确保代理商只能查看被分配给自己的套餐。
|
||||||
|
|
||||||
|
#### Scenario: 数据访问隔离
|
||||||
|
|
||||||
|
- **WHEN** 代理商查询可售套餐列表
|
||||||
|
- **THEN** 系统仅返回分配给该代理商的套餐
|
||||||
|
- **AND** 不显示其他代理商的套餐信息
|
||||||
|
|
||||||
|
#### Scenario: 跨代理商访问保护
|
||||||
|
|
||||||
|
- **WHEN** 代理商尝试访问未分配给自己的套餐详情
|
||||||
|
- **THEN** 系统返回403无权访问错误
|
||||||
|
|
||||||
|
### Requirement: 权限控制
|
||||||
|
|
||||||
|
系统 SHALL 对可售套餐查询功能实施权限控制。
|
||||||
|
|
||||||
|
#### Scenario: 仅代理商可访问
|
||||||
|
|
||||||
|
- **WHEN** 非代理商用户访问可售套餐接口
|
||||||
|
- **THEN** 系统返回403无权访问错误
|
||||||
|
|
||||||
|
#### Scenario: 未认证用户访问
|
||||||
|
|
||||||
|
- **WHEN** 未登录用户访问可售套餐接口
|
||||||
|
- **THEN** 系统返回401未认证错误
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Package Management Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 套餐列表查询
|
||||||
|
|
||||||
|
系统 SHALL 提供套餐列表查询功能,支持分页和多条件筛选。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户访问套餐管理页面
|
||||||
|
- **THEN** 系统显示套餐列表,包含套餐编码、套餐名称、系列ID、套餐类型、流量、价格、状态、上架状态等
|
||||||
|
- **AND** 支持按套餐名称模糊搜索
|
||||||
|
- **AND** 支持按系列ID筛选
|
||||||
|
- **AND** 支持按状态筛选(启用/禁用)
|
||||||
|
- **AND** 支持按上架状态筛选(上架/下架)
|
||||||
|
- **AND** 支持按套餐类型筛选(formal/addon)
|
||||||
|
- **AND** 支持分页,每页最多100条记录
|
||||||
|
|
||||||
|
#### Scenario: 多条件组合查询
|
||||||
|
|
||||||
|
- **WHEN** 用户同时使用多个筛选条件
|
||||||
|
- **THEN** 系统返回满足所有条件的套餐列表
|
||||||
|
|
||||||
|
### Requirement: 创建套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员创建新的套餐。
|
||||||
|
|
||||||
|
#### Scenario: 成功创建套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户填写必填字段(套餐编码、套餐名称、套餐类型、套餐时长、套餐价格)
|
||||||
|
- **AND** 可选填写系列ID、流量信息、成本价、建议售价等
|
||||||
|
- **AND** 提交表单
|
||||||
|
- **THEN** 系统创建套餐,默认状态为启用,默认上架状态为下架
|
||||||
|
- **AND** 返回创建的套餐详情
|
||||||
|
|
||||||
|
#### Scenario: 套餐编码唯一性验证
|
||||||
|
|
||||||
|
- **WHEN** 用户使用已存在的套餐编码创建套餐
|
||||||
|
- **THEN** 系统返回错误提示"套餐编码已存在"
|
||||||
|
|
||||||
|
#### Scenario: 验证套餐时长范围
|
||||||
|
|
||||||
|
- **WHEN** 套餐时长小于1个月或大于120个月
|
||||||
|
- **THEN** 系统返回验证错误"套餐时长必须在1-120个月之间"
|
||||||
|
|
||||||
|
#### Scenario: 流量类型与流量额度关系
|
||||||
|
|
||||||
|
- **WHEN** 流量类型为真流量(real)
|
||||||
|
- **THEN** 真流量额度(real_data_mb)必须大于0
|
||||||
|
- **AND** 总流量额度(data_amount_mb)等于真流量额度
|
||||||
|
|
||||||
|
- **WHEN** 流量类型为虚流量(virtual)
|
||||||
|
- **THEN** 虚流量额度(virtual_data_mb)必须大于0
|
||||||
|
- **AND** 真流量额度和虚流量额度之和等于总流量额度
|
||||||
|
|
||||||
|
### Requirement: 查看套餐详情
|
||||||
|
|
||||||
|
系统 SHALL 允许用户查看单个套餐的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询存在的套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户通过套餐ID查询详情
|
||||||
|
- **THEN** 系统返回该套餐的完整信息,包括所有字段
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户查询不存在的套餐ID
|
||||||
|
- **THEN** 系统返回404错误
|
||||||
|
|
||||||
|
### Requirement: 更新套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员更新套餐信息。
|
||||||
|
|
||||||
|
#### Scenario: 成功更新套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户修改套餐的可变字段(名称、时长、价格、流量等)
|
||||||
|
- **AND** 提交更新
|
||||||
|
- **THEN** 系统更新套餐信息
|
||||||
|
- **AND** 返回更新后的套餐详情
|
||||||
|
|
||||||
|
#### Scenario: 套餐编码不可修改
|
||||||
|
|
||||||
|
- **WHEN** 用户尝试修改套餐编码
|
||||||
|
- **THEN** 系统忽略该字段,不允许修改
|
||||||
|
|
||||||
|
### Requirement: 删除套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员删除套餐。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户删除未被使用的套餐
|
||||||
|
- **THEN** 系统删除该套餐
|
||||||
|
- **AND** 返回成功状态
|
||||||
|
|
||||||
|
#### Scenario: 删除被分配的套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户删除已被分配给代理商的套餐
|
||||||
|
- **THEN** 系统返回错误提示"该套餐已被分配,无法删除"
|
||||||
|
|
||||||
|
### Requirement: 套餐状态管理
|
||||||
|
|
||||||
|
系统 SHALL 支持套餐启用/禁用状态管理。
|
||||||
|
|
||||||
|
#### Scenario: 启用套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户将禁用状态的套餐切换为启用
|
||||||
|
- **THEN** 系统更新状态为启用(status=1)
|
||||||
|
|
||||||
|
#### Scenario: 禁用套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户将启用状态的套餐切换为禁用
|
||||||
|
- **THEN** 系统更新状态为禁用(status=2)
|
||||||
|
- **AND** 该套餐将不可用于新的分配或充值
|
||||||
|
|
||||||
|
### Requirement: 套餐上架状态管理
|
||||||
|
|
||||||
|
系统 SHALL 支持套餐上架/下架状态管理。
|
||||||
|
|
||||||
|
#### Scenario: 上架套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户将下架状态的套餐切换为上架
|
||||||
|
- **THEN** 系统更新上架状态为上架(shelf_status=1)
|
||||||
|
- **AND** 该套餐将对代理商可见
|
||||||
|
|
||||||
|
#### Scenario: 下架套餐
|
||||||
|
|
||||||
|
- **WHEN** 用户将上架状态的套餐切换为下架
|
||||||
|
- **THEN** 系统更新上架状态为下架(shelf_status=2)
|
||||||
|
- **AND** 该套餐将对代理商不可见
|
||||||
|
|
||||||
|
### Requirement: 套餐类型支持
|
||||||
|
|
||||||
|
系统 SHALL 支持两种套餐类型。
|
||||||
|
|
||||||
|
#### Scenario: 正式套餐
|
||||||
|
|
||||||
|
- **WHEN** 创建套餐类型为 formal 的套餐
|
||||||
|
- **THEN** 系统记录为正式套餐
|
||||||
|
|
||||||
|
#### Scenario: 附加套餐
|
||||||
|
|
||||||
|
- **WHEN** 创建套餐类型为 addon 的套餐
|
||||||
|
- **THEN** 系统记录为附加套餐
|
||||||
|
|
||||||
|
### Requirement: 权限控制
|
||||||
|
|
||||||
|
系统 SHALL 对套餐管理功能实施权限控制。
|
||||||
|
|
||||||
|
#### Scenario: 未认证用户访问
|
||||||
|
|
||||||
|
- **WHEN** 未登录用户访问套餐管理接口
|
||||||
|
- **THEN** 系统返回401未认证错误
|
||||||
|
|
||||||
|
#### Scenario: 无权限用户访问
|
||||||
|
|
||||||
|
- **WHEN** 已登录但无权限的用户访问套餐管理接口
|
||||||
|
- **THEN** 系统返回403无权访问错误
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Package Series Management Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 套餐系列列表查询
|
||||||
|
|
||||||
|
系统 SHALL 提供套餐系列列表查询功能,支持分页和条件筛选。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户访问套餐系列管理页面
|
||||||
|
- **THEN** 系统显示套餐系列列表,包含系列名称、系列编码、描述、状态、创建时间、更新时间
|
||||||
|
- **AND** 支持按系列名称模糊搜索
|
||||||
|
- **AND** 支持按状态筛选(启用/禁用)
|
||||||
|
- **AND** 支持分页,每页最多100条记录
|
||||||
|
|
||||||
|
#### Scenario: 空列表处理
|
||||||
|
|
||||||
|
- **WHEN** 没有符合条件的套餐系列
|
||||||
|
- **THEN** 系统显示空状态提示
|
||||||
|
|
||||||
|
### Requirement: 创建套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员创建新的套餐系列。
|
||||||
|
|
||||||
|
#### Scenario: 成功创建套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户填写系列编码、系列名称(必填)
|
||||||
|
- **AND** 可选填写描述(最大500字符)
|
||||||
|
- **AND** 提交表单
|
||||||
|
- **THEN** 系统创建套餐系列,默认状态为启用
|
||||||
|
- **AND** 返回创建的套餐系列详情
|
||||||
|
|
||||||
|
#### Scenario: 系列编码重复
|
||||||
|
|
||||||
|
- **WHEN** 用户使用已存在的系列编码创建套餐系列
|
||||||
|
- **THEN** 系统返回错误提示"系列编码已存在"
|
||||||
|
|
||||||
|
#### Scenario: 验证系列名称长度
|
||||||
|
|
||||||
|
- **WHEN** 系列名称长度小于1或大于255个字符
|
||||||
|
- **THEN** 系统返回验证错误
|
||||||
|
|
||||||
|
### Requirement: 查看套餐系列详情
|
||||||
|
|
||||||
|
系统 SHALL 允许用户查看单个套餐系列的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询存在的套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户通过系列ID查询详情
|
||||||
|
- **THEN** 系统返回该套餐系列的完整信息
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户查询不存在的系列ID
|
||||||
|
- **THEN** 系统返回404错误
|
||||||
|
|
||||||
|
### Requirement: 更新套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员更新套餐系列信息。
|
||||||
|
|
||||||
|
#### Scenario: 成功更新套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户修改系列名称或描述
|
||||||
|
- **AND** 提交更新
|
||||||
|
- **THEN** 系统更新套餐系列信息
|
||||||
|
- **AND** 返回更新后的套餐系列详情
|
||||||
|
|
||||||
|
#### Scenario: 系列编码不可修改
|
||||||
|
|
||||||
|
- **WHEN** 用户尝试修改系列编码
|
||||||
|
- **THEN** 系统忽略该字段,不允许修改
|
||||||
|
|
||||||
|
### Requirement: 删除套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员删除套餐系列。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户删除未被套餐使用的系列
|
||||||
|
- **THEN** 系统删除该套餐系列
|
||||||
|
- **AND** 返回成功状态
|
||||||
|
|
||||||
|
#### Scenario: 删除被使用的套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户删除已被套餐关联的系列
|
||||||
|
- **THEN** 系统返回错误提示"该系列下存在套餐,无法删除"
|
||||||
|
|
||||||
|
### Requirement: 套餐系列状态管理
|
||||||
|
|
||||||
|
系统 SHALL 支持套餐系列状态的开关管理。
|
||||||
|
|
||||||
|
#### Scenario: 启用套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户将禁用状态的系列切换为启用
|
||||||
|
- **THEN** 系统更新状态为启用(status=1)
|
||||||
|
|
||||||
|
#### Scenario: 禁用套餐系列
|
||||||
|
|
||||||
|
- **WHEN** 用户将启用状态的系列切换为禁用
|
||||||
|
- **THEN** 系统更新状态为禁用(status=2)
|
||||||
|
- **AND** 该系列下的套餐可能受到影响(业务规则)
|
||||||
|
|
||||||
|
### Requirement: 权限控制
|
||||||
|
|
||||||
|
系统 SHALL 对套餐系列管理功能实施权限控制。
|
||||||
|
|
||||||
|
#### Scenario: 未认证用户访问
|
||||||
|
|
||||||
|
- **WHEN** 未登录用户访问套餐系列管理接口
|
||||||
|
- **THEN** 系统返回401未认证错误
|
||||||
|
|
||||||
|
#### Scenario: 无权限用户访问
|
||||||
|
|
||||||
|
- **WHEN** 已登录但无权限的用户访问套餐系列管理接口
|
||||||
|
- **THEN** 系统返回403无权访问错误
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Shop Package Allocation (单套餐分配) Specification
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 单套餐分配列表查询
|
||||||
|
|
||||||
|
系统 SHALL 提供单套餐分配记录的查询功能。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有单套餐分配
|
||||||
|
|
||||||
|
- **WHEN** 管理员访问单套餐分配管理页面
|
||||||
|
- **THEN** 系统显示单套餐分配列表
|
||||||
|
- **AND** 每条记录包含:分配ID、套餐ID、套餐编码、套餐名称
|
||||||
|
- **AND** 显示被分配的店铺ID和店铺名称
|
||||||
|
- **AND** 显示覆盖的成本价(cost_price,单位:分)
|
||||||
|
- **AND** 显示原计算成本价(calculated_cost_price,供参考)
|
||||||
|
- **AND** 显示关联的系列分配ID
|
||||||
|
- **AND** 显示状态、创建时间、更新时间
|
||||||
|
- **AND** 支持分页,每页最多100条记录
|
||||||
|
|
||||||
|
#### Scenario: 按店铺筛选
|
||||||
|
|
||||||
|
- **WHEN** 管理员选择特定店铺ID筛选
|
||||||
|
- **THEN** 系统返回该店铺的所有单套餐分配记录
|
||||||
|
|
||||||
|
#### Scenario: 按套餐筛选
|
||||||
|
|
||||||
|
- **WHEN** 管理员选择特定套餐ID筛选
|
||||||
|
- **THEN** 系统返回该套餐的所有分配记录
|
||||||
|
|
||||||
|
#### Scenario: 按状态筛选
|
||||||
|
|
||||||
|
- **WHEN** 管理员选择状态(启用/禁用)筛选
|
||||||
|
- **THEN** 系统返回符合状态条件的分配记录
|
||||||
|
|
||||||
|
### Requirement: 创建单套餐分配
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员为店铺分配特定套餐并设置成本价。
|
||||||
|
|
||||||
|
#### Scenario: 成功创建单套餐分配
|
||||||
|
|
||||||
|
- **WHEN** 管理员选择套餐ID、店铺ID
|
||||||
|
- **AND** 设置覆盖的成本价(必填,最小值为0)
|
||||||
|
- **AND** 提交分配
|
||||||
|
- **THEN** 系统创建单套餐分配记录
|
||||||
|
- **AND** 计算并保存原计算成本价(基于系列分配规则)
|
||||||
|
- **AND** 返回创建的分配详情,包括关联的系列分配ID
|
||||||
|
|
||||||
|
#### Scenario: 重复分配检查
|
||||||
|
|
||||||
|
- **WHEN** 管理员为同一店铺分配已分配过的套餐
|
||||||
|
- **THEN** 系统返回错误提示"该套餐已分配给此店铺"
|
||||||
|
|
||||||
|
#### Scenario: 店铺和套餐验证
|
||||||
|
|
||||||
|
- **WHEN** 管理员使用不存在的店铺ID或套餐ID
|
||||||
|
- **THEN** 系统返回错误提示"店铺或套餐不存在"
|
||||||
|
|
||||||
|
### Requirement: 查看单套餐分配详情
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员查看单个分配记录的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询存在的分配记录
|
||||||
|
|
||||||
|
- **WHEN** 管理员通过分配ID查询详情
|
||||||
|
- **THEN** 系统返回该分配记录的完整信息
|
||||||
|
- **AND** 包含套餐完整信息、店铺信息、定价信息
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的分配记录
|
||||||
|
|
||||||
|
- **WHEN** 管理员查询不存在的分配ID
|
||||||
|
- **THEN** 系统返回404错误
|
||||||
|
|
||||||
|
### Requirement: 更新单套餐分配
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员更新单套餐分配的成本价。
|
||||||
|
|
||||||
|
#### Scenario: 成功更新成本价
|
||||||
|
|
||||||
|
- **WHEN** 管理员修改覆盖的成本价
|
||||||
|
- **AND** 提交更新
|
||||||
|
- **THEN** 系统更新成本价
|
||||||
|
- **AND** 返回更新后的分配详情
|
||||||
|
|
||||||
|
#### Scenario: 成本价验证
|
||||||
|
|
||||||
|
- **WHEN** 管理员设置成本价小于0
|
||||||
|
- **THEN** 系统返回验证错误"成本价必须大于等于0"
|
||||||
|
|
||||||
|
### Requirement: 删除单套餐分配
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员删除单套餐分配记录。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除分配
|
||||||
|
|
||||||
|
- **WHEN** 管理员删除单套餐分配记录
|
||||||
|
- **THEN** 系统删除该分配记录
|
||||||
|
- **AND** 该店铺将无法再以此定价售卖该套餐
|
||||||
|
- **AND** 返回成功状态
|
||||||
|
|
||||||
|
#### Scenario: 删除正在使用的分配
|
||||||
|
|
||||||
|
- **WHEN** 管理员删除正在被订单使用的分配记录
|
||||||
|
- **THEN** 系统可能返回警告或阻止删除(根据业务规则)
|
||||||
|
|
||||||
|
### Requirement: 单套餐分配状态管理
|
||||||
|
|
||||||
|
系统 SHALL 支持单套餐分配的启用/禁用状态管理。
|
||||||
|
|
||||||
|
#### Scenario: 启用分配
|
||||||
|
|
||||||
|
- **WHEN** 管理员将禁用状态的分配切换为启用
|
||||||
|
- **THEN** 系统更新状态为启用(status=1)
|
||||||
|
- **AND** 该店铺可以使用此定价售卖套餐
|
||||||
|
|
||||||
|
#### Scenario: 禁用分配
|
||||||
|
|
||||||
|
- **WHEN** 管理员将启用状态的分配切换为禁用
|
||||||
|
- **THEN** 系统更新状态为禁用(status=2)
|
||||||
|
- **AND** 该店铺将无法使用此定价售卖套餐
|
||||||
|
|
||||||
|
### Requirement: 成本价优先级规则
|
||||||
|
|
||||||
|
系统 SHALL 实现单套餐分配成本价覆盖系列分配规则。
|
||||||
|
|
||||||
|
#### Scenario: 单套餐分配优先
|
||||||
|
|
||||||
|
- **WHEN** 店铺同时拥有系列分配和单套餐分配
|
||||||
|
- **THEN** 系统使用单套餐分配的成本价
|
||||||
|
- **AND** 原计算成本价(calculated_cost_price)保存系列分配规则计算的价格,供参考
|
||||||
|
|
||||||
|
#### Scenario: 仅系列分配
|
||||||
|
|
||||||
|
- **WHEN** 店铺只有系列分配,没有单套餐分配
|
||||||
|
- **THEN** 系统使用系列分配规则计算成本价
|
||||||
|
|
||||||
|
### Requirement: 关联系列分配追踪
|
||||||
|
|
||||||
|
系统 SHALL 追踪单套餐分配与系列分配的关联关系。
|
||||||
|
|
||||||
|
#### Scenario: 记录关联的系列分配
|
||||||
|
|
||||||
|
- **WHEN** 创建单套餐分配时
|
||||||
|
- **THEN** 系统记录关联的系列分配ID(allocation_id)
|
||||||
|
- **AND** 用于追溯定价来源
|
||||||
|
|
||||||
|
### Requirement: 权限控制
|
||||||
|
|
||||||
|
系统 SHALL 对单套餐分配管理功能实施权限控制。
|
||||||
|
|
||||||
|
#### Scenario: 未认证用户访问
|
||||||
|
|
||||||
|
- **WHEN** 未登录用户访问单套餐分配接口
|
||||||
|
- **THEN** 系统返回401未认证错误
|
||||||
|
|
||||||
|
#### Scenario: 无权限用户访问
|
||||||
|
|
||||||
|
- **WHEN** 已登录但无权限的用户访问单套餐分配接口
|
||||||
|
- **THEN** 系统返回403无权访问错误
|
||||||
|
|
||||||
|
#### Scenario: 仅管理员可操作
|
||||||
|
|
||||||
|
- **WHEN** 非管理员用户尝试创建、更新或删除单套餐分配
|
||||||
|
- **THEN** 系统返回403无权访问错误
|
||||||
169
openspec/changes/add-package-management-system/tasks.md
Normal file
169
openspec/changes/add-package-management-system/tasks.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Implementation Tasks
|
||||||
|
|
||||||
|
## 1. 基础设施准备
|
||||||
|
|
||||||
|
- [ ] 1.1 创建套餐管理类型定义文件(src/types/api/packageManagement.ts)
|
||||||
|
- [ ] 1.2 创建套餐常量配置文件(src/config/constants/package.ts)
|
||||||
|
- [ ] 1.3 导出常量配置到 constants/index.ts
|
||||||
|
|
||||||
|
## 2. API 服务层实现
|
||||||
|
|
||||||
|
### 2.1 套餐系列 API(packageSeries.ts)
|
||||||
|
|
||||||
|
- [ ] 2.1.1 实现 getPackageSeries(套餐系列列表)
|
||||||
|
- [ ] 2.1.2 实现 createPackageSeries(创建套餐系列)
|
||||||
|
- [ ] 2.1.3 实现 getPackageSeriesDetail(获取套餐系列详情)
|
||||||
|
- [ ] 2.1.4 实现 updatePackageSeries(更新套餐系列)
|
||||||
|
- [ ] 2.1.5 实现 deletePackageSeries(删除套餐系列)
|
||||||
|
- [ ] 2.1.6 实现 updatePackageSeriesStatus(更新套餐系列状态)
|
||||||
|
|
||||||
|
### 2.2 套餐管理 API(package.ts)
|
||||||
|
|
||||||
|
- [ ] 2.2.1 实现 getPackages(套餐列表)
|
||||||
|
- [ ] 2.2.2 实现 createPackage(创建套餐)
|
||||||
|
- [ ] 2.2.3 实现 getPackageDetail(获取套餐详情)
|
||||||
|
- [ ] 2.2.4 实现 updatePackage(更新套餐)
|
||||||
|
- [ ] 2.2.5 实现 deletePackage(删除套餐)
|
||||||
|
- [ ] 2.2.6 实现 updatePackageStatus(更新套餐状态)
|
||||||
|
- [ ] 2.2.7 实现 updatePackageShelfStatus(更新套餐上架状态)
|
||||||
|
|
||||||
|
### 2.3 代理可售套餐 API(myPackage.ts)
|
||||||
|
|
||||||
|
- [ ] 2.3.1 实现 getMyPackages(我的可售套餐列表)
|
||||||
|
- [ ] 2.3.2 实现 getMyPackageDetail(获取可售套餐详情)
|
||||||
|
- [ ] 2.3.3 实现 getMySeriesAllocations(我的被分配系列列表)
|
||||||
|
|
||||||
|
### 2.4 单套餐分配 API(shopPackageAllocation.ts)
|
||||||
|
|
||||||
|
- [ ] 2.4.1 实现 getShopPackageAllocations(单套餐分配列表)
|
||||||
|
- [ ] 2.4.2 实现 createShopPackageAllocation(创建单套餐分配)
|
||||||
|
- [ ] 2.4.3 实现 getShopPackageAllocationDetail(获取单套餐分配详情)
|
||||||
|
- [ ] 2.4.4 实现 updateShopPackageAllocation(更新单套餐分配)
|
||||||
|
- [ ] 2.4.5 实现 deleteShopPackageAllocation(删除单套餐分配)
|
||||||
|
- [ ] 2.4.6 实现 updateShopPackageAllocationStatus(更新单套餐分配状态)
|
||||||
|
|
||||||
|
- [ ] 2.5 在 src/api/modules/index.ts 中导出所有新服务
|
||||||
|
|
||||||
|
## 3. 页面实现
|
||||||
|
|
||||||
|
### 3.1 套餐系列管理页面(package-series/index.vue)
|
||||||
|
|
||||||
|
- [ ] 3.1.1 实现列表展示(表格、分页)
|
||||||
|
- [ ] 3.1.2 实现搜索栏(系列名称、状态筛选)
|
||||||
|
- [ ] 3.1.3 实现新增对话框(表单验证)
|
||||||
|
- [ ] 3.1.4 实现编辑功能(复用新增对话框,根据 dialogType 区分新增/编辑)
|
||||||
|
- [ ] 3.1.5 实现删除功能(二次确认)
|
||||||
|
- [ ] 3.1.6 实现状态开关(启用/禁用)
|
||||||
|
- [ ] 3.1.7 集成 API 服务并处理加载状态
|
||||||
|
|
||||||
|
### 3.2 套餐管理页面(package-list/index.vue)
|
||||||
|
|
||||||
|
- [ ] 3.2.1 实现列表展示(表格、分页)
|
||||||
|
- [ ] 3.2.2 实现搜索栏(名称、系列、状态、上架状态、类型筛选)
|
||||||
|
- [ ] 3.2.3 实现系列下拉选择器(加载套餐系列列表,只显示启用状态)
|
||||||
|
- [ ] 3.2.4 实现新增对话框(表单验证、系列选择)
|
||||||
|
- [ ] 3.2.5 实现编辑功能(复用新增对话框,根据 dialogType 区分新增/编辑)
|
||||||
|
- [ ] 3.2.6 实现删除功能(二次确认)
|
||||||
|
- [ ] 3.2.7 实现状态开关(启用/禁用)
|
||||||
|
- [ ] 3.2.8 实现上架状态开关(上架/下架)
|
||||||
|
- [ ] 3.2.9 集成 API 服务并处理加载状态
|
||||||
|
|
||||||
|
### 3.3 代理可售套餐页面(my-packages/index.vue)
|
||||||
|
|
||||||
|
- [ ] 3.3.1 创建页面文件和基本结构
|
||||||
|
- [ ] 3.3.2 实现列表展示(表格、分页)
|
||||||
|
- [ ] 3.3.3 实现搜索栏(系列、类型筛选)
|
||||||
|
- [ ] 3.3.4 实现详情对话框(显示成本价、建议售价、利润空间)
|
||||||
|
- [ ] 3.3.5 实现被分配系列列表Tab(可选)
|
||||||
|
- [ ] 3.3.6 集成 API 服务并处理加载状态
|
||||||
|
|
||||||
|
### 3.4 单套餐分配页面(package-assign/index.vue)
|
||||||
|
|
||||||
|
- [ ] 3.4.1 创建页面文件和基本结构
|
||||||
|
- [ ] 3.4.2 实现列表展示(表格、分页)
|
||||||
|
- [ ] 3.4.3 实现搜索栏(店铺、套餐、状态筛选)
|
||||||
|
- [ ] 3.4.4 实现套餐下拉选择器(加载套餐列表,只显示启用且上架的套餐)
|
||||||
|
- [ ] 3.4.5 实现店铺下拉选择器(使用 ShopService 加载店铺列表)
|
||||||
|
- [ ] 3.4.6 实现新增对话框(套餐选择、店铺选择、成本价输入)
|
||||||
|
- [ ] 3.4.7 实现编辑功能(单独对话框或复用新增对话框,只允许修改成本价)
|
||||||
|
- [ ] 3.4.8 实现删除功能(二次确认)
|
||||||
|
- [ ] 3.4.9 实现状态管理(启用/禁用开关)
|
||||||
|
- [ ] 3.4.10 集成 API 服务并处理加载状态
|
||||||
|
|
||||||
|
## 4. 路由配置
|
||||||
|
|
||||||
|
- [ ] 4.1 在 asyncRoutes.ts 中添加 my-packages 路由配置
|
||||||
|
- [ ] 4.2 验证路由权限配置正确
|
||||||
|
|
||||||
|
## 5. 集成测试
|
||||||
|
|
||||||
|
### 5.1 套餐系列管理测试
|
||||||
|
|
||||||
|
- [ ] 5.1.1 测试列表查询(空列表、有数据、分页)
|
||||||
|
- [ ] 5.1.2 测试搜索功能(名称模糊搜索、状态筛选)
|
||||||
|
- [ ] 5.1.3 测试新增功能(成功、编码重复、字段验证)
|
||||||
|
- [ ] 5.1.4 测试编辑功能(成功、字段验证)
|
||||||
|
- [ ] 5.1.5 测试删除功能(成功、有关联套餐时禁止删除)
|
||||||
|
- [ ] 5.1.6 测试状态切换(启用→禁用、禁用→启用)
|
||||||
|
- [ ] 5.1.7 测试权限控制(未登录、无权限)
|
||||||
|
|
||||||
|
### 5.2 套餐管理测试
|
||||||
|
|
||||||
|
- [ ] 5.2.1 测试列表查询(空列表、有数据、分页)
|
||||||
|
- [ ] 5.2.2 测试多条件筛选(名称、系列、状态、上架状态、类型)
|
||||||
|
- [ ] 5.2.3 测试系列下拉选择器(只显示启用状态的系列)
|
||||||
|
- [ ] 5.2.4 测试新增功能(成功、编码重复、时长验证、流量验证)
|
||||||
|
- [ ] 5.2.5 测试编辑功能(成功、字段验证)
|
||||||
|
- [ ] 5.2.6 测试删除功能(成功、已分配时禁止删除)
|
||||||
|
- [ ] 5.2.7 测试状态切换(启用→禁用、禁用→启用)
|
||||||
|
- [ ] 5.2.8 测试上架状态切换(上架→下架、下架→上架)
|
||||||
|
- [ ] 5.2.9 测试权限控制(未登录、无权限)
|
||||||
|
|
||||||
|
### 5.3 代理可售套餐测试
|
||||||
|
|
||||||
|
- [ ] 5.3.1 测试列表查询(空列表、有数据、分页)
|
||||||
|
- [ ] 5.3.2 测试筛选功能(按系列、按类型)
|
||||||
|
- [ ] 5.3.3 测试详情查询(显示成本价、建议售价、利润空间、价格来源)
|
||||||
|
- [ ] 5.3.4 测试数据隔离(只能看到分配给自己的套餐)
|
||||||
|
- [ ] 5.3.5 测试被分配系列列表(如果实现)
|
||||||
|
- [ ] 5.3.6 测试权限控制(非代理商用户无法访问)
|
||||||
|
|
||||||
|
### 5.4 单套餐分配测试
|
||||||
|
|
||||||
|
- [ ] 5.4.1 测试列表查询(空列表、有数据、分页)
|
||||||
|
- [ ] 5.4.2 测试筛选功能(按店铺、按套餐、按状态)
|
||||||
|
- [ ] 5.4.3 测试套餐下拉选择器(只显示启用且上架的套餐)
|
||||||
|
- [ ] 5.4.4 测试店铺下拉选择器(加载店铺列表)
|
||||||
|
- [ ] 5.4.5 测试新增功能(成功、重复分配、成本价验证)
|
||||||
|
- [ ] 5.4.6 测试编辑功能(修改成本价)
|
||||||
|
- [ ] 5.4.7 测试删除功能(成功、有订单时的处理)
|
||||||
|
- [ ] 5.4.8 测试状态切换(启用→禁用、禁用→启用)
|
||||||
|
- [ ] 5.4.9 测试价格覆盖规则(单套餐分配优先于系列分配)
|
||||||
|
- [ ] 5.4.10 测试权限控制(仅管理员可操作)
|
||||||
|
|
||||||
|
### 5.5 通用功能测试
|
||||||
|
|
||||||
|
- [ ] 5.5.1 测试所有页面的表单验证(必填、长度、格式)
|
||||||
|
- [ ] 5.5.2 测试所有页面的 loading 状态(列表、提交、删除)
|
||||||
|
- [ ] 5.5.3 测试所有页面的错误处理(网络错误、业务错误)
|
||||||
|
- [ ] 5.5.4 测试所有页面的二次确认(删除操作)
|
||||||
|
- [ ] 5.5.5 测试分页功能(换页、改变每页数量)
|
||||||
|
- [ ] 5.5.6 测试刷新功能(列表刷新)
|
||||||
|
- [ ] 5.5.7 测试列显示/隐藏功能
|
||||||
|
- [ ] 5.5.8 测试状态值映射(前端0/1与后端1/2的转换)
|
||||||
|
|
||||||
|
## 6. 代码优化和文档
|
||||||
|
|
||||||
|
- [ ] 6.1 代码格式化和 ESLint 检查
|
||||||
|
- [ ] 6.2 添加必要的注释
|
||||||
|
- [ ] 6.3 更新 API 文档(如需要)
|
||||||
|
- [ ] 6.4 提交代码并创建 PR
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 所有页面需参考 `/system/role` 页面的组件化结构
|
||||||
|
- 使用统一的 `CommonStatus` 常量(需要注意文档中的状态值映射)
|
||||||
|
- API 字段使用下划线命名(如 `series_name`),前端类型使用驼峰命名
|
||||||
|
- 所有删除操作需要二次确认
|
||||||
|
- 所有表单需要完整的验证规则
|
||||||
|
- 统一使用 Element Plus 的 Message 和 MessageBox 组件
|
||||||
22
openspec/changes/add-shop-default-roles/proposal.md
Normal file
22
openspec/changes/add-shop-default-roles/proposal.md
Normal 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 (假设该页面存在,如不存在需创建)
|
||||||
|
- 依赖的现有功能: 角色管理系统
|
||||||
@@ -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** 允许用户重试或关闭对话框
|
||||||
62
openspec/changes/add-shop-default-roles/tasks.md
Normal file
62
openspec/changes/add-shop-default-roles/tasks.md
Normal 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 交互符合预期
|
||||||
|
- 边界情况处理正确
|
||||||
|
- 权限控制生效
|
||||||
@@ -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 调用
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 确认所有相关功能正常工作
|
||||||
82
openspec/changes/update-package-management-api/proposal.md
Normal file
82
openspec/changes/update-package-management-api/proposal.md
Normal 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. 完成后删除旧的、不再使用的类型定义
|
||||||
@@ -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` 字段必填,且为表示月数的数字字符串
|
||||||
@@ -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) - 分配者店铺ID,0表示平台分配
|
||||||
|
- `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为50,shop_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返回所有关联该系列分配的单套餐分配记录
|
||||||
@@ -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) - 分配者店铺ID,0表示平台分配
|
||||||
|
- `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为50,shop_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"
|
||||||
62
openspec/changes/update-package-management-api/tasks.md
Normal file
62
openspec/changes/update-package-management-api/tasks.md
Normal 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变更影响和迁移说明(如需要)
|
||||||
215
openspec/changes/update-series-allocation-commission/design.md
Normal file
215
openspec/changes/update-series-allocation-commission/design.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Design: 套餐系列分配佣金系统重构
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
当前系统使用简单的定价模式(pricing*mode/pricing_value)和一次性佣金(one_time_commission*\*)来管理套餐系列分配。随着业务发展,需要更复杂的佣金系统:
|
||||||
|
|
||||||
|
- 支持基础返佣(固定金额或百分比)
|
||||||
|
- 支持梯度返佣(根据销量或销售额分档返佣)
|
||||||
|
- 更清晰的数据模型和API接口
|
||||||
|
|
||||||
|
**背景约束**:
|
||||||
|
|
||||||
|
- 前后端需要同步部署(Breaking Change)
|
||||||
|
- 需要数据迁移方案
|
||||||
|
- 影响现有的套餐系列分配功能
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals**:
|
||||||
|
|
||||||
|
- 实现新的佣金配置模型,支持基础返佣和梯度返佣
|
||||||
|
- 提供清晰的UI界面让用户配置复杂的返佣规则
|
||||||
|
- 保证数据一致性和类型安全
|
||||||
|
- 提供良好的用户体验(表单验证、错误提示)
|
||||||
|
|
||||||
|
**Non-Goals**:
|
||||||
|
|
||||||
|
- 不处理历史数据的完整性验证(由后端负责)
|
||||||
|
- 不实现佣金计算逻辑(由后端负责)
|
||||||
|
- 不处理佣金结算流程(属于其他模块)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Data Model Design
|
||||||
|
|
||||||
|
**决策**: 采用嵌套对象结构表示佣金配置
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- `base_commission: { mode, value }` - 清晰表达基础返佣的两个维度
|
||||||
|
- `tier_config: { period_type, tier_type, tiers[] }` - 梯度配置与基础配置分离,可选性强
|
||||||
|
- `tiers: [{ threshold, mode, value }]` - 每个档位都有独立的返佣模式和值
|
||||||
|
|
||||||
|
**替代方案考虑**:
|
||||||
|
|
||||||
|
- ❌ 平铺所有字段 - 会导致字段过多,语义不清晰
|
||||||
|
- ❌ 使用JSON字符串存储配置 - 失去类型安全,不利于表单编辑
|
||||||
|
|
||||||
|
### 2. UI Design Pattern
|
||||||
|
|
||||||
|
**决策**: 使用渐进式表单设计
|
||||||
|
|
||||||
|
**表单结构**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 基础返佣配置 (必填)
|
||||||
|
- 返佣模式: 单选 (固定金额/百分比)
|
||||||
|
- 返佣值: 数字输入
|
||||||
|
|
||||||
|
2. 梯度返佣设置 (可选)
|
||||||
|
- 启用开关: 是/否
|
||||||
|
- [如果启用]:
|
||||||
|
- 周期类型: 下拉 (月度/季度/年度)
|
||||||
|
- 梯度类型: 下拉 (销量/销售额)
|
||||||
|
- 档位列表: 动态表单
|
||||||
|
* 阈值
|
||||||
|
* 返佣模式
|
||||||
|
* 返佣值
|
||||||
|
* [添加]/[删除] 按钮
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 渐进式设计降低初始复杂度
|
||||||
|
- 只有启用梯度返佣时才显示相关配置
|
||||||
|
- 动态档位列表提供灵活性
|
||||||
|
|
||||||
|
**替代方案考虑**:
|
||||||
|
|
||||||
|
- ❌ 全部平铺展示 - 对不需要梯度返佣的用户造成困扰
|
||||||
|
- ❌ 使用向导模式 - 增加操作步骤,不适合编辑场景
|
||||||
|
|
||||||
|
### 3. Form Validation Strategy
|
||||||
|
|
||||||
|
**决策**: 分层验证 + 条件验证
|
||||||
|
|
||||||
|
**验证规则**:
|
||||||
|
|
||||||
|
1. 基础返佣配置:
|
||||||
|
|
||||||
|
- mode: 必选
|
||||||
|
- value: 必填,>= 0
|
||||||
|
|
||||||
|
2. 梯度返佣配置(当启用时):
|
||||||
|
- period_type: 必选
|
||||||
|
- tier_type: 必选
|
||||||
|
- tiers: 至少一个档位
|
||||||
|
- 每个档位:
|
||||||
|
- threshold: 必填,> 0
|
||||||
|
- mode: 必选
|
||||||
|
- value: 必填,> 0
|
||||||
|
- 档位阈值必须递增
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
|
||||||
|
- 使用 Element Plus 的表单验证
|
||||||
|
- 自定义validator处理档位阈值递增验证
|
||||||
|
- 使用 computed 动态生成验证规则
|
||||||
|
|
||||||
|
### 4. API Response Handling
|
||||||
|
|
||||||
|
**决策**: 统一使用 `list` 字段名,添加适配层
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
|
||||||
|
- 后端统一规范使用 `list` 而非 `items`
|
||||||
|
- 添加 `total_pages` 字段提供更完整的分页信息
|
||||||
|
- 保持前端代码与后端规范一致
|
||||||
|
|
||||||
|
**迁移策略**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const res = await getShopSeriesAllocations(params)
|
||||||
|
allocationList.value = res.data.items || []
|
||||||
|
|
||||||
|
// After
|
||||||
|
const res = await getShopSeriesAllocations(params)
|
||||||
|
allocationList.value = res.data.list || []
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
### Risk 1: Breaking Change导致部署协调困难
|
||||||
|
|
||||||
|
**风险**: 前后端必须同时部署,否则会出现接口不兼容
|
||||||
|
|
||||||
|
**缓解措施**:
|
||||||
|
|
||||||
|
- 与后端团队协调部署窗口
|
||||||
|
- 准备回退方案(前端代码分支)
|
||||||
|
- 在测试环境充分验证
|
||||||
|
|
||||||
|
### Risk 2: 复杂表单导致用户体验问题
|
||||||
|
|
||||||
|
**风险**: 梯度返佣配置较复杂,用户可能不理解
|
||||||
|
|
||||||
|
**缓解措施**:
|
||||||
|
|
||||||
|
- 提供清晰的字段说明
|
||||||
|
- 添加示例或帮助文档链接
|
||||||
|
- 使用默认值简化初次配置
|
||||||
|
|
||||||
|
### Risk 3: 数据迁移可能失败
|
||||||
|
|
||||||
|
**风险**: 旧数据无法完全转换为新模型
|
||||||
|
|
||||||
|
**缓解措施**:
|
||||||
|
|
||||||
|
- 要求后端提供数据迁移脚本和验证
|
||||||
|
- 在迁移后检查数据完整性
|
||||||
|
- 保留旧数据备份
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### Phase 1: 准备阶段
|
||||||
|
|
||||||
|
1. 与后端确认API变更细节和时间表
|
||||||
|
2. 在开发环境实现前端变更
|
||||||
|
3. 与后端在测试环境联调
|
||||||
|
|
||||||
|
### Phase 2: 测试阶段
|
||||||
|
|
||||||
|
1. 功能测试: 创建、编辑、删除、列表展示
|
||||||
|
2. 集成测试: 与后端API集成
|
||||||
|
3. 用户验收测试: 业务人员验证
|
||||||
|
|
||||||
|
### Phase 3: 部署阶段
|
||||||
|
|
||||||
|
1. 准备回退方案
|
||||||
|
2. 与后端协调部署窗口
|
||||||
|
3. 同步部署前后端
|
||||||
|
4. 验证生产环境功能
|
||||||
|
|
||||||
|
### Phase 4: 监控阶段
|
||||||
|
|
||||||
|
1. 监控API错误率
|
||||||
|
2. 收集用户反馈
|
||||||
|
3. 修复遗留问题
|
||||||
|
|
||||||
|
### Rollback Plan
|
||||||
|
|
||||||
|
如果部署后发现严重问题:
|
||||||
|
|
||||||
|
1. 前端回退到上一版本
|
||||||
|
2. 后端回退API(如果可能)
|
||||||
|
3. 通知用户暂时不可用
|
||||||
|
4. 修复问题后重新部署
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Q**: 梯度返佣的档位数量是否有上限?
|
||||||
|
|
||||||
|
- **A**: 待后端确认,前端可以先不限制或设置合理上限(如10个)
|
||||||
|
|
||||||
|
2. **Q**: 返佣值的单位和精度如何处理?
|
||||||
|
|
||||||
|
- **A**: 固定金额使用"分"为单位,百分比使用千分比(如200=20%),待后端确认
|
||||||
|
|
||||||
|
3. **Q**: 是否需要在列表页显示梯度返佣信息?
|
||||||
|
|
||||||
|
- **A**: 暂时只显示基础返佣,梯度信息在详情或编辑时查看
|
||||||
|
|
||||||
|
4. **Q**: 旧数据如何映射到新模型?
|
||||||
|
- **A**: 待后端提供迁移方案,前端需要能够正确显示迁移后的数据
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Change: 更新套餐系列分配佣金系统
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
当前套餐系列分配使用简单的定价模式(固定加价/百分比加价)和一次性佣金配置,不支持复杂的返佣规则。新的业务需求要求支持:
|
||||||
|
|
||||||
|
- 基础返佣配置(固定金额或百分比)
|
||||||
|
- 梯度返佣系统(根据销量或销售额分档返佣)
|
||||||
|
- 更灵活的佣金计算模型
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
**BREAKING** - 完全重构套餐系列分配的数据模型和API接口:
|
||||||
|
|
||||||
|
- 移除旧的定价字段: `pricing_mode`, `pricing_value`, `calculated_cost_price`
|
||||||
|
- 移除旧的一次性佣金字段: `one_time_commission_trigger`, `one_time_commission_threshold`, `one_time_commission_amount`
|
||||||
|
- 新增基础返佣配置: `base_commission` (包含 mode 和 value)
|
||||||
|
- 新增梯度返佣开关: `enable_tier_commission`
|
||||||
|
- 新增梯度返佣配置: `tier_config` (可选,当启用梯度返佣时需要)
|
||||||
|
- 响应数据结构从 `items` 改为 `list` (符合后端统一规范)
|
||||||
|
- 更新所有相关的创建/更新接口以支持新的佣金模型
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **影响模块**: 套餐系列分配 (`/package-management/series-assign`)
|
||||||
|
- **受影响文件**:
|
||||||
|
- `src/types/api/packageManagement.ts` - TypeScript类型定义
|
||||||
|
- `src/api/modules/shopSeriesAllocation.ts` - API服务层
|
||||||
|
- `src/views/package-management/series-assign/index.vue` - 前端页面
|
||||||
|
- **数据迁移**: 需要后端提供数据迁移方案,将旧的定价数据转换为新的佣金配置
|
||||||
|
- **向后兼容性**: **不兼容**,需要前后端同时部署
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
1. **API响应结构变更**:
|
||||||
|
|
||||||
|
- 列表接口响应从 `{ items, page, page_size, total }` 改为 `{ list, page, page_size, total, total_pages }`
|
||||||
|
- 移除 `pricing_mode`, `pricing_value`, `calculated_cost_price` 字段
|
||||||
|
- 移除一次性佣金相关字段
|
||||||
|
- 新增 `base_commission`, `enable_tier_commission` 字段
|
||||||
|
|
||||||
|
2. **API请求结构变更**:
|
||||||
|
|
||||||
|
- 创建/更新接口需要新的 `base_commission` 对象
|
||||||
|
- 支持可选的 `enable_tier_commission` 和 `tier_config`
|
||||||
|
|
||||||
|
3. **前端组件变更**:
|
||||||
|
- 表单需要支持基础佣金配置和梯度返佣配置
|
||||||
|
- 表格列需要显示新的佣金信息
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# Package Series Allocation Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 套餐系列分配列表查询
|
||||||
|
|
||||||
|
系统 SHALL 提供套餐系列分配列表查询功能,支持按店铺、系列和状态筛选,返回包含新佣金配置的分配信息。
|
||||||
|
|
||||||
|
#### Scenario: 成功获取分配列表
|
||||||
|
|
||||||
|
- **WHEN** 用户请求套餐系列分配列表
|
||||||
|
- **THEN** 系统返回分配列表,每项包含:
|
||||||
|
- 基本信息: id, series_id, series_name, shop_id, shop_name
|
||||||
|
- 分配者信息: allocator_shop_id, allocator_shop_name
|
||||||
|
- 佣金配置: base_commission (mode, value), enable_tier_commission
|
||||||
|
- 状态和时间: status, created_at, updated_at
|
||||||
|
- **AND** 响应结构为: `{ list, page, page_size, total, total_pages }`
|
||||||
|
|
||||||
|
#### Scenario: 按条件筛选分配列表
|
||||||
|
|
||||||
|
- **WHEN** 用户提供筛选条件 (shop_id, series_id, status)
|
||||||
|
- **THEN** 系统返回符合条件的分配列表
|
||||||
|
- **AND** 支持分页参数 (page, page_size)
|
||||||
|
|
||||||
|
### Requirement: 创建套餐系列分配
|
||||||
|
|
||||||
|
系统 SHALL 允许用户创建套餐系列分配,配置基础返佣和可选的梯度返佣规则。
|
||||||
|
|
||||||
|
#### Scenario: 创建基础返佣分配
|
||||||
|
|
||||||
|
- **WHEN** 用户提交创建请求,包含:
|
||||||
|
- series_id: 套餐系列ID
|
||||||
|
- shop_id: 被分配的店铺ID
|
||||||
|
- base_commission: { mode: "fixed"/"percent", value: number }
|
||||||
|
- **THEN** 系统创建分配记录
|
||||||
|
- **AND** 返回完整的分配信息包含 allocator_shop_id 和 allocator_shop_name
|
||||||
|
|
||||||
|
#### Scenario: 创建带梯度返佣的分配
|
||||||
|
|
||||||
|
- **WHEN** 用户提交创建请求,包含:
|
||||||
|
- series_id, shop_id, base_commission (同上)
|
||||||
|
- enable_tier_commission: true
|
||||||
|
- tier_config: { period_type, tier_type, tiers[] }
|
||||||
|
- period_type: "monthly"/"quarterly"/"yearly"
|
||||||
|
- tier_type: "sales_count"/"sales_amount"
|
||||||
|
- tiers: [{ threshold, mode, value }]
|
||||||
|
- **THEN** 系统创建分配记录并保存梯度配置
|
||||||
|
- **AND** 梯度档位按阈值升序存储
|
||||||
|
|
||||||
|
#### Scenario: 验证梯度档位阈值递增
|
||||||
|
|
||||||
|
- **WHEN** 用户提交的梯度档位阈值不是递增的
|
||||||
|
- **THEN** 系统返回400错误
|
||||||
|
- **AND** 错误消息说明阈值必须递增
|
||||||
|
|
||||||
|
### Requirement: 更新套餐系列分配
|
||||||
|
|
||||||
|
系统 SHALL 允许用户更新现有分配的佣金配置,包括基础返佣和梯度返佣。
|
||||||
|
|
||||||
|
#### Scenario: 更新基础返佣配置
|
||||||
|
|
||||||
|
- **WHEN** 用户提交更新请求,包含新的 base_commission
|
||||||
|
- **THEN** 系统更新分配记录
|
||||||
|
- **AND** 返回更新后的完整信息
|
||||||
|
|
||||||
|
#### Scenario: 启用或禁用梯度返佣
|
||||||
|
|
||||||
|
- **WHEN** 用户更新 enable_tier_commission 标志
|
||||||
|
- **THEN** 系统更新配置
|
||||||
|
- **AND** 如果启用,必须提供有效的 tier_config
|
||||||
|
- **AND** 如果禁用,tier_config 可以为空
|
||||||
|
|
||||||
|
#### Scenario: 更新梯度返佣配置
|
||||||
|
|
||||||
|
- **WHEN** 用户提交包含新 tier_config 的更新请求
|
||||||
|
- **THEN** 系统替换整个梯度配置
|
||||||
|
- **AND** 验证新档位阈值递增
|
||||||
|
- **AND** 返回更新后的信息
|
||||||
|
|
||||||
|
### Requirement: 删除套餐系列分配
|
||||||
|
|
||||||
|
系统 SHALL 允许用户删除套餐系列分配记录。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除分配
|
||||||
|
|
||||||
|
- **WHEN** 用户请求删除指定ID的分配
|
||||||
|
- **THEN** 系统删除该分配记录
|
||||||
|
- **AND** 返回成功响应 (200)
|
||||||
|
|
||||||
|
#### Scenario: 删除不存在的分配
|
||||||
|
|
||||||
|
- **WHEN** 用户请求删除不存在的分配ID
|
||||||
|
- **THEN** 系统返回404错误
|
||||||
|
- **AND** 错误消息说明分配不存在
|
||||||
|
|
||||||
|
### Requirement: 获取套餐系列分配详情
|
||||||
|
|
||||||
|
系统 SHALL 提供单个分配记录的详细信息查询。
|
||||||
|
|
||||||
|
#### Scenario: 成功获取详情
|
||||||
|
|
||||||
|
- **WHEN** 用户请求指定ID的分配详情
|
||||||
|
- **THEN** 系统返回完整的分配信息,包括:
|
||||||
|
- 所有基本字段
|
||||||
|
- base_commission 对象
|
||||||
|
- enable_tier_commission 标志
|
||||||
|
- tier_config (如果启用了梯度返佣)
|
||||||
|
|
||||||
|
### Requirement: 更新套餐系列分配状态
|
||||||
|
|
||||||
|
系统 SHALL 允许用户启用或禁用套餐系列分配。
|
||||||
|
|
||||||
|
#### Scenario: 切换分配状态
|
||||||
|
|
||||||
|
- **WHEN** 用户提交状态更新请求 (status: 1启用/2禁用)
|
||||||
|
- **THEN** 系统更新分配状态
|
||||||
|
- **AND** 返回成功响应
|
||||||
|
|
||||||
|
#### Scenario: 禁用分配后的行为
|
||||||
|
|
||||||
|
- **WHEN** 分配被禁用 (status: 2)
|
||||||
|
- **THEN** 该分配不再参与佣金计算 (由后端业务逻辑保证)
|
||||||
|
- **AND** 前端列表中状态显示为"禁用"
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
### Requirement: 定价模式配置
|
||||||
|
|
||||||
|
**Reason**: 旧的定价模式 (pricing_mode, pricing_value) 已被新的佣金模型替代
|
||||||
|
|
||||||
|
**Migration**: 旧数据通过后端迁移脚本转换为基础返佣配置:
|
||||||
|
|
||||||
|
- pricing_mode="fixed" → base_commission.mode="fixed"
|
||||||
|
- pricing_mode="percentage" → base_commission.mode="percent"
|
||||||
|
- pricing_value → base_commission.value (需要单位转换)
|
||||||
|
|
||||||
|
### Requirement: 一次性佣金配置
|
||||||
|
|
||||||
|
**Reason**: 一次性佣金配置 (one*time_commission*\*) 已被梯度返佣系统替代
|
||||||
|
|
||||||
|
**Migration**: 旧的一次性佣金通过以下方式迁移:
|
||||||
|
|
||||||
|
- 如果设置了一次性佣金,转换为单档位的梯度返佣
|
||||||
|
- trigger → tier_type mapping (first_activation → sales_count, cumulative_recharge → sales_amount)
|
||||||
|
- threshold → tiers[0].threshold
|
||||||
|
- amount → tiers[0].value (mode设为fixed)
|
||||||
|
|
||||||
|
### Requirement: 计算成本价字段
|
||||||
|
|
||||||
|
**Reason**: calculated_cost_price 字段在新模型中不再使用
|
||||||
|
|
||||||
|
**Migration**: 该字段不再返回,成本计算由后端业务逻辑内部处理
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 基础返佣配置
|
||||||
|
|
||||||
|
系统 SHALL 为每个分配提供基础返佣配置,包含返佣模式和返佣值。
|
||||||
|
|
||||||
|
#### Scenario: 固定金额返佣
|
||||||
|
|
||||||
|
- **WHEN** base_commission.mode = "fixed"
|
||||||
|
- **THEN** base_commission.value 表示固定返佣金额(单位:分)
|
||||||
|
- **AND** 每笔交易返佣该固定金额
|
||||||
|
|
||||||
|
#### Scenario: 百分比返佣
|
||||||
|
|
||||||
|
- **WHEN** base_commission.mode = "percent"
|
||||||
|
- **THEN** base_commission.value 表示返佣百分比的千分比 (如200表示20%)
|
||||||
|
- **AND** 每笔交易返佣 = 交易金额 \* (value / 1000)
|
||||||
|
|
||||||
|
### Requirement: 梯度返佣配置
|
||||||
|
|
||||||
|
系统 SHALL 支持可选的梯度返佣配置,根据周期内的销量或销售额分档返佣。
|
||||||
|
|
||||||
|
#### Scenario: 按销量分档返佣
|
||||||
|
|
||||||
|
- **WHEN** tier_config.tier_type = "sales_count"
|
||||||
|
- **THEN** 系统根据周期内的销售数量匹配档位
|
||||||
|
- **AND** 达到档位阈值后,该档位的返佣规则生效
|
||||||
|
|
||||||
|
#### Scenario: 按销售额分档返佣
|
||||||
|
|
||||||
|
- **WHEN** tier_config.tier_type = "sales_amount"
|
||||||
|
- **THEN** 系统根据周期内的销售金额(分)匹配档位
|
||||||
|
- **AND** 达到档位阈值后,该档位的返佣规则生效
|
||||||
|
|
||||||
|
#### Scenario: 月度返佣周期
|
||||||
|
|
||||||
|
- **WHEN** tier_config.period_type = "monthly"
|
||||||
|
- **THEN** 系统按自然月统计销量或销售额
|
||||||
|
- **AND** 每月初重置计数
|
||||||
|
|
||||||
|
#### Scenario: 季度返佣周期
|
||||||
|
|
||||||
|
- **WHEN** tier_config.period_type = "quarterly"
|
||||||
|
- **THEN** 系统按季度统计销量或销售额
|
||||||
|
- **AND** 每季度初重置计数
|
||||||
|
|
||||||
|
#### Scenario: 年度返佣周期
|
||||||
|
|
||||||
|
- **WHEN** tier_config.period_type = "yearly"
|
||||||
|
- **THEN** 系统按年度统计销量或销售额
|
||||||
|
- **AND** 每年初重置计数
|
||||||
|
|
||||||
|
### Requirement: 梯度档位管理
|
||||||
|
|
||||||
|
系统 SHALL 支持多个返佣档位,每个档位有独立的阈值和返佣规则。
|
||||||
|
|
||||||
|
#### Scenario: 多档位返佣
|
||||||
|
|
||||||
|
- **WHEN** 配置了多个档位 (tiers数组)
|
||||||
|
- **THEN** 系统按阈值从低到高排列档位
|
||||||
|
- **AND** 当销量/销售额超过某档位阈值时,使用该档位的返佣规则
|
||||||
|
|
||||||
|
#### Scenario: 档位阈值唯一性
|
||||||
|
|
||||||
|
- **WHEN** 用户提交包含重复阈值的档位
|
||||||
|
- **THEN** 系统返回400错误
|
||||||
|
- **AND** 错误消息说明阈值必须唯一
|
||||||
|
|
||||||
|
#### Scenario: 档位返佣模式
|
||||||
|
|
||||||
|
- **WHEN** 档位的 mode = "fixed"
|
||||||
|
- **THEN** value 表示固定返佣金额(分)
|
||||||
|
- **WHEN** 档位的 mode = "percent"
|
||||||
|
- **THEN** value 表示返佣百分比的千分比
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Implementation Tasks
|
||||||
|
|
||||||
|
## 1. Type Definitions Update
|
||||||
|
|
||||||
|
- [x] 1.1 更新 `ShopSeriesAllocationResponse` 接口,移除旧字段,添加新的佣金字段
|
||||||
|
- [x] 1.2 创建 `BaseCommissionConfig` 类型定义 (mode, value)
|
||||||
|
- [x] 1.3 创建 `TierCommissionConfig` 类型定义 (period_type, tier_type, tiers)
|
||||||
|
- [x] 1.4 创建 `TierEntry` 类型定义 (threshold, mode, value)
|
||||||
|
- [x] 1.5 更新 `CreateShopSeriesAllocationRequest` 接口以支持新的佣金字段
|
||||||
|
- [x] 1.6 更新 `UpdateShopSeriesAllocationRequest` 接口以支持新的佣金字段
|
||||||
|
- [x] 1.7 更新分页响应类型,从 `items` 改为 `list`,添加 `total_pages`
|
||||||
|
|
||||||
|
## 2. API Service Layer Update
|
||||||
|
|
||||||
|
- [x] 2.1 更新 `getShopSeriesAllocations` 方法以处理新的响应结构 (list 而非 items)
|
||||||
|
- [x] 2.2 更新 `createShopSeriesAllocation` 方法以发送新的佣金配置
|
||||||
|
- [x] 2.3 更新 `updateShopSeriesAllocation` 方法以发送新的佣金配置
|
||||||
|
- [x] 2.4 确保所有API方法正确处理新的类型定义
|
||||||
|
|
||||||
|
## 3. Frontend Page Refactoring
|
||||||
|
|
||||||
|
- [x] 3.1 移除旧的定价模式相关代码 (pricing_mode, pricing_value)
|
||||||
|
- [x] 3.2 移除旧的一次性佣金配置相关代码
|
||||||
|
- [x] 3.3 更新表格列定义,移除 `calculated_cost_price` 等旧列
|
||||||
|
- [x] 3.4 添加基础佣金配置表单字段 (mode: fixed/percent, value)
|
||||||
|
- [x] 3.5 添加梯度返佣开关字段 (enable_tier_commission)
|
||||||
|
- [x] 3.6 添加梯度返佣配置表单 (period_type, tier_type, tiers数组)
|
||||||
|
- [x] 3.7 实现梯度档位的动态添加/删除功能
|
||||||
|
- [x] 3.8 更新表格列以显示新的佣金信息 (base_commission)
|
||||||
|
- [x] 3.9 更新表单验证规则以适配新的字段要求
|
||||||
|
- [x] 3.10 更新数据列表获取逻辑,从 `res.data.items` 改为 `res.data.list`
|
||||||
|
- [x] 3.11 更新 `showDialog` 函数以正确填充新的佣金字段
|
||||||
|
- [x] 3.12 更新 `handleDialogClosed` 函数以重置新的佣金字段
|
||||||
|
- [x] 3.13 更新 `handleSubmit` 函数以正确提交新的佣金配置
|
||||||
|
|
||||||
|
## 4. UI/UX Enhancement
|
||||||
|
|
||||||
|
- [x] 4.1 设计并实现基础佣金配置UI (单选模式 + 数值输入)
|
||||||
|
- [x] 4.2 设计并实现梯度返佣配置UI (周期类型、梯度类型、档位列表)
|
||||||
|
- [x] 4.3 添加梯度档位的表单验证 (阈值递增、必填字段等)
|
||||||
|
- [x] 4.4 优化对话框布局以容纳新增的配置项
|
||||||
|
- [x] 4.5 添加梯度返佣的帮助提示或说明文档链接
|
||||||
|
|
||||||
|
## 5. Data Migration Support
|
||||||
|
|
||||||
|
- [x] 5.1 与后端确认数据迁移方案和时间表
|
||||||
|
- [x] 5.2 准备回退方案(如果部署失败)
|
||||||
|
- [x] 5.3 准备测试数据以验证迁移正确性
|
||||||
|
|
||||||
|
## 6. Testing and Validation
|
||||||
|
|
||||||
|
- [x] 6.1 测试基础佣金配置的创建和编辑
|
||||||
|
- [x] 6.2 测试梯度返佣的创建和编辑
|
||||||
|
- [x] 6.3 测试档位添加/删除功能
|
||||||
|
- [x] 6.4 测试表单验证规则
|
||||||
|
- [x] 6.5 测试数据列表的显示和分页
|
||||||
|
- [x] 6.6 测试状态切换功能
|
||||||
|
- [x] 6.7 测试删除功能
|
||||||
|
- [x] 6.8 验证与后端API的集成
|
||||||
653
package-lock.json
generated
653
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "vue-ts-vite-demo",
|
"name": "internet-of-things-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "vue-ts-vite-demo",
|
"name": "internet-of-things-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@vueuse/core": "^11.0.0",
|
"@vueuse/core": "^11.0.0",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "next",
|
"@wangeditor/editor-for-vue": "next",
|
||||||
|
"aws-sdk": "^2.1693.0",
|
||||||
"axios": "^1.7.5",
|
"axios": "^1.7.5",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"echarts": "^5.4.0",
|
"echarts": "^5.4.0",
|
||||||
@@ -43,18 +44,18 @@
|
|||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/compiler-sfc": "^3.0.5",
|
"@vue/compiler-sfc": "^3.0.5",
|
||||||
"commitizen": "^4.3.0",
|
"commitizen": "^4.3.0",
|
||||||
"cz-git": "^1.9.4",
|
"cz-git": "^1.11.1",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.9.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-vue": "^9.27.0",
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"husky": "^9.1.5",
|
"husky": "^9.1.5",
|
||||||
"lint-staged": "^15.2.10",
|
"lint-staged": "^15.5.2",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.5.3",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.81.0",
|
||||||
"stylelint": "^16.8.2",
|
"stylelint": "^16.20.0",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"stylelint-config-html": "^1.1.0",
|
||||||
"stylelint-config-recess-order": "^4.6.0",
|
"stylelint-config-recess-order": "^4.6.0",
|
||||||
"stylelint-config-recommended-scss": "^14.1.0",
|
"stylelint-config-recommended-scss": "^14.1.0",
|
||||||
@@ -146,6 +147,67 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@cacheable/memory": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@cacheable/memory/-/memory-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cacheable/utils": "^2.3.3",
|
||||||
|
"@keyv/bigmap": "^1.3.0",
|
||||||
|
"hookified": "^1.14.0",
|
||||||
|
"keyv": "^5.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cacheable/memory/node_modules/@keyv/bigmap": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@keyv/bigmap/-/bigmap-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hashery": "^1.4.0",
|
||||||
|
"hookified": "^1.15.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"keyv": "^5.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cacheable/memory/node_modules/keyv": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@keyv/serialize": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cacheable/utils": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@cacheable/utils/-/utils-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hashery": "^1.3.0",
|
||||||
|
"keyv": "^5.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cacheable/utils/node_modules/keyv": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@keyv/serialize": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/autocomplete": {
|
"node_modules/@codemirror/autocomplete": {
|
||||||
"version": "6.18.6",
|
"version": "6.18.6",
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
|
"resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
|
||||||
@@ -762,6 +824,23 @@
|
|||||||
"@csstools/css-tokenizer": "^3.0.4"
|
"@csstools/css-tokenizer": "^3.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.0.26",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz",
|
||||||
|
"integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/@csstools/css-tokenizer": {
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||||
@@ -813,13 +892,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@dual-bundle/import-meta-resolve": {
|
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||||
"version": "4.1.0",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
|
||||||
"integrity": "sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==",
|
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/JounQin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@element-plus/icons-vue": {
|
"node_modules/@element-plus/icons-vue": {
|
||||||
@@ -1618,37 +1698,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@keyv/serialize": {
|
"node_modules/@keyv/serialize": {
|
||||||
"version": "1.0.3",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.1.1.tgz",
|
||||||
"integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==",
|
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"license": "MIT"
|
||||||
"buffer": "^6.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@keyv/serialize/node_modules/buffer": {
|
|
||||||
"version": "6.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz",
|
|
||||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.1",
|
|
||||||
"ieee754": "^1.2.1"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@lezer/common": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
@@ -3645,6 +3699,69 @@
|
|||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/available-typed-arrays": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"possible-typed-array-names": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/aws-sdk": {
|
||||||
|
"version": "2.1693.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/aws-sdk/-/aws-sdk-2.1693.0.tgz",
|
||||||
|
"integrity": "sha512-cJmb8xEnVLT+R6fBS5sn/EFJiX7tUnDaPtOPZ1vFbOJtd0fnZn/Ky2XGgsvvoeliWeH7mL3TWSX5zXXGSQV6gQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer": "4.9.2",
|
||||||
|
"events": "1.1.1",
|
||||||
|
"ieee754": "1.1.13",
|
||||||
|
"jmespath": "0.16.0",
|
||||||
|
"querystring": "0.2.0",
|
||||||
|
"sax": "1.2.1",
|
||||||
|
"url": "0.10.3",
|
||||||
|
"util": "^0.12.4",
|
||||||
|
"uuid": "8.0.0",
|
||||||
|
"xml2js": "0.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/aws-sdk/node_modules/buffer": {
|
||||||
|
"version": "4.9.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/buffer/-/buffer-4.9.2.tgz",
|
||||||
|
"integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.0.2",
|
||||||
|
"ieee754": "^1.1.4",
|
||||||
|
"isarray": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/aws-sdk/node_modules/ieee754": {
|
||||||
|
"version": "1.1.13",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.1.13.tgz",
|
||||||
|
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/aws-sdk/node_modules/uuid": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.9.0.tgz",
|
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.9.0.tgz",
|
||||||
@@ -3665,7 +3782,6 @@
|
|||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -4424,13 +4540,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cacheable": {
|
"node_modules/cacheable": {
|
||||||
"version": "1.9.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmmirror.com/cacheable/-/cacheable-1.9.0.tgz",
|
"resolved": "https://registry.npmmirror.com/cacheable/-/cacheable-2.3.2.tgz",
|
||||||
"integrity": "sha512-8D5htMCxPDUULux9gFzv30f04Xo3wCnik0oOxKoRTPIBoqA7HtOcJ87uBhQTs3jCfZZTrUBGsYIZOgE0ZRgMAg==",
|
"integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hookified": "^1.8.2",
|
"@cacheable/memory": "^2.0.7",
|
||||||
"keyv": "^5.3.3"
|
"@cacheable/utils": "^2.3.3",
|
||||||
|
"hookified": "^1.15.0",
|
||||||
|
"keyv": "^5.5.5",
|
||||||
|
"qified": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cacheable-request": {
|
"node_modules/cacheable-request": {
|
||||||
@@ -4482,12 +4602,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cacheable/node_modules/keyv": {
|
"node_modules/cacheable/node_modules/keyv": {
|
||||||
"version": "5.3.3",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz",
|
||||||
"integrity": "sha512-Rwu4+nXI9fqcxiEHtbkvoes2X+QfkTRo1TMkPfwzipGsJlJO/z69vqB4FNl9xJ3xCpAcbkvmEabZfPzrwN3+gQ==",
|
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@keyv/serialize": "^1.0.3"
|
"@keyv/serialize": "^1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cachedir": {
|
"node_modules/cachedir": {
|
||||||
@@ -4499,6 +4620,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.0",
|
||||||
|
"es-define-property": "^1.0.0",
|
||||||
|
"get-intrinsic": "^1.2.4",
|
||||||
|
"set-function-length": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@@ -4511,6 +4650,22 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -5405,10 +5560,11 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
@@ -5634,6 +5790,23 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/define-data-property": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/define-lazy-prop": {
|
"node_modules/define-lazy-prop": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
||||||
@@ -6939,6 +7112,15 @@
|
|||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/events/-/events-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exec-buffer": {
|
"node_modules/exec-buffer": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/exec-buffer/-/exec-buffer-3.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/exec-buffer/-/exec-buffer-3.2.0.tgz",
|
||||||
@@ -7485,6 +7667,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/for-each": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-callable": "^1.2.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.2.tgz",
|
||||||
@@ -7596,6 +7793,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/generator-function": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-caller-file": {
|
"node_modules/get-caller-file": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
@@ -8108,6 +8314,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-property-descriptors": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-symbol-support-x": {
|
"node_modules/has-symbol-support-x": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmmirror.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz",
|
"resolved": "https://registry.npmmirror.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz",
|
||||||
@@ -8154,6 +8372,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hashery": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/hashery/-/hashery-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hookified": "^1.14.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -8200,10 +8431,11 @@
|
|||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
||||||
},
|
},
|
||||||
"node_modules/hookified": {
|
"node_modules/hookified": {
|
||||||
"version": "1.9.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmmirror.com/hookified/-/hookified-1.9.0.tgz",
|
"resolved": "https://registry.npmmirror.com/hookified/-/hookified-1.15.0.tgz",
|
||||||
"integrity": "sha512-2yEEGqphImtKIe1NXWEhu6yD3hlFR4Mxk4Mtp3XEyScpSt4pQ4ymmXA1zzxZpj99QkFK+nN0nzjeb2+RUi/6CQ==",
|
"integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "2.8.9",
|
"version": "2.8.9",
|
||||||
@@ -8323,9 +8555,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "7.0.4",
|
"version": "7.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
|
||||||
"integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==",
|
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
@@ -8970,8 +9203,7 @@
|
|||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ini": {
|
"node_modules/ini": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
@@ -9070,6 +9302,22 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arguments": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"has-tostringtag": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
@@ -9088,6 +9336,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-callable": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-core-module": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz",
|
"resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||||
@@ -9169,6 +9429,25 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-generator-function": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"generator-function": "^2.0.0",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"safe-regex-test": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-gif": {
|
"node_modules/is-gif": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-gif/-/is-gif-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-gif/-/is-gif-3.0.0.tgz",
|
||||||
@@ -9284,6 +9563,24 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-regex": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-retry-allowed": {
|
"node_modules/is-retry-allowed": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz",
|
||||||
@@ -9332,6 +9629,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-typed-array": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"which-typed-array": "^1.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-unicode-supported": {
|
"node_modules/is-unicode-supported": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||||
@@ -9390,8 +9702,7 @@
|
|||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -9420,6 +9731,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jmespath": {
|
||||||
|
"version": "0.16.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/jmespath/-/jmespath-0.16.0.tgz",
|
||||||
|
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jpegtran-bin": {
|
"node_modules/jpegtran-bin": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/jpegtran-bin/-/jpegtran-bin-6.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/jpegtran-bin/-/jpegtran-bin-6.0.1.tgz",
|
||||||
@@ -11485,10 +11805,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/possible-typed-array-names": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.3",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -11503,8 +11832,9 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.8",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -11730,6 +12060,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qified": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/qified/-/qified-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hookified": "^1.14.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qrcode.vue": {
|
"node_modules/qrcode.vue": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/qrcode.vue/-/qrcode.vue-3.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/qrcode.vue/-/qrcode.vue-3.6.0.tgz",
|
||||||
@@ -11767,6 +12110,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/querystring": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/querystring/-/querystring-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
|
||||||
|
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -12213,6 +12565,23 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-regex-test": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"is-regex": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -12239,6 +12608,12 @@
|
|||||||
"@parcel/watcher": "^2.4.1"
|
"@parcel/watcher": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/scroll-into-view-if-needed": {
|
"node_modules/scroll-into-view-if-needed": {
|
||||||
"version": "2.2.31",
|
"version": "2.2.31",
|
||||||
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
||||||
@@ -12312,6 +12687,23 @@
|
|||||||
"semver": "bin/semver"
|
"semver": "bin/semver"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-function-length": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"define-data-property": "^1.1.4",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-intrinsic": "^1.2.4",
|
||||||
|
"gopd": "^1.0.1",
|
||||||
|
"has-property-descriptors": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -12799,9 +13191,9 @@
|
|||||||
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="
|
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="
|
||||||
},
|
},
|
||||||
"node_modules/stylelint": {
|
"node_modules/stylelint": {
|
||||||
"version": "16.19.1",
|
"version": "16.26.1",
|
||||||
"resolved": "https://registry.npmmirror.com/stylelint/-/stylelint-16.19.1.tgz",
|
"resolved": "https://registry.npmmirror.com/stylelint/-/stylelint-16.26.1.tgz",
|
||||||
"integrity": "sha512-C1SlPZNMKl+d/C867ZdCRthrS+6KuZ3AoGW113RZCOL0M8xOGpgx7G70wq7lFvqvm4dcfdGFVLB/mNaLFChRKw==",
|
"integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -12813,35 +13205,37 @@
|
|||||||
"url": "https://github.com/sponsors/stylelint"
|
"url": "https://github.com/sponsors/stylelint"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||||
"@csstools/css-tokenizer": "^3.0.3",
|
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
|
||||||
"@csstools/media-query-list-parser": "^4.0.2",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
|
"@csstools/media-query-list-parser": "^4.0.3",
|
||||||
"@csstools/selector-specificity": "^5.0.0",
|
"@csstools/selector-specificity": "^5.0.0",
|
||||||
"@dual-bundle/import-meta-resolve": "^4.1.0",
|
"@dual-bundle/import-meta-resolve": "^4.2.1",
|
||||||
"balanced-match": "^2.0.0",
|
"balanced-match": "^2.0.0",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
"cosmiconfig": "^9.0.0",
|
"cosmiconfig": "^9.0.0",
|
||||||
"css-functions-list": "^3.2.3",
|
"css-functions-list": "^3.2.3",
|
||||||
"css-tree": "^3.1.0",
|
"css-tree": "^3.1.0",
|
||||||
"debug": "^4.3.7",
|
"debug": "^4.4.3",
|
||||||
"fast-glob": "^3.3.3",
|
"fast-glob": "^3.3.3",
|
||||||
"fastest-levenshtein": "^1.0.16",
|
"fastest-levenshtein": "^1.0.16",
|
||||||
"file-entry-cache": "^10.0.8",
|
"file-entry-cache": "^11.1.1",
|
||||||
"global-modules": "^2.0.0",
|
"global-modules": "^2.0.0",
|
||||||
"globby": "^11.1.0",
|
"globby": "^11.1.0",
|
||||||
"globjoin": "^0.1.4",
|
"globjoin": "^0.1.4",
|
||||||
"html-tags": "^3.3.1",
|
"html-tags": "^3.3.1",
|
||||||
"ignore": "^7.0.3",
|
"ignore": "^7.0.5",
|
||||||
"imurmurhash": "^0.1.4",
|
"imurmurhash": "^0.1.4",
|
||||||
"is-plain-object": "^5.0.0",
|
"is-plain-object": "^5.0.0",
|
||||||
"known-css-properties": "^0.36.0",
|
"known-css-properties": "^0.37.0",
|
||||||
"mathml-tag-names": "^2.1.3",
|
"mathml-tag-names": "^2.1.3",
|
||||||
"meow": "^13.2.0",
|
"meow": "^13.2.0",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
"normalize-path": "^3.0.0",
|
"normalize-path": "^3.0.0",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"postcss-resolve-nested-selector": "^0.1.6",
|
"postcss-resolve-nested-selector": "^0.1.6",
|
||||||
"postcss-safe-parser": "^7.0.1",
|
"postcss-safe-parser": "^7.0.1",
|
||||||
"postcss-selector-parser": "^7.1.0",
|
"postcss-selector-parser": "^7.1.0",
|
||||||
@@ -13062,23 +13456,25 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/stylelint/node_modules/file-entry-cache": {
|
"node_modules/stylelint/node_modules/file-entry-cache": {
|
||||||
"version": "10.1.0",
|
"version": "11.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-10.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-11.1.2.tgz",
|
||||||
"integrity": "sha512-Et/ex6smi3wOOB+n5mek+Grf7P2AxZR5ueqRUvAAn4qkyatXi3cUC1cuQXVkX0VlzBVsN4BkWJFmY/fYiRTdww==",
|
"integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flat-cache": "^6.1.9"
|
"flat-cache": "^6.1.20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stylelint/node_modules/flat-cache": {
|
"node_modules/stylelint/node_modules/flat-cache": {
|
||||||
"version": "6.1.9",
|
"version": "6.1.20",
|
||||||
"resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-6.1.9.tgz",
|
"resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-6.1.20.tgz",
|
||||||
"integrity": "sha512-DUqiKkTlAfhtl7g78IuwqYM+YqvT+as0mY+EVk6mfimy19U79pJCzDZQsnqk3Ou/T6hFXWLGbwbADzD/c8Tydg==",
|
"integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cacheable": "^1.9.0",
|
"cacheable": "^2.3.2",
|
||||||
"flatted": "^3.3.3",
|
"flatted": "^3.3.3",
|
||||||
"hookified": "^1.8.2"
|
"hookified": "^1.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stylelint/node_modules/global-modules": {
|
"node_modules/stylelint/node_modules/global-modules": {
|
||||||
@@ -13113,6 +13509,13 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/stylelint/node_modules/known-css-properties": {
|
||||||
|
"version": "0.37.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/known-css-properties/-/known-css-properties-0.37.0.tgz",
|
||||||
|
"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stylelint/node_modules/meow": {
|
"node_modules/stylelint/node_modules/meow": {
|
||||||
"version": "13.2.0",
|
"version": "13.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz",
|
||||||
@@ -14081,6 +14484,16 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/url": {
|
||||||
|
"version": "0.10.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/url/-/url-0.10.3.tgz",
|
||||||
|
"integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "1.3.2",
|
||||||
|
"querystring": "0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/url-parse-lax": {
|
"node_modules/url-parse-lax": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz",
|
||||||
@@ -14102,6 +14515,25 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/url/node_modules/punycode": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/util": {
|
||||||
|
"version": "0.12.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/util/-/util-0.12.5.tgz",
|
||||||
|
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"is-arguments": "^1.0.4",
|
||||||
|
"is-generator-function": "^1.0.7",
|
||||||
|
"is-typed-array": "^1.1.3",
|
||||||
|
"which-typed-array": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -14706,6 +15138,27 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-typed-array": {
|
||||||
|
"version": "1.1.20",
|
||||||
|
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
|
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"available-typed-arrays": "^1.0.7",
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"for-each": "^0.3.5",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-tostringtag": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wildcard": {
|
"node_modules/wildcard": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
|
||||||
@@ -14870,6 +15323,28 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml2js": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": ">=0.6.0",
|
||||||
|
"xmlbuilder": "~11.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xss": {
|
"node_modules/xss": {
|
||||||
"version": "1.0.15",
|
"version": "1.0.15",
|
||||||
"resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz",
|
"resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"pinia-plugin-persistedstate": "^4.3.0",
|
"pinia-plugin-persistedstate": "^4.3.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"qrcode.vue": "^3.6.0",
|
"qrcode.vue": "^3.6.0",
|
||||||
"vue": "^3.5.12",
|
"vue": "^3.5.12",
|
||||||
"vue-draggable-plus": "^0.6.0",
|
"vue-draggable-plus": "^0.6.0",
|
||||||
|
|||||||
5601
pnpm-lock.yaml
generated
5601
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
|||||||
import zh from 'element-plus/es/locale/lang/zh-cn'
|
import zh from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import en from 'element-plus/es/locale/lang/en'
|
import en from 'element-plus/es/locale/lang/en'
|
||||||
import { systemUpgrade } from '@/utils'
|
import { systemUpgrade } from '@/utils'
|
||||||
import { UserService } from './api/usersApi'
|
import { AuthService } from '@/api/modules'
|
||||||
import { ApiStatus } from './utils/http/status'
|
import { ApiStatus } from './utils/http/status'
|
||||||
import { setThemeTransitionClass } from '@/utils'
|
import { setThemeTransitionClass } from '@/utils'
|
||||||
import { checkStorageCompatibility } from '@/utils'
|
import { checkStorageCompatibility } from '@/utils'
|
||||||
@@ -41,10 +41,11 @@
|
|||||||
const getUserInfo = async () => {
|
const getUserInfo = async () => {
|
||||||
if (userStore.isLogin && userStore.accessToken) {
|
if (userStore.isLogin && userStore.accessToken) {
|
||||||
try {
|
try {
|
||||||
const res = await UserService.getUserInfo()
|
const res = await AuthService.getUserInfo()
|
||||||
if (res.code === ApiStatus.success && res.data) {
|
if (res.code === ApiStatus.success && res.data) {
|
||||||
// API 返回的是 { user, permissions },我们需要保存 user
|
// API 返回的是 { user, permissions },我们需要保存 user 和 permissions
|
||||||
userStore.setUserInfo(res.data.user)
|
userStore.setUserInfo(res.data.user)
|
||||||
|
userStore.setPermissions(res.data.permissions || [])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取用户信息失败:', error)
|
console.error('获取用户信息失败:', error)
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ export class BaseService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH 请求
|
||||||
|
* @param url 请求URL
|
||||||
|
* @param data 请求数据
|
||||||
|
* @param config 额外配置
|
||||||
|
*/
|
||||||
|
protected static patch<T = any>(
|
||||||
|
url: string,
|
||||||
|
data?: Record<string, any>,
|
||||||
|
config?: Record<string, any>
|
||||||
|
): Promise<T> {
|
||||||
|
return request.patch<T>({
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
...config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE 请求
|
* DELETE 请求
|
||||||
* @param url 请求URL
|
* @param url 请求URL
|
||||||
@@ -170,16 +188,16 @@ export class BaseService {
|
|||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.keys(params).forEach((key) => {
|
Object.keys(params).forEach((key) => {
|
||||||
formData.append(key, params[key])
|
if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
|
||||||
|
formData.append(key, String(params[key]))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不要手动设置 Content-Type,让浏览器自动设置(包含正确的 boundary)
|
||||||
return request.post<BaseResponse<T>>({
|
return request.post<BaseResponse<T>>({
|
||||||
url,
|
url,
|
||||||
data: formData,
|
data: formData
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data'
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -92,4 +92,24 @@ export class AccountService extends BaseService {
|
|||||||
static removeRoleFromAccount(accountId: number, roleId: number): Promise<BaseResponse> {
|
static removeRoleFromAccount(accountId: number, roleId: number): Promise<BaseResponse> {
|
||||||
return this.delete<BaseResponse>(`/api/admin/accounts/${accountId}/roles/${roleId}`)
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/api/modules/agentRecharge.ts
Normal file
61
src/api/modules/agentRecharge.ts
Normal 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
179
src/api/modules/asset.ts
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,42 +16,42 @@ import type {
|
|||||||
|
|
||||||
export class AuthService extends BaseService {
|
export class AuthService extends BaseService {
|
||||||
/**
|
/**
|
||||||
* 用户登录
|
* 用户登录(统一认证接口)
|
||||||
* @param params 登录参数
|
* @param params 登录参数
|
||||||
*/
|
*/
|
||||||
static login(params: LoginParams): Promise<BaseResponse<LoginData>> {
|
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> {
|
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>> {
|
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 刷新参数
|
* @param params 刷新参数
|
||||||
*/
|
*/
|
||||||
static refreshToken(params: RefreshTokenParams): Promise<BaseResponse<RefreshTokenData>> {
|
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 修改密码参数
|
* @param params 修改密码参数
|
||||||
*/
|
*/
|
||||||
static changePassword(params: ChangePasswordParams): Promise<BaseResponse> {
|
static changePassword(params: ChangePasswordParams): Promise<BaseResponse> {
|
||||||
return this.post<BaseResponse>('/api/auth/change-password', params)
|
return this.put<BaseResponse>('/api/auth/password', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
43
src/api/modules/authorization.ts
Normal file
43
src/api/modules/authorization.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 授权记录相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type { BaseResponse, PaginationResponse } from '@/types/api'
|
||||||
|
import type {
|
||||||
|
AuthorizationItem,
|
||||||
|
AuthorizationListParams,
|
||||||
|
UpdateAuthorizationRemarkRequest
|
||||||
|
} from '@/types/api/authorization'
|
||||||
|
|
||||||
|
export class AuthorizationService extends BaseService {
|
||||||
|
/**
|
||||||
|
* 获取授权记录列表
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getAuthorizations(
|
||||||
|
params?: AuthorizationListParams
|
||||||
|
): Promise<PaginationResponse<AuthorizationItem>> {
|
||||||
|
return this.getPage<AuthorizationItem>('/api/admin/authorizations', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取授权记录详情
|
||||||
|
* @param id 授权记录ID
|
||||||
|
*/
|
||||||
|
static getAuthorizationDetail(id: number): Promise<BaseResponse<AuthorizationItem>> {
|
||||||
|
return this.getOne<AuthorizationItem>(`/api/admin/authorizations/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改授权备注
|
||||||
|
* @param id 授权记录ID
|
||||||
|
* @param data 备注数据
|
||||||
|
*/
|
||||||
|
static updateAuthorizationRemark(
|
||||||
|
id: number,
|
||||||
|
data: UpdateAuthorizationRemarkRequest
|
||||||
|
): Promise<BaseResponse<AuthorizationItem>> {
|
||||||
|
return this.put<BaseResponse<AuthorizationItem>>(`/api/admin/authorizations/${id}/remark`, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import type {
|
|||||||
CardOrder,
|
CardOrder,
|
||||||
BaseResponse,
|
BaseResponse,
|
||||||
PaginationResponse,
|
PaginationResponse,
|
||||||
ListResponse
|
ListResponse,
|
||||||
|
GatewayRealnameLinkResponse
|
||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
|
|
||||||
export class CardService extends BaseService {
|
export class CardService extends BaseService {
|
||||||
@@ -79,13 +80,22 @@ export class CardService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据ICCID获取单卡信息
|
* 根据ICCID获取单卡信息(旧接口,用于现有功能)
|
||||||
* @param iccid ICCID
|
* @param iccid ICCID
|
||||||
*/
|
*/
|
||||||
static getCardByIccid(iccid: string): Promise<BaseResponse<Card>> {
|
static getCardByIccid(iccid: string): Promise<BaseResponse<Card>> {
|
||||||
return this.getOne<Card>(`/api/cards/iccid/${iccid}`)
|
return this.getOne<Card>(`/api/cards/iccid/${iccid}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过ICCID查询单卡详情(旧接口,已废弃)
|
||||||
|
* @deprecated 使用 AssetService.resolveAsset 替代
|
||||||
|
* @param iccid ICCID
|
||||||
|
*/
|
||||||
|
static getIotCardDetailByIccid(iccid: string): Promise<BaseResponse<any>> {
|
||||||
|
return this.getOne<any>(`/api/admin/iot-cards/by-iccid/${iccid}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网卡操作(充值、停复机、增减流量等)
|
* 网卡操作(充值、停复机、增减流量等)
|
||||||
* @param params 操作参数
|
* @param params 操作参数
|
||||||
@@ -269,4 +279,156 @@ export class CardService extends BaseService {
|
|||||||
static getCardChangeNotices(params?: any): Promise<PaginationResponse<any>> {
|
static getCardChangeNotices(params?: any): Promise<PaginationResponse<any>> {
|
||||||
return this.getPage('/api/card-change-notices', params)
|
return this.getPage('/api/card-change-notices', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== ICCID批量导入相关 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入ICCID(新版:使用 JSON 格式)
|
||||||
|
* @param data 导入请求参数
|
||||||
|
*/
|
||||||
|
static importIotCards(data: {
|
||||||
|
carrier_id: number
|
||||||
|
file_key: string
|
||||||
|
batch_no?: string
|
||||||
|
}): Promise<BaseResponse<{ task_id: number; task_no: string; message: string }>> {
|
||||||
|
return this.post<BaseResponse<{ task_id: number; task_no: string; message: string }>>(
|
||||||
|
'/api/admin/iot-cards/import',
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入任务列表
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getIotCardImportTasks(params?: any): Promise<PaginationResponse<any>> {
|
||||||
|
return this.getPage('/api/admin/iot-cards/import-tasks', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入任务详情
|
||||||
|
* @param id 任务ID
|
||||||
|
*/
|
||||||
|
static getIotCardImportTaskDetail(id: number): Promise<BaseResponse<any>> {
|
||||||
|
return this.getOne(`/api/admin/iot-cards/import-tasks/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 单卡列表(未绑定设备)相关 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单卡列表(未绑定设备)
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getStandaloneIotCards(params?: any): Promise<PaginationResponse<any>> {
|
||||||
|
return this.getPage('/api/admin/iot-cards/standalone', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量分配单卡
|
||||||
|
* @param data 分配参数
|
||||||
|
*/
|
||||||
|
static allocateStandaloneCards(data: any): Promise<BaseResponse<any>> {
|
||||||
|
return this.post('/api/admin/iot-cards/standalone/allocate', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量回收单卡
|
||||||
|
* @param data 回收参数
|
||||||
|
*/
|
||||||
|
static recallStandaloneCards(data: any): Promise<BaseResponse<any>> {
|
||||||
|
return this.post('/api/admin/iot-cards/standalone/recall', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 资产分配记录相关 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资产分配记录列表
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getAssetAllocationRecords(params?: any): Promise<PaginationResponse<any>> {
|
||||||
|
return this.getPage('/api/admin/asset-allocation-records', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资产分配记录详情
|
||||||
|
* @param id 记录ID
|
||||||
|
*/
|
||||||
|
static getAssetAllocationRecordDetail(id: number): Promise<BaseResponse<any>> {
|
||||||
|
return this.getOne(`/api/admin/asset-allocation-records/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 批量设置卡的套餐系列绑定相关 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置卡的套餐系列绑定
|
||||||
|
* @param data 请求参数
|
||||||
|
*/
|
||||||
|
static batchSetCardSeriesBinding(data: {
|
||||||
|
iccids: string[]
|
||||||
|
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`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
src/api/modules/carrier.ts
Normal file
73
src/api/modules/carrier.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 运营商管理相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type {
|
||||||
|
Carrier,
|
||||||
|
CarrierQueryParams,
|
||||||
|
CreateCarrierParams,
|
||||||
|
UpdateCarrierParams,
|
||||||
|
UpdateCarrierStatusParams,
|
||||||
|
BaseResponse,
|
||||||
|
PaginationResponse
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export class CarrierService extends BaseService {
|
||||||
|
/**
|
||||||
|
* 获取运营商列表
|
||||||
|
* GET /api/admin/carriers
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getCarriers(params?: CarrierQueryParams): Promise<PaginationResponse<Carrier>> {
|
||||||
|
return this.getPage<Carrier>('/api/admin/carriers', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建运营商
|
||||||
|
* POST /api/admin/carriers
|
||||||
|
* @param data 运营商数据
|
||||||
|
*/
|
||||||
|
static createCarrier(data: CreateCarrierParams): Promise<BaseResponse<Carrier>> {
|
||||||
|
return this.create<Carrier>('/api/admin/carriers', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新运营商
|
||||||
|
* PUT /api/admin/carriers/{id}
|
||||||
|
* @param id 运营商ID
|
||||||
|
* @param data 运营商数据
|
||||||
|
*/
|
||||||
|
static updateCarrier(id: number, data: UpdateCarrierParams): Promise<BaseResponse<Carrier>> {
|
||||||
|
return this.update<Carrier>(`/api/admin/carriers/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除运营商
|
||||||
|
* DELETE /api/admin/carriers/{id}
|
||||||
|
* @param id 运营商ID
|
||||||
|
*/
|
||||||
|
static deleteCarrier(id: number): Promise<BaseResponse> {
|
||||||
|
return this.remove(`/api/admin/carriers/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取运营商详情
|
||||||
|
* GET /api/admin/carriers/{id}
|
||||||
|
* @param id 运营商ID
|
||||||
|
*/
|
||||||
|
static getCarrierDetail(id: number): Promise<BaseResponse<Carrier>> {
|
||||||
|
return this.getOne<Carrier>(`/api/admin/carriers/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新运营商状态
|
||||||
|
* PUT /api/admin/carriers/{id}/status
|
||||||
|
* @param id 运营商ID
|
||||||
|
* @param status 状态 (1:启用, 0:禁用)
|
||||||
|
*/
|
||||||
|
static updateCarrierStatus(id: number, status: number): Promise<BaseResponse> {
|
||||||
|
const data: UpdateCarrierStatusParams = { status }
|
||||||
|
return this.update(`/api/admin/carriers/${id}/status`, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,11 @@ import type {
|
|||||||
SubmitWithdrawalParams,
|
SubmitWithdrawalParams,
|
||||||
ShopCommissionRecordPageResult,
|
ShopCommissionRecordPageResult,
|
||||||
ShopCommissionSummaryQueryParams,
|
ShopCommissionSummaryQueryParams,
|
||||||
ShopCommissionSummaryPageResult
|
ShopCommissionSummaryPageResult,
|
||||||
|
MyCommissionStatsQueryParams,
|
||||||
|
MyCommissionStatsResponse,
|
||||||
|
MyDailyCommissionStatsQueryParams,
|
||||||
|
DailyCommissionStatsItem
|
||||||
} from '@/types/api/commission'
|
} from '@/types/api/commission'
|
||||||
|
|
||||||
export class CommissionService extends BaseService {
|
export class CommissionService extends BaseService {
|
||||||
@@ -130,6 +134,32 @@ export class CommissionService extends BaseService {
|
|||||||
return this.post<BaseResponse>('/api/admin/my/withdrawal-requests', params)
|
return this.post<BaseResponse>('/api/admin/my/withdrawal-requests', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取我的佣金统计
|
||||||
|
* GET /api/admin/my/commission-stats
|
||||||
|
*/
|
||||||
|
static getMyCommissionStats(
|
||||||
|
params?: MyCommissionStatsQueryParams
|
||||||
|
): Promise<BaseResponse<MyCommissionStatsResponse>> {
|
||||||
|
return this.get<BaseResponse<MyCommissionStatsResponse>>(
|
||||||
|
'/api/admin/my/commission-stats',
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取我的每日佣金统计
|
||||||
|
* GET /api/admin/my/commission-daily-stats
|
||||||
|
*/
|
||||||
|
static getMyDailyCommissionStats(
|
||||||
|
params?: MyDailyCommissionStatsQueryParams
|
||||||
|
): Promise<BaseResponse<DailyCommissionStatsItem[]>> {
|
||||||
|
return this.get<BaseResponse<DailyCommissionStatsItem[]>>(
|
||||||
|
'/api/admin/my/commission-daily-stats',
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 代理商佣金管理 ====================
|
// ==================== 代理商佣金管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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'
|
import { BaseService } from '../BaseService'
|
||||||
|
|||||||
241
src/api/modules/device.ts
Normal file
241
src/api/modules/device.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* 设备管理相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type {
|
||||||
|
Device,
|
||||||
|
DeviceQueryParams,
|
||||||
|
DeviceListResponse,
|
||||||
|
DeviceCardsResponse,
|
||||||
|
BindCardToDeviceRequest,
|
||||||
|
BindCardToDeviceResponse,
|
||||||
|
UnbindCardFromDeviceResponse,
|
||||||
|
AllocateDevicesRequest,
|
||||||
|
AllocateDevicesResponse,
|
||||||
|
RecallDevicesRequest,
|
||||||
|
RecallDevicesResponse,
|
||||||
|
ImportDeviceRequest,
|
||||||
|
ImportDeviceResponse,
|
||||||
|
DeviceImportTaskQueryParams,
|
||||||
|
DeviceImportTaskListResponse,
|
||||||
|
DeviceImportTaskDetail,
|
||||||
|
BaseResponse,
|
||||||
|
SetSpeedLimitRequest,
|
||||||
|
SwitchCardRequest,
|
||||||
|
SetWiFiRequest,
|
||||||
|
DeviceOperationResponse
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export class DeviceService extends BaseService {
|
||||||
|
// ========== 设备基础管理 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备列表
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getDevices(params?: DeviceQueryParams): Promise<BaseResponse<DeviceListResponse>> {
|
||||||
|
return this.get<BaseResponse<DeviceListResponse>>('/api/admin/devices', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备详情
|
||||||
|
* @param id 设备ID
|
||||||
|
*/
|
||||||
|
static getDeviceById(id: number): Promise<BaseResponse<Device>> {
|
||||||
|
return this.getOne<Device>(`/api/admin/devices/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过ICCID查询设备详情
|
||||||
|
* @param iccid ICCID
|
||||||
|
*/
|
||||||
|
static getDeviceByIccid(iccid: string): Promise<BaseResponse<any>> {
|
||||||
|
return this.getOne<any>(`/api/admin/devices/by-iccid/${iccid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除设备
|
||||||
|
* @param id 设备ID
|
||||||
|
*/
|
||||||
|
static deleteDevice(id: number): Promise<BaseResponse> {
|
||||||
|
return this.remove(`/api/admin/devices/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 设备卡绑定管理 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备绑定的卡列表
|
||||||
|
* @param id 设备ID
|
||||||
|
*/
|
||||||
|
static getDeviceCards(id: number): Promise<BaseResponse<DeviceCardsResponse>> {
|
||||||
|
return this.getOne<DeviceCardsResponse>(`/api/admin/devices/${id}/cards`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定卡到设备
|
||||||
|
* @param id 设备ID
|
||||||
|
* @param data 绑定参数
|
||||||
|
*/
|
||||||
|
static bindCard(
|
||||||
|
id: number,
|
||||||
|
data: BindCardToDeviceRequest
|
||||||
|
): Promise<BaseResponse<BindCardToDeviceResponse>> {
|
||||||
|
return this.post<BaseResponse<BindCardToDeviceResponse>>(`/api/admin/devices/${id}/cards`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑设备上的卡
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @param cardId IoT卡ID
|
||||||
|
*/
|
||||||
|
static unbindCard(
|
||||||
|
deviceId: number,
|
||||||
|
cardId: number
|
||||||
|
): Promise<BaseResponse<UnbindCardFromDeviceResponse>> {
|
||||||
|
return this.delete<BaseResponse<UnbindCardFromDeviceResponse>>(
|
||||||
|
`/api/admin/devices/${deviceId}/cards/${cardId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 批量分配和回收 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量分配设备
|
||||||
|
* @param data 分配参数
|
||||||
|
*/
|
||||||
|
static allocateDevices(
|
||||||
|
data: AllocateDevicesRequest
|
||||||
|
): Promise<BaseResponse<AllocateDevicesResponse>> {
|
||||||
|
return this.post<BaseResponse<AllocateDevicesResponse>>('/api/admin/devices/allocate', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量回收设备
|
||||||
|
* @param data 回收参数
|
||||||
|
*/
|
||||||
|
static recallDevices(data: RecallDevicesRequest): Promise<BaseResponse<RecallDevicesResponse>> {
|
||||||
|
return this.post<BaseResponse<RecallDevicesResponse>>('/api/admin/devices/recall', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 设备导入 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入设备
|
||||||
|
* @param data 导入参数
|
||||||
|
*/
|
||||||
|
static importDevices(data: ImportDeviceRequest): Promise<BaseResponse<ImportDeviceResponse>> {
|
||||||
|
return this.post<BaseResponse<ImportDeviceResponse>>('/api/admin/devices/import', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入任务列表
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getImportTasks(
|
||||||
|
params?: DeviceImportTaskQueryParams
|
||||||
|
): Promise<BaseResponse<DeviceImportTaskListResponse>> {
|
||||||
|
return this.get<BaseResponse<DeviceImportTaskListResponse>>(
|
||||||
|
'/api/admin/devices/import/tasks',
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入任务详情
|
||||||
|
* @param id 任务ID
|
||||||
|
*/
|
||||||
|
static getImportTaskDetail(id: number): Promise<BaseResponse<DeviceImportTaskDetail>> {
|
||||||
|
return this.getOne<DeviceImportTaskDetail>(`/api/admin/devices/import/tasks/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 批量设置设备的套餐系列绑定相关 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置设备的套餐系列绑定
|
||||||
|
* @param data 请求参数
|
||||||
|
*/
|
||||||
|
static batchSetDeviceSeriesBinding(data: {
|
||||||
|
device_ids: 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`, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,24 @@ import type {
|
|||||||
UpdateEnterpriseStatusParams,
|
UpdateEnterpriseStatusParams,
|
||||||
CreateEnterpriseResponse
|
CreateEnterpriseResponse
|
||||||
} from '@/types/api/enterprise'
|
} from '@/types/api/enterprise'
|
||||||
|
import type {
|
||||||
|
AllocateCardsRequest,
|
||||||
|
AllocateCardsResponse,
|
||||||
|
AllocateCardsPreviewRequest,
|
||||||
|
AllocateCardsPreviewResponse,
|
||||||
|
EnterpriseCardListParams,
|
||||||
|
EnterpriseCardPageResult,
|
||||||
|
RecallCardsRequest,
|
||||||
|
RecallCardsResponse
|
||||||
|
} from '@/types/api/enterpriseCard'
|
||||||
|
import type {
|
||||||
|
EnterpriseDeviceListParams,
|
||||||
|
EnterpriseDevicePageResult,
|
||||||
|
AllocateDevicesRequest,
|
||||||
|
AllocateDevicesResponse,
|
||||||
|
RecallDevicesRequest,
|
||||||
|
RecallDevicesResponse
|
||||||
|
} from '@/types/api/enterpriseDevice'
|
||||||
|
|
||||||
export class EnterpriseService extends BaseService {
|
export class EnterpriseService extends BaseService {
|
||||||
/**
|
/**
|
||||||
@@ -63,4 +81,131 @@ export class EnterpriseService extends BaseService {
|
|||||||
): Promise<BaseResponse> {
|
): Promise<BaseResponse> {
|
||||||
return this.put<BaseResponse>(`/api/admin/enterprises/${id}/status`, data)
|
return this.put<BaseResponse>(`/api/admin/enterprises/${id}/status`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 企业卡授权相关 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权卡给企业
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param data 授权请求数据
|
||||||
|
*/
|
||||||
|
static allocateCards(
|
||||||
|
enterpriseId: number,
|
||||||
|
data: AllocateCardsRequest
|
||||||
|
): Promise<BaseResponse<AllocateCardsResponse>> {
|
||||||
|
return this.post<BaseResponse<AllocateCardsResponse>>(
|
||||||
|
`/api/admin/enterprises/${enterpriseId}/allocate-cards`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡授权预检
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param data 预检请求数据
|
||||||
|
*/
|
||||||
|
static previewAllocateCards(
|
||||||
|
enterpriseId: number,
|
||||||
|
data: AllocateCardsPreviewRequest
|
||||||
|
): Promise<BaseResponse<AllocateCardsPreviewResponse>> {
|
||||||
|
return this.post<BaseResponse<AllocateCardsPreviewResponse>>(
|
||||||
|
`/api/admin/enterprises/${enterpriseId}/allocate-cards/preview`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取企业卡列表
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getEnterpriseCards(
|
||||||
|
enterpriseId: number,
|
||||||
|
params?: EnterpriseCardListParams
|
||||||
|
): Promise<BaseResponse<EnterpriseCardPageResult>> {
|
||||||
|
return this.get<BaseResponse<EnterpriseCardPageResult>>(
|
||||||
|
`/api/admin/enterprises/${enterpriseId}/cards`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复机卡
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param cardId 卡ID
|
||||||
|
*/
|
||||||
|
static resumeCard(enterpriseId: number, cardId: number): Promise<BaseResponse> {
|
||||||
|
return this.post<BaseResponse>(`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/resume`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停机卡
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param cardId 卡ID
|
||||||
|
*/
|
||||||
|
static suspendCard(enterpriseId: number, cardId: number): Promise<BaseResponse> {
|
||||||
|
return this.post<BaseResponse>(`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/suspend`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回收卡授权
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param data 回收请求数据
|
||||||
|
*/
|
||||||
|
static recallCards(
|
||||||
|
enterpriseId: number,
|
||||||
|
data: RecallCardsRequest
|
||||||
|
): Promise<BaseResponse<RecallCardsResponse>> {
|
||||||
|
return this.post<BaseResponse<RecallCardsResponse>>(
|
||||||
|
`/api/admin/enterprises/${enterpriseId}/recall-cards`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 企业设备授权相关 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权设备给企业
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param data 授权请求数据
|
||||||
|
*/
|
||||||
|
static allocateDevices(
|
||||||
|
enterpriseId: number,
|
||||||
|
data: AllocateDevicesRequest
|
||||||
|
): Promise<BaseResponse<AllocateDevicesResponse>> {
|
||||||
|
return this.post<BaseResponse<AllocateDevicesResponse>>(
|
||||||
|
`/api/admin/enterprises/${enterpriseId}/allocate-devices`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取企业设备列表
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getEnterpriseDevices(
|
||||||
|
enterpriseId: number,
|
||||||
|
params?: EnterpriseDeviceListParams
|
||||||
|
): Promise<BaseResponse<EnterpriseDevicePageResult>> {
|
||||||
|
return this.get<BaseResponse<EnterpriseDevicePageResult>>(
|
||||||
|
`/api/admin/enterprises/${enterpriseId}/devices`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销设备授权
|
||||||
|
* @param enterpriseId 企业ID
|
||||||
|
* @param data 撤销请求数据
|
||||||
|
*/
|
||||||
|
static recallDevices(
|
||||||
|
enterpriseId: number,
|
||||||
|
data: RecallDevicesRequest
|
||||||
|
): Promise<BaseResponse<RecallDevicesResponse>> {
|
||||||
|
return this.post<BaseResponse<RecallDevicesResponse>>(
|
||||||
|
`/api/admin/enterprises/${enterpriseId}/recall-devices`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
129
src/api/modules/exchange.ts
Normal file
129
src/api/modules/exchange.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,18 @@ export { CardService } from './card'
|
|||||||
export { CommissionService } from './commission'
|
export { CommissionService } from './commission'
|
||||||
export { EnterpriseService } from './enterprise'
|
export { EnterpriseService } from './enterprise'
|
||||||
export { CustomerAccountService } from './customerAccount'
|
export { CustomerAccountService } from './customerAccount'
|
||||||
|
export { StorageService } from './storage'
|
||||||
|
export { AuthorizationService } from './authorization'
|
||||||
|
export { DeviceService } from './device'
|
||||||
|
export { CarrierService } from './carrier'
|
||||||
|
export { PackageSeriesService } from './packageSeries'
|
||||||
|
export { PackageManageService } from './packageManage'
|
||||||
|
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: 按需添加其他业务模块
|
// TODO: 按需添加其他业务模块
|
||||||
// export { PackageService } from './package'
|
|
||||||
// export { DeviceService } from './device'
|
|
||||||
// export { SettingService } from './setting'
|
// export { SettingService } from './setting'
|
||||||
|
|||||||
62
src/api/modules/order.ts
Normal file
62
src/api/modules/order.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 订单管理相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type {
|
||||||
|
Order,
|
||||||
|
OrderQueryParams,
|
||||||
|
OrderListResponse,
|
||||||
|
CreateOrderRequest,
|
||||||
|
CreateOrderResponse,
|
||||||
|
PurchaseCheckRequest,
|
||||||
|
PurchaseCheckResponse,
|
||||||
|
BaseResponse
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export class OrderService extends BaseService {
|
||||||
|
/**
|
||||||
|
* 获取订单列表
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getOrders(params?: OrderQueryParams): Promise<BaseResponse<OrderListResponse>> {
|
||||||
|
return this.get<BaseResponse<OrderListResponse>>('/api/admin/orders', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取订单详情
|
||||||
|
* @param id 订单ID
|
||||||
|
*/
|
||||||
|
static getOrderById(id: number): Promise<BaseResponse<Order>> {
|
||||||
|
return this.getOne<Order>(`/api/admin/orders/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建订单
|
||||||
|
* @param data 创建订单请求参数
|
||||||
|
*/
|
||||||
|
static createOrder(data: CreateOrderRequest): Promise<BaseResponse<CreateOrderResponse>> {
|
||||||
|
return this.post<BaseResponse<CreateOrderResponse>>('/api/admin/orders', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订单
|
||||||
|
* @param id 订单ID
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/api/modules/packageManage.ts
Normal file
100
src/api/modules/packageManage.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 套餐管理 API 服务
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type {
|
||||||
|
PackageResponse,
|
||||||
|
PackageQueryParams,
|
||||||
|
CreatePackageRequest,
|
||||||
|
UpdatePackageRequest,
|
||||||
|
UpdatePackageStatusRequest,
|
||||||
|
UpdatePackageShelfStatusRequest,
|
||||||
|
SeriesSelectOption,
|
||||||
|
BaseResponse,
|
||||||
|
PaginationResponse,
|
||||||
|
ListResponse
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export class PackageManageService extends BaseService {
|
||||||
|
/**
|
||||||
|
* 获取套餐分页列表
|
||||||
|
* GET /api/admin/packages
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getPackages(params?: PackageQueryParams): Promise<PaginationResponse<PackageResponse>> {
|
||||||
|
return this.getPage<PackageResponse>('/api/admin/packages', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建套餐
|
||||||
|
* POST /api/admin/packages
|
||||||
|
* @param data 套餐数据
|
||||||
|
*/
|
||||||
|
static createPackage(data: CreatePackageRequest): Promise<BaseResponse<PackageResponse>> {
|
||||||
|
return this.create<PackageResponse>('/api/admin/packages', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取套餐详情
|
||||||
|
* GET /api/admin/packages/{id}
|
||||||
|
* @param id 套餐ID
|
||||||
|
*/
|
||||||
|
static getPackageDetail(id: number): Promise<BaseResponse<PackageResponse>> {
|
||||||
|
return this.getOne<PackageResponse>(`/api/admin/packages/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新套餐
|
||||||
|
* PUT /api/admin/packages/{id}
|
||||||
|
* @param id 套餐ID
|
||||||
|
* @param data 套餐数据
|
||||||
|
*/
|
||||||
|
static updatePackage(
|
||||||
|
id: number,
|
||||||
|
data: UpdatePackageRequest
|
||||||
|
): Promise<BaseResponse<PackageResponse>> {
|
||||||
|
return this.update<PackageResponse>(`/api/admin/packages/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除套餐
|
||||||
|
* DELETE /api/admin/packages/{id}
|
||||||
|
* @param id 套餐ID
|
||||||
|
*/
|
||||||
|
static deletePackage(id: number): Promise<BaseResponse> {
|
||||||
|
return this.remove(`/api/admin/packages/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新套餐状态
|
||||||
|
* PUT /api/admin/packages/{id}/status
|
||||||
|
* @param id 套餐ID
|
||||||
|
* @param status 状态 (1:启用, 2:禁用)
|
||||||
|
*/
|
||||||
|
static updatePackageStatus(id: number, status: number): Promise<BaseResponse> {
|
||||||
|
const data: UpdatePackageStatusRequest = { status }
|
||||||
|
return this.patch<BaseResponse>(`/api/admin/packages/${id}/status`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新套餐上架状态
|
||||||
|
* PATCH /api/admin/packages/{id}/shelf
|
||||||
|
* @param id 套餐ID
|
||||||
|
* @param shelf_status 上架状态 (1:上架, 2:下架)
|
||||||
|
*/
|
||||||
|
static updatePackageShelfStatus(id: number, shelf_status: number): Promise<BaseResponse> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/api/modules/packageSeries.ts
Normal file
80
src/api/modules/packageSeries.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 套餐系列管理 API 服务
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type {
|
||||||
|
PackageSeriesResponse,
|
||||||
|
PackageSeriesQueryParams,
|
||||||
|
CreatePackageSeriesRequest,
|
||||||
|
UpdatePackageSeriesRequest,
|
||||||
|
UpdatePackageSeriesStatusRequest,
|
||||||
|
BaseResponse,
|
||||||
|
PaginationResponse
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export class PackageSeriesService extends BaseService {
|
||||||
|
/**
|
||||||
|
* 获取套餐系列分页列表
|
||||||
|
* GET /api/admin/package-series
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
static getPackageSeries(
|
||||||
|
params?: PackageSeriesQueryParams
|
||||||
|
): Promise<PaginationResponse<PackageSeriesResponse>> {
|
||||||
|
return this.getPage<PackageSeriesResponse>('/api/admin/package-series', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建套餐系列
|
||||||
|
* POST /api/admin/package-series
|
||||||
|
* @param data 套餐系列数据
|
||||||
|
*/
|
||||||
|
static createPackageSeries(
|
||||||
|
data: CreatePackageSeriesRequest
|
||||||
|
): Promise<BaseResponse<PackageSeriesResponse>> {
|
||||||
|
return this.create<PackageSeriesResponse>('/api/admin/package-series', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取套餐系列详情
|
||||||
|
* GET /api/admin/package-series/{id}
|
||||||
|
* @param id 系列ID
|
||||||
|
*/
|
||||||
|
static getPackageSeriesDetail(id: number): Promise<BaseResponse<PackageSeriesResponse>> {
|
||||||
|
return this.getOne<PackageSeriesResponse>(`/api/admin/package-series/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新套餐系列
|
||||||
|
* PUT /api/admin/package-series/{id}
|
||||||
|
* @param id 系列ID
|
||||||
|
* @param data 套餐系列数据
|
||||||
|
*/
|
||||||
|
static updatePackageSeries(
|
||||||
|
id: number,
|
||||||
|
data: UpdatePackageSeriesRequest
|
||||||
|
): Promise<BaseResponse<PackageSeriesResponse>> {
|
||||||
|
return this.update<PackageSeriesResponse>(`/api/admin/package-series/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除套餐系列
|
||||||
|
* DELETE /api/admin/package-series/{id}
|
||||||
|
* @param id 系列ID
|
||||||
|
*/
|
||||||
|
static deletePackageSeries(id: number): Promise<BaseResponse> {
|
||||||
|
return this.remove(`/api/admin/package-series/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新套餐系列状态
|
||||||
|
* PUT /api/admin/package-series/{id}/status
|
||||||
|
* @param id 系列ID
|
||||||
|
* @param status 状态 (1:启用, 2:禁用)
|
||||||
|
*/
|
||||||
|
static updatePackageSeriesStatus(id: number, status: number): Promise<BaseResponse> {
|
||||||
|
const data: UpdatePackageSeriesStatusRequest = { status }
|
||||||
|
return this.patch<BaseResponse>(`/api/admin/package-series/${id}/status`, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 平台账号相关 API - 匹配后端实际接口
|
* 平台账号相关 API - 匹配后端实际接口
|
||||||
|
*
|
||||||
|
* @deprecated 此 API 已废弃,请使用统一的 AccountService 代替
|
||||||
|
* @see AccountService - 统一账号管理接口 (/api/admin/accounts)
|
||||||
|
*
|
||||||
|
* 迁移说明:
|
||||||
|
* - 所有账号类型统一使用 /api/admin/accounts 接口
|
||||||
|
* - 通过 user_type 参数区分账号类型 (2=平台, 3=代理, 4=企业)
|
||||||
|
* - 详见:docs/迁移指南.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseService } from '../BaseService'
|
import { BaseService } from '../BaseService'
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
ShopQueryParams,
|
ShopQueryParams,
|
||||||
CreateShopParams,
|
CreateShopParams,
|
||||||
UpdateShopParams,
|
UpdateShopParams,
|
||||||
|
ShopRolesResponse,
|
||||||
|
AssignShopRolesRequest,
|
||||||
BaseResponse,
|
BaseResponse,
|
||||||
PaginationResponse
|
PaginationResponse
|
||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
@@ -49,4 +51,38 @@ export class ShopService extends BaseService {
|
|||||||
static deleteShop(id: number): Promise<BaseResponse> {
|
static deleteShop(id: number): Promise<BaseResponse> {
|
||||||
return this.delete<BaseResponse>(`/api/admin/shops/${id}`)
|
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}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 代理账号相关 API - 匹配后端实际接口
|
* 代理账号相关 API - 匹配后端实际接口
|
||||||
|
*
|
||||||
|
* @deprecated 此 API 已废弃,请使用统一的 AccountService 代替
|
||||||
|
* @see AccountService - 统一账号管理接口 (/api/admin/accounts)
|
||||||
|
*
|
||||||
|
* 迁移说明:
|
||||||
|
* - 所有账号类型统一使用 /api/admin/accounts 接口
|
||||||
|
* - 通过 user_type 参数区分账号类型 (2=平台, 3=代理, 4=企业)
|
||||||
|
* - 详见:docs/迁移指南.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseService } from '../BaseService'
|
import { BaseService } from '../BaseService'
|
||||||
|
|||||||
90
src/api/modules/shopSeriesGrant.ts
Normal file
90
src/api/modules/shopSeriesGrant.ts
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/api/modules/storage.ts
Normal file
103
src/api/modules/storage.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 对象存储相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type { BaseResponse } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件用途枚举
|
||||||
|
*/
|
||||||
|
export type FilePurpose = 'iot_import' | 'device_import' | 'export' | 'attachment'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上传 URL 请求参数
|
||||||
|
*/
|
||||||
|
export interface GetUploadUrlRequest {
|
||||||
|
/** 文件名(如:cards.csv) */
|
||||||
|
file_name: string
|
||||||
|
/** 文件 MIME 类型(如:text/csv),留空则自动推断 */
|
||||||
|
content_type?: string
|
||||||
|
/** 文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件) */
|
||||||
|
purpose: FilePurpose
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上传 URL 响应
|
||||||
|
*/
|
||||||
|
export interface GetUploadUrlResponse {
|
||||||
|
/** 预签名上传 URL,使用 PUT 方法上传文件 */
|
||||||
|
upload_url: string
|
||||||
|
/** 文件路径标识,上传成功后用于调用业务接口 */
|
||||||
|
file_key: string
|
||||||
|
/** URL 有效期(秒) */
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageService extends BaseService {
|
||||||
|
/**
|
||||||
|
* 获取文件上传预签名 URL
|
||||||
|
*
|
||||||
|
* ## 完整上传流程
|
||||||
|
* 1. 调用本接口获取预签名 URL 和 file_key
|
||||||
|
* 2. 使用预签名 URL 上传文件(发起 PUT 请求直接上传到对象存储)
|
||||||
|
* 3. 使用 file_key 调用相关业务接口
|
||||||
|
*
|
||||||
|
* @param data 请求参数
|
||||||
|
*/
|
||||||
|
static getUploadUrl(data: GetUploadUrlRequest): Promise<BaseResponse<GetUploadUrlResponse>> {
|
||||||
|
return this.post<BaseResponse<GetUploadUrlResponse>>('/api/admin/storage/upload-url', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用预签名 URL 上传文件到对象存储
|
||||||
|
*
|
||||||
|
* 注意事项:
|
||||||
|
* - 预签名 URL 有效期 15 分钟,请及时使用
|
||||||
|
* - 上传时 Content-Type 需与请求时一致
|
||||||
|
* - file_key 在上传成功后永久有效
|
||||||
|
* - 开发环境通过代理上传,生产环境直接上传
|
||||||
|
*
|
||||||
|
* @param uploadUrl 预签名 URL(由对象存储生成)
|
||||||
|
* @param file 文件
|
||||||
|
* @param contentType 文件类型(需与 getUploadUrl 请求时保持一致)
|
||||||
|
*/
|
||||||
|
static async uploadFile(uploadUrl: string, file: File, contentType?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 开发环境使用代理解决 CORS 问题
|
||||||
|
let finalUrl = uploadUrl
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// 将对象存储域名替换为代理路径
|
||||||
|
finalUrl = uploadUrl.replace(/^https?:\/\/obs-helf\.cucloud\.cn/, '/obs-proxy')
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
|
||||||
|
// 只有在明确指定 contentType 时才设置,否则让浏览器自动处理
|
||||||
|
if (contentType) {
|
||||||
|
headers['Content-Type'] = contentType
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(finalUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: file,
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => response.statusText)
|
||||||
|
throw new Error(`上传文件失败 (${response.status}): ${errorText}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// 增强错误信息
|
||||||
|
if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
|
||||||
|
throw new Error(
|
||||||
|
'CORS 错误: 无法上传文件到对象存储。' +
|
||||||
|
'这通常是因为对象存储服务器未正确配置 CORS 策略。' +
|
||||||
|
'请联系后端开发人员检查对象存储的 CORS 配置。'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/api/modules/wechatConfig.ts
Normal file
78
src/api/modules/wechatConfig.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,17 +1,16 @@
|
|||||||
// 全局样式
|
// 全局样式
|
||||||
|
|
||||||
@font-face {
|
// 强制所有元素使用小米字体
|
||||||
font-family: 'DMSans';
|
* {
|
||||||
font-style: normal;
|
font-family:
|
||||||
font-weight: 400;
|
'MiSans',
|
||||||
src: url(../fonts/DMSans.woff2) format('woff2');
|
-apple-system,
|
||||||
}
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
@font-face {
|
Roboto,
|
||||||
font-family: 'Montserrat';
|
'Helvetica Neue',
|
||||||
font-style: normal;
|
Arial,
|
||||||
font-weight: 400;
|
sans-serif !important;
|
||||||
src: url(../fonts/Montserrat.woff2) format('woff2');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
@@ -194,3 +193,16 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
// --el-border-color: #E4E4E7 !important; // DCDFE6
|
// --el-border-color: #E4E4E7 !important; // DCDFE6
|
||||||
// 按钮粗度
|
// 按钮粗度
|
||||||
--el-font-weight-primary: 400 !important;
|
--el-font-weight-primary: 400 !important;
|
||||||
|
// Element Plus 全局字体
|
||||||
|
--el-font-family:
|
||||||
|
'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
sans-serif !important;
|
||||||
|
|
||||||
--el-component-custom-height: 36px !important;
|
--el-component-custom-height: 36px !important;
|
||||||
|
|
||||||
@@ -180,6 +184,16 @@
|
|||||||
|
|
||||||
// 修改el-button样式
|
// 修改el-button样式
|
||||||
.el-button {
|
.el-button {
|
||||||
|
font-family:
|
||||||
|
'MiSans',
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
sans-serif !important;
|
||||||
|
|
||||||
&.el-button--text {
|
&.el-button--text {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
@@ -198,6 +212,48 @@
|
|||||||
border-radius: 6px !important;
|
border-radius: 6px !important;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
transition: all 0s !important;
|
transition: all 0s !important;
|
||||||
|
font-family:
|
||||||
|
'MiSans',
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为所有 Element Plus 组件添加小米字体
|
||||||
|
.el-input,
|
||||||
|
.el-input__wrapper,
|
||||||
|
.el-select,
|
||||||
|
.el-select__wrapper,
|
||||||
|
.el-form-item__label,
|
||||||
|
.el-table,
|
||||||
|
.el-pagination,
|
||||||
|
.el-dialog,
|
||||||
|
.el-message,
|
||||||
|
.el-message-box,
|
||||||
|
.el-dropdown-menu,
|
||||||
|
.el-menu,
|
||||||
|
.el-radio,
|
||||||
|
.el-checkbox,
|
||||||
|
.el-switch,
|
||||||
|
.el-date-picker,
|
||||||
|
.el-cascader,
|
||||||
|
.el-tree-select,
|
||||||
|
.el-upload,
|
||||||
|
.el-card,
|
||||||
|
.el-divider {
|
||||||
|
font-family:
|
||||||
|
'MiSans',
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-checkbox-group {
|
.el-checkbox-group {
|
||||||
|
|||||||
@@ -35,8 +35,14 @@ body {
|
|||||||
color: var(--art-text-gray-700);
|
color: var(--art-text-gray-700);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-family:
|
font-family:
|
||||||
Inter, 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
|
'MiSans',
|
||||||
'微软雅黑', Arial, sans-serif;
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
59
src/components/business/CodeGeneratorButton.vue
Normal file
59
src/components/business/CodeGeneratorButton.vue
Normal 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>
|
||||||
286
src/components/business/CustomerAccountDialog.vue
Normal file
286
src/components/business/CustomerAccountDialog.vue
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<ElDialog v-model="visible" title="客户账号列表" width="80%" @close="handleClose">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<ArtSearchBar
|
||||||
|
v-model:filter="searchForm"
|
||||||
|
:items="searchFormItems"
|
||||||
|
:show-expand="false"
|
||||||
|
@reset="handleReset"
|
||||||
|
@search="handleSearch"
|
||||||
|
></ArtSearchBar>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<ArtTable
|
||||||
|
ref="tableRef"
|
||||||
|
row-key="ID"
|
||||||
|
:loading="loading"
|
||||||
|
:data="accountList"
|
||||||
|
height="60vh"
|
||||||
|
:currentPage="pagination.page"
|
||||||
|
:pageSize="pagination.pageSize"
|
||||||
|
:total="pagination.total"
|
||||||
|
:marginTop="10"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElTableColumn v-for="col in columns" :key="col.prop || (col as any).type" v-bind="col" />
|
||||||
|
</template>
|
||||||
|
</ArtTable>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h } from 'vue'
|
||||||
|
import { ElMessage, ElTag } from 'element-plus'
|
||||||
|
import { AccountService } from '@/api/modules'
|
||||||
|
import type { SearchFormItem } from '@/types'
|
||||||
|
import type { PlatformAccount } from '@/types/api'
|
||||||
|
import { formatDateTime } from '@/utils/business/format'
|
||||||
|
|
||||||
|
// 用户类型映射
|
||||||
|
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<{
|
||||||
|
modelValue: boolean
|
||||||
|
shopId?: number
|
||||||
|
enterpriseId?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tableRef = ref()
|
||||||
|
const accountList = ref<PlatformAccount[]>([])
|
||||||
|
|
||||||
|
// 搜索表单初始值
|
||||||
|
const initialSearchState = {
|
||||||
|
username: '',
|
||||||
|
phone: '',
|
||||||
|
user_type: undefined as number | undefined,
|
||||||
|
status: undefined as number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索表单
|
||||||
|
const searchForm = reactive({ ...initialSearchState })
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const pagination = reactive({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索表单配置
|
||||||
|
const searchFormItems: SearchFormItem[] = [
|
||||||
|
{
|
||||||
|
label: '用户名',
|
||||||
|
prop: 'username',
|
||||||
|
type: 'input',
|
||||||
|
config: {
|
||||||
|
clearable: true,
|
||||||
|
placeholder: '请输入用户名'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '手机号',
|
||||||
|
prop: 'phone',
|
||||||
|
type: 'input',
|
||||||
|
config: {
|
||||||
|
clearable: true,
|
||||||
|
placeholder: '请输入手机号'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '用户类型',
|
||||||
|
prop: 'user_type',
|
||||||
|
type: 'select',
|
||||||
|
config: {
|
||||||
|
clearable: true,
|
||||||
|
placeholder: '全部'
|
||||||
|
},
|
||||||
|
options: () => [
|
||||||
|
{ label: '代理账号', value: 3 },
|
||||||
|
{ label: '企业账号', value: 4 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '状态',
|
||||||
|
prop: 'status',
|
||||||
|
type: 'select',
|
||||||
|
config: {
|
||||||
|
clearable: true,
|
||||||
|
placeholder: '全部'
|
||||||
|
},
|
||||||
|
options: () => [
|
||||||
|
{ label: '启用', value: 1 },
|
||||||
|
{ label: '禁用', value: 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 获取用户类型标签类型
|
||||||
|
const getUserTypeTag = (type: number) => {
|
||||||
|
return type === 3 ? 'success' : 'primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态标签类型
|
||||||
|
const getStatusTag = (status: number) => {
|
||||||
|
return status === 1 ? 'success' : 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列配置
|
||||||
|
const columns = computed(() => [
|
||||||
|
{
|
||||||
|
prop: 'username',
|
||||||
|
label: '用户名',
|
||||||
|
minWidth: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'phone',
|
||||||
|
label: '手机号',
|
||||||
|
width: 130
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'user_type',
|
||||||
|
label: '用户类型',
|
||||||
|
width: 110,
|
||||||
|
formatter: (row: PlatformAccount) => {
|
||||||
|
return h(ElTag, { type: getUserTypeTag(row.user_type) }, () => getUserTypeName(row.user_type))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'shop_id',
|
||||||
|
label: '店铺ID',
|
||||||
|
width: 100,
|
||||||
|
formatter: (row: PlatformAccount) => row.shop_id || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'enterprise_id',
|
||||||
|
label: '企业ID',
|
||||||
|
width: 100,
|
||||||
|
formatter: (row: PlatformAccount) => row.enterprise_id || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'status',
|
||||||
|
label: '状态',
|
||||||
|
width: 100,
|
||||||
|
formatter: (row: PlatformAccount) => {
|
||||||
|
return h(ElTag, { type: getStatusTag(row.status) }, () => getStatusName(row.status))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'CreatedAt',
|
||||||
|
label: '创建时间',
|
||||||
|
width: 180,
|
||||||
|
formatter: (row: PlatformAccount) => formatDateTime(row.CreatedAt)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// 监听弹窗打开,重新加载数据
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
handleReset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取客户账号列表
|
||||||
|
const getTableData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page: pagination.page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
username: searchForm.username || undefined,
|
||||||
|
phone: searchForm.phone || undefined,
|
||||||
|
user_type: searchForm.user_type,
|
||||||
|
status: searchForm.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据传入的参数筛选
|
||||||
|
if (props.shopId) {
|
||||||
|
params.shop_id = props.shopId
|
||||||
|
}
|
||||||
|
if (props.enterpriseId) {
|
||||||
|
params.enterprise_id = props.enterpriseId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理空值
|
||||||
|
Object.keys(params).forEach((key) => {
|
||||||
|
if (params[key] === '' || params[key] === undefined) {
|
||||||
|
delete params[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await AccountService.getAccounts(params)
|
||||||
|
if (res.code === 0) {
|
||||||
|
accountList.value = res.data.items || []
|
||||||
|
pagination.total = res.data.total || 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
ElMessage.error('获取客户账号列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
Object.assign(searchForm, { ...initialSearchState })
|
||||||
|
pagination.page = 1
|
||||||
|
getTableData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
getTableData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理表格分页变化
|
||||||
|
const handleSizeChange = (newPageSize: number) => {
|
||||||
|
pagination.pageSize = newPageSize
|
||||||
|
getTableData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (newCurrentPage: number) => {
|
||||||
|
pagination.page = newCurrentPage
|
||||||
|
getTableData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭弹窗
|
||||||
|
const handleClose = () => {
|
||||||
|
// 重置搜索表单
|
||||||
|
Object.assign(searchForm, { ...initialSearchState })
|
||||||
|
pagination.page = 1
|
||||||
|
accountList.value = []
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// 客户账号列表弹窗样式
|
||||||
|
</style>
|
||||||
159
src/components/common/DetailPage.vue
Normal file
159
src/components/common/DetailPage.vue
Normal 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>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
text?: string
|
text?: string
|
||||||
type?: 'add' | 'edit' | 'delete' | 'more'
|
type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
|
||||||
icon?: string // 自定义图标
|
icon?: string // 自定义图标
|
||||||
iconClass?: BgColorEnum // 自定义按钮背景色、文字颜色
|
iconClass?: BgColorEnum // 自定义按钮背景色、文字颜色
|
||||||
iconColor?: string // 外部传入的图标文字颜色
|
iconColor?: string // 外部传入的图标文字颜色
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
{ type: 'add', icon: '', color: BgColorEnum.PRIMARY },
|
{ type: 'add', icon: '', color: BgColorEnum.PRIMARY },
|
||||||
{ type: 'edit', icon: '', color: BgColorEnum.SECONDARY },
|
{ type: 'edit', icon: '', color: BgColorEnum.SECONDARY },
|
||||||
{ type: 'delete', icon: '', color: BgColorEnum.ERROR },
|
{ type: 'delete', icon: '', color: BgColorEnum.ERROR },
|
||||||
{ type: 'more', icon: '', color: '' }
|
{ type: 'more', icon: '', color: '' },
|
||||||
|
{ type: 'view', icon: '', color: BgColorEnum.SECONDARY }
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// 计算最终使用的图标:优先使用外部传入的 icon,否则根据 type 获取默认图标
|
// 计算最终使用的图标:优先使用外部传入的 icon,否则根据 type 获取默认图标
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
import ArtSearchSelect from './widget/art-search-select/index.vue'
|
import ArtSearchSelect from './widget/art-search-select/index.vue'
|
||||||
import ArtSearchRadio from './widget/art-search-radio/index.vue'
|
import ArtSearchRadio from './widget/art-search-radio/index.vue'
|
||||||
import ArtSearchDate from './widget/art-search-date/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'
|
import { SearchComponentType, SearchFormItem } from '@/types'
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
@@ -131,6 +132,7 @@
|
|||||||
const componentsMap: Record<string, any> = {
|
const componentsMap: Record<string, any> = {
|
||||||
input: ArtSearchInput,
|
input: ArtSearchInput,
|
||||||
select: ArtSearchSelect,
|
select: ArtSearchSelect,
|
||||||
|
'tree-select': ArtSearchTreeSelect,
|
||||||
radio: ArtSearchRadio,
|
radio: ArtSearchRadio,
|
||||||
datetime: ArtSearchDate,
|
datetime: ArtSearchDate,
|
||||||
date: ArtSearchDate,
|
date: ArtSearchDate,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 快速入口 -->
|
<!-- 快速入口 -->
|
||||||
<ArtFastEnter v-if="width >= 1200" />
|
<!--<ArtFastEnter v-if="width >= 1200" />-->
|
||||||
|
|
||||||
<!-- 面包屑 -->
|
<!-- 面包屑 -->
|
||||||
<ArtBreadcrumb
|
<ArtBreadcrumb
|
||||||
@@ -65,19 +65,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 通知 -->
|
<!-- 通知 -->
|
||||||
<div class="btn-box notice-btn" @click="visibleNotice">
|
<!--<div class="btn-box notice-btn" @click="visibleNotice">-->
|
||||||
<div class="btn notice-button">
|
<!-- <div class="btn notice-button">-->
|
||||||
<i class="iconfont-sys notice-btn"></i>
|
<!-- <i class="iconfont-sys notice-btn"></i>-->
|
||||||
<span class="count notice-btn"></span>
|
<!-- <span class="count notice-btn"></span>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</div>
|
<!--</div>-->
|
||||||
<!-- 聊天 -->
|
<!-- 聊天 -->
|
||||||
<div class="btn-box chat-btn" @click="openChat">
|
<!--<div class="btn-box chat-btn" @click="openChat">-->
|
||||||
<div class="btn chat-button">
|
<!-- <div class="btn chat-button">-->
|
||||||
<i class="iconfont-sys"></i>
|
<!-- <i class="iconfont-sys"></i>-->
|
||||||
<span class="dot"></span>
|
<!-- <span class="dot"></span>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</div>
|
<!--</div>-->
|
||||||
<!-- 语言 -->
|
<!-- 语言 -->
|
||||||
<div class="btn-box" v-if="showLanguage">
|
<div class="btn-box" v-if="showLanguage">
|
||||||
<el-dropdown @command="changeLanguage" popper-class="langDropDownStyle">
|
<el-dropdown @command="changeLanguage" popper-class="langDropDownStyle">
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<div class="user-info-display">
|
<div class="user-info-display">
|
||||||
<div class="avatar">{{ getUserAvatar }}</div>
|
<div class="avatar">{{ getUserAvatar }}</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="username">{{ userInfo.username || '用户' }}</div>
|
<div class="username">{{ userInfo.username || '' }}</div>
|
||||||
<div class="user-type">{{ userInfo.user_type_name || '' }}</div>
|
<div class="user-type">{{ userInfo.user_type_name || '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
*/
|
*/
|
||||||
const getUserAvatar = computed(() => {
|
const getUserAvatar = computed(() => {
|
||||||
const username = userInfo.value.username
|
const username = userInfo.value.username
|
||||||
if (!username) return 'U'
|
if (!username) return ''
|
||||||
|
|
||||||
const firstChar = username.charAt(0)
|
const firstChar = username.charAt(0)
|
||||||
// 检查是否为中文字符(Unicode 范围:\u4e00-\u9fa5)
|
// 检查是否为中文字符(Unicode 范围:\u4e00-\u9fa5)
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
|
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
|
||||||
<div v-show="visible" :style="menuStyle" class="context-menu">
|
<div v-show="visible" :style="menuStyle" class="context-menu">
|
||||||
<ul class="menu-list" :style="menuListStyle">
|
<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
|
<li
|
||||||
v-if="!item.children"
|
v-if="!item.children"
|
||||||
@@ -249,10 +255,23 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
transition: background-color 0.15s ease;
|
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);
|
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 {
|
&.has-line {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
|||||||
38
src/components/core/others/TableContextMenuHint.vue
Normal file
38
src/components/core/others/TableContextMenuHint.vue
Normal 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>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
:row-key="rowKey"
|
:row-key="rowKey"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
:height="height"
|
:height="height"
|
||||||
:max-height="maxHeight"
|
:max-height="maxHeight"
|
||||||
:show-header="showHeader"
|
:show-header="showHeader"
|
||||||
@@ -28,7 +29,10 @@
|
|||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}"
|
}"
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
|
@row-contextmenu="handleRowContextmenu"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
|
@cell-mouse-enter="handleCellMouseEnter"
|
||||||
|
@cell-mouse-leave="handleCellMouseLeave"
|
||||||
>
|
>
|
||||||
<!-- 序号列 -->
|
<!-- 序号列 -->
|
||||||
<el-table-column
|
<el-table-column
|
||||||
@@ -85,6 +89,8 @@
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
/** 行数据的 Key,用于标识每一行数据 */
|
/** 行数据的 Key,用于标识每一行数据 */
|
||||||
rowKey?: string
|
rowKey?: string
|
||||||
|
/** 行的 className 的回调方法 */
|
||||||
|
rowClassName?: ((data: { row: any; rowIndex: number }) => string) | string
|
||||||
/** 是否显示边框 */
|
/** 是否显示边框 */
|
||||||
border?: boolean | null
|
border?: boolean | null
|
||||||
/** 是否使用斑马纹样式 */
|
/** 是否使用斑马纹样式 */
|
||||||
@@ -135,6 +141,7 @@
|
|||||||
data: () => [],
|
data: () => [],
|
||||||
loading: false,
|
loading: false,
|
||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
|
rowClassName: undefined,
|
||||||
border: null,
|
border: null,
|
||||||
stripe: null,
|
stripe: null,
|
||||||
index: false,
|
index: false,
|
||||||
@@ -174,9 +181,12 @@
|
|||||||
'update:currentPage',
|
'update:currentPage',
|
||||||
'update:pageSize',
|
'update:pageSize',
|
||||||
'row-click',
|
'row-click',
|
||||||
|
'row-contextmenu',
|
||||||
'size-change',
|
'size-change',
|
||||||
'current-change',
|
'current-change',
|
||||||
'selection-change'
|
'selection-change',
|
||||||
|
'cell-mouse-enter',
|
||||||
|
'cell-mouse-leave'
|
||||||
])
|
])
|
||||||
|
|
||||||
const tableStore = useTableStore()
|
const tableStore = useTableStore()
|
||||||
@@ -274,6 +284,21 @@
|
|||||||
emit('row-click', row, column, event)
|
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) => {
|
const handleSelectionChange = (selection: any) => {
|
||||||
emit('selection-change', selection)
|
emit('selection-change', selection)
|
||||||
|
|||||||
134
src/components/device/SetWiFiDialog.vue
Normal file
134
src/components/device/SetWiFiDialog.vue
Normal 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>
|
||||||
111
src/components/device/SpeedLimitDialog.vue
Normal file
111
src/components/device/SpeedLimitDialog.vue
Normal 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>
|
||||||
153
src/components/device/SwitchCardDialog.vue
Normal file
153
src/components/device/SwitchCardDialog.vue
Normal 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>
|
||||||
@@ -1,46 +1,57 @@
|
|||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { useCommon } from '@/composables/useCommon'
|
|
||||||
import type { AppRouteRecord } from '@/types/router'
|
|
||||||
|
|
||||||
type AuthItem = NonNullable<AppRouteRecord['meta']['authList']>[number]
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限判断组合式函数
|
||||||
|
* 用于在 setup 函数或表格 formatter 中判断权限
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const { hasAuth, hasAllAuth } = useAuth()
|
||||||
|
* hasAuth('role:add') // 检查是否拥有权限
|
||||||
|
* hasAuth(['role:add', 'role:edit']) // 检查是否拥有任意一个权限
|
||||||
|
* hasAllAuth(['role:add', 'role:edit']) // 检查是否拥有全部权限
|
||||||
|
*/
|
||||||
|
export const useAuth = () => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按钮权限(前后端模式通用)
|
* 检查是否有指定权限(满足任意一个即可)
|
||||||
* 用法:
|
* @param permission 权限标识,可以是字符串或字符串数组
|
||||||
* const { hasAuth } = useAuth()
|
|
||||||
* hasAuth('add') // 检查是否拥有新增权限
|
|
||||||
*/
|
|
||||||
export const useAuth = () => {
|
|
||||||
const route = useRoute()
|
|
||||||
const { isFrontendMode } = useCommon()
|
|
||||||
const { info } = storeToRefs(userStore)
|
|
||||||
|
|
||||||
// 前端按钮权限(例如:['add', 'edit'])
|
|
||||||
const frontendAuthList = info.value?.buttons ?? []
|
|
||||||
|
|
||||||
// 后端路由 meta 配置的权限列表(例如:[{ auth_mark: 'add' }])
|
|
||||||
const backendAuthList: AuthItem[] = Array.isArray(route.meta.authList)
|
|
||||||
? (route.meta.authList as AuthItem[])
|
|
||||||
: []
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否拥有某权限标识
|
|
||||||
* @param auth 权限标识
|
|
||||||
* @returns 是否有权限
|
* @returns 是否有权限
|
||||||
*/
|
*/
|
||||||
const hasAuth = (auth: string): boolean => {
|
const hasAuth = (permission: string | string[]): boolean => {
|
||||||
if (isFrontendMode.value) {
|
if (!permission) return false
|
||||||
return frontendAuthList.includes(auth)
|
|
||||||
|
if (typeof permission === 'string') {
|
||||||
|
// 单个权限(同时检查 permissions 和 buttons)
|
||||||
|
return userStore.hasPermission(permission) || userStore.hasButton(permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
return backendAuthList.some((item) => item?.auth_mark === auth)
|
if (Array.isArray(permission)) {
|
||||||
|
// 多个权限(满足任意一个即可)
|
||||||
|
return permission.some(
|
||||||
|
(perm) => userStore.hasPermission(perm) || userStore.hasButton(perm)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有所有指定权限(需要全部满足)
|
||||||
|
* @param permissions 权限标识数组
|
||||||
|
* @returns 是否有全部权限
|
||||||
|
*/
|
||||||
|
const hasAllAuth = (permissions: string[]): boolean => {
|
||||||
|
if (!permissions || !Array.isArray(permissions)) return false
|
||||||
|
|
||||||
|
// 需要全部满足
|
||||||
|
return permissions.every(
|
||||||
|
(perm) => userStore.hasPermission(perm) || userStore.hasButton(perm)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasAuth
|
hasAuth,
|
||||||
|
hasAllAuth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
import { ref, computed } from 'vue'
|
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 {
|
export interface ColumnOption {
|
||||||
type?: 'selection' | 'expand' | 'index'
|
type?: 'selection' | 'expand' | 'index'
|
||||||
@@ -76,6 +93,27 @@ export function useCheckedColumns(columnsFactory: () => ColumnOption[]) {
|
|||||||
const columnMap = new Map<string, ColumnOption>()
|
const columnMap = new Map<string, ColumnOption>()
|
||||||
|
|
||||||
cols.forEach((column) => {
|
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') {
|
if (column.type === 'selection') {
|
||||||
columnMap.set(SELECTION_KEY, column)
|
columnMap.set(SELECTION_KEY, column)
|
||||||
} else if (column.type === 'expand') {
|
} else if (column.type === 'expand') {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { FormInstance, FormRules } from 'element-plus'
|
|||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { HOME_PAGE } from '@/router/routesAlias'
|
import { HOME_PAGE } from '@/router/routesAlias'
|
||||||
import AppConfig from '@/config'
|
import AppConfig from '@/config'
|
||||||
import { AuthService } from '@/api/authApi'
|
import { AuthService } from '@/api/modules'
|
||||||
import { ApiStatus } from '@/utils/http/status'
|
import { ApiStatus } from '@/utils/http/status'
|
||||||
import { MOCK_ACCOUNTS, mockLogin, mockGetUserInfo, type MockAccount } from '@/mock/auth'
|
import { MOCK_ACCOUNTS, mockLogin, mockGetUserInfo, type MockAccount } from '@/mock/auth'
|
||||||
import { saveCredentials, getRememberedCredentials } from '@/utils/auth/rememberPassword'
|
import { saveCredentials, getRememberedCredentials } from '@/utils/auth/rememberPassword'
|
||||||
@@ -42,10 +42,6 @@ export function useLogin() {
|
|||||||
// 加载状态
|
// 加载状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// 拖拽验证
|
|
||||||
const isPassing = ref(false)
|
|
||||||
const isClickPass = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = reactive<LoginForm>({
|
const formData = reactive<LoginForm>({
|
||||||
account: '',
|
account: '',
|
||||||
@@ -115,13 +111,6 @@ export function useLogin() {
|
|||||||
const valid = await formRef.value.validate()
|
const valid = await formRef.value.validate()
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
|
||||||
// 检查拖拽验证
|
|
||||||
if (!isPassing.value) {
|
|
||||||
isClickPass.value = true
|
|
||||||
ElMessage.warning(t('login.placeholder[2]'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 判断使用 Mock 还是真实 API
|
// 判断使用 Mock 还是真实 API
|
||||||
@@ -208,12 +197,26 @@ export function useLogin() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存权限、菜单和按钮
|
||||||
|
if (response.data.permissions) {
|
||||||
|
userStore.setPermissions(response.data.permissions)
|
||||||
|
}
|
||||||
|
if (response.data.menus) {
|
||||||
|
userStore.setMenus(response.data.menus)
|
||||||
|
}
|
||||||
|
if (response.data.buttons) {
|
||||||
|
userStore.setButtons(response.data.buttons)
|
||||||
|
}
|
||||||
|
|
||||||
// 保存记住密码
|
// 保存记住密码
|
||||||
saveCredentials(formData.username, formData.password, formData.rememberPassword)
|
saveCredentials(formData.username, formData.password, formData.rememberPassword)
|
||||||
|
|
||||||
// 显示登录成功提示
|
// 显示登录成功提示
|
||||||
showLoginSuccessNotice()
|
showLoginSuccessNotice()
|
||||||
|
|
||||||
|
// 等待数据持久化完成
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
// 跳转到重定向页面或首页
|
// 跳转到重定向页面或首页
|
||||||
const redirectPath = getRedirectPath(route)
|
const redirectPath = getRedirectPath(route)
|
||||||
await router.push(redirectPath || HOME_PAGE)
|
await router.push(redirectPath || HOME_PAGE)
|
||||||
@@ -234,14 +237,6 @@ export function useLogin() {
|
|||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置拖拽验证
|
|
||||||
*/
|
|
||||||
const resetDragVerify = () => {
|
|
||||||
isPassing.value = false
|
|
||||||
isClickPass.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时初始化
|
// 组件挂载时初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initForm()
|
initForm()
|
||||||
@@ -252,11 +247,8 @@ export function useLogin() {
|
|||||||
formData,
|
formData,
|
||||||
rules,
|
rules,
|
||||||
loading,
|
loading,
|
||||||
isPassing,
|
|
||||||
isClickPass,
|
|
||||||
mockAccounts,
|
mockAccounts,
|
||||||
setupAccount,
|
setupAccount,
|
||||||
handleLogin,
|
handleLogin
|
||||||
resetDragVerify
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/composables/usePermission.ts
Normal file
86
src/composables/usePermission.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 权限检查 Composable
|
||||||
|
* 用于在模板或脚本中检查用户权限
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
|
||||||
|
export function usePermission() {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 权限列表
|
||||||
|
const permissions = computed(() => userStore.permissions)
|
||||||
|
|
||||||
|
// 按钮权限列表
|
||||||
|
const buttons = computed(() => userStore.buttons)
|
||||||
|
|
||||||
|
// 是否是超级管理员
|
||||||
|
const isSuperAdmin = computed(() => userStore.isSuperAdmin)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有指定权限
|
||||||
|
* @param permission 权限码
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
const hasPermission = (permission: string): boolean => {
|
||||||
|
return userStore.hasPermission(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有任意一个权限
|
||||||
|
* @param perms 权限码数组
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
const hasAnyPermission = (perms: string[]): boolean => {
|
||||||
|
return userStore.hasAnyPermission(perms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有所有权限
|
||||||
|
* @param perms 权限码数组
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
const hasAllPermissions = (perms: string[]): boolean => {
|
||||||
|
return userStore.hasAllPermissions(perms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有菜单权限
|
||||||
|
* @param menuCode 菜单码
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
const hasMenuPermission = (menuCode: string): boolean => {
|
||||||
|
return hasPermission(`menu:${menuCode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有按钮权限(从 permissions 数组检查)
|
||||||
|
* @param buttonCode 按钮码
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
const hasButtonPermission = (buttonCode: string): boolean => {
|
||||||
|
return hasPermission(buttonCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有按钮权限(从 buttons 数组检查)
|
||||||
|
* @param buttonCode 按钮权限码
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
const hasButton = (buttonCode: string): boolean => {
|
||||||
|
return userStore.hasButton(buttonCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
buttons,
|
||||||
|
isSuperAdmin,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
hasMenuPermission,
|
||||||
|
hasButtonPermission,
|
||||||
|
hasButton
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/composables/useTableContextMenu.ts
Normal file
63
src/composables/useTableContextMenu.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/config/constants/carrierTypes.ts
Normal file
32
src/config/constants/carrierTypes.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 运营商类型配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CarrierType } from '@/types/api'
|
||||||
|
|
||||||
|
// 运营商类型选项
|
||||||
|
export const CARRIER_TYPE_OPTIONS = [
|
||||||
|
{ label: '中国移动', value: CarrierType.CMCC, color: '#4CAF50' },
|
||||||
|
{ label: '中国联通', value: CarrierType.CUCC, color: '#2196F3' },
|
||||||
|
{ label: '中国电信', value: CarrierType.CTCC, color: '#FF9800' },
|
||||||
|
{ label: '中国广电', value: CarrierType.CBN, color: '#9C27B0' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 运营商类型映射
|
||||||
|
export const CARRIER_TYPE_MAP = CARRIER_TYPE_OPTIONS.reduce(
|
||||||
|
(map, item) => {
|
||||||
|
map[item.value] = item
|
||||||
|
return map
|
||||||
|
},
|
||||||
|
{} as Record<CarrierType, { label: string; value: CarrierType; color: string }>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取运营商类型标签
|
||||||
|
export function getCarrierTypeLabel(type: CarrierType): string {
|
||||||
|
return CARRIER_TYPE_MAP[type]?.label || type
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取运营商类型颜色
|
||||||
|
export function getCarrierTypeColor(type: CarrierType): string {
|
||||||
|
return CARRIER_TYPE_MAP[type]?.color || '#666'
|
||||||
|
}
|
||||||
@@ -16,3 +16,12 @@ export * from './commission'
|
|||||||
|
|
||||||
// 通用状态相关
|
// 通用状态相关
|
||||||
export * from './status'
|
export * from './status'
|
||||||
|
|
||||||
|
// IoT卡相关
|
||||||
|
export * from './iotCard'
|
||||||
|
|
||||||
|
// 运营商类型相关
|
||||||
|
export * from './carrierTypes'
|
||||||
|
|
||||||
|
// 套餐管理相关
|
||||||
|
export * from './package'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user