Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c43d1978a8 | |||
| 475c62aefc | |||
| c0daa6ed11 | |||
| 2e073c2b87 | |||
| 13248468d3 | |||
| ab7a8d5cfa | |||
| 64d40cbbbf | |||
| d72a4f804e | |||
| 8b2251ef97 | |||
| 2916c04ba5 | |||
| c7245cec67 | |||
| 985412c3bc | |||
| f53f63e679 | |||
| cae1f847e4 | |||
| 5428f01cdf | |||
| 7652b290b0 | |||
| cf188bb94a | |||
| b3ed406f84 | |||
| 2f06aa9f5f | |||
| f0e89695f4 | |||
| c07e09c151 | |||
| 71d3605f4f | |||
| b7e806cc80 | |||
| 1a4587f893 | |||
| 40a5e486a6 | |||
| 3357566b02 | |||
| 25d782b050 | |||
| 9edb59d16e | |||
| cf19956b88 | |||
| cd16ec17ae | |||
| f4dbf17ce9 | |||
| 25975c8b29 | |||
| d650bfeb61 | |||
| 3e751d4c42 | |||
| 193db5735f | |||
| 09a83f4985 | |||
| 20790cf029 | |||
| 86f3835e51 | |||
| a2a7644a9f | |||
| 3ca956d9f0 | |||
| 5d16589e54 | |||
| 255bd9e832 | |||
| 2e82ff8a34 | |||
| c100c37a32 | |||
| cdd8f86b92 | |||
| fe29a8b3d0 | |||
| 2f38932878 | |||
| fdc9db9a9c | |||
| 4d45cf535e | |||
| 9d8f23262b | |||
| e96e9c4a7e | |||
| 5a31de64b3 | |||
| b4260fedb8 | |||
| 793a96a44f | |||
| 043be60f4f | |||
| 127d54eaa6 | |||
| ead7c5bba5 | |||
| 5b5e0f62ec | |||
| 9105503850 | |||
| 20260e221c | |||
| a270f58500 | |||
| 7992ee9902 | |||
| 084aeebf13 | |||
| 1e79005440 | |||
| a6d4d70221 | |||
| e3e70114fb | |||
| 0b57581799 | |||
| 36271b8bd0 | |||
| cae89dd5ad | |||
| d3479fb0bb | |||
| 98cb0e1c8e | |||
| 779359cec5 | |||
| 60f141a0a9 | |||
| fe1a118132 | |||
| 2ce2c1a31f | |||
| 1655d86f6b | |||
| fcebebd216 | |||
| 5a93f4f8a8 | |||
| 4d10deef86 | |||
| cf7ac515f6 | |||
| 4ef208a662 | |||
| f6dcec75d7 | |||
| 4cc684eca6 | |||
| 00ea1845a7 | |||
| 0c6166b3c7 | |||
| 978b18d5d5 | |||
| 54f78e15fe | |||
| ab2df50c0d | |||
| 6859753470 | |||
| 32bb4502e7 | |||
| 4a13048718 | |||
| b56359e572 | |||
| 41d6492daf | |||
| 14fcac3a24 | |||
| 0fc582bc8c | |||
| 777022632c |
@@ -17,7 +17,12 @@ store封装到src/store目录下。
|
|||||||
|
|
||||||
注册侧边栏在/config/menus.js文件中。
|
注册侧边栏在/config/menus.js文件中。
|
||||||
|
|
||||||
|
新添加要求:
|
||||||
|
在遇到用户id需要填写和修改的弹窗将其修改为可预览样式
|
||||||
|
关于填写表单为推荐人id的需要使用组件AvatarSelector展示,如果是文件id或者是封面id 的也需要预览展示需要向头像列表组件一样,可以弄个文件组件/api/v1/admin/file/list这个是文件列表接口
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1.只要涉及弹窗添加和修改xxxid类型的就需要生成一个弹窗组件并使用到页面中
|
||||||
|
|
||||||
## 1. 基础布局规范
|
## 1. 基础布局规范
|
||||||
```css
|
```css
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ninBo
|
||||||
steps:
|
steps:
|
||||||
- name: Download Artifact
|
- name: Download Artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: https://gitea.s1f.ren/actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: vue3-build
|
name: vue3-build
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ninBo
|
||||||
steps:
|
steps:
|
||||||
- name: Download Artifact
|
- name: Download Artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: https://gitea.s1f.ren/actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: vue3-build
|
name: vue3-build
|
||||||
|
|
||||||
|
|||||||
Generated
+984
-931
File diff suppressed because it is too large
Load Diff
+72
-1
@@ -226,11 +226,16 @@ html, body {
|
|||||||
color: #3498db !important;
|
color: #3498db !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 卡片扁平化 */
|
/* 卡片扁平化 + 层次感 */
|
||||||
.el-card {
|
.el-card {
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
border: 1px solid #e1e8ed !important;
|
border: 1px solid #e1e8ed !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.el-card[shadow="hover"]:hover {
|
||||||
|
border-color: #c0c4cc !important;
|
||||||
|
box-shadow: 0 2px 12px rgba(44, 62, 80, 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格扁平化 */
|
/* 表格扁平化 */
|
||||||
@@ -434,4 +439,70 @@ html, body {
|
|||||||
.el-dialog .el-form-item {
|
.el-dialog .el-form-item {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Descriptions 描述列表增强 */
|
||||||
|
.el-descriptions {
|
||||||
|
--el-descriptions-item-bordered-label-background: #fafbfc;
|
||||||
|
}
|
||||||
|
.el-descriptions__label {
|
||||||
|
color: #606266 !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
.el-descriptions__content {
|
||||||
|
color: #1d2129 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading 遮罩增强 */
|
||||||
|
.el-loading-mask {
|
||||||
|
background-color: rgba(255, 255, 255, 0.85) !important;
|
||||||
|
}
|
||||||
|
.el-loading-spinner .circular {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.el-loading-spinner .el-loading-text {
|
||||||
|
color: #606266 !important;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Box 增强 */
|
||||||
|
.el-message-box {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: 0 4px 16px rgba(44, 62, 80, 0.15) !important;
|
||||||
|
}
|
||||||
|
.el-message-box__header {
|
||||||
|
padding: 16px 20px 12px !important;
|
||||||
|
}
|
||||||
|
.el-message-box__title {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #1d2129 !important;
|
||||||
|
}
|
||||||
|
.el-message-box__btns .el-button {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert 增强 */
|
||||||
|
.el-alert {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs 增强 */
|
||||||
|
.el-tabs__item {
|
||||||
|
transition: color 0.2s ease !important;
|
||||||
|
}
|
||||||
|
.el-tabs__item.is-active {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch 开关增强 */
|
||||||
|
.el-switch {
|
||||||
|
--el-switch-on-color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局链接按钮悬浮下划线 */
|
||||||
|
.el-button.is-link:hover,
|
||||||
|
.el-button--primary.is-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -15,3 +15,55 @@ export const addSignRewardType = (data) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 拼团活动相关接口
|
||||||
|
/**获取拼团队伍列表 */
|
||||||
|
export const getGroupBuyList = () => {
|
||||||
|
return http2.get('/api/v1/users/activity/group_buy/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**获取拼团队伍详情 */
|
||||||
|
export const getGroupBuyDetail = (groupBuyId) => {
|
||||||
|
return http2.get('/api/v1/users/activity/group_buy/detail', {
|
||||||
|
params: { group_buy_id: groupBuyId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**为队伍添加随机伪人 */
|
||||||
|
export const addRandomUser = (groupBuyId) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('group_buy_id', groupBuyId)
|
||||||
|
return http2.post('/api/v1/admin/activity/group_buy/add_random_user', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**创建随机伪人队伍 */
|
||||||
|
export const addRandomGroup = (data) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('name', data.name)
|
||||||
|
formData.append('group_buy_type_id', data.group_buy_type_id)
|
||||||
|
return http2.post('/api/v1/admin/activity/group_buy/add_random_group', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**导出成功队伍信息 */
|
||||||
|
export const exportIdcInfo = () => {
|
||||||
|
return http2.get('/api/v1/admin/activity/group_buy/export_idc_info')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**为指定队伍下发订单 */
|
||||||
|
export const setOrder = (groupBuyId) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('group_buy_id', groupBuyId)
|
||||||
|
return http2.post('/api/v1/admin/activity/group_buy/set_order', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,768 @@
|
|||||||
|
import { http2 } from '@/utils/request.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务管理 API
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取 KVM 主控服务列表 */
|
||||||
|
export const getKvmServiceList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 KVM 主控服务详情 */
|
||||||
|
export const getKvmServiceDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 KVM 主控服务 */
|
||||||
|
export const createKvmService = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改 KVM 主控服务 */
|
||||||
|
export const updateKvmService = (id, data) => {
|
||||||
|
return http2.post(`/api/v1/admin/server/host_service/update?id=${id}`, data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 KVM 主控服务 */
|
||||||
|
export const deleteKvmService = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 宿主机组映射管理 API
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取本地主机组列表 */
|
||||||
|
export const getHostGroupList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/host_group/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从远程同步主机组到本地 */
|
||||||
|
export const syncHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/host_group/sync', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定主机组到商品组或商品 */
|
||||||
|
export const bindHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/host_group/bind', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改本地主机组信息 */
|
||||||
|
export const updateHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/host_group/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据主机组树自动生成 GoodGroup/Goods/Args */
|
||||||
|
export const generateGoodsByHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/host_group/generate_goods', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除本地主机组 */
|
||||||
|
export const deleteHostGroup = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/host_group/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 远程宿主机组管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取远程主机组列表 */
|
||||||
|
export const getRemoteHostGroupList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host_group/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取远程主机组详情 */
|
||||||
|
export const getRemoteHostGroupDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host_group/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取远程主机组树形结构 */
|
||||||
|
export const getRemoteHostGroupTree = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host_group/tree', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取主机组最优主机配置信息 */
|
||||||
|
export const getOptimalHostInfo = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host_group/optimal_host', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建远程主机组 */
|
||||||
|
export const createRemoteHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/host_group/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改远程主机组 */
|
||||||
|
export const updateRemoteHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/host_group/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除远程主机组 */
|
||||||
|
export const deleteRemoteHostGroup = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/host_group/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 宿主机管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取宿主机列表 */
|
||||||
|
export const getRemoteHostList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取宿主机详情 */
|
||||||
|
export const getRemoteHostDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取宿主机指标数据 */
|
||||||
|
export const getRemoteHostMetrics = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host/metrics', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询历史指标(宿主机或虚拟机) */
|
||||||
|
export const getMetricsHistory = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host/metrics_history', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增宿主机 */
|
||||||
|
export const addRemoteHost = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/host/add', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改宿主机 */
|
||||||
|
export const updateRemoteHost = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/host/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除宿主机 */
|
||||||
|
export const deleteRemoteHost = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/host/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建宿主机注册令牌 */
|
||||||
|
export const createHostToken = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/host/create_token', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 镜像管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取镜像列表 */
|
||||||
|
export const getImageList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/image/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取镜像详情 */
|
||||||
|
export const getImageDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/image/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取镜像在指定宿主机上的状态 */
|
||||||
|
export const getImageHostStatus = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/image/host_status', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建镜像 */
|
||||||
|
export const createImage = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/image/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改镜像 */
|
||||||
|
export const updateImage = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/image/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除镜像 */
|
||||||
|
export const deleteImage = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/image/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新下载镜像 */
|
||||||
|
export const reloadImage = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/image/reload', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向宿主机同步镜像 */
|
||||||
|
export const syncImageToHost = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/image/sync', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定宿主机重新下载指定镜像 */
|
||||||
|
export const reloadImageOnHost = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/image/reload_host', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取宿主机镜像列表与状态(对比) */
|
||||||
|
export const getImageCompareHost = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/image/compare_host', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 网络管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取网络列表 */
|
||||||
|
export const getNetworkList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/network/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取网络详情 */
|
||||||
|
export const getNetworkDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/network/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建网络 */
|
||||||
|
export const createNetwork = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/network/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改网络 */
|
||||||
|
export const updateNetwork = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/network/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量创建网络 */
|
||||||
|
export const batchCreateNetwork = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/network/batch_create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除网络 */
|
||||||
|
export const deleteNetwork = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/network/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 数据卷管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取数据卷列表 */
|
||||||
|
export const getVolumeList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/volume/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取数据卷详情 */
|
||||||
|
export const getVolumeDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/volume/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建数据卷 */
|
||||||
|
export const createVolume = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/volume/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调整数据卷大小 */
|
||||||
|
export const resizeVolume = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/volume/resize', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 挂载卷到虚拟机 */
|
||||||
|
export const mountVolume = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/volume/mount', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卸载卷 */
|
||||||
|
export const unmountVolume = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/volume/unmount', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移卷 */
|
||||||
|
export const transferVolume = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/volume/transfer', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除卷 */
|
||||||
|
export const deleteVolume = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/volume/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 虚拟机管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取虚拟机列表 */
|
||||||
|
export const getVmList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/vm/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取虚拟机详情 */
|
||||||
|
export const getVmDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/vm/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取虚拟机状态 */
|
||||||
|
export const getVmStatus = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/vm/status', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取虚拟机指标数据 */
|
||||||
|
export const getVmMetrics = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/vm/metrics', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建虚拟机 */
|
||||||
|
export const createVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改虚拟机 */
|
||||||
|
export const updateVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重建虚拟机 */
|
||||||
|
export const rebuildVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/rebuild', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重构虚拟机 */
|
||||||
|
export const refactorVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/refactor', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改虚拟机带宽 */
|
||||||
|
export const updateVmTraffic = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/update_traffic', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 流量策略 ==========
|
||||||
|
// 测试未通过(接口新增,待联调)
|
||||||
|
/** 获取虚拟机流量策略 */
|
||||||
|
export const getVmTrafficPolicy = (params) => http2.get('/api/v1/admin/server/host_service/point/vm/traffic_policy', { params })
|
||||||
|
/** 修改虚拟机流量策略 */
|
||||||
|
export const updateVmTrafficPolicy = (data) => http2.post('/api/v1/admin/server/host_service/point/vm/traffic_policy/update', data, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
/** 增加虚拟机固定流量上限 */
|
||||||
|
export const addVmFixedTraffic = (data) => http2.post('/api/v1/admin/server/host_service/point/vm/traffic_policy/add_fixed', data, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
/** 增加虚拟机一次性临时流量 */
|
||||||
|
export const addVmTemporaryTraffic = (data) => http2.post('/api/v1/admin/server/host_service/point/vm/traffic_policy/add_temporary', data, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
|
||||||
|
/** 启动虚拟机 */
|
||||||
|
export const startVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/start', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止虚拟机 */
|
||||||
|
export const stopVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/stop', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重启虚拟机 */
|
||||||
|
export const rebootVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/reboot', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 暂停虚拟机 */
|
||||||
|
export const suspendVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/suspend', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复虚拟机 */
|
||||||
|
export const resumeVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/resume', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 虚拟机进入救援系统 */
|
||||||
|
export const rescueVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/rescue', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 虚拟机退出救援系统 */
|
||||||
|
export const exitRescueVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/exit_rescue', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除虚拟机 */
|
||||||
|
export const deleteVm = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/vm/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移虚拟机(更换宿主机) */
|
||||||
|
export const migrateVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/migrate', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发起虚拟机数据迁移 */
|
||||||
|
export const dataMigrateVm = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/data_migrate', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取虚拟机数据迁移进度 */
|
||||||
|
export const getDataMigrateProgress = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/vm/data_migrate/progress', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中断虚拟机数据迁移 */
|
||||||
|
export const abortDataMigrate = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vm/data_migrate/abort', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 安全组管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取安全组列表 */
|
||||||
|
export const getSecurityGroupList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/post_group/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取安全组详情 */
|
||||||
|
export const getSecurityGroupDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/post_group/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建安全组 */
|
||||||
|
export const createSecurityGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改安全组 */
|
||||||
|
export const updateSecurityGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步安全组 */
|
||||||
|
export const syncSecurityGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/sync', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定安全组到虚拟机 */
|
||||||
|
export const bindSecurityGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/bind', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解绑安全组 */
|
||||||
|
export const unbindSecurityGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/unbind', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除安全组 */
|
||||||
|
export const deleteSecurityGroup = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/post_group/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开启安全组白名单 */
|
||||||
|
export const enableSecurityGroupWhitelist = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/enable_whitelist', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭安全组白名单 */
|
||||||
|
export const disableSecurityGroupWhitelist = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/disable_whitelist', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增安全组规则 */
|
||||||
|
export const createSecurityGroupRule = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/create_rule', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改安全组规则 */
|
||||||
|
export const updateSecurityGroupRule = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/update_rule', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除安全组规则 */
|
||||||
|
export const deleteSecurityGroupRule = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/post_group/delete_rule', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用安全组 */
|
||||||
|
export const applySecurityGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/apply', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - VNC 节点管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取 VNC 节点列表 */
|
||||||
|
export const getVncNodeList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/vnc/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取虚拟机 VNC 连接信息 */
|
||||||
|
export const getVmVnc = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/vnc/vm_vnc', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增 VNC 节点 */
|
||||||
|
export const addVncNode = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vnc/add', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试 VNC 节点连接 */
|
||||||
|
export const testVncNode = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vnc/test', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改 VNC 节点 */
|
||||||
|
export const updateVncNode = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/vnc/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 VNC 节点 */
|
||||||
|
export const deleteVncNode = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/vnc/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置安全组共享状态 */
|
||||||
|
export const setSecurityGroupShared = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/set_shared', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 快照管理 ==========
|
||||||
|
/** 获取快照列表 */
|
||||||
|
export const getSnapshotList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/snapshot/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取快照任务进度 */
|
||||||
|
export const getSnapshotProgress = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/snapshot/progress', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建快照 */
|
||||||
|
export const createSnapshot = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/snapshot/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复快照 */
|
||||||
|
export const restoreSnapshot = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/snapshot/restore', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除快照 */
|
||||||
|
export const deleteSnapshot = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/snapshot/delete', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 备份管理 ==========
|
||||||
|
/** 获取备份列表 */
|
||||||
|
export const getBackupList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/backup/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取备份任务进度 */
|
||||||
|
export const getBackupProgress = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/backup/progress', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建备份 */
|
||||||
|
export const createBackup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/backup/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复备份 */
|
||||||
|
export const restoreBackup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/backup/restore', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除备份 */
|
||||||
|
export const deleteBackup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/backup/delete', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取快照数量与上限 */
|
||||||
|
export const getSnapshotCount = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/snapshot/count', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置快照数量上限 */
|
||||||
|
export const setSnapshotLimit = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/snapshot/set_limit', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取备份数量与上限 */
|
||||||
|
export const getBackupCount = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/backup/count', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置备份数量上限 */
|
||||||
|
export const setBackupLimit = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/backup/set_limit', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 用户组网管理 (UserNetworking)
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取组网列表 */
|
||||||
|
export const getUserNetworkingList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/networking/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取组网详情 */
|
||||||
|
export const getUserNetworkingDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/networking/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建用户组网 */
|
||||||
|
export const createUserNetworking = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/networking/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为虚拟机分配组网 IP */
|
||||||
|
export const assignUserNetworking = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/networking/assign', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除组网 */
|
||||||
|
export const deleteUserNetworking = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/networking/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除组网下的指定网络 */
|
||||||
|
export const removeUserNetworkingNetwork = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/networking/remove_network', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -33,3 +33,11 @@ export const updateOrder = (data) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
/**重试订单流程 */
|
||||||
|
export const retryOrderHook = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/order/retry_hook', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+115
-1
@@ -57,6 +57,10 @@ export const deleteProductGroup = (data) => {
|
|||||||
export const getProductList = (params) => {
|
export const getProductList = (params) => {
|
||||||
return http2.get('/api/v1/admin/good/goods/list', {params: params})
|
return http2.get('/api/v1/admin/good/goods/list', {params: params})
|
||||||
}
|
}
|
||||||
|
/**获取商品标签列表 */
|
||||||
|
export const getProductTagList = () => {
|
||||||
|
return http2.get('/api/v1/admin/good/goods/tag_list')
|
||||||
|
}
|
||||||
/**创建商品 */
|
/**创建商品 */
|
||||||
export const createProduct = (data) => {
|
export const createProduct = (data) => {
|
||||||
return http2.post('/api/v1/admin/good/goods/create', data,{
|
return http2.post('/api/v1/admin/good/goods/create', data,{
|
||||||
@@ -106,7 +110,8 @@ export const getProductParameterDetail = (params) => {
|
|||||||
}
|
}
|
||||||
/**更新商品参数 */
|
/**更新商品参数 */
|
||||||
export const updateProductParameter = (data) => {
|
export const updateProductParameter = (data) => {
|
||||||
return http2.post('/api/v1/admin/good/spec/update', data,{
|
return http2.post('/api/v1/admin/good/spec/update', null, {
|
||||||
|
params: data,
|
||||||
headers:{
|
headers:{
|
||||||
'Content-Type':'multipart/form-data'
|
'Content-Type':'multipart/form-data'
|
||||||
}
|
}
|
||||||
@@ -140,3 +145,112 @@ export const updateProductParameterValue = (data) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**---------------------------------- */
|
||||||
|
/**商品套餐管理 */
|
||||||
|
|
||||||
|
/**获取商品套餐列表 */
|
||||||
|
export const getProductPlanList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/good/plan/list', {params: params})
|
||||||
|
}
|
||||||
|
/**获取商品套餐详情 */
|
||||||
|
export const getProductPlanDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/good/plan/detail', {params: params})
|
||||||
|
}
|
||||||
|
/**创建商品套餐 */
|
||||||
|
export const createProductPlan = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/good/plan/create', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/**更新商品套餐 */
|
||||||
|
export const updateProductPlan = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/good/plan/update', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/**删除商品套餐 */
|
||||||
|
export const deleteProductPlan = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/good/plan/delete', {params: params})
|
||||||
|
}
|
||||||
|
/**禁用商品套餐 */
|
||||||
|
export const disableProductPlan = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/good/plan/disable', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/**启用商品套餐 */
|
||||||
|
export const enableProductPlan = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/good/plan/enable', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**禁用套餐固定价格 */
|
||||||
|
export const disablePlanFixedPrice = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/good/plan/disable_fixed_price', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**启用套餐固定价格 */
|
||||||
|
export const enablePlanFixedPrice = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/good/plan/enable_fixed_price', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**---------------------------------- */
|
||||||
|
/**商品分组标签管理 */
|
||||||
|
|
||||||
|
/**获取商品分组标签列表 */
|
||||||
|
export const getProductGroupTagList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/good/group_tag/list', {params: params})
|
||||||
|
}
|
||||||
|
/**获取商品分组标签详情 */
|
||||||
|
export const getProductGroupTagDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/good/group_tag/detail', {params: params})
|
||||||
|
}
|
||||||
|
/**创建商品分组标签 */
|
||||||
|
export const createProductGroupTag = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/good/group_tag/create', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/**更新商品分组标签 */
|
||||||
|
export const updateProductGroupTag = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/good/group_tag/update', data,{
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/**删除商品分组标签 */
|
||||||
|
export const deleteProductGroupTag = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/good/group_tag/delete', {params: params})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**---------------------------------- */
|
||||||
|
/**已购商品管理 */
|
||||||
|
|
||||||
|
/**获取用户已购商品列表 */
|
||||||
|
export const getUserGoodsList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/good/user_goods/list', {params: params})
|
||||||
|
}
|
||||||
+11
-5
@@ -18,10 +18,6 @@ export const addUserConsumption = (data) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**查询用户余额 */
|
|
||||||
export const getUserBalance = (data) => {
|
|
||||||
return http2.get('/api/v1/admin/user/balance/select?user_id='+data.user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**获取用户余额记录 */
|
/**获取用户余额记录 */
|
||||||
export const getUserBalanceRecord = (data) => {
|
export const getUserBalanceRecord = (data) => {
|
||||||
@@ -53,7 +49,7 @@ export const updateUserInfo = (data) => {
|
|||||||
|
|
||||||
/**删除用户 */
|
/**删除用户 */
|
||||||
export const deleteUser = (data) => {
|
export const deleteUser = (data) => {
|
||||||
return http2.delete('/api/v1/admin/user/user/delete?group_id='+data.group_id)
|
return http2.delete('/api/v1/admin/user/user/delete?user_id='+data.user_id)
|
||||||
}
|
}
|
||||||
/**修改用户头像 */
|
/**修改用户头像 */
|
||||||
export const updateUserAvatar = (data) => {
|
export const updateUserAvatar = (data) => {
|
||||||
@@ -163,3 +159,13 @@ export const addUserGroupMember = (data) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**退款对应账单 */
|
||||||
|
export const refundBalance = (data) => {
|
||||||
|
return http2.get('/api/v1/admin/user/balance/refund', {
|
||||||
|
params:data,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { http2 } from '@/utils/request.js'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/admin/good/user_vm'
|
||||||
|
const GOODS_BASE = '/api/v1/admin/good/user_goods'
|
||||||
|
|
||||||
|
const fd = (data) => {
|
||||||
|
const f = new FormData()
|
||||||
|
Object.entries(data).forEach(([k, v]) => {
|
||||||
|
if (v === undefined || v === null) return
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
v.forEach(item => f.append(k, item))
|
||||||
|
} else if (typeof v === 'boolean') {
|
||||||
|
f.append(k, v ? 'true' : 'false')
|
||||||
|
} else {
|
||||||
|
f.append(k, v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 用户虚拟机 ==========
|
||||||
|
export const getUserVmList = (params) => http2.get(`${BASE}/list`, { params })
|
||||||
|
export const getUserVmDetail = (params) => http2.get(`${BASE}/detail`, { params })
|
||||||
|
export const getUserVmVnc = (params) => http2.get(`${BASE}/vnc`, { params })
|
||||||
|
export const getUserVmHostImages = (params) => http2.get(`${BASE}/host_images`, { params })
|
||||||
|
export const getGoodHostGroupImages = (params) => http2.get(`${BASE}/good_host_group_images`, { params })
|
||||||
|
export const createUserVm = (data) => http2.post(`${BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const bindUserVm = (data) => http2.post(`${BASE}/bind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const transferUserVm = (data) => http2.post(`${BASE}/transfer`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const migrateUserVm = (data) => http2.post(`${BASE}/migrate`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const updateUserVmTraffic = (data) => http2.post(`${BASE}/update_traffic`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const updateUserVm = (data) => http2.post(`${BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const refactorUserVm = (data) => http2.post(`${BASE}/refactor`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const startUserVm = (data) => http2.post(`${BASE}/start`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const stopUserVm = (data) => http2.post(`${BASE}/stop`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const rebootUserVm = (data) => http2.post(`${BASE}/reboot`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const suspendUserVm = (data) => http2.post(`${BASE}/suspend`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const resumeUserVm = (data) => http2.post(`${BASE}/resume`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const rescueUserVm = (data) => http2.post(`${BASE}/rescue`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const exitRescueUserVm = (data) => http2.post(`${BASE}/exit_rescue`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const rebuildUserVm = (data) => http2.post(`${BASE}/rebuild`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteUserVm = (params) => http2.delete(`${BASE}/delete`, { params })
|
||||||
|
|
||||||
|
// ========== 数据卷 ==========
|
||||||
|
export const getUserVmVolumeList = (params) => http2.get(`${BASE}/volume/list`, { params })
|
||||||
|
export const getUserVmVolumeDetail = (params) => http2.get(`${BASE}/volume/detail`, { params })
|
||||||
|
export const createUserVmVolume = (data) => http2.post(`${BASE}/volume/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const resizeUserVmVolume = (data) => http2.post(`${BASE}/volume/resize`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const mountUserVmVolume = (data) => http2.post(`${BASE}/volume/mount`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const unmountUserVmVolume = (data) => http2.post(`${BASE}/volume/unmount`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteUserVmVolume = (params) => http2.delete(`${BASE}/volume/delete`, { params })
|
||||||
|
|
||||||
|
// ========== 快照 ==========
|
||||||
|
export const getUserVmSnapshotList = (params) => http2.get(`${BASE}/snapshot/list`, { params })
|
||||||
|
export const getUserVmSnapshotProgress = (params) => http2.get(`${BASE}/snapshot/progress`, { params })
|
||||||
|
export const getUserVmSnapshotCount = (params) => http2.get(`${BASE}/snapshot/count`, { params })
|
||||||
|
export const createUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const restoreUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/restore`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/delete`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const setUserVmSnapshotLimit = (data) => http2.post(`${BASE}/snapshot/set_limit`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
|
||||||
|
// ========== 备份 ==========
|
||||||
|
export const getUserVmBackupList = (params) => http2.get(`${BASE}/backup/list`, { params })
|
||||||
|
export const getUserVmBackupProgress = (params) => http2.get(`${BASE}/backup/progress`, { params })
|
||||||
|
export const getUserVmBackupCount = (params) => http2.get(`${BASE}/backup/count`, { params })
|
||||||
|
export const createUserVmBackup = (data) => http2.post(`${BASE}/backup/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const restoreUserVmBackup = (data) => http2.post(`${BASE}/backup/restore`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteUserVmBackup = (data) => http2.post(`${BASE}/backup/delete`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const setUserVmBackupLimit = (data) => http2.post(`${BASE}/backup/set_limit`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
|
||||||
|
// ========== 安全组 ==========
|
||||||
|
export const getUserVmPostGroupList = (params) => http2.get(`${BASE}/post_group/list`, { params })
|
||||||
|
export const getUserVmPostGroupDetail = (params) => http2.get(`${BASE}/post_group/detail`, { params })
|
||||||
|
export const getUserVmPostGroupUserList = (params) => http2.get(`${BASE}/post_group/user_list`, { params })
|
||||||
|
export const createUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const updateUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const bindUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/bind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const unbindUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/unbind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const applyUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/apply`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const setSharedUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/set_shared`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteUserVmPostGroup = (params) => http2.delete(`${BASE}/post_group/delete`, { params })
|
||||||
|
export const enableUserVmPostGroupWhitelist = (data) => http2.post(`${BASE}/post_group/enable_whitelist`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const disableUserVmPostGroupWhitelist = (data) => http2.post(`${BASE}/post_group/disable_whitelist`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const createUserVmPostGroupRule = (data) => http2.post(`${BASE}/post_group/create_rule`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const updateUserVmPostGroupRule = (data) => http2.post(`${BASE}/post_group/update_rule`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteUserVmPostGroupRule = (params) => http2.delete(`${BASE}/post_group/delete_rule`, { params })
|
||||||
|
|
||||||
|
// ========== 网络 ==========
|
||||||
|
export const getUserVmNetworkList = (params) => http2.get(`${BASE}/network/list`, { params })
|
||||||
|
export const getUserVmNetworkDetail = (params) => http2.get(`${BASE}/network/detail`, { params })
|
||||||
|
|
||||||
|
// ========== 组网 ==========
|
||||||
|
export const getUserVmNetworkingList = (params) => http2.get(`${BASE}/networking/list`, { params })
|
||||||
|
export const getUserVmNetworkingDetail = (params) => http2.get(`${BASE}/networking/detail`, { params })
|
||||||
|
export const createUserVmNetworking = (data) => http2.post(`${BASE}/networking/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const assignUserVmNetworking = (data) => http2.post(`${BASE}/networking/assign`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const removeUserVmNetworkingNetwork = (data) => http2.post(`${BASE}/networking/remove_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteUserVmNetworking = (params) => http2.delete(`${BASE}/networking/delete`, { params })
|
||||||
|
|
||||||
|
// ========== 用户商品 ==========
|
||||||
|
export const getUserGoodsList = (params) => http2.get(`${GOODS_BASE}/list`, { params })
|
||||||
|
export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`, { params })
|
||||||
|
export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params })
|
||||||
|
|
||||||
|
export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params })
|
||||||
|
|
||||||
|
// ========== 流量策略 ==========
|
||||||
|
// 测试未通过(接口新增,待联调)
|
||||||
|
export const getUserVmTrafficPolicy = (params) => http2.get(`${BASE}/traffic_policy`, { params })
|
||||||
|
export const updateUserVmTrafficPolicy = (data) => http2.post(`${BASE}/traffic_policy/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const addUserVmFixedTraffic = (data) => http2.post(`${BASE}/traffic_policy/add_fixed`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const addUserVmTemporaryTraffic = (data) => http2.post(`${BASE}/traffic_policy/add_temporary`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
|
||||||
|
// ========== 到期提醒 ==========
|
||||||
|
export const getExpireRemindList = (params) => http2.get(`${GOODS_BASE}/expire_remind/list`, { params })
|
||||||
|
export const sendExpireRemind = (data) => http2.post(`${GOODS_BASE}/expire_remind/send`, data, { headers: { 'Content-Type': 'application/json' } })
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { http2 } from '@/utils/request.js'
|
||||||
|
|
||||||
|
const fd = (data) => {
|
||||||
|
const f = new FormData()
|
||||||
|
Object.entries(data).forEach(([k, v]) => {
|
||||||
|
if (v === undefined || v === null || v === '') return
|
||||||
|
f.append(k, v)
|
||||||
|
})
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_GROUP = '/api/v1/admin/server/vnc_command/group'
|
||||||
|
const BASE_ITEM = '/api/v1/admin/server/vnc_command/item'
|
||||||
|
|
||||||
|
// 分组
|
||||||
|
export const getVncCommandGroupList = () => http2.get(`${BASE_GROUP}/list`)
|
||||||
|
export const createVncCommandGroup = (data) => http2.post(`${BASE_GROUP}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const updateVncCommandGroup = (data) => http2.post(`${BASE_GROUP}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteVncCommandGroup = (params) => http2.delete(`${BASE_GROUP}/delete`, { params })
|
||||||
|
|
||||||
|
// 指令项
|
||||||
|
export const createVncCommandItem = (data) => http2.post(`${BASE_ITEM}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const updateVncCommandItem = (data) => http2.post(`${BASE_ITEM}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
export const deleteVncCommandItem = (params) => http2.delete(`${BASE_ITEM}/delete`, { params })
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { http2 } from "@/utils/request.js"
|
||||||
|
|
||||||
|
// ========== 后台菜单管理 ==========
|
||||||
|
|
||||||
|
/** 获取后台菜单列表 */
|
||||||
|
export const getWebRoutsList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/web_routs/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增后台菜单 */
|
||||||
|
export const addWebRouts = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/web_routs/add', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改后台菜单 */
|
||||||
|
export const updateWebRouts = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/web_routs/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除后台菜单 */
|
||||||
|
export const deleteWebRouts = (data) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/web_routs/delete', {
|
||||||
|
data,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 后台菜单权限管理 ==========
|
||||||
|
|
||||||
|
/** 获取后台菜单权限列表 */
|
||||||
|
export const getWebRoutsPermissionList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/web_routs/permission/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增后台菜单权限 */
|
||||||
|
export const addWebRoutsPermission = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/web_routs/permission/add', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改后台菜单权限 */
|
||||||
|
export const updateWebRoutsPermission = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/web_routs/permission/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除后台菜单权限 */
|
||||||
|
export const deleteWebRoutsPermission = (data) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/web_routs/permission/delete', {
|
||||||
|
data,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户的后台菜单权限树 */
|
||||||
|
export const getMyWebRoutsPermission = () => {
|
||||||
|
return http2.get('/api/v1/admin/server/web_routs/my')
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import request from "@/utils/request.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建拼团
|
||||||
|
* @param {Object} data - 拼团数据
|
||||||
|
* @param {string} data.name - 拼团名称
|
||||||
|
* @param {number} data.maxPerson - 最大人数
|
||||||
|
* @param {string} data.cover - 封面图片URL
|
||||||
|
* @returns {Promise} 返回拼团详情
|
||||||
|
*/
|
||||||
|
export const createGroupBuy = (data) => {
|
||||||
|
return request.post("/api/v1/group-buy/create", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查拼团
|
||||||
|
* @param {string} groupBuyId - 拼团ID
|
||||||
|
* @returns {Promise} 返回检查结果
|
||||||
|
*/
|
||||||
|
export const checkGroupBuy = (groupBuyId) => {
|
||||||
|
return request.get(`/api/v1/group-buy/check/${groupBuyId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取拼团详情
|
||||||
|
* @param {string} groupBuyId - 拼团ID
|
||||||
|
* @returns {Promise} 返回拼团详情
|
||||||
|
*/
|
||||||
|
export const getGroupBuyDetail = (groupBuyId) => {
|
||||||
|
return request.get(`/api/v1/group-buy/${groupBuyId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取拼团列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {number} params.page - 页码
|
||||||
|
* @param {number} params.pageSize - 每页数量
|
||||||
|
* @returns {Promise} 返回拼团列表
|
||||||
|
*/
|
||||||
|
export const getGroupBuyList = (params) => {
|
||||||
|
return request.get("/api/v1/users/activity/group_buy/list", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入拼团
|
||||||
|
* @param {string} groupBuyId - 拼团ID
|
||||||
|
* @param {Object} data - 用户数据
|
||||||
|
* @returns {Promise} 返回加入结果
|
||||||
|
*/
|
||||||
|
export const joinGroupBuy = (groupBuyId, data) => {
|
||||||
|
return request.post(`/api/v1/group-buy/${groupBuyId}/join`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除拼团
|
||||||
|
* @param {string} groupBuyId - 拼团ID
|
||||||
|
* @returns {Promise} 返回删除结果
|
||||||
|
*/
|
||||||
|
export const deleteGroupBuy = (groupBuyId) => {
|
||||||
|
return request.delete(`/api/v1/group-buy/${groupBuyId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 拼团类型管理接口 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取拼团活动类型列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {number} [params.page=1] - 页码
|
||||||
|
* @param {number} [params.count=10] - 每页条数
|
||||||
|
* @param {string} [params.key] - 关键词筛选
|
||||||
|
* @param {number} [params.expire_time] - 过期时间筛选(时间戳)
|
||||||
|
* @param {string} [params.tag] - 标签筛选
|
||||||
|
* @returns {Promise} 返回拼团类型列表
|
||||||
|
*/
|
||||||
|
export const getGroupBuyTypeList = (params) => {
|
||||||
|
return request.get("/api/v1/admin/activity/group_buy/type/list", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取拼团活动类型标签列表
|
||||||
|
* @returns {Promise} 返回标签列表
|
||||||
|
*/
|
||||||
|
export const getGroupBuyTypeTags = () => {
|
||||||
|
return request.get("/api/v1/admin/activity/group_buy/type/tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增拼团活动类型
|
||||||
|
* @param {Object} data - 类型数据
|
||||||
|
* @param {string} data.name - 名称
|
||||||
|
* @param {string} [data.note] - 备注
|
||||||
|
* @param {string} data.price - 价格(分)
|
||||||
|
* @param {string} [data.renew_price] - 续费价格(分)
|
||||||
|
* @param {string} data.max_person - 拼团需要人数
|
||||||
|
* @param {string} [data.tag] - 标签
|
||||||
|
* @param {number} [data.expire_time] - 活动过期时间
|
||||||
|
* @returns {Promise} 返回新增结果
|
||||||
|
*/
|
||||||
|
export const addGroupBuyType = (data) => {
|
||||||
|
return request.post("/api/v1/admin/activity/group_buy/type/add", data,{
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改拼团活动类型
|
||||||
|
* @param {Object} data - 类型数据
|
||||||
|
* @param {string} data.id - ID编号
|
||||||
|
* @param {string} [data.name] - 名称
|
||||||
|
* @param {string} [data.note] - 备注
|
||||||
|
* @param {string} [data.price] - 价格(分)
|
||||||
|
* @param {string} [data.renew_price] - 续费价格(分)
|
||||||
|
* @param {string} [data.max_person] - 拼团需要人数
|
||||||
|
* @param {string} [data.tag] - 标签
|
||||||
|
* @param {number} [data.expire_time] - 活动过期时间
|
||||||
|
* @returns {Promise} 返回修改结果
|
||||||
|
*/
|
||||||
|
export const updateGroupBuyType = (data) => {
|
||||||
|
return request.post("/api/v1/admin/activity/group_buy/type/update", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除拼团活动类型
|
||||||
|
* @param {string} id - 类型ID
|
||||||
|
* @returns {Promise} 返回删除结果
|
||||||
|
*/
|
||||||
|
export const deleteGroupBuyType = (id) => {
|
||||||
|
return request.delete("/api/v1/admin/activity/group_buy/type/delete", { params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 拼团队伍管理接口 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查队伍列表
|
||||||
|
* @returns {Promise} 返回队伍检查结果
|
||||||
|
*/
|
||||||
|
export const checkGroupBuyTeams = () => {
|
||||||
|
return request.get("/api/v1/admin/activity/group_buy/check")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为队伍添加随机伪人
|
||||||
|
* @param {string} groupBuyId - 队伍ID
|
||||||
|
* @returns {Promise} 返回添加结果
|
||||||
|
*/
|
||||||
|
export const addRandomUser = (groupBuyId) => {
|
||||||
|
return request.post("/api/v1/admin/activity/group_buy/add_random_user", { group_buy_id: groupBuyId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建随机伪人队伍
|
||||||
|
* @param {Object} data - 队伍数据
|
||||||
|
* @param {string} data.name - 队伍名称
|
||||||
|
* @param {string} data.group_buy_type_id - 队伍类型ID
|
||||||
|
* @returns {Promise} 返回创建结果
|
||||||
|
*/
|
||||||
|
export const addRandomGroup = (data) => {
|
||||||
|
return request.post("/api/v1/admin/activity/group_buy/add_random_group", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出成功队伍信息
|
||||||
|
* @returns {Promise} 返回导出数据
|
||||||
|
*/
|
||||||
|
export const exportGroupBuyIdcInfo = () => {
|
||||||
|
return request.get("/api/v1/admin/activity/group_buy/export_idc_info")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为指定队伍下发订单
|
||||||
|
* @param {string} groupBuyId - 队伍ID
|
||||||
|
* @returns {Promise} 返回下发结果
|
||||||
|
*/
|
||||||
|
export const setGroupBuyOrder = (groupBuyId) => {
|
||||||
|
return request.post("/api/v1/admin/activity/group_buy/set_order", { group_buy_id: groupBuyId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定队伍
|
||||||
|
* @param {string} groupBuyId - 队伍ID
|
||||||
|
* @returns {Promise} 返回删除结果
|
||||||
|
*/
|
||||||
|
export const removeGroupBuy = (groupBuyId) => {
|
||||||
|
return request.delete("/api/v1/admin/activity/group_buy/remove", { params: { group_buy_id: groupBuyId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有队伍
|
||||||
|
* @returns {Promise} 返回清除结果
|
||||||
|
*/
|
||||||
|
export const clearAllGroupBuy = () => {
|
||||||
|
return request.delete("/api/v1/admin/activity/group_buy/clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定用户的所有队伍
|
||||||
|
* @param {string} userId - 用户ID
|
||||||
|
* @returns {Promise} 返回清除结果
|
||||||
|
*/
|
||||||
|
export const clearUserGroupBuy = (userId) => {
|
||||||
|
return request.delete("/api/v1/admin/activity/group_buy/user_clear", { params: { user_id: userId } })
|
||||||
|
}
|
||||||
@@ -8,3 +8,13 @@ export const userLogin = (username,password) => {
|
|||||||
export const getUserInfo = () => {
|
export const getUserInfo = () => {
|
||||||
return request.get("/api/v1/users/info/info")
|
return request.get("/api/v1/users/info/info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取交换token(用于无感刷新)
|
||||||
|
export const getRefreshToken = (domain) => {
|
||||||
|
return request.get("/api/v1/users/info/refresh_token", { domain })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用交换token获取新的access token
|
||||||
|
export const refreshAccessToken = (refresh_token) => {
|
||||||
|
return request.post("/api/v1/user/refresh_token", { refresh_token })
|
||||||
|
}
|
||||||
+9
-2
@@ -5,8 +5,15 @@ import request from "@/utils/request.js";
|
|||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function getTickerList(count, page, status) {
|
export function getTickerList(count, page, status, orderBy, order, userId, keyword) {
|
||||||
return request.get('/api/v1/admin/work_order/list', { count, page, status })
|
const params = { count, page }
|
||||||
|
if (status !== undefined && status !== '') params.status = status
|
||||||
|
if (orderBy) params.orderBy = orderBy
|
||||||
|
if (order) params.order = order
|
||||||
|
if (userId) params.user_id = userId
|
||||||
|
if (keyword) params.keyword = keyword
|
||||||
|
console.log('工单列表请求参数:', params) // 调试日志
|
||||||
|
return request.get('/api/v1/admin/work_order/list', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 待处理
|
// 待处理
|
||||||
|
|||||||
@@ -2,65 +2,94 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="visible"
|
:model-value="visible"
|
||||||
title="选择用户"
|
title="选择用户"
|
||||||
width="800px"
|
width="700px"
|
||||||
class="user-selector-dialog"
|
class="user-selector-dialog"
|
||||||
append-to-body
|
append-to-body
|
||||||
@update:model-value="handleVisibleChange"
|
@update:model-value="handleVisibleChange"
|
||||||
>
|
>
|
||||||
<!-- 搜索栏 -->
|
<div class="user-selector-content">
|
||||||
<div class="selector-search">
|
<!-- 搜索栏 -->
|
||||||
<el-input
|
<div class="selector-search">
|
||||||
v-model="searchParams.key"
|
<el-input
|
||||||
placeholder="搜索用户名或ID"
|
v-model="searchParams.key"
|
||||||
clearable
|
placeholder="搜索用户名、邮箱或ID"
|
||||||
@keyup.enter="handleSearch"
|
clearable
|
||||||
style="width: 300px; margin-right: 12px"
|
@keyup.enter="handleSearch"
|
||||||
|
class="search-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button @click="handleReset" class="reset-btn">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="userList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
max-height="350"
|
||||||
|
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<el-table-column prop="user_id" label="用户ID" width="100" />
|
||||||
<el-icon><Search /></el-icon>
|
<el-table-column prop="user_name" label="用户名" min-width="130">
|
||||||
</template>
|
<template #default="{ row }">
|
||||||
</el-input>
|
<div class="user-name-cell">
|
||||||
<el-button type="primary" @click="handleSearch">
|
<el-avatar v-if="row.cover" :src="row.cover" :size="28" />
|
||||||
<el-icon><Search /></el-icon>
|
<el-avatar v-else :size="28">
|
||||||
搜索
|
{{ row.user_name?.charAt(0)?.toUpperCase() || 'U' }}
|
||||||
</el-button>
|
</el-avatar>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<span class="user-name">{{ row.user_name || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="email" label="邮箱" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-ellipsis">{{ row.email || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.disable" type="danger" size="small">禁用</el-tag>
|
||||||
|
<el-tag v-else type="success" size="small">正常</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
background
|
||||||
|
small
|
||||||
|
class="selector-pagination"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 用户表格 -->
|
|
||||||
<el-table
|
|
||||||
v-loading="loading"
|
|
||||||
:data="userList"
|
|
||||||
highlight-current-row
|
|
||||||
@current-change="handleCurrentChange"
|
|
||||||
style="width: 100%; margin-top: 16px"
|
|
||||||
:height="400"
|
|
||||||
>
|
|
||||||
<el-table-column type="index" label="序号" width="60" />
|
|
||||||
<el-table-column prop="UserId" label="用户ID" width="100" />
|
|
||||||
<el-table-column prop="UserName" label="用户名" min-width="150" />
|
|
||||||
<el-table-column prop="Email" label="邮箱" min-width="180" />
|
|
||||||
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="searchParams.page"
|
|
||||||
v-model:page-size="searchParams.count"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
:total="total"
|
|
||||||
@size-change="handleSizeChange"
|
|
||||||
@current-change="handlePageChange"
|
|
||||||
background
|
|
||||||
class="selector-pagination"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
|
<span v-if="selectedUser" class="selected-info">
|
||||||
|
已选择: <el-tag type="primary" size="small">{{ selectedUser.user_name }} (ID: {{ selectedUser.user_id }})</el-tag>
|
||||||
|
</span>
|
||||||
<el-button @click="closeDialog">取消</el-button>
|
<el-button @click="closeDialog">取消</el-button>
|
||||||
<el-button type="primary" @click="confirmSelection" :disabled="!selectedUser">
|
<el-button type="primary" @click="confirmSelection" :disabled="!selectedUser">
|
||||||
确定选择
|
确定
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -69,7 +98,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch } from 'vue'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import { getUserList } from '@/api/admin/user'
|
import { getUserList } from '@/api/admin/user'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
@@ -97,9 +126,7 @@ const searchParams = reactive({
|
|||||||
watch(() => props.visible, (newVal) => {
|
watch(() => props.visible, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
selectedUser.value = null
|
selectedUser.value = null
|
||||||
if (userList.value.length === 0) {
|
fetchUserList()
|
||||||
fetchUserList()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -163,11 +190,44 @@ const confirmSelection = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.user-selector-content {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.selector-search {
|
.selector-search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 0;
|
gap: 12px;
|
||||||
|
padding-bottom: 16px;
|
||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-pagination {
|
.selector-pagination {
|
||||||
@@ -175,6 +235,19 @@ const confirmSelection = () => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-info {
|
||||||
|
margin-right: auto;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-table__row) {
|
:deep(.el-table__row) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -184,8 +257,16 @@ const confirmSelection = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.current-row) {
|
:deep(.current-row) {
|
||||||
background-color: var(--el-color-primary-light-8) !important;
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.current-row td) {
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
font-weight: bold;
|
}
|
||||||
|
|
||||||
|
:deep(.el-avatar) {
|
||||||
|
background-color: var(--el-color-primary-light-5);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
title="选择头像"
|
:title="title"
|
||||||
width="800px"
|
width="800px"
|
||||||
append-to-body
|
append-to-body
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<div class="avatar-selector">
|
<div class="avatar-selector">
|
||||||
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||||
<!-- 用户文件列表 -->
|
<!-- 文件列表 -->
|
||||||
<el-tab-pane label="用户文件" name="userFiles">
|
<el-tab-pane label="文件" name="userFiles">
|
||||||
<div class="file-list-container">
|
<div class="file-list-container">
|
||||||
<div class="file-list-header">
|
<div class="file-list-header">
|
||||||
<h4>用户文件列表</h4>
|
<h4>文件列表</h4>
|
||||||
<el-button type="primary" @click="switchToUpload" :icon="Upload">
|
<el-button type="primary" @click="switchToUpload" :icon="Upload">
|
||||||
上传新头像
|
上传新文件
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-grid" v-loading="loading">
|
<div class="file-grid" v-loading="loading">
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 上传头像 -->
|
<!-- 上传文件 -->
|
||||||
<el-tab-pane label="上传头像" name="upload">
|
<el-tab-pane label="上传文件" name="upload">
|
||||||
<div class="upload-section">
|
<div class="upload-section">
|
||||||
<el-upload
|
<el-upload
|
||||||
:http-request="handleUpload"
|
:http-request="handleUpload"
|
||||||
@@ -118,6 +118,10 @@ import { closeAllMessage } from '../../utils/message'
|
|||||||
currentCoverId: {
|
currentCoverId: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '选择文件'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -270,6 +274,7 @@ import { closeAllMessage } from '../../utils/message'
|
|||||||
formData.append('files', file)
|
formData.append('files', file)
|
||||||
formData.append('file_names', file.name)
|
formData.append('file_names', file.name)
|
||||||
formData.append('update_type', 'cover')
|
formData.append('update_type', 'cover')
|
||||||
|
formData.append('open_down', 'true')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await uploadFile(formData)
|
const res = await uploadFile(formData)
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="选择优惠码"
|
||||||
|
width="900px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="discount-code-selector">
|
||||||
|
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||||
|
<!-- 选择优惠码 -->
|
||||||
|
<el-tab-pane label="选择优惠码" name="selectCode">
|
||||||
|
<div class="code-list-container">
|
||||||
|
<!-- 搜索筛选区域 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-form :inline="true" :model="searchParams" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchParams.key"
|
||||||
|
placeholder="搜索优惠码名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch" :icon="Search">
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset" :icon="Refresh">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 优惠码列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="codeList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="350"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="id" label="优惠码ID" width="100" align="center" />
|
||||||
|
<el-table-column prop="name" label="优惠码名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="code" label="优惠码" width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="success" effect="plain">{{ row.code }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="优惠类型" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.percentage > 0 ? 'warning' : 'primary'" size="small">
|
||||||
|
{{ row.percentage > 0 ? '折扣' : '固定金额' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="优惠值" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.percentage > 0" class="discount-value">{{ row.percentage }}%</span>
|
||||||
|
<span v-else class="discount-value">¥{{ (row.amount / 100).toFixed(2) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最低消费" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.minAmount">¥{{ (row.minAmount / 100).toFixed(2) }}</span>
|
||||||
|
<span v-else>无限制</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="使用次数" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.userTimes || 0 }} / {{ row.maxTimes || '∞' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="有效期" width="160" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="{ 'expired': isExpired(row.endTime) }">
|
||||||
|
{{ formatDate(row.endTime) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="codeList.length === 0 && !loading" description="暂无优惠码数据" />
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="!selectedCode"
|
||||||
|
>
|
||||||
|
确定选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getDiscountCodeList } from '@/api/admin/discount'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 当前已选中的优惠码ID(用于回显)
|
||||||
|
currentCodeId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 类型过滤:discount_code - 优惠码
|
||||||
|
codeType: {
|
||||||
|
type: String,
|
||||||
|
default: 'code'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false)
|
||||||
|
const activeTab = ref('selectCode')
|
||||||
|
const loading = ref(false)
|
||||||
|
const codeList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedCode = ref(null)
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
key: '',
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
if (newVal) {
|
||||||
|
// 重置状态
|
||||||
|
activeTab.value = 'selectCode'
|
||||||
|
selectedCode.value = null
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchCodeList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取优惠码列表
|
||||||
|
const fetchCodeList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
codeList.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: searchParams.page,
|
||||||
|
count: searchParams.count,
|
||||||
|
discount_type: props.codeType
|
||||||
|
}
|
||||||
|
if (searchParams.key) {
|
||||||
|
params.key = searchParams.key
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getDiscountCodeList(params)
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
codeList.value = res.data.data?.data || []
|
||||||
|
total.value = res.data.data?.all_count || 0
|
||||||
|
|
||||||
|
// 如果有当前选中的优惠码ID,自动选中
|
||||||
|
if (props.currentCodeId) {
|
||||||
|
const currentCode = codeList.value.find(
|
||||||
|
code => code.id === props.currentCodeId
|
||||||
|
)
|
||||||
|
if (currentCode) {
|
||||||
|
selectedCode.value = currentCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.msg || '获取优惠码列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取优惠码列表失败:', error)
|
||||||
|
ElMessage.error('获取优惠码列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签页切换
|
||||||
|
const handleTabClick = (tab) => {
|
||||||
|
if (tab.paneName === 'selectCode') {
|
||||||
|
fetchCodeList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchCodeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchCodeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
searchParams.count = size
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchCodeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
searchParams.page = page
|
||||||
|
fetchCodeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择优惠码
|
||||||
|
const handleCurrentChange = (row) => {
|
||||||
|
selectedCode.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行样式
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (selectedCode.value && row.id === selectedCode.value.id) {
|
||||||
|
return 'selected-row'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedCode.value = null
|
||||||
|
codeList.value = []
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否过期
|
||||||
|
const isExpired = (endTime) => {
|
||||||
|
if (!endTime) return false
|
||||||
|
return new Date(endTime) < new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedCode.value) {
|
||||||
|
emit('confirm', selectedCode.value)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('请选择一个优惠码')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.discount-code-selector {
|
||||||
|
min-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-list-container {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-value {
|
||||||
|
color: #e6a23c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expired {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
:deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row:hover) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row td) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr.current-row > td) {
|
||||||
|
background-color: var(--el-color-primary-light-8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页样式 */
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item.is-active) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择宿主机组" width="650px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索宿主机组名称" clearable style="width:200px" @keyup.enter="handleSearch" @clear="handleSearch" />
|
||||||
|
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="filteredList" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.note || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="serviceId" label="服务ID" width="80" />
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper" v-if="total > pageSize">
|
||||||
|
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getHostGroupList } from '@/api/admin/kvmService'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
serviceId: { type: Number, default: 0 },
|
||||||
|
currentId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
const keyword = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 10
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const filteredList = computed(() => {
|
||||||
|
if (!keyword.value) return list.value
|
||||||
|
const kw = keyword.value.toLowerCase()
|
||||||
|
return list.value.filter(i => (i.name || '').toLowerCase().includes(kw))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) { page.value = 1; loadList() }
|
||||||
|
})
|
||||||
|
watch(visible, (val) => emit('update:modelValue', val))
|
||||||
|
|
||||||
|
const handleSearch = () => { page.value = 1; loadList() }
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getHostGroupList({ service_id: props.serviceId, page: page.value, count: pageSize })
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
|
||||||
|
list.value = items.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
name: i.name ?? i.Name,
|
||||||
|
note: i.note ?? i.Note,
|
||||||
|
serviceId: i.serviceId ?? i.service_id ?? 0,
|
||||||
|
serviceHostGroupId: i.serviceHostGroupId ?? 0
|
||||||
|
}))
|
||||||
|
total.value = body.data.total ?? body.data.all_count ?? list.value.length
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
|
||||||
|
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedItem.value) {
|
||||||
|
emit('confirm', selectedItem.value)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleClose = () => { selectedItem.value = null }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-container { min-height: 200px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
:deep(.current-row) { background-color: #ecf5ff !important; }
|
||||||
|
:deep(.el-table__body tr) { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择宿主机" width="700px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索宿主机名称/IP" clearable style="width:200px" @keyup.enter="loadList" @clear="loadList" />
|
||||||
|
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="filteredList" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="ip" label="IP" min-width="130" />
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '在线' : '离线' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper" v-if="total > pageSize">
|
||||||
|
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getRemoteHostList } from '@/api/admin/kvmService'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
serviceId: { type: Number, default: 0 },
|
||||||
|
hostGroupId: { type: Number, default: 0 },
|
||||||
|
currentId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
const keyword = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 10
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const filteredList = computed(() => {
|
||||||
|
if (!keyword.value) return list.value
|
||||||
|
const kw = keyword.value.toLowerCase()
|
||||||
|
return list.value.filter(i => (i.name || '').toLowerCase().includes(kw) || (i.ip || '').includes(kw))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) { page.value = 1; loadList() }
|
||||||
|
})
|
||||||
|
watch(visible, (val) => emit('update:modelValue', val))
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
|
||||||
|
if (props.hostGroupId) params.host_group_id = props.hostGroupId
|
||||||
|
const res = await getRemoteHostList(params)
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const inner = body.data
|
||||||
|
const hosts = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||||
|
list.value = hosts.map(i => ({
|
||||||
|
id: i.id, name: i.name, ip: i.ip, is_active: i.is_active ?? true,
|
||||||
|
host_group_id: i.host_group_id
|
||||||
|
}))
|
||||||
|
total.value = inner.total ?? list.value.length
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
|
||||||
|
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedItem.value) { emit('confirm', selectedItem.value); visible.value = false }
|
||||||
|
}
|
||||||
|
const handleClose = () => { selectedItem.value = null }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-container { min-height: 200px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
:deep(.current-row) { background-color: #ecf5ff !important; }
|
||||||
|
:deep(.el-table__body tr) { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div class="icon-selector">
|
||||||
|
<el-input
|
||||||
|
:model-value="modelValue"
|
||||||
|
placeholder="点击选择图标"
|
||||||
|
readonly
|
||||||
|
@click="popoverVisible = true"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon v-if="modelValue" :size="18">
|
||||||
|
<component :is="modelValue" />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<el-icon v-if="modelValue" class="clear-btn" @click.stop="handleClear"><CircleClose /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-dialog v-model="popoverVisible" title="选择图标" width="680px" append-to-body>
|
||||||
|
<el-input v-model="searchKey" placeholder="搜索图标名称" clearable class="icon-search">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div class="icon-grid">
|
||||||
|
<div
|
||||||
|
v-for="name in filteredIcons"
|
||||||
|
:key="name"
|
||||||
|
class="icon-item"
|
||||||
|
:class="{ active: modelValue === name }"
|
||||||
|
@click="handleSelect(name)"
|
||||||
|
>
|
||||||
|
<el-icon :size="22"><component :is="name" /></el-icon>
|
||||||
|
<span class="icon-name">{{ name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredIcons.length === 0" class="icon-empty">
|
||||||
|
未找到匹配的图标
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import { Search, CircleClose } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const popoverVisible = ref(false)
|
||||||
|
const searchKey = ref('')
|
||||||
|
|
||||||
|
const allIcons = Object.keys(ElementPlusIconsVue).sort()
|
||||||
|
|
||||||
|
const filteredIcons = computed(() => {
|
||||||
|
if (!searchKey.value) return allIcons
|
||||||
|
const key = searchKey.value.toLowerCase()
|
||||||
|
return allIcons.filter(name => name.toLowerCase().includes(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelect = (name) => {
|
||||||
|
emit('update:modelValue', name)
|
||||||
|
popoverVisible.value = false
|
||||||
|
searchKey.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-selector { width: 100%; }
|
||||||
|
.icon-search { margin-bottom: 12px; }
|
||||||
|
.icon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.icon-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.icon-item:hover {
|
||||||
|
border-color: #409eff;
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.icon-item.active {
|
||||||
|
border-color: #409eff;
|
||||||
|
background: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.icon-name {
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.icon-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #909399;
|
||||||
|
padding: 40px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.clear-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #c0c4cc;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.clear-btn:hover { color: #f56c6c; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="选择图片"
|
||||||
|
width="900px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="image-selector">
|
||||||
|
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||||
|
<!-- 文件库 -->
|
||||||
|
<el-tab-pane label="文件库" name="fileLibrary">
|
||||||
|
<div class="file-list-container">
|
||||||
|
<div class="file-list-header">
|
||||||
|
<h4>图片文件库</h4>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span v-if="props.multiple && selectedIds.size > 0" class="selected-count">
|
||||||
|
已选 {{ selectedIds.size }} 个文件
|
||||||
|
</span>
|
||||||
|
<el-button type="primary" @click="switchToUpload" :icon="Upload">
|
||||||
|
上传新图片
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索过滤 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索文件名"
|
||||||
|
:prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
@input="handleSearch"
|
||||||
|
style="width: 300px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-grid" v-loading="loading">
|
||||||
|
<div
|
||||||
|
v-for="file in filteredFileList"
|
||||||
|
:key="file.id"
|
||||||
|
class="file-item"
|
||||||
|
:class="{ 'selected': props.multiple ? selectedIds.has(file.id) : selectedId === file.id }"
|
||||||
|
@click="selectFile(file)"
|
||||||
|
>
|
||||||
|
<div class="file-check-badge" v-if="props.multiple && selectedIds.has(file.id)">
|
||||||
|
<el-icon><Select /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="file-preview">
|
||||||
|
<img
|
||||||
|
:src="processImageUrl(file.url)"
|
||||||
|
:alt="file.realName"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<p class="file-name" :title="file.realName">{{ file.realName }}</p>
|
||||||
|
<p class="file-size">{{ formatFileSize(file.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="filteredFileList.length === 0 && !loading" description="暂无图片文件" />
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[12, 24, 36, 48]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 上传图片 -->
|
||||||
|
<el-tab-pane label="上传图片" name="upload">
|
||||||
|
<div class="upload-section">
|
||||||
|
<el-upload
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="false"
|
||||||
|
:on-change="handleFileChange"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||||
|
<div class="el-upload__text">
|
||||||
|
将文件拖到此处,或<em>点击上传</em>
|
||||||
|
</div>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
支持jpg、png、gif、webp等图片格式,单个文件不超过5MB
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<!-- 待上传文件列表 -->
|
||||||
|
<div v-if="pendingFiles.length > 0" class="pending-files">
|
||||||
|
<div class="pending-header">
|
||||||
|
<h4>待上传文件 ({{ pendingFiles.length }})</h4>
|
||||||
|
<el-button type="danger" link @click="pendingFiles = []">清空</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="pending-list">
|
||||||
|
<div v-for="(file, index) in pendingFiles" :key="index" class="pending-item">
|
||||||
|
<img :src="file.previewUrl" class="pending-preview" />
|
||||||
|
<span class="pending-name" :title="file.name">{{ file.name }}</span>
|
||||||
|
<span class="pending-size">{{ formatFileSize(file.size) }}</span>
|
||||||
|
<el-button type="danger" link size="small" @click="removePendingFile(index)">移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleBatchUpload"
|
||||||
|
:loading="uploading"
|
||||||
|
style="margin-top: 16px; width: 100%;"
|
||||||
|
>
|
||||||
|
开始上传 ({{ pendingFiles.length }} 个文件)
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="props.multiple ? selectedIds.size === 0 : !selectedId"
|
||||||
|
>
|
||||||
|
确定选择{{ props.multiple && selectedIds.size > 0 ? ` (${selectedIds.size})` : '' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Upload, UploadFilled, Search, Select, Delete } from '@element-plus/icons-vue'
|
||||||
|
import { getFileList, getFileDetail, uploadFile } from '@/api/admin/file'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
currentFileId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false)
|
||||||
|
const activeTab = ref('fileLibrary')
|
||||||
|
const fileList = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedId = ref('')
|
||||||
|
const selectedIds = ref(new Set()) // 多选模式下选中的文件ID集合
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(12)
|
||||||
|
const total = ref(0)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const pendingFiles = ref([]) // 待上传文件列表
|
||||||
|
const uploading = ref(false) // 批量上传中
|
||||||
|
let fetchVersion = 0 // 防止 fetchFileList 竞态条件
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
if (newVal) {
|
||||||
|
selectedId.value = props.currentFileId
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
currentPage.value = 1
|
||||||
|
searchKeyword.value = ''
|
||||||
|
fetchFileList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤后的文件列表
|
||||||
|
const filteredFileList = computed(() => {
|
||||||
|
if (!searchKeyword.value) {
|
||||||
|
return fileList.value
|
||||||
|
}
|
||||||
|
return fileList.value.filter(file =>
|
||||||
|
file.realName?.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理图片URL,确保正确显示
|
||||||
|
const processImageUrl = (url) => {
|
||||||
|
if (!url) return ''
|
||||||
|
// 先处理转义字符:将 \u0026 替换为 &
|
||||||
|
let processedUrl = url.replace(/\\u0026/g, '&')
|
||||||
|
// 再进行URL解码
|
||||||
|
return decodeURIComponent(processedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件列表(带版本号防止竞态条件)
|
||||||
|
const fetchFileList = async () => {
|
||||||
|
const currentFetchVersion = ++fetchVersion
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getFileList({
|
||||||
|
page: currentPage.value,
|
||||||
|
count: pageSize.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果有更新的请求发起,丢弃当前结果
|
||||||
|
if (currentFetchVersion !== fetchVersion) return
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
const list = res.data.data.list || []
|
||||||
|
total.value = res.data.data.all_count || 0
|
||||||
|
|
||||||
|
// 并行获取所有文件详情(替代逐个串行,大幅提升速度)
|
||||||
|
const detailPromises = list.map(item =>
|
||||||
|
getFileDetail({ file_id: item.id })
|
||||||
|
.then(res2 => {
|
||||||
|
if (res2.data.code === 200) {
|
||||||
|
return {
|
||||||
|
id: res2.data.data.data.id,
|
||||||
|
url: res2.data.data.url,
|
||||||
|
size: res2.data.data.data.size,
|
||||||
|
realName: res2.data.data.data.realName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('获取文件详情失败:', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const results = await Promise.all(detailPromises)
|
||||||
|
|
||||||
|
// 再次检查版本号,防止旧结果覆盖新结果
|
||||||
|
if (currentFetchVersion !== fetchVersion) return
|
||||||
|
|
||||||
|
fileList.value = results.filter(item => item !== null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (currentFetchVersion === fetchVersion) {
|
||||||
|
console.error('获取文件列表失败:', error)
|
||||||
|
ElMessage.error('获取文件列表失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentFetchVersion === fetchVersion) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签页切换
|
||||||
|
const handleTabClick = (tab) => {
|
||||||
|
if (tab.name === 'fileLibrary') {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchFileList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
// 搜索时重置到第一页
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchFileList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchFileList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到上传标签页
|
||||||
|
const switchToUpload = () => {
|
||||||
|
activeTab.value = 'upload'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatFileSize = (size) => {
|
||||||
|
if (!size) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let unitIndex = 0
|
||||||
|
let fileSize = size
|
||||||
|
|
||||||
|
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
fileSize /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择文件
|
||||||
|
const selectFile = (file) => {
|
||||||
|
if (props.multiple) {
|
||||||
|
// 多选模式:切换选中状态
|
||||||
|
const newSet = new Set(selectedIds.value)
|
||||||
|
if (newSet.has(file.id)) {
|
||||||
|
newSet.delete(file.id)
|
||||||
|
} else {
|
||||||
|
newSet.add(file.id)
|
||||||
|
}
|
||||||
|
selectedIds.value = newSet
|
||||||
|
} else {
|
||||||
|
selectedId.value = file.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件选择变化(收集待上传文件)
|
||||||
|
const handleFileChange = (file) => {
|
||||||
|
const rawFile = file.raw
|
||||||
|
if (!rawFile) return
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
const isImage = rawFile.type.startsWith('image/')
|
||||||
|
if (!isImage) {
|
||||||
|
ElMessage.error(`${rawFile.name} 不是图片文件,已跳过`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小
|
||||||
|
const isLt5M = rawFile.size / 1024 / 1024 < 5
|
||||||
|
if (!isLt5M) {
|
||||||
|
ElMessage.error(`${rawFile.name} 超过 5MB,已跳过`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否重复添加
|
||||||
|
const exists = pendingFiles.value.some(f => f.name === rawFile.name && f.size === rawFile.size)
|
||||||
|
if (exists) return
|
||||||
|
|
||||||
|
// 添加到待上传列表,生成本地预览URL
|
||||||
|
pendingFiles.value.push({
|
||||||
|
raw: rawFile,
|
||||||
|
name: rawFile.name,
|
||||||
|
size: rawFile.size,
|
||||||
|
previewUrl: URL.createObjectURL(rawFile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除待上传文件
|
||||||
|
const removePendingFile = (index) => {
|
||||||
|
const file = pendingFiles.value[index]
|
||||||
|
if (file?.previewUrl) {
|
||||||
|
URL.revokeObjectURL(file.previewUrl)
|
||||||
|
}
|
||||||
|
pendingFiles.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量上传(所有文件合并为一次请求,多个 file_names 和 files 条目)
|
||||||
|
const handleBatchUpload = async () => {
|
||||||
|
if (pendingFiles.value.length === 0) {
|
||||||
|
ElMessage.warning('请先选择要上传的文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
pendingFiles.value.forEach(file => {
|
||||||
|
formData.append('file_names', file.name)
|
||||||
|
formData.append('files', file.raw)
|
||||||
|
})
|
||||||
|
formData.append('update_type', 'cover')
|
||||||
|
formData.append('open_down', 'true')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(formData)
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
const count = pendingFiles.value.length
|
||||||
|
// 释放所有预览URL
|
||||||
|
pendingFiles.value.forEach(f => {
|
||||||
|
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
|
||||||
|
})
|
||||||
|
pendingFiles.value = []
|
||||||
|
ElMessage.success(`成功上传 ${count} 个文件`)
|
||||||
|
|
||||||
|
// 刷新文件列表并切换到文件库
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetchFileList()
|
||||||
|
activeTab.value = 'fileLibrary'
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.msg || '上传失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量上传失败:', error)
|
||||||
|
ElMessage.error('上传失败,请重试')
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片加载错误处理
|
||||||
|
const handleImageError = (event) => {
|
||||||
|
event.target.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedId.value = ''
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
fileList.value = []
|
||||||
|
currentPage.value = 1
|
||||||
|
total.value = 0
|
||||||
|
searchKeyword.value = ''
|
||||||
|
// 清理待上传文件的预览URL
|
||||||
|
pendingFiles.value.forEach(f => {
|
||||||
|
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
|
||||||
|
})
|
||||||
|
pendingFiles.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (props.multiple) {
|
||||||
|
// 多选模式:返回选中的文件数组
|
||||||
|
if (selectedIds.value.size === 0) return
|
||||||
|
const selectedFiles = fileList.value
|
||||||
|
.filter(file => selectedIds.value.has(file.id))
|
||||||
|
.map(file => ({
|
||||||
|
id: file.id,
|
||||||
|
url: file.url || '',
|
||||||
|
realName: file.realName || ''
|
||||||
|
}))
|
||||||
|
emit('confirm', selectedFiles)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
// 单选模式:返回单个文件对象
|
||||||
|
if (selectedId.value) {
|
||||||
|
const selectedFile = fileList.value.find(file => file.id === selectedId.value)
|
||||||
|
emit('confirm', {
|
||||||
|
id: selectedId.value,
|
||||||
|
url: selectedFile?.url || '',
|
||||||
|
realName: selectedFile?.realName || ''
|
||||||
|
})
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-selector {
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-container {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
max-height: 450px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
border: 2px solid #e4e7ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
border-color: #409EFF;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.selected {
|
||||||
|
border-color: #409EFF;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-check-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background-color: #409EFF;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 2px 4px rgba(64, 158, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-count {
|
||||||
|
color: #409EFF;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 待上传文件列表 */
|
||||||
|
.pending-files {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item:hover {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-preview {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #303133;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择镜像" width="700px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 120px" @change="handleSearch">
|
||||||
|
<el-option label="Linux" value="linux" />
|
||||||
|
<el-option label="Windows" value="windows" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="系统" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ row.os_type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper" v-if="total > pageSize">
|
||||||
|
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getImageList } from '@/api/admin/kvmService'
|
||||||
|
import { getUserVmHostImages, getGoodHostGroupImages } from '@/api/admin/userVm'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
serviceId: { type: Number, default: 0 },
|
||||||
|
goodId: { type: Number, default: 0 },
|
||||||
|
currentId: { type: Number, default: 0 },
|
||||||
|
useUserVmApi: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
const keyword = ref('')
|
||||||
|
const filterOsType = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 10
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) { page.value = 1; loadList() }
|
||||||
|
})
|
||||||
|
watch(visible, (val) => emit('update:modelValue', val))
|
||||||
|
|
||||||
|
const handleSearch = () => { page.value = 1; loadList() }
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
let res
|
||||||
|
if (props.goodId > 0) {
|
||||||
|
const params = { good_id: props.goodId, page: page.value, count: pageSize }
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
if (filterOsType.value) params.os_type = filterOsType.value
|
||||||
|
res = await getGoodHostGroupImages(params)
|
||||||
|
} else if (props.useUserVmApi) {
|
||||||
|
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
if (filterOsType.value) params.os_type = filterOsType.value
|
||||||
|
res = await getUserVmHostImages(params)
|
||||||
|
} else {
|
||||||
|
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
if (filterOsType.value) params.os_type = filterOsType.value
|
||||||
|
res = await getImageList(params)
|
||||||
|
}
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const inner = body.data
|
||||||
|
let items = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||||
|
if (props.useUserVmApi || props.goodId > 0) {
|
||||||
|
items = items.map(item => item.image || item).filter(Boolean)
|
||||||
|
}
|
||||||
|
list.value = items
|
||||||
|
total.value = inner.total ?? inner.all_count ?? list.value.length
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
|
||||||
|
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedItem.value) {
|
||||||
|
emit('confirm', selectedItem.value)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleClose = () => { selectedItem.value = null }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-container { min-height: 200px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
:deep(.current-row) { background-color: #ecf5ff !important; }
|
||||||
|
:deep(.el-table__body tr) { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择主控服务" width="640px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-toolbar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索服务名称/地址" clearable style="width:220px"
|
||||||
|
@keyup.enter="handleSearch" @clear="handleSearch">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="handleRefresh" :loading="loading">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" highlight-current-row
|
||||||
|
@current-change="row => selected = row" :height="320" stripe size="small">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="name" label="服务名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="地址" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-family:monospace;color:#409eff">{{ row.host }}:{{ row.port }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.note || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无主控服务" />
|
||||||
|
<div class="selector-footer-bar">
|
||||||
|
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
|
||||||
|
layout="total,sizes,prev,pager,next" small background
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||||
|
@current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getKvmServiceList } from '@/api/admin/kvmService'
|
||||||
|
|
||||||
|
const props = defineProps({ modelValue: { type: Boolean, default: false } })
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const selected = ref(null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
|
||||||
|
watch(visible, (v) => emit('update:modelValue', v))
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { page: page.value, count: pageSize.value }
|
||||||
|
if (keyword.value) params.key = keyword.value
|
||||||
|
const res = await getKvmServiceList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const inner = res.data.data
|
||||||
|
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||||
|
list.value = raw.map(s => ({ id: s.id ?? s.Id, name: s.name ?? s.Name, host: s.host ?? s.Host, port: s.port ?? s.Port, note: s.note ?? s.Note }))
|
||||||
|
total.value = inner.all_count ?? inner.total ?? list.value.length
|
||||||
|
}
|
||||||
|
} catch { /* */ } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => { page.value = 1; loadList() }
|
||||||
|
const handleRefresh = () => { keyword.value = ''; page.value = 1; loadList() }
|
||||||
|
const handleClose = () => { visible.value = false }
|
||||||
|
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="menu-path-selector">
|
||||||
|
<el-input
|
||||||
|
:model-value="modelValue"
|
||||||
|
placeholder="点击从菜单中选择路径,或手动输入"
|
||||||
|
clearable
|
||||||
|
@input="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="dialogVisible = true">
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" title="选择菜单路径" width="550px" append-to-body>
|
||||||
|
<el-input v-model="searchKey" placeholder="搜索菜单名称或路径" clearable class="path-search">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div class="menu-tree">
|
||||||
|
<el-tree
|
||||||
|
:data="filteredMenuTree"
|
||||||
|
:props="{ label: 'label', children: 'children' }"
|
||||||
|
node-key="path"
|
||||||
|
:default-expand-all="!!searchKey"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
highlight-current
|
||||||
|
@node-click="handleNodeClick"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="tree-node" :class="{ 'is-selected': modelValue === data.path, 'no-path': !data.path }">
|
||||||
|
<el-icon v-if="data.icon" :size="16" style="margin-right: 6px; flex-shrink: 0;">
|
||||||
|
<component :is="data.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span class="node-title">{{ data.title }}</span>
|
||||||
|
<el-tag v-if="data.path" size="small" type="info" class="node-path">{{ data.path }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredMenuTree.length === 0" class="tree-empty">
|
||||||
|
未找到匹配的菜单
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Search, FolderOpened } from '@element-plus/icons-vue'
|
||||||
|
import { menus } from '@/config/menus'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const searchKey = ref('')
|
||||||
|
|
||||||
|
const buildTreeData = (menuList) => {
|
||||||
|
return menuList.map(item => {
|
||||||
|
const node = {
|
||||||
|
path: item.path || '',
|
||||||
|
title: item.title,
|
||||||
|
icon: item.icon || '',
|
||||||
|
label: item.title
|
||||||
|
}
|
||||||
|
if (item.children?.length) {
|
||||||
|
node.children = buildTreeData(item.children)
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuTree = computed(() => buildTreeData(menus))
|
||||||
|
|
||||||
|
const filterTree = (nodes, keyword) => {
|
||||||
|
const key = keyword.toLowerCase()
|
||||||
|
const result = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
const titleMatch = node.title?.toLowerCase().includes(key)
|
||||||
|
const pathMatch = node.path?.toLowerCase().includes(key)
|
||||||
|
let filteredChildren = []
|
||||||
|
if (node.children?.length) {
|
||||||
|
filteredChildren = filterTree(node.children, keyword)
|
||||||
|
}
|
||||||
|
if (titleMatch || pathMatch || filteredChildren.length > 0) {
|
||||||
|
result.push({
|
||||||
|
...node,
|
||||||
|
children: filteredChildren.length > 0 ? filteredChildren : node.children
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMenuTree = computed(() => {
|
||||||
|
if (!searchKey.value) return menuTree.value
|
||||||
|
return filterTree(menuTree.value, searchKey.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleNodeClick = (data) => {
|
||||||
|
if (!data.path) return
|
||||||
|
emit('update:modelValue', data.path)
|
||||||
|
dialogVisible.value = false
|
||||||
|
searchKey.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-path-selector { width: 100%; }
|
||||||
|
.path-search { margin-bottom: 12px; }
|
||||||
|
.menu-tree {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 4px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.tree-node.is-selected {
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.tree-node.no-path {
|
||||||
|
color: #909399;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.node-title { margin-right: 8px; font-size: 13px; }
|
||||||
|
.node-path { flex-shrink: 0; }
|
||||||
|
.tree-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #909399;
|
||||||
|
padding: 40px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
:deep(.el-tree-node__content) { height: 36px; }
|
||||||
|
:deep(.el-tree-node__content:hover) { background-color: #f5f7fa; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择网络" width="800px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-select v-if="!filterType" v-model="typeFilter" placeholder="网络类型" clearable style="width: 130px" @change="handleSearch">
|
||||||
|
<el-option label="网桥(Bridge)" value="bridge" />
|
||||||
|
<el-option label="内网(NAT)" value="nat" />
|
||||||
|
</el-select>
|
||||||
|
<el-tag v-else type="success" size="small">仅{{ filterType === 'bridge' ? '网桥' : filterType === 'nat' ? '内网' : filterType }}</el-tag>
|
||||||
|
<el-select v-if="!filterUsed" v-model="usedFilter" placeholder="占用状态" clearable style="width: 130px" @change="handleSearch">
|
||||||
|
<el-option label="未占用" value="false" />
|
||||||
|
<el-option label="已占用" value="true" />
|
||||||
|
</el-select>
|
||||||
|
<el-tag v-else :type="filterUsed === 'false' ? 'success' : 'info'" size="small">{{ filterUsed === 'false' ? '仅未占用' : '仅已占用' }}</el-tag>
|
||||||
|
<el-select v-model="ipVersionFilter" placeholder="IP版本" clearable style="width: 110px" @change="handleSearch">
|
||||||
|
<el-option label="IPv4" value="ipv4" />
|
||||||
|
<el-option label="IPv6" value="ipv6" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Refresh" @click="loadList" circle />
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
|
||||||
|
:height="340" :row-class-name="rowClassName" size="small" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column label="类型" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">
|
||||||
|
{{ row.type === 'bridge' ? '网桥' : 'NAT' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="gateway" label="网关" width="130" />
|
||||||
|
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="bridge_name" label="网桥名称" width="100" />
|
||||||
|
<el-table-column label="状态" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag>
|
||||||
|
<el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">-</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper" v-if="total > 0">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||||
|
@current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div style="display: flex; justify-content: space-between; width: 100%">
|
||||||
|
<el-button type="success" @click="handleCreate">创建网络</el-button>
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getNetworkList } from '@/api/admin/kvmService'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
serviceId: { type: Number, default: 0 },
|
||||||
|
hostId: { type: Number, default: 0 },
|
||||||
|
filterType: { type: String, default: '' },
|
||||||
|
filterUsed: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const typeFilter = ref('')
|
||||||
|
const usedFilter = ref('')
|
||||||
|
const ipVersionFilter = ref('')
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
const type = ref('bridge')
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) {
|
||||||
|
page.value = 1
|
||||||
|
keyword.value = ''
|
||||||
|
typeFilter.value = props.filterType || ''
|
||||||
|
usedFilter.value = props.filterUsed || ''
|
||||||
|
ipVersionFilter.value = ''
|
||||||
|
selectedItem.value = null
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(visible, (val) => emit('update:modelValue', val))
|
||||||
|
|
||||||
|
const handleSearch = () => { page.value = 1; loadList() }
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
if (!props.serviceId || !props.hostId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value }
|
||||||
|
const effectiveType = props.filterType || typeFilter.value || type.value
|
||||||
|
if (effectiveType) params.type = effectiveType
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
const effectiveUsed = props.filterUsed || usedFilter.value
|
||||||
|
if (effectiveUsed) params.used = effectiveUsed
|
||||||
|
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
|
||||||
|
const res = await getNetworkList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const inner = res.data.data
|
||||||
|
const items = inner.data || inner.networks || (Array.isArray(inner) ? inner : [])
|
||||||
|
list.value = items.map(item => ({
|
||||||
|
...item,
|
||||||
|
_used: item.used !== undefined ? item.used
|
||||||
|
: effectiveUsed === 'true' ? true
|
||||||
|
: effectiveUsed === 'false' ? false
|
||||||
|
: null
|
||||||
|
}))
|
||||||
|
total.value = inner.meta?.count ?? inner.total ?? list.value.length
|
||||||
|
} else { list.value = []; total.value = 0 }
|
||||||
|
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
|
||||||
|
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedItem.value) {
|
||||||
|
emit('confirm', selectedItem.value)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleClose = () => { selectedItem.value = null }
|
||||||
|
const handleCreate = () => {
|
||||||
|
emit('create')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ loadList })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-container { min-height: 200px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||||
|
:deep(.selected-row) { background-color: #ecf5ff !important; }
|
||||||
|
:deep(.el-table__body tr) { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择订单" width="800px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-toolbar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索订单名称/ID" clearable style="width:220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="handleRefresh" :loading="loading">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" highlight-current-row @current-change="row => selected = row" :height="360" stripe size="small">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="订单名称" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="价格" width="100">
|
||||||
|
<template #default="{ row }">¥{{ ((row.price || 0) / 100).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.state === 1 ? 'success' : row.state === 0 ? 'warning' : 'info'" size="small">
|
||||||
|
{{ row.state === 1 ? '已支付' : row.state === 0 ? '待支付' : '已失效' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="到期时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.expireTime || row.expire_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无订单" />
|
||||||
|
<div class="selector-selected" v-if="selected">
|
||||||
|
<el-tag type="primary" size="large" closable @close="selected = null">已选:{{ selected.name }} (ID: {{ selected.id }})</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="selector-footer-bar">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
|
||||||
|
layout="total,sizes,prev,pager,next" small background
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }" @current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getOrderList } from '@/api/admin/order'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const props = defineProps({ modelValue: { type: Boolean, default: false } })
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const selected = ref(null)
|
||||||
|
|
||||||
|
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '-'
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
|
||||||
|
watch(visible, (v) => emit('update:modelValue', v))
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { page: page.value, count: pageSize.value }
|
||||||
|
if (keyword.value) params.key = keyword.value
|
||||||
|
const res = await getOrderList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const d = res.data.data
|
||||||
|
list.value = d.list || d.data || (Array.isArray(d) ? d : [])
|
||||||
|
total.value = d.all_count ?? d.total ?? list.value.length
|
||||||
|
}
|
||||||
|
} catch { /* */ } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => { page.value = 1; loadList() }
|
||||||
|
const handleRefresh = () => { keyword.value = ''; page.value = 1; loadList() }
|
||||||
|
const handleClose = () => { visible.value = false }
|
||||||
|
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.selector-selected { margin-top: 12px; }
|
||||||
|
.selector-footer-bar { display: flex; justify-content: flex-end; align-items: center; margin-top: 10px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="选择路径权限"
|
||||||
|
width="900px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="permission-selector">
|
||||||
|
<!-- 搜索筛选区域 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-form :inline="true" :model="searchParams" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchParams.key"
|
||||||
|
placeholder="搜索路径或名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="请求方法">
|
||||||
|
<el-select
|
||||||
|
v-model="searchParams.method"
|
||||||
|
placeholder="全部方法"
|
||||||
|
clearable
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<el-option label="GET" value="GET" />
|
||||||
|
<el-option label="POST" value="POST" />
|
||||||
|
<el-option label="PUT" value="PUT" />
|
||||||
|
<el-option label="DELETE" value="DELETE" />
|
||||||
|
<el-option label="PATCH" value="PATCH" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch" :icon="Search">
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset" :icon="Refresh">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 权限列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="filteredList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="400"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
|
<el-table-column prop="method" label="方法" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.method" :type="getMethodTag(row.method)" size="small">
|
||||||
|
{{ row.method }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="path" label="路径" min-width="250" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已选信息 -->
|
||||||
|
<div class="selected-info" v-if="selectedPermission">
|
||||||
|
<el-alert type="success" :closable="false">
|
||||||
|
<template #title>
|
||||||
|
<div class="selected-content">
|
||||||
|
<span>已选择: </span>
|
||||||
|
<el-tag v-if="selectedPermission.method" :type="getMethodTag(selectedPermission.method)" size="small" style="margin-right: 8px;">
|
||||||
|
{{ selectedPermission.method }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="selected-path">{{ selectedPermission.path }}</span>
|
||||||
|
<span class="selected-name" v-if="selectedPermission.name"> - {{ selectedPermission.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleConfirm" :disabled="!selectedPermission">
|
||||||
|
确认选择
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getPermissionList } from '@/api/admin/Permission'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
currentPermissionId: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
key: '',
|
||||||
|
method: '',
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const permissionList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedPermission = ref(null)
|
||||||
|
|
||||||
|
// 过滤后的列表
|
||||||
|
const filteredList = computed(() => {
|
||||||
|
let list = permissionList.value
|
||||||
|
|
||||||
|
// 关键词过滤
|
||||||
|
if (searchParams.key) {
|
||||||
|
const keyword = searchParams.key.toLowerCase()
|
||||||
|
list = list.filter(item =>
|
||||||
|
(item.path && item.path.toLowerCase().includes(keyword)) ||
|
||||||
|
(item.name && item.name.toLowerCase().includes(keyword)) ||
|
||||||
|
(item.note && item.note.toLowerCase().includes(keyword))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法过滤
|
||||||
|
if (searchParams.method) {
|
||||||
|
list = list.filter(item => item.method === searchParams.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取方法标签颜色
|
||||||
|
const getMethodTag = (method) => {
|
||||||
|
const tagMap = {
|
||||||
|
'GET': 'success',
|
||||||
|
'POST': 'primary',
|
||||||
|
'PUT': 'warning',
|
||||||
|
'DELETE': 'danger',
|
||||||
|
'PATCH': 'info'
|
||||||
|
}
|
||||||
|
return tagMap[method?.toUpperCase()] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行样式
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (selectedPermission.value && row.id === selectedPermission.value.id) {
|
||||||
|
return 'selected-row'
|
||||||
|
}
|
||||||
|
if (props.currentPermissionId && row.id === props.currentPermissionId) {
|
||||||
|
return 'current-row'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取权限列表
|
||||||
|
const fetchPermissionList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getPermissionList({
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
permissionList.value = res.data.data?.list || []
|
||||||
|
total.value = permissionList.value.length
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '获取权限列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取权限列表失败:', error)
|
||||||
|
ElMessage.error('获取权限列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchParams.page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.method = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
searchParams.count = size
|
||||||
|
searchParams.page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
searchParams.page = page
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择行
|
||||||
|
const handleCurrentChange = (row) => {
|
||||||
|
selectedPermission.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedPermission.value) {
|
||||||
|
emit('confirm', selectedPermission.value)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭弹窗
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedPermission.value = null
|
||||||
|
handleReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听弹窗打开
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val) {
|
||||||
|
fetchPermissionList()
|
||||||
|
// 如果有当前选中的ID,尝试预选
|
||||||
|
if (props.currentPermissionId) {
|
||||||
|
const found = permissionList.value.find(p => p.id === props.currentPermissionId)
|
||||||
|
if (found) {
|
||||||
|
selectedPermission.value = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.permission-selector {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafbfc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-info {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-path {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-name {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .selected-row) {
|
||||||
|
background-color: #ecf5ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .current-row) {
|
||||||
|
background-color: #f0f9eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .selected-row td),
|
||||||
|
:deep(.el-table .current-row td) {
|
||||||
|
background-color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
width: 95% !important;
|
||||||
|
margin: 2vh auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form .el-form-item {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form .el-input,
|
||||||
|
.search-form .el-select {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择套餐" width="700px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-toolbar">
|
||||||
|
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建套餐</el-button>
|
||||||
|
<span style="color:#909399;font-size:13px" v-if="goodId">商品 ID: {{ goodId }}</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" highlight-current-row @current-change="row => selected = row" :height="300" stripe size="small">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="套餐名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="note" label="说明" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.note || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.disable ? 'danger' : 'success'" size="small">{{ row.disable ? '禁用' : '启用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无套餐" />
|
||||||
|
<div class="selector-footer-bar">
|
||||||
|
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
|
||||||
|
layout="total,sizes,prev,pager,next" small background
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }" @current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 新建套餐弹窗 -->
|
||||||
|
<el-dialog v-model="showCreate" title="新建套餐" width="680px" append-to-body destroy-on-close class="scrollable-dialog">
|
||||||
|
<el-form :model="createForm" label-width="90px">
|
||||||
|
<el-form-item label="套餐名称" required><el-input v-model="createForm.name" placeholder="请输入套餐名称" /></el-form-item>
|
||||||
|
<el-form-item label="说明"><el-input v-model="createForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" /></el-form-item>
|
||||||
|
<el-form-item label="参数配置">
|
||||||
|
<div style="width:100%">
|
||||||
|
<div v-if="!goodId" style="color:#c0c4cc;font-size:13px">请先选择商品</div>
|
||||||
|
<div v-else-if="createSpecLoading" style="color:#909399;font-size:13px">加载参数中...</div>
|
||||||
|
<div v-else-if="createSpecList.length === 0" style="color:#909399;font-size:13px">该商品暂无参数</div>
|
||||||
|
<div v-else>
|
||||||
|
<div v-for="spec in createSpecList" :key="spec.id" style="margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #f5f5f5">
|
||||||
|
<div style="font-size:13px;font-weight:500;color:#303133;margin-bottom:6px">
|
||||||
|
{{ spec.name }}
|
||||||
|
<el-tag v-if="spec.must" size="small" type="danger" style="margin-left:4px">必填</el-tag>
|
||||||
|
</div>
|
||||||
|
<template v-if="spec.type === 'select' && spec.attrs && spec.attrs.length > 0">
|
||||||
|
<el-radio-group v-model="createSpecValues[spec.id]" size="small" @change="buildCreateArgsJson">
|
||||||
|
<el-radio-button v-for="attr in spec.attrs" :key="attr.id" :value="attr.id">{{ attr.name }}</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="spec.type === 'number'">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||||
|
<el-input-number
|
||||||
|
v-model="createDisplayValues[spec.id]"
|
||||||
|
:min="hasUnit(spec) ? fromBaseUnit(spec.min ?? 0, createDisplayUnits[spec.id], getArgKey(spec)) : (spec.min ?? 0)"
|
||||||
|
:max="hasUnit(spec) ? fromBaseUnit(spec.max ?? 0, createDisplayUnits[spec.id], getArgKey(spec)) : (spec.max ?? 0)"
|
||||||
|
:step="hasUnit(spec) ? (fromBaseUnit(spec.step ?? 1, createDisplayUnits[spec.id], getArgKey(spec)) || 1) : (spec.step ?? 1)"
|
||||||
|
:step-strictly="true"
|
||||||
|
size="small"
|
||||||
|
@change="onCreateNumberChange(spec)"
|
||||||
|
style="width:180px"
|
||||||
|
/>
|
||||||
|
<el-select v-if="hasUnit(spec)" :model-value="createDisplayUnits[spec.id]" size="small" style="width:90px" @change="(newUnit) => onCreateUnitChange(spec, newUnit)">
|
||||||
|
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
|
||||||
|
</el-select>
|
||||||
|
<span style="font-size:12px;color:#909399">范围: {{ spec.min ?? 0 }} ~ {{ spec.max ?? 0 }}
|
||||||
|
<template v-if="hasUnit(spec)"> {{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-input v-model="createSpecValues[spec.id]" placeholder="请输入值" size="small" style="width:200px" @input="buildCreateArgsJson" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="createForm.args" style="margin-top:8px">
|
||||||
|
<div style="font-size:12px;color:#909399;margin-bottom:4px">参数 JSON:</div>
|
||||||
|
<el-input v-model="createForm.args" type="textarea" :rows="3" readonly style="font-family:monospace;font-size:12px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="createForm.index" :min="0" controls-position="right" style="width:120px" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { Refresh, Plus } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getProductPlanList, createProductPlan, getProductParameterList } from '@/api/admin/product'
|
||||||
|
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
goodId: { type: [Number, String], default: 0 }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const selected = ref(null)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const createForm = reactive({ name: '', note: '', index: 0, args: '' })
|
||||||
|
const createSpecList = ref([])
|
||||||
|
const createSpecLoading = ref(false)
|
||||||
|
const createSpecValues = reactive({})
|
||||||
|
const createDisplayValues = reactive({})
|
||||||
|
const createDisplayUnits = reactive({})
|
||||||
|
|
||||||
|
watch(showCreate, (v) => {
|
||||||
|
if (v && props.goodId) loadCreateSpec()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadCreateSpec = async () => {
|
||||||
|
createSpecLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProductParameterList({ good_id: props.goodId })
|
||||||
|
if (res?.data?.code === 200) {
|
||||||
|
createSpecList.value = res.data.data || []
|
||||||
|
for (const spec of createSpecList.value) {
|
||||||
|
if (spec.type === 'number') {
|
||||||
|
if (createSpecValues[spec.id] === undefined) createSpecValues[spec.id] = spec.min ?? 0
|
||||||
|
if (hasUnit(spec)) {
|
||||||
|
createDisplayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||||
|
createDisplayValues[spec.id] = fromBaseUnit(spec.min ?? 0, createDisplayUnits[spec.id], getArgKey(spec))
|
||||||
|
} else {
|
||||||
|
createDisplayValues[spec.id] = spec.min ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { createSpecList.value = [] } finally { createSpecLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCreateNumberChange = (spec) => {
|
||||||
|
if (hasUnit(spec)) {
|
||||||
|
const argKey = getArgKey(spec)
|
||||||
|
const unit = createDisplayUnits[spec.id]
|
||||||
|
createSpecValues[spec.id] = Math.round(toBaseUnit(createDisplayValues[spec.id] || 0, unit, argKey))
|
||||||
|
} else {
|
||||||
|
createSpecValues[spec.id] = createDisplayValues[spec.id]
|
||||||
|
}
|
||||||
|
buildCreateArgsJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCreateUnitChange = (spec, newUnit) => {
|
||||||
|
const argKey = getArgKey(spec)
|
||||||
|
const oldUnit = createDisplayUnits[spec.id]
|
||||||
|
const oldDisplay = createDisplayValues[spec.id] || 0
|
||||||
|
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
|
||||||
|
createDisplayUnits[spec.id] = newUnit
|
||||||
|
createDisplayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
|
||||||
|
createSpecValues[spec.id] = Math.round(baseValue)
|
||||||
|
buildCreateArgsJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildCreateArgsJson = () => {
|
||||||
|
const result = []
|
||||||
|
for (const spec of createSpecList.value) {
|
||||||
|
const val = createSpecValues[spec.id]
|
||||||
|
if (val === undefined || val === null || val === '') continue
|
||||||
|
if (spec.type === 'select') {
|
||||||
|
const attr = spec.attrs?.find(a => a.id === val)
|
||||||
|
if (attr) result.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value, number: 0, key: getArgKey(spec) || undefined })
|
||||||
|
} else if (spec.type === 'number') {
|
||||||
|
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: '', number: val, key: getArgKey(spec) || undefined })
|
||||||
|
} else {
|
||||||
|
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val), number: 0, key: getArgKey(spec) || undefined })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createForm.args = result.length > 0 ? JSON.stringify(result) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
|
||||||
|
watch(visible, (v) => emit('update:modelValue', v))
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { page: page.value, count: pageSize.value }
|
||||||
|
if (props.goodId) params.good_id = props.goodId
|
||||||
|
const res = await getProductPlanList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const d = res.data.data
|
||||||
|
list.value = d.data || (Array.isArray(d) ? d : [])
|
||||||
|
total.value = d.all_count ?? d.total ?? list.value.length
|
||||||
|
}
|
||||||
|
} catch { /* */ } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (!createForm.name) { ElMessage.warning('请输入套餐名称'); return }
|
||||||
|
if (!props.goodId) { ElMessage.warning('请先选择商品'); return }
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('good_id', props.goodId)
|
||||||
|
fd.append('name', createForm.name)
|
||||||
|
if (createForm.note) fd.append('note', createForm.note)
|
||||||
|
fd.append('index', createForm.index)
|
||||||
|
if (createForm.args) fd.append('args', createForm.args)
|
||||||
|
const res = await createProductPlan(fd)
|
||||||
|
if (res?.data?.code === 200) {
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
showCreate.value = false
|
||||||
|
Object.assign(createForm, { name: '', note: '', index: 0, args: '' })
|
||||||
|
for (const k in createSpecValues) delete createSpecValues[k]
|
||||||
|
for (const k in createDisplayValues) delete createDisplayValues[k]
|
||||||
|
for (const k in createDisplayUnits) delete createDisplayUnits[k]
|
||||||
|
loadList()
|
||||||
|
} else ElMessage.error(res?.data?.message || '创建失败')
|
||||||
|
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => { visible.value = false }
|
||||||
|
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="选择商品组"
|
||||||
|
width="800px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="group-selector">
|
||||||
|
<!-- 搜索筛选区域 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-form :inline="true" class="search-form">
|
||||||
|
<el-form-item>
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索商品组名称"
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch" :icon="Search">搜索</el-button>
|
||||||
|
<el-button @click="handleReset" :icon="Refresh">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 商品组列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="groupList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="350"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
|
<el-table-column prop="name" label="商品组名称" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="父级ID" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.parentId || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="标签" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.tag" size="small" type="info">{{ row.tag?.name || row.tag }}</el-tag>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.disable ? 'danger' : 'success'" size="small">
|
||||||
|
{{ row.disable ? '禁用' : '启用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="groupList.length === 0 && !loading" description="暂无商品组数据" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="!selectedGroup"
|
||||||
|
>
|
||||||
|
确定选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getProductGroupList } from '@/api/admin/product'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 当前已选中的商品组ID(用于回显)
|
||||||
|
currentGroupId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const groupList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedGroup = ref(null)
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
if (newVal) {
|
||||||
|
selectedGroup.value = null
|
||||||
|
keyword.value = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取商品组列表
|
||||||
|
const fetchGroupList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
groupList.value = []
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: searchParams.page,
|
||||||
|
count: searchParams.count
|
||||||
|
}
|
||||||
|
if (keyword.value.trim()) {
|
||||||
|
params.keyword = keyword.value.trim()
|
||||||
|
}
|
||||||
|
const res = await getProductGroupList(params)
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const inner = body.data
|
||||||
|
const items = Array.isArray(inner) ? inner : (inner.data || inner.list || [])
|
||||||
|
// 过滤掉已删除的
|
||||||
|
groupList.value = items.filter(item => !item.delete)
|
||||||
|
total.value = inner.all_count ?? inner.total ?? groupList.value.length
|
||||||
|
|
||||||
|
// 如果有当前选中的商品组ID,自动选中
|
||||||
|
if (props.currentGroupId) {
|
||||||
|
const current = groupList.value.find(
|
||||||
|
g => g.id === Number(props.currentGroupId)
|
||||||
|
)
|
||||||
|
if (current) {
|
||||||
|
selectedGroup.value = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(body?.message || '获取商品组列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取商品组列表失败:', error)
|
||||||
|
ElMessage.error('获取商品组列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
keyword.value = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
searchParams.count = size
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
searchParams.page = page
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择商品组
|
||||||
|
const handleCurrentChange = (row) => {
|
||||||
|
selectedGroup.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行样式
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (selectedGroup.value && row.id === selectedGroup.value.id) {
|
||||||
|
return 'selected-row'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedGroup.value = null
|
||||||
|
groupList.value = []
|
||||||
|
keyword.value = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedGroup.value) {
|
||||||
|
emit('confirm', selectedGroup.value)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('请选择一个商品组')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-selector {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
:deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row:hover) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row td) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr.current-row > td) {
|
||||||
|
background-color: var(--el-color-primary-light-8) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="选择商品"
|
||||||
|
width="900px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="product-selector">
|
||||||
|
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||||
|
<!-- 选择商品 -->
|
||||||
|
<el-tab-pane label="选择商品" name="selectProduct">
|
||||||
|
<div class="product-list-container">
|
||||||
|
<!-- 搜索筛选区域 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-form :inline="true" :model="searchParams" class="search-form">
|
||||||
|
<el-form-item label="商品分组">
|
||||||
|
<el-select
|
||||||
|
v-model="searchParams.good_group_id"
|
||||||
|
placeholder="全部分组"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in groupOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="商品标签">
|
||||||
|
<el-select
|
||||||
|
v-model="searchParams.tag"
|
||||||
|
placeholder="全部标签"
|
||||||
|
:clearable="!defaultTag"
|
||||||
|
:disabled="!!defaultTag"
|
||||||
|
style="width: 150px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in tagOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch" :icon="Search">
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset" :icon="Refresh">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 商品列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="productList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="350"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="id" label="商品ID" width="100" align="center" />
|
||||||
|
<el-table-column label="商品图片" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-image
|
||||||
|
:src="row.image || '/logo.svg'"
|
||||||
|
fit="cover"
|
||||||
|
style="width: 50px; height: 50px; border-radius: 4px;"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="table" label="所属表" width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column label="价格" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="price">¥{{ (row.price / 100).toFixed(2) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="库存" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.inventory > 0 ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.inventory }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="productList.length === 0 && !loading" description="暂无商品数据" />
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="!selectedProduct"
|
||||||
|
>
|
||||||
|
确定选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getProductList, getProductGroupList, getProductTagList } from '@/api/admin/product'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 当前已选中的商品ID(用于回显)
|
||||||
|
currentProductId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 默认标签过滤(设置后自动锁定该标签)
|
||||||
|
defaultTag: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false)
|
||||||
|
const activeTab = ref('selectProduct')
|
||||||
|
const loading = ref(false)
|
||||||
|
const productList = ref([])
|
||||||
|
const groupOptions = ref([])
|
||||||
|
const tagOptions = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedProduct = ref(null)
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
good_group_id: '',
|
||||||
|
tag: '',
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
if (newVal) {
|
||||||
|
activeTab.value = 'selectProduct'
|
||||||
|
selectedProduct.value = null
|
||||||
|
searchParams.page = 1
|
||||||
|
if (props.defaultTag) {
|
||||||
|
searchParams.tag = props.defaultTag
|
||||||
|
}
|
||||||
|
fetchGroupList()
|
||||||
|
fetchTagList()
|
||||||
|
fetchProductList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取商品分组列表
|
||||||
|
const fetchGroupList = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getProductGroupList({ page: 1, count: 10 })
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
groupOptions.value = res.data.data.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取分组列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取商品标签列表
|
||||||
|
const fetchTagList = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getProductTagList()
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
tagOptions.value = res.data.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取商品列表
|
||||||
|
const fetchProductList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
productList.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: searchParams.page,
|
||||||
|
count: searchParams.count
|
||||||
|
}
|
||||||
|
if (searchParams.good_group_id) {
|
||||||
|
params.good_group_id = searchParams.good_group_id
|
||||||
|
}
|
||||||
|
if (searchParams.tag) {
|
||||||
|
params.tag = searchParams.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getProductList(params)
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
const allData = res.data.data.data || []
|
||||||
|
// 过滤掉已删除的数据(兼容 delete 字段不存在的情况)
|
||||||
|
productList.value = allData.filter(item => item.delete !== true)
|
||||||
|
total.value = res.data.data.all_count ?? allData.length
|
||||||
|
|
||||||
|
// cover 字段直接是图片 URL,无需再请求 file detail
|
||||||
|
productList.value.forEach(item => {
|
||||||
|
if (item.cover) item.image = item.cover
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果有当前选中的商品ID,自动选中
|
||||||
|
if (props.currentProductId) {
|
||||||
|
const currentProduct = productList.value.find(
|
||||||
|
product => product.id === props.currentProductId
|
||||||
|
)
|
||||||
|
if (currentProduct) {
|
||||||
|
selectedProduct.value = currentProduct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.msg || '获取商品列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取商品列表失败:', error)
|
||||||
|
ElMessage.error('获取商品列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签页切换
|
||||||
|
const handleTabClick = (tab) => {
|
||||||
|
if (tab.paneName === 'selectProduct') {
|
||||||
|
fetchProductList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchProductList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
searchParams.good_group_id = ''
|
||||||
|
searchParams.tag = props.defaultTag || ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchProductList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
searchParams.count = size
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchProductList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
searchParams.page = page
|
||||||
|
fetchProductList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择商品
|
||||||
|
const handleCurrentChange = (row) => {
|
||||||
|
selectedProduct.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行样式
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (selectedProduct.value && row.id === selectedProduct.value.id) {
|
||||||
|
return 'selected-row'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedProduct.value = null
|
||||||
|
productList.value = []
|
||||||
|
searchParams.good_group_id = ''
|
||||||
|
searchParams.tag = props.defaultTag || ''
|
||||||
|
searchParams.page = 1
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedProduct.value) {
|
||||||
|
emit('confirm', selectedProduct.value)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('请选择一个商品')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-selector {
|
||||||
|
min-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-list-container {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
:deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row:hover) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row td) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr.current-row > td) {
|
||||||
|
background-color: var(--el-color-primary-light-8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页样式 */
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item.is-active) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择安全组" width="700px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索安全组" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-button :icon="Refresh" @click="loadList" circle />
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
|
||||||
|
:height="340" :row-class-name="rowClassName" size="small" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column label="方向" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.direction === 'in' ? 'primary' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : '出站' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="锁定" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="白名单" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper" v-if="total > 0">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||||
|
@current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div style="display: flex; justify-content: space-between; width: 100%">
|
||||||
|
<el-button type="success" @click="handleCreate">创建安全组</el-button>
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getSecurityGroupList } from '@/api/admin/kvmService'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
serviceId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) { page.value = 1; keyword.value = ''; selectedItem.value = null; loadList() }
|
||||||
|
})
|
||||||
|
watch(visible, (val) => emit('update:modelValue', val))
|
||||||
|
|
||||||
|
const handleSearch = () => { page.value = 1; loadList() }
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
if (!props.serviceId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { service_id: props.serviceId, page: page.value, page_size: pageSize.value }
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
const res = await getSecurityGroupList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const inner = res.data.data
|
||||||
|
list.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : [])
|
||||||
|
total.value = inner.meta?.count ?? inner.total ?? list.value.length
|
||||||
|
} else { list.value = []; total.value = 0 }
|
||||||
|
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
|
||||||
|
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedItem.value) { emit('confirm', selectedItem.value); visible.value = false }
|
||||||
|
}
|
||||||
|
const handleClose = () => { selectedItem.value = null }
|
||||||
|
const handleCreate = () => {
|
||||||
|
visible.value = false
|
||||||
|
emit('create')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-container { min-height: 200px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||||
|
:deep(.selected-row) { background-color: #ecf5ff !important; }
|
||||||
|
:deep(.el-table__body tr) { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="adminGroup ? '选择管理员组' : '选择用户组'"
|
||||||
|
width="900px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="user-group-selector">
|
||||||
|
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||||
|
<!-- 选择用户组 -->
|
||||||
|
<el-tab-pane :label="adminGroup ? '选择管理员组' : '选择用户组'" name="selectGroup">
|
||||||
|
<div class="group-list-container">
|
||||||
|
<!-- 搜索筛选区域 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-form :inline="true" :model="searchParams" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchParams.key"
|
||||||
|
:placeholder="adminGroup ? '搜索管理员组名称' : '搜索用户组名称'"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch" :icon="Search">
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset" :icon="Refresh">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 管理员组列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-if="adminGroup"
|
||||||
|
v-loading="loading"
|
||||||
|
:data="groupList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="350"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
|
<el-table-column prop="name" label="组名称" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="group-name">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="auth" label="权限标识" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.note || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 用户组列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-else
|
||||||
|
v-loading="loading"
|
||||||
|
:data="groupList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="350"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column label="组ID" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.group_id || row.GroupId || row.id || row.Id }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="组名称" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="group-name">{{ row.group_name || row.name || row.Name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="升级金额" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.floor_price || row.FloorPrice" class="price-text">
|
||||||
|
¥{{ row.floor_price || row.FloorPrice }}
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="下一级组ID" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.higher_level_id || row.HigherLevelId || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="(row.fixed || row.Fixed) ? 'warning' : 'success'" size="small">
|
||||||
|
{{ (row.fixed || row.Fixed) ? '固定' : '可升级' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="成员数量" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="info" size="small" effect="plain">
|
||||||
|
{{ row.member_count || row.MemberCount || 0 }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="groupList.length === 0 && !loading" :description="adminGroup ? '暂无管理员组数据' : '暂无用户组数据'" />
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="!selectedGroup"
|
||||||
|
>
|
||||||
|
确定选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getUserGroupList } from '@/api/admin/user'
|
||||||
|
import { getAdminGroupList } from '@/api/admin/group'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 当前已选中的用户组ID(用于回显)
|
||||||
|
currentGroupId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 排除的用户组ID(避免选择自己作为下一级)
|
||||||
|
excludeGroupId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 是否请求管理员组接口
|
||||||
|
adminGroup: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false)
|
||||||
|
const activeTab = ref('selectGroup')
|
||||||
|
const loading = ref(false)
|
||||||
|
const groupList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedGroup = ref(null)
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
key: '',
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
if (newVal) {
|
||||||
|
// 重置状态
|
||||||
|
activeTab.value = 'selectGroup'
|
||||||
|
selectedGroup.value = null
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取用户组列表
|
||||||
|
const fetchGroupList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
groupList.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: searchParams.page,
|
||||||
|
count: searchParams.count
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = props.adminGroup ? await getAdminGroupList(params) : await getUserGroupList(params)
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
let responseData = res.data?.data || res.data
|
||||||
|
|
||||||
|
if (props.adminGroup) {
|
||||||
|
groupList.value = responseData?.data || []
|
||||||
|
total.value = responseData?.total || groupList.value.length
|
||||||
|
} else if (Array.isArray(responseData)) {
|
||||||
|
groupList.value = responseData
|
||||||
|
total.value = responseData.length
|
||||||
|
} else if (responseData.list) {
|
||||||
|
groupList.value = responseData.list || []
|
||||||
|
total.value = responseData.total || responseData.all_count || 0
|
||||||
|
} else if (responseData.data && Array.isArray(responseData.data)) {
|
||||||
|
groupList.value = responseData.data
|
||||||
|
total.value = responseData.all_count || responseData.data.length
|
||||||
|
} else {
|
||||||
|
groupList.value = []
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤掉排除的用户组
|
||||||
|
if (props.excludeGroupId) {
|
||||||
|
groupList.value = groupList.value.filter(item => {
|
||||||
|
const itemId = item.group_id || item.GroupId || item.id || item.Id
|
||||||
|
return itemId !== props.excludeGroupId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词过滤
|
||||||
|
if (searchParams.key) {
|
||||||
|
const keyword = searchParams.key.toLowerCase()
|
||||||
|
groupList.value = groupList.value.filter(item => {
|
||||||
|
const name = (item.group_name || item.name || item.Name || '').toLowerCase()
|
||||||
|
const id = String(item.group_id || item.GroupId || item.id || item.Id)
|
||||||
|
return name.includes(keyword) || id.includes(keyword)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有当前选中的用户组ID,自动选中
|
||||||
|
if (props.currentGroupId) {
|
||||||
|
const currentGroup = groupList.value.find(item => {
|
||||||
|
const itemId = item.group_id || item.GroupId || item.id || item.Id
|
||||||
|
return itemId === props.currentGroupId
|
||||||
|
})
|
||||||
|
if (currentGroup) {
|
||||||
|
selectedGroup.value = currentGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.msg || '获取用户组列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户组列表失败:', error)
|
||||||
|
ElMessage.error('获取用户组列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签页切换
|
||||||
|
const handleTabClick = (tab) => {
|
||||||
|
if (tab.paneName === 'selectGroup') {
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
searchParams.count = size
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
searchParams.page = page
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择用户组
|
||||||
|
const handleCurrentChange = (row) => {
|
||||||
|
selectedGroup.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行样式
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (selectedGroup.value) {
|
||||||
|
const selectedId = selectedGroup.value.group_id || selectedGroup.value.GroupId || selectedGroup.value.id || selectedGroup.value.Id
|
||||||
|
const rowId = row.group_id || row.GroupId || row.id || row.Id
|
||||||
|
if (rowId === selectedId) {
|
||||||
|
return 'selected-row'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedGroup.value = null
|
||||||
|
groupList.value = []
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedGroup.value) {
|
||||||
|
emit('confirm', selectedGroup.value)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('请选择一个用户组')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-group-selector {
|
||||||
|
min-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list-container {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-text {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
:deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row:hover) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row td) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr.current-row > td) {
|
||||||
|
background-color: var(--el-color-primary-light-8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页样式 */
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item.is-active) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="选择用户"
|
||||||
|
width="900px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="user-selector">
|
||||||
|
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||||
|
<!-- 选择用户 -->
|
||||||
|
<el-tab-pane label="选择用户" name="selectUser">
|
||||||
|
<div class="user-list-container">
|
||||||
|
<!-- 搜索筛选区域 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-form :inline="true" :model="searchParams" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchParams.key"
|
||||||
|
placeholder="搜索用户名或ID"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户状态">
|
||||||
|
<el-select
|
||||||
|
v-model="searchParams.status"
|
||||||
|
placeholder="全部状态"
|
||||||
|
clearable
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<el-option label="正常" value="1" />
|
||||||
|
<el-option label="禁用" value="0" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch" :icon="Search">
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset" :icon="Refresh">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" @click="switchToAdd" :icon="Plus">
|
||||||
|
添加新用户
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="userList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="350"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="user_id" label="用户ID" width="100" align="center" />
|
||||||
|
<el-table-column prop="user_name" label="用户名" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="user-info-cell">
|
||||||
|
<el-avatar :size="32" :src="row.cover">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
</el-avatar>
|
||||||
|
<span class="user-name">{{ row.user_name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="phone" label="手机号" width="130" show-overflow-tooltip />
|
||||||
|
<!-- <el-table-column prop="status" label="状态" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.status === 1 ? '正常' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column> -->
|
||||||
|
<el-table-column prop="created_at" label="注册时间" width="160" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" />
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 添加用户 -->
|
||||||
|
<el-tab-pane label="添加用户" name="addUser">
|
||||||
|
<div class="add-user-section">
|
||||||
|
<el-form
|
||||||
|
ref="addFormRef"
|
||||||
|
:model="addForm"
|
||||||
|
:rules="addFormRules"
|
||||||
|
label-width="100px"
|
||||||
|
class="add-user-form"
|
||||||
|
>
|
||||||
|
<el-form-item label="用户名" prop="user_name">
|
||||||
|
<el-input
|
||||||
|
v-model="addForm.user_name"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
maxlength="50"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input
|
||||||
|
v-model="addForm.email"
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input
|
||||||
|
v-model="addForm.phone"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
maxlength="11"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="addForm.password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirmPassword">
|
||||||
|
<el-input
|
||||||
|
v-model="addForm.confirmPassword"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleAddUser" :loading="addLoading">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
立即创建
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetAddForm">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
重置表单
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="!selectedUser"
|
||||||
|
v-if="activeTab === 'selectUser'"
|
||||||
|
>
|
||||||
|
确定选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
|
||||||
|
import { getUserList, createTask } from '@/api/admin/user'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 当前已选中的用户ID(用于回显)
|
||||||
|
currentUserId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false)
|
||||||
|
const activeTab = ref('selectUser')
|
||||||
|
const loading = ref(false)
|
||||||
|
const addLoading = ref(false)
|
||||||
|
const userList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedUser = ref(null)
|
||||||
|
const addFormRef = ref(null)
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
key: '',
|
||||||
|
status: '',
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加用户表单
|
||||||
|
const addForm = reactive({
|
||||||
|
user_name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 密码确认验证
|
||||||
|
const validateConfirmPassword = (rule, value, callback) => {
|
||||||
|
if (value === '') {
|
||||||
|
callback(new Error('请再次输入密码'))
|
||||||
|
} else if (value !== addForm.password) {
|
||||||
|
callback(new Error('两次输入密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加用户表单验证规则
|
||||||
|
const addFormRules = {
|
||||||
|
user_name: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 50, message: '用户名长度在 2 到 50 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
phone: [
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||||
|
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
if (newVal) {
|
||||||
|
// 重置状态
|
||||||
|
activeTab.value = 'selectUser'
|
||||||
|
selectedUser.value = null
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchUserList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取用户列表
|
||||||
|
const fetchUserList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
userList.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: searchParams.page,
|
||||||
|
count: searchParams.count,
|
||||||
|
key: searchParams.key || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getUserList(params)
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
userList.value = res.data.data?.data || []
|
||||||
|
total.value = res.data.data?.all_count || 0
|
||||||
|
|
||||||
|
// 如果有当前选中的用户ID,自动选中
|
||||||
|
if (props.currentUserId) {
|
||||||
|
const currentUser = userList.value.find(
|
||||||
|
user => user.user_id === props.currentUserId
|
||||||
|
)
|
||||||
|
if (currentUser) {
|
||||||
|
selectedUser.value = currentUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.msg || '获取用户列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户列表失败:', error)
|
||||||
|
ElMessage.error('获取用户列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签页切换
|
||||||
|
const handleTabClick = (tab) => {
|
||||||
|
if (tab.paneName === 'selectUser') {
|
||||||
|
fetchUserList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchUserList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.status = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchUserList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
searchParams.count = size
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchUserList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
searchParams.page = page
|
||||||
|
fetchUserList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到添加用户标签页
|
||||||
|
const switchToAdd = () => {
|
||||||
|
activeTab.value = 'addUser'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择用户
|
||||||
|
const handleCurrentChange = (row) => {
|
||||||
|
selectedUser.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行样式
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (selectedUser.value && row.user_id === selectedUser.value.user_id) {
|
||||||
|
return 'selected-row'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加用户
|
||||||
|
const handleAddUser = async () => {
|
||||||
|
if (!addFormRef.value) return
|
||||||
|
|
||||||
|
await addFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
addLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('user_name', addForm.user_name)
|
||||||
|
formData.append('email', addForm.email)
|
||||||
|
if (addForm.phone) {
|
||||||
|
formData.append('phone', addForm.phone)
|
||||||
|
}
|
||||||
|
formData.append('password', addForm.password)
|
||||||
|
|
||||||
|
const res = await createTask(formData)
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success('用户创建成功')
|
||||||
|
|
||||||
|
// 获取新创建的用户信息
|
||||||
|
const newUser = res.data.data
|
||||||
|
|
||||||
|
// 自动选择新创建的用户
|
||||||
|
if (newUser) {
|
||||||
|
selectedUser.value = {
|
||||||
|
user_id: newUser.user_id || newUser.id,
|
||||||
|
user_name: newUser.user_name || addForm.user_name,
|
||||||
|
email: newUser.email || addForm.email,
|
||||||
|
phone: newUser.phone || addForm.phone,
|
||||||
|
...newUser
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发确认事件并关闭弹窗
|
||||||
|
emit('confirm', selectedUser.value)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
// 如果没有返回用户信息,切换到选择标签页并刷新列表
|
||||||
|
activeTab.value = 'selectUser'
|
||||||
|
searchParams.page = 1
|
||||||
|
await fetchUserList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
resetAddForm()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.msg || '用户创建失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('用户创建失败:', error)
|
||||||
|
ElMessage.error('用户创建失败')
|
||||||
|
} finally {
|
||||||
|
addLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置添加表单
|
||||||
|
const resetAddForm = () => {
|
||||||
|
addForm.user_name = ''
|
||||||
|
addForm.email = ''
|
||||||
|
addForm.phone = ''
|
||||||
|
addForm.password = ''
|
||||||
|
addForm.confirmPassword = ''
|
||||||
|
addFormRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedUser.value = null
|
||||||
|
userList.value = []
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.status = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
total.value = 0
|
||||||
|
resetAddForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedUser.value) {
|
||||||
|
emit('confirm', selectedUser.value)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('请选择一个用户')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-selector {
|
||||||
|
min-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-container {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-user-section {
|
||||||
|
padding: 30px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-user-form {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-user-form :deep(.el-input) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
:deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row:hover) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row td) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr.current-row > td) {
|
||||||
|
background-color: var(--el-color-primary-light-8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页样式 */
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item.is-active) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择网络" width="800px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-tag v-if="filterType" :type="filterType === 'bridge' ? 'success' : 'warning'" size="small" effect="dark">仅{{ filterType === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
|
||||||
|
<el-tag v-if="filterUnused" type="success" size="small" effect="dark">仅未占用</el-tag>
|
||||||
|
<el-select v-model="ipVersionFilter" placeholder="IP版本" clearable style="width: 110px" @change="handleSearch">
|
||||||
|
<el-option label="IPv4" value="ipv4" />
|
||||||
|
<el-option label="IPv6" value="ipv6" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Refresh" @click="loadList" circle />
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
|
||||||
|
:height="340" :row-class-name="rowClassName" size="small" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column label="类型" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">
|
||||||
|
{{ row.type === 'bridge' ? '网桥' : 'NAT' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||||||
|
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="bridge_name" label="网桥名称" width="100" />
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper" v-if="total > 0">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||||
|
@current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div style="display: flex; justify-content: space-between; width: 100%">
|
||||||
|
<el-button v-if="props.showCreateButton" type="success" @click="handleCreate">创建网络</el-button>
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getUserVmNetworkList } from '@/api/admin/userVm'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
userGoodsId: { type: Number, default: 0 },
|
||||||
|
filterType: { type: String, default: '' },
|
||||||
|
filterUnused: { type: Boolean, default: false },
|
||||||
|
showCreateButton: { type: Boolean, default: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const ipVersionFilter = ref('')
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) {
|
||||||
|
page.value = 1
|
||||||
|
keyword.value = ''
|
||||||
|
ipVersionFilter.value = ''
|
||||||
|
selectedItem.value = null
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(visible, (val) => emit('update:modelValue', val))
|
||||||
|
|
||||||
|
const handleSearch = () => { page.value = 1; loadList() }
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
if (!props.userGoodsId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { user_goods_id: props.userGoodsId, page: page.value, count: pageSize.value }
|
||||||
|
if (keyword.value) params.key = keyword.value
|
||||||
|
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
|
||||||
|
const res = await getUserVmNetworkList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const inner = res.data.data
|
||||||
|
let all = inner.data || (Array.isArray(inner) ? inner : [])
|
||||||
|
if (props.filterType) {
|
||||||
|
all = all.filter(n => n.type === props.filterType)
|
||||||
|
}
|
||||||
|
if (props.filterUnused) {
|
||||||
|
all = all.filter(n => !n.vm_id)
|
||||||
|
}
|
||||||
|
list.value = all
|
||||||
|
total.value = inner.meta?.count ?? inner.total ?? all.length
|
||||||
|
} else { list.value = []; total.value = 0 }
|
||||||
|
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
|
||||||
|
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedItem.value) {
|
||||||
|
emit('confirm', selectedItem.value)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleClose = () => { selectedItem.value = null }
|
||||||
|
const handleCreate = () => {
|
||||||
|
visible.value = false
|
||||||
|
emit('create')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-container { min-height: 200px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||||
|
:deep(.selected-row) { background-color: #ecf5ff !important; }
|
||||||
|
:deep(.el-table__body tr) { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择安全组" width="640px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-toolbar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索安全组名称" clearable style="width:200px"
|
||||||
|
@keyup.enter="loadList" @clear="loadList">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="showCreate = true">新增安全组</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" highlight-current-row
|
||||||
|
@current-change="row => selected = row" :height="280" stripe size="small">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="方向" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">
|
||||||
|
{{ row.direction === 'in' ? '入站' : row.direction === 'out' ? '出站' : (row.direction || '-') }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="白名单" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="共享" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.shared ? 'success' : 'info'" size="small">{{ row.shared ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无安全组" />
|
||||||
|
<div class="selector-footer-bar">
|
||||||
|
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
|
||||||
|
layout="total,sizes,prev,pager,next" small background
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||||
|
@current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 新增安全组弹窗 -->
|
||||||
|
<el-dialog v-model="showCreate" title="新增安全组" width="440px" append-to-body destroy-on-close>
|
||||||
|
<el-form :model="createForm" label-width="90px">
|
||||||
|
<el-form-item label="名称" required>
|
||||||
|
<el-input v-model="createForm.name" placeholder="安全组名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="方向">
|
||||||
|
<el-select v-model="createForm.direction" style="width:100%">
|
||||||
|
<el-option label="入站 (in)" value="in" />
|
||||||
|
<el-option label="出站 (out)" value="out" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="锁定">
|
||||||
|
<el-switch v-model="createForm.lock" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="白名单">
|
||||||
|
<el-switch v-model="createForm.drop_all" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getUserVmPostGroupUserList, createUserVmPostGroup } from '@/api/admin/userVm'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
userGoodsId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const selected = ref(null)
|
||||||
|
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const createForm = reactive({ name: '', direction: 'in', lock: false, drop_all: false })
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
|
||||||
|
watch(visible, (v) => emit('update:modelValue', v))
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
if (!props.userGoodsId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { user_goods_id: props.userGoodsId, page: page.value, page_size: pageSize.value }
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
const res = await getUserVmPostGroupUserList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const d = res.data.data
|
||||||
|
list.value = d.groups || d.data || (Array.isArray(d) ? d : [])
|
||||||
|
total.value = d.total ?? list.value.length
|
||||||
|
}
|
||||||
|
} catch { /* */ } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (!createForm.name) { ElMessage.warning('请输入名称'); return }
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await createUserVmPostGroup({
|
||||||
|
user_goods_id: props.userGoodsId,
|
||||||
|
name: createForm.name,
|
||||||
|
direction: createForm.direction,
|
||||||
|
lock: createForm.lock,
|
||||||
|
drop_all: createForm.drop_all
|
||||||
|
})
|
||||||
|
if (res?.data?.code === 200) {
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
showCreate.value = false
|
||||||
|
Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false })
|
||||||
|
loadList()
|
||||||
|
} else ElMessage.error(res?.data?.message || '创建失败')
|
||||||
|
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => { visible.value = false }
|
||||||
|
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择数据卷进行挂载" width="680px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-toolbar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索数据卷名称" clearable style="width:200px"
|
||||||
|
@keyup.enter="loadList" @clear="loadList">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建数据卷</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" highlight-current-row
|
||||||
|
@current-change="row => selected = row" :height="280" stripe size="small">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="大小" width="80">
|
||||||
|
<template #default="{ row }">{{ row.size }} GB</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'ready' ? 'success' : 'info'" size="small">{{ row.status || '-' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="挂载" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_mount ? 'success' : 'info'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无数据卷" />
|
||||||
|
<div class="selector-footer-bar">
|
||||||
|
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
|
||||||
|
layout="total,sizes,prev,pager,next" small background
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||||
|
@current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selected || !!selected.is_mount" @click="handleConfirm">
|
||||||
|
{{ selected?.is_mount ? '已挂载' : '确定挂载' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 新建数据卷弹窗 -->
|
||||||
|
<el-dialog v-model="showCreate" title="新建数据卷" width="440px" append-to-body destroy-on-close>
|
||||||
|
<el-form :model="createForm" label-width="100px">
|
||||||
|
<el-form-item label="名称" required><el-input v-model="createForm.name" placeholder="数据卷名称" /></el-form-item>
|
||||||
|
<el-form-item label="大小">
|
||||||
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="createForm.size" :min="1" controls-position="right" style="flex:1" />
|
||||||
|
<el-select v-model="createForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标设备名"><el-input v-model="createForm.target_device" placeholder="不填自动生成" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getUserVmVolumeList, createUserVmVolume } from '@/api/admin/userVm'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
userGoodsId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const selected = ref(null)
|
||||||
|
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const createForm = reactive({ name: '', size: 10, _sizeUnit: 'GB', target_device: '' })
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
|
||||||
|
watch(visible, (v) => emit('update:modelValue', v))
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
if (!props.userGoodsId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getUserVmVolumeList({ user_goods_id: props.userGoodsId, page: page.value, count: pageSize.value })
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const d = res.data.data
|
||||||
|
list.value = d.data || (Array.isArray(d) ? d : [])
|
||||||
|
total.value = d.all_count ?? d.total ?? list.value.length
|
||||||
|
}
|
||||||
|
} catch { /* */ } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (!createForm.name) { ElMessage.warning('请输入名称'); return }
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
const sizeGb = createForm._sizeUnit === 'TB' ? createForm.size * 1024 : createForm.size
|
||||||
|
const res = await createUserVmVolume({
|
||||||
|
user_goods_id: props.userGoodsId,
|
||||||
|
name: createForm.name,
|
||||||
|
size: sizeGb,
|
||||||
|
target_device: createForm.target_device
|
||||||
|
})
|
||||||
|
if (res?.data?.code === 200) {
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
showCreate.value = false
|
||||||
|
Object.assign(createForm, { name: '', size: 10, _sizeUnit: 'GB', target_device: '' })
|
||||||
|
loadList()
|
||||||
|
} else ElMessage.error(res?.data?.message || '创建失败')
|
||||||
|
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => { visible.value = false }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selected.value && !selected.value.is_mount) {
|
||||||
|
emit('confirm', selected.value)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||||
|
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
.unit-select { width: 90px; flex-shrink: 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择虚拟机" width="700px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="hostIdFilter" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="loadList">
|
||||||
|
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="配置" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.vcpu }}核 / {{ formatMem(row.memory) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { getRemoteHostList, getVmList } from '@/api/admin/kvmService'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
serviceId: { type: Number, default: 0 },
|
||||||
|
hostId: { type: Number, default: 0 },
|
||||||
|
currentId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
const hostIdFilter = ref('')
|
||||||
|
const hostOptions = ref([])
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) { loadHostOptions(); if (props.hostId) { hostIdFilter.value = props.hostId; loadList() } }
|
||||||
|
})
|
||||||
|
watch(visible, (val) => emit('update:modelValue', val))
|
||||||
|
|
||||||
|
const loadHostOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getRemoteHostList({ service_id: props.serviceId, page: 1, page_size: 10 })
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const inner = body.data
|
||||||
|
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||||
|
if (!hostIdFilter.value && hostOptions.value.length) hostIdFilter.value = hostOptions.value[0].id
|
||||||
|
if (hostIdFilter.value) loadList()
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
if (!hostIdFilter.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getVmList({ service_id: props.serviceId, host_id: hostIdFilter.value, page: 1, count: 10 })
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const inner = body.data
|
||||||
|
list.value = inner.data || (Array.isArray(inner) ? inner : [])
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMem = (kb) => {
|
||||||
|
if (!kb) return '-'
|
||||||
|
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||||||
|
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
|
||||||
|
return kb + ' KB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusType = (s) => ({ running: 'success', ready: 'success', stopped: 'danger', error: 'danger', paused: 'warning' }[s] || 'info')
|
||||||
|
const statusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停' }[s] || s || '-')
|
||||||
|
|
||||||
|
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
|
||||||
|
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedItem.value) {
|
||||||
|
emit('confirm', selectedItem.value)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleClose = () => { selectedItem.value = null }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-container { min-height: 200px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
|
:deep(.current-row) { background-color: #ecf5ff !important; }
|
||||||
|
:deep(.el-table__body tr) { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="选择数据卷" width="750px" append-to-body @close="handleClose">
|
||||||
|
<div class="selector-container">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="keyword" placeholder="搜索数据卷" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="handleSearch">
|
||||||
|
<el-option label="就绪" value="ready" />
|
||||||
|
<el-option label="等待中" value="pending" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Refresh" @click="loadList" circle />
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
|
||||||
|
:height="340" :row-class-name="rowClassName" size="small" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="大小" width="90">
|
||||||
|
<template #default="{ row }">{{ row.size ? row.size + ' GB' : '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="挂载" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_mount ? 'warning' : 'success'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="path" label="路径" min-width="180" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper" v-if="total > 0">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
|
||||||
|
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||||
|
@current-change="p => { page = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div style="display: flex; justify-content: space-between; width: 100%">
|
||||||
|
<el-button type="success" @click="handleCreate">创建数据卷</el-button>
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getVolumeList } from '@/api/admin/kvmService'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
serviceId: { type: Number, default: 0 },
|
||||||
|
hostId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) {
|
||||||
|
page.value = 1
|
||||||
|
keyword.value = ''
|
||||||
|
statusFilter.value = ''
|
||||||
|
selectedItem.value = null
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(visible, (val) => emit('update:modelValue', val))
|
||||||
|
|
||||||
|
const handleSearch = () => { page.value = 1; loadList() }
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
if (!props.serviceId || !props.hostId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, count: pageSize.value }
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
if (statusFilter.value) params.status = statusFilter.value
|
||||||
|
const res = await getVolumeList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const inner = res.data.data
|
||||||
|
list.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : [])
|
||||||
|
total.value = inner.meta?.count ?? inner.total ?? list.value.length
|
||||||
|
} else { list.value = []; total.value = 0 }
|
||||||
|
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusType = (s) => ({ ready: 'success', pending: 'warning', error: 'danger' }[s] || 'info')
|
||||||
|
const statusLabel = (s) => ({ ready: '就绪', pending: '等待中', creating: '创建中', error: '错误' }[s] || s || '-')
|
||||||
|
|
||||||
|
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
|
||||||
|
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedItem.value) {
|
||||||
|
emit('confirm', selectedItem.value)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleClose = () => { selectedItem.value = null }
|
||||||
|
const handleCreate = () => {
|
||||||
|
visible.value = false
|
||||||
|
emit('create')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-container { min-height: 200px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||||
|
:deep(.selected-row) { background-color: #ecf5ff !important; }
|
||||||
|
:deep(.el-table__body tr) { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="选择代金券"
|
||||||
|
width="900px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="voucher-selector">
|
||||||
|
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||||
|
<!-- 选择代金券 -->
|
||||||
|
<el-tab-pane label="选择代金券" name="selectVoucher">
|
||||||
|
<div class="voucher-list-container">
|
||||||
|
<!-- 搜索筛选区域 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-form :inline="true" :model="searchParams" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchParams.key"
|
||||||
|
placeholder="搜索代金券名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch" :icon="Search">
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset" :icon="Refresh">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 代金券列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="voucherList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="350"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="id" label="代金券ID" width="100" align="center" />
|
||||||
|
<el-table-column prop="name" label="代金券名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="code" label="代金券码" width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="warning" effect="plain">{{ row.code }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="优惠类型" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.percentage > 0 ? 'warning' : 'primary'" size="small">
|
||||||
|
{{ row.percentage > 0 ? '折扣' : '固定金额' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="面值" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.percentage > 0" class="voucher-value">{{ row.percentage }}%</span>
|
||||||
|
<span v-else class="voucher-value">¥{{ (row.amount / 100).toFixed(2) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最低消费" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.minAmount">¥{{ (row.minAmount / 100).toFixed(2) }}</span>
|
||||||
|
<span v-else>无限制</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="使用次数" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.userTimes || 0 }} / {{ row.maxTimes || '∞' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="有效期" width="160" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="{ 'expired': isExpired(row.endTime) }">
|
||||||
|
{{ formatDate(row.endTime) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="voucherList.length === 0 && !loading" description="暂无代金券数据" />
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="!selectedVoucher"
|
||||||
|
>
|
||||||
|
确定选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getDiscountCodeList } from '@/api/admin/discount'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 当前已选中的代金券ID(用于回显)
|
||||||
|
currentVoucherId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false)
|
||||||
|
const activeTab = ref('selectVoucher')
|
||||||
|
const loading = ref(false)
|
||||||
|
const voucherList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedVoucher = ref(null)
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
key: '',
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
if (newVal) {
|
||||||
|
// 重置状态
|
||||||
|
activeTab.value = 'selectVoucher'
|
||||||
|
selectedVoucher.value = null
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchVoucherList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取代金券列表
|
||||||
|
const fetchVoucherList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
voucherList.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: searchParams.page,
|
||||||
|
count: searchParams.count,
|
||||||
|
discount_type: 'coupon' // 代金券类型
|
||||||
|
}
|
||||||
|
if (searchParams.key) {
|
||||||
|
params.key = searchParams.key
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getDiscountCodeList(params)
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
voucherList.value = res.data.data?.data || []
|
||||||
|
total.value = res.data.data?.all_count || 0
|
||||||
|
|
||||||
|
// 如果有当前选中的代金券ID,自动选中
|
||||||
|
if (props.currentVoucherId) {
|
||||||
|
const currentVoucher = voucherList.value.find(
|
||||||
|
voucher => voucher.id === props.currentVoucherId
|
||||||
|
)
|
||||||
|
if (currentVoucher) {
|
||||||
|
selectedVoucher.value = currentVoucher
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.msg || '获取代金券列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取代金券列表失败:', error)
|
||||||
|
ElMessage.error('获取代金券列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签页切换
|
||||||
|
const handleTabClick = (tab) => {
|
||||||
|
if (tab.paneName === 'selectVoucher') {
|
||||||
|
fetchVoucherList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchVoucherList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchVoucherList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
searchParams.count = size
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchVoucherList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
searchParams.page = page
|
||||||
|
fetchVoucherList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择代金券
|
||||||
|
const handleCurrentChange = (row) => {
|
||||||
|
selectedVoucher.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行样式
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (selectedVoucher.value && row.id === selectedVoucher.value.id) {
|
||||||
|
return 'selected-row'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedVoucher.value = null
|
||||||
|
voucherList.value = []
|
||||||
|
searchParams.key = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否过期
|
||||||
|
const isExpired = (endTime) => {
|
||||||
|
if (!endTime) return false
|
||||||
|
return new Date(endTime) < new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedVoucher.value) {
|
||||||
|
emit('confirm', selectedVoucher.value)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('请选择一个代金券')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.voucher-selector {
|
||||||
|
min-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-list-container {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-value {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expired {
|
||||||
|
color: #909399;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
:deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row:hover) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row td) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr.current-row > td) {
|
||||||
|
background-color: var(--el-color-primary-light-8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页样式 */
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item.is-active) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="selector-field-row">
|
||||||
|
<el-input
|
||||||
|
:model-value="displayText"
|
||||||
|
readonly
|
||||||
|
:placeholder="placeholder"
|
||||||
|
style="flex:1"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="disabled"
|
||||||
|
style="margin-left:8px"
|
||||||
|
@click="$emit('select')"
|
||||||
|
>{{ buttonText }}</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="clearable && modelValue"
|
||||||
|
style="margin-left:4px"
|
||||||
|
@click="$emit('update:modelValue', null); $emit('clear')"
|
||||||
|
>清除</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="hint" :style="{ fontSize: '12px', color: hintType === 'disabled' ? '#c0c4cc' : '#909399', marginTop: '4px' }">
|
||||||
|
{{ hint }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: [Number, String, Object], default: null },
|
||||||
|
displayText: { type: String, default: '' },
|
||||||
|
placeholder: { type: String, default: '请选择' },
|
||||||
|
buttonText: { type: String, default: '选择' },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
clearable: { type: Boolean, default: true },
|
||||||
|
hint: { type: String, default: '' },
|
||||||
|
hintType: { type: String, default: 'normal' }
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['select', 'clear', 'update:modelValue'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selector-field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="admin-layout">
|
<div class="admin-layout" :class="{ 'sidebar-collapsed': isCollapsed, 'mobile-open': isMobileMenuOpen }">
|
||||||
|
<!-- 移动端遮罩层 -->
|
||||||
|
<div class="mobile-overlay" v-if="isMobileMenuOpen" @click="closeMobileMenu"></div>
|
||||||
|
|
||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
<div class="sidebar">
|
<div class="sidebar" :class="{ 'collapsed': isCollapsed }">
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<img src="@/assets/logo.png" alt="Logo" class="logo-img" />
|
<img src="@/assets/logo.png" alt="Logo" class="logo-img" v-show="!isCollapsed" />
|
||||||
|
<img src="@/assets/logo.svg" alt="Logo" class="logo-img-mini" v-show="isCollapsed" />
|
||||||
</div>
|
</div>
|
||||||
<el-scrollbar class="sidebar-scrollbar">
|
<el-scrollbar class="sidebar-scrollbar">
|
||||||
<el-menu
|
<el-menu
|
||||||
@@ -13,11 +17,20 @@
|
|||||||
text-color="#34495e"
|
text-color="#34495e"
|
||||||
active-text-color="#2c3e50"
|
active-text-color="#2c3e50"
|
||||||
:unique-opened="true"
|
:unique-opened="true"
|
||||||
|
:collapse="isCollapsed"
|
||||||
|
:collapse-transition="false"
|
||||||
router
|
router
|
||||||
>
|
>
|
||||||
<sidebar-menu-item v-for="menu in menus" :key="menu.path" :menu="menu" />
|
<sidebar-menu-item v-for="menu in menus" :key="menu.path" :menu="menu" />
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
<!-- 收缩按钮 -->
|
||||||
|
<div class="collapse-btn" @click="toggleCollapse">
|
||||||
|
<el-icon :size="18">
|
||||||
|
<Fold v-if="!isCollapsed" />
|
||||||
|
<Expand v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主区域 -->
|
<!-- 主区域 -->
|
||||||
@@ -25,10 +38,14 @@
|
|||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
<div class="navbar-left">
|
<div class="navbar-left">
|
||||||
|
<!-- 移动端菜单按钮 -->
|
||||||
|
<el-button type="text" class="mobile-menu-btn" @click="toggleMobileMenu">
|
||||||
|
<el-icon :size="22"><Menu /></el-icon>
|
||||||
|
</el-button>
|
||||||
<breadcrumb />
|
<breadcrumb />
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-right">
|
<div class="navbar-right">
|
||||||
<div class="navbar-item">
|
<div class="navbar-item hidden-mobile">
|
||||||
<el-tooltip content="全屏" placement="bottom">
|
<el-tooltip content="全屏" placement="bottom">
|
||||||
<el-button type="text" class="header-btn" @click="toggleFullScreen">
|
<el-button type="text" class="header-btn" @click="toggleFullScreen">
|
||||||
<el-icon :size="18"><full-screen /></el-icon>
|
<el-icon :size="18"><full-screen /></el-icon>
|
||||||
@@ -39,9 +56,9 @@
|
|||||||
<div class="navbar-item">
|
<div class="navbar-item">
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
<el-avatar :size="32" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
|
<el-avatar :size="32" :src="userStore.getUserAvatar() || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" />
|
||||||
<span class="username">{{ userStore.userInfo.user_name }}</span>
|
<span class="username hidden-mobile">{{ userStore.userInfo.user_name }}</span>
|
||||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
<el-icon class="el-icon--right hidden-mobile"><arrow-down /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
@@ -81,7 +98,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import SidebarMenuItem from './SidebarMenuItem.vue'
|
import SidebarMenuItem from './SidebarMenuItem.vue'
|
||||||
import Breadcrumb from './Breadcrumb.vue'
|
import Breadcrumb from './Breadcrumb.vue'
|
||||||
@@ -92,7 +109,10 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
User,
|
User,
|
||||||
Key,
|
Key,
|
||||||
SwitchButton
|
SwitchButton,
|
||||||
|
Fold,
|
||||||
|
Expand,
|
||||||
|
Menu
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
@@ -105,11 +125,46 @@ const router = useRouter()
|
|||||||
// 侧边栏菜单数据
|
// 侧边栏菜单数据
|
||||||
const menus = ref(menuConfig)
|
const menus = ref(menuConfig)
|
||||||
|
|
||||||
|
// 侧边栏收缩状态
|
||||||
|
const isCollapsed = ref(false)
|
||||||
|
|
||||||
|
// 移动端菜单状态
|
||||||
|
const isMobileMenuOpen = ref(false)
|
||||||
|
|
||||||
|
// 检测是否是移动端
|
||||||
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768
|
||||||
|
// 移动端默认收起侧边栏
|
||||||
|
if (isMobile.value) {
|
||||||
|
isCollapsed.value = false
|
||||||
|
isMobileMenuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前激活的菜单项
|
// 获取当前激活的菜单项
|
||||||
const activeMenu = computed(() => {
|
const activeMenu = computed(() => {
|
||||||
return route.path
|
return route.path
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 切换侧边栏收缩
|
||||||
|
const toggleCollapse = () => {
|
||||||
|
isCollapsed.value = !isCollapsed.value
|
||||||
|
// 保存状态到localStorage
|
||||||
|
localStorage.setItem('sidebarCollapsed', isCollapsed.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换移动端菜单
|
||||||
|
const toggleMobileMenu = () => {
|
||||||
|
isMobileMenuOpen.value = !isMobileMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭移动端菜单
|
||||||
|
const closeMobileMenu = () => {
|
||||||
|
isMobileMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 切换全屏
|
// 切换全屏
|
||||||
const toggleFullScreen = () => {
|
const toggleFullScreen = () => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
@@ -129,9 +184,35 @@ const handleLogout = () => {
|
|||||||
type: 'warning'
|
type: 'warning'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('tokenExpire')
|
||||||
|
localStorage.removeItem('userInfo')
|
||||||
|
userStore.clearUserInfo()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听路由变化,移动端自动关闭菜单
|
||||||
|
router.afterEach(() => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
closeMobileMenu()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 恢复侧边栏状态
|
||||||
|
const savedState = localStorage.getItem('sidebarCollapsed')
|
||||||
|
if (savedState !== null) {
|
||||||
|
isCollapsed.value = savedState === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测设备类型
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -141,6 +222,18 @@ const handleLogout = () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端遮罩层 */
|
||||||
|
.mobile-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 998;
|
||||||
|
}
|
||||||
|
|
||||||
/* 侧边栏样式 */
|
/* 侧边栏样式 */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
@@ -148,7 +241,15 @@ const handleLogout = () => {
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border-right: 1px solid #e1e8ed;
|
border-right: 1px solid #e1e8ed;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 20;
|
z-index: 999;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-container {
|
.logo-container {
|
||||||
@@ -159,6 +260,7 @@ const handleLogout = () => {
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border-bottom: 1px solid #e1e8ed;
|
border-bottom: 1px solid #e1e8ed;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-img {
|
.logo-img {
|
||||||
@@ -167,8 +269,15 @@ const handleLogout = () => {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo-img-mini {
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-scrollbar {
|
.sidebar-scrollbar {
|
||||||
height: calc(100vh - 70px);
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-menu {
|
.sidebar-menu {
|
||||||
@@ -178,6 +287,32 @@ const handleLogout = () => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 收缩按钮 */
|
||||||
|
.collapse-btn {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-top: 1px solid #e1e8ed;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #7f8c8d;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn:hover {
|
||||||
|
color: #2c3e50;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端菜单按钮 */
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: none;
|
||||||
|
margin-right: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
/* 主容器样式 */
|
/* 主容器样式 */
|
||||||
.main-container {
|
.main-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -185,6 +320,7 @@ const handleLogout = () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #f0f2f5;
|
background-color: #f0f2f5;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部导航栏样式 */
|
/* 顶部导航栏样式 */
|
||||||
@@ -197,18 +333,21 @@ const handleLogout = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-left {
|
.navbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-right {
|
.navbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-item {
|
.navbar-item {
|
||||||
@@ -286,6 +425,63 @@ const handleLogout = () => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端隐藏元素 */
|
||||||
|
.hidden-mobile {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobile-overlay {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: -260px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 260px;
|
||||||
|
left: -260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout.mobile-open .sidebar {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout.mobile-open .sidebar.collapsed {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-mobile {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-dropdown-menu) {
|
:deep(.el-dropdown-menu) {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: 1px solid #e1e8ed;
|
border: 1px solid #e1e8ed;
|
||||||
@@ -346,77 +542,232 @@ const handleLogout = () => {
|
|||||||
/* Element Plus 菜单项样式优化 */
|
/* Element Plus 菜单项样式优化 */
|
||||||
:deep(.el-menu) {
|
:deep(.el-menu) {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 一级菜单标题(有子菜单) */
|
||||||
:deep(.el-sub-menu__title) {
|
:deep(.el-sub-menu__title) {
|
||||||
height: 50px;
|
height: 48px;
|
||||||
line-height: 50px;
|
line-height: 48px;
|
||||||
margin: 0;
|
margin: 2px 8px;
|
||||||
padding: 0 20px;
|
padding: 0 16px !important;
|
||||||
transition: background-color 0.2s ease;
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
color: #34495e !important;
|
color: #34495e !important;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu__title:hover) {
|
:deep(.el-sub-menu__title:hover) {
|
||||||
background-color: #f8f9fa !important;
|
background-color: #f5f7fa !important;
|
||||||
color: #2c3e50 !important;
|
color: #2c3e50 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-menu-item) {
|
/* 一级菜单项(无子菜单) */
|
||||||
height: 50px;
|
:deep(.sidebar-menu > .el-menu-item) {
|
||||||
line-height: 50px;
|
height: 48px;
|
||||||
margin: 0;
|
line-height: 48px;
|
||||||
padding: 0 20px;
|
margin: 2px 8px;
|
||||||
transition: background-color 0.2s ease;
|
padding: 0 16px !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
color: #34495e !important;
|
color: #34495e !important;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-menu-item:hover) {
|
:deep(.sidebar-menu > .el-menu-item:hover) {
|
||||||
background-color: #f8f9fa !important;
|
background-color: #f5f7fa !important;
|
||||||
color: #2c3e50 !important;
|
color: #2c3e50 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-menu-item.is-active) {
|
:deep(.sidebar-menu > .el-menu-item.is-active) {
|
||||||
background-color: rgba(44, 62, 80, 0.1) !important;
|
background-color: rgba(44, 62, 80, 0.08) !important;
|
||||||
color: #2c3e50 !important;
|
color: #2c3e50 !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-menu-item.is-active::before) {
|
:deep(.sidebar-menu > .el-menu-item.is-active::before) {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 100%;
|
height: 24px;
|
||||||
|
background-color: #2c3e50;
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开的一级菜单标题 */
|
||||||
|
:deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
|
||||||
|
color: #2c3e50 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: #f5f7fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 二级菜单容器 */
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu) {
|
||||||
|
background-color: transparent !important;
|
||||||
|
padding: 4px 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 二级菜单项 */
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item) {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
margin: 2px 8px 2px 16px;
|
||||||
|
padding: 0 16px 0 28px !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
position: relative;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item::before) {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background-color: #c0c4cc;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item:hover) {
|
||||||
|
background-color: #f5f7fa !important;
|
||||||
|
color: #2c3e50 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item:hover::before) {
|
||||||
|
background-color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active) {
|
||||||
|
background-color: rgba(44, 62, 80, 0.08) !important;
|
||||||
|
color: #2c3e50 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active::before) {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
background-color: #2c3e50;
|
background-color: #2c3e50;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
/* 二级菜单中的子菜单标题(三级菜单父级) */
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title) {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
margin: 2px 8px 2px 16px;
|
||||||
|
padding: 0 16px 0 28px !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266 !important;
|
||||||
|
font-weight: 400;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title::before) {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background-color: #c0c4cc;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title:hover) {
|
||||||
|
background-color: #f5f7fa !important;
|
||||||
color: #2c3e50 !important;
|
color: #2c3e50 !important;
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu .el-menu) {
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title:hover::before) {
|
||||||
background-color: #fafbfc !important;
|
background-color: #7f8c8d;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu .el-menu-item) {
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu.is-opened > .el-sub-menu__title) {
|
||||||
margin: 0;
|
color: #2c3e50 !important;
|
||||||
padding-left: 48px !important;
|
font-weight: 500;
|
||||||
|
background-color: #f5f7fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu.is-opened > .el-sub-menu__title::before) {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 三级菜单容器 */
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu) {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu .el-menu-item.is-active) {
|
/* 三级菜单项 */
|
||||||
background-color: rgba(44, 62, 80, 0.12) !important;
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item) {
|
||||||
|
height: 36px;
|
||||||
|
line-height: 36px;
|
||||||
|
margin: 2px 8px 2px 28px;
|
||||||
|
padding: 0 16px 0 24px !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
position: relative;
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item::before) {
|
||||||
|
content: '-';
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item:hover) {
|
||||||
|
background-color: #f5f7fa !important;
|
||||||
|
color: #606266 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item:hover::before) {
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active) {
|
||||||
|
background-color: rgba(44, 62, 80, 0.08) !important;
|
||||||
|
color: #2c3e50 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active::before) {
|
||||||
|
content: '•';
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子菜单箭头图标 */
|
||||||
:deep(.el-sub-menu__icon-arrow) {
|
:deep(.el-sub-menu__icon-arrow) {
|
||||||
|
color: #909399 !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-sub-menu:hover > .el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||||
color: #7f8c8d !important;
|
color: #7f8c8d !important;
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
|
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-sub-menu v-if="hasChildren" :index="menu.path">
|
<el-sub-menu v-if="hasChildren" :index="menu.path">
|
||||||
<template #title>
|
<template #title>
|
||||||
<el-icon v-if="menu.icon || menu.meta?.icon">
|
<el-icon v-if="menu.icon || menu.meta?.icon" class="menu-icon">
|
||||||
<component :is="menu.icon || menu.meta?.icon" />
|
<component :is="menu.icon || menu.meta?.icon" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<span>{{ menu.title || menu.meta?.title }}</span>
|
<span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
|
||||||
</template>
|
</template>
|
||||||
<sidebar-menu-item
|
<sidebar-menu-item
|
||||||
v-for="child in menu.children"
|
v-for="child in menu.children"
|
||||||
:key="child.path"
|
:key="child.path"
|
||||||
:menu="child"
|
:menu="child"
|
||||||
|
:level="level + 1"
|
||||||
/>
|
/>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
<el-menu-item v-else :index="menu.path">
|
<el-menu-item v-else :index="menu.path">
|
||||||
<el-icon v-if="menu.icon || menu.meta?.icon">
|
<el-icon v-if="menu.icon || menu.meta?.icon" class="menu-icon">
|
||||||
<component :is="menu.icon || menu.meta?.icon" />
|
<component :is="menu.icon || menu.meta?.icon" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<template #title>{{ menu.title || menu.meta?.title }}</template>
|
<template #title>
|
||||||
|
<span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
|
||||||
|
</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -29,6 +32,10 @@ const props = defineProps({
|
|||||||
menu: {
|
menu: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,49 +46,45 @@ const hasChildren = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.el-icon {
|
/* 菜单图标样式 */
|
||||||
margin-right: 12px;
|
.menu-icon {
|
||||||
|
margin-right: 10px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #7f8c8d;
|
color: #7f8c8d;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-item .el-icon, :deep(.el-sub-menu__title .el-icon) {
|
/* 菜单标题 */
|
||||||
|
.menu-title {
|
||||||
|
font-size: inherit;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标交互状态 */
|
||||||
|
.el-menu-item .menu-icon,
|
||||||
|
:deep(.el-sub-menu__title .menu-icon) {
|
||||||
color: #7f8c8d !important;
|
color: #7f8c8d !important;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-item:hover .el-icon,
|
.el-menu-item:hover .menu-icon,
|
||||||
:deep(.el-sub-menu__title:hover .el-icon) {
|
:deep(.el-sub-menu__title:hover .menu-icon) {
|
||||||
color: #34495e !important;
|
color: #34495e !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 激活菜单项图标 */
|
/* 激活菜单项图标 */
|
||||||
.el-menu-item.is-active .el-icon {
|
.el-menu-item.is-active .menu-icon {
|
||||||
color: #2c3e50 !important;
|
color: #2c3e50 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu.is-active > .el-sub-menu__title .el-icon) {
|
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .menu-icon) {
|
||||||
color: #2c3e50 !important;
|
color: #2c3e50 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 菜单文字样式 */
|
|
||||||
.el-menu-item span, :deep(.el-sub-menu__title span) {
|
|
||||||
font-size: 14px;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 子菜单项样式优化 */
|
|
||||||
:deep(.el-sub-menu .el-menu-item) {
|
|
||||||
font-size: 13px;
|
|
||||||
padding-left: 48px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-sub-menu .el-menu-item .el-icon) {
|
|
||||||
font-size: 16px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="tags-view-container">
|
<div class="tags-view-container"
|
||||||
<div class="tags-view-wrapper">
|
@mouseenter="hovered = true" @mouseleave="hovered = false">
|
||||||
|
<div class="tags-view-wrapper" ref="scrollWrapperRef"
|
||||||
|
@wheel.prevent="handleWheel"
|
||||||
|
@scroll="onScroll">
|
||||||
<div class="tags-view-scroll">
|
<div class="tags-view-scroll">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="tag in visitedViews"
|
v-for="tag in visitedViews"
|
||||||
@@ -24,6 +27,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-track" :class="{ visible: hovered && hasOverflow }">
|
||||||
|
<div class="scroll-thumb" :style="thumbStyle" @mousedown="onThumbDown"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 右键菜单 -->
|
<!-- 右键菜单 -->
|
||||||
<ul v-show="visible" :style="{left: left+'px', top: top+'px'}" class="contextmenu">
|
<ul v-show="visible" :style="{left: left+'px', top: top+'px'}" class="contextmenu">
|
||||||
<li @click="refreshSelectedTag(selectedTag)">
|
<li @click="refreshSelectedTag(selectedTag)">
|
||||||
@@ -65,28 +72,20 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const tagsViewStore = useTagsViewStore()
|
const tagsViewStore = useTagsViewStore()
|
||||||
|
|
||||||
// 访问过的标签 (从 store 获取)
|
|
||||||
const visitedViews = computed(() => tagsViewStore.visitedViews)
|
const visitedViews = computed(() => tagsViewStore.visitedViews)
|
||||||
const affixTags = computed(() => tagsViewStore.affixTags)
|
const affixTags = computed(() => tagsViewStore.affixTags)
|
||||||
|
|
||||||
// 右键菜单
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const top = ref(0)
|
const top = ref(0)
|
||||||
const left = ref(0)
|
const left = ref(0)
|
||||||
const selectedTag = ref({})
|
const selectedTag = ref({})
|
||||||
|
|
||||||
// 初始化标签
|
|
||||||
const initTags = () => {
|
const initTags = () => {
|
||||||
// 如果当前路由不在访问过的标签中,添加它
|
|
||||||
if (route.name) {
|
if (route.name) {
|
||||||
tagsViewStore.addVisitedView(route)
|
tagsViewStore.addVisitedView(route)
|
||||||
}
|
}
|
||||||
// 添加固定标签(仪表盘)
|
|
||||||
const dashboardRoute = router.getRoutes().find(r => r.name === 'Dashboard')
|
const dashboardRoute = router.getRoutes().find(r => r.name === 'Dashboard')
|
||||||
if (dashboardRoute) {
|
if (dashboardRoute) {
|
||||||
// 注意:这里我们直接修改 store 的 affixTags,或者 store 应该提供一个 action
|
|
||||||
// 简单起见,我们假设 store 的 affixTags 是可以直接修改的 ref,或者我们在 store 中添加初始化逻辑
|
|
||||||
// 但为了保持一致性,我们这里只处理 visitedViews 的添加
|
|
||||||
if (!tagsViewStore.affixTags.some(tag => tag.path === dashboardRoute.path)) {
|
if (!tagsViewStore.affixTags.some(tag => tag.path === dashboardRoute.path)) {
|
||||||
tagsViewStore.affixTags.push(dashboardRoute)
|
tagsViewStore.affixTags.push(dashboardRoute)
|
||||||
}
|
}
|
||||||
@@ -94,13 +93,11 @@ const initTags = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新选中的标签
|
|
||||||
const refreshSelectedTag = (view) => {
|
const refreshSelectedTag = (view) => {
|
||||||
const { fullPath } = view
|
const { fullPath } = view
|
||||||
router.replace('/redirect' + fullPath)
|
router.replace('/redirect' + fullPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭选中的标签
|
|
||||||
const closeSelectedTag = (view) => {
|
const closeSelectedTag = (view) => {
|
||||||
tagsViewStore.delVisitedView(view).then((visitedViews) => {
|
tagsViewStore.delVisitedView(view).then((visitedViews) => {
|
||||||
if (isActive(view)) {
|
if (isActive(view)) {
|
||||||
@@ -109,15 +106,11 @@ const closeSelectedTag = (view) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭其他标签
|
|
||||||
const closeOthersTags = () => {
|
const closeOthersTags = () => {
|
||||||
router.push(selectedTag.value)
|
router.push(selectedTag.value)
|
||||||
tagsViewStore.delOthersViews(selectedTag.value).then(() => {
|
tagsViewStore.delOthersViews(selectedTag.value)
|
||||||
// moveToCurrentTag() // 如果有滚动逻辑
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭左侧标签
|
|
||||||
const closeLeftTags = () => {
|
const closeLeftTags = () => {
|
||||||
tagsViewStore.delLeftViews(selectedTag.value).then((visitedViews) => {
|
tagsViewStore.delLeftViews(selectedTag.value).then((visitedViews) => {
|
||||||
if (!visitedViews.find(i => i.path === route.path)) {
|
if (!visitedViews.find(i => i.path === route.path)) {
|
||||||
@@ -126,7 +119,6 @@ const closeLeftTags = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭右侧标签
|
|
||||||
const closeRightTags = () => {
|
const closeRightTags = () => {
|
||||||
tagsViewStore.delRightViews(selectedTag.value).then((visitedViews) => {
|
tagsViewStore.delRightViews(selectedTag.value).then((visitedViews) => {
|
||||||
if (!visitedViews.find(i => i.path === route.path)) {
|
if (!visitedViews.find(i => i.path === route.path)) {
|
||||||
@@ -135,20 +127,17 @@ const closeRightTags = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭所有标签
|
|
||||||
const closeAllTags = () => {
|
const closeAllTags = () => {
|
||||||
tagsViewStore.delAllViews().then((visitedViews) => {
|
tagsViewStore.delAllViews().then((visitedViews) => {
|
||||||
toLastView(visitedViews)
|
toLastView(visitedViews)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转到最后一个标签或首页
|
|
||||||
const toLastView = (visitedViews, view) => {
|
const toLastView = (visitedViews, view) => {
|
||||||
const latestView = visitedViews.slice(-1)[0]
|
const latestView = visitedViews.slice(-1)[0]
|
||||||
if (latestView) {
|
if (latestView) {
|
||||||
router.push(latestView)
|
router.push(latestView)
|
||||||
} else {
|
} else {
|
||||||
// 如果没有标签,则跳转到首页
|
|
||||||
if (view && view.name === 'Dashboard') {
|
if (view && view.name === 'Dashboard') {
|
||||||
router.push('/redirect' + '/dashboard')
|
router.push('/redirect' + '/dashboard')
|
||||||
} else {
|
} else {
|
||||||
@@ -157,17 +146,14 @@ const toLastView = (visitedViews, view) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否是当前激活的标签
|
|
||||||
const isActive = (tag) => {
|
const isActive = (tag) => {
|
||||||
return tag.path === route.path
|
return tag.path === route.path
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否是固定标签
|
|
||||||
const isAffix = (tag) => {
|
const isAffix = (tag) => {
|
||||||
return tag.meta && tag.meta.affix
|
return tag.meta && tag.meta.affix
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开右键菜单
|
|
||||||
const openMenu = (e, tag) => {
|
const openMenu = (e, tag) => {
|
||||||
const menuMinWidth = 125
|
const menuMinWidth = 125
|
||||||
const offsetLeft = e.clientX
|
const offsetLeft = e.clientX
|
||||||
@@ -181,30 +167,112 @@ const openMenu = (e, tag) => {
|
|||||||
selectedTag.value = tag
|
selectedTag.value = tag
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭右键菜单
|
// ---- 滚动 & 滚动条 ----
|
||||||
|
const scrollWrapperRef = ref(null)
|
||||||
|
const hovered = ref(false)
|
||||||
|
const hasOverflow = ref(false)
|
||||||
|
const thumbStyle = ref({ width: '0px', left: '0px' })
|
||||||
|
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
if (scrollWrapperRef.value) {
|
||||||
|
scrollWrapperRef.value.scrollLeft += e.deltaY || e.deltaX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshState = () => {
|
||||||
|
const el = scrollWrapperRef.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = el
|
||||||
|
const maxScroll = scrollWidth - clientWidth
|
||||||
|
hasOverflow.value = maxScroll > 1
|
||||||
|
|
||||||
|
if (!hasOverflow.value) {
|
||||||
|
thumbStyle.value = { width: '0px', left: '0px' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackWidth = clientWidth
|
||||||
|
const thumbW = Math.max((clientWidth / scrollWidth) * trackWidth, 30)
|
||||||
|
const scrollRatio = maxScroll > 0 ? scrollLeft / maxScroll : 0
|
||||||
|
const thumbLeft = scrollRatio * (trackWidth - thumbW)
|
||||||
|
thumbStyle.value = { width: thumbW + 'px', left: thumbLeft + 'px' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
refreshState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToActiveTag = () => {
|
||||||
|
const el = scrollWrapperRef.value
|
||||||
|
if (!el) return
|
||||||
|
const activeEl = el.querySelector('.active-tag')
|
||||||
|
if (!activeEl) return
|
||||||
|
const wrapperRect = el.getBoundingClientRect()
|
||||||
|
const tagRect = activeEl.getBoundingClientRect()
|
||||||
|
if (tagRect.left < wrapperRect.left + 28) {
|
||||||
|
el.scrollLeft -= (wrapperRect.left + 28 - tagRect.left + 12)
|
||||||
|
} else if (tagRect.right > wrapperRect.right - 28) {
|
||||||
|
el.scrollLeft += (tagRect.right - wrapperRect.right + 28 + 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onThumbDown = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const el = scrollWrapperRef.value
|
||||||
|
if (!el) return
|
||||||
|
const startX = e.clientX
|
||||||
|
const startScroll = el.scrollLeft
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth
|
||||||
|
const trackWidth = el.clientWidth
|
||||||
|
const thumbW = Math.max((el.clientWidth / el.scrollWidth) * trackWidth, 30)
|
||||||
|
const movable = trackWidth - thumbW
|
||||||
|
|
||||||
|
const onMove = (ev) => {
|
||||||
|
const dx = ev.clientX - startX
|
||||||
|
const scrollDelta = movable > 0 ? (dx / movable) * maxScroll : 0
|
||||||
|
el.scrollLeft = Math.min(Math.max(startScroll + scrollDelta, 0), maxScroll)
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visitedViews, () => nextTick(() => { refreshState(); scrollToActiveTag() }), { deep: true })
|
||||||
|
|
||||||
const closeMenu = () => {
|
const closeMenu = () => {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听路由变化,添加标签
|
|
||||||
watch(route, (newRoute) => {
|
watch(route, (newRoute) => {
|
||||||
if (newRoute.name) {
|
if (newRoute.name) {
|
||||||
tagsViewStore.addVisitedView(newRoute)
|
tagsViewStore.addVisitedView(newRoute)
|
||||||
}
|
}
|
||||||
|
nextTick(scrollToActiveTag)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 点击其他区域关闭右键菜单
|
|
||||||
const handleClickOutside = () => {
|
const handleClickOutside = () => {
|
||||||
closeMenu()
|
closeMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
refreshState()
|
||||||
|
scrollToActiveTag()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initTags()
|
initTags()
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
nextTick(() => { refreshState(); scrollToActiveTag() })
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -215,17 +283,21 @@ onBeforeUnmount(() => {
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border-bottom: 1px solid #e1e8ed;
|
border-bottom: 1px solid #e1e8ed;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 标签滚动区域 */
|
||||||
.tags-view-wrapper {
|
.tags-view-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
overflow-x: scroll;
|
||||||
display: flex;
|
overflow-y: hidden;
|
||||||
align-items: center;
|
scrollbar-width: none;
|
||||||
padding: 0 12px;
|
-ms-overflow-style: none;
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-view-wrapper::-webkit-scrollbar {
|
.tags-view-wrapper::-webkit-scrollbar {
|
||||||
@@ -233,18 +305,51 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tags-view-scroll {
|
.tags-view-scroll {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 底部自定义滚动条(在容器上,不在滚动区域内) */
|
||||||
|
.scroll-track {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-track.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(180,188,199,0.45);
|
||||||
|
transition: background 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-thumb:hover {
|
||||||
|
background: rgba(180,188,199,0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签样式 */
|
||||||
.tag, .active-tag {
|
.tag, .active-tag {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
margin-right: 0;
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -252,6 +357,8 @@ onBeforeUnmount(() => {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
@@ -326,6 +433,7 @@ onBeforeUnmount(() => {
|
|||||||
background-color: rgba(231, 76, 60, 0.1);
|
background-color: rgba(231, 76, 60, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 右键菜单 */
|
||||||
.contextmenu {
|
.contextmenu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 环境配置文件
|
||||||
|
* 所有硬编码的 URL / 域名 / 环境变量统一在此管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 当前环境
|
||||||
|
const isDevelopment = import.meta.env.MODE === 'development'
|
||||||
|
|
||||||
|
// API 基础地址
|
||||||
|
// 开发环境使用 vite 代理 (baseUrl 为空),生产环境使用实际地址
|
||||||
|
const API_BASE_MAP = {
|
||||||
|
development: import.meta.env.VITE_API_BASE_URL || 'https://apiservertest.s1f.ren', // 直接请求后端,不走 vite proxy
|
||||||
|
production: import.meta.env.VITE_API_BASE_URL || 'https://cloudapi.007yjs.com',
|
||||||
|
staging: import.meta.env.VITE_API_BASE_URL || 'https://apiservertest.s1f.ren'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前环境的 API 基础地址
|
||||||
|
const currentEnv = import.meta.env.VITE_APP_ENV || import.meta.env.MODE || 'development'
|
||||||
|
export const baseUrl = API_BASE_MAP[currentEnv] || API_BASE_MAP.development
|
||||||
|
|
||||||
|
// ACS 服务基础地址
|
||||||
|
export const acsBaseUrl = baseUrl
|
||||||
|
|
||||||
|
// 网站标题
|
||||||
|
export const siteTitle = '007UI管理系统'
|
||||||
|
|
||||||
|
// 请求超时时间(毫秒)
|
||||||
|
export const requestTimeout = 50000
|
||||||
|
export const acsRequestTimeout = 30000
|
||||||
|
|
||||||
|
// Token 存储键名
|
||||||
|
export const TOKEN_KEY = 'token'
|
||||||
|
export const TOKEN_EXPIRE_KEY = 'tokenExpire'
|
||||||
|
export const USER_INFO_KEY = 'userInfo'
|
||||||
|
|
||||||
|
// 不需要 token 认证的 URL 前缀
|
||||||
|
export const noAuthUrls = [
|
||||||
|
'/v1/user/login',
|
||||||
|
'/v1/user/check/get_code_img',
|
||||||
|
'/v1/user/register',
|
||||||
|
'/v1/user/refresh_token'
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
isDevelopment,
|
||||||
|
baseUrl,
|
||||||
|
acsBaseUrl,
|
||||||
|
siteTitle,
|
||||||
|
requestTimeout,
|
||||||
|
acsRequestTimeout,
|
||||||
|
TOKEN_KEY,
|
||||||
|
TOKEN_EXPIRE_KEY,
|
||||||
|
USER_INFO_KEY,
|
||||||
|
noAuthUrls
|
||||||
|
}
|
||||||
+48
-19
@@ -6,8 +6,14 @@ export const menus = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/ticket',
|
path: '/ticket',
|
||||||
title: '工单处理',
|
title: '工单管理',
|
||||||
icon: 'DataBoard'
|
icon: 'Tickets',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/ticket/list',
|
||||||
|
title: '工单列表'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/user',
|
path: '/user',
|
||||||
@@ -18,10 +24,6 @@ export const menus = [
|
|||||||
path: '/user/list',
|
path: '/user/list',
|
||||||
title: '用户列表'
|
title: '用户列表'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/user/balance',
|
|
||||||
title: '用户余额管理'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/user/group',
|
path: '/user/group',
|
||||||
title: '用户组管理'
|
title: '用户组管理'
|
||||||
@@ -37,15 +39,16 @@ export const menus = [
|
|||||||
title: '商品管理',
|
title: '商品管理',
|
||||||
icon: 'Goods',
|
icon: 'Goods',
|
||||||
children: [
|
children: [
|
||||||
{
|
{ path: '/product/manage', title: '商品管理' }
|
||||||
path: '/product/list',
|
]
|
||||||
title: '商品列表'
|
},
|
||||||
},
|
{
|
||||||
{
|
path: '/user-goods',
|
||||||
path: '/product/group',
|
title: '用户商品管理',
|
||||||
title: '商品分组'
|
icon: 'ShoppingCart',
|
||||||
},
|
children: [
|
||||||
|
{ path: '/user-goods/list', title: '所有商品' },
|
||||||
|
{ path: '/user-goods/vm-list', title: '云服务器' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,6 +86,10 @@ export const menus = [
|
|||||||
{
|
{
|
||||||
path: '/activity/signin',
|
path: '/activity/signin',
|
||||||
title: '签到活动'
|
title: '签到活动'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/activity/groupbuy',
|
||||||
|
title: '拼团管理'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -134,6 +141,24 @@ export const menus = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '虚拟化平台管理',
|
||||||
|
icon: 'Platform',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/virtualization/kvm-service',
|
||||||
|
title: '主控服务管理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/virtualization/host-group-mapping',
|
||||||
|
title: '宿主机组映射管理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/virtualization/vnc-command',
|
||||||
|
title: 'VNC指令管理'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/system',
|
path: '/system',
|
||||||
title: '系统管理',
|
title: '系统管理',
|
||||||
@@ -158,12 +183,16 @@ export const menus = [
|
|||||||
title: '域名白名单'
|
title: '域名白名单'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/setting-group',
|
path: '/system/setting-manage',
|
||||||
title: '配置组管理'
|
title: '配置管理'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/setting-list',
|
path: '/system/menu',
|
||||||
title: '配置管理'
|
title: '菜单管理',
|
||||||
|
children: [
|
||||||
|
{ path: '/system/menu-manage', title: '菜单列表' },
|
||||||
|
{ path: '/system/menu-permission', title: '菜单权限' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-26
@@ -39,7 +39,27 @@ const routes = [
|
|||||||
title: '工单管理',
|
title: '工单管理',
|
||||||
icon: 'Tickets'
|
icon: 'Tickets'
|
||||||
},
|
},
|
||||||
component: () => import('../views/ticket/TicketChat.vue'),
|
redirect: '/ticket/list',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'list',
|
||||||
|
name: 'TicketList',
|
||||||
|
component: () => import('../views/ticket/TicketList.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '工单列表'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'detail',
|
||||||
|
name: 'TicketDetail',
|
||||||
|
component: () => import('../views/ticket/TicketDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '工单详情',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/ticket/list'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// ACS管理路由
|
// ACS管理路由
|
||||||
@@ -208,29 +228,50 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: 'product',
|
path: 'product',
|
||||||
name: 'Product',
|
name: 'Product',
|
||||||
meta: {
|
meta: { title: '商品管理', icon: 'Goods' },
|
||||||
title: '商品管理',
|
redirect: '/product/manage',
|
||||||
icon: 'Goods'
|
children: [
|
||||||
},
|
{
|
||||||
redirect: '/product/list',
|
path: 'manage',
|
||||||
|
name: 'ProductManage',
|
||||||
|
component: () => import('../views/product/ProductGroup.vue'),
|
||||||
|
meta: { title: '商品管理' }
|
||||||
|
},
|
||||||
|
{ path: 'list', redirect: '/product/manage' },
|
||||||
|
{ path: 'group', redirect: '/product/manage' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// 用户商品管理路由
|
||||||
|
{
|
||||||
|
path: 'user-goods',
|
||||||
|
name: 'UserGoods',
|
||||||
|
meta: { title: '用户商品管理', icon: 'ShoppingCart' },
|
||||||
|
redirect: '/user-goods/list',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'list',
|
path: 'list',
|
||||||
name: 'ProductList',
|
name: 'UserGoodsList',
|
||||||
component: () => import('../views/product/ProductList.vue'),
|
component: () => import('../views/product/UserGoodsList.vue'),
|
||||||
meta: {
|
meta: { title: '所有商品' }
|
||||||
title: '商品列表'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'group',
|
path: 'detail/:id',
|
||||||
name: 'ProductGroup',
|
name: 'UserGoodsDetail',
|
||||||
component: () => import('../views/product/ProductGroup.vue'),
|
component: () => import('../views/product/UserGoodsDetail.vue'),
|
||||||
meta: {
|
meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' }
|
||||||
title: '商品分组'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'vm-list',
|
||||||
|
name: 'UserVmList',
|
||||||
|
component: () => import('../views/user-vm/UserVmList.vue'),
|
||||||
|
meta: { title: '云服务器' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vm-detail',
|
||||||
|
name: 'UserVmDetail',
|
||||||
|
component: () => import('../views/user-vm/UserVmDetail.vue'),
|
||||||
|
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// 订单管理路由
|
// 订单管理路由
|
||||||
@@ -309,6 +350,14 @@ const routes = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: '签到活动'
|
title: '签到活动'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/activity/groupbuy',
|
||||||
|
name: 'GroupBuyManage',
|
||||||
|
component: () => import('../views/activity/GroupBuyManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '拼团管理'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -354,16 +403,187 @@ const routes = [
|
|||||||
meta: { title: '域名白名单' }
|
meta: { title: '域名白名单' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'setting-group',
|
path: 'setting-manage',
|
||||||
name: 'SettingGroup',
|
name: 'SettingManage',
|
||||||
component: () => import('../views/system/SettingGroup.vue'),
|
component: () => import('../views/system/SettingManage.vue'),
|
||||||
meta: { title: '配置组管理' }
|
meta: { title: '配置管理' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'setting-list',
|
path: 'menu-manage',
|
||||||
name: 'SettingList',
|
name: 'MenuManage',
|
||||||
component: () => import('../views/system/Setting.vue'),
|
component: () => import('../views/system/MenuManage.vue'),
|
||||||
meta: { title: '配置管理' }
|
meta: { title: '菜单管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'menu-permission',
|
||||||
|
name: 'MenuPermission',
|
||||||
|
component: () => import('../views/system/MenuPermission.vue'),
|
||||||
|
meta: { title: '菜单权限' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'virtualization',
|
||||||
|
name: 'Virtualization',
|
||||||
|
meta: {
|
||||||
|
title: '虚拟化平台管理',
|
||||||
|
icon: 'Platform'
|
||||||
|
},
|
||||||
|
redirect: '/virtualization/kvm-service',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'kvm-service',
|
||||||
|
name: 'KvmService',
|
||||||
|
component: () => import('../views/virtualization/KvmService.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '主控服务管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'kvm-service-detail',
|
||||||
|
name: 'KvmServiceDetail',
|
||||||
|
component: () => import('../views/virtualization/KvmServiceDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '主控服务详情',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'host-group-mapping',
|
||||||
|
name: 'HostGroupMapping',
|
||||||
|
component: () => import('../views/virtualization/HostGroupMapping.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '宿主机组映射管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'host-manage',
|
||||||
|
name: 'HostManage',
|
||||||
|
component: () => import('../views/virtualization/HostManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '宿主机管理',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'image-manage',
|
||||||
|
name: 'ImageManage',
|
||||||
|
component: () => import('../views/virtualization/ImageManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '镜像管理',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'network-manage',
|
||||||
|
name: 'NetworkManage',
|
||||||
|
component: () => import('../views/virtualization/NetworkManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '网络管理',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'volume-manage',
|
||||||
|
name: 'VolumeManage',
|
||||||
|
component: () => import('../views/virtualization/VolumeManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '数据卷管理',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vm-manage',
|
||||||
|
name: 'VmManage',
|
||||||
|
component: () => import('../views/virtualization/VmManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '虚拟机管理',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'security-group',
|
||||||
|
name: 'SecurityGroupManage',
|
||||||
|
component: () => import('../views/virtualization/SecurityGroupManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '安全组管理',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vnc-node',
|
||||||
|
name: 'VncNodeManage',
|
||||||
|
component: () => import('../views/virtualization/VncNodeManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'VNC节点管理',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vnc-command',
|
||||||
|
name: 'VncCommandManage',
|
||||||
|
component: () => import('../views/virtualization/VncCommandManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'VNC指令管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'host-detail',
|
||||||
|
name: 'VirtHostDetail',
|
||||||
|
component: () => import('../views/virtualization/HostDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '宿主机详情',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'image-detail',
|
||||||
|
name: 'VirtImageDetail',
|
||||||
|
component: () => import('../views/virtualization/ImageDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '镜像详情',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vm-detail',
|
||||||
|
name: 'VirtVmDetail',
|
||||||
|
component: () => import('../views/virtualization/VmDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '虚拟机详情',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'security-group-detail',
|
||||||
|
name: 'VirtSecurityGroupDetail',
|
||||||
|
component: () => import('../views/virtualization/SecurityGroupDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '安全组详情',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'volume-detail',
|
||||||
|
name: 'VirtVolumeDetail',
|
||||||
|
component: () => import('../views/virtualization/VolumeDetail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '数据卷详情',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-2
@@ -4,11 +4,28 @@ import {ref} from "vue";
|
|||||||
|
|
||||||
export const useUserStore = defineStore('userStore',() => {
|
export const useUserStore = defineStore('userStore',() => {
|
||||||
|
|
||||||
let userInfo = ref({})
|
// 初始化时从localStorage读取用户信息
|
||||||
|
const savedUserInfo = localStorage.getItem('userInfo')
|
||||||
|
let userInfo = ref(savedUserInfo ? JSON.parse(savedUserInfo) : {})
|
||||||
|
|
||||||
function setUserInfo(u){
|
function setUserInfo(u){
|
||||||
userInfo.value = u
|
userInfo.value = u
|
||||||
|
// 同步保存到localStorage
|
||||||
|
if (u && Object.keys(u).length > 0) {
|
||||||
|
localStorage.setItem('userInfo', JSON.stringify(u))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {userInfo,setUserInfo}
|
// 清除用户信息
|
||||||
|
function clearUserInfo() {
|
||||||
|
userInfo.value = {}
|
||||||
|
localStorage.removeItem('userInfo')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户头像
|
||||||
|
function getUserAvatar() {
|
||||||
|
return userInfo.value?.cover || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {userInfo, setUserInfo, clearUserInfo, getUserAvatar}
|
||||||
})
|
})
|
||||||
+374
-1
@@ -114,11 +114,384 @@ body {
|
|||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式工具类 */
|
/* 可点击元素统一手型光标 */
|
||||||
|
.el-button,
|
||||||
|
.el-button--link,
|
||||||
|
.el-tag.is-closable .el-tag__close,
|
||||||
|
.el-dropdown,
|
||||||
|
.el-dropdown-menu__item,
|
||||||
|
.el-switch,
|
||||||
|
.el-checkbox,
|
||||||
|
.el-radio,
|
||||||
|
.el-select .el-input__wrapper,
|
||||||
|
.el-table__body tr.el-table__row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 全局弹窗卡片样式 ==================== */
|
||||||
|
/* 自动为所有未手动分区的弹窗表单添加卡片背景 */
|
||||||
|
.el-dialog:not(.tk-dialog):not(.token-dialog):not(.token-result-dialog) .el-dialog__body > .el-form {
|
||||||
|
background: #fafbfc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 20px 4px;
|
||||||
|
border: 1px solid #f0f2f5;
|
||||||
|
}
|
||||||
|
/* 统一弹窗 footer 按钮对齐 */
|
||||||
|
.el-dialog .el-dialog__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.tk-dialog .el-dialog__body {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
|
}
|
||||||
|
.tk-dialog .el-dialog__body::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari */
|
||||||
|
}
|
||||||
|
.tk-dialog .el-form {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.tk-section {
|
||||||
|
background: #fafbfc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 20px 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid #f0f2f5;
|
||||||
|
}
|
||||||
|
.tk-section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1d2129;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid #409eff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.tk-dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tk-resource-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 24px;
|
||||||
|
}
|
||||||
|
.tk-resource-grid .el-form-item {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.tk-resource-grid .el-form-item .el-form-item__label {
|
||||||
|
width: 80px !important;
|
||||||
|
}
|
||||||
|
.tk-resource-grid .el-form-item .el-form-item__content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.tk-resource-grid .el-input-number {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tk-unit-select {
|
||||||
|
width: 68px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tk-res-unit {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tk-inline-unit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tk-inline-unit .el-input-number,
|
||||||
|
.tk-inline-unit .el-input,
|
||||||
|
.tk-inline-unit .el-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 全局页面布局组件 ==================== */
|
||||||
|
|
||||||
|
/* 页面头部 */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.page-header .header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.page-header .header-info h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1d2129;
|
||||||
|
}
|
||||||
|
.page-header .sub-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.page-header .header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 嵌入式工具栏 */
|
||||||
|
.embedded-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用工具栏 */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 筛选栏 */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 筛选区域(卡片式) */
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页 */
|
||||||
|
.pagination-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 绑定选择器行 */
|
||||||
|
.bind-selector-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情操作按钮组 */
|
||||||
|
.detail-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 全局表格增强 ==================== */
|
||||||
|
.el-table {
|
||||||
|
--el-table-header-bg-color: #fafafa;
|
||||||
|
--el-table-row-hover-bg-color: #f5f7fa;
|
||||||
|
--el-table-border-color: #ebeef5;
|
||||||
|
}
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #1d2129 !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
border-bottom: 2px solid #e1e8ed !important;
|
||||||
|
}
|
||||||
|
.el-table td.el-table__cell {
|
||||||
|
border-bottom: 1px solid #f0f2f5 !important;
|
||||||
|
color: #34495e !important;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.el-table .el-table__empty-block {
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.el-table .el-table__empty-text {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
/* 表格固定列阴影 */
|
||||||
|
.el-table__fixed {
|
||||||
|
box-shadow: 4px 0 8px -4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.el-table__fixed-right {
|
||||||
|
box-shadow: -4px 0 8px -4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 全局骨架屏样式 ==================== */
|
||||||
|
@keyframes tk-skeleton-loading {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
.skeleton-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.skeleton-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.skeleton-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.skeleton-cell {
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: tk-skeleton-loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 全局过渡动画 ==================== */
|
||||||
|
.el-table,
|
||||||
|
.el-card,
|
||||||
|
.el-tag,
|
||||||
|
.el-button {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 通用文本类 ==================== */
|
||||||
|
.text-muted {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
.mono-text {
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
color: #409eff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 视觉增强 ==================== */
|
||||||
|
|
||||||
|
/* 卡片式筛选区域 */
|
||||||
|
.filter-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作栏 */
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用结果/令牌展示 */
|
||||||
|
.tk-result-wrapper { text-align: center; }
|
||||||
|
.tk-result-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; text-align: left; }
|
||||||
|
.tk-result-icon { font-size: 36px; color: #e6a23c; background: #fdf6ec; border-radius: 50%; padding: 10px; }
|
||||||
|
.tk-result-name { font-size: 16px; font-weight: 600; color: #1d2129; }
|
||||||
|
.tk-result-meta { font-size: 13px; color: #909399; margin-top: 2px; }
|
||||||
|
.tk-token-block { background: #1d2129; border-radius: 8px; padding: 16px; margin-bottom: 16px; text-align: left; }
|
||||||
|
.tk-token-label { font-size: 11px; color: #909399; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
|
.tk-token-value { font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 13px; color: #67c23a; word-break: break-all; line-height: 1.6; user-select: all; }
|
||||||
|
.tk-copy-btn { width: 100%; }
|
||||||
|
|
||||||
|
/* 表单提示 */
|
||||||
|
.form-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 资源信息标签组 */
|
||||||
|
.resource-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 响应式工具类 ==================== */
|
||||||
|
|
||||||
|
/* 表格横向滚动提示 */
|
||||||
|
.el-table {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hidden-xs {
|
.hidden-xs {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.page-header .header-right {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filter-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.filter-bar .el-input,
|
||||||
|
.filter-bar .el-select {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.pagination-wrapper {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pagination-wrapper .el-pagination {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
/* 弹窗在移动端更宽 */
|
||||||
|
.el-dialog {
|
||||||
|
width: 92% !important;
|
||||||
|
margin: 5vh auto !important;
|
||||||
|
}
|
||||||
|
/* 表格小屏字号调整 */
|
||||||
|
.el-table td.el-table__cell {
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
/* 表单小屏行距压缩 */
|
||||||
|
.el-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
/* tk-resource-grid 在移动端变为单列 */
|
||||||
|
.tk-resource-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中等屏幕适配 */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.el-table .el-table__body-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) and (max-width: 992px) {
|
@media (min-width: 768px) and (max-width: 992px) {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic Unit System
|
||||||
|
*
|
||||||
|
* Handles dynamic unit conversion and display for product parameters.
|
||||||
|
* Base units: storage=GB, bandwidth=Mbps, cpu=Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UNIT_CONVERSIONS = {
|
||||||
|
cpu: { Core: 1 },
|
||||||
|
bandwidth_up: { Mbps: 1, Gbps: 1000 },
|
||||||
|
bandwidth_down: { Mbps: 1, Gbps: 1000 },
|
||||||
|
storage: { GB: 1, TB: 1024 },
|
||||||
|
ipv4: { '个': 1 },
|
||||||
|
ipv6: { '个': 1 },
|
||||||
|
custom: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_UNITS = {
|
||||||
|
cpu: 'Core',
|
||||||
|
bandwidth_up: 'Mbps',
|
||||||
|
bandwidth_down: 'Mbps',
|
||||||
|
storage: 'GB',
|
||||||
|
ipv4: '个',
|
||||||
|
ipv6: '个',
|
||||||
|
custom: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DISPLAY_UNITS = {
|
||||||
|
cpu: 'Core',
|
||||||
|
bandwidth_up: 'Mbps',
|
||||||
|
bandwidth_down: 'Mbps',
|
||||||
|
storage: 'GB',
|
||||||
|
ipv4: '个',
|
||||||
|
ipv6: '个',
|
||||||
|
custom: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARG_KEY_OPTIONS = [
|
||||||
|
{ label: 'CPU (cpu)', value: 'cpu' },
|
||||||
|
{ label: 'IPv4', value: 'ipv4' },
|
||||||
|
{ label: 'IPv6', value: 'ipv6' },
|
||||||
|
{ label: '上行带宽 (bandwidth_up)', value: 'bandwidth_up' },
|
||||||
|
{ label: '下行带宽 (bandwidth_down)', value: 'bandwidth_down' },
|
||||||
|
{ label: '存储空间 (storage)', value: 'storage' },
|
||||||
|
{ label: '自定义 (custom)', value: 'custom' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert value between units
|
||||||
|
* @param {number} value
|
||||||
|
* @param {string} fromUnit
|
||||||
|
* @param {string} toUnit
|
||||||
|
* @param {string} argKey - e.g. 'storage', 'bandwidth_up'
|
||||||
|
*/
|
||||||
|
export function convertUnit(value, fromUnit, toUnit, argKey) {
|
||||||
|
if (value === null || value === undefined || fromUnit === toUnit) return value
|
||||||
|
const conversions = UNIT_CONVERSIONS[argKey]
|
||||||
|
if (!conversions || !conversions[fromUnit] || !conversions[toUnit]) return value
|
||||||
|
const baseValue = value * conversions[fromUnit]
|
||||||
|
return baseValue / conversions[toUnit]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert from display unit to base unit for storage/submission
|
||||||
|
*/
|
||||||
|
export function toBaseUnit(value, displayUnit, argKey) {
|
||||||
|
const baseUnit = BASE_UNITS[argKey]
|
||||||
|
if (!baseUnit || !displayUnit) return value
|
||||||
|
return convertUnit(value, displayUnit, baseUnit, argKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert from base unit to display unit for showing in UI
|
||||||
|
*/
|
||||||
|
export function fromBaseUnit(value, displayUnit, argKey) {
|
||||||
|
const baseUnit = BASE_UNITS[argKey]
|
||||||
|
if (!baseUnit || !displayUnit) return value
|
||||||
|
return convertUnit(value, baseUnit, displayUnit, argKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base unit string for a given argKey
|
||||||
|
*/
|
||||||
|
export function getBaseUnit(argKey) {
|
||||||
|
return BASE_UNITS[argKey] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default display unit for a given argKey
|
||||||
|
*/
|
||||||
|
export function getDefaultDisplayUnit(argKey) {
|
||||||
|
return DEFAULT_DISPLAY_UNITS[argKey] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available units for a parameter type
|
||||||
|
*/
|
||||||
|
export function getAvailableUnits(argKey) {
|
||||||
|
const conversions = UNIT_CONVERSIONS[argKey]
|
||||||
|
return conversions ? Object.keys(conversions) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get argKey select options
|
||||||
|
*/
|
||||||
|
export function getArgKeyOptions() {
|
||||||
|
return ARG_KEY_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a parameter has dynamic unit enabled.
|
||||||
|
* Returns true when arg_key maps to a known unit type with multiple selectable units.
|
||||||
|
*/
|
||||||
|
export function hasUnit(param) {
|
||||||
|
if (!param) return false
|
||||||
|
const argKey = param.argKey || param.arg_key || param.key || ''
|
||||||
|
if (!argKey || !(argKey in UNIT_CONVERSIONS)) return false
|
||||||
|
return Object.keys(UNIT_CONVERSIONS[argKey]).length > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the argKey from a parameter object (handles camelCase, snake_case, and plain key)
|
||||||
|
*/
|
||||||
|
export function getArgKey(param) {
|
||||||
|
if (!param) return ''
|
||||||
|
return param.argKey || param.arg_key || param.key || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the available units from a parameter object
|
||||||
|
*/
|
||||||
|
export function getParamUnits(param) {
|
||||||
|
if (!hasUnit(param)) return []
|
||||||
|
const argKey = getArgKey(param)
|
||||||
|
const paramUnits = param.availableUnits || param.available_units
|
||||||
|
if (paramUnits && paramUnits.length > 0) return paramUnits
|
||||||
|
return getAvailableUnits(argKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default unit from a parameter object
|
||||||
|
*/
|
||||||
|
export function getParamDefaultUnit(param) {
|
||||||
|
if (!hasUnit(param)) return ''
|
||||||
|
const argKey = getArgKey(param)
|
||||||
|
return param.defaultUnit || param.default_unit || getDefaultDisplayUnit(argKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a unit is valid for a parameter type
|
||||||
|
*/
|
||||||
|
export function isValidUnit(unit, argKey) {
|
||||||
|
const conversions = UNIT_CONVERSIONS[argKey]
|
||||||
|
return conversions && Object.prototype.hasOwnProperty.call(conversions, unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValueWithUnit(value, unit) {
|
||||||
|
if (value === null || value === undefined || value === '') return '-'
|
||||||
|
return unit ? `${value} ${unit}` : String(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
const ERROR_CODE_MAP = {
|
||||||
|
// 主控服务
|
||||||
|
kvm_service_list_error: '获取主控服务列表失败',
|
||||||
|
kvm_service_detail_error: '获取主控服务详情失败',
|
||||||
|
kvm_service_create_error: '创建主控服务失败',
|
||||||
|
kvm_service_update_error: '修改主控服务失败',
|
||||||
|
kvm_service_delete_error: '删除主控服务失败',
|
||||||
|
|
||||||
|
// 宿主机组(本地)
|
||||||
|
kvm_host_group_list_error: '获取宿主机组列表失败',
|
||||||
|
kvm_host_group_sync_error: '同步宿主机组失败',
|
||||||
|
kvm_host_group_bind_error: '绑定宿主机组失败',
|
||||||
|
kvm_host_group_update_error: '修改宿主机组失败',
|
||||||
|
kvm_host_group_delete_error: '删除宿主机组失败',
|
||||||
|
kvm_host_group_generate_error: '生成商品失败',
|
||||||
|
kvm_host_group_optimal_error: '获取最优主机失败',
|
||||||
|
|
||||||
|
// 宿主机组(远程)
|
||||||
|
kvm_remote_host_group_list_error: '获取远程宿主机组列表失败',
|
||||||
|
kvm_remote_host_group_detail_error: '获取远程宿主机组详情失败',
|
||||||
|
kvm_remote_host_group_tree_error: '获取远程宿主机组树失败',
|
||||||
|
kvm_remote_host_group_create_error: '创建远程宿主机组失败',
|
||||||
|
kvm_remote_host_group_update_error: '修改远程宿主机组失败',
|
||||||
|
kvm_remote_host_group_delete_error: '删除远程宿主机组失败',
|
||||||
|
|
||||||
|
// 宿主机
|
||||||
|
kvm_host_list_error: '获取宿主机列表失败',
|
||||||
|
kvm_host_detail_error: '获取宿主机详情失败',
|
||||||
|
kvm_host_metrics_error: '获取宿主机指标失败',
|
||||||
|
kvm_host_add_error: '新增宿主机失败',
|
||||||
|
kvm_host_update_error: '修改宿主机失败',
|
||||||
|
kvm_host_delete_error: '删除宿主机失败',
|
||||||
|
|
||||||
|
// 镜像
|
||||||
|
kvm_image_list_error: '获取镜像列表失败',
|
||||||
|
kvm_image_detail_error: '获取镜像详情失败',
|
||||||
|
kvm_image_host_status_error: '获取镜像宿主机状态失败',
|
||||||
|
kvm_image_create_error: '创建镜像失败',
|
||||||
|
kvm_image_update_error: '修改镜像失败',
|
||||||
|
kvm_image_delete_error: '删除镜像失败',
|
||||||
|
kvm_image_reload_error: '重新下载镜像失败',
|
||||||
|
kvm_image_sync_error: '同步镜像到宿主机失败',
|
||||||
|
kvm_image_reload_host_error: '重新下载镜像到宿主机失败',
|
||||||
|
|
||||||
|
// 网络
|
||||||
|
kvm_network_list_error: '获取网络列表失败',
|
||||||
|
kvm_network_detail_error: '获取网络详情失败',
|
||||||
|
kvm_network_create_error: '创建网络失败',
|
||||||
|
kvm_network_update_error: '修改网络失败',
|
||||||
|
kvm_network_delete_error: '删除网络失败',
|
||||||
|
|
||||||
|
// 数据卷
|
||||||
|
kvm_volume_list_error: '获取数据卷列表失败',
|
||||||
|
kvm_volume_detail_error: '获取数据卷详情失败',
|
||||||
|
kvm_volume_create_error: '创建数据卷失败',
|
||||||
|
kvm_volume_resize_error: '调整数据卷大小失败',
|
||||||
|
kvm_volume_mount_error: '挂载数据卷失败',
|
||||||
|
kvm_volume_unmount_error: '卸载数据卷失败',
|
||||||
|
kvm_volume_transfer_error: '迁移数据卷失败',
|
||||||
|
kvm_volume_delete_error: '删除数据卷失败',
|
||||||
|
|
||||||
|
// 虚拟机
|
||||||
|
kvm_vm_list_error: '获取虚拟机列表失败',
|
||||||
|
kvm_vm_detail_error: '获取虚拟机详情失败',
|
||||||
|
kvm_vm_status_error: '获取虚拟机状态失败',
|
||||||
|
kvm_vm_metrics_error: '获取虚拟机指标失败',
|
||||||
|
kvm_vm_create_error: '创建虚拟机失败',
|
||||||
|
kvm_vm_update_error: '修改虚拟机失败',
|
||||||
|
kvm_vm_rebuild_error: '重建虚拟机失败',
|
||||||
|
kvm_vm_refactor_error: '重构虚拟机失败',
|
||||||
|
kvm_vm_update_traffic_error: '修改虚拟机带宽失败',
|
||||||
|
kvm_vm_start_error: '启动虚拟机失败',
|
||||||
|
kvm_vm_stop_error: '停止虚拟机失败',
|
||||||
|
kvm_vm_reboot_error: '重启虚拟机失败',
|
||||||
|
kvm_vm_suspend_error: '暂停虚拟机失败',
|
||||||
|
kvm_vm_resume_error: '恢复虚拟机失败',
|
||||||
|
kvm_vm_rescue_error: '进入救援系统失败',
|
||||||
|
kvm_vm_exit_rescue_error: '退出救援系统失败',
|
||||||
|
kvm_vm_delete_error: '删除虚拟机失败',
|
||||||
|
|
||||||
|
// 安全组
|
||||||
|
kvm_post_group_list_error: '获取安全组列表失败',
|
||||||
|
kvm_post_group_detail_error: '获取安全组详情失败',
|
||||||
|
kvm_post_group_create_error: '创建安全组失败',
|
||||||
|
kvm_post_group_update_error: '修改安全组失败',
|
||||||
|
kvm_post_group_sync_error: '同步安全组失败',
|
||||||
|
kvm_post_group_bind_error: '绑定安全组失败',
|
||||||
|
kvm_post_group_unbind_error: '解绑安全组失败',
|
||||||
|
kvm_post_group_delete_error: '删除安全组失败',
|
||||||
|
kvm_post_group_enable_whitelist_error: '开启安全组白名单失败',
|
||||||
|
kvm_post_group_disable_whitelist_error: '关闭安全组白名单失败',
|
||||||
|
kvm_post_group_create_rule_error: '新增安全组规则失败',
|
||||||
|
kvm_post_group_update_rule_error: '修改安全组规则失败',
|
||||||
|
kvm_post_group_delete_rule_error: '删除安全组规则失败',
|
||||||
|
kvm_post_group_apply_error: '应用安全组失败',
|
||||||
|
kvm_security_group_list_error: '获取安全组列表失败',
|
||||||
|
kvm_security_group_detail_error: '获取安全组详情失败',
|
||||||
|
kvm_security_group_create_error: '创建安全组失败',
|
||||||
|
kvm_security_group_update_error: '修改安全组失败',
|
||||||
|
kvm_security_group_delete_error: '删除安全组失败',
|
||||||
|
|
||||||
|
// VNC
|
||||||
|
kvm_vnc_list_error: '获取VNC节点列表失败',
|
||||||
|
kvm_vnc_add_error: '新增VNC节点失败',
|
||||||
|
kvm_vnc_test_error: '测试VNC节点连接失败',
|
||||||
|
kvm_vnc_update_error: '修改VNC节点失败',
|
||||||
|
kvm_vnc_delete_error: '删除VNC节点失败',
|
||||||
|
kvm_vnc_vm_vnc_error: '获取VNC连接信息失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从嵌套的 RPC 错误字符串中提取有意义的中文描述
|
||||||
|
*/
|
||||||
|
function parseRpcError(err) {
|
||||||
|
if (!err) return ''
|
||||||
|
const descMatch = err.match(/desc\s*=\s*(.+)/)
|
||||||
|
if (descMatch) {
|
||||||
|
const descContent = descMatch[1]
|
||||||
|
const jsonMatch = descContent.match(/body=(\{.+\})/)
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonMatch[1])
|
||||||
|
if (parsed.message) return parsed.message
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
const clean = descContent.trim()
|
||||||
|
if (clean && !clean.startsWith('http')) return clean
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一提取 API 响应中的错误信息
|
||||||
|
* @param {object} body - axios response.data (即 { code, message, error, data })
|
||||||
|
* @param {string} fallback - 兜底文案
|
||||||
|
* @returns {string} 中文错误描述
|
||||||
|
*/
|
||||||
|
export function extractApiError(body, fallback = '操作失败') {
|
||||||
|
if (!body) return fallback
|
||||||
|
|
||||||
|
// 识别数据库唯一约束冲突
|
||||||
|
if (body.error && body.error.includes('duplicate key value violates unique constraint')) {
|
||||||
|
const nameMatch = body.error.match(/create \w+ \[(.+?)\] error/)
|
||||||
|
const hint = nameMatch ? `「${nameMatch[1]}」已存在,请勿重复生成` : '数据已存在,请勿重复操作'
|
||||||
|
return hint
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpcMsg = parseRpcError(body.error)
|
||||||
|
if (rpcMsg) return rpcMsg
|
||||||
|
|
||||||
|
const mapped = ERROR_CODE_MAP[body.message]
|
||||||
|
if (mapped) return mapped
|
||||||
|
|
||||||
|
if (body.message && !/^[a-z_]+$/.test(body.message)) return body.message
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
+151
-29
@@ -1,26 +1,104 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
import {getRefreshToken,refreshAccessToken} from "@/api/login.js";
|
||||||
// 基础URL
|
import { baseUrl, acsBaseUrl, noAuthUrls as noAuthUrlList, requestTimeout, acsRequestTimeout, TOKEN_KEY, TOKEN_EXPIRE_KEY, USER_INFO_KEY } from '@/config/env.js'
|
||||||
const baseUrl = 'https://apiservertest.s1f.ren'
|
|
||||||
// const baseUrl = 'https://cloudapi.007yjs.com'
|
|
||||||
|
|
||||||
// 检查URL是否需要认证
|
// 检查URL是否需要认证
|
||||||
const urlNeedAuth = (url) => {
|
const urlNeedAuth = (url) => {
|
||||||
// 这里可以添加不需要认证的URL列表
|
return !noAuthUrlList.some(noAuthUrl => url.includes(noAuthUrl))
|
||||||
const noAuthUrls = ['/v1/user/login', '/v1/user/check/get_code_img', '/v1/user/register']
|
|
||||||
return !noAuthUrls.some(noAuthUrl => url.includes(noAuthUrl))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查token是否过期
|
// 检查token是否过期
|
||||||
const isTokenExpired = () => {
|
const isTokenExpired = () => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
const expire = localStorage.getItem(TOKEN_EXPIRE_KEY)
|
||||||
if (!token) return true
|
if (!token) return true
|
||||||
|
|
||||||
// 这里可以添加token过期检查逻辑,如果有JWT可以解析它
|
// 检查过期时间
|
||||||
// 简单实现,仅检查token是否存在
|
if (expire) {
|
||||||
return false
|
const expireTime = parseInt(expire) * 1000 // 转换为毫秒
|
||||||
|
const now = Date.now()
|
||||||
|
return now >= expireTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有过期时间时,默认认为Token已过期(因为无法验证有效性)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查token是否即将过期(5分钟内)
|
||||||
|
const isTokenExpiringSoon = () => {
|
||||||
|
const expire = localStorage.getItem(TOKEN_EXPIRE_KEY)
|
||||||
|
if (!expire) return false
|
||||||
|
|
||||||
|
const expireTime = parseInt(expire) * 1000 // 转换为毫秒
|
||||||
|
const now = Date.now()
|
||||||
|
const fiveMinutes = 5 * 60 * 1000 // 5分钟
|
||||||
|
|
||||||
|
// 如果已过期,返回false(由isTokenExpired处理)
|
||||||
|
if (now >= expireTime) return false
|
||||||
|
|
||||||
|
// 如果在5分钟内过期,返回true
|
||||||
|
return (expireTime - now) <= fiveMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在刷新token的标志
|
||||||
|
let isRefreshing = false
|
||||||
|
// 等待刷新token的请求队列
|
||||||
|
let refreshSubscribers = []
|
||||||
|
|
||||||
|
// 添加请求到队列
|
||||||
|
const subscribeTokenRefresh = (callback) => {
|
||||||
|
refreshSubscribers.push(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新token后执行队列中的请求
|
||||||
|
const onTokenRefreshed = (newToken) => {
|
||||||
|
refreshSubscribers.forEach(callback => callback(newToken))
|
||||||
|
refreshSubscribers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行token刷新
|
||||||
|
const doRefreshToken = async () => {
|
||||||
|
try {
|
||||||
|
const domain = window.location.hostname
|
||||||
|
// 获取交换token
|
||||||
|
const refreshTokenRes = await getRefreshToken(domain,{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (refreshTokenRes.data?.code === 200 && refreshTokenRes.data?.data?.refresh_token) {
|
||||||
|
// 使用交换token获取新的access token
|
||||||
|
const newTokenRes = await refreshAccessToken(refreshTokenRes.data.data.refresh_token)
|
||||||
|
|
||||||
|
if (newTokenRes.data?.code === 200 && newTokenRes.data?.data?.token) {
|
||||||
|
const { token, expire } = newTokenRes.data.data
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
if (expire) {
|
||||||
|
localStorage.setItem(TOKEN_EXPIRE_KEY, expire.toString())
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 刷新失败,触发登出逻辑
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||||
|
localStorage.removeItem(USER_INFO_KEY)
|
||||||
|
ElMessage.warning('登录过期,请重新登录')
|
||||||
|
router.push('/login')
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token刷新失败:', error)
|
||||||
|
// 刷新失败,触发登出逻辑
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||||
|
localStorage.removeItem(USER_INFO_KEY)
|
||||||
|
ElMessage.warning('登录过期,请重新登录')
|
||||||
|
router.push('/login')
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Request {
|
class Request {
|
||||||
@@ -37,7 +115,7 @@ class Request {
|
|||||||
(config) => {
|
(config) => {
|
||||||
// 在发送请求之前做些什么
|
// 在发送请求之前做些什么
|
||||||
// 例如:添加 token
|
// 例如:添加 token
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -93,8 +171,8 @@ class Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DELETE 请求
|
// DELETE 请求
|
||||||
delete(url,data={}, config = {}) {
|
delete(url, config = {}) {
|
||||||
return this.instance.delete(url,data, config)
|
return this.instance.delete(url, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH 请求
|
// PATCH 请求
|
||||||
@@ -106,7 +184,7 @@ class Request {
|
|||||||
// 创建默认实例
|
// 创建默认实例
|
||||||
const request = new Request({
|
const request = new Request({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
timeout: 50000,
|
timeout: requestTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
}
|
}
|
||||||
@@ -117,23 +195,67 @@ export const baseURL = baseUrl
|
|||||||
|
|
||||||
export const http2 = axios.create({
|
export const http2 = axios.create({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
timeout: 30000,
|
timeout: acsRequestTimeout,
|
||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
http2.interceptors.request.use(config => {
|
http2.interceptors.request.use(async config => {
|
||||||
const token = localStorage.getItem('token'); // 假设 token 存储在 localStorage
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
if(urlNeedAuth(config.url) && isTokenExpired()){
|
|
||||||
if (token){
|
// 检查是否需要认证
|
||||||
localStorage.removeItem('token');
|
if (urlNeedAuth(config.url)) {
|
||||||
ElMessage.warning('登陆过期,请重新登陆')
|
// 检查token是否已过期
|
||||||
}
|
if (isTokenExpired()) {
|
||||||
router.push('/login')
|
if (token) {
|
||||||
return Promise.reject();
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
}
|
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
localStorage.removeItem(USER_INFO_KEY)
|
||||||
|
ElMessage.warning('登录过期,请重新登录')
|
||||||
|
}
|
||||||
|
router.push('/login')
|
||||||
|
return Promise.reject(new Error('Token已过期'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查token是否即将过期,进行无感刷新
|
||||||
|
if (isTokenExpiringSoon() && !isRefreshing) {
|
||||||
|
isRefreshing = true
|
||||||
|
try {
|
||||||
|
const newToken = await doRefreshToken()
|
||||||
|
if (newToken) {
|
||||||
|
console.log('Token已无感刷新')
|
||||||
|
onTokenRefreshed(newToken)
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
} else {
|
||||||
|
// 刷新失败,doRefreshToken已处理登出逻辑,直接拒绝请求
|
||||||
|
return Promise.reject(new Error('Token刷新失败'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token刷新异常:', error)
|
||||||
|
// 刷新异常,doRefreshToken已处理登出逻辑,直接拒绝请求
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
} else if (isRefreshing) {
|
||||||
|
// 正在刷新,等待刷新完成
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
subscribeTokenRefresh((newToken) => {
|
||||||
|
if (newToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
// 重新发送原始请求
|
||||||
|
resolve(config)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Token刷新失败'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 正常情况,直接使用token
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 不需要认证的请求,不添加token
|
||||||
|
|
||||||
config.url = config.url
|
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -147,7 +269,7 @@ http2.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
const { status } = error.response;
|
const { status } = error.response;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
ElMessage.warning('登陆过期,请重新登陆')
|
ElMessage.warning('登陆过期,请重新登陆')
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
|
|||||||
+152
-2
@@ -18,7 +18,7 @@ export const formatDate = (dateStr) => {
|
|||||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 时间格式转 Unix 时间戳(毫秒级)
|
* 时间格式转 Unix 时间戳(秒级)
|
||||||
* @param {string|Date} time - 输入时间(支持 '2025-10-28 00:00:00'、'2025/10/28'、Date 对象等)
|
* @param {string|Date} time - 输入时间(支持 '2025-10-28 00:00:00'、'2025/10/28'、Date 对象等)
|
||||||
* @returns {number|null} 转换后的毫秒级时间戳(失败返回 null)
|
* @returns {number|null} 转换后的毫秒级时间戳(失败返回 null)
|
||||||
*/
|
*/
|
||||||
@@ -50,10 +50,160 @@ export function timeToTimestamp(time) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.floor(timestamp / 1000); // 返回毫秒级时间戳(如 1751107200000)
|
return Math.floor(timestamp / 1000); // 返回秒级时间戳(如 1751107200000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function reducenum(num){
|
export function reducenum(num){
|
||||||
return num / 100
|
return num / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分转元显示(返回 ¥xx.xx 或 '-')
|
||||||
|
*/
|
||||||
|
export function formatPrice(fen, fallback = '-') {
|
||||||
|
if (!fen && fen !== 0) return fallback
|
||||||
|
return '¥' + (fen / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元转分(四舍五入取整)
|
||||||
|
*/
|
||||||
|
export function yuanToFen(yuan) {
|
||||||
|
return Math.round((yuan || 0) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化到期时间(year < 2000 视为永久)
|
||||||
|
*/
|
||||||
|
export function formatExpireTime(t) {
|
||||||
|
if (!t) return '-'
|
||||||
|
const d = new Date(t)
|
||||||
|
if (isNaN(d.getTime())) return '-'
|
||||||
|
if (d.getFullYear() < 2000) return '永久'
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ISO 格式时间字符串转换为毫秒级时间戳(用于时间选择器)
|
||||||
|
* @param {string|Date|number} time - 输入时间(支持 ISO 格式字符串如 '2023-11-08T01:10:00+08:00'、Date 对象、时间戳等)
|
||||||
|
* @returns {number|null} 转换后的毫秒级时间戳(失败或无效时间返回 null)
|
||||||
|
*/
|
||||||
|
export function isoToMilliseconds(time) {
|
||||||
|
// 处理空值
|
||||||
|
if (!time || time === null || time === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理特殊的无效时间标识
|
||||||
|
if (typeof time === 'string' && (time === '0001-01-01T00:00:00Z' || time === '0001-01-01T00:00:00+00:00')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经是数字(时间戳),直接返回
|
||||||
|
if (typeof time === 'number') {
|
||||||
|
// 如果是秒级时间戳(小于 13 位),转换为毫秒
|
||||||
|
if (time < 1000000000000) {
|
||||||
|
return time * 1000
|
||||||
|
}
|
||||||
|
return time
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Date 对象
|
||||||
|
if (time instanceof Date) {
|
||||||
|
const timestamp = time.getTime()
|
||||||
|
return isNaN(timestamp) ? null : timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理字符串格式
|
||||||
|
if (typeof time === 'string') {
|
||||||
|
try {
|
||||||
|
const date = new Date(time)
|
||||||
|
const timestamp = date.getTime()
|
||||||
|
|
||||||
|
// 检查是否为有效时间
|
||||||
|
if (isNaN(timestamp)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestamp
|
||||||
|
} catch (error) {
|
||||||
|
console.error('时间转换失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间为 "YYYY-MM-DD HH:mm:ss" 格式(用于接口提交)
|
||||||
|
* @param {string|Date|number} time
|
||||||
|
* @returns {string} 格式化后的时间字符串,无效时返回 ''
|
||||||
|
*/
|
||||||
|
export function formatToApiTime(time) {
|
||||||
|
if (!time) return ''
|
||||||
|
const d = time instanceof Date ? time : new Date(time)
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 虚拟机状态映射 ==========
|
||||||
|
const VM_STATUS_MAP = {
|
||||||
|
pending: { label: '等待中', type: 'info' },
|
||||||
|
creating: { label: '创建中', type: 'warning' },
|
||||||
|
ready: { label: '就绪', type: 'success' },
|
||||||
|
running: { label: '运行中', type: 'success' },
|
||||||
|
stopped: { label: '已停止', type: 'danger' },
|
||||||
|
stop: { label: '已停止', type: 'danger' },
|
||||||
|
shutoff: { label: '已关闭', type: 'danger' },
|
||||||
|
error: { label: '错误', type: 'danger' },
|
||||||
|
paused: { label: '已暂停', type: 'warning' },
|
||||||
|
reboot: { label: '重启中', type: 'warning' },
|
||||||
|
poweroff: { label: '已关机', type: 'info' },
|
||||||
|
unknown: { label: '未知', type: 'info' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取虚拟机状态标签文字
|
||||||
|
*/
|
||||||
|
export function vmStatusLabel(status) {
|
||||||
|
return VM_STATUS_MAP[status]?.label || status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取虚拟机状态 Tag 类型
|
||||||
|
*/
|
||||||
|
export function vmStatusType(status) {
|
||||||
|
return VM_STATUS_MAP[status]?.type || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 磁盘状态映射 ==========
|
||||||
|
const VOLUME_STATUS_MAP = {
|
||||||
|
pending: { label: '等待中', type: 'info' },
|
||||||
|
creating: { label: '创建中', type: 'warning' },
|
||||||
|
ready: { label: '就绪', type: 'success' },
|
||||||
|
in_use: { label: '使用中', type: 'success' },
|
||||||
|
attaching: { label: '挂载中', type: 'warning' },
|
||||||
|
detaching: { label: '卸载中', type: 'warning' },
|
||||||
|
resizing: { label: '扩容中', type: 'warning' },
|
||||||
|
deleting: { label: '删除中', type: 'danger' },
|
||||||
|
error: { label: '错误', type: 'danger' },
|
||||||
|
unknown: { label: '未知', type: 'info' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取磁盘状态标签文字
|
||||||
|
*/
|
||||||
|
export function volumeStatusLabel(status) {
|
||||||
|
return VOLUME_STATUS_MAP[status]?.label || status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取磁盘状态 Tag 类型
|
||||||
|
*/
|
||||||
|
export function volumeStatusType(status) {
|
||||||
|
return VOLUME_STATUS_MAP[status]?.type || 'info'
|
||||||
|
}
|
||||||
|
|||||||
+9
-1
@@ -105,16 +105,24 @@ const forgetPassword = () => {
|
|||||||
const handleLogin = () => {
|
const handleLogin = () => {
|
||||||
loginFormRef.value?.validate(async valid =>{
|
loginFormRef.value?.validate(async valid =>{
|
||||||
window.localStorage.removeItem('token')
|
window.localStorage.removeItem('token')
|
||||||
|
window.localStorage.removeItem('tokenExpire')
|
||||||
|
window.localStorage.removeItem('userInfo')
|
||||||
if (valid) {
|
if (valid) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
let resp = await userLogin(loginForm.username, loginForm.password)
|
let resp = await userLogin(loginForm.username, loginForm.password)
|
||||||
console.log("login:",resp)
|
console.log("login:",resp)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if(resp.code === 200){
|
if(resp.code === 200){
|
||||||
|
// 保存token和过期时间
|
||||||
|
window.localStorage.setItem('token', resp.data.token)
|
||||||
|
if (resp.data.expire) {
|
||||||
|
window.localStorage.setItem('tokenExpire', resp.data.expire.toString())
|
||||||
|
}
|
||||||
|
|
||||||
window.localStorage.setItem('token',resp.data.token)
|
|
||||||
let userInfo = await getUserInfo()
|
let userInfo = await getUserInfo()
|
||||||
if(userInfo.data.is_admin){
|
if(userInfo.data.is_admin){
|
||||||
|
// 保存用户信息到localStorage
|
||||||
|
window.localStorage.setItem('userInfo', JSON.stringify(userInfo.data))
|
||||||
await router.push('/dashboard')
|
await router.push('/dashboard')
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning('你不是管理员,不能登陆到后台控制面板')
|
ElMessage.warning('你不是管理员,不能登陆到后台控制面板')
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ const toLoad = async (data) => {
|
|||||||
})
|
})
|
||||||
form.server_id = data
|
form.server_id = data
|
||||||
nowserver_id.value = data
|
nowserver_id.value = data
|
||||||
let res = await getServerPlan({server_id:data,count:100})
|
let res = await getServerPlan({server_id:data,count:10})
|
||||||
planlist.value = res.data.data.map(item => {
|
planlist.value = res.data.data.map(item => {
|
||||||
return {
|
return {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -748,7 +748,7 @@ const fetchCategoryList = async (serverId) => {
|
|||||||
// 编辑镜像
|
// 编辑镜像
|
||||||
const handleEdit = async (data) => {
|
const handleEdit = async (data) => {
|
||||||
try {
|
try {
|
||||||
let res = await getServerPlan({server_id: data.server_id,count: 100})
|
let res = await getServerPlan({server_id: data.server_id,count: 10})
|
||||||
if (res.data && res.data.data) {
|
if (res.data && res.data.data) {
|
||||||
planlist.value = res.data.data.map(item => {
|
planlist.value = res.data.data.map(item => {
|
||||||
return {
|
return {
|
||||||
@@ -874,7 +874,7 @@ const getit = async () => {
|
|||||||
|
|
||||||
// 选择图片
|
// 选择图片
|
||||||
const picPagin = reactive({
|
const picPagin = reactive({
|
||||||
count: 50,
|
count: 10,
|
||||||
page: 1,
|
page: 1,
|
||||||
key: '',
|
key: '',
|
||||||
user_type: 1
|
user_type: 1
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ const categoryRules = {
|
|||||||
// 素材库相关
|
// 素材库相关
|
||||||
const picSwitch = ref(false)
|
const picSwitch = ref(false)
|
||||||
const picPagin = reactive({
|
const picPagin = reactive({
|
||||||
count: 50,
|
count: 10,
|
||||||
page: 1,
|
page: 1,
|
||||||
key: '',
|
key: '',
|
||||||
user_type: 1
|
user_type: 1
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ const showNewCategoryInput = ref(false)
|
|||||||
const picSwitch = ref(false)
|
const picSwitch = ref(false)
|
||||||
const picLoading = ref(false)
|
const picLoading = ref(false)
|
||||||
const picPagin = reactive({
|
const picPagin = reactive({
|
||||||
count: 20,
|
count: 10,
|
||||||
page: 1,
|
page: 1,
|
||||||
key: '',
|
key: '',
|
||||||
user_type: 1
|
user_type: 1
|
||||||
@@ -314,7 +314,7 @@ const initData = async () => {
|
|||||||
// Fallback: fetch list and find item
|
// Fallback: fetch list and find item
|
||||||
const listRes = await getUserMirrorList({
|
const listRes = await getUserMirrorList({
|
||||||
server_id: serverId.value,
|
server_id: serverId.value,
|
||||||
count: 100,
|
count: 10,
|
||||||
page: 1
|
page: 1
|
||||||
})
|
})
|
||||||
if (listRes.data.code === 200) {
|
if (listRes.data.code === 200) {
|
||||||
|
|||||||
@@ -467,7 +467,7 @@
|
|||||||
<h3 class="tab-title">数据卷列表</h3>
|
<h3 class="tab-title">数据卷列表</h3>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="showAddVolumeDialog = true"
|
@click="handleAddVolume"
|
||||||
:icon="Plus"
|
:icon="Plus"
|
||||||
:disabled="vmInfo.state != 2"
|
:disabled="vmInfo.state != 2"
|
||||||
>
|
>
|
||||||
@@ -671,8 +671,11 @@
|
|||||||
width="500px"
|
width="500px"
|
||||||
>
|
>
|
||||||
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
|
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
|
||||||
<el-form-item label="大小(GB)" prop="size">
|
<el-form-item label="大小" prop="size">
|
||||||
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" style="flex:1" />
|
||||||
|
<el-select v-model="volumeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -693,8 +696,11 @@
|
|||||||
>
|
>
|
||||||
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
|
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
|
||||||
|
|
||||||
<el-form-item label="大小(GB)" prop="size">
|
<el-form-item label="大小" prop="size">
|
||||||
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" style="flex:1" />
|
||||||
|
<el-select v-model="volumeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -1067,6 +1073,7 @@ const showMigrateVolumeDialog = ref(false);
|
|||||||
const currentVolumeToEdit = ref(null);
|
const currentVolumeToEdit = ref(null);
|
||||||
const volumeForm = reactive({
|
const volumeForm = reactive({
|
||||||
size: 10,
|
size: 10,
|
||||||
|
_sizeUnit: 'GB'
|
||||||
});
|
});
|
||||||
const volumeFormRef = ref(null);
|
const volumeFormRef = ref(null);
|
||||||
const volumeRules = {
|
const volumeRules = {
|
||||||
@@ -2371,6 +2378,7 @@ const handleAddVolume = () => {
|
|||||||
showAddVolumeDialog.value = true;
|
showAddVolumeDialog.value = true;
|
||||||
// 重置表单
|
// 重置表单
|
||||||
volumeForm.size = 10;
|
volumeForm.size = 10;
|
||||||
|
volumeForm._sizeUnit = 'GB';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 编辑数据卷
|
// 编辑数据卷
|
||||||
@@ -2378,6 +2386,7 @@ const handleEditVolume = (volume) => {
|
|||||||
currentVolumeToEdit.value = volume;
|
currentVolumeToEdit.value = volume;
|
||||||
// 填充表单
|
// 填充表单
|
||||||
volumeForm.size = volume.size;
|
volumeForm.size = volume.size;
|
||||||
|
volumeForm._sizeUnit = 'GB';
|
||||||
showEditVolumeDialog.value = true;
|
showEditVolumeDialog.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2404,9 +2413,10 @@ const submitAddVolume = async () => {
|
|||||||
if (valid) {
|
if (valid) {
|
||||||
addingVolume.value = true;
|
addingVolume.value = true;
|
||||||
try {
|
try {
|
||||||
|
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
|
||||||
const res = await addVolume({
|
const res = await addVolume({
|
||||||
instance_id: route.query.instance_id,
|
instance_id: route.query.instance_id,
|
||||||
size: String(volumeForm.size),
|
size: String(sizeGb),
|
||||||
user_id: user_id.value
|
user_id: user_id.value
|
||||||
});
|
});
|
||||||
console.log("添加数据卷112",res)
|
console.log("添加数据卷112",res)
|
||||||
@@ -2438,9 +2448,10 @@ const submitEditVolume = async () => {
|
|||||||
editingVolume.value = true;
|
editingVolume.value = true;
|
||||||
try {
|
try {
|
||||||
// 这里应该调用修改数据卷的API
|
// 这里应该调用修改数据卷的API
|
||||||
|
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
|
||||||
const res = await updateVolume({
|
const res = await updateVolume({
|
||||||
volume_id: currentVolumeToEdit.value.id,
|
volume_id: currentVolumeToEdit.value.id,
|
||||||
size: volumeForm.size
|
size: sizeGb
|
||||||
});
|
});
|
||||||
console.log("编辑数据卷数据:",res)
|
console.log("编辑数据卷数据:",res)
|
||||||
|
|
||||||
@@ -2770,4 +2781,7 @@ const fetchServersList = async () => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
.unit-select { width: 90px; flex-shrink: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ const fetchPlanList = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await getServerPlan({
|
const response = await getServerPlan({
|
||||||
server_id: props.ID,
|
server_id: props.ID,
|
||||||
count: 100
|
count: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response && response.data && response.data.code === 200) {
|
if (response && response.data && response.data.code === 200) {
|
||||||
|
|||||||
@@ -315,7 +315,11 @@
|
|||||||
class="data-table"
|
class="data-table"
|
||||||
>
|
>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="size" label="空间大小(MB)" width="140" />
|
<el-table-column prop="size" label="空间大小(MB)" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.size != null && row.size !== '' ? `${row.size} MB` : '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="mount_path" label="挂载路径" min-width="200" />
|
<el-table-column prop="mount_path" label="挂载路径" min-width="200" />
|
||||||
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|||||||
@@ -1901,7 +1901,7 @@ const GetSpecs = async () => {
|
|||||||
try {
|
try {
|
||||||
let plans = await getServerPlan({
|
let plans = await getServerPlan({
|
||||||
server_id: route.query.server_id,
|
server_id: route.query.server_id,
|
||||||
count: 30
|
count: 10
|
||||||
});
|
});
|
||||||
spec_list.value = plans.data.data;
|
spec_list.value = plans.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2407,7 +2407,7 @@ const fetchContainerPlanList = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await getServerPlan({
|
const response = await getServerPlan({
|
||||||
server_id: route.query.server_id,
|
server_id: route.query.server_id,
|
||||||
count: 100
|
count: 10
|
||||||
});
|
});
|
||||||
console.log("获取容器套餐列表1111:",response);
|
console.log("获取容器套餐列表1111:",response);
|
||||||
|
|
||||||
@@ -2430,7 +2430,7 @@ const fetchContainerMirrorList = async () => {
|
|||||||
|
|
||||||
containerMirrorLoading.value = true;
|
containerMirrorLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await getMirrorList({server_id: route.query.server_id, page: 1, count: 999,key: '',class_id: ''});
|
const response = await getMirrorList({server_id: route.query.server_id, page: 1, count: 10,key: '',class_id: ''});
|
||||||
console.log("获取镜像列表1111:",response);
|
console.log("获取镜像列表1111:",response);
|
||||||
|
|
||||||
if (response && response.data && response.data.code === 200) {
|
if (response && response.data && response.data.code === 200) {
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-buy-container">
|
||||||
|
<el-card class="header-card">
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button type="primary" icon="Plus" @click="openCreateDialog">
|
||||||
|
创建随机队伍
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" icon="Download" @click="handleExport" :loading="exportLoading">
|
||||||
|
导出成功队伍
|
||||||
|
</el-button>
|
||||||
|
<el-button type="info" icon="Refresh" @click="fetchGroupList" :loading="loading">
|
||||||
|
刷新列表
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" @click="handleClearAll">
|
||||||
|
清除所有队伍
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" @click="showClearUserDialog = true">
|
||||||
|
清除用户队伍
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="table-card">
|
||||||
|
<el-table :data="groupList" v-loading="loading" stripe border>
|
||||||
|
<el-table-column prop="id" label="队伍ID" />
|
||||||
|
<el-table-column prop="name" label="队伍名称" min-width="150" />
|
||||||
|
<el-table-column prop="currentMembers" label="当前人数" width="100" align="center" />
|
||||||
|
<el-table-column prop="maxMembers" label="需要人数" width="100" align="center" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.status)">
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'pending'"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="handleAddRandomUser(row)"
|
||||||
|
:loading="row.addingUser"
|
||||||
|
>
|
||||||
|
添加伪人
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'success'"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click="handleSetOrder(row)"
|
||||||
|
:loading="row.settingOrder"
|
||||||
|
>
|
||||||
|
下发订单
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
@click="handleViewMembers(row)"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="handleRemoveGroup(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 创建随机队伍对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showCreateDialog"
|
||||||
|
title="创建随机伪人队伍"
|
||||||
|
width="500px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form :model="createForm" :rules="createRules" ref="createFormRef" label-width="100px">
|
||||||
|
<el-form-item label="队伍名称" prop="name">
|
||||||
|
<el-input v-model="createForm.name" placeholder="请输入队伍名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签" prop="tag">
|
||||||
|
<el-select v-model="createForm.tag" placeholder="请选择标签" style="width: 100%" @change="handleTagChange">
|
||||||
|
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="拼团类型" prop="groupBuyTypeId">
|
||||||
|
<el-select v-model="createForm.groupBuyTypeId" placeholder="请先选择标签" :disabled="!createForm.tag" style="width: 100%">
|
||||||
|
<el-option v-for="item in typeList" :key="item.id" :label="`${item.name} (${item.maxPerson}人)`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleCreate" :loading="createLoading">
|
||||||
|
创建
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 查看成员对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showMembersDialog"
|
||||||
|
title="队伍成员列表"
|
||||||
|
width="700px"
|
||||||
|
>
|
||||||
|
<el-table :data="currentMembers" border stripe>
|
||||||
|
<el-table-column label="头像" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-avatar :size="50" :src="row.cover" v-if="row.cover">
|
||||||
|
<img src="https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png" />
|
||||||
|
</el-avatar>
|
||||||
|
<el-avatar :size="50" v-else>
|
||||||
|
{{ row.username?.charAt(0) || '?' }}
|
||||||
|
</el-avatar>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="用户ID" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.userId" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.userId }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||||
|
<el-table-column label="队长" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.teamLeader" type="warning" size="small">队长</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="danger" size="small" @click="handleClearUserGroups(row.userId)">清除队伍</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 清除用户队伍对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showClearUserDialog"
|
||||||
|
title="清除指定用户的所有队伍"
|
||||||
|
width="400px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form :model="clearUserForm" label-width="80px">
|
||||||
|
<el-form-item label="用户ID">
|
||||||
|
<el-input v-model="clearUserForm.userId" placeholder="请输入用户ID" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showClearUserDialog = false">取消</el-button>
|
||||||
|
<el-button type="danger" @click="handleClearUserSubmit" :loading="clearUserLoading">确认清除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getGroupBuyList,
|
||||||
|
getGroupBuyDetail,
|
||||||
|
addRandomUser,
|
||||||
|
addRandomGroup,
|
||||||
|
exportIdcInfo,
|
||||||
|
setOrder
|
||||||
|
} from '@/api/admin/activity'
|
||||||
|
import { getGroupBuyTypeList, getGroupBuyTypeTags, removeGroupBuy, clearAllGroupBuy, clearUserGroupBuy } from '@/api/groupBuy'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 数据状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const exportLoading = ref(false)
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const groupList = ref([])
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showMembersDialog = ref(false)
|
||||||
|
const showClearUserDialog = ref(false)
|
||||||
|
const currentMembers = ref([])
|
||||||
|
const clearUserLoading = ref(false)
|
||||||
|
const clearUserForm = reactive({ userId: '' })
|
||||||
|
|
||||||
|
// 创建表单
|
||||||
|
const createFormRef = ref(null)
|
||||||
|
const createForm = reactive({
|
||||||
|
name: '',
|
||||||
|
tag: '',
|
||||||
|
groupBuyTypeId: ''
|
||||||
|
})
|
||||||
|
const typeList = ref([])
|
||||||
|
const tagList = ref([])
|
||||||
|
|
||||||
|
const createRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入队伍名称', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
tag: [
|
||||||
|
{ required: true, message: '请选择标签', trigger: 'change' }
|
||||||
|
],
|
||||||
|
groupBuyTypeId: [
|
||||||
|
{ required: true, message: '请选择拼团类型', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标签列表
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyTypeTags()
|
||||||
|
if (res.code === 200) {
|
||||||
|
tagList.value = res.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 tag 获取拼团类型列表
|
||||||
|
const fetchTypeListByTag = async (tag) => {
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyTypeList({ page: 1, count: 10, tag })
|
||||||
|
if (res.code === 200) {
|
||||||
|
typeList.value = res.data?.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取拼团类型失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tag 变化时获取对应的拼团类型
|
||||||
|
const handleTagChange = (tag) => {
|
||||||
|
createForm.groupBuyTypeId = ''
|
||||||
|
typeList.value = []
|
||||||
|
if (tag) {
|
||||||
|
fetchTypeListByTag(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开创建对话框
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
createForm.name = ''
|
||||||
|
createForm.tag = ''
|
||||||
|
createForm.groupBuyTypeId = ''
|
||||||
|
typeList.value = []
|
||||||
|
fetchTags()
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取队伍列表
|
||||||
|
const fetchGroupList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyList()
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
const allGroups = res.data.data.group_buy_list || []
|
||||||
|
const lackGroups = res.data.data.lack_group_buy_list || []
|
||||||
|
const successGroups = res.data.data.success_group_buy_list || []
|
||||||
|
|
||||||
|
// 获取成功和缺人队伍的ID列表用于状态判断
|
||||||
|
const successIds = successGroups.map(g => g.group_buy_id)
|
||||||
|
const lackIds = lackGroups.map(g => g.group_buy_id)
|
||||||
|
|
||||||
|
// 将队伍数据转换为显示数据
|
||||||
|
groupList.value = allGroups.map(group => {
|
||||||
|
let status = 'empty'
|
||||||
|
if (successIds.includes(group.group_buy_id)) {
|
||||||
|
status = 'success'
|
||||||
|
} else if (lackIds.includes(group.group_buy_id)) {
|
||||||
|
status = 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: group.group_buy_id,
|
||||||
|
name: group.name,
|
||||||
|
type: group.maxPerson === 5 ? 0 : 1,
|
||||||
|
currentMembers: group.users?.length || 0,
|
||||||
|
maxMembers: group.maxPerson,
|
||||||
|
status: status,
|
||||||
|
createTime: group.createTime || '-',
|
||||||
|
members: group.users || [],
|
||||||
|
addingUser: false,
|
||||||
|
settingOrder: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success(`加载成功,共 ${allGroups.length} 个队伍`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '获取队伍列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取队伍列表出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const statusMap = {
|
||||||
|
'empty': '空队伍',
|
||||||
|
'pending': '进行中',
|
||||||
|
'success': '已满员'
|
||||||
|
}
|
||||||
|
return statusMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态类型
|
||||||
|
const getStatusType = (status) => {
|
||||||
|
const typeMap = {
|
||||||
|
'empty': 'info',
|
||||||
|
'pending': 'warning',
|
||||||
|
'success': 'success'
|
||||||
|
}
|
||||||
|
return typeMap[status] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建随机队伍
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!createFormRef.value) return
|
||||||
|
|
||||||
|
await createFormRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
createLoading.value = true
|
||||||
|
console.log("队伍名称:",createForm.name)
|
||||||
|
try {
|
||||||
|
const res = await addRandomGroup({ name: createForm.name, group_buy_type_id: String(createForm.groupBuyTypeId) })
|
||||||
|
console.log('创建队伍响应:', res)
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success(`创建成功!队伍ID: ${res.data.group_buy_id}`)
|
||||||
|
showCreateDialog.value = false
|
||||||
|
createForm.name = ''
|
||||||
|
createForm.groupBuyTypeId = ''
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '创建失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建队伍出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加随机伪人
|
||||||
|
const handleAddRandomUser = async (row) => {
|
||||||
|
row.addingUser = true
|
||||||
|
try {
|
||||||
|
const res = await addRandomUser(row.id)
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success('添加伪人成功')
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '添加伪人失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加伪人出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
row.addingUser = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下发订单
|
||||||
|
const handleSetOrder = async (row) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定要为该队伍下发订单吗?',
|
||||||
|
'确认操作',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
row.settingOrder = true
|
||||||
|
try {
|
||||||
|
const res = await setOrder(row.id)
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success('订单下发成功')
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '订单下发失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下发订单出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
row.settingOrder = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 用户取消操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewMembers = async (row) => {
|
||||||
|
try {
|
||||||
|
// 获取详细信息
|
||||||
|
const res = await getGroupBuyDetail(row.id)
|
||||||
|
if (res && res.data && res.data.code === 200) {
|
||||||
|
const detail = res.data.data
|
||||||
|
// 使用详情接口返回的数据
|
||||||
|
currentMembers.value = (detail.users || []).map(member => ({
|
||||||
|
userId: member.user_id,
|
||||||
|
username: member.user_name || `用户${member.user_id}`,
|
||||||
|
cover: member.cover || '',
|
||||||
|
teamLeader: member.team_leader || false,
|
||||||
|
idcUid: member.idc_uid || '-',
|
||||||
|
idcPhone: member.idc_phone || '-'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 更新列表中的数据
|
||||||
|
row.name = detail.name
|
||||||
|
row.currentMembers = detail.users?.length || 0
|
||||||
|
row.maxMembers = detail.maxPerson
|
||||||
|
row.members = detail.users || []
|
||||||
|
} else {
|
||||||
|
// 如果获取失败,使用列表中的数据
|
||||||
|
currentMembers.value = row.members.map(member => ({
|
||||||
|
userId: member.user_id,
|
||||||
|
username: member.user_name || `用户${member.user_id}`,
|
||||||
|
cover: member.cover || '',
|
||||||
|
teamLeader: member.team_leader || false,
|
||||||
|
idcUid: member.idc_uid || '-',
|
||||||
|
idcPhone: member.idc_phone || '-'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
showMembersDialog.value = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取成员信息失败:', error)
|
||||||
|
// 使用列表中的数据
|
||||||
|
currentMembers.value = row.members.map(member => ({
|
||||||
|
userId: member.user_id,
|
||||||
|
username: member.user_name || `用户${member.user_id}`,
|
||||||
|
cover: member.cover || '',
|
||||||
|
teamLeader: member.team_leader || false,
|
||||||
|
idcUid: member.idc_uid || '-',
|
||||||
|
idcPhone: member.idc_phone || '-'
|
||||||
|
}))
|
||||||
|
showMembersDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出成功队伍信息
|
||||||
|
const handleExport = async () => {
|
||||||
|
exportLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await exportIdcInfo()
|
||||||
|
|
||||||
|
if (res.data && res.data.code === 200) {
|
||||||
|
// 将data对象转为JSON字符串并下载
|
||||||
|
const jsonStr = JSON.stringify(res.data.data, null, 2)
|
||||||
|
const blob = new Blob([jsonStr], { type: 'application/json' })
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `拼团成功队伍_${new Date().getTime()}.json`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data?.message || '导出失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出出错:', error)
|
||||||
|
ElMessage.error('导出失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
exportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchGroupList()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除指定队伍
|
||||||
|
const handleRemoveGroup = async (row) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该队伍吗?', '确认删除', { type: 'warning' })
|
||||||
|
const res = await removeGroupBuy(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有队伍
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要清除所有队伍吗?此操作不可恢复!', '危险操作', { type: 'error', confirmButtonText: '确定清除' })
|
||||||
|
const res = await clearAllGroupBuy()
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('已清除所有队伍')
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '清除失败')
|
||||||
|
}
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除指定用户的所有队伍
|
||||||
|
const handleClearUserGroups = async (userId) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要清除用户 ${userId} 的所有队伍吗?`, '确认操作', { type: 'warning' })
|
||||||
|
const res = await clearUserGroupBuy(userId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('清除成功')
|
||||||
|
showMembersDialog.value = false
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '清除失败')
|
||||||
|
}
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过弹窗清除用户队伍
|
||||||
|
const handleClearUserSubmit = async () => {
|
||||||
|
if (!clearUserForm.userId) {
|
||||||
|
ElMessage.warning('请输入用户ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearUserLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await clearUserGroupBuy(clearUserForm.userId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('清除成功')
|
||||||
|
showClearUserDialog.value = false
|
||||||
|
clearUserForm.userId = ''
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '清除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清除用户队伍失败:', error)
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
} finally {
|
||||||
|
clearUserLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-buy-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button:hover) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,921 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-buy-manage-container">
|
||||||
|
<el-card class="main-container" shadow="never">
|
||||||
|
<el-tabs v-model="activeTab" class="group-buy-tabs">
|
||||||
|
<!-- 拼团活动标签页 -->
|
||||||
|
<el-tab-pane label="拼团活动" name="activity">
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button type="primary" icon="Plus" @click="openCreateDialog">
|
||||||
|
创建随机队伍
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" icon="Download" @click="handleExport" :loading="exportLoading">
|
||||||
|
导出成功队伍
|
||||||
|
</el-button>
|
||||||
|
<el-button type="info" icon="Refresh" @click="fetchGroupList" :loading="activityLoading">
|
||||||
|
刷新列表
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" @click="handleClearAll">
|
||||||
|
清除所有队伍
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" @click="showClearUserDialog = true">
|
||||||
|
清除用户队伍
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-section">
|
||||||
|
<el-table :data="groupList" v-loading="activityLoading" stripe border>
|
||||||
|
<el-table-column prop="id" label="队伍ID" />
|
||||||
|
<el-table-column prop="name" label="队伍名称" min-width="150" />
|
||||||
|
<el-table-column prop="currentMembers" label="当前人数" width="100" align="center" />
|
||||||
|
<el-table-column prop="maxMembers" label="需要人数" width="100" align="center" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.status)">
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'pending'"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="handleAddRandomUser(row)"
|
||||||
|
:loading="row.addingUser"
|
||||||
|
>
|
||||||
|
添加伪人
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'success'"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click="handleSetOrder(row)"
|
||||||
|
:loading="row.settingOrder"
|
||||||
|
>
|
||||||
|
下发订单
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
@click="handleViewMembers(row)"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="handleRemoveGroup(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 拼团类型标签页 -->
|
||||||
|
<el-tab-pane label="拼团类型" name="type">
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button type="primary" icon="Plus" @click="handleAddType">新增类型</el-button>
|
||||||
|
<el-select v-model="searchTag" placeholder="请选择标签" style="width: 180px; margin-left: 12px" @change="handleTagChange">
|
||||||
|
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="searchKey" placeholder="关键词搜索" style="width: 200px; margin-left: 12px" clearable :disabled="!searchTag" @keyup.enter="fetchTypeList" />
|
||||||
|
<el-button type="info" icon="Refresh" @click="fetchTypeList" :loading="typeLoading" :disabled="!searchTag" style="margin-left: 12px">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-section">
|
||||||
|
<el-empty v-if="!searchTag" description="请先选择标签" />
|
||||||
|
<template v-else>
|
||||||
|
<el-table :data="typeTableData" v-loading="typeLoading" stripe border>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column label="价格" width="120">
|
||||||
|
<template #default="{ row }">¥{{ (row.price / 100).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="续费价格" width="120">
|
||||||
|
<template #default="{ row }">¥{{ (row.renewPrice / 100).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="maxPerson" label="拼团人数" width="100" align="center" />
|
||||||
|
<el-table-column prop="tag" label="标签" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.tag" type="info">{{ row.tag }}</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="过期时间" width="180">
|
||||||
|
<template #default="{ row }">{{ row.expireTime ? formatTime(row.expireTime) : '永久' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" size="small" @click="handleEditType(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="handleDeleteType(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination v-model:current-page="typePage" v-model:page-size="typePageSize" :total="typeTotal" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="fetchTypeList" @current-change="fetchTypeList" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 创建随机队伍对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showCreateDialog"
|
||||||
|
title="创建随机伪人队伍"
|
||||||
|
width="500px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form :model="createForm" :rules="createRules" ref="createFormRef" label-width="100px">
|
||||||
|
<el-form-item label="队伍名称" prop="name">
|
||||||
|
<el-input v-model="createForm.name" placeholder="请输入队伍名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签" prop="tag">
|
||||||
|
<el-select v-model="createForm.tag" placeholder="请选择标签" style="width: 100%" @change="handleCreateTagChange">
|
||||||
|
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="拼团类型" prop="groupBuyTypeId">
|
||||||
|
<el-select v-model="createForm.groupBuyTypeId" placeholder="请先选择标签" :disabled="!createForm.tag" style="width: 100%">
|
||||||
|
<el-option v-for="item in createTypeList" :key="item.id" :label="`${item.name} (${item.maxPerson}人)`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleCreate" :loading="createLoading">
|
||||||
|
创建
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 查看成员对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showMembersDialog"
|
||||||
|
title="队伍成员列表"
|
||||||
|
width="700px"
|
||||||
|
>
|
||||||
|
<el-table :data="currentMembers" border stripe>
|
||||||
|
<el-table-column label="头像" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-avatar :size="50" :src="row.cover" v-if="row.cover">
|
||||||
|
<img src="https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png" />
|
||||||
|
</el-avatar>
|
||||||
|
<el-avatar :size="50" v-else>
|
||||||
|
{{ row.username?.charAt(0) || '?' }}
|
||||||
|
</el-avatar>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="用户ID" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.userId" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.userId }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||||
|
<el-table-column label="队长" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.teamLeader" type="warning" size="small">队长</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="danger" size="small" @click="handleClearUserGroups(row.userId)">清除队伍</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 清除用户队伍对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showClearUserDialog"
|
||||||
|
title="清除指定用户的所有队伍"
|
||||||
|
width="400px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form :model="clearUserForm" label-width="80px">
|
||||||
|
<el-form-item label="用户ID">
|
||||||
|
<el-input v-model="clearUserForm.userId" placeholder="请输入用户ID" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showClearUserDialog = false">取消</el-button>
|
||||||
|
<el-button type="danger" @click="handleClearUserSubmit" :loading="clearUserLoading">确认清除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 拼团类型表单对话框 -->
|
||||||
|
<el-dialog v-model="typeDialogVisible" :title="isEditType ? '编辑拼团类型' : '新增拼团类型'" width="500px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="typeForm" :rules="typeRules" ref="typeFormRef" label-width="100px">
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="typeForm.name" placeholder="请输入名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="价格" prop="price">
|
||||||
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="typeForm.price" :min="0" style="flex:1" />
|
||||||
|
<span class="unit-text">分</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="续费价格" prop="renewPrice">
|
||||||
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="typeForm.renewPrice" :min="0" style="flex:1" />
|
||||||
|
<span class="unit-text">分</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="拼团人数" prop="maxPerson">
|
||||||
|
<el-input-number v-model="typeForm.maxPerson" :min="2" :max="100" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签" prop="tag">
|
||||||
|
<el-select v-model="typeForm.tag" placeholder="选择标签" filterable allow-create style="width: 100%">
|
||||||
|
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="过期时间" prop="expireTime">
|
||||||
|
<el-date-picker v-model="typeForm.expireTime" type="datetime" placeholder="选择过期时间" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注字段">
|
||||||
|
<div class="note-fields-container">
|
||||||
|
<el-button type="primary" size="small" @click="addNoteField" style="margin-bottom: 10px">+ 添加字段</el-button>
|
||||||
|
<el-table :data="typeForm.noteFields" border size="small" v-if="typeForm.noteFields.length">
|
||||||
|
<el-table-column label="名称" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.label" placeholder="如:内存" size="small" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="默认值" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.defaultValue" placeholder="如:20GB" size="small" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="60" align="center">
|
||||||
|
<template #default="{ $index }">
|
||||||
|
<el-button type="danger" size="small" link @click="removeNoteField($index)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="typeDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleTypeSubmit" :loading="typeSubmitLoading">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getGroupBuyList,
|
||||||
|
getGroupBuyDetail,
|
||||||
|
addRandomUser,
|
||||||
|
addRandomGroup,
|
||||||
|
exportIdcInfo,
|
||||||
|
setOrder
|
||||||
|
} from '@/api/admin/activity'
|
||||||
|
import {
|
||||||
|
getGroupBuyTypeList,
|
||||||
|
getGroupBuyTypeTags,
|
||||||
|
removeGroupBuy,
|
||||||
|
clearAllGroupBuy,
|
||||||
|
clearUserGroupBuy,
|
||||||
|
addGroupBuyType,
|
||||||
|
updateGroupBuyType,
|
||||||
|
deleteGroupBuyType
|
||||||
|
} from '@/api/groupBuy'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 当前激活的标签页
|
||||||
|
const activeTab = ref('activity')
|
||||||
|
|
||||||
|
// ==================== 拼团活动相关 ====================
|
||||||
|
const activityLoading = ref(false)
|
||||||
|
const exportLoading = ref(false)
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const groupList = ref([])
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showMembersDialog = ref(false)
|
||||||
|
const showClearUserDialog = ref(false)
|
||||||
|
const currentMembers = ref([])
|
||||||
|
const clearUserLoading = ref(false)
|
||||||
|
const clearUserForm = reactive({ userId: '' })
|
||||||
|
|
||||||
|
const createFormRef = ref(null)
|
||||||
|
const createForm = reactive({
|
||||||
|
name: '',
|
||||||
|
tag: '',
|
||||||
|
groupBuyTypeId: ''
|
||||||
|
})
|
||||||
|
const createTypeList = ref([])
|
||||||
|
const tagList = ref([])
|
||||||
|
|
||||||
|
const createRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入队伍名称', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
tag: [
|
||||||
|
{ required: true, message: '请选择标签', trigger: 'change' }
|
||||||
|
],
|
||||||
|
groupBuyTypeId: [
|
||||||
|
{ required: true, message: '请选择拼团类型', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 拼团类型相关 ====================
|
||||||
|
const typeLoading = ref(false)
|
||||||
|
const typeTableData = ref([])
|
||||||
|
const typeTotal = ref(0)
|
||||||
|
const typePage = ref(1)
|
||||||
|
const typePageSize = ref(10)
|
||||||
|
const searchKey = ref('')
|
||||||
|
const searchTag = ref('')
|
||||||
|
const typeDialogVisible = ref(false)
|
||||||
|
const isEditType = ref(false)
|
||||||
|
const typeSubmitLoading = ref(false)
|
||||||
|
const typeFormRef = ref(null)
|
||||||
|
|
||||||
|
const typeForm = reactive({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
price: 0,
|
||||||
|
renewPrice: 0,
|
||||||
|
maxPerson: 5,
|
||||||
|
tag: '',
|
||||||
|
expireTime: null,
|
||||||
|
noteFields: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeRules = {
|
||||||
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
|
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||||
|
renewPrice: [{ required: true, message: '请输入续费价格', trigger: 'blur' }],
|
||||||
|
maxPerson: [{ required: true, message: '请输入拼团人数', trigger: 'blur' }],
|
||||||
|
tag: [{ required: true, message: '请选择标签', trigger: 'change' }],
|
||||||
|
expireTime: [{ required: true, message: '请选择过期时间', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 通用方法 ====================
|
||||||
|
const formatTime = (timeStr) => {
|
||||||
|
if (!timeStr) return '-'
|
||||||
|
return new Date(timeStr).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标签列表
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyTypeTags()
|
||||||
|
if (res.code === 200) {
|
||||||
|
tagList.value = res.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 拼团活动方法 ====================
|
||||||
|
// 根据 tag 获取拼团类型列表(用于创建对话框)
|
||||||
|
const fetchCreateTypeListByTag = async (tag) => {
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyTypeList({ page: 1, count: 10, tag })
|
||||||
|
if (res.code === 200) {
|
||||||
|
createTypeList.value = res.data?.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取拼团类型失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tag 变化时获取对应的拼团类型(创建对话框)
|
||||||
|
const handleCreateTagChange = (tag) => {
|
||||||
|
createForm.groupBuyTypeId = ''
|
||||||
|
createTypeList.value = []
|
||||||
|
if (tag) {
|
||||||
|
fetchCreateTypeListByTag(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开创建对话框
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
createForm.name = ''
|
||||||
|
createForm.tag = ''
|
||||||
|
createForm.groupBuyTypeId = ''
|
||||||
|
createTypeList.value = []
|
||||||
|
fetchTags()
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取队伍列表
|
||||||
|
const fetchGroupList = async () => {
|
||||||
|
activityLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyList()
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
const allGroups = res.data.data.group_buy_list || []
|
||||||
|
const lackGroups = res.data.data.lack_group_buy_list || []
|
||||||
|
const successGroups = res.data.data.success_group_buy_list || []
|
||||||
|
|
||||||
|
const successIds = successGroups.map(g => g.group_buy_id)
|
||||||
|
const lackIds = lackGroups.map(g => g.group_buy_id)
|
||||||
|
|
||||||
|
groupList.value = allGroups.map(group => {
|
||||||
|
let status = 'empty'
|
||||||
|
if (successIds.includes(group.group_buy_id)) {
|
||||||
|
status = 'success'
|
||||||
|
} else if (lackIds.includes(group.group_buy_id)) {
|
||||||
|
status = 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: group.group_buy_id,
|
||||||
|
name: group.name,
|
||||||
|
type: group.maxPerson === 5 ? 0 : 1,
|
||||||
|
currentMembers: group.users?.length || 0,
|
||||||
|
maxMembers: group.maxPerson,
|
||||||
|
status: status,
|
||||||
|
createTime: group.createTime || '-',
|
||||||
|
members: group.users || [],
|
||||||
|
addingUser: false,
|
||||||
|
settingOrder: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success(`加载成功,共 ${allGroups.length} 个队伍`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '获取队伍列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取队伍列表出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
activityLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const statusMap = {
|
||||||
|
'empty': '空队伍',
|
||||||
|
'pending': '进行中',
|
||||||
|
'success': '已满员'
|
||||||
|
}
|
||||||
|
return statusMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (status) => {
|
||||||
|
const typeMap = {
|
||||||
|
'empty': 'info',
|
||||||
|
'pending': 'warning',
|
||||||
|
'success': 'success'
|
||||||
|
}
|
||||||
|
return typeMap[status] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!createFormRef.value) return
|
||||||
|
|
||||||
|
await createFormRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await addRandomGroup({ name: createForm.name, group_buy_type_id: String(createForm.groupBuyTypeId) })
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success(`创建成功!队伍ID: ${res.data.group_buy_id}`)
|
||||||
|
showCreateDialog.value = false
|
||||||
|
createForm.name = ''
|
||||||
|
createForm.groupBuyTypeId = ''
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '创建失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建队伍出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddRandomUser = async (row) => {
|
||||||
|
row.addingUser = true
|
||||||
|
try {
|
||||||
|
const res = await addRandomUser(row.id)
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success('添加伪人成功')
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '添加伪人失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加伪人出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
row.addingUser = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetOrder = async (row) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定要为该队伍下发订单吗?',
|
||||||
|
'确认操作',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
row.settingOrder = true
|
||||||
|
try {
|
||||||
|
const res = await setOrder(row.id)
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success('订单下发成功')
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '订单下发失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下发订单出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
row.settingOrder = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 用户取消操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewMembers = async (row) => {
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyDetail(row.id)
|
||||||
|
if (res && res.data && res.data.code === 200) {
|
||||||
|
const detail = res.data.data
|
||||||
|
currentMembers.value = (detail.users || []).map(member => ({
|
||||||
|
userId: member.user_id,
|
||||||
|
username: member.user_name || `用户${member.user_id}`,
|
||||||
|
cover: member.cover || '',
|
||||||
|
teamLeader: member.team_leader || false,
|
||||||
|
idcUid: member.idc_uid || '-',
|
||||||
|
idcPhone: member.idc_phone || '-'
|
||||||
|
}))
|
||||||
|
|
||||||
|
row.name = detail.name
|
||||||
|
row.currentMembers = detail.users?.length || 0
|
||||||
|
row.maxMembers = detail.maxPerson
|
||||||
|
row.members = detail.users || []
|
||||||
|
} else {
|
||||||
|
currentMembers.value = row.members.map(member => ({
|
||||||
|
userId: member.user_id,
|
||||||
|
username: member.user_name || `用户${member.user_id}`,
|
||||||
|
cover: member.cover || '',
|
||||||
|
teamLeader: member.team_leader || false,
|
||||||
|
idcUid: member.idc_uid || '-',
|
||||||
|
idcPhone: member.idc_phone || '-'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
showMembersDialog.value = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取成员信息失败:', error)
|
||||||
|
currentMembers.value = row.members.map(member => ({
|
||||||
|
userId: member.user_id,
|
||||||
|
username: member.user_name || `用户${member.user_id}`,
|
||||||
|
cover: member.cover || '',
|
||||||
|
teamLeader: member.team_leader || false,
|
||||||
|
idcUid: member.idc_uid || '-',
|
||||||
|
idcPhone: member.idc_phone || '-'
|
||||||
|
}))
|
||||||
|
showMembersDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
exportLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await exportIdcInfo()
|
||||||
|
|
||||||
|
if (res.data && res.data.code === 200) {
|
||||||
|
const jsonStr = JSON.stringify(res.data.data, null, 2)
|
||||||
|
const blob = new Blob([jsonStr], { type: 'application/json' })
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `拼团成功队伍_${new Date().getTime()}.json`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data?.message || '导出失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出出错:', error)
|
||||||
|
ElMessage.error('导出失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
exportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveGroup = async (row) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该队伍吗?', '确认删除', { type: 'warning' })
|
||||||
|
const res = await removeGroupBuy(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要清除所有队伍吗?此操作不可恢复!', '危险操作', { type: 'error', confirmButtonText: '确定清除' })
|
||||||
|
const res = await clearAllGroupBuy()
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('已清除所有队伍')
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '清除失败')
|
||||||
|
}
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearUserGroups = async (userId) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要清除用户 ${userId} 的所有队伍吗?`, '确认操作', { type: 'warning' })
|
||||||
|
const res = await clearUserGroupBuy(userId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('清除成功')
|
||||||
|
showMembersDialog.value = false
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '清除失败')
|
||||||
|
}
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearUserSubmit = async () => {
|
||||||
|
if (!clearUserForm.userId) {
|
||||||
|
ElMessage.warning('请输入用户ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearUserLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await clearUserGroupBuy(clearUserForm.userId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('清除成功')
|
||||||
|
showClearUserDialog.value = false
|
||||||
|
clearUserForm.userId = ''
|
||||||
|
fetchGroupList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '清除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清除用户队伍失败:', error)
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
} finally {
|
||||||
|
clearUserLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 拼团类型方法 ====================
|
||||||
|
const fetchTypeList = async () => {
|
||||||
|
typeLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyTypeList({ page: typePage.value, count: typePageSize.value, key: searchKey.value || undefined, tag: searchTag.value || undefined })
|
||||||
|
if (res.code === 200) {
|
||||||
|
typeTableData.value = res.data?.data || []
|
||||||
|
typeTotal.value = res.data?.all_count || 0
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '获取列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取列表失败:', error)
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
} finally {
|
||||||
|
typeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTagChange = (tag) => {
|
||||||
|
typePage.value = 1
|
||||||
|
searchKey.value = ''
|
||||||
|
typeTableData.value = []
|
||||||
|
typeTotal.value = 0
|
||||||
|
if (tag) {
|
||||||
|
fetchTypeList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddType = () => {
|
||||||
|
isEditType.value = false
|
||||||
|
Object.assign(typeForm, { id: '', name: '', price: 0, renewPrice: 0, maxPerson: 5, tag: '', expireTime: null, noteFields: [] })
|
||||||
|
typeDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditType = (row) => {
|
||||||
|
isEditType.value = true
|
||||||
|
let noteFields = []
|
||||||
|
try {
|
||||||
|
noteFields = row.note ? JSON.parse(row.note) : []
|
||||||
|
} catch { noteFields = [] }
|
||||||
|
Object.assign(typeForm, { id: row.id, name: row.name, price: row.price, renewPrice: row.renewPrice, maxPerson: row.maxPerson, tag: row.tag || '', expireTime: row.expireTime || null, noteFields })
|
||||||
|
typeDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addNoteField = () => {
|
||||||
|
typeForm.noteFields.push({ label: '', defaultValue: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeNoteField = (index) => {
|
||||||
|
typeForm.noteFields.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTypeSubmit = async () => {
|
||||||
|
if (!typeFormRef.value) return
|
||||||
|
await typeFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
typeSubmitLoading.value = true
|
||||||
|
try {
|
||||||
|
const noteJson = JSON.stringify(typeForm.noteFields.filter(f => f.label))
|
||||||
|
const data = {
|
||||||
|
name: typeForm.name,
|
||||||
|
price: String(typeForm.price),
|
||||||
|
renew_price: String(typeForm.renewPrice),
|
||||||
|
max_person: String(typeForm.maxPerson),
|
||||||
|
tag: typeForm.tag,
|
||||||
|
expire_time: typeForm.expireTime ? Math.floor(new Date(typeForm.expireTime).getTime() / 1000) : 0,
|
||||||
|
note: noteJson
|
||||||
|
}
|
||||||
|
if (isEditType.value) data.id = String(typeForm.id)
|
||||||
|
const res = isEditType.value ? await updateGroupBuyType(data) : await addGroupBuyType(data)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(isEditType.value ? '修改成功' : '新增成功')
|
||||||
|
typeDialogVisible.value = false
|
||||||
|
fetchTypeList()
|
||||||
|
fetchTags()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交失败:', error)
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
} finally {
|
||||||
|
typeSubmitLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteType = async (row) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该拼团类型吗?', '确认删除', { type: 'warning' })
|
||||||
|
const res = await deleteGroupBuyType(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchTypeList()
|
||||||
|
fetchTags()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听标签页切换
|
||||||
|
watch(activeTab, (newVal) => {
|
||||||
|
if (newVal === 'activity') {
|
||||||
|
fetchGroupList()
|
||||||
|
} else if (newVal === 'type') {
|
||||||
|
fetchTags()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
fetchGroupList()
|
||||||
|
fetchTags()
|
||||||
|
|
||||||
|
// 检查路由参数决定初始标签页
|
||||||
|
if (route.query.tab === 'type') {
|
||||||
|
activeTab.value = 'type'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-buy-manage-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
border: 1px solid #e1e8ed;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-buy-tabs {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #e1e8ed;
|
||||||
|
background: #fafbfc;
|
||||||
|
margin: 0 -20px;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-section {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-fields-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式优化 */
|
||||||
|
:deep(.el-table) {
|
||||||
|
border: none;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__header) {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th) {
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
border-bottom: 2px solid #e1e8ed;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table td) {
|
||||||
|
border-bottom: 1px solid #f0f2f5;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table tr:hover > td) {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 0;
|
||||||
|
border-bottom: 1px solid #e1e8ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__nav-wrap::after) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 50px;
|
||||||
|
line-height: 50px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item.is-active) {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__active-bar) {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-buy-type-container">
|
||||||
|
<el-card class="header-card">
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button type="primary" icon="Plus" @click="handleAdd">新增类型</el-button>
|
||||||
|
<el-select v-model="searchTag" placeholder="请选择标签" style="width: 180px; margin-left: 12px" @change="handleTagChange">
|
||||||
|
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="searchKey" placeholder="关键词搜索" style="width: 200px; margin-left: 12px" clearable :disabled="!searchTag" @keyup.enter="fetchList" />
|
||||||
|
<el-button type="info" icon="Refresh" @click="fetchList" :loading="loading" :disabled="!searchTag" style="margin-left: 12px">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="table-card">
|
||||||
|
<el-empty v-if="!searchTag" description="请先选择标签" />
|
||||||
|
<template v-else>
|
||||||
|
<el-table :data="tableData" v-loading="loading" stripe border>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column label="价格" width="120">
|
||||||
|
<template #default="{ row }">¥{{ (row.price / 100).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="续费价格" width="120">
|
||||||
|
<template #default="{ row }">¥{{ (row.renewPrice / 100).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="maxPerson" label="拼团人数" width="100" align="center" />
|
||||||
|
<el-table-column prop="tag" label="标签" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.tag" type="info">{{ row.tag }}</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="过期时间" width="180">
|
||||||
|
<template #default="{ row }">{{ row.expireTime ? formatTime(row.expireTime) : '永久' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="fetchList" @current-change="fetchList" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑拼团类型' : '新增拼团类型'" width="500px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="价格" prop="price">
|
||||||
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="form.price" :min="0" style="flex:1" />
|
||||||
|
<span class="unit-text">分</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="续费价格" prop="renewPrice">
|
||||||
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="form.renewPrice" :min="0" style="flex:1" />
|
||||||
|
<span class="unit-text">分</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="拼团人数" prop="maxPerson">
|
||||||
|
<el-input-number v-model="form.maxPerson" :min="2" :max="100" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签" prop="tag">
|
||||||
|
<el-select v-model="form.tag" placeholder="选择标签" filterable allow-create style="width: 100%">
|
||||||
|
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="过期时间" prop="expireTime">
|
||||||
|
<el-date-picker v-model="form.expireTime" type="datetime" placeholder="选择过期时间" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注字段">
|
||||||
|
<div class="note-fields-container">
|
||||||
|
<el-button type="primary" size="small" @click="addNoteField" style="margin-bottom: 10px">+ 添加字段</el-button>
|
||||||
|
<el-table :data="form.noteFields" border size="small" v-if="form.noteFields.length">
|
||||||
|
<el-table-column label="名称" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.label" placeholder="如:内存" size="small" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="默认值" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.defaultValue" placeholder="如:20GB" size="small" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="60" align="center">
|
||||||
|
<template #default="{ $index }">
|
||||||
|
<el-button type="danger" size="small" link @click="removeNoteField($index)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getGroupBuyTypeList, getGroupBuyTypeTags, addGroupBuyType, updateGroupBuyType, deleteGroupBuyType } from '@/api/groupBuy'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tableData = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const searchKey = ref('')
|
||||||
|
const searchTag = ref('')
|
||||||
|
const tagList = ref([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const formRef = ref(null)
|
||||||
|
|
||||||
|
const form = reactive({ id: '', name: '', price: 0, renewPrice: 0, maxPerson: 5, tag: '', expireTime: null, noteFields: [] })
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
|
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||||
|
renewPrice: [{ required: true, message: '请输入续费价格', trigger: 'blur' }],
|
||||||
|
maxPerson: [{ required: true, message: '请输入拼团人数', trigger: 'blur' }],
|
||||||
|
tag: [{ required: true, message: '请选择标签', trigger: 'change' }],
|
||||||
|
expireTime: [{ required: true, message: '请选择过期时间', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加备注字段
|
||||||
|
const addNoteField = () => {
|
||||||
|
form.noteFields.push({ label: '', defaultValue: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除备注字段
|
||||||
|
const removeNoteField = (index) => {
|
||||||
|
form.noteFields.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timeStr) => {
|
||||||
|
if (!timeStr) return '-'
|
||||||
|
return new Date(timeStr).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyTypeList({ page: page.value, count: pageSize.value, key: searchKey.value || undefined, tag: searchTag.value || undefined })
|
||||||
|
console.log("获取拼团类型列表数据:",res)
|
||||||
|
if (res.code === 200) {
|
||||||
|
tableData.value = res.data?.data || []
|
||||||
|
total.value = res.data?.all_count || 0
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '获取列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取列表失败:', error)
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getGroupBuyTypeTags()
|
||||||
|
if (res.code === 200) tagList.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签变化时重新获取列表
|
||||||
|
const handleTagChange = (tag) => {
|
||||||
|
page.value = 1
|
||||||
|
searchKey.value = ''
|
||||||
|
tableData.value = []
|
||||||
|
total.value = 0
|
||||||
|
if (tag) {
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
Object.assign(form, { id: '', name: '', price: 0, renewPrice: 0, maxPerson: 5, tag: '', expireTime: null, noteFields: [] })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
isEdit.value = true
|
||||||
|
let noteFields = []
|
||||||
|
try {
|
||||||
|
noteFields = row.note ? JSON.parse(row.note) : []
|
||||||
|
} catch { noteFields = [] }
|
||||||
|
Object.assign(form, { id: row.id, name: row.name, price: row.price, renewPrice: row.renewPrice, maxPerson: row.maxPerson, tag: row.tag || '', expireTime: row.expireTime || null, noteFields })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
const noteJson = JSON.stringify(form.noteFields.filter(f => f.label))
|
||||||
|
const data = {
|
||||||
|
name: form.name,
|
||||||
|
price: String(form.price),
|
||||||
|
renew_price: String(form.renewPrice),
|
||||||
|
max_person: String(form.maxPerson),
|
||||||
|
tag: form.tag,
|
||||||
|
expire_time: form.expireTime ? Math.floor(new Date(form.expireTime).getTime() / 1000) : 0,
|
||||||
|
note: noteJson
|
||||||
|
}
|
||||||
|
if (isEdit.value) data.id = String(form.id)
|
||||||
|
const res = isEdit.value ? await updateGroupBuyType(data) : await addGroupBuyType(data)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
fetchTags()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交失败:', error)
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该拼团类型吗?', '确认删除', { type: 'warning' })
|
||||||
|
const res = await deleteGroupBuyType(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchList()
|
||||||
|
fetchTags()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { fetchTags() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-buy-type-container { padding: 20px; }
|
||||||
|
.header-card { margin-bottom: 20px; }
|
||||||
|
.header-actions { display: flex; align-items: center; }
|
||||||
|
.table-card { box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); }
|
||||||
|
.pagination-wrapper { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||||
|
.note-fields-container { width: 100%; }
|
||||||
|
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
@@ -67,7 +67,12 @@
|
|||||||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||||
>
|
>
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="container_id" label="容器ID" width="280" show-overflow-tooltip />
|
<el-table-column label="容器ID" width="280" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.container_id" type="primary" :underline="false" @click="router.push({ path: '/servers/container', query: { container_id: row.container_id } })">{{ row.container_id }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="url" label="访问地址" min-width="200" show-overflow-tooltip>
|
<el-table-column prop="url" label="访问地址" min-width="200" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link :href="row.url" target="_blank" type="primary" v-if="row.url">
|
<el-link :href="row.url" target="_blank" type="primary" v-if="row.url">
|
||||||
@@ -146,6 +151,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Refresh, Download, Search, Delete, View, Warning,
|
Refresh, Download, Search, Delete, View, Warning,
|
||||||
@@ -159,6 +165,8 @@ import {
|
|||||||
|
|
||||||
} from '@/utils/acs/audit'
|
} from '@/utils/acs/audit'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
domain: '',
|
domain: '',
|
||||||
@@ -419,7 +427,7 @@ const getFullStatsData = async () => {
|
|||||||
// 获取第一页大量数据来进行统计,或者调用专门的统计接口
|
// 获取第一页大量数据来进行统计,或者调用专门的统计接口
|
||||||
const statsParams = {
|
const statsParams = {
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000, // 获取大量数据进行统计
|
count: 10, // 获取大量数据进行统计
|
||||||
server_id: '',
|
server_id: '',
|
||||||
user_id: '',
|
user_id: '',
|
||||||
key: queryParams.domain || ''
|
key: queryParams.domain || ''
|
||||||
|
|||||||
@@ -35,7 +35,12 @@
|
|||||||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||||
>
|
>
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="container_id" label="容器ID" width="280" show-overflow-tooltip />
|
<el-table-column label="容器ID" width="280" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.container_id" type="primary" :underline="false" @click="router.push({ path: '/servers/container', query: { container_id: row.container_id } })">{{ row.container_id }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="url" label="违规地址" min-width="200" show-overflow-tooltip>
|
<el-table-column prop="url" label="违规地址" min-width="200" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link :href="row.url" target="_blank" type="danger" v-if="row.url">
|
<el-link :href="row.url" target="_blank" type="danger" v-if="row.url">
|
||||||
@@ -195,6 +200,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Refresh, Download, Search, Delete, View, Warning,
|
Refresh, Download, Search, Delete, View, Warning,
|
||||||
@@ -208,6 +214,8 @@ import {
|
|||||||
|
|
||||||
} from '@/utils/acs/audit'
|
} from '@/utils/acs/audit'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
domain: '',
|
domain: '',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<el-icon><component :is="card.icon" /></el-icon>
|
<el-icon><component :is="card.icon" /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<!-- <div class="card-footer">
|
||||||
<span>较昨日</span>
|
<span>较昨日</span>
|
||||||
<span :class="card.trend > 0 ? 'up' : 'down'">
|
<span :class="card.trend > 0 ? 'up' : 'down'">
|
||||||
{{ card.trend > 0 ? '+' : '' }}{{ card.trend }}%
|
{{ card.trend > 0 ? '+' : '' }}{{ card.trend }}%
|
||||||
@@ -23,13 +23,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-inner" :style="{width: card.progress + '%', background: card.progressColor}"></div>
|
<div class="progress-inner" :style="{width: card.progress + '%', background: card.progressColor}"></div>
|
||||||
</div>
|
</div> -->
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- 图表部分 -->
|
<!-- 图表部分 -->
|
||||||
<el-row :gutter="24" class="chart-row">
|
<!-- <el-row :gutter="24" class="chart-row">
|
||||||
<el-col :xs="24" :sm="24" :md="24" :lg="16" :xl="16">
|
<el-col :xs="24" :sm="24" :md="24" :lg="16" :xl="16">
|
||||||
<el-card class="chart-card" shadow="hover">
|
<el-card class="chart-card" shadow="hover">
|
||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
@@ -116,10 +116,137 @@
|
|||||||
<div class="chart-container" ref="customerChartRef"></div>
|
<div class="chart-container" ref="customerChartRef"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
</el-row> -->
|
||||||
|
|
||||||
|
<!-- 数据列表区域 -->
|
||||||
|
<el-row :gutter="24" class="list-row">
|
||||||
|
<!-- 最近用户 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
|
||||||
|
<el-card class="list-card" shadow="hover" v-loading="listLoading">
|
||||||
|
<div class="card-header-custom">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon class="header-icon user-icon"><User /></el-icon>
|
||||||
|
<h3>最近用户</h3>
|
||||||
|
</div>
|
||||||
|
<el-link type="primary" :underline="false" class="view-all" @click="goToUserList">
|
||||||
|
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
|
||||||
|
</el-link>
|
||||||
|
</div>
|
||||||
|
<div class="list-content">
|
||||||
|
<div v-if="recentUsers.length === 0" class="empty-tip">暂无数据</div>
|
||||||
|
<div v-for="item in recentUsers" :key="item.id" class="list-item" @click="goToUserDetail(item.id)">
|
||||||
|
<div class="item-main">
|
||||||
|
<div class="item-title">{{ item.name }}</div>
|
||||||
|
<div class="item-sub">{{ item.email }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-extra">
|
||||||
|
<div class="item-id">ID: {{ item.id }}</div>
|
||||||
|
<div class="item-time">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 最近订单 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
|
||||||
|
<el-card class="list-card" shadow="hover" v-loading="listLoading">
|
||||||
|
<div class="card-header-custom">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon class="header-icon order-icon"><ShoppingCart /></el-icon>
|
||||||
|
<h3>最近订单</h3>
|
||||||
|
</div>
|
||||||
|
<el-link type="primary" :underline="false" class="view-all" @click="goToOrderList">
|
||||||
|
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
|
||||||
|
</el-link>
|
||||||
|
</div>
|
||||||
|
<div class="list-content">
|
||||||
|
<div v-if="recentOrders.length === 0" class="empty-tip">暂无数据</div>
|
||||||
|
<div v-for="item in recentOrders" :key="item.id" class="list-item" @click="showOrderDetail(item)">
|
||||||
|
<div class="item-main">
|
||||||
|
<div class="item-title">{{ item.name }}</div>
|
||||||
|
<div class="item-sub">用户ID: {{ item.userId }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-extra">
|
||||||
|
<el-tag :type="getOrderStatusType(item.state)" size="small">
|
||||||
|
{{ getOrderStatusText(item.state) }}
|
||||||
|
</el-tag>
|
||||||
|
<div class="item-price">¥{{ (item.price / 100).toFixed(2) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 最近工单 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
|
||||||
|
<el-card class="list-card" shadow="hover" v-loading="listLoading">
|
||||||
|
<div class="card-header-custom">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon class="header-icon ticket-icon"><Tickets /></el-icon>
|
||||||
|
<h3>最近工单</h3>
|
||||||
|
</div>
|
||||||
|
<el-link type="primary" :underline="false" class="view-all" @click="goToTicketList">
|
||||||
|
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
|
||||||
|
</el-link>
|
||||||
|
</div>
|
||||||
|
<div class="list-content">
|
||||||
|
<div v-if="recentTickets.length === 0" class="empty-tip">暂无数据</div>
|
||||||
|
<div v-for="item in recentTickets" :key="item.id" class="list-item" @click="goToTicketDetail(item.id)">
|
||||||
|
<div class="item-main">
|
||||||
|
<div class="item-title">{{ item.title || '工单 #' + item.id }}</div>
|
||||||
|
<div class="item-sub">用户ID: {{ item.userId }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-extra">
|
||||||
|
<el-tag :type="getTicketStatusType(item.status)" size="small">
|
||||||
|
{{ getTicketStatusText(item.status) }}
|
||||||
|
</el-tag>
|
||||||
|
<div class="item-time">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 订单详情弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="orderDetailVisible"
|
||||||
|
title="订单详情"
|
||||||
|
width="600px"
|
||||||
|
append-to-body
|
||||||
|
class="order-detail-dialog"
|
||||||
|
>
|
||||||
|
<el-descriptions :column="2" border v-if="currentOrder">
|
||||||
|
<el-descriptions-item label="订单ID">{{ currentOrder.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单名称">{{ currentOrder.name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户ID">{{ currentOrder.userId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品ID">{{ currentOrder.commodityId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单金额">
|
||||||
|
<span class="detail-price">¥{{ (currentOrder.price / 100).toFixed(2) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="续费价格">
|
||||||
|
<span class="detail-renew-price">¥{{ (currentOrder.renewPrice / 100).toFixed(2) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数量">{{ currentOrder.payNum }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单状态">
|
||||||
|
<el-tag :type="getOrderStatusType(currentOrder.state)">
|
||||||
|
{{ getOrderStatusText(currentOrder.state) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="支付方式">{{ currentOrder.payType || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDate(currentOrder.createdAt) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="orderDetailVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="goToOrderList">查看全部订单</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 最近活动和待办事项 -->
|
<!-- 最近活动和待办事项 -->
|
||||||
<el-row :gutter="24" class="activity-row">
|
<!-- <el-row :gutter="24" class="activity-row">
|
||||||
<el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
|
<el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
|
||||||
<el-card class="activity-card" shadow="hover">
|
<el-card class="activity-card" shadow="hover">
|
||||||
<div class="card-header-custom">
|
<div class="card-header-custom">
|
||||||
@@ -207,28 +334,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, computed } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
User, ShoppingCart, Money, DataAnalysis,
|
User, ShoppingCart, Money, DataAnalysis,
|
||||||
MoreFilled, ArrowUp, ArrowDown, Right,
|
MoreFilled, ArrowUp, ArrowDown, Right,
|
||||||
Download, Refresh, Check, Delete, Plus,
|
Download, Refresh, Check, Delete, Plus,
|
||||||
Setting, Calendar, Filter
|
Setting, Calendar, Filter, Tickets, View
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import Qrcode from '@/components/Qrcode.vue'
|
import Qrcode from '@/components/Qrcode.vue'
|
||||||
import {useUserStore} from "@/store/userStore.js";
|
import {useUserStore} from "@/store/userStore.js";
|
||||||
|
import { getUserList } from '@/api/admin/user'
|
||||||
|
import { getOrderList } from '@/api/admin/order'
|
||||||
|
import { getTicketCount, getTickerList } from '@/api/ticket'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const userCount = ref(0)
|
||||||
|
const orderCount = ref(0)
|
||||||
|
const ticketCount = ref(0)
|
||||||
|
|
||||||
|
// 列表数据
|
||||||
|
const recentUsers = ref([])
|
||||||
|
const recentOrders = ref([])
|
||||||
|
const recentTickets = ref([])
|
||||||
|
const listLoading = ref(false)
|
||||||
|
|
||||||
// 数据统计卡片
|
// 数据统计卡片
|
||||||
const statisticsCards = ref([
|
const statisticsCards = computed(() => [
|
||||||
{
|
{
|
||||||
title: '访问量',
|
title: '用户量',
|
||||||
value: '8,846',
|
value: userCount.value.toLocaleString(),
|
||||||
icon: 'User',
|
icon: 'User',
|
||||||
trend: 12.5,
|
trend: 12.5,
|
||||||
class: 'visitors',
|
class: 'visitors',
|
||||||
@@ -237,7 +381,7 @@ const statisticsCards = ref([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '订单量',
|
title: '订单量',
|
||||||
value: '1,257',
|
value: orderCount.value.toLocaleString(),
|
||||||
icon: 'ShoppingCart',
|
icon: 'ShoppingCart',
|
||||||
trend: 5.2,
|
trend: 5.2,
|
||||||
class: 'orders',
|
class: 'orders',
|
||||||
@@ -245,9 +389,9 @@ const statisticsCards = ref([
|
|||||||
progressColor: 'rgba(82, 196, 26, 0.8)'
|
progressColor: 'rgba(82, 196, 26, 0.8)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '销售额',
|
title: '工单量',
|
||||||
value: '¥ 125,430',
|
value: ticketCount.value.toLocaleString(),
|
||||||
icon: 'Money',
|
icon: 'Tickets',
|
||||||
trend: -2.3,
|
trend: -2.3,
|
||||||
class: 'sales',
|
class: 'sales',
|
||||||
progress: 52,
|
progress: 52,
|
||||||
@@ -264,6 +408,150 @@ const statisticsCards = ref([
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 获取统计数据
|
||||||
|
const fetchStatistics = async () => {
|
||||||
|
try {
|
||||||
|
// 获取用户数量
|
||||||
|
const userRes = await getUserList({ page: 1, count: 10, key: '' })
|
||||||
|
console.log("用户数量,",userRes)
|
||||||
|
if (userRes.data?.code === 200) {
|
||||||
|
userCount.value = userRes.data.data.all_count || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取订单数量
|
||||||
|
const orderRes = await getOrderList({ page: 1, count: 10 })
|
||||||
|
console.log("订单数量,",orderRes)
|
||||||
|
if (orderRes.data?.code === 200) {
|
||||||
|
orderCount.value = orderRes.data.data.all_count || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取工单数量
|
||||||
|
const ticketRes = await getTicketCount()
|
||||||
|
console.log("工单数量,",ticketRes)
|
||||||
|
if (ticketRes.code === 200) {
|
||||||
|
ticketCount.value = ticketRes.data?.all_count || 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最近列表数据
|
||||||
|
const fetchRecentLists = async () => {
|
||||||
|
listLoading.value = true
|
||||||
|
try {
|
||||||
|
// 获取最近用户
|
||||||
|
const userRes = await getUserList({ page: 1, count: 10, key: '' })
|
||||||
|
if (userRes.data?.code === 200) {
|
||||||
|
recentUsers.value = (userRes.data.data.data || []).map(user => ({
|
||||||
|
id: user.user_id,
|
||||||
|
name: user.user_name,
|
||||||
|
email: user.email || '未设置',
|
||||||
|
phone: user.phone || '未设置',
|
||||||
|
createdAt: user.created_at
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最近订单
|
||||||
|
const orderRes = await getOrderList({ page: 1, count: 10 })
|
||||||
|
if (orderRes.data?.code === 200) {
|
||||||
|
recentOrders.value = (orderRes.data.data.list || []).map(order => ({
|
||||||
|
id: order.id,
|
||||||
|
name: order.name,
|
||||||
|
userId: order.userId,
|
||||||
|
commodityId: order.commodityId,
|
||||||
|
price: order.price,
|
||||||
|
renewPrice: order.renewPrice || 0,
|
||||||
|
payNum: order.payNum || 1,
|
||||||
|
state: order.state,
|
||||||
|
payType: order.payType,
|
||||||
|
createdAt: order.CreatedAt
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最近工单
|
||||||
|
const ticketRes = await getTickerList(5, 1)
|
||||||
|
console.log("最近工单,",ticketRes)
|
||||||
|
if (ticketRes.code === 200) {
|
||||||
|
recentTickets.value = (ticketRes.data.data?.list || ticketRes.data.data || []).map(ticket => ({
|
||||||
|
id: ticket.work_id || ticket.id,
|
||||||
|
title: ticket.title,
|
||||||
|
status: ticket.status,
|
||||||
|
userId: ticket.user?.userId,
|
||||||
|
createdAt: ticket.created_at || ticket.CreatedAt
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取列表数据失败:', error)
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取订单状态
|
||||||
|
const getOrderStatusText = (state) => {
|
||||||
|
const statusMap = { 0: '待支付', 1: '已支付', 2: '已失效' }
|
||||||
|
return statusMap[state] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOrderStatusType = (state) => {
|
||||||
|
const typeMap = { 0: 'warning', 1: 'success', 2: 'info' }
|
||||||
|
return typeMap[state] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取工单状态
|
||||||
|
const getTicketStatusText = (status) => {
|
||||||
|
const statusMap = { 0: '待处理', 1: '处理中', 2: '已回复', 3: '已解决' }
|
||||||
|
return statusMap[status] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTicketStatusType = (status) => {
|
||||||
|
const typeMap = { 0: 'danger', 1: 'warning', 2: 'primary', 3: 'success' }
|
||||||
|
return typeMap[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到详情页
|
||||||
|
const goToUserDetail = (userId) => {
|
||||||
|
router.push({ path: '/user/detail', query: { user_id: userId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToUserList = () => {
|
||||||
|
router.push('/user/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToOrderList = () => {
|
||||||
|
router.push('/order/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToTicketList = () => {
|
||||||
|
router.push('/ticket/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToTicketDetail = (ticketId) => {
|
||||||
|
router.push({ path: '/ticket/detail', query: { work_id: ticketId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单详情弹窗
|
||||||
|
const orderDetailVisible = ref(false)
|
||||||
|
const currentOrder = ref(null)
|
||||||
|
|
||||||
|
const showOrderDetail = (order) => {
|
||||||
|
currentOrder.value = order
|
||||||
|
orderDetailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 客户构成数据
|
// 客户构成数据
|
||||||
const customerData = ref([
|
const customerData = ref([
|
||||||
{ name: '企业客户', value: 1048, percentage: 33, color: '#1890ff' },
|
{ name: '企业客户', value: 1048, percentage: 33, color: '#1890ff' },
|
||||||
@@ -331,6 +619,10 @@ const getPriorityType = (priority) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 获取统计数据和列表数据
|
||||||
|
fetchStatistics()
|
||||||
|
fetchRecentLists()
|
||||||
|
|
||||||
initSalesChart()
|
initSalesChart()
|
||||||
initCustomerChart()
|
initCustomerChart()
|
||||||
|
|
||||||
@@ -531,15 +823,19 @@ watch(salesRange, (newVal) => {
|
|||||||
/* 统计卡片样式 */
|
/* 统计卡片样式 */
|
||||||
.stat-card {
|
.stat-card {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
border-radius: 12px;
|
|
||||||
border: none;
|
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-left: 3px solid transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-card.visitors { border-left-color: #1890ff !important; }
|
||||||
|
.stat-card.orders { border-left-color: #52c41a !important; }
|
||||||
|
.stat-card.sales { border-left-color: #faad14 !important; }
|
||||||
|
.stat-card.conversion { border-left-color: #722ed1 !important; }
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-top {
|
.card-top {
|
||||||
@@ -570,10 +866,10 @@ watch(salesRange, (newVal) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 56px;
|
width: 48px;
|
||||||
height: 56px;
|
height: 48px;
|
||||||
border-radius: 12px;
|
border-radius: 4px;
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,8 +931,6 @@ watch(salesRange, (newVal) => {
|
|||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
border-radius: 12px;
|
|
||||||
border: none;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,8 +1038,6 @@ watch(salesRange, (newVal) => {
|
|||||||
|
|
||||||
.activity-card, .todo-card {
|
.activity-card, .todo-card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 12px;
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header-custom {
|
.card-header-custom {
|
||||||
@@ -870,6 +1162,149 @@ watch(salesRange, (newVal) => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 列表卡片样式 */
|
||||||
|
.list-row {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
height: 410px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card :deep(.el-card__body) {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-icon {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-icon {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-icon {
|
||||||
|
color: #faad14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-content {
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
color: #909399;
|
||||||
|
padding: 60px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:hover {
|
||||||
|
background-color: #fafafa;
|
||||||
|
margin: 0 -20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-extra {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-price {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 订单详情弹窗样式 */
|
||||||
|
.order-detail-dialog :deep(.el-descriptions__label) {
|
||||||
|
width: 100px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-dialog :deep(.el-descriptions__content) {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-price {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-renew-price {
|
||||||
|
color: #409eff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -888,5 +1323,30 @@ watch(salesRange, (newVal) => {
|
|||||||
.todo-list {
|
.todo-list {
|
||||||
height: 320px;
|
height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-card {
|
||||||
|
height: auto;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-content {
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-dialog :deep(.el-dialog) {
|
||||||
|
width: 90% !important;
|
||||||
|
margin: 5vh auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-dialog :deep(.el-descriptions) {
|
||||||
|
--el-descriptions-item-bordered-label-background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-dialog :deep(.el-descriptions__label),
|
||||||
|
.order-detail-dialog :deep(.el-descriptions__content) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -141,17 +141,26 @@
|
|||||||
<el-radio label="percentage">百分比折扣</el-radio>
|
<el-radio label="percentage">百分比折扣</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额(元)" prop="amount">
|
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额" prop="amount">
|
||||||
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="flex:1" />
|
||||||
|
<span class="unit-text">元</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
|
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
|
||||||
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
|
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="最低消费(元)" prop="min_amount">
|
<el-form-item label="最低消费" prop="min_amount">
|
||||||
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
|
||||||
|
<span class="unit-text">元</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="最大抵扣(元)" prop="max_amount">
|
<el-form-item label="最大抵扣" prop="max_amount">
|
||||||
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
|
||||||
|
<span class="unit-text">元</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="最大使用次数" prop="max_times">
|
<el-form-item label="最大使用次数" prop="max_times">
|
||||||
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||||
@@ -651,6 +660,9 @@ onMounted(() => {
|
|||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ const fetchVoucherListOptions = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getDiscountCodeList({
|
const res = await getDiscountCodeList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000,
|
count: 10,
|
||||||
discount_type: 'coupon'
|
discount_type: 'coupon'
|
||||||
})
|
})
|
||||||
console.log('获取代金券列表:', res.data)
|
console.log('获取代金券列表:', res.data)
|
||||||
@@ -407,7 +407,7 @@ const fetchProductList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getProductList({
|
const res = await getProductList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000
|
count: 10
|
||||||
})
|
})
|
||||||
console.log('获取商品列表:', res.data)
|
console.log('获取商品列表:', res.data)
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
@@ -424,7 +424,7 @@ const fetchProductGroupList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getProductGroupList({
|
const res = await getProductGroupList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000
|
count: 10
|
||||||
})
|
})
|
||||||
console.log('获取商品组列表:', res.data)
|
console.log('获取商品组列表:', res.data)
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
@@ -798,33 +798,6 @@ onMounted(() => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格样式优化 */
|
|
||||||
:deep(.el-table) {
|
|
||||||
border: none;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table__header) {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table th) {
|
|
||||||
background: #f8f9fa !important;
|
|
||||||
border-bottom: 2px solid #e1e8ed;
|
|
||||||
color: #2c3e50;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table td) {
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
color: #34495e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table tr:hover > td) {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-card__body) {
|
:deep(.el-card__body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,8 @@
|
|||||||
<el-table-column prop="discountId" label="代金券ID" width="120" v-if="!codeId" />
|
<el-table-column prop="discountId" label="代金券ID" width="120" v-if="!codeId" />
|
||||||
<el-table-column label="用户名" min-width="150">
|
<el-table-column label="用户名" min-width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row?.user?.user_name || '-' }}
|
<el-link v-if="row.userId && row?.user?.user_name" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.user.user_name }}</el-link>
|
||||||
|
<span v-else>{{ row?.user?.user_name || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="手机号" min-width="150">
|
<el-table-column label="手机号" min-width="150">
|
||||||
@@ -239,6 +240,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Delete, Search, Plus, Refresh, User } from '@element-plus/icons-vue'
|
import { Delete, Search, Plus, Refresh, User } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
@@ -261,6 +263,8 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
code_id: props.codeId || '',
|
code_id: props.codeId || '',
|
||||||
@@ -361,7 +365,7 @@ const fetchVoucherListOptions = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getDiscountCodeList({
|
const res = await getDiscountCodeList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000,
|
count: 10,
|
||||||
discount_type: 'coupon'
|
discount_type: 'coupon'
|
||||||
})
|
})
|
||||||
console.log('获取代金券列表:', res.data)
|
console.log('获取代金券列表:', res.data)
|
||||||
@@ -397,7 +401,7 @@ const fetchUserGroupList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getUserGroupList({
|
const res = await getUserGroupList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 10000,
|
count: 10,
|
||||||
key: ''
|
key: ''
|
||||||
})
|
})
|
||||||
console.log('获取用户组列表:', res.data)
|
console.log('获取用户组列表:', res.data)
|
||||||
@@ -803,33 +807,6 @@ onMounted(() => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格样式优化 */
|
|
||||||
:deep(.el-table) {
|
|
||||||
border: none;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table__header) {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table th) {
|
|
||||||
background: #f8f9fa !important;
|
|
||||||
border-bottom: 2px solid #e1e8ed;
|
|
||||||
color: #2c3e50;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table td) {
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
color: #34495e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table tr:hover > td) {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-card__body) {
|
:deep(.el-card__body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-buy-manage">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>拼团管理</span>
|
||||||
|
<el-button type="primary" @click="showCreateDialog">创建拼团</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 拼团列表 -->
|
||||||
|
<el-table :data="groupBuyList" style="width: 100%">
|
||||||
|
<el-table-column prop="group_buy_id" label="拼团ID" width="180" />
|
||||||
|
<el-table-column prop="name" label="拼团名称" width="150" />
|
||||||
|
<el-table-column prop="maxPerson" label="最大人数" width="100" />
|
||||||
|
<el-table-column label="当前人数" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.users?.length || 0 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||||
|
<el-table-column label="操作" fixed="right" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="checkGroupBuy(row.group_buy_id)">
|
||||||
|
检查
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="primary" @click="viewDetail(row)">
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" @click="deleteGroupBuy(row.group_buy_id)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 创建拼团对话框 -->
|
||||||
|
<el-dialog v-model="createDialogVisible" title="创建拼团" width="500px">
|
||||||
|
<el-form :model="createForm" label-width="100px">
|
||||||
|
<el-form-item label="拼团名称">
|
||||||
|
<el-input v-model="createForm.name" placeholder="请输入拼团名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最大人数">
|
||||||
|
<el-input-number v-model="createForm.maxPerson" :min="2" :max="100" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="封面图片">
|
||||||
|
<el-input v-model="createForm.cover" placeholder="请输入封面图片URL" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleCreate" :loading="creating">
|
||||||
|
创建
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 拼团详情对话框 -->
|
||||||
|
<el-dialog v-model="detailDialogVisible" title="拼团详情" width="600px">
|
||||||
|
<el-descriptions :column="2" border v-if="currentGroupBuy">
|
||||||
|
<el-descriptions-item label="拼团ID">
|
||||||
|
{{ currentGroupBuy.group_buy_id }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="拼团名称">
|
||||||
|
{{ currentGroupBuy.name }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最大人数">
|
||||||
|
{{ currentGroupBuy.maxPerson }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="当前人数">
|
||||||
|
{{ currentGroupBuy.users?.length || 0 }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间" :span="2">
|
||||||
|
{{ currentGroupBuy.createTime }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px">
|
||||||
|
<h4>参与用户</h4>
|
||||||
|
<el-table :data="currentGroupBuy?.users || []" style="width: 100%">
|
||||||
|
<el-table-column prop="user_id" label="用户ID" width="100" />
|
||||||
|
<el-table-column prop="user_name" label="用户名" />
|
||||||
|
<el-table-column label="团长" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.team_leader" type="success">是</el-tag>
|
||||||
|
<el-tag v-else type="info">否</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
createGroupBuy as createGroupBuyApi,
|
||||||
|
checkGroupBuy as checkGroupBuyApi,
|
||||||
|
getGroupBuyList,
|
||||||
|
getGroupBuyDetail,
|
||||||
|
deleteGroupBuy as deleteGroupBuyApi
|
||||||
|
} from '@/api/groupBuy.js'
|
||||||
|
|
||||||
|
// 拼团列表
|
||||||
|
const groupBuyList = ref([])
|
||||||
|
|
||||||
|
// 创建对话框
|
||||||
|
const createDialogVisible = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const createForm = ref({
|
||||||
|
name: '',
|
||||||
|
maxPerson: 5,
|
||||||
|
cover: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 详情对话框
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const currentGroupBuy = ref(null)
|
||||||
|
|
||||||
|
// 加载拼团列表
|
||||||
|
const loadGroupBuyList = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await getGroupBuyList({ page: 1, pageSize: 10 })
|
||||||
|
if (resp && resp.code === 200) {
|
||||||
|
groupBuyList.value = resp.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载拼团列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示创建对话框
|
||||||
|
const showCreateDialog = () => {
|
||||||
|
createForm.value = {
|
||||||
|
name: '',
|
||||||
|
maxPerson: 5,
|
||||||
|
cover: ''
|
||||||
|
}
|
||||||
|
createDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建拼团
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!createForm.value.name) {
|
||||||
|
ElMessage.warning('请输入拼团名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const resp = await createGroupBuyApi(createForm.value)
|
||||||
|
console.log('创建拼团响应:', resp)
|
||||||
|
|
||||||
|
if (resp && resp.code === 200) {
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
createDialogVisible.value = false
|
||||||
|
|
||||||
|
// 将新创建的拼团添加到列表
|
||||||
|
if (resp.data && resp.data.group_buy_id) {
|
||||||
|
groupBuyList.value.unshift(resp.data)
|
||||||
|
} else {
|
||||||
|
// 如果返回的不是完整数据,重新加载列表
|
||||||
|
await loadGroupBuyList()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(resp?.message || '创建失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建拼团失败:', error)
|
||||||
|
ElMessage.error('创建失败')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查拼团
|
||||||
|
const checkGroupBuy = async (groupBuyId) => {
|
||||||
|
try {
|
||||||
|
const resp = await checkGroupBuyApi(groupBuyId)
|
||||||
|
console.log('检查拼团响应:', resp)
|
||||||
|
|
||||||
|
if (resp && resp.code === 200) {
|
||||||
|
ElMessage.success(`检查结果: ${resp.data}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(resp?.message || '检查失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查拼团失败:', error)
|
||||||
|
ElMessage.error('检查失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const viewDetail = async (row) => {
|
||||||
|
try {
|
||||||
|
const resp = await getGroupBuyDetail(row.group_buy_id)
|
||||||
|
if (resp && resp.code === 200) {
|
||||||
|
currentGroupBuy.value = resp.data
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
} else {
|
||||||
|
// 如果获取详情失败,使用列表中的数据
|
||||||
|
currentGroupBuy.value = row
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取详情失败:', error)
|
||||||
|
// 使用列表中的数据
|
||||||
|
currentGroupBuy.value = row
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除拼团
|
||||||
|
const deleteGroupBuy = async (groupBuyId) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除这个拼团吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
const resp = await deleteGroupBuyApi(groupBuyId)
|
||||||
|
if (resp && resp.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await loadGroupBuyList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(resp?.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除拼团失败:', error)
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadGroupBuyList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-buy-manage {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -177,30 +177,36 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="用户" prop="user_id">
|
<el-form-item label="用户" prop="user_id" v-if="addForm.target_type === 'user'">
|
||||||
<div class="user-selector-wrapper">
|
<div class="user-selector-wrapper">
|
||||||
<div class="selected-user-display" v-if="addForm.user_id">
|
<el-input
|
||||||
<el-tag type="primary" closable @close="clearSelectedUser">
|
:model-value="getSelectedUserName()"
|
||||||
{{ getSelectedUserName() }}
|
placeholder="请选择用户"
|
||||||
</el-tag>
|
readonly
|
||||||
</div>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
plain
|
|
||||||
@click="openUserSelector"
|
@click="openUserSelector"
|
||||||
style="width: 100%"
|
|
||||||
>
|
>
|
||||||
<el-icon><User /></el-icon>
|
<template #append>
|
||||||
{{ addForm.user_id ? '重新选择用户' : '选择用户' }}
|
<el-button @click="openUserSelector">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button
|
||||||
|
v-if="addForm.user_id"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="clearSelectedUser"
|
||||||
|
class="clear-btn"
|
||||||
|
>
|
||||||
|
清除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="用户组" prop="group_id">
|
<el-form-item label="用户组" prop="group_id" v-if="addForm.target_type === 'group'">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="addForm.group_id"
|
v-model="addForm.group_id"
|
||||||
placeholder="请选择用户组"
|
placeholder="请选择用户组"
|
||||||
:disabled="addForm.target_type === 'user'"
|
|
||||||
filterable
|
filterable
|
||||||
clearable
|
clearable
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@@ -478,7 +484,7 @@ const fetchVoucherListOptions = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getDiscountCodeList({
|
const res = await getDiscountCodeList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000,
|
count: 10,
|
||||||
discount_type: 'coupon'
|
discount_type: 'coupon'
|
||||||
})
|
})
|
||||||
console.log('获取代金券列表:', res.data)
|
console.log('获取代金券列表:', res.data)
|
||||||
@@ -496,7 +502,7 @@ const fetchDiscountList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getDiscountCodeList({
|
const res = await getDiscountCodeList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 100,
|
count: 10,
|
||||||
discount_type: 'coupon'
|
discount_type: 'coupon'
|
||||||
})
|
})
|
||||||
console.log('获取代金券列表:', res.data)
|
console.log('获取代金券列表:', res.data)
|
||||||
@@ -507,7 +513,7 @@ const fetchDiscountList = async () => {
|
|||||||
}
|
}
|
||||||
const res2 = await getDiscountCodeList({
|
const res2 = await getDiscountCodeList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 100,
|
count: 10,
|
||||||
discount_type: 'code'
|
discount_type: 'code'
|
||||||
})
|
})
|
||||||
console.log('获取优惠码列表:', res2.data)
|
console.log('获取优惠码列表:', res2.data)
|
||||||
@@ -527,7 +533,7 @@ const fetchVoucherOptions = async () => {
|
|||||||
const res = await getDiscountCodeList({
|
const res = await getDiscountCodeList({
|
||||||
discount_type: 'coupon',
|
discount_type: 'coupon',
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 100
|
count: 10
|
||||||
})
|
})
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
voucherOptions.value = res.data.data?.data || []
|
voucherOptions.value = res.data.data?.data || []
|
||||||
@@ -543,7 +549,7 @@ const fetchCodeOptions = async () => {
|
|||||||
const res = await getDiscountCodeList({
|
const res = await getDiscountCodeList({
|
||||||
discount_type: 'code',
|
discount_type: 'code',
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 100
|
count: 10
|
||||||
})
|
})
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
codeOptions.value = res.data.data?.data || []
|
codeOptions.value = res.data.data?.data || []
|
||||||
@@ -560,7 +566,7 @@ const fetchUserList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getUserList({
|
const res = await getUserList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 100,
|
count: 10,
|
||||||
key: ''
|
key: ''
|
||||||
})
|
})
|
||||||
console.log('获取用户列表:', res.data)
|
console.log('获取用户列表:', res.data)
|
||||||
@@ -582,7 +588,7 @@ const fetchGroupOptions = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getUserGroupList({
|
const res = await getUserGroupList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 100
|
count: 10
|
||||||
})
|
})
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
groupOptions.value = res.data.data?.data || []
|
groupOptions.value = res.data.data?.data || []
|
||||||
@@ -653,9 +659,9 @@ const confirmUserSelection = (user) => {
|
|||||||
ElMessage.warning('请选择一个用户')
|
ElMessage.warning('请选择一个用户')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addForm.user_id = user.UserId
|
addForm.user_id = user.user_id
|
||||||
// 将选中的用户添加到 userOptions 中(如果不存在)
|
// 将选中的用户添加到 userOptions 中(如果不存在)
|
||||||
if (!userOptions.value.find(u => u.UserId === user.UserId)) {
|
if (!userOptions.value.find(u => u.user_id === user.user_id)) {
|
||||||
userOptions.value.push(user)
|
userOptions.value.push(user)
|
||||||
}
|
}
|
||||||
userSelectorVisible.value = false
|
userSelectorVisible.value = false
|
||||||
@@ -668,8 +674,9 @@ const clearSelectedUser = () => {
|
|||||||
|
|
||||||
// 获取选中用户的显示名称
|
// 获取选中用户的显示名称
|
||||||
const getSelectedUserName = () => {
|
const getSelectedUserName = () => {
|
||||||
const user = userOptions.value.find(u => u.UserId === addForm.user_id)
|
if (!addForm.user_id) return ''
|
||||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${addForm.user_id}`
|
const user = userOptions.value.find(u => u.user_id === addForm.user_id)
|
||||||
|
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${addForm.user_id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加用户代金券
|
// 添加用户代金券
|
||||||
@@ -940,5 +947,21 @@ onMounted(() => {
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 用户选择器样式 */
|
||||||
|
.user-selector-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector-wrapper .el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector-wrapper .clear-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -105,14 +105,23 @@
|
|||||||
<el-form-item label="备注" prop="note">
|
<el-form-item label="备注" prop="note">
|
||||||
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
|
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="面额(元)" prop="amount">
|
<el-form-item label="面额" prop="amount">
|
||||||
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="flex:1" />
|
||||||
|
<span class="unit-text">元</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="最低消费(元)" prop="min_amount">
|
<el-form-item label="最低消费" prop="min_amount">
|
||||||
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
|
||||||
|
<span class="unit-text">元</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="最大抵扣(元)" prop="max_amount">
|
<el-form-item label="最大抵扣" prop="max_amount">
|
||||||
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
|
||||||
|
<span class="unit-text">元</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="最大使用次数" prop="max_times">
|
<el-form-item label="最大使用次数" prop="max_times">
|
||||||
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||||
@@ -120,8 +129,11 @@
|
|||||||
<el-form-item label="单用户最大次数" prop="user_times">
|
<el-form-item label="单用户最大次数" prop="user_times">
|
||||||
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="有效期(天)" prop="duration_days">
|
<el-form-item label="有效期" prop="duration_days">
|
||||||
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="flex:1" />
|
||||||
|
<span class="unit-text">天</span>
|
||||||
|
</div>
|
||||||
<div class="form-tip">代金券领取后的有效持续时间</div>
|
<div class="form-tip">代金券领取后的有效持续时间</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="发放时间范围" prop="timeRange">
|
<el-form-item label="发放时间范围" prop="timeRange">
|
||||||
@@ -295,9 +307,39 @@ const handleEdit = (row) => {
|
|||||||
dialogType.value = 'edit'
|
dialogType.value = 'edit'
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
|
||||||
// 转换日期字符串为日期选择器格式
|
console.log('编辑代金券原始数据:', row)
|
||||||
const startTime = row.startTime ? new Date(row.startTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
|
|
||||||
const endTime = row.endTime ? new Date(row.endTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
|
// 转换时间为日期字符串(YYYY-MM-DD HH:mm:ss 格式)
|
||||||
|
let startTime = ''
|
||||||
|
let endTime = ''
|
||||||
|
|
||||||
|
if (row.startTime) {
|
||||||
|
// 处理字符串格式的时间(如 "2026-01-08T00:00:00+08:00")
|
||||||
|
const start = new Date(row.startTime)
|
||||||
|
if (!isNaN(start.getTime())) {
|
||||||
|
startTime = start.getFullYear() + '-' +
|
||||||
|
String(start.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(start.getDate()).padStart(2, '0') + ' ' +
|
||||||
|
String(start.getHours()).padStart(2, '0') + ':' +
|
||||||
|
String(start.getMinutes()).padStart(2, '0') + ':' +
|
||||||
|
String(start.getSeconds()).padStart(2, '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.endTime) {
|
||||||
|
// 处理字符串格式的时间(如 "2026-02-25T00:00:00+08:00")
|
||||||
|
const end = new Date(row.endTime)
|
||||||
|
if (!isNaN(end.getTime())) {
|
||||||
|
endTime = end.getFullYear() + '-' +
|
||||||
|
String(end.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(end.getDate()).padStart(2, '0') + ' ' +
|
||||||
|
String(end.getHours()).padStart(2, '0') + ':' +
|
||||||
|
String(end.getMinutes()).padStart(2, '0') + ':' +
|
||||||
|
String(end.getSeconds()).padStart(2, '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('转换后的时间:', { startTime, endTime })
|
||||||
|
|
||||||
Object.assign(voucherForm, {
|
Object.assign(voucherForm, {
|
||||||
code_id: row.id,
|
code_id: row.id,
|
||||||
@@ -309,12 +351,14 @@ const handleEdit = (row) => {
|
|||||||
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
|
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
|
||||||
max_times: row.maxTimes || 0,
|
max_times: row.maxTimes || 0,
|
||||||
user_times: row.userTimes || 0,
|
user_times: row.userTimes || 0,
|
||||||
duration_days: row.duration ? row.duration / 86400 : 30, // 秒转天
|
duration_days: row.duration ? Math.round(row.duration / 86400) : 30, // 秒转天
|
||||||
timeRange: startTime && endTime ? [startTime, endTime] : [],
|
timeRange: startTime && endTime ? [startTime, endTime] : [],
|
||||||
renew: row.renew || false,
|
renew: row.renew || false,
|
||||||
can_stacking: row.canStacking || false,
|
can_stacking: row.canStacking || false,
|
||||||
can_combine: row.canCombine || false
|
can_combine: row.canCombine || false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('表单数据:', voucherForm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理代金券
|
// 管理代金券
|
||||||
@@ -507,6 +551,9 @@ onMounted(() => {
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -43,7 +43,12 @@
|
|||||||
stripe
|
stripe
|
||||||
>
|
>
|
||||||
<el-table-column prop="id" label="记录ID" width="80" fixed="left" />
|
<el-table-column prop="id" label="记录ID" width="80" fixed="left" />
|
||||||
<el-table-column prop="user_id" label="用户ID" width="100" />
|
<el-table-column label="用户ID" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.user_id" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.user_id } })">{{ row.user_id }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="username" label="用户名" width="150" />
|
<el-table-column prop="username" label="用户名" width="150" />
|
||||||
<el-table-column prop="email" label="邮箱" min-width="200" />
|
<el-table-column prop="email" label="邮箱" min-width="200" />
|
||||||
<el-table-column prop="discount_id" label="代金券ID" width="120" v-if="!codeId" />
|
<el-table-column prop="discount_id" label="代金券ID" width="120" v-if="!codeId" />
|
||||||
@@ -58,7 +63,12 @@
|
|||||||
<span>¥{{ row.order_amount ? (row.order_amount / 100).toFixed(2) : '0.00' }}</span>
|
<span>¥{{ row.order_amount ? (row.order_amount / 100).toFixed(2) : '0.00' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="order_id" label="订单ID" width="150" />
|
<el-table-column label="订单ID" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.order_id" type="primary" :underline="false" @click="router.push({ path: '/order/list', query: { key: row.order_id } })">{{ row.order_id }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="使用状态" width="100">
|
<el-table-column label="使用状态" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getStatusType(row.status)">
|
<el-tag :type="getStatusType(row.status)">
|
||||||
@@ -174,6 +184,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Search, Refresh, Download } from '@element-plus/icons-vue'
|
import { Search, Refresh, Download } from '@element-plus/icons-vue'
|
||||||
import { getUserVoucherHistory, getDiscountCodeList } from '@/api/admin/discount'
|
import { getUserVoucherHistory, getDiscountCodeList } from '@/api/admin/discount'
|
||||||
@@ -187,6 +198,8 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
user_id: undefined,
|
user_id: undefined,
|
||||||
@@ -397,7 +410,7 @@ const fetchUserList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getUserList({
|
const res = await getUserList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 10000,
|
count: 10,
|
||||||
key: ''
|
key: ''
|
||||||
})
|
})
|
||||||
UserOptions.value = res.data.data?.data || []
|
UserOptions.value = res.data.data?.data || []
|
||||||
@@ -412,7 +425,7 @@ const fetchDiscountList = async () => {
|
|||||||
const res = await getDiscountCodeList({
|
const res = await getDiscountCodeList({
|
||||||
discount_type: 'coupon',
|
discount_type: 'coupon',
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000
|
count: 10
|
||||||
})
|
})
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
discountOptions.value = res.data.data?.data || []
|
discountOptions.value = res.data.data?.data || []
|
||||||
|
|||||||
@@ -40,7 +40,12 @@
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-table-column prop="Id" label="ID" width="80" />
|
<el-table-column prop="Id" label="ID" width="80" />
|
||||||
<el-table-column prop="UserId" label="用户ID" min-width="100" />
|
<el-table-column label="用户ID" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.UserId" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.UserId } })">{{ row.UserId }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="代金券ID" min-width="110" v-if="!codeId">
|
<el-table-column label="代金券ID" min-width="110" v-if="!codeId">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.discountId || '-' }}
|
{{ row.discountId || '-' }}
|
||||||
@@ -212,6 +217,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
|
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
@@ -230,6 +236,8 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
user_id: undefined,
|
user_id: undefined,
|
||||||
@@ -459,7 +467,7 @@ const fetchDiscountList = async () => {
|
|||||||
const res = await getDiscountCodeList({
|
const res = await getDiscountCodeList({
|
||||||
discount_type: 'coupon',
|
discount_type: 'coupon',
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000
|
count: 10
|
||||||
})
|
})
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
discountOptions.value = res.data.data?.data || []
|
discountOptions.value = res.data.data?.data || []
|
||||||
|
|||||||
+437
-38
@@ -5,6 +5,36 @@
|
|||||||
<!-- 搜索和操作栏 -->
|
<!-- 搜索和操作栏 -->
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-content">
|
<div class="filter-content">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="queryParams.key" placeholder="订单名称/ID" clearable style="width: 150px" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户ID">
|
||||||
|
<el-input v-model="queryParams.user_id" placeholder="用户ID" clearable style="width: 120px" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户关键词">
|
||||||
|
<el-input v-model="queryParams.user_key" placeholder="用户名/手机号/邮箱" clearable style="width: 180px" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="queryParams.state" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option label="待支付" value="0" />
|
||||||
|
<el-option label="已支付" value="1" />
|
||||||
|
<el-option label="已失效" value="2" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="错误信息">
|
||||||
|
<el-select v-model="queryParams.error" placeholder="全部" clearable style="width: 140px">
|
||||||
|
<el-option label="有错误的订单" :value="true" />
|
||||||
|
<el-option label="无错误的订单" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleQuery">
|
||||||
|
<el-icon><Search /></el-icon>搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<el-button type="primary" @click="handleAdd">
|
<el-button type="primary" @click="handleAdd">
|
||||||
<el-icon><Plus /></el-icon>新增订单
|
<el-icon><Plus /></el-icon>新增订单
|
||||||
@@ -43,8 +73,18 @@
|
|||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="id" label="订单ID" width="100" />
|
<el-table-column prop="id" label="订单ID" width="100" />
|
||||||
<el-table-column prop="name" label="订单名称" min-width="180" />
|
<el-table-column prop="name" label="订单名称" min-width="180" />
|
||||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
<el-table-column label="用户ID" width="100">
|
||||||
<el-table-column prop="commodityId" label="商品ID" width="100" />
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.userId" type="primary" :underline="false" @click.stop="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.userId }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="商品ID" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.commodityId" type="primary" :underline="false" @click.stop="router.push({ path: '/user-goods/list', query: { good_id: row.commodityId } })">{{ row.commodityId }}</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="表名" width="120">
|
<el-table-column label="表名" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small">{{ row.table || '未知' }}</el-tag>
|
<el-tag size="small">{{ row.table || '未知' }}</el-tag>
|
||||||
@@ -65,11 +105,22 @@
|
|||||||
<span>{{ row.payNum }}</span>
|
<span>{{ row.payNum }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="订单状态" width="100">
|
<el-table-column label="订单状态" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getStatusType(row.state)">
|
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
||||||
{{ getStatusText(row.state) }}
|
<el-tag :type="getStatusType(row.state)">
|
||||||
</el-tag>
|
{{ getStatusText(row.state) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="row.error" type="danger" size="small">异常</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="错误信息" min-width="250">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip v-if="row.error" :content="row.error" placement="top" :show-after="300">
|
||||||
|
<span class="error-text">{{ row.error }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="支付方式" width="100">
|
<el-table-column label="支付方式" width="100">
|
||||||
@@ -87,11 +138,12 @@
|
|||||||
<span>{{ formatDate(row.CreatedAt) }}</span>
|
<span>{{ formatDate(row.CreatedAt) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200" fixed="right">
|
<el-table-column label="操作" width="250" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<el-button type="primary" link @click="handleView(row)">查看</el-button>
|
<el-button type="primary" link @click="handleView(row)">查看</el-button>
|
||||||
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
|
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button v-if="row.error" type="danger" link @click="handleRetryOrder(row)">重试流程</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -138,6 +190,10 @@
|
|||||||
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.CreatedAt) }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.CreatedAt) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="更新时间">{{ formatDate(orderDetail.UpdatedAt) }}</el-descriptions-item>
|
<el-descriptions-item label="更新时间">{{ formatDate(orderDetail.UpdatedAt) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="参数信息">{{ orderDetail.args || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="参数信息">{{ orderDetail.args || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="orderDetail.error" label="错误信息" :span="2">
|
||||||
|
<el-tag type="danger" size="small" style="margin-right: 6px;">异常</el-tag>
|
||||||
|
<span style="color: #f56c6c;">{{ orderDetail.error }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item>
|
<el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -162,28 +218,149 @@
|
|||||||
<el-input v-model="orderForm.table" placeholder="请输入所属表" />
|
<el-input v-model="orderForm.table" placeholder="请输入所属表" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="用户ID" prop="user_id">
|
<el-form-item label="用户ID" prop="user_id">
|
||||||
<el-input-number v-model="orderForm.user_id" :min="1" placeholder="请输入用户ID" style="width: 100%" />
|
<el-input
|
||||||
|
v-if="selectedUserInfo"
|
||||||
|
:model-value="`${selectedUserInfo.user_name} (ID: ${orderForm.user_id})`"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<el-icon class="clear-icon" @click="clearUser"><Close /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="userSelectorVisible = true">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
placeholder="请选择用户"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
@click="userSelectorVisible = true"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="userSelectorVisible = true">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="商品ID" prop="commodity_id">
|
<el-form-item label="商品ID" prop="commodity_id">
|
||||||
<el-input-number v-model="orderForm.commodity_id" :min="0" placeholder="请输入商品ID" style="width: 100%" />
|
<el-input
|
||||||
|
v-if="selectedProductInfo"
|
||||||
|
:model-value="`${selectedProductInfo.name} (ID: ${orderForm.commodity_id})`"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<el-icon class="clear-icon" @click="clearProduct"><Close /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="productSelectorVisible = true">
|
||||||
|
<el-icon><ShoppingCart /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
placeholder="请选择商品"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
@click="productSelectorVisible = true"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="productSelectorVisible = true">
|
||||||
|
<el-icon><ShoppingCart /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="购买数量" prop="pay_num">
|
<el-form-item label="购买数量" prop="pay_num">
|
||||||
<el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" />
|
<el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="价格(分)" prop="price">
|
<el-form-item label="价格" prop="price">
|
||||||
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="flex:1" />
|
||||||
|
<span class="unit-text">分</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="续费价格(分)" prop="renew_price">
|
<el-form-item label="续费价格" prop="renew_price">
|
||||||
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="width: 100%" />
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="flex:1" />
|
||||||
|
<span class="unit-text">分</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="过期时间" prop="expire_time">
|
<el-form-item label="过期时间" prop="expire_time">
|
||||||
<el-input-number v-model="orderForm.expire_time" :min="0" placeholder="请输入过期时间(时间戳)" style="width: 100%" />
|
<el-date-picker
|
||||||
|
v-model="orderForm.expire_time"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="请选择过期时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="x"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="优惠码ID" prop="discount_code_id">
|
<el-form-item label="优惠码ID" prop="discount_code_id">
|
||||||
<el-input-number v-model="orderForm.discount_code_id" :min="0" placeholder="请输入优惠码ID" style="width: 100%" />
|
<el-input
|
||||||
|
v-if="selectedDiscountCodeInfo"
|
||||||
|
:model-value="`${selectedDiscountCodeInfo.name || selectedDiscountCodeInfo.code} (ID: ${orderForm.discount_code_id})`"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<el-icon class="clear-icon" @click="clearDiscountCode"><Close /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="discountCodeSelectorVisible = true">
|
||||||
|
<el-icon><Ticket /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
placeholder="请选择优惠码(可选)"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
@click="discountCodeSelectorVisible = true"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="discountCodeSelectorVisible = true">
|
||||||
|
<el-icon><Ticket /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="代金券ID" prop="coupon_id">
|
<el-form-item label="代金券ID" prop="coupon_id">
|
||||||
<el-input-number v-model="orderForm.coupon_id" :min="0" placeholder="请输入代金券ID (必填)" style="width: 100%" />
|
<el-input
|
||||||
|
v-if="selectedVoucherInfo"
|
||||||
|
:model-value="`${selectedVoucherInfo.name || selectedVoucherInfo.code} (ID: ${orderForm.coupon_id})`"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<el-icon class="clear-icon" @click="clearVoucher"><Close /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="voucherSelectorVisible = true">
|
||||||
|
<el-icon><Money /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
placeholder="请选择代金券(可选)"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
@click="voucherSelectorVisible = true"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="voucherSelectorVisible = true">
|
||||||
|
<el-icon><Money /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="订单状态" prop="state">
|
<el-form-item label="订单状态" prop="state">
|
||||||
<el-radio-group v-model="orderForm.state">
|
<el-radio-group v-model="orderForm.state">
|
||||||
@@ -209,20 +386,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 用户选择器 -->
|
||||||
|
<UserListSelector
|
||||||
|
v-model="userSelectorVisible"
|
||||||
|
:current-user-id="orderForm.user_id"
|
||||||
|
@confirm="handleUserSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 商品选择器 -->
|
||||||
|
<ProductSelector
|
||||||
|
v-model="productSelectorVisible"
|
||||||
|
:current-product-id="orderForm.commodity_id"
|
||||||
|
@confirm="handleProductSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 优惠码选择器 -->
|
||||||
|
<DiscountCodeSelector
|
||||||
|
v-model="discountCodeSelectorVisible"
|
||||||
|
:current-code-id="orderForm.discount_code_id"
|
||||||
|
@confirm="handleDiscountCodeSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 代金券选择器 -->
|
||||||
|
<VoucherSelector
|
||||||
|
v-model="voucherSelectorVisible"
|
||||||
|
:current-voucher-id="orderForm.coupon_id"
|
||||||
|
@confirm="handleVoucherSelect"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Delete, Search, Download, Refresh } from '@element-plus/icons-vue'
|
import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close } from '@element-plus/icons-vue'
|
||||||
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder } from '@/api/admin/order'
|
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder, retryOrderHook } from '@/api/admin/order'
|
||||||
|
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||||
|
import ProductSelector from '@/components/admin/ProductSelector.vue'
|
||||||
|
import DiscountCodeSelector from '@/components/admin/DiscountCodeSelector.vue'
|
||||||
|
import VoucherSelector from '@/components/admin/VoucherSelector.vue'
|
||||||
|
import { isoToMilliseconds, timeToTimestamp, formatDate as formatDateTool } from '@/utils/tool'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
|
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 10
|
count: 10,
|
||||||
|
key: '',
|
||||||
|
state: '',
|
||||||
|
user_id: '',
|
||||||
|
user_key: '',
|
||||||
|
error: null
|
||||||
})
|
})
|
||||||
|
|
||||||
// 订单表单
|
// 订单表单
|
||||||
@@ -278,14 +496,43 @@ const detailDialogVisible = ref(false)
|
|||||||
const dialogType = ref('add')
|
const dialogType = ref('add')
|
||||||
const orderFormRef = ref(null)
|
const orderFormRef = ref(null)
|
||||||
|
|
||||||
|
// 选择器弹窗状态
|
||||||
|
const userSelectorVisible = ref(false)
|
||||||
|
const productSelectorVisible = ref(false)
|
||||||
|
const discountCodeSelectorVisible = ref(false)
|
||||||
|
const voucherSelectorVisible = ref(false)
|
||||||
|
|
||||||
|
// 选择的显示信息
|
||||||
|
const selectedUserInfo = ref(null)
|
||||||
|
const selectedProductInfo = ref(null)
|
||||||
|
const selectedDiscountCodeInfo = ref(null)
|
||||||
|
const selectedVoucherInfo = ref(null)
|
||||||
|
|
||||||
// 获取订单列表
|
// 获取订单列表
|
||||||
const fetchOrderList = async () => {
|
const fetchOrderList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getOrderList(queryParams)
|
// 过滤空值参数
|
||||||
|
const params = {}
|
||||||
|
Object.keys(queryParams).forEach(key => {
|
||||||
|
if (queryParams[key] !== '' && queryParams[key] !== null && queryParams[key] !== undefined) {
|
||||||
|
params[key] = queryParams[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const res = await getOrderList(params)
|
||||||
console.log('订单列表数据:', res.data)
|
console.log('订单列表数据:', res.data)
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
orderList.value = res.data.data.list || []
|
// 处理时间数据:将ISO格式转换为毫秒级时间戳(用于时间选择器)
|
||||||
|
const list = (res.data.data.list || []).map(item => {
|
||||||
|
if (item.expireTime) {
|
||||||
|
// 保存原始时间用于显示
|
||||||
|
item._originalExpireTime = item.expireTime
|
||||||
|
// 转换为毫秒级时间戳用于时间选择器
|
||||||
|
item._expireTimeMs = isoToMilliseconds(item.expireTime)
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
orderList.value = list
|
||||||
total.value = res.data.data.all_count || 0
|
total.value = res.data.data.all_count || 0
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -296,16 +543,9 @@ const fetchOrderList = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期 - 使用工具函数
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return '-'
|
return formatDateTool(dateStr)
|
||||||
const date = new Date(dateStr)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取订单状态类型
|
// 获取订单状态类型
|
||||||
@@ -337,10 +577,11 @@ const handleQuery = () => {
|
|||||||
|
|
||||||
// 重置查询
|
// 重置查询
|
||||||
const resetQuery = () => {
|
const resetQuery = () => {
|
||||||
queryParams.order_no = ''
|
queryParams.key = ''
|
||||||
|
queryParams.state = ''
|
||||||
queryParams.user_id = ''
|
queryParams.user_id = ''
|
||||||
queryParams.status = ''
|
queryParams.user_key = ''
|
||||||
queryParams.dateRange = []
|
queryParams.error = null
|
||||||
queryParams.page = 1
|
queryParams.page = 1
|
||||||
fetchOrderList()
|
fetchOrderList()
|
||||||
}
|
}
|
||||||
@@ -365,6 +606,7 @@ const handleCurrentChange = (page) => {
|
|||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
dialogType.value = 'add'
|
dialogType.value = 'add'
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
clearAllSelections()
|
||||||
Object.assign(orderForm, {
|
Object.assign(orderForm, {
|
||||||
order_id: undefined,
|
order_id: undefined,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -403,6 +645,16 @@ const handleView = async (row) => {
|
|||||||
const handleEdit = (row) => {
|
const handleEdit = (row) => {
|
||||||
dialogType.value = 'edit'
|
dialogType.value = 'edit'
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
clearAllSelections()
|
||||||
|
|
||||||
|
// 处理过期时间:优先使用已转换的时间戳,否则转换ISO格式
|
||||||
|
let expireTimeMs = null
|
||||||
|
if (row._expireTimeMs !== undefined) {
|
||||||
|
expireTimeMs = row._expireTimeMs
|
||||||
|
} else if (row.expireTime) {
|
||||||
|
expireTimeMs = isoToMilliseconds(row.expireTime)
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(orderForm, {
|
Object.assign(orderForm, {
|
||||||
order_id: row.id,
|
order_id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -412,7 +664,7 @@ const handleEdit = (row) => {
|
|||||||
pay_num: row.payNum,
|
pay_num: row.payNum,
|
||||||
price: row.price,
|
price: row.price,
|
||||||
renew_price: row.renewPrice,
|
renew_price: row.renewPrice,
|
||||||
expire_time: row.expireTime ? new Date(row.expireTime).getTime() / 1000 : 0,
|
expire_time: expireTimeMs,
|
||||||
discount_code_id: 0, // 从详情接口获取
|
discount_code_id: 0, // 从详情接口获取
|
||||||
coupon_id: 0, // 从详情接口获取
|
coupon_id: 0, // 从详情接口获取
|
||||||
state: row.state,
|
state: row.state,
|
||||||
@@ -420,6 +672,40 @@ const handleEdit = (row) => {
|
|||||||
args: row.args || '',
|
args: row.args || '',
|
||||||
note: row.note || ''
|
note: row.note || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 设置显示信息(只显示ID,名称需要从选择器中获取)
|
||||||
|
if (row.userId) {
|
||||||
|
selectedUserInfo.value = { user_id: row.userId, user_name: `用户${row.userId}` }
|
||||||
|
}
|
||||||
|
if (row.commodityId) {
|
||||||
|
selectedProductInfo.value = { id: row.commodityId, name: `商品${row.commodityId}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重试订单流程
|
||||||
|
const handleRetryOrder = (row) => {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
`确认对订单「${row.name}」(ID: ${row.id}) 重试流程吗?`,
|
||||||
|
'重试订单流程',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认重试',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await retryOrderHook({ order_id: row.id })
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success('重试流程已触发')
|
||||||
|
fetchOrderList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '重试失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重试订单流程失败:', error)
|
||||||
|
ElMessage.error(error.response?.data?.message || '重试订单流程失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除订单
|
// 删除订单
|
||||||
@@ -463,6 +749,13 @@ const submitForm = () => {
|
|||||||
orderFormRef.value?.validate(async (valid) => {
|
orderFormRef.value?.validate(async (valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
try {
|
try {
|
||||||
|
// 处理过期时间:将毫秒级时间戳转换为秒级时间戳
|
||||||
|
let expireTimeSeconds = 0
|
||||||
|
if (orderForm.expire_time) {
|
||||||
|
const timestamp = timeToTimestamp(new Date(orderForm.expire_time))
|
||||||
|
expireTimeSeconds = timestamp || 0
|
||||||
|
}
|
||||||
|
|
||||||
// 准备提交的数据
|
// 准备提交的数据
|
||||||
const submitData = {
|
const submitData = {
|
||||||
name: orderForm.name,
|
name: orderForm.name,
|
||||||
@@ -472,7 +765,7 @@ const submitForm = () => {
|
|||||||
pay_num: Number(orderForm.pay_num),
|
pay_num: Number(orderForm.pay_num),
|
||||||
price: Number(orderForm.price),
|
price: Number(orderForm.price),
|
||||||
renew_price: Number(orderForm.renew_price),
|
renew_price: Number(orderForm.renew_price),
|
||||||
expire_time: Number(orderForm.expire_time),
|
expire_time: expireTimeSeconds,
|
||||||
discount_code_id: Number(orderForm.discount_code_id),
|
discount_code_id: Number(orderForm.discount_code_id),
|
||||||
coupon_id: Number(orderForm.coupon_id),
|
coupon_id: Number(orderForm.coupon_id),
|
||||||
state: Number(orderForm.state),
|
state: Number(orderForm.state),
|
||||||
@@ -508,8 +801,67 @@ const submitForm = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户选择处理
|
||||||
|
const handleUserSelect = (user) => {
|
||||||
|
orderForm.user_id = user.user_id
|
||||||
|
selectedUserInfo.value = user
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearUser = () => {
|
||||||
|
orderForm.user_id = undefined
|
||||||
|
selectedUserInfo.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 商品选择处理
|
||||||
|
const handleProductSelect = (product) => {
|
||||||
|
orderForm.commodity_id = product.id
|
||||||
|
selectedProductInfo.value = product
|
||||||
|
// 自动填充表名
|
||||||
|
if (product.table) {
|
||||||
|
orderForm.table = product.table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearProduct = () => {
|
||||||
|
orderForm.commodity_id = 0
|
||||||
|
selectedProductInfo.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优惠码选择处理
|
||||||
|
const handleDiscountCodeSelect = (code) => {
|
||||||
|
orderForm.discount_code_id = code.id
|
||||||
|
selectedDiscountCodeInfo.value = code
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearDiscountCode = () => {
|
||||||
|
orderForm.discount_code_id = 0
|
||||||
|
selectedDiscountCodeInfo.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代金券选择处理
|
||||||
|
const handleVoucherSelect = (voucher) => {
|
||||||
|
orderForm.coupon_id = voucher.id
|
||||||
|
selectedVoucherInfo.value = voucher
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearVoucher = () => {
|
||||||
|
orderForm.coupon_id = 0
|
||||||
|
selectedVoucherInfo.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有选择信息
|
||||||
|
const clearAllSelections = () => {
|
||||||
|
selectedUserInfo.value = null
|
||||||
|
selectedProductInfo.value = null
|
||||||
|
selectedDiscountCodeInfo.value = null
|
||||||
|
selectedVoucherInfo.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (route.query.key) queryParams.key = String(route.query.key)
|
||||||
|
if (route.query.user_id) queryParams.user_id = String(route.query.user_id)
|
||||||
|
if (route.query.state) queryParams.state = String(route.query.state)
|
||||||
fetchOrderList()
|
fetchOrderList()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -532,13 +884,29 @@ onMounted(() => {
|
|||||||
|
|
||||||
.filter-content {
|
.filter-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form :deep(.el-form-item__label) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.action-bar {
|
.action-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -651,4 +1019,35 @@ onMounted(() => {
|
|||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 选择器清除图标样式 */
|
||||||
|
.clear-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #909399;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-icon:hover {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 12px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
word-break: break-all;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+2073
-66
File diff suppressed because it is too large
Load Diff
+1966
-104
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
|||||||
|
<template>
|
||||||
|
<div class="goods-detail-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-button @click="goBack" link class="back-btn">
|
||||||
|
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表
|
||||||
|
</el-button>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<span class="page-title">所有商品详情</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-button type="primary" plain @click="loadDetail" :loading="loading">
|
||||||
|
<el-icon><Refresh /></el-icon> 刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="main-content" v-loading="loading">
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
|
||||||
|
<el-button type="primary" @click="loadDetail">重新加载</el-button>
|
||||||
|
</el-empty>
|
||||||
|
|
||||||
|
<el-card class="profile-card" shadow="hover" v-if="detail">
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-basic">
|
||||||
|
<div class="icon-wrapper">
|
||||||
|
<el-icon :size="48" color="#409eff"><Monitor /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="identity">
|
||||||
|
<div class="name-row">
|
||||||
|
<h1 class="name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
|
||||||
|
<el-button size="small" type="primary" plain @click="openEdit">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="handleDelete">删除</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="id-row">
|
||||||
|
<span class="label">ID:</span>
|
||||||
|
<span class="value">{{ detail.id || goodsId }}</span>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<span class="label">用户ID:</span>
|
||||||
|
<span class="value">{{ detail.userId || detail.user_id || '-' }}</span>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<span class="label">到期:</span>
|
||||||
|
<span class="value">{{ formatExpireTime(detail.expireTime || detail.expire_time) }}</span>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<span class="label">续费价:</span>
|
||||||
|
<span class="value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">套餐ID</div>
|
||||||
|
<div class="stat-value">{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">备注</div>
|
||||||
|
<div class="stat-value note-value">{{ detail.note || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-divider style="margin: 16px 0 12px" />
|
||||||
|
<el-descriptions :column="3" border size="small" style="width:100%">
|
||||||
|
<el-descriptions-item label="商品ID">{{ detail.goodId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="基础价格">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="hover" v-if="detail" class="related-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="card-title">关联信息</span>
|
||||||
|
</template>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单状态">
|
||||||
|
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
|
||||||
|
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
|
||||||
|
<el-form :model="editForm" label-width="110px">
|
||||||
|
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
|
||||||
|
<el-form-item label="续费价格">
|
||||||
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||||
|
<span class="unit-text">元</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="基础价格">
|
||||||
|
<div class="unit-input-row">
|
||||||
|
<el-input-number v-model="editForm.base_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||||
|
<span class="unit-text">元</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="到期时间"><el-date-picker v-model="editForm.expire_time" type="datetime" placeholder="选择到期时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /></el-form-item>
|
||||||
|
<el-form-item label="归属项">
|
||||||
|
<div style="width:100%">
|
||||||
|
<template v-if="detail?.good?.table === 'kvm_service'">
|
||||||
|
<div class="selector-row" style="margin-bottom:8px">
|
||||||
|
<el-input :model-value="editForm._serviceName || (editForm._serviceId ? `主控服务 #${editForm._serviceId}` : '')"
|
||||||
|
readonly placeholder="1. 选择主控服务" style="flex:1" />
|
||||||
|
<el-button type="primary" @click="showServiceSelector = true" style="margin-left:8px">选择</el-button>
|
||||||
|
<el-button v-if="editForm._serviceId" @click="editForm._serviceId = 0; editForm._serviceName = ''; editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="selector-row">
|
||||||
|
<el-input :model-value="editForm._itemName || (editForm.item_id ? `虚拟机 #${editForm.item_id}` : '')"
|
||||||
|
readonly placeholder="2. 选择虚拟机" style="flex:1" />
|
||||||
|
<el-button type="primary" @click="showVmSelector = true" :disabled="!editForm._serviceId" style="margin-left:8px">选择</el-button>
|
||||||
|
<el-button v-if="editForm.item_id" @click="editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:#909399;margin-top:4px">归属项为虚拟机ID,需先选择主控服务</div>
|
||||||
|
</template>
|
||||||
|
<el-input-number v-else v-model="editForm.item_id" :min="0" controls-position="right" style="width:100%" />
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitLoading" @click="submitEdit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<VmSelectorPopup v-model="showVmSelector" :service-id="editForm._serviceId || 0"
|
||||||
|
@confirm="vm => { editForm.item_id = vm.id; editForm._itemName = vm.name }" />
|
||||||
|
<KvmServiceSelector v-model="showServiceSelector"
|
||||||
|
@confirm="s => { editForm._serviceId = s.id; editForm._serviceName = s.name; editForm.item_id = 0; editForm._itemName = '' }" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { ArrowLeft, Refresh, Monitor } from '@element-plus/icons-vue'
|
||||||
|
import { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||||
|
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const goodsId = computed(() => parseInt(route.params.id) || 0)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const detail = ref(null)
|
||||||
|
|
||||||
|
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||||
|
const formatExpireTime = (t) => {
|
||||||
|
if (!t) return '-'
|
||||||
|
const d = dayjs(t)
|
||||||
|
if (d.year() < 2000) return '永久'
|
||||||
|
return d.format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => router.push('/user-goods/list')
|
||||||
|
|
||||||
|
const loadDetail = async () => {
|
||||||
|
if (!goodsId.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getUserGoodsDetail({ id: goodsId.value })
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
detail.value = res.data.data.data ?? res.data.data
|
||||||
|
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
|
||||||
|
const showVmSelector = ref(false)
|
||||||
|
const showServiceSelector = ref(false)
|
||||||
|
|
||||||
|
const openEdit = () => {
|
||||||
|
const rawRenew = detail.value?.renewPrice || detail.value?.renew_price || 0
|
||||||
|
const rawBase = detail.value?.basePrice || detail.value?.base_price || 0
|
||||||
|
Object.assign(editForm, {
|
||||||
|
note: detail.value?.note || '',
|
||||||
|
renew_price: rawRenew / 100,
|
||||||
|
base_price: rawBase / 100,
|
||||||
|
expire_time: detail.value?.expireTime || detail.value?.expire_time
|
||||||
|
? dayjs(detail.value?.expireTime || detail.value?.expire_time).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
: '',
|
||||||
|
item_id: detail.value?.itemId || detail.value?.item_id || 0,
|
||||||
|
_serviceId: 0,
|
||||||
|
_serviceName: '',
|
||||||
|
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
|
||||||
|
})
|
||||||
|
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitEdit = async () => {
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = { id: goodsId.value }
|
||||||
|
if (editForm.note !== undefined) data.note = editForm.note
|
||||||
|
if (editForm.renew_price) data.renew_price = Math.round(editForm.renew_price * 100)
|
||||||
|
if (editForm.base_price) data.base_price = Math.round(editForm.base_price * 100)
|
||||||
|
if (editForm.expire_time) data.expire_time = editForm.expire_time
|
||||||
|
if (editForm.item_id) data.item_id = editForm.item_id
|
||||||
|
const res = await updateUserGoods(data)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editVisible.value = false; loadDetail() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) }
|
||||||
|
finally { submitLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
ElMessageBox.confirm('确定删除该用户商品吗?', '删除确认', { type: 'warning' })
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteUserGoods({ id: goodsId.value })
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDetail)
|
||||||
|
watch(goodsId, (newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.goods-detail-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e1e8ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
border: 1px solid #e1e8ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-basic {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #e8f4fd 0%, #d6eaff 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-row .label {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-row .value {
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-value {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-card {
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 1px solid #e1e8ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-descriptions__label) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-content">
|
||||||
|
<el-form :inline="true" :model="tagQueryParams" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="tagQueryParams.key" placeholder="搜索标签名称" clearable style="width: 180px" @keyup.enter="fetchTagList" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchTagList">
|
||||||
|
<el-icon><Search /></el-icon>查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetTagQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="action-bar">
|
||||||
|
<el-button type="primary" @click="handleAddTag">
|
||||||
|
<el-icon><Plus /></el-icon>新增标签
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" @click="fetchTagList">
|
||||||
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-section">
|
||||||
|
<el-table
|
||||||
|
v-loading="tagLoading"
|
||||||
|
:data="tagList"
|
||||||
|
style="width: 100%"
|
||||||
|
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="标签ID" width="100" />
|
||||||
|
<el-table-column prop="name" label="标签名称" min-width="200" />
|
||||||
|
<el-table-column prop="index" label="排序" width="100" sortable />
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button type="primary" link @click="handleEditTag(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link @click="handleDeleteTag(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无标签数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="tagQueryParams.page"
|
||||||
|
v-model:page-size="tagQueryParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:total="tagTotal"
|
||||||
|
@size-change="handleTagSizeChange"
|
||||||
|
@current-change="handleTagCurrentChange"
|
||||||
|
background
|
||||||
|
class="pagination"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分组标签表单对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="tagDialogVisible"
|
||||||
|
:title="tagDialogType === 'add' ? '新增分组标签' : '编辑分组标签'"
|
||||||
|
width="500px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="tagFormRef"
|
||||||
|
:model="tagForm"
|
||||||
|
:rules="tagRules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="标签名称" prop="name">
|
||||||
|
<el-input v-model="tagForm.name" placeholder="请输入标签名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序索引" prop="index">
|
||||||
|
<el-input-number v-model="tagForm.index" :min="0" :max="9999" placeholder="数值越小越靠前" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="tagDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitTagForm">确定</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
getProductGroupTagList,
|
||||||
|
createProductGroupTag,
|
||||||
|
updateProductGroupTag,
|
||||||
|
deleteProductGroupTag
|
||||||
|
} from '@/api/admin/product'
|
||||||
|
|
||||||
|
const tagQueryParams = reactive({ page: 1, count: 10, key: '' })
|
||||||
|
const tagLoading = ref(false)
|
||||||
|
const tagList = ref([])
|
||||||
|
const tagTotal = ref(0)
|
||||||
|
const tagDialogVisible = ref(false)
|
||||||
|
const tagDialogType = ref('add')
|
||||||
|
const tagFormRef = ref(null)
|
||||||
|
const tagForm = reactive({ id: undefined, name: '', index: 0 })
|
||||||
|
const tagRules = { name: [{ required: true, message: '请输入标签名称', trigger: 'blur' }] }
|
||||||
|
|
||||||
|
const fetchTagList = async () => {
|
||||||
|
tagLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = { ...tagQueryParams }
|
||||||
|
if (!params.key) delete params.key
|
||||||
|
const res = await getProductGroupTagList(params)
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
const data = res.data.data
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
tagList.value = data
|
||||||
|
tagTotal.value = data.length
|
||||||
|
} else if (data && data.list) {
|
||||||
|
tagList.value = data.list
|
||||||
|
tagTotal.value = data.all_count || data.total || data.list.length
|
||||||
|
} else if (data && data.data) {
|
||||||
|
tagList.value = data.data
|
||||||
|
tagTotal.value = data.total || data.data.length
|
||||||
|
} else {
|
||||||
|
tagList.value = []
|
||||||
|
tagTotal.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签列表失败:', error)
|
||||||
|
ElMessage.error('获取标签列表失败')
|
||||||
|
} finally {
|
||||||
|
tagLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTagQuery = () => { tagQueryParams.key = ''; tagQueryParams.page = 1; fetchTagList() }
|
||||||
|
const handleTagSizeChange = (size) => { tagQueryParams.count = size; fetchTagList() }
|
||||||
|
const handleTagCurrentChange = (page) => { tagQueryParams.page = page; fetchTagList() }
|
||||||
|
|
||||||
|
const handleAddTag = () => {
|
||||||
|
tagDialogType.value = 'add'
|
||||||
|
tagDialogVisible.value = true
|
||||||
|
Object.assign(tagForm, { id: undefined, name: '', index: 0 })
|
||||||
|
tagFormRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditTag = (row) => {
|
||||||
|
tagDialogType.value = 'edit'
|
||||||
|
tagDialogVisible.value = true
|
||||||
|
Object.assign(tagForm, { id: row.id, name: row.name, index: row.index || 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTag = (row) => {
|
||||||
|
ElMessageBox.confirm(`确认删除标签 ${row.name} 吗?`, '警告', {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteProductGroupTag({ id: row.id })
|
||||||
|
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchTagList() }
|
||||||
|
} catch (error) { ElMessage.error('删除失败') }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitTagForm = () => {
|
||||||
|
tagFormRef.value?.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
let res
|
||||||
|
if (tagDialogType.value === 'add') {
|
||||||
|
res = await createProductGroupTag({ name: tagForm.name, index: tagForm.index })
|
||||||
|
} else {
|
||||||
|
res = await updateProductGroupTag({ id: tagForm.id, name: tagForm.name, index: tagForm.index })
|
||||||
|
}
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success(tagDialogType.value === 'add' ? '新增成功' : '修改成功')
|
||||||
|
tagDialogVisible.value = false
|
||||||
|
fetchTagList()
|
||||||
|
}
|
||||||
|
} catch (error) { ElMessage.error('操作失败') }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ fetchTagList })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-section { padding: 0; border-bottom: 1px solid #e1e8ed; background: #fafbfc; }
|
||||||
|
.filter-content { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; gap: 20px; flex-wrap: wrap; }
|
||||||
|
.search-form { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0; }
|
||||||
|
.search-form :deep(.el-form-item) { margin-bottom: 0; margin-right: 0; }
|
||||||
|
.action-bar { display: flex; gap: 12px; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
|
||||||
|
.table-section { padding: 0; }
|
||||||
|
.action-buttons { display: flex; gap: 4px; align-items: center; flex-wrap: nowrap; }
|
||||||
|
.action-buttons .el-button { padding: 4px 8px; }
|
||||||
|
.pagination { margin-top: 20px; padding: 16px 20px; border-top: 1px solid #e1e8ed; background: #fafbfc; justify-content: flex-end; }
|
||||||
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0; }
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -184,25 +184,57 @@ import {
|
|||||||
User, Edit, Document, Timer, EditPen, Message,
|
User, Edit, Document, Timer, EditPen, Message,
|
||||||
Phone, OfficeBuilding, UploadFilled
|
Phone, OfficeBuilding, UploadFilled
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
import { useUserStore } from '@/store/userStore.js'
|
||||||
|
|
||||||
// 是否处于编辑模式
|
// 是否处于编辑模式
|
||||||
const isEditing = ref(false)
|
const isEditing = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 从localStorage或store获取用户信息
|
||||||
|
const getSavedUserInfo = () => {
|
||||||
|
const savedInfo = userStore.userInfo
|
||||||
|
if (savedInfo && Object.keys(savedInfo).length > 0) {
|
||||||
|
return {
|
||||||
|
username: savedInfo.user_name || '',
|
||||||
|
realName: savedInfo.real_name?.Name || savedInfo.user_name || '',
|
||||||
|
email: savedInfo.email || '',
|
||||||
|
phone: savedInfo.phone || '',
|
||||||
|
department: savedInfo.admin_group?.name || '',
|
||||||
|
position: savedInfo.is_admin ? '管理员' : '普通用户',
|
||||||
|
role: savedInfo.admin_group?.name || '普通用户',
|
||||||
|
createTime: savedInfo.created_at || '',
|
||||||
|
lastLogin: savedInfo.created_at || '',
|
||||||
|
bio: savedInfo.admin_group?.note || '',
|
||||||
|
avatar: savedInfo.cover || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
||||||
|
sex: savedInfo.sex || '',
|
||||||
|
age: savedInfo.age || '',
|
||||||
|
userId: savedInfo.user_id || '',
|
||||||
|
userGroup: savedInfo.user_group?.Name || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
realName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
department: '',
|
||||||
|
position: '',
|
||||||
|
role: '',
|
||||||
|
createTime: '',
|
||||||
|
lastLogin: '',
|
||||||
|
bio: '',
|
||||||
|
avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
||||||
|
sex: '',
|
||||||
|
age: '',
|
||||||
|
userId: '',
|
||||||
|
userGroup: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 用户信息数据
|
// 用户信息数据
|
||||||
const userInfo = reactive({
|
const userInfo = reactive(getSavedUserInfo())
|
||||||
username: 'admin',
|
|
||||||
realName: '管理员',
|
|
||||||
email: 'admin@example.com',
|
|
||||||
phone: '13800138000',
|
|
||||||
department: '技术部',
|
|
||||||
position: '系统管理员',
|
|
||||||
role: '超级管理员',
|
|
||||||
createTime: '2023-01-01 00:00:00',
|
|
||||||
lastLogin: '2023-06-15 10:30:45',
|
|
||||||
bio: '系统管理员,负责系统的日常维护和管理工作。拥有丰富的系统管理经验,精通Linux服务器配置和维护,熟悉网络安全,对系统性能优化有独到见解。',
|
|
||||||
avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const userForm = reactive({...userInfo})
|
const userForm = reactive({...userInfo})
|
||||||
@@ -296,9 +328,9 @@ const handleAvatarSuccess = (res) => {
|
|||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
const fetchUserInfo = async () => {
|
const fetchUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
// 模拟API调用
|
// 从store获取最新用户信息
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
const savedInfo = getSavedUserInfo()
|
||||||
// 实际项目中,应该从后端获取用户信息并更新userInfo
|
Object.assign(userInfo, savedInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('获取用户信息失败')
|
ElMessage.error('获取用户信息失败')
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -349,33 +349,6 @@ onMounted(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格样式优化 */
|
|
||||||
:deep(.el-table) {
|
|
||||||
border: none;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table__header) {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table th) {
|
|
||||||
background: #f8f9fa !important;
|
|
||||||
border-bottom: 2px solid #e1e8ed;
|
|
||||||
color: #2c3e50;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table td) {
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
color: #34495e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table tr:hover > td) {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-card__body) {
|
:deep(.el-card__body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
<template>
|
||||||
|
<div class="menu-manage-container">
|
||||||
|
<el-card class="main-container" shadow="never">
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-content">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="queryParams.key" placeholder="菜单名称/路径" clearable style="width: 180px" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="父级菜单">
|
||||||
|
<el-select v-model="queryParams.parent_id" placeholder="全部" clearable style="width: 160px">
|
||||||
|
<el-option v-for="m in parentMenuOptions" :key="m.id" :label="m.title" :value="m.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleQuery">
|
||||||
|
<el-icon><Search /></el-icon>搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="action-bar">
|
||||||
|
<el-radio-group v-model="viewMode" size="default" @change="handleViewModeChange">
|
||||||
|
<el-radio-button value="list">
|
||||||
|
<el-icon style="vertical-align: -2px;"><Grid /></el-icon> 列表视图
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="tree">
|
||||||
|
<el-icon style="vertical-align: -2px;"><Connection /></el-icon> 树状视图
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-button v-if="viewMode === 'list'" type="primary" @click="handleAdd(null)">
|
||||||
|
<el-icon><Plus /></el-icon>新增顶级菜单
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" @click="handleRefresh">
|
||||||
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表视图 -->
|
||||||
|
<div v-if="viewMode === 'list'" class="table-section">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="menuList"
|
||||||
|
style="width: 100%"
|
||||||
|
row-key="id"
|
||||||
|
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||||
|
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="title" label="菜单名称" min-width="180" />
|
||||||
|
<el-table-column prop="path" label="路径" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="info">{{ row.path || '-' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="icon" label="图标" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.icon" style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<el-icon><component :is="row.icon" /></el-icon>
|
||||||
|
<span>{{ row.icon }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="parentId" label="父级ID" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.parentId ?? '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.CreatedAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="250" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button type="primary" link @click="handleAdd(row)">添加子菜单</el-button>
|
||||||
|
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无菜单数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.page"
|
||||||
|
v-model:page-size="queryParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:total="total"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
background
|
||||||
|
class="pagination"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 树状视图 -->
|
||||||
|
<div v-if="viewMode === 'tree'" v-loading="myPermLoading" class="tree-section">
|
||||||
|
<el-tree
|
||||||
|
v-if="myPermTree.length > 0"
|
||||||
|
:data="myPermTree"
|
||||||
|
node-key="id"
|
||||||
|
default-expand-all
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="perm-tree-node">
|
||||||
|
<el-icon v-if="data.icon" style="margin-right: 6px; flex-shrink: 0;"><component :is="data.icon" /></el-icon>
|
||||||
|
<span class="perm-tree-title">{{ data.title }}</span>
|
||||||
|
<el-tag size="small" type="info" style="margin-left: 8px;">{{ data.path || '-' }}</el-tag>
|
||||||
|
<el-tag :type="data.enable ? 'success' : 'danger'" size="small" style="margin-left: 6px;">
|
||||||
|
{{ data.enable ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
<el-empty v-if="!myPermLoading && myPermTree.length === 0" description="暂无菜单权限数据" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 菜单表单对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogType === 'add' ? '新增菜单' : '编辑菜单'"
|
||||||
|
width="550px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="menuForm" :rules="menuRules" label-width="100px">
|
||||||
|
<el-form-item label="菜单名称" prop="title">
|
||||||
|
<el-input v-model="menuForm.title" placeholder="请输入菜单名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="菜单路径" prop="path">
|
||||||
|
<MenuPathSelector v-model="menuForm.path" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="菜单图标" prop="icon">
|
||||||
|
<IconSelector v-model="menuForm.icon" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="父级菜单">
|
||||||
|
<el-select v-model="menuForm.parent_id" placeholder="无(顶级菜单)" clearable style="width: 100%">
|
||||||
|
<el-option label="无(顶级菜单)" :value="0" />
|
||||||
|
<el-option v-for="m in parentMenuOptions" :key="m.id" :label="m.title" :value="m.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search, Plus, Refresh, Grid, Connection } from '@element-plus/icons-vue'
|
||||||
|
import { getWebRoutsList, addWebRouts, updateWebRouts, deleteWebRouts, getMyWebRoutsPermission } from '@/api/admin/webRouts'
|
||||||
|
import { formatDate as formatDateTool } from '@/utils/tool'
|
||||||
|
import IconSelector from '@/components/admin/IconSelector.vue'
|
||||||
|
import MenuPathSelector from '@/components/admin/MenuPathSelector.vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const menuList = ref([])
|
||||||
|
const parentMenuOptions = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogType = ref('add')
|
||||||
|
const formRef = ref(null)
|
||||||
|
const viewMode = ref('list')
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
page: 1,
|
||||||
|
count: 10,
|
||||||
|
key: '',
|
||||||
|
parent_id: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuForm = reactive({
|
||||||
|
id: undefined,
|
||||||
|
title: '',
|
||||||
|
path: '',
|
||||||
|
icon: '',
|
||||||
|
parent_id: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuRules = {
|
||||||
|
title: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
|
||||||
|
path: [{ required: true, message: '请输入菜单路径', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => formatDateTool(dateStr)
|
||||||
|
|
||||||
|
const flattenForParent = (list) => {
|
||||||
|
const result = []
|
||||||
|
for (const item of list) {
|
||||||
|
result.push(item)
|
||||||
|
if (item.children?.length) {
|
||||||
|
result.push(...flattenForParent(item.children))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMenuList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
Object.keys(queryParams).forEach(key => {
|
||||||
|
if (queryParams[key] !== '' && queryParams[key] !== null && queryParams[key] !== undefined) {
|
||||||
|
params[key] = queryParams[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const res = await getWebRoutsList(params)
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
menuList.value = res.data.data?.list || []
|
||||||
|
total.value = res.data.data?.all_count || 0
|
||||||
|
parentMenuOptions.value = flattenForParent(menuList.value)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取菜单列表失败:', error)
|
||||||
|
ElMessage.error('获取菜单列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
queryParams.page = 1
|
||||||
|
fetchMenuList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetQuery = () => {
|
||||||
|
queryParams.key = ''
|
||||||
|
queryParams.parent_id = null
|
||||||
|
queryParams.page = 1
|
||||||
|
fetchMenuList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
queryParams.count = size
|
||||||
|
fetchMenuList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (page) => {
|
||||||
|
queryParams.page = page
|
||||||
|
fetchMenuList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = (parentRow) => {
|
||||||
|
dialogType.value = 'add'
|
||||||
|
dialogVisible.value = true
|
||||||
|
Object.assign(menuForm, {
|
||||||
|
id: undefined,
|
||||||
|
title: '',
|
||||||
|
path: '',
|
||||||
|
icon: '',
|
||||||
|
parent_id: parentRow?.id || 0
|
||||||
|
})
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
dialogType.value = 'edit'
|
||||||
|
dialogVisible.value = true
|
||||||
|
Object.assign(menuForm, {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
path: row.path,
|
||||||
|
icon: row.icon,
|
||||||
|
parent_id: row.parentId || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
ElMessageBox.confirm(`确认删除菜单「${row.title}」吗?删除后其子菜单也将受到影响。`, '警告', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteWebRouts({ id: row.id })
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchMenuList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
formRef.value?.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
const submitData = {
|
||||||
|
title: menuForm.title,
|
||||||
|
path: menuForm.path,
|
||||||
|
icon: menuForm.icon
|
||||||
|
}
|
||||||
|
if (menuForm.parent_id) {
|
||||||
|
submitData.parent_id = menuForm.parent_id
|
||||||
|
}
|
||||||
|
let res
|
||||||
|
if (dialogType.value === 'add') {
|
||||||
|
res = await addWebRouts(submitData)
|
||||||
|
} else {
|
||||||
|
submitData.id = menuForm.id
|
||||||
|
res = await updateWebRouts(submitData)
|
||||||
|
}
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchMenuList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const myPermLoading = ref(false)
|
||||||
|
const myPermTree = ref([])
|
||||||
|
|
||||||
|
const fetchMyPermission = async () => {
|
||||||
|
myPermLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getMyWebRoutsPermission()
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
myPermTree.value = res.data.data || []
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '获取失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取我的菜单权限失败:', error)
|
||||||
|
ElMessage.error('获取我的菜单权限失败')
|
||||||
|
} finally {
|
||||||
|
myPermLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewModeChange = (mode) => {
|
||||||
|
if (mode === 'list') {
|
||||||
|
fetchMenuList()
|
||||||
|
} else {
|
||||||
|
fetchMyPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (viewMode.value === 'list') {
|
||||||
|
fetchMenuList()
|
||||||
|
} else {
|
||||||
|
fetchMyPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchMenuList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-manage-container { padding: 0; }
|
||||||
|
.main-container { border: 1px solid #e1e8ed; background: #ffffff; }
|
||||||
|
.filter-section { padding: 0; border-bottom: 1px solid #e1e8ed; background: #fafbfc; }
|
||||||
|
.filter-content { display: flex; justify-content: space-between; align-items: flex-start; padding: 16px 20px; gap: 20px; flex-wrap: wrap; }
|
||||||
|
.filter-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
.filter-form :deep(.el-form-item) { margin-bottom: 0; margin-right: 8px; }
|
||||||
|
.filter-form :deep(.el-form-item__label) { font-size: 13px; }
|
||||||
|
.action-bar { display: flex; gap: 12px; flex-shrink: 0; }
|
||||||
|
.table-section { padding: 0; }
|
||||||
|
.action-buttons { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.text-muted { color: #c0c4cc; }
|
||||||
|
.pagination { margin-top: 20px; padding: 16px 20px; border-top: 1px solid #e1e8ed; background: #fafbfc; justify-content: flex-end; }
|
||||||
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; }
|
||||||
|
:deep(.el-card__body) { padding: 0; }
|
||||||
|
:deep(.el-table th) { background: #f8f9fa !important; border-bottom: 2px solid #e1e8ed; color: #2c3e50; font-weight: 600; font-size: 13px; }
|
||||||
|
:deep(.el-table td) { border-bottom: 1px solid #f0f2f5; color: #34495e; }
|
||||||
|
:deep(.el-table tr:hover > td) { background-color: #f8f9fa !important; }
|
||||||
|
.tree-section { padding: 16px 20px; min-height: 300px; }
|
||||||
|
.perm-tree-node { display: flex; align-items: center; padding: 2px 0; width: 100%; }
|
||||||
|
.perm-tree-title { font-size: 13px; font-weight: 500; }
|
||||||
|
.tree-section :deep(.el-tree-node__content) { height: 38px; }
|
||||||
|
.tree-section :deep(.el-tree-node__content:hover) { background-color: #f5f7fa; }
|
||||||
|
.action-bar { display: flex; gap: 12px; flex-shrink: 0; align-items: center; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
<template>
|
||||||
|
<div class="menu-permission-container">
|
||||||
|
<el-card class="main-container" shadow="never">
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-content">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||||
|
<el-form-item label="类型">
|
||||||
|
<el-select v-model="queryParams.owner_type" placeholder="请选择类型" clearable style="width: 130px" @change="handleOwnerTypeChange">
|
||||||
|
<el-option label="用户" value="user" />
|
||||||
|
<el-option label="管理员组" value="group" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户" v-if="queryParams.owner_type === 'user'">
|
||||||
|
<div class="selector-inline">
|
||||||
|
<el-tag v-if="queryParams.user_id" type="primary" closable @close="clearQueryUser" style="margin-right: 8px;">
|
||||||
|
{{ queryUserName || `用户 #${queryParams.user_id}` }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button type="primary" plain @click="openUserSelector('query')" size="default">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ queryParams.user_id ? '重新选择' : '选择用户' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="管理员组" v-if="queryParams.owner_type === 'group'">
|
||||||
|
<div class="selector-inline">
|
||||||
|
<el-tag v-if="queryParams.admin_group_id" type="success" closable @close="clearQueryGroup" style="margin-right: 8px;">
|
||||||
|
{{ queryGroupName || `组 #${queryParams.admin_group_id}` }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button type="success" plain @click="openGroupSelector('query')" size="default">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ queryParams.admin_group_id ? '重新选择' : '选择管理员组' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleQuery">
|
||||||
|
<el-icon><Search /></el-icon>查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="action-bar">
|
||||||
|
<el-button type="primary" @click="handleAdd">
|
||||||
|
<el-icon><Plus /></el-icon>分配权限
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" @click="handleRefresh">
|
||||||
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-section">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="permissionList"
|
||||||
|
style="width: 100%"
|
||||||
|
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column label="所属类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getOwnerType(row) === 'user' ? 'primary' : 'success'" size="small">
|
||||||
|
{{ getOwnerType(row) === 'user' ? '用户' : '管理员组' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="所属对象" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.userId">用户 #{{ row.userId }}</span>
|
||||||
|
<span v-else-if="row.adminGroupId">管理员组 #{{ row.adminGroupId }}</span>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="菜单" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.webRouts">
|
||||||
|
<span style="font-weight: 500;">{{ row.webRouts.title }}</span>
|
||||||
|
<el-tag size="small" type="info" style="margin-left: 8px;">{{ row.webRouts.path }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-muted">菜单ID: {{ row.webRoutsId }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.enable ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.enable ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.CreatedAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button type="primary" link @click="handleToggleEnable(row)">
|
||||||
|
{{ row.enable ? '禁用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无权限数据" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 用户选择弹窗 -->
|
||||||
|
<UserListSelector
|
||||||
|
v-model="userSelectorVisible"
|
||||||
|
:current-user-id="selectorTarget === 'query' ? queryParams.user_id : permForm.user_id"
|
||||||
|
@confirm="handleUserConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 管理员组选择弹窗 -->
|
||||||
|
<UserGroupSelector
|
||||||
|
v-model="groupSelectorVisible"
|
||||||
|
:current-group-id="selectorTarget === 'query' ? queryParams.admin_group_id : permForm.admin_group_id"
|
||||||
|
admin-group
|
||||||
|
@confirm="handleGroupConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 菜单选择弹窗 -->
|
||||||
|
<el-dialog v-model="menuSelectorVisible" title="选择菜单" width="700px" append-to-body @open="openMenuSelector">
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||||
|
<el-input v-model="menuSearchKey" placeholder="搜索菜单名称或路径" clearable style="flex: 1;" @keyup.enter="fetchMenuSelectorList">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="fetchMenuSelectorList">搜索</el-button>
|
||||||
|
<el-button type="success" @click="fetchMenuSelectorList">
|
||||||
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="menuSelectorLoading"
|
||||||
|
:data="menuSelectorFlatList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleMenuCurrentChange"
|
||||||
|
:height="400"
|
||||||
|
style="width: 100%"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="title" label="菜单名称" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ paddingLeft: (row._level || 0) * 20 + 'px' }">
|
||||||
|
<span v-if="row._level" style="color: #c0c4cc; margin-right: 4px;">└</span>
|
||||||
|
{{ row.title }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="path" label="路径" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="info">{{ row.path || '-' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="icon" label="图标" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-icon v-if="row.icon"><component :is="row.icon" /></el-icon>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="menuSelectorVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!menuSelectorTemp" @click="confirmMenuSelect">确定选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 分配/编辑权限对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogType === 'add' ? '分配菜单权限' : '编辑菜单权限'"
|
||||||
|
width="600px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="permForm" :rules="formRules" label-width="120px">
|
||||||
|
<el-form-item label="所属类型" prop="owner_type">
|
||||||
|
<el-select v-model="permForm.owner_type" placeholder="请选择" style="width: 100%" :disabled="dialogType === 'edit'" @change="handleFormOwnerTypeChange">
|
||||||
|
<el-option label="用户" value="user" />
|
||||||
|
<el-option label="管理员组" value="group" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户" prop="user_id" v-if="permForm.owner_type === 'user'">
|
||||||
|
<div class="selector-inline" style="width: 100%;">
|
||||||
|
<el-tag v-if="permForm.user_id" type="primary" closable @close="permForm.user_id = null" style="margin-right: 8px;">
|
||||||
|
{{ formUserName || `用户 #${permForm.user_id}` }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button type="primary" plain @click="openUserSelector('form')">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ permForm.user_id ? '重新选择' : '选择用户' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="管理员组" prop="admin_group_id" v-if="permForm.owner_type === 'group'">
|
||||||
|
<div class="selector-inline" style="width: 100%;">
|
||||||
|
<el-tag v-if="permForm.admin_group_id" type="success" closable @close="permForm.admin_group_id = null" style="margin-right: 8px;">
|
||||||
|
{{ formGroupName || `组 #${permForm.admin_group_id}` }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button type="success" plain @click="openGroupSelector('form')">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ permForm.admin_group_id ? '重新选择' : '选择管理员组' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="菜单" prop="web_routs_id">
|
||||||
|
<div class="selector-inline" style="width: 100%;">
|
||||||
|
<el-tag v-if="permForm.web_routs_id" closable @close="clearFormMenu" style="margin-right: 8px;">
|
||||||
|
{{ formMenuName || `菜单 #${permForm.web_routs_id}` }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button plain @click="menuSelectorVisible = true">
|
||||||
|
<el-icon><Menu /></el-icon>
|
||||||
|
{{ permForm.web_routs_id ? '重新选择' : '选择菜单' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="是否启用">
|
||||||
|
<el-switch v-model="permForm.enable" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search, Plus, Refresh, User, Menu } from '@element-plus/icons-vue'
|
||||||
|
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||||
|
import UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
|
||||||
|
import {
|
||||||
|
getWebRoutsList,
|
||||||
|
getWebRoutsPermissionList,
|
||||||
|
addWebRoutsPermission,
|
||||||
|
updateWebRoutsPermission,
|
||||||
|
deleteWebRoutsPermission
|
||||||
|
} from '@/api/admin/webRouts'
|
||||||
|
import { formatDate as formatDateTool } from '@/utils/tool'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const permissionList = ref([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogType = ref('add')
|
||||||
|
const formRef = ref(null)
|
||||||
|
|
||||||
|
const userSelectorVisible = ref(false)
|
||||||
|
const groupSelectorVisible = ref(false)
|
||||||
|
const menuSelectorVisible = ref(false)
|
||||||
|
const menuSelectorLoading = ref(false)
|
||||||
|
const menuSelectorTemp = ref(null)
|
||||||
|
const menuSearchKey = ref('')
|
||||||
|
const selectorTarget = ref('query')
|
||||||
|
|
||||||
|
const queryUserName = ref('')
|
||||||
|
const queryGroupName = ref('')
|
||||||
|
const formUserName = ref('')
|
||||||
|
const formGroupName = ref('')
|
||||||
|
const formMenuName = ref('')
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
owner_type: 'group',
|
||||||
|
user_id: null,
|
||||||
|
admin_group_id: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const permForm = reactive({
|
||||||
|
id: undefined,
|
||||||
|
web_routs_id: null,
|
||||||
|
enable: true,
|
||||||
|
owner_type: 'group',
|
||||||
|
admin_group_id: null,
|
||||||
|
user_id: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
owner_type: [{ required: true, message: '请选择所属类型', trigger: 'change' }],
|
||||||
|
web_routs_id: [{ required: true, message: '请选择菜单', trigger: 'change' }],
|
||||||
|
user_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
|
||||||
|
admin_group_id: [{ required: true, message: '请选择管理员组', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => formatDateTool(dateStr)
|
||||||
|
|
||||||
|
const getOwnerType = (row) => {
|
||||||
|
if (row.userId) return 'user'
|
||||||
|
if (row.adminGroupId) return 'group'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPermissionList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (queryParams.owner_type) params.owner_type = queryParams.owner_type
|
||||||
|
if (queryParams.owner_type === 'user' && queryParams.user_id) params.user_id = queryParams.user_id
|
||||||
|
if (queryParams.owner_type === 'group' && queryParams.admin_group_id) params.admin_group_id = queryParams.admin_group_id
|
||||||
|
const res = await getWebRoutsPermissionList(params)
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
permissionList.value = res.data.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取权限列表失败:', error)
|
||||||
|
ElMessage.error('获取权限列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattenMenuTree = (list, level = 0) => {
|
||||||
|
const result = []
|
||||||
|
for (const item of list) {
|
||||||
|
result.push({ ...item, _level: level, children: undefined })
|
||||||
|
if (item.children?.length) {
|
||||||
|
result.push(...flattenMenuTree(item.children, level + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuSelectorFlatList = ref([])
|
||||||
|
|
||||||
|
const openMenuSelector = () => {
|
||||||
|
menuSelectorTemp.value = null
|
||||||
|
menuSearchKey.value = ''
|
||||||
|
fetchMenuSelectorList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMenuSelectorList = async () => {
|
||||||
|
menuSelectorLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = { page: 1, count: 10 }
|
||||||
|
if (menuSearchKey.value) params.key = menuSearchKey.value
|
||||||
|
const res = await getWebRoutsList(params)
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
const treeList = res.data.data?.list || []
|
||||||
|
menuSelectorFlatList.value = flattenMenuTree(treeList)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取菜单列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
menuSelectorLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuCurrentChange = (row) => {
|
||||||
|
menuSelectorTemp.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMenuSelect = () => {
|
||||||
|
if (!menuSelectorTemp.value) return
|
||||||
|
permForm.web_routs_id = menuSelectorTemp.value.id
|
||||||
|
formMenuName.value = `${menuSelectorTemp.value.title} (${menuSelectorTemp.value.path})`
|
||||||
|
menuSelectorVisible.value = false
|
||||||
|
menuSelectorTemp.value = null
|
||||||
|
menuSearchKey.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFormMenu = () => {
|
||||||
|
permForm.web_routs_id = null
|
||||||
|
formMenuName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOwnerTypeChange = () => {
|
||||||
|
queryParams.user_id = null
|
||||||
|
queryParams.admin_group_id = null
|
||||||
|
queryUserName.value = ''
|
||||||
|
queryGroupName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFormOwnerTypeChange = () => {
|
||||||
|
permForm.user_id = null
|
||||||
|
permForm.admin_group_id = null
|
||||||
|
formUserName.value = ''
|
||||||
|
formGroupName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const canQuery = () => {
|
||||||
|
if (queryParams.owner_type === 'user' && !queryParams.user_id) {
|
||||||
|
ElMessage.warning('请先选择用户')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (queryParams.owner_type === 'group' && !queryParams.admin_group_id) {
|
||||||
|
ElMessage.warning('请先选择管理员组')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!queryParams.owner_type) {
|
||||||
|
ElMessage.warning('请先选择类型')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
if (!canQuery()) return
|
||||||
|
fetchPermissionList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (!canQuery()) return
|
||||||
|
fetchPermissionList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetQuery = () => {
|
||||||
|
queryParams.owner_type = 'group'
|
||||||
|
queryParams.user_id = null
|
||||||
|
queryParams.admin_group_id = null
|
||||||
|
queryUserName.value = ''
|
||||||
|
queryGroupName.value = ''
|
||||||
|
permissionList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearQueryUser = () => {
|
||||||
|
queryParams.user_id = null
|
||||||
|
queryUserName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearQueryGroup = () => {
|
||||||
|
queryParams.admin_group_id = null
|
||||||
|
queryGroupName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const openUserSelector = (target) => {
|
||||||
|
selectorTarget.value = target
|
||||||
|
userSelectorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openGroupSelector = (target) => {
|
||||||
|
selectorTarget.value = target
|
||||||
|
groupSelectorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUserConfirm = (user) => {
|
||||||
|
const id = user.user_id || user.UserId || user.userId
|
||||||
|
const name = user.user_name || user.UserName || user.userName || `用户 #${id}`
|
||||||
|
if (selectorTarget.value === 'query') {
|
||||||
|
queryParams.user_id = id
|
||||||
|
queryUserName.value = name
|
||||||
|
} else {
|
||||||
|
permForm.user_id = id
|
||||||
|
formUserName.value = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGroupConfirm = (group) => {
|
||||||
|
const name = group.name || group.groupName || `组 #${group.id}`
|
||||||
|
const id = group.id
|
||||||
|
if (selectorTarget.value === 'query') {
|
||||||
|
queryParams.admin_group_id = id
|
||||||
|
queryGroupName.value = name
|
||||||
|
} else {
|
||||||
|
permForm.admin_group_id = id
|
||||||
|
formGroupName.value = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialogType.value = 'add'
|
||||||
|
dialogVisible.value = true
|
||||||
|
Object.assign(permForm, {
|
||||||
|
id: undefined,
|
||||||
|
web_routs_id: null,
|
||||||
|
enable: true,
|
||||||
|
owner_type: 'group',
|
||||||
|
admin_group_id: null,
|
||||||
|
user_id: null
|
||||||
|
})
|
||||||
|
formUserName.value = ''
|
||||||
|
formGroupName.value = ''
|
||||||
|
formMenuName.value = ''
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
dialogType.value = 'edit'
|
||||||
|
dialogVisible.value = true
|
||||||
|
const ownerType = row.userId ? 'user' : 'group'
|
||||||
|
Object.assign(permForm, {
|
||||||
|
id: row.id,
|
||||||
|
web_routs_id: row.webRoutsId,
|
||||||
|
enable: row.enable,
|
||||||
|
owner_type: ownerType,
|
||||||
|
admin_group_id: row.adminGroupId || null,
|
||||||
|
user_id: row.userId || null
|
||||||
|
})
|
||||||
|
formUserName.value = row.userId ? `用户 #${row.userId}` : ''
|
||||||
|
formGroupName.value = row.adminGroupId ? `组 #${row.adminGroupId}` : ''
|
||||||
|
if (row.webRouts) {
|
||||||
|
formMenuName.value = `${row.webRouts.title} (${row.webRouts.path})`
|
||||||
|
} else {
|
||||||
|
formMenuName.value = row.webRoutsId ? `菜单 #${row.webRoutsId}` : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleEnable = async (row) => {
|
||||||
|
const newEnable = !row.enable
|
||||||
|
const action = newEnable ? '启用' : '禁用'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认${action}该菜单权限吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
const res = await updateWebRoutsPermission({
|
||||||
|
id: row.id,
|
||||||
|
enable: newEnable
|
||||||
|
})
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success(`${action}成功`)
|
||||||
|
fetchPermissionList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || `${action}失败`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.response?.data?.message || `${action}失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
ElMessageBox.confirm('确认删除该菜单权限吗?', '警告', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteWebRoutsPermission({ id: row.id })
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchPermissionList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
formRef.value?.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
const submitData = {
|
||||||
|
web_routs_id: permForm.web_routs_id,
|
||||||
|
enable: permForm.enable,
|
||||||
|
owner_type: permForm.owner_type
|
||||||
|
}
|
||||||
|
if (permForm.owner_type === 'user') {
|
||||||
|
submitData.user_id = permForm.user_id
|
||||||
|
} else {
|
||||||
|
submitData.admin_group_id = permForm.admin_group_id
|
||||||
|
}
|
||||||
|
|
||||||
|
let res
|
||||||
|
if (dialogType.value === 'add') {
|
||||||
|
res = await addWebRoutsPermission(submitData)
|
||||||
|
} else {
|
||||||
|
submitData.id = permForm.id
|
||||||
|
res = await updateWebRoutsPermission(submitData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
ElMessage.success(dialogType.value === 'add' ? '分配成功' : '修改成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchPermissionList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-permission-container { padding: 0; }
|
||||||
|
.main-container { border: 1px solid #e1e8ed; background: #ffffff; }
|
||||||
|
.filter-section { padding: 0; border-bottom: 1px solid #e1e8ed; background: #fafbfc; }
|
||||||
|
.filter-content { display: flex; justify-content: space-between; align-items: flex-start; padding: 16px 20px; gap: 20px; flex-wrap: wrap; }
|
||||||
|
.filter-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
.filter-form :deep(.el-form-item) { margin-bottom: 0; margin-right: 8px; }
|
||||||
|
.filter-form :deep(.el-form-item__label) { font-size: 13px; }
|
||||||
|
.action-bar { display: flex; gap: 12px; flex-shrink: 0; }
|
||||||
|
.selector-inline { display: flex; align-items: center; }
|
||||||
|
.table-section { padding: 0; }
|
||||||
|
.action-buttons { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.text-muted { color: #c0c4cc; }
|
||||||
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; }
|
||||||
|
:deep(.el-card__body) { padding: 0; }
|
||||||
|
:deep(.el-table th) { background: #f8f9fa !important; border-bottom: 2px solid #e1e8ed; color: #2c3e50; font-weight: 600; font-size: 13px; }
|
||||||
|
:deep(.el-table td) { border-bottom: 1px solid #f0f2f5; color: #34495e; }
|
||||||
|
:deep(.el-table tr:hover > td) { background-color: #f8f9fa !important; }
|
||||||
|
</style>
|
||||||
@@ -24,9 +24,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="管理员组" v-if="queryParams.owner_type === 'group'">
|
<el-form-item label="管理员组" v-if="queryParams.owner_type === 'group'">
|
||||||
<el-select v-model="queryParams.admin_group_id" placeholder="请选择管理员组" clearable filterable style="width: 200px">
|
<div class="selector-inline">
|
||||||
<el-option v-for="item in adminGroupOptions" :key="item.id" :label="`${item.name} (ID: ${item.id})`" :value="item.id" />
|
<el-tag v-if="queryParams.admin_group_id" type="success" closable @close="clearQueryGroup" style="margin-right: 8px;">
|
||||||
</el-select>
|
{{ getQueryGroupName() }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button type="success" plain @click="openQueryGroupSelector" size="default">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ queryParams.admin_group_id ? '重新选择' : '选择管理员组' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="handleQuery">
|
<el-button type="primary" @click="handleQuery">
|
||||||
@@ -115,14 +121,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 用户选择弹窗 -->
|
<!-- 用户选择弹窗 - 使用UserListSelector组件 -->
|
||||||
<el-dialog
|
<UserListSelector
|
||||||
v-model="userSelectorVisible"
|
v-model="userSelectorVisible"
|
||||||
|
:current-user-id="selectorType === 'query' ? queryParams.user_id : permissionForm.user_id"
|
||||||
|
@confirm="handleUserSelectorConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 管理员组选择弹窗 - 使用UserGroupSelector组件 -->
|
||||||
|
<UserGroupSelector
|
||||||
|
v-model="groupSelectorVisible"
|
||||||
|
:current-group-id="selectorType === 'query' ? queryParams.admin_group_id : permissionForm.admin_group_id"
|
||||||
|
admin-group
|
||||||
|
@confirm="handleGroupSelectorConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 路径权限选择弹窗 -->
|
||||||
|
<PermissionPathSelector
|
||||||
|
v-model="permissionSelectorVisible"
|
||||||
|
:current-permission-id="permissionForm.permission_id"
|
||||||
|
@confirm="handlePermissionSelectorConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 旧的用户选择弹窗 - 已废弃 -->
|
||||||
|
<!-- <el-dialog
|
||||||
|
v-model="userSelectorVisibleOld"
|
||||||
title="选择用户"
|
title="选择用户"
|
||||||
width="800px"
|
width="800px"
|
||||||
class="user-selector-dialog"
|
class="user-selector-dialog"
|
||||||
>
|
>
|
||||||
<!-- 搜索栏 -->
|
|
||||||
<div class="selector-search">
|
<div class="selector-search">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="userSearchParams.key"
|
v-model="userSearchParams.key"
|
||||||
@@ -142,7 +169,6 @@
|
|||||||
<el-button @click="resetUserSearch">重置</el-button>
|
<el-button @click="resetUserSearch">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 用户表格 -->
|
|
||||||
<el-table
|
<el-table
|
||||||
v-loading="userSelectorLoading"
|
v-loading="userSelectorLoading"
|
||||||
:data="userSelectorList"
|
:data="userSelectorList"
|
||||||
@@ -164,7 +190,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="userSearchParams.page"
|
v-model:current-page="userSearchParams.page"
|
||||||
v-model:page-size="userSearchParams.count"
|
v-model:page-size="userSearchParams.count"
|
||||||
@@ -178,12 +204,12 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="userSelectorVisible = false">取消</el-button>
|
<el-button @click="userSelectorVisibleOld = false">取消</el-button>
|
||||||
<el-button type="primary" @click="confirmUserSelection" :disabled="!selectedUserTemp">
|
<el-button type="primary" @click="confirmUserSelection" :disabled="!selectedUserTemp">
|
||||||
确定选择
|
确定选择
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog> -->
|
||||||
<!-- 分配权限对话框 -->
|
<!-- 分配权限对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
@@ -204,45 +230,80 @@
|
|||||||
<div class="form-tip">如果是 user 则填写 user_id,如果是 group 则填写 admin_group_id</div>
|
<div class="form-tip">如果是 user 则填写 user_id,如果是 group 则填写 admin_group_id</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="用户" prop="user_id" v-if="permissionForm.owner_type === 'user'" >
|
<el-form-item label="用户" prop="user_id" v-if="permissionForm.owner_type === 'user'" >
|
||||||
<div class="user_selector-inline">
|
<div class="recommend-user-selector">
|
||||||
<el-tag v-if="permissionForm.user_id" type="primary" closable @close="clearFormUser" style="margin-right: 8px;">
|
<el-input
|
||||||
{{ getFormUserName() }}
|
:model-value="getFormUserName()"
|
||||||
</el-tag>
|
placeholder="点击选择用户"
|
||||||
<el-button type="primary" plain @click="openFormUserSelector" size="default" :disabled="permissionForm.user_id">
|
readonly
|
||||||
<el-icon><User /></el-icon>
|
@click="openFormUserSelector"
|
||||||
{{ permissionForm.user_id ? '重新选择' : '选择用户' }}
|
:disabled="!!permissionForm.id"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="openFormUserSelector" :disabled="!!permissionForm.id">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button
|
||||||
|
v-if="permissionForm.user_id && !permissionForm.id"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="clearFormUser"
|
||||||
|
class="clear-btn"
|
||||||
|
>
|
||||||
|
清除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="管理员组" prop="admin_group_id" v-if="permissionForm.owner_type === 'group'">
|
<el-form-item label="管理员组" prop="admin_group_id" v-if="permissionForm.owner_type === 'group'">
|
||||||
<el-select v-model="permissionForm.admin_group_id" placeholder="请选择管理员组" filterable style="width: 100%">
|
<div class="recommend-user-selector">
|
||||||
<el-option v-for="item in adminGroupOptions" :key="item.id" :label="`${item.name} (ID: ${item.id})`" :value="item.id" />
|
<el-input
|
||||||
</el-select>
|
:model-value="getFormGroupName()"
|
||||||
|
placeholder="点击选择管理员组"
|
||||||
|
readonly
|
||||||
|
@click="openFormGroupSelector"
|
||||||
|
:disabled="!!permissionForm.id"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="openFormGroupSelector" :disabled="!!permissionForm.id">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button
|
||||||
|
v-if="permissionForm.admin_group_id && !permissionForm.id"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="clearFormGroup"
|
||||||
|
class="clear-btn"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="路径权限" prop="permission_id">
|
<el-form-item label="路径权限" prop="permission_id">
|
||||||
<div style="display: flex; gap: 8px;">
|
<div class="recommend-user-selector">
|
||||||
<el-select
|
<el-input
|
||||||
v-model="permissionForm.permission_id"
|
:model-value="getFormPermissionName()"
|
||||||
placeholder="请选择路径权限"
|
placeholder="点击选择路径权限"
|
||||||
filterable
|
readonly
|
||||||
style="flex: 1"
|
@click="openPermissionSelector"
|
||||||
:loading="permissionLoading"
|
|
||||||
>
|
>
|
||||||
<el-option
|
<template #append>
|
||||||
v-for="item in permissionOptions"
|
<el-button @click="openPermissionSelector">
|
||||||
:key="item.id"
|
<el-icon><Search /></el-icon>
|
||||||
:value="item.id"
|
</el-button>
|
||||||
>
|
</template>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
</el-input>
|
||||||
<span>
|
<el-button
|
||||||
<el-tag v-if="item.method" :type="getMethodTag(item.method)" size="small" style="margin-right: 8px;">{{ item.method }}</el-tag>
|
v-if="permissionForm.permission_id"
|
||||||
{{ item.path }}
|
type="danger"
|
||||||
</span>
|
link
|
||||||
<span style="color: #999; font-size: 12px; margin-left: 12px;">{{ item.note || item.name || `ID: ${item.id}` }}</span>
|
@click="clearFormPermission"
|
||||||
</div>
|
class="clear-btn"
|
||||||
</el-option>
|
>
|
||||||
</el-select>
|
清除
|
||||||
<el-button @click="fetchPermissionList" :loading="permissionLoading" :icon="Refresh">刷新</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-tip">共 {{ permissionOptions.length }} 个路径权限可选</div>
|
<div class="form-tip">共 {{ permissionOptions.length }} 个路径权限可选</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -283,6 +344,9 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Search, Refresh, User } from '@element-plus/icons-vue'
|
import { Plus, Search, Refresh, User } from '@element-plus/icons-vue'
|
||||||
|
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||||
|
import UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
|
||||||
|
import PermissionPathSelector from '@/components/admin/PermissionPathSelector.vue'
|
||||||
import {
|
import {
|
||||||
getPermissionListByAdmin,
|
getPermissionListByAdmin,
|
||||||
addPermissionAdmin,
|
addPermissionAdmin,
|
||||||
@@ -297,6 +361,8 @@ import { formatDate ,timeToTimestamp} from '@/utils/tool'
|
|||||||
|
|
||||||
const selectorType = ref('query')
|
const selectorType = ref('query')
|
||||||
const userSelectorVisible = ref(false)
|
const userSelectorVisible = ref(false)
|
||||||
|
const groupSelectorVisible = ref(false)
|
||||||
|
const permissionSelectorVisible = ref(false)
|
||||||
const userSelectorList = ref([])
|
const userSelectorList = ref([])
|
||||||
const userSelectorTotal = ref(0)
|
const userSelectorTotal = ref(0)
|
||||||
const userSearchParams = reactive({
|
const userSearchParams = reactive({
|
||||||
@@ -307,6 +373,8 @@ const userSearchParams = reactive({
|
|||||||
const selectedUserTemp = ref(null)
|
const selectedUserTemp = ref(null)
|
||||||
const userSelectorLoading = ref(false)
|
const userSelectorLoading = ref(false)
|
||||||
const UserOptions = ref([])
|
const UserOptions = ref([])
|
||||||
|
const GroupOptions = ref([])
|
||||||
|
const selectedPermission = ref(null)
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
owner_type: '',
|
owner_type: '',
|
||||||
@@ -324,16 +392,125 @@ const getQueryUserName = () => {
|
|||||||
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
|
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
|
||||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
|
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
|
||||||
}
|
}
|
||||||
|
// 清除查询管理员组
|
||||||
|
const clearQueryGroup = () => {
|
||||||
|
queryParams.admin_group_id = undefined
|
||||||
|
}
|
||||||
|
// 获取查询管理员组名称
|
||||||
|
const getQueryGroupName = () => {
|
||||||
|
const group = GroupOptions.value.find(g => g.id === queryParams.admin_group_id) ||
|
||||||
|
adminGroupOptions.value.find(g => g.id === queryParams.admin_group_id)
|
||||||
|
return group ? `${group.name} (ID: ${group.id})` : `管理员组ID: ${queryParams.admin_group_id}`
|
||||||
|
}
|
||||||
|
// 打开查询管理员组选择器
|
||||||
|
const openQueryGroupSelector = () => {
|
||||||
|
selectorType.value = 'query'
|
||||||
|
groupSelectorVisible.value = true
|
||||||
|
}
|
||||||
// 表单:清除用户
|
// 表单:清除用户
|
||||||
const clearFormUser = () => {
|
const clearFormUser = () => {
|
||||||
permissionForm.user_id = undefined
|
permissionForm.user_id = undefined
|
||||||
}
|
}
|
||||||
// 表单:获取显示名称
|
// 表单:获取显示名称
|
||||||
const getFormUserName = () => {
|
const getFormUserName = () => {
|
||||||
|
if (!permissionForm.user_id) return ''
|
||||||
const user = UserOptions.value.find(u => u.UserId === permissionForm.user_id)
|
const user = UserOptions.value.find(u => u.UserId === permissionForm.user_id)
|
||||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${permissionForm.user_id}`
|
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${permissionForm.user_id}`
|
||||||
}
|
}
|
||||||
// 确认用户选择
|
|
||||||
|
// 表单:获取管理员组显示名称
|
||||||
|
const getFormGroupName = () => {
|
||||||
|
if (!permissionForm.admin_group_id) return ''
|
||||||
|
const group = GroupOptions.value.find(g => g.id === permissionForm.admin_group_id)
|
||||||
|
return group ? `${group.name} (ID: ${group.id})` : `管理员组ID: ${permissionForm.admin_group_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单:获取路径权限显示名称
|
||||||
|
const getFormPermissionName = () => {
|
||||||
|
if (!permissionForm.permission_id) return ''
|
||||||
|
if (selectedPermission.value && selectedPermission.value.id === permissionForm.permission_id) {
|
||||||
|
const p = selectedPermission.value
|
||||||
|
return `${p.method || ''} ${p.path}${p.name ? ' - ' + p.name : ''}`
|
||||||
|
}
|
||||||
|
const perm = permissionOptions.value.find(p => p.id === permissionForm.permission_id)
|
||||||
|
return perm ? `${perm.method || ''} ${perm.path}${perm.name ? ' - ' + perm.name : ''}` : `权限ID: ${permissionForm.permission_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除表单管理员组
|
||||||
|
const clearFormGroup = () => {
|
||||||
|
permissionForm.admin_group_id = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除表单路径权限
|
||||||
|
const clearFormPermission = () => {
|
||||||
|
permissionForm.permission_id = undefined
|
||||||
|
selectedPermission.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开管理员组选择器
|
||||||
|
const openFormGroupSelector = () => {
|
||||||
|
selectorType.value = 'form'
|
||||||
|
groupSelectorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开路径权限选择器
|
||||||
|
const openPermissionSelector = () => {
|
||||||
|
permissionSelectorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员组选择确认
|
||||||
|
const handleGroupSelectorConfirm = (group) => {
|
||||||
|
if (group) {
|
||||||
|
const groupId = group.id || group.Id
|
||||||
|
const groupName = group.name || group.Name
|
||||||
|
|
||||||
|
if (selectorType.value === 'query') {
|
||||||
|
queryParams.admin_group_id = groupId
|
||||||
|
if (!GroupOptions.value.find(g => g.id === groupId)) {
|
||||||
|
GroupOptions.value.push({ id: groupId, name: groupName })
|
||||||
|
}
|
||||||
|
fetchAdminPermissionList()
|
||||||
|
} else {
|
||||||
|
permissionForm.admin_group_id = groupId
|
||||||
|
if (!GroupOptions.value.find(g => g.id === groupId)) {
|
||||||
|
GroupOptions.value.push({ id: groupId, name: groupName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupSelectorVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径权限选择确认
|
||||||
|
const handlePermissionSelectorConfirm = (permission) => {
|
||||||
|
if (permission) {
|
||||||
|
permissionForm.permission_id = permission.id
|
||||||
|
selectedPermission.value = permission
|
||||||
|
}
|
||||||
|
permissionSelectorVisible.value = false
|
||||||
|
}
|
||||||
|
// UserListSelector 组件确认回调
|
||||||
|
const handleUserSelectorConfirm = (user) => {
|
||||||
|
if (user) {
|
||||||
|
const userId = user.user_id || user.UserId
|
||||||
|
const userName = user.user_name || user.UserName
|
||||||
|
|
||||||
|
if (selectorType.value === 'query') {
|
||||||
|
queryParams.user_id = userId
|
||||||
|
if (!UserOptions.value.find(u => u.UserId === userId)) {
|
||||||
|
UserOptions.value.push({ UserId: userId, UserName: userName })
|
||||||
|
}
|
||||||
|
fetchAdminPermissionList()
|
||||||
|
} else if (selectorType.value === 'form') {
|
||||||
|
permissionForm.user_id = userId
|
||||||
|
if (!UserOptions.value.find(u => u.UserId === userId)) {
|
||||||
|
UserOptions.value.push({ UserId: userId, UserName: userName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userSelectorVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认用户选择(旧方法,保留兼容)
|
||||||
const confirmUserSelection = () => {
|
const confirmUserSelection = () => {
|
||||||
if (!selectedUserTemp.value) {
|
if (!selectedUserTemp.value) {
|
||||||
ElMessage.warning('请选择一个用户')
|
ElMessage.warning('请选择一个用户')
|
||||||
@@ -684,7 +861,7 @@ const fetchUserList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getUserList({
|
const res = await getUserList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 10000,
|
count: 10,
|
||||||
key: ''
|
key: ''
|
||||||
})
|
})
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
@@ -700,7 +877,7 @@ const fetchAdminGroupList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getAdminGroupList({
|
const res = await getAdminGroupList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 1000
|
count: 10
|
||||||
})
|
})
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
adminGroupOptions.value = res.data.data?.data || []
|
adminGroupOptions.value = res.data.data?.data || []
|
||||||
@@ -716,7 +893,7 @@ const fetchPermissionList = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getPermissionList({
|
const res = await getPermissionList({
|
||||||
page: 1,
|
page: 1,
|
||||||
count: 10000
|
count: 10
|
||||||
})
|
})
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
permissionOptions.value = res.data.data?.list || []
|
permissionOptions.value = res.data.data?.list || []
|
||||||
@@ -837,4 +1014,34 @@ onMounted(() => {
|
|||||||
:deep(.el-card__body) {
|
:deep(.el-card__body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 推介人选择器样式 - 与UserList.vue保持一致 */
|
||||||
|
.recommend-user-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommend-user-selector .el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommend-user-selector .clear-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user_selector-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,600 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="setting-container">
|
|
||||||
<!-- 主容器 -->
|
|
||||||
<el-card class="main-container" shadow="never">
|
|
||||||
<!-- 搜索和操作栏 -->
|
|
||||||
<div class="filter-section">
|
|
||||||
<div class="filter-content">
|
|
||||||
<el-form :inline="true" :model="queryParams" class="search-form">
|
|
||||||
<el-form-item label="配置组">
|
|
||||||
<el-select v-model="queryParams.group_id" placeholder="请选择配置组" clearable style="width: 200px" @change="handleQuery">
|
|
||||||
<el-option
|
|
||||||
v-for="group in groupList"
|
|
||||||
:key="group.id"
|
|
||||||
:label="group.name"
|
|
||||||
:value="group.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="关键词筛选">
|
|
||||||
<el-input v-model="queryParams.key" placeholder="请输入关键词" clearable style="width: 200px" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleQuery">
|
|
||||||
<el-icon><Search /></el-icon>查询
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="resetQuery">重置</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<div class="action-bar">
|
|
||||||
<el-button type="primary" @click="handleAdd">
|
|
||||||
<el-icon><Plus /></el-icon>新增配置
|
|
||||||
</el-button>
|
|
||||||
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
|
|
||||||
<el-icon><Delete /></el-icon>批量删除
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 配置列表 -->
|
|
||||||
<div class="table-section">
|
|
||||||
<el-table
|
|
||||||
v-loading="loading"
|
|
||||||
:data="settingList"
|
|
||||||
@selection-change="handleSelectionChange"
|
|
||||||
style="width: 100%"
|
|
||||||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
|
||||||
>
|
|
||||||
<el-table-column type="selection" width="55" />
|
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
|
||||||
<el-table-column prop="name" label="名称" min-width="150" />
|
|
||||||
<el-table-column prop="value" label="值" min-width="200" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.type === 'bool'">{{ row.value ? '是' : '否' }}</span>
|
|
||||||
<span v-else>{{ row.value }}</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="type" label="类型" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="getTypeColor(row.type)">
|
|
||||||
{{ row.type || '未知' }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="settingGroupID" label="配置组" width="150" />
|
|
||||||
<el-table-column label="是否开放" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-switch
|
|
||||||
v-model="row.open"
|
|
||||||
@change="handleToggleOpen(row)"
|
|
||||||
:disabled="toggleLoading === row.id"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="note" label="备注" min-width="200" show-overflow-tooltip />
|
|
||||||
<el-table-column label="创建时间" width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatDate(row.CreatedAt) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="200" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
|
||||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="queryParams.page"
|
|
||||||
v-model:page-size="queryParams.count"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
:total="total"
|
|
||||||
@size-change="handleSizeChange"
|
|
||||||
@current-change="handleCurrentChange"
|
|
||||||
background
|
|
||||||
class="pagination"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 配置表单对话框 -->
|
|
||||||
<el-dialog
|
|
||||||
v-model="dialogVisible"
|
|
||||||
:title="dialogTitle"
|
|
||||||
width="600px"
|
|
||||||
destroy-on-close
|
|
||||||
>
|
|
||||||
<el-form
|
|
||||||
ref="settingFormRef"
|
|
||||||
:model="settingForm"
|
|
||||||
:rules="settingRules"
|
|
||||||
label-width="120px"
|
|
||||||
>
|
|
||||||
<el-form-item label="配置组" prop="settingGroupID">
|
|
||||||
<el-select v-model="settingForm.settingGroupID" placeholder="请选择配置组" style="width: 100%">
|
|
||||||
<el-option
|
|
||||||
v-for="group in groupList"
|
|
||||||
:key="group.id"
|
|
||||||
:label="group.name"
|
|
||||||
:value="group.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="名称" prop="name">
|
|
||||||
<el-input v-model="settingForm.name" placeholder="请输入配置名称" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="类型" prop="type">
|
|
||||||
<el-select v-model="settingForm.type" placeholder="请选择类型" style="width: 100%" @change="handleTypeChange">
|
|
||||||
<el-option label="字符串 (string)" value="string" />
|
|
||||||
<el-option label="整数 (int)" value="int" />
|
|
||||||
<el-option label="浮点数 (float)" value="float" />
|
|
||||||
<el-option label="布尔值 (bool)" value="bool" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="值" prop="value">
|
|
||||||
<el-input
|
|
||||||
v-if="settingForm.type === 'string'"
|
|
||||||
v-model="settingForm.value"
|
|
||||||
type="textarea"
|
|
||||||
:rows="3"
|
|
||||||
placeholder="请输入配置值"
|
|
||||||
/>
|
|
||||||
<el-input-number
|
|
||||||
v-else-if="settingForm.type === 'int'"
|
|
||||||
v-model="settingForm.value"
|
|
||||||
:controls="false"
|
|
||||||
placeholder="请输入整数"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
<el-input-number
|
|
||||||
v-else-if="settingForm.type === 'float'"
|
|
||||||
v-model="settingForm.value"
|
|
||||||
:controls="false"
|
|
||||||
:precision="2"
|
|
||||||
placeholder="请输入浮点数"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
<el-switch
|
|
||||||
v-else-if="settingForm.type === 'bool'"
|
|
||||||
v-model="settingForm.value"
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
v-else
|
|
||||||
v-model="settingForm.value"
|
|
||||||
placeholder="请输入配置值"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="是否开放访问">
|
|
||||||
<el-switch v-model="settingForm.open" />
|
|
||||||
<span style="margin-left: 10px; color: #909399; font-size: 12px;">
|
|
||||||
开启后允许公开访问
|
|
||||||
</span>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="备注" prop="note">
|
|
||||||
<el-input
|
|
||||||
v-model="settingForm.note"
|
|
||||||
type="textarea"
|
|
||||||
:rows="3"
|
|
||||||
placeholder="请输入备注信息"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" @click="submitForm">确定</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Search, Plus, Delete } from '@element-plus/icons-vue'
|
|
||||||
import {
|
|
||||||
getSettingList,
|
|
||||||
getSettingInfo,
|
|
||||||
createSetting,
|
|
||||||
updateSetting,
|
|
||||||
setSettingOpen,
|
|
||||||
deleteSetting
|
|
||||||
} from '@/api/admin/setting'
|
|
||||||
import { getSettingGroupList } from '@/api/admin/setting'
|
|
||||||
|
|
||||||
// 查询参数
|
|
||||||
const queryParams = reactive({
|
|
||||||
group_id: undefined,
|
|
||||||
key: '',
|
|
||||||
page: 1,
|
|
||||||
count: 10
|
|
||||||
})
|
|
||||||
|
|
||||||
// 配置表单
|
|
||||||
const settingForm = reactive({
|
|
||||||
id: undefined,
|
|
||||||
name: '',
|
|
||||||
value: '',
|
|
||||||
type: 'string',
|
|
||||||
settingGroupID: undefined,
|
|
||||||
open: false,
|
|
||||||
note: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const settingRules = {
|
|
||||||
name: [
|
|
||||||
{ required: true, message: '请输入配置名称', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
value: [
|
|
||||||
{ required: true, message: '请输入配置值', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
type: [
|
|
||||||
{ required: true, message: '请选择配置类型', trigger: 'change' }
|
|
||||||
],
|
|
||||||
settingGroupID: [
|
|
||||||
{ required: true, message: '请选择配置组', trigger: 'change' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态数据
|
|
||||||
const loading = ref(false)
|
|
||||||
const settingList = ref([])
|
|
||||||
const groupList = ref([])
|
|
||||||
const total = ref(0)
|
|
||||||
const selectedRows = ref([])
|
|
||||||
const dialogVisible = ref(false)
|
|
||||||
const dialogTitle = ref('新增配置')
|
|
||||||
const settingFormRef = ref(null)
|
|
||||||
const toggleLoading = ref(null)
|
|
||||||
|
|
||||||
// 格式化日期时间
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '-'
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取类型颜色
|
|
||||||
const getTypeColor = (type) => {
|
|
||||||
const colorMap = {
|
|
||||||
'string': 'primary',
|
|
||||||
'int': 'success',
|
|
||||||
'float': 'warning',
|
|
||||||
'bool': 'info'
|
|
||||||
}
|
|
||||||
return colorMap[type] || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取配置组列表
|
|
||||||
const fetchGroupList = async () => {
|
|
||||||
try {
|
|
||||||
const res = await getSettingGroupList({ page: 1, count: 1000 })
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
groupList.value = res.data.data.data || []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取配置组列表失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取配置列表
|
|
||||||
const fetchSettingList = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const params = { ...queryParams }
|
|
||||||
if (!params.group_id) {
|
|
||||||
delete params.group_id
|
|
||||||
}
|
|
||||||
const res = await getSettingList(params)
|
|
||||||
console.log('配置列表数据:', res.data)
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
settingList.value = res.data.data.data || []
|
|
||||||
total.value = res.data.data.all_count || 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取配置列表失败:', error)
|
|
||||||
ElMessage.error('获取配置列表失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询
|
|
||||||
const handleQuery = () => {
|
|
||||||
queryParams.page = 1
|
|
||||||
fetchSettingList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置查询
|
|
||||||
const resetQuery = () => {
|
|
||||||
queryParams.group_id = undefined
|
|
||||||
queryParams.key = ''
|
|
||||||
queryParams.page = 1
|
|
||||||
fetchSettingList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择项变化
|
|
||||||
const handleSelectionChange = (selection) => {
|
|
||||||
selectedRows.value = selection
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页
|
|
||||||
const handleSizeChange = (size) => {
|
|
||||||
queryParams.count = size
|
|
||||||
fetchSettingList()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCurrentChange = (page) => {
|
|
||||||
queryParams.page = page
|
|
||||||
fetchSettingList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 类型变化
|
|
||||||
const handleTypeChange = (type) => {
|
|
||||||
// 根据类型重置值
|
|
||||||
if (type === 'bool') {
|
|
||||||
settingForm.value = false
|
|
||||||
} else if (type === 'int' || type === 'float') {
|
|
||||||
settingForm.value = 0
|
|
||||||
} else {
|
|
||||||
settingForm.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增配置
|
|
||||||
const handleAdd = () => {
|
|
||||||
dialogTitle.value = '新增配置'
|
|
||||||
Object.assign(settingForm, {
|
|
||||||
id: undefined,
|
|
||||||
name: '',
|
|
||||||
value: '',
|
|
||||||
type: 'string',
|
|
||||||
setting_group_id: undefined,
|
|
||||||
open: false,
|
|
||||||
note: ''
|
|
||||||
})
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑配置
|
|
||||||
const handleEdit = async (row) => {
|
|
||||||
dialogTitle.value = '编辑配置'
|
|
||||||
try {
|
|
||||||
const res = await getSettingInfo({ id: row.id })
|
|
||||||
console.log('配置详情数据:', res)
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
const data = res.data.data
|
|
||||||
Object.assign(settingForm, {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name || '',
|
|
||||||
value: data.value,
|
|
||||||
type: data.type || 'string',
|
|
||||||
settingGroupID: data.settingGroupID,
|
|
||||||
open: data.open || false,
|
|
||||||
note: data.note || ''
|
|
||||||
})
|
|
||||||
console.log('配置详情数据:', settingForm)
|
|
||||||
// 根据类型转换值
|
|
||||||
if (data.type === 'bool') {
|
|
||||||
settingForm.value = data.value === true || data.value === 'true' || data.value === 1
|
|
||||||
} else if (data.type === 'int') {
|
|
||||||
settingForm.value = parseInt(data.value) || 0
|
|
||||||
} else if (data.type === 'float') {
|
|
||||||
settingForm.value = parseFloat(data.value) || 0
|
|
||||||
}
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取配置详情失败:', error)
|
|
||||||
ElMessage.error('获取配置详情失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换开放状态
|
|
||||||
const handleToggleOpen = async (row) => {
|
|
||||||
toggleLoading.value = row.id
|
|
||||||
try {
|
|
||||||
const res = await setSettingOpen({
|
|
||||||
id: row.id,
|
|
||||||
open: row.open
|
|
||||||
})
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
ElMessage.success('修改成功')
|
|
||||||
} else {
|
|
||||||
// 恢复原状态
|
|
||||||
row.open = !row.open
|
|
||||||
ElMessage.error(res.data.message || '修改失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 恢复原状态
|
|
||||||
row.open = !row.open
|
|
||||||
console.error('修改失败:', error)
|
|
||||||
ElMessage.error(error.response?.data?.message || '修改失败')
|
|
||||||
} finally {
|
|
||||||
toggleLoading.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除配置
|
|
||||||
const handleDelete = (row) => {
|
|
||||||
ElMessageBox.confirm(`确认删除配置 "${row.name}" 吗?`, '警告', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}).then(async () => {
|
|
||||||
try {
|
|
||||||
const res = await deleteSetting({ id: row.id })
|
|
||||||
console.log('删除配置响应:', res.data)
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
ElMessage.success('删除成功')
|
|
||||||
fetchSettingList()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除失败:', error)
|
|
||||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
|
||||||
}
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除
|
|
||||||
const handleBatchDelete = () => {
|
|
||||||
if (selectedRows.value.length === 0) {
|
|
||||||
ElMessage.warning('请至少选择一条记录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}).then(async () => {
|
|
||||||
try {
|
|
||||||
const deletePromises = selectedRows.value.map(row =>
|
|
||||||
deleteSetting({ id: row.id })
|
|
||||||
)
|
|
||||||
await Promise.all(deletePromises)
|
|
||||||
ElMessage.success('批量删除成功')
|
|
||||||
fetchSettingList()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量删除失败:', error)
|
|
||||||
ElMessage.error('批量删除失败')
|
|
||||||
}
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const submitForm = () => {
|
|
||||||
settingFormRef.value?.validate(async (valid) => {
|
|
||||||
if (valid) {
|
|
||||||
try {
|
|
||||||
const submitData = {
|
|
||||||
name: settingForm.name,
|
|
||||||
value: String(settingForm.value),
|
|
||||||
type: settingForm.type,
|
|
||||||
setting_group_id: settingForm.settingGroupID,
|
|
||||||
open: settingForm.open,
|
|
||||||
note: settingForm.note
|
|
||||||
}
|
|
||||||
if (settingForm.id) {
|
|
||||||
submitData.id = settingForm.id
|
|
||||||
}
|
|
||||||
console.log('提交配置数据:', submitData)
|
|
||||||
const res = settingForm.id
|
|
||||||
? await updateSetting(submitData)
|
|
||||||
: await createSetting(submitData)
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
ElMessage.success(settingForm.id ? '修改成功' : '创建成功')
|
|
||||||
dialogVisible.value = false
|
|
||||||
fetchSettingList()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('提交失败:', error)
|
|
||||||
ElMessage.error(error.response?.data?.message || '提交失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
fetchGroupList()
|
|
||||||
fetchSettingList()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.setting-container {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-container {
|
|
||||||
border: 1px solid #e1e8ed;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
padding: 0;
|
|
||||||
border-bottom: 1px solid #e1e8ed;
|
|
||||||
background: #fafbfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 20px;
|
|
||||||
gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form {
|
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form :deep(.el-form-item) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-section {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid #e1e8ed;
|
|
||||||
background: #fafbfc;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格样式优化 */
|
|
||||||
:deep(.el-table) {
|
|
||||||
border: none;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table__header) {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table th) {
|
|
||||||
background: #f8f9fa !important;
|
|
||||||
border-bottom: 2px solid #e1e8ed;
|
|
||||||
color: #2c3e50;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table td) {
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
color: #34495e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table tr:hover > td) {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-card__body) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="setting-group-container">
|
|
||||||
<!-- 主容器 -->
|
|
||||||
<el-card class="main-container" shadow="never">
|
|
||||||
<!-- 搜索和操作栏 -->
|
|
||||||
<div class="filter-section">
|
|
||||||
<div class="filter-content">
|
|
||||||
<el-form :inline="true" :model="queryParams" class="search-form">
|
|
||||||
<el-form-item label="关键词筛选">
|
|
||||||
<el-input v-model="queryParams.key" placeholder="请输入关键词" clearable style="width: 200px" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleQuery">
|
|
||||||
<el-icon><Search /></el-icon>查询
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="resetQuery">重置</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<div class="action-bar">
|
|
||||||
<el-button type="primary" @click="handleAdd">
|
|
||||||
<el-icon><Plus /></el-icon>新增配置组
|
|
||||||
</el-button>
|
|
||||||
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
|
|
||||||
<el-icon><Delete /></el-icon>批量删除
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 配置组列表 -->
|
|
||||||
<div class="table-section">
|
|
||||||
<el-table
|
|
||||||
v-loading="loading"
|
|
||||||
:data="groupList"
|
|
||||||
@selection-change="handleSelectionChange"
|
|
||||||
style="width: 100%"
|
|
||||||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
|
||||||
>
|
|
||||||
<el-table-column type="selection" width="55" />
|
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
|
||||||
<el-table-column prop="name" label="名称" min-width="200" />
|
|
||||||
<el-table-column prop="note" label="备注" min-width="250" show-overflow-tooltip />
|
|
||||||
<el-table-column label="创建时间" width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatDate(row.CreatedAt) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="更新时间" width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatDate(row.UpdatedAt) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="200" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
|
||||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="queryParams.page"
|
|
||||||
v-model:page-size="queryParams.count"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
:total="total"
|
|
||||||
@size-change="handleSizeChange"
|
|
||||||
@current-change="handleCurrentChange"
|
|
||||||
background
|
|
||||||
class="pagination"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 配置组表单对话框 -->
|
|
||||||
<el-dialog
|
|
||||||
v-model="dialogVisible"
|
|
||||||
:title="dialogTitle"
|
|
||||||
width="500px"
|
|
||||||
destroy-on-close
|
|
||||||
>
|
|
||||||
<el-form
|
|
||||||
ref="groupFormRef"
|
|
||||||
:model="groupForm"
|
|
||||||
:rules="groupRules"
|
|
||||||
label-width="100px"
|
|
||||||
>
|
|
||||||
<el-form-item label="名称" prop="name">
|
|
||||||
<el-input v-model="groupForm.name" placeholder="请输入配置组名称" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="备注" prop="note">
|
|
||||||
<el-input
|
|
||||||
v-model="groupForm.note"
|
|
||||||
type="textarea"
|
|
||||||
:rows="3"
|
|
||||||
placeholder="请输入备注信息"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" @click="submitForm">确定</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Search, Plus, Delete } from '@element-plus/icons-vue'
|
|
||||||
import {
|
|
||||||
getSettingGroupList,
|
|
||||||
getSettingGroupInfo,
|
|
||||||
createSettingGroup,
|
|
||||||
updateSettingGroup,
|
|
||||||
deleteSettingGroup
|
|
||||||
} from '@/api/admin/setting'
|
|
||||||
|
|
||||||
// 查询参数
|
|
||||||
const queryParams = reactive({
|
|
||||||
key: '',
|
|
||||||
page: 1,
|
|
||||||
count: 10
|
|
||||||
})
|
|
||||||
|
|
||||||
// 配置组表单
|
|
||||||
const groupForm = reactive({
|
|
||||||
id: undefined,
|
|
||||||
name: '',
|
|
||||||
note: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const groupRules = {
|
|
||||||
name: [
|
|
||||||
{ required: true, message: '请输入配置组名称', trigger: 'blur' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态数据
|
|
||||||
const loading = ref(false)
|
|
||||||
const groupList = ref([])
|
|
||||||
const total = ref(0)
|
|
||||||
const selectedRows = ref([])
|
|
||||||
const dialogVisible = ref(false)
|
|
||||||
const dialogTitle = ref('新增配置组')
|
|
||||||
const groupFormRef = ref(null)
|
|
||||||
|
|
||||||
// 格式化日期时间
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '-'
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取配置组列表
|
|
||||||
const fetchGroupList = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getSettingGroupList(queryParams)
|
|
||||||
console.log('配置组列表数据:', res.data)
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
groupList.value = res.data.data.data || []
|
|
||||||
total.value = res.data.data.all_count || 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取配置组列表失败:', error)
|
|
||||||
ElMessage.error('获取配置组列表失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询
|
|
||||||
const handleQuery = () => {
|
|
||||||
queryParams.page = 1
|
|
||||||
fetchGroupList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置查询
|
|
||||||
const resetQuery = () => {
|
|
||||||
queryParams.key = ''
|
|
||||||
queryParams.page = 1
|
|
||||||
fetchGroupList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择项变化
|
|
||||||
const handleSelectionChange = (selection) => {
|
|
||||||
selectedRows.value = selection
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页
|
|
||||||
const handleSizeChange = (size) => {
|
|
||||||
queryParams.count = size
|
|
||||||
fetchGroupList()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCurrentChange = (page) => {
|
|
||||||
queryParams.page = page
|
|
||||||
fetchGroupList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增配置组
|
|
||||||
const handleAdd = () => {
|
|
||||||
dialogTitle.value = '新增配置组'
|
|
||||||
Object.assign(groupForm, {
|
|
||||||
id: undefined,
|
|
||||||
name: '',
|
|
||||||
note: ''
|
|
||||||
})
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑配置组
|
|
||||||
const handleEdit = async (row) => {
|
|
||||||
dialogTitle.value = '编辑配置组'
|
|
||||||
try {
|
|
||||||
const res = await getSettingGroupInfo({ setting_group_id: row.id })
|
|
||||||
console.log('配置组详情数据:', res.data)
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
Object.assign(groupForm, {
|
|
||||||
id: res.data.data.id,
|
|
||||||
name: res.data.data.name || '',
|
|
||||||
note: res.data.data.note || ''
|
|
||||||
})
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取配置组详情失败:', error)
|
|
||||||
ElMessage.error('获取配置组详情失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除配置组
|
|
||||||
const handleDelete = (row) => {
|
|
||||||
ElMessageBox.confirm(`确认删除配置组 "${row.name}" 吗?`, '警告', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}).then(async () => {
|
|
||||||
try {
|
|
||||||
const res = await deleteSettingGroup({ setting_group_id: row.id })
|
|
||||||
console.log('删除配置组响应:', res.data)
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
ElMessage.success('删除成功')
|
|
||||||
fetchGroupList()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除失败:', error)
|
|
||||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
|
||||||
}
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除
|
|
||||||
const handleBatchDelete = () => {
|
|
||||||
if (selectedRows.value.length === 0) {
|
|
||||||
ElMessage.warning('请至少选择一条记录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}).then(async () => {
|
|
||||||
try {
|
|
||||||
const deletePromises = selectedRows.value.map(row =>
|
|
||||||
deleteSettingGroup({ setting_group_id: row.id })
|
|
||||||
)
|
|
||||||
await Promise.all(deletePromises)
|
|
||||||
ElMessage.success('批量删除成功')
|
|
||||||
fetchGroupList()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量删除失败:', error)
|
|
||||||
ElMessage.error('批量删除失败')
|
|
||||||
}
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const submitForm = () => {
|
|
||||||
groupFormRef.value?.validate(async (valid) => {
|
|
||||||
if (valid) {
|
|
||||||
try {
|
|
||||||
const submitData = {
|
|
||||||
name: groupForm.name,
|
|
||||||
note: groupForm.note
|
|
||||||
}
|
|
||||||
if (groupForm.id) {
|
|
||||||
submitData.id = groupForm.id
|
|
||||||
}
|
|
||||||
console.log('提交配置组数据:', submitData)
|
|
||||||
const res = groupForm.id
|
|
||||||
? await updateSettingGroup(submitData)
|
|
||||||
: await createSettingGroup(submitData)
|
|
||||||
if (res.data.code === 200) {
|
|
||||||
ElMessage.success(groupForm.id ? '修改成功' : '创建成功')
|
|
||||||
dialogVisible.value = false
|
|
||||||
fetchGroupList()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('提交失败:', error)
|
|
||||||
ElMessage.error(error.response?.data?.message || '提交失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
fetchGroupList()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.setting-group-container {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-container {
|
|
||||||
border: 1px solid #e1e8ed;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
padding: 0;
|
|
||||||
border-bottom: 1px solid #e1e8ed;
|
|
||||||
background: #fafbfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 20px;
|
|
||||||
gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form {
|
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form :deep(.el-form-item) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-section {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid #e1e8ed;
|
|
||||||
background: #fafbfc;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格样式优化 */
|
|
||||||
:deep(.el-table) {
|
|
||||||
border: none;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table__header) {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table th) {
|
|
||||||
background: #f8f9fa !important;
|
|
||||||
border-bottom: 2px solid #e1e8ed;
|
|
||||||
color: #2c3e50;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table td) {
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
color: #34495e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table tr:hover > td) {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-card__body) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="type" label="文件类型" width="120">
|
<el-table-column prop="type" label="文件类型" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getFileTypeColor(row.type)">
|
<el-tag :type="getFileTypeColor(row.type, row.url, row.realName)">
|
||||||
{{ row.type || '未知' }}
|
{{ row.type || '未知' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<div class="preview-label">文件预览</div>
|
<div class="preview-label">文件预览</div>
|
||||||
<div class="preview-content">
|
<div class="preview-content">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="isImageFile(fileDetail.type) && fileDetail.url"
|
v-if="isImageFile(fileDetail.type, fileDetail.url, fileDetail.realName) && fileDetail.url"
|
||||||
:src="fileDetail.url"
|
:src="fileDetail.url"
|
||||||
fit="contain"
|
fit="contain"
|
||||||
style="max-width: 100%; max-height: 400px; border-radius: 8px;"
|
style="max-width: 100%; max-height: 400px; border-radius: 8px;"
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
<el-descriptions-item label="真实文件名" label-align="right" :span="2">{{ fileDetail.realName }}</el-descriptions-item>
|
<el-descriptions-item label="真实文件名" label-align="right" :span="2">{{ fileDetail.realName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="保存名称" label-align="right">{{ fileDetail.saveName }}</el-descriptions-item>
|
<el-descriptions-item label="保存名称" label-align="right">{{ fileDetail.saveName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="文件类型" label-align="right">
|
<el-descriptions-item label="文件类型" label-align="right">
|
||||||
<el-tag :type="getFileTypeColor(fileDetail.type)">{{ fileDetail.type || '未知' }}</el-tag>
|
<el-tag :type="getFileTypeColor(fileDetail.type, fileDetail.url, fileDetail.realName)">{{ fileDetail.type || '未知' }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="文件大小" label-align="right">{{ formatFileSize(fileDetail.size) }}</el-descriptions-item>
|
<el-descriptions-item label="文件大小" label-align="right">{{ formatFileSize(fileDetail.size) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="是否公开" label-align="right">
|
<el-descriptions-item label="是否公开" label-align="right">
|
||||||
@@ -302,14 +302,37 @@ const uploadForm = reactive({
|
|||||||
const uploadFileList = ref([])
|
const uploadFileList = ref([])
|
||||||
|
|
||||||
// 判断是否为图片文件
|
// 判断是否为图片文件
|
||||||
const isImageFile = (type) => {
|
const isImageFile = (type, url, realName) => {
|
||||||
const imageTypes = ['cover', 'image', 'avatar', 'photo', 'picture']
|
// 检查type字段
|
||||||
return imageTypes.includes(type?.toLowerCase())
|
const imageTypes = ['cover', 'image', 'avatar', 'photo', 'picture', 'work_order']
|
||||||
|
if (type && imageTypes.includes(type?.toLowerCase())) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件扩展名
|
||||||
|
if (realName) {
|
||||||
|
const extension = realName.split('.').pop()?.toLowerCase()
|
||||||
|
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico']
|
||||||
|
if (extension && imageExtensions.includes(extension)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查URL中的文件扩展名
|
||||||
|
if (url) {
|
||||||
|
const urlExtension = url.split('.').pop()?.toLowerCase().split('?')[0]
|
||||||
|
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico']
|
||||||
|
if (urlExtension && imageExtensions.includes(urlExtension)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取文件类型颜色
|
// 获取文件类型颜色
|
||||||
const getFileTypeColor = (type) => {
|
const getFileTypeColor = (type, url, realName) => {
|
||||||
if (isImageFile(type)) return 'success'
|
if (isImageFile(type, url, realName)) return 'success'
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
'document': 'primary',
|
'document': 'primary',
|
||||||
'video': 'warning',
|
'video': 'warning',
|
||||||
@@ -394,8 +417,12 @@ const handleView = async (row) => {
|
|||||||
const res = await getFileDetail({ file_id: row.id })
|
const res = await getFileDetail({ file_id: row.id })
|
||||||
console.log('文件详情数据:', res.data)
|
console.log('文件详情数据:', res.data)
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
fileDetail.value = res.data.data.data
|
// 确保正确设置文件详情和URL
|
||||||
fileDetail.value.url = res.data.data.url
|
const fileData = res.data.data.data || res.data.data
|
||||||
|
fileDetail.value = {
|
||||||
|
...fileData,
|
||||||
|
url: fileData.url || res.data.data.url || ''
|
||||||
|
}
|
||||||
detailDialogVisible.value = true
|
detailDialogVisible.value = true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -510,23 +537,69 @@ const handleRemoveFile = (file, fileList) => {
|
|||||||
uploadFileList.value = fileList
|
uploadFileList.value = fileList
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交上传
|
// 提交上传(批量上传:将所有文件合并为一次请求)
|
||||||
const handleSubmitUpload = () => {
|
const handleSubmitUpload = async () => {
|
||||||
if (uploadFileList.value.length === 0) {
|
if (uploadFileList.value.length === 0) {
|
||||||
ElMessage.warning('请至少选择一个文件')
|
ElMessage.warning('请至少选择一个文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 触发所有待上传文件的上传
|
|
||||||
const filesToUpload = uploadFileList.value.filter(file =>
|
// 筛选待上传的有效文件
|
||||||
file.status !== 'success' && file.status !== 'uploading'
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']
|
||||||
)
|
const filesToUpload = uploadFileList.value.filter(file => {
|
||||||
|
if (file.status === 'success') return false
|
||||||
|
const raw = file.raw
|
||||||
|
if (!raw) return false
|
||||||
|
const isValidType = validTypes.includes(raw.type)
|
||||||
|
const isLt10M = raw.size / 1024 / 1024 < 10
|
||||||
|
if (!isValidType) {
|
||||||
|
ElMessage.warning(`文件 ${raw.name} 格式不符合要求,已跳过`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!isLt10M) {
|
||||||
|
ElMessage.warning(`文件 ${raw.name} 大小超过 10MB,已跳过`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if (filesToUpload.length === 0) {
|
if (filesToUpload.length === 0) {
|
||||||
ElMessage.info('所有文件已上传完成')
|
ElMessage.info('没有可上传的有效文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 逐个提交文件
|
|
||||||
uploadRef.value?.submit()
|
|
||||||
|
|
||||||
|
// 构建 FormData,多个 file_names 和 files 条目在同一请求中
|
||||||
|
const formData = new FormData()
|
||||||
|
filesToUpload.forEach(file => {
|
||||||
|
formData.append('file_names', file.raw.name)
|
||||||
|
formData.append('files', file.raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加上传类型
|
||||||
|
if (uploadForm.update_type) {
|
||||||
|
formData.append('update_type', uploadForm.update_type)
|
||||||
|
}
|
||||||
|
// 添加是否开放下载
|
||||||
|
formData.append('open_down', uploadForm.open_down ? 'true' : 'false')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(formData)
|
||||||
|
|
||||||
|
if (res && res.data && res.data.code === 200) {
|
||||||
|
ElMessage.success(`成功上传 ${filesToUpload.length} 个文件`)
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadDialogVisible.value = false
|
||||||
|
uploadFileList.value = []
|
||||||
|
fetchFileList()
|
||||||
|
}, 500)
|
||||||
|
} else {
|
||||||
|
const errorMsg = res?.data?.message || res?.data?.msg || '上传失败'
|
||||||
|
ElMessage.error(errorMsg)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量上传失败:', error)
|
||||||
|
ElMessage.error(error?.response?.data?.message || error?.message || '上传失败,请重试')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传前检查(只做提示,不阻止文件添加到列表)
|
// 上传前检查(只做提示,不阻止文件添加到列表)
|
||||||
@@ -541,84 +614,21 @@ const beforeUpload = (file) => {
|
|||||||
if (!isLt10M) {
|
if (!isLt10M) {
|
||||||
ElMessage.warning(`文件 ${file.name} 大小超过 10MB`)
|
ElMessage.warning(`文件 ${file.name} 大小超过 10MB`)
|
||||||
}
|
}
|
||||||
// 允许文件添加到列表,在上传时再进行验证
|
// 允许文件添加到列表,在提交时再进行验证
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义上传方法
|
// 自定义上传方法(保留为空壳,实际上传由 handleSubmitUpload 批量处理)
|
||||||
const handleCustomUpload = async (options) => {
|
const handleCustomUpload = async (options) => {
|
||||||
const { file, onSuccess, onError } = options
|
// 不做任何操作,所有上传由 handleSubmitUpload 统一批量处理
|
||||||
console.log('开始上传文件:', file)
|
// el-upload 的 auto-upload 已设为 false,此方法不会被自动调用
|
||||||
|
|
||||||
// 在上传前进行验证
|
|
||||||
const isValidType = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'].includes(file.type)
|
|
||||||
const isLt10M = file.size / 1024 / 1024 < 10
|
|
||||||
|
|
||||||
if (!isValidType) {
|
|
||||||
const error = new Error(`文件 ${file.name} 格式不符合要求(仅支持 JPG/PNG/GIF/PDF/DOC/DOCX)`)
|
|
||||||
// 标记为校验类错误,on-error 中不再弹 error 提示
|
|
||||||
error.isValidation = true
|
|
||||||
onError(error, file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isLt10M) {
|
|
||||||
const error = new Error(`文件 ${file.name} 大小超过 10MB`)
|
|
||||||
error.isValidation = true
|
|
||||||
onError(error, file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
|
|
||||||
// 根据 API 文档,字段名应该是 files(复数)
|
|
||||||
formData.append('files', file)
|
|
||||||
|
|
||||||
// 添加文件名列表(虽然 API 文档说是数组,但实际传递时直接传字符串)
|
|
||||||
formData.append('file_names', file.name)
|
|
||||||
|
|
||||||
// 添加上传类型
|
|
||||||
if (uploadForm.update_type) {
|
|
||||||
formData.append('update_type', uploadForm.update_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加是否开放下载
|
|
||||||
formData.append('open_down', uploadForm.open_down ? '1' : '0')
|
|
||||||
|
|
||||||
console.log('上传参数:', {
|
|
||||||
files: file.name,
|
|
||||||
file_names: [file.name],
|
|
||||||
update_type: uploadForm.update_type,
|
|
||||||
open_down: uploadForm.open_down
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await uploadFile(formData)
|
|
||||||
console.log('上传响应:', res)
|
|
||||||
|
|
||||||
// 根据返回码严格区分成功和失败
|
|
||||||
if (res && res.data && res.data.code === 200) {
|
|
||||||
onSuccess(res.data.data, file)
|
|
||||||
} else {
|
|
||||||
const errorMsg = res?.data?.message || res?.data?.msg || '上传失败'
|
|
||||||
const error = new Error(errorMsg)
|
|
||||||
onError(error, file)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('上传文件失败:', error)
|
|
||||||
const err = new Error(error?.response?.data?.message || error?.message || '上传失败')
|
|
||||||
onError(err, file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传成功
|
// 上传成功
|
||||||
const handleUploadSuccess = (response, file, fileList) => {
|
const handleUploadSuccess = (response, file, fileList) => {
|
||||||
console.log('上传成功文件:', file)
|
|
||||||
console.log('上传成功文件列表:',fileList)
|
|
||||||
|
|
||||||
|
|
||||||
// 成功回调只会在 code === 200 时触发
|
|
||||||
// ElMessage.success(`文件 ${file.name} 上传成功`)
|
|
||||||
// 更新文件列表状态
|
|
||||||
uploadFileList.value = fileList
|
uploadFileList.value = fileList
|
||||||
// 如果所有文件都上传成功,关闭对话框并刷新列表
|
// 如果所有文件都上传成功,关闭对话框并刷新列表
|
||||||
const allSuccess = fileList.every(f => f.status === 'success')
|
const allSuccess = fileList.every(f => f.status === 'success')
|
||||||
|
|||||||
@@ -528,33 +528,6 @@ onMounted(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格样式优化 */
|
|
||||||
:deep(.el-table) {
|
|
||||||
border: none;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table__header) {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table th) {
|
|
||||||
background: #f8f9fa !important;
|
|
||||||
border-bottom: 2px solid #e1e8ed;
|
|
||||||
color: #2c3e50;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table td) {
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
color: #34495e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table tr:hover > td) {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-card__body) {
|
:deep(.el-card__body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,927 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ticket-list-page">
|
||||||
|
<!-- 顶部状态标签栏 -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status-tabs">
|
||||||
|
<div class="tab-item pending" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')">
|
||||||
|
待处理 <span class="count">{{ stats.pending }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tab-item processing" :class="{ active: activeStatus === 'processing' }" @click="filterByStatus('processing')">
|
||||||
|
处理中 <span class="count">{{ stats.processing }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tab-item replied" :class="{ active: activeStatus === 'replied' }" @click="filterByStatus('replied')">
|
||||||
|
已回复 <span class="count">{{ stats.replied }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tab-item completed" :class="{ active: activeStatus === 'completed' }" @click="filterByStatus('completed')">
|
||||||
|
已完成 <span class="count">{{ stats.completed }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tab-item" :class="{ active: activeStatus === '' }" @click="filterByStatus('')">
|
||||||
|
全部 <span class="count">{{ stats.total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 筛选工具栏 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="sortBy" placeholder="排序方式" clearable style="width: 140px" @change="handleSortChange">
|
||||||
|
<el-option label="不排序" value="" />
|
||||||
|
<el-option label="创建时间" value="created_at" />
|
||||||
|
<el-option label="更新时间" value="updated_at" />
|
||||||
|
<el-option label="工单号" value="id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="sortOrder" placeholder="排序顺序" clearable style="width: 100px" @change="handleSortChange">
|
||||||
|
<el-option label="默认" value="" />
|
||||||
|
<el-option label="降序" value="desc" />
|
||||||
|
<el-option label="升序" value="asc" />
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
:model-value="selectedUser ? selectedUser.user_name : ''"
|
||||||
|
placeholder="点击选择用户筛选"
|
||||||
|
readonly
|
||||||
|
style="width: 180px; cursor: pointer"
|
||||||
|
@click="showUserDialog = true"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #suffix v-if="selectedUser">
|
||||||
|
<el-icon @click.stop="clearUserFilter" style="cursor: pointer"><Close /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索工单标题/内容"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@input="handleKeywordSearch"
|
||||||
|
@clear="handleKeywordSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button icon="Refresh" @click="refreshList">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工单表格(PC端) -->
|
||||||
|
<el-table
|
||||||
|
v-loading="isLoading"
|
||||||
|
:data="filteredTickets"
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
class="desktop-table"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="工单号" width="100" />
|
||||||
|
<el-table-column label="用户" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="user-info">
|
||||||
|
<el-avatar :size="32" :src="row.avatar">{{ row.username?.charAt(0) }}</el-avatar>
|
||||||
|
<el-link v-if="row.userId" type="primary" :underline="false" @click.stop="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.username }}</el-link>
|
||||||
|
<span v-else class="username">{{ row.username }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="title" label="工单标题" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.status)" size="small">
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||||
|
<el-table-column prop="lastReplyTime" label="最后回复" width="180" />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" size="small" @click.stop="goToDetail(row)">
|
||||||
|
回复
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status !== 'completed'"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleComplete(row)"
|
||||||
|
>
|
||||||
|
结束
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 移动端卡片列表 -->
|
||||||
|
<div class="mobile-ticket-list" v-loading="isLoading">
|
||||||
|
<div
|
||||||
|
v-for="ticket in filteredTickets"
|
||||||
|
:key="ticket.id"
|
||||||
|
class="ticket-card"
|
||||||
|
@click="goToDetail(ticket)"
|
||||||
|
>
|
||||||
|
<div class="ticket-card-header">
|
||||||
|
<span class="ticket-card-id">#{{ ticket.id }}</span>
|
||||||
|
<el-tag :type="getStatusType(ticket.status)" size="small">
|
||||||
|
{{ getStatusText(ticket.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-card-user">
|
||||||
|
<el-avatar :size="28" :src="ticket.avatar">{{ ticket.username?.charAt(0) }}</el-avatar>
|
||||||
|
<span class="ticket-card-username">{{ ticket.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-card-title">{{ ticket.title }}</div>
|
||||||
|
<div class="ticket-card-footer">
|
||||||
|
<span class="ticket-card-time">{{ ticket.createTime }}</span>
|
||||||
|
<div class="ticket-card-actions">
|
||||||
|
<el-button type="primary" size="small" @click.stop="goToDetail(ticket)">回复</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="ticket.status !== 'completed'"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleComplete(ticket)"
|
||||||
|
>结束</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="filteredTickets.length === 0 && !isLoading" description="暂无工单数据" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="totalCount"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户选择对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showUserDialog"
|
||||||
|
title="选择用户"
|
||||||
|
width="600px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div class="user-dialog-content">
|
||||||
|
<el-input
|
||||||
|
v-model="userSearchKeyword"
|
||||||
|
placeholder="输入用户名/手机号/邮箱搜索"
|
||||||
|
clearable
|
||||||
|
@input="handleUserSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<div class="user-list-container" v-loading="isSearchingUser">
|
||||||
|
<!-- 调试信息 -->
|
||||||
|
<div style="padding: 8px; font-size: 12px; color: #909399; border-bottom: 1px solid #eee;">
|
||||||
|
搜索关键词: {{ userSearchKeyword }} | 用户数量: {{ userList.length }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!userSearchKeyword" class="empty-hint">
|
||||||
|
请输入关键词搜索用户
|
||||||
|
</div>
|
||||||
|
<div v-else-if="userSearchKeyword && userList.length === 0 && !isSearchingUser" class="empty-hint">
|
||||||
|
未找到匹配的用户
|
||||||
|
</div>
|
||||||
|
<div v-if="userList.length > 0" class="user-list">
|
||||||
|
<div
|
||||||
|
v-for="user in userList"
|
||||||
|
:key="user.user_id"
|
||||||
|
class="user-list-item"
|
||||||
|
@click="selectUser(user)"
|
||||||
|
>
|
||||||
|
<el-avatar :size="40" :src="user.cover">{{ user.user_name?.charAt(0) }}</el-avatar>
|
||||||
|
<div class="user-list-info">
|
||||||
|
<div class="user-list-name">{{ user.user_name }}</div>
|
||||||
|
<div class="user-list-sub">
|
||||||
|
<span v-if="user.phone">手机: {{ user.phone }}</span>
|
||||||
|
<span v-else-if="user.email">邮箱: {{ user.email }}</span>
|
||||||
|
<span v-else>UID: {{ user.user_id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-icon class="user-list-arrow"><ArrowRight /></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search, User, Close, ArrowRight } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
getTickerList,
|
||||||
|
closeTicket,
|
||||||
|
getTicketCount
|
||||||
|
} from '@/api/ticket'
|
||||||
|
import { getUserList } from '@/api/admin/user'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
// 工单数据
|
||||||
|
const ticketList = ref([])
|
||||||
|
const activeStatus = ref('pending') // 默认选中"待处理"
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const keywordSearchTimer = ref(null)
|
||||||
|
|
||||||
|
// 用户搜索
|
||||||
|
const userSearchKeyword = ref('')
|
||||||
|
const userList = ref([])
|
||||||
|
const selectedUser = ref(null)
|
||||||
|
const showUserDialog = ref(false)
|
||||||
|
const isSearchingUser = ref(false)
|
||||||
|
const userSearchTimer = ref(null)
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
const sortBy = ref('') // 默认不排序
|
||||||
|
const sortOrder = ref('') // 默认不选择排序顺序
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const stats = reactive({
|
||||||
|
pending: 0,
|
||||||
|
processing: 0,
|
||||||
|
replied: 0,
|
||||||
|
completed: 0,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 自动刷新定时器
|
||||||
|
const autoRefreshTimer = ref(null)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 状态转换
|
||||||
|
const convertStatusToString = (status) => {
|
||||||
|
const statusMap = { 0: 'pending', 1: 'processing', 2: 'replied', 3: 'completed' }
|
||||||
|
return statusMap[status] || 'processing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const statusMap = { pending: '待处理', processing: '处理中', replied: '已回复', completed: '已完成' }
|
||||||
|
return statusMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (status) => {
|
||||||
|
const typeMap = { pending: 'warning', processing: 'primary', replied: 'info', completed: 'success' }
|
||||||
|
return typeMap[status] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取工单列表
|
||||||
|
const fetchTicketList = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
let statusParam = ''
|
||||||
|
if (activeStatus.value) {
|
||||||
|
const statusMap = { pending: '0', processing: '1', replied: '2', completed: '3' }
|
||||||
|
statusParam = statusMap[activeStatus.value] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('调用getTickerList,排序参数:', { sortBy: sortBy.value, sortOrder: sortOrder.value })
|
||||||
|
const res = await getTickerList(
|
||||||
|
pageSize.value,
|
||||||
|
currentPage.value,
|
||||||
|
statusParam,
|
||||||
|
sortBy.value,
|
||||||
|
sortOrder.value,
|
||||||
|
selectedUser.value?.user_id,
|
||||||
|
searchKeyword.value.trim()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (res.code === 200) {
|
||||||
|
ticketList.value = (res.data.data || []).map(item => ({
|
||||||
|
id: item.work_id,
|
||||||
|
title: item.name,
|
||||||
|
username: item.user?.userName || `用户${item.user?.userId || 'Unknown'}`,
|
||||||
|
userId: item.user?.userId,
|
||||||
|
avatar: item.user?.coverUrl || '',
|
||||||
|
createTime: new Date(item.created_at).toLocaleString(),
|
||||||
|
lastReplyTime: new Date(item.update_time).toLocaleString(),
|
||||||
|
status: convertStatusToString(item.status)
|
||||||
|
}))
|
||||||
|
totalCount.value = res.data.all_count || 0
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '获取工单列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取工单列表出错:', error)
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取统计数据
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getTicketCount()
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data
|
||||||
|
stats.total = data.all_count
|
||||||
|
stats.pending = data.wait_count
|
||||||
|
stats.replied = data.reply_count
|
||||||
|
stats.completed = data.close_count
|
||||||
|
stats.processing = data.all_count - data.wait_count - data.reply_count - data.close_count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计数据出错:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤后的工单列表
|
||||||
|
const filteredTickets = computed(() => ticketList.value)
|
||||||
|
|
||||||
|
// 用户搜索
|
||||||
|
const handleUserSearch = () => {
|
||||||
|
if (userSearchTimer.value) {
|
||||||
|
clearTimeout(userSearchTimer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = userSearchKeyword.value.trim()
|
||||||
|
if (!keyword) {
|
||||||
|
userList.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userSearchTimer.value = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
isSearchingUser.value = true
|
||||||
|
const res = await getUserList({ page: 1, count: 10, key: keyword })
|
||||||
|
|
||||||
|
console.log('用户搜索响应:', res)
|
||||||
|
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
// 注意:响应结构是 res.data.data.data
|
||||||
|
userList.value = res.data.data?.data || []
|
||||||
|
console.log('用户列表更新:', userList.value)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.data?.message || '搜索用户失败')
|
||||||
|
userList.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索用户出错:', error)
|
||||||
|
ElMessage.error('搜索用户失败')
|
||||||
|
userList.value = []
|
||||||
|
} finally {
|
||||||
|
isSearchingUser.value = false
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择用户
|
||||||
|
const selectUser = (user) => {
|
||||||
|
selectedUser.value = user
|
||||||
|
showUserDialog.value = false
|
||||||
|
userSearchKeyword.value = ''
|
||||||
|
userList.value = []
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTicketList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除用户筛选
|
||||||
|
const clearUserFilter = () => {
|
||||||
|
selectedUser.value = null
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTicketList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
|
const handleKeywordSearch = () => {
|
||||||
|
if (keywordSearchTimer.value) {
|
||||||
|
clearTimeout(keywordSearchTimer.value)
|
||||||
|
}
|
||||||
|
keywordSearchTimer.value = setTimeout(() => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTicketList()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按状态过滤
|
||||||
|
const filterByStatus = (status) => {
|
||||||
|
if (activeStatus.value === status) return
|
||||||
|
activeStatus.value = status
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTicketList()
|
||||||
|
|
||||||
|
// 切换状态时重新设置定时器
|
||||||
|
stopAutoRefresh()
|
||||||
|
if (status === 'pending') {
|
||||||
|
startAutoRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序变化处理
|
||||||
|
const handleSortChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTicketList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTicketList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = () => {
|
||||||
|
fetchTicketList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新列表
|
||||||
|
const refreshList = () => {
|
||||||
|
fetchTicketList()
|
||||||
|
fetchStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到详情页
|
||||||
|
const goToDetail = (row) => {
|
||||||
|
router.push({ path: '/ticket/detail', query: { id: row.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = (row) => {
|
||||||
|
goToDetail(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束工单
|
||||||
|
const handleComplete = (ticket) => {
|
||||||
|
ElMessageBox.confirm('确定要结束此工单吗?结束后将无法继续回复。', '确认操作', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await closeTicket(ticket.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('工单已成功结束')
|
||||||
|
refreshList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '结束工单失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('网络错误,请稍后重试')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动自动刷新(仅在待处理状态)
|
||||||
|
const startAutoRefresh = () => {
|
||||||
|
if (autoRefreshTimer.value) return
|
||||||
|
|
||||||
|
autoRefreshTimer.value = setInterval(() => {
|
||||||
|
if (activeStatus.value === 'pending') {
|
||||||
|
// 静默刷新,不显示loading
|
||||||
|
const originalLoading = isLoading.value
|
||||||
|
fetchTicketList().finally(() => {
|
||||||
|
isLoading.value = originalLoading
|
||||||
|
})
|
||||||
|
fetchStats()
|
||||||
|
}
|
||||||
|
}, 30000) // 30秒
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止自动刷新
|
||||||
|
const stopAutoRefresh = () => {
|
||||||
|
if (autoRefreshTimer.value) {
|
||||||
|
clearInterval(autoRefreshTimer.value)
|
||||||
|
autoRefreshTimer.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFirstLoad = true
|
||||||
|
|
||||||
|
// 监听对话框关闭,清空搜索状态
|
||||||
|
watch(showUserDialog, (newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
// 对话框关闭时清空搜索
|
||||||
|
userSearchKeyword.value = ''
|
||||||
|
userList.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTicketList()
|
||||||
|
fetchStats()
|
||||||
|
|
||||||
|
// 如果默认是待处理状态,启动自动刷新
|
||||||
|
if (activeStatus.value === 'pending') {
|
||||||
|
startAutoRefresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当页面被激活时(从详情页返回时)
|
||||||
|
onActivated(() => {
|
||||||
|
// 跳过首次加载,只在从其他页面返回时刷新
|
||||||
|
if (!isFirstLoad) {
|
||||||
|
refreshList()
|
||||||
|
}
|
||||||
|
isFirstLoad = false
|
||||||
|
|
||||||
|
// 重新启动自动刷新(如果是待处理状态)
|
||||||
|
if (activeStatus.value === 'pending') {
|
||||||
|
startAutoRefresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时清理定时器
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopAutoRefresh()
|
||||||
|
if (userSearchTimer.value) {
|
||||||
|
clearTimeout(userSearchTimer.value)
|
||||||
|
}
|
||||||
|
if (keywordSearchTimer.value) {
|
||||||
|
clearTimeout(keywordSearchTimer.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ticket-list-page {
|
||||||
|
padding: 0;
|
||||||
|
height: calc(100vh - 100px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 14px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
transition: all 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
background: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.pending.active { background: #e6a23c; }
|
||||||
|
.tab-item.processing.active { background: #409eff; }
|
||||||
|
.tab-item.replied.active { background: #909399; }
|
||||||
|
.tab-item.completed.active { background: #67c23a; }
|
||||||
|
|
||||||
|
.tab-item .count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dialog-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-container {
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-item:hover {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-arrow {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table tr) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端卡片列表 */
|
||||||
|
.mobile-ticket-list {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card:active {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card-id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card-username {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 大屏平板尺寸响应式样式 (1020px - 1280px) */
|
||||||
|
@media (max-width: 1280px) and (min-width: 1021px) {
|
||||||
|
.filter-bar {
|
||||||
|
padding: 10px 16px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-select {
|
||||||
|
width: 120px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-input {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .el-table__cell) {
|
||||||
|
padding: 10px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平板尺寸响应式样式 (769px - 1020px) */
|
||||||
|
@media (max-width: 1020px) and (min-width: 769px) {
|
||||||
|
.status-bar {
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs::-webkit-scrollbar-thumb {
|
||||||
|
background: #dcdfe6;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
padding: 10px 16px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-select {
|
||||||
|
width: 120px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .el-table__cell) {
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端响应式样式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ticket-list-page {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-select,
|
||||||
|
.filter-bar .el-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏PC端表格,显示移动端卡片 */
|
||||||
|
:deep(.el-table) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-ticket-list {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
padding: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination__sizes),
|
||||||
|
.pagination-wrapper :deep(.el-pagination__jump) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户选择弹窗移动端适配 */
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
width: 90% !important;
|
||||||
|
margin: 5vh auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.tab-item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item .count {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user