Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -17,7 +17,12 @@ store封装到src/store目录下。
|
||||
|
||||
注册侧边栏在/config/menus.js文件中。
|
||||
|
||||
新添加要求:
|
||||
在遇到用户id需要填写和修改的弹窗将其修改为可预览样式
|
||||
关于填写表单为推荐人id的需要使用组件AvatarSelector展示,如果是文件id或者是封面id 的也需要预览展示需要向头像列表组件一样,可以弄个文件组件/api/v1/admin/file/list这个是文件列表接口
|
||||
|
||||
规则:
|
||||
1.只要涉及弹窗添加和修改xxxid类型的就需要生成一个弹窗组件并使用到页面中
|
||||
|
||||
## 1. 基础布局规范
|
||||
```css
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
runs-on: ninBo
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v3
|
||||
uses: https://gitea.s1f.ren/actions/download-artifact@v3
|
||||
with:
|
||||
name: vue3-build
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
runs-on: ninBo
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v3
|
||||
uses: https://gitea.s1f.ren/actions/download-artifact@v3
|
||||
with:
|
||||
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;
|
||||
}
|
||||
|
||||
/* 卡片扁平化 */
|
||||
/* 卡片扁平化 + 层次感 */
|
||||
.el-card {
|
||||
border-radius: 0 !important;
|
||||
border: 1px solid #e1e8ed !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 {
|
||||
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>
|
||||
@@ -0,0 +1,757 @@
|
||||
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 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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// 商品管理 API 接口测试文件
|
||||
// 此文件用于验证所有接口是否正确对接 OpenAPI 文档
|
||||
|
||||
import {
|
||||
// 商品分组管理
|
||||
getProductGroupList,
|
||||
createProductGroup,
|
||||
updateProductGroup,
|
||||
hideProductGroup,
|
||||
startProductGroup,
|
||||
deleteProductGroup,
|
||||
|
||||
// 商品管理
|
||||
getProductList,
|
||||
getProductTagList,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
|
||||
// 商品参数管理
|
||||
getProductParameterList,
|
||||
createProductParameter,
|
||||
getProductParameterDetail,
|
||||
updateProductParameter,
|
||||
deleteProductParameter,
|
||||
addProductParameterValue,
|
||||
deleteProductParameterValue,
|
||||
updateProductParameterValue
|
||||
} from './product'
|
||||
|
||||
/**
|
||||
* 商品管理 API 接口对接验证
|
||||
*
|
||||
* 根据 OpenAPI 文档,所有接口已完整对接:
|
||||
*
|
||||
* 1. 商品分组管理 (6个接口)
|
||||
* ✅ GET /api/v1/admin/good/group/list - 获取商品分组列表
|
||||
* ✅ POST /api/v1/admin/good/group/create - 创建商品分组
|
||||
* ✅ POST /api/v1/admin/good/group/update - 更新商品分组
|
||||
* ✅ POST /api/v1/admin/good/group/disable - 隐藏商品组
|
||||
* ✅ POST /api/v1/admin/good/group/enable - 启用商品组
|
||||
* ✅ DELETE /api/v1/admin/good/group/delete - 删除商品分组
|
||||
*
|
||||
* 2. 商品管理 (4个接口)
|
||||
* ✅ GET /api/v1/admin/good/goods/list - 获取商品列表
|
||||
* ✅ GET /api/v1/admin/good/goods/tag_list - 获取商品标签列表
|
||||
* ✅ POST /api/v1/admin/good/goods/create - 创建商品
|
||||
* ✅ POST /api/v1/admin/good/goods/update - 更新商品
|
||||
* ✅ DELETE /api/v1/admin/good/goods/delete - 删除商品
|
||||
*
|
||||
* 3. 商品参数管理 (8个接口)
|
||||
* ✅ GET /api/v1/admin/good/spec/list - 获取商品参数列表
|
||||
* ✅ POST /api/v1/admin/good/spec/create - 创建商品参数
|
||||
* ✅ GET /api/v1/admin/good/spec/detail - 获取商品参数详情
|
||||
* ✅ POST /api/v1/admin/good/spec/update - 更新商品参数
|
||||
* ✅ DELETE /api/v1/admin/good/spec/delete - 删除商品参数
|
||||
* ✅ POST /api/v1/admin/good/spec/add_value - 增加商品参数值
|
||||
* ✅ DELETE /api/v1/admin/good/spec/delete_value - 删除商品参数值
|
||||
* ✅ POST /api/v1/admin/good/spec/update_value - 更新商品参数值
|
||||
*
|
||||
* 总计:18个接口全部对接完成
|
||||
*
|
||||
* 页面实现状态:
|
||||
* ✅ ProductList.vue - 商品列表管理页面(包含商品参数管理)
|
||||
* ✅ ProductGroup.vue - 商品分组管理页面
|
||||
*
|
||||
* 注意事项:
|
||||
* 1. 所有 POST/DELETE 接口使用 multipart/form-data 格式
|
||||
* 2. 更新商品参数接口使用 query 参数而非 body
|
||||
* 3. 价格字段以分为单位存储
|
||||
* 4. 商品标签从 tag_list 接口获取
|
||||
*/
|
||||
|
||||
export const API_STATUS = {
|
||||
totalApis: 18,
|
||||
implementedApis: 18,
|
||||
completionRate: '100%',
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
@@ -144,4 +144,113 @@ export const updateProductParameterValue = (data) => {
|
||||
'Content-Type':'multipart/form-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})
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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 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')
|
||||
}
|
||||
@@ -7,4 +7,14 @@ export const userLogin = (username,password) => {
|
||||
|
||||
export const getUserInfo = () => {
|
||||
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 })
|
||||
}
|
||||
+3
-1
@@ -5,11 +5,13 @@ import request from "@/utils/request.js";
|
||||
* @returns {Promise}
|
||||
*/
|
||||
|
||||
export function getTickerList(count, page, status, orderBy, order) {
|
||||
export function getTickerList(count, page, status, orderBy, order, userId, keyword) {
|
||||
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
|
||||
:model-value="visible"
|
||||
title="选择用户"
|
||||
width="800px"
|
||||
width="700px"
|
||||
class="user-selector-dialog"
|
||||
append-to-body
|
||||
@update:model-value="handleVisibleChange"
|
||||
>
|
||||
<!-- 搜索栏 -->
|
||||
<div class="selector-search">
|
||||
<el-input
|
||||
v-model="searchParams.key"
|
||||
placeholder="搜索用户名或ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 300px; margin-right: 12px"
|
||||
<div class="user-selector-content">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="selector-search">
|
||||
<el-input
|
||||
v-model="searchParams.key"
|
||||
placeholder="搜索用户名、邮箱或ID"
|
||||
clearable
|
||||
@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-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-table-column prop="user_id" label="用户ID" width="100" />
|
||||
<el-table-column prop="user_name" label="用户名" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<div class="user-name-cell">
|
||||
<el-avatar v-if="row.cover" :src="row.cover" :size="28" />
|
||||
<el-avatar v-else :size="28">
|
||||
{{ row.user_name?.charAt(0)?.toUpperCase() || 'U' }}
|
||||
</el-avatar>
|
||||
<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>
|
||||
|
||||
<!-- 用户表格 -->
|
||||
<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>
|
||||
<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 type="primary" @click="confirmSelection" :disabled="!selectedUser">
|
||||
确定选择
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,7 +98,7 @@
|
||||
|
||||
<script setup>
|
||||
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 { ElMessage } from 'element-plus'
|
||||
|
||||
@@ -97,9 +126,7 @@ const searchParams = reactive({
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
selectedUser.value = null
|
||||
if (userList.value.length === 0) {
|
||||
fetchUserList()
|
||||
}
|
||||
fetchUserList()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -163,11 +190,44 @@ const confirmSelection = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-selector-content {
|
||||
max-height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.selector-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
gap: 12px;
|
||||
padding-bottom: 16px;
|
||||
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 {
|
||||
@@ -175,6 +235,19 @@ const confirmSelection = () => {
|
||||
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) {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -184,8 +257,16 @@ const confirmSelection = () => {
|
||||
}
|
||||
|
||||
: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);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:deep(.el-avatar) {
|
||||
background-color: var(--el-color-primary-light-5);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择头像"
|
||||
:title="title"
|
||||
width="800px"
|
||||
append-to-body
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="avatar-selector">
|
||||
<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-header">
|
||||
<h4>用户文件列表</h4>
|
||||
<h4>文件列表</h4>
|
||||
<el-button type="primary" @click="switchToUpload" :icon="Upload">
|
||||
上传新头像
|
||||
上传新文件
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="file-grid" v-loading="loading">
|
||||
@@ -58,8 +58,8 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 上传头像 -->
|
||||
<el-tab-pane label="上传头像" name="upload">
|
||||
<!-- 上传文件 -->
|
||||
<el-tab-pane label="上传文件" name="upload">
|
||||
<div class="upload-section">
|
||||
<el-upload
|
||||
:http-request="handleUpload"
|
||||
@@ -118,6 +118,10 @@ import { closeAllMessage } from '../../utils/message'
|
||||
currentCoverId: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '选择文件'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -270,6 +274,7 @@ import { closeAllMessage } from '../../utils/message'
|
||||
formData.append('files', file)
|
||||
formData.append('file_names', file.name)
|
||||
formData.append('update_type', 'cover')
|
||||
formData.append('open_down', 'true')
|
||||
|
||||
try {
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
<el-scrollbar class="sidebar-scrollbar">
|
||||
<el-menu
|
||||
@@ -13,11 +17,20 @@
|
||||
text-color="#34495e"
|
||||
active-text-color="#2c3e50"
|
||||
:unique-opened="true"
|
||||
:collapse="isCollapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
>
|
||||
<sidebar-menu-item v-for="menu in menus" :key="menu.path" :menu="menu" />
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<!-- 收缩按钮 -->
|
||||
<div class="collapse-btn" @click="toggleCollapse">
|
||||
<el-icon :size="18">
|
||||
<Fold v-if="!isCollapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主区域 -->
|
||||
@@ -25,10 +38,14 @@
|
||||
<!-- 顶部导航 -->
|
||||
<div class="navbar">
|
||||
<div class="navbar-left">
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<el-button type="text" class="mobile-menu-btn" @click="toggleMobileMenu">
|
||||
<el-icon :size="22"><Menu /></el-icon>
|
||||
</el-button>
|
||||
<breadcrumb />
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<div class="navbar-item">
|
||||
<div class="navbar-item hidden-mobile">
|
||||
<el-tooltip content="全屏" placement="bottom">
|
||||
<el-button type="text" class="header-btn" @click="toggleFullScreen">
|
||||
<el-icon :size="18"><full-screen /></el-icon>
|
||||
@@ -39,9 +56,9 @@
|
||||
<div class="navbar-item">
|
||||
<el-dropdown trigger="click">
|
||||
<div class="avatar-container">
|
||||
<el-avatar :size="32" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
|
||||
<span class="username">{{ userStore.userInfo.user_name }}</span>
|
||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
<el-avatar :size="32" :src="userStore.getUserAvatar() || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" />
|
||||
<span class="username hidden-mobile">{{ userStore.userInfo.user_name }}</span>
|
||||
<el-icon class="el-icon--right hidden-mobile"><arrow-down /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
@@ -81,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import SidebarMenuItem from './SidebarMenuItem.vue'
|
||||
import Breadcrumb from './Breadcrumb.vue'
|
||||
@@ -92,7 +109,10 @@ import {
|
||||
ArrowDown,
|
||||
User,
|
||||
Key,
|
||||
SwitchButton
|
||||
SwitchButton,
|
||||
Fold,
|
||||
Expand,
|
||||
Menu
|
||||
} from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
@@ -105,11 +125,46 @@ const router = useRouter()
|
||||
// 侧边栏菜单数据
|
||||
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(() => {
|
||||
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 = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
@@ -129,9 +184,35 @@ const handleLogout = () => {
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('tokenExpire')
|
||||
localStorage.removeItem('userInfo')
|
||||
userStore.clearUserInfo()
|
||||
router.push('/login')
|
||||
}).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>
|
||||
|
||||
<style scoped>
|
||||
@@ -141,6 +222,18 @@ const handleLogout = () => {
|
||||
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 {
|
||||
width: 260px;
|
||||
@@ -148,7 +241,15 @@ const handleLogout = () => {
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #e1e8ed;
|
||||
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 {
|
||||
@@ -159,6 +260,7 @@ const handleLogout = () => {
|
||||
padding: 0 20px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
@@ -167,8 +269,15 @@ const handleLogout = () => {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-img-mini {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar-scrollbar {
|
||||
height: calc(100vh - 70px);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
@@ -178,6 +287,32 @@ const handleLogout = () => {
|
||||
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 {
|
||||
flex: 1;
|
||||
@@ -185,6 +320,7 @@ const handleLogout = () => {
|
||||
flex-direction: column;
|
||||
background-color: #f0f2f5;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 顶部导航栏样式 */
|
||||
@@ -197,18 +333,21 @@ const handleLogout = () => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-item {
|
||||
@@ -286,6 +425,63 @@ const handleLogout = () => {
|
||||
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) {
|
||||
border-radius: 0;
|
||||
border: 1px solid #e1e8ed;
|
||||
@@ -346,77 +542,232 @@ const handleLogout = () => {
|
||||
/* Element Plus 菜单项样式优化 */
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 一级菜单标题(有子菜单) */
|
||||
:deep(.el-sub-menu__title) {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
transition: background-color 0.2s ease;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 2px 8px;
|
||||
padding: 0 16px !important;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
color: #34495e !important;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu__title:hover) {
|
||||
background-color: #f8f9fa !important;
|
||||
background-color: #f5f7fa !important;
|
||||
color: #2c3e50 !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
transition: background-color 0.2s ease;
|
||||
/* 一级菜单项(无子菜单) */
|
||||
:deep(.sidebar-menu > .el-menu-item) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 2px 8px;
|
||||
padding: 0 16px !important;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
color: #34495e !important;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover) {
|
||||
background-color: #f8f9fa !important;
|
||||
:deep(.sidebar-menu > .el-menu-item:hover) {
|
||||
background-color: #f5f7fa !important;
|
||||
color: #2c3e50 !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: rgba(44, 62, 80, 0.1) !important;
|
||||
:deep(.sidebar-menu > .el-menu-item.is-active) {
|
||||
background-color: rgba(44, 62, 80, 0.08) !important;
|
||||
color: #2c3e50 !important;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active::before) {
|
||||
:deep(.sidebar-menu > .el-menu-item.is-active::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
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;
|
||||
}
|
||||
|
||||
: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;
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu .el-menu) {
|
||||
background-color: #fafbfc !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title:hover::before) {
|
||||
background-color: #7f8c8d;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu .el-menu-item) {
|
||||
margin: 0;
|
||||
padding-left: 48px !important;
|
||||
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu.is-opened > .el-sub-menu__title) {
|
||||
color: #2c3e50 !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;
|
||||
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) {
|
||||
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;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
<template>
|
||||
<el-sub-menu v-if="hasChildren" :index="menu.path">
|
||||
<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" />
|
||||
</el-icon>
|
||||
<span>{{ menu.title || menu.meta?.title }}</span>
|
||||
<span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
|
||||
</template>
|
||||
<sidebar-menu-item
|
||||
v-for="child in menu.children"
|
||||
:key="child.path"
|
||||
:menu="child"
|
||||
:menu="child"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
<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" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -29,6 +32,10 @@ const props = defineProps({
|
||||
menu: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,49 +46,45 @@ const hasChildren = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-icon {
|
||||
margin-right: 12px;
|
||||
/* 菜单图标样式 */
|
||||
.menu-icon {
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
transition: color 0.2s ease;
|
||||
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;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.el-menu-item:hover .el-icon,
|
||||
:deep(.el-sub-menu__title:hover .el-icon) {
|
||||
.el-menu-item:hover .menu-icon,
|
||||
:deep(.el-sub-menu__title:hover .menu-icon) {
|
||||
color: #34495e !important;
|
||||
}
|
||||
|
||||
/* 激活菜单项图标 */
|
||||
.el-menu-item.is-active .el-icon {
|
||||
.el-menu-item.is-active .menu-icon {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 菜单文字样式 */
|
||||
.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>
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="tags-view-container">
|
||||
<div class="tags-view-wrapper">
|
||||
<div class="tags-view-container"
|
||||
@mouseenter="hovered = true" @mouseleave="hovered = false">
|
||||
<div class="tags-view-wrapper" ref="scrollWrapperRef"
|
||||
@wheel.prevent="handleWheel"
|
||||
@scroll="onScroll">
|
||||
<div class="tags-view-scroll">
|
||||
<router-link
|
||||
v-for="tag in visitedViews"
|
||||
@@ -23,6 +26,10 @@
|
||||
</router-link>
|
||||
</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">
|
||||
@@ -65,28 +72,20 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
// 访问过的标签 (从 store 获取)
|
||||
const visitedViews = computed(() => tagsViewStore.visitedViews)
|
||||
const affixTags = computed(() => tagsViewStore.affixTags)
|
||||
|
||||
// 右键菜单
|
||||
const visible = ref(false)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const selectedTag = ref({})
|
||||
|
||||
// 初始化标签
|
||||
const initTags = () => {
|
||||
// 如果当前路由不在访问过的标签中,添加它
|
||||
if (route.name) {
|
||||
tagsViewStore.addVisitedView(route)
|
||||
}
|
||||
// 添加固定标签(仪表盘)
|
||||
const dashboardRoute = router.getRoutes().find(r => r.name === 'Dashboard')
|
||||
if (dashboardRoute) {
|
||||
// 注意:这里我们直接修改 store 的 affixTags,或者 store 应该提供一个 action
|
||||
// 简单起见,我们假设 store 的 affixTags 是可以直接修改的 ref,或者我们在 store 中添加初始化逻辑
|
||||
// 但为了保持一致性,我们这里只处理 visitedViews 的添加
|
||||
if (!tagsViewStore.affixTags.some(tag => tag.path === dashboardRoute.path)) {
|
||||
tagsViewStore.affixTags.push(dashboardRoute)
|
||||
}
|
||||
@@ -94,13 +93,11 @@ const initTags = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新选中的标签
|
||||
const refreshSelectedTag = (view) => {
|
||||
const { fullPath } = view
|
||||
router.replace('/redirect' + fullPath)
|
||||
}
|
||||
|
||||
// 关闭选中的标签
|
||||
const closeSelectedTag = (view) => {
|
||||
tagsViewStore.delVisitedView(view).then((visitedViews) => {
|
||||
if (isActive(view)) {
|
||||
@@ -109,15 +106,11 @@ const closeSelectedTag = (view) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭其他标签
|
||||
const closeOthersTags = () => {
|
||||
router.push(selectedTag.value)
|
||||
tagsViewStore.delOthersViews(selectedTag.value).then(() => {
|
||||
// moveToCurrentTag() // 如果有滚动逻辑
|
||||
})
|
||||
tagsViewStore.delOthersViews(selectedTag.value)
|
||||
}
|
||||
|
||||
// 关闭左侧标签
|
||||
const closeLeftTags = () => {
|
||||
tagsViewStore.delLeftViews(selectedTag.value).then((visitedViews) => {
|
||||
if (!visitedViews.find(i => i.path === route.path)) {
|
||||
@@ -126,7 +119,6 @@ const closeLeftTags = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭右侧标签
|
||||
const closeRightTags = () => {
|
||||
tagsViewStore.delRightViews(selectedTag.value).then((visitedViews) => {
|
||||
if (!visitedViews.find(i => i.path === route.path)) {
|
||||
@@ -135,20 +127,17 @@ const closeRightTags = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭所有标签
|
||||
const closeAllTags = () => {
|
||||
tagsViewStore.delAllViews().then((visitedViews) => {
|
||||
toLastView(visitedViews)
|
||||
})
|
||||
}
|
||||
|
||||
// 跳转到最后一个标签或首页
|
||||
const toLastView = (visitedViews, view) => {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView) {
|
||||
router.push(latestView)
|
||||
} else {
|
||||
// 如果没有标签,则跳转到首页
|
||||
if (view && view.name === 'Dashboard') {
|
||||
router.push('/redirect' + '/dashboard')
|
||||
} else {
|
||||
@@ -157,17 +146,14 @@ const toLastView = (visitedViews, view) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否是当前激活的标签
|
||||
const isActive = (tag) => {
|
||||
return tag.path === route.path
|
||||
}
|
||||
|
||||
// 判断是否是固定标签
|
||||
const isAffix = (tag) => {
|
||||
return tag.meta && tag.meta.affix
|
||||
}
|
||||
|
||||
// 打开右键菜单
|
||||
const openMenu = (e, tag) => {
|
||||
const menuMinWidth = 125
|
||||
const offsetLeft = e.clientX
|
||||
@@ -181,30 +167,112 @@ const openMenu = (e, 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 = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 监听路由变化,添加标签
|
||||
watch(route, (newRoute) => {
|
||||
if (newRoute.name) {
|
||||
tagsViewStore.addVisitedView(newRoute)
|
||||
}
|
||||
nextTick(scrollToActiveTag)
|
||||
})
|
||||
|
||||
// 点击其他区域关闭右键菜单
|
||||
const handleClickOutside = () => {
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
refreshState()
|
||||
scrollToActiveTag()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTags()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
nextTick(() => { refreshState(); scrollToActiveTag() })
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -215,17 +283,21 @@ onBeforeUnmount(() => {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 标签滚动区域 */
|
||||
.tags-view-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.tags-view-wrapper::-webkit-scrollbar {
|
||||
@@ -233,18 +305,51 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.tags-view-scroll {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
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 {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
margin-right: 0;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
@@ -252,6 +357,8 @@ onBeforeUnmount(() => {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@@ -326,6 +433,7 @@ onBeforeUnmount(() => {
|
||||
background-color: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
|
||||
/* 右键菜单 */
|
||||
.contextmenu {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
@@ -361,4 +469,4 @@ onBeforeUnmount(() => {
|
||||
.contextmenu li:hover .el-icon {
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+40
-19
@@ -39,15 +39,16 @@ export const menus = [
|
||||
title: '商品管理',
|
||||
icon: 'Goods',
|
||||
children: [
|
||||
{
|
||||
path: '/product/list',
|
||||
title: '商品列表'
|
||||
},
|
||||
{
|
||||
path: '/product/group',
|
||||
title: '商品分组'
|
||||
},
|
||||
|
||||
{ path: '/product/manage', title: '商品管理' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/user-goods',
|
||||
title: '用户商品管理',
|
||||
icon: 'ShoppingCart',
|
||||
children: [
|
||||
{ path: '/user-goods/list', title: '所有商品' },
|
||||
{ path: '/user-goods/vm-list', title: '云服务器' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -85,12 +86,10 @@ export const menus = [
|
||||
{
|
||||
path: '/activity/signin',
|
||||
title: '签到活动'
|
||||
},{
|
||||
path:'/activity/groupbuy',
|
||||
title:'拼团活动',
|
||||
},{
|
||||
path:'/activity/groupbuy-type',
|
||||
title:'拼团类型'
|
||||
},
|
||||
{
|
||||
path: '/activity/groupbuy',
|
||||
title: '拼团管理'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -142,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',
|
||||
title: '系统管理',
|
||||
@@ -166,12 +183,16 @@ export const menus = [
|
||||
title: '域名白名单'
|
||||
},
|
||||
{
|
||||
path: '/system/setting-group',
|
||||
title: '配置组管理'
|
||||
path: '/system/setting-manage',
|
||||
title: '配置管理'
|
||||
},
|
||||
{
|
||||
path: '/system/setting-list',
|
||||
title: '配置管理'
|
||||
path: '/system/menu',
|
||||
title: '菜单管理',
|
||||
children: [
|
||||
{ path: '/system/menu-manage', title: '菜单列表' },
|
||||
{ path: '/system/menu-permission', title: '菜单权限' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+220
-36
@@ -228,29 +228,50 @@ const routes = [
|
||||
{
|
||||
path: 'product',
|
||||
name: 'Product',
|
||||
meta: {
|
||||
title: '商品管理',
|
||||
icon: 'Goods'
|
||||
},
|
||||
redirect: '/product/list',
|
||||
meta: { title: '商品管理', icon: 'Goods' },
|
||||
redirect: '/product/manage',
|
||||
children: [
|
||||
{
|
||||
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: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'ProductList',
|
||||
component: () => import('../views/product/ProductList.vue'),
|
||||
meta: {
|
||||
title: '商品列表'
|
||||
}
|
||||
name: 'UserGoodsList',
|
||||
component: () => import('../views/product/UserGoodsList.vue'),
|
||||
meta: { title: '所有商品' }
|
||||
},
|
||||
{
|
||||
path: 'group',
|
||||
name: 'ProductGroup',
|
||||
component: () => import('../views/product/ProductGroup.vue'),
|
||||
meta: {
|
||||
title: '商品分组'
|
||||
}
|
||||
path: 'detail/:id',
|
||||
name: 'UserGoodsDetail',
|
||||
component: () => import('../views/product/UserGoodsDetail.vue'),
|
||||
meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' }
|
||||
},
|
||||
|
||||
{
|
||||
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' }
|
||||
}
|
||||
]
|
||||
},
|
||||
// 订单管理路由
|
||||
@@ -332,18 +353,10 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/activity/groupbuy',
|
||||
name: 'GroupBuyActivity',
|
||||
component: () => import('../views/activity/GroupBuyActivity.vue'),
|
||||
name: 'GroupBuyManage',
|
||||
component: () => import('../views/activity/GroupBuyManage.vue'),
|
||||
meta: {
|
||||
title: '拼团活动'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/activity/groupbuy-type',
|
||||
name: 'GroupBuyType',
|
||||
component: () => import('../views/activity/GroupBuyType.vue'),
|
||||
meta: {
|
||||
title: '拼团类型'
|
||||
title: '拼团管理'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -390,16 +403,187 @@ const routes = [
|
||||
meta: { title: '域名白名单' }
|
||||
},
|
||||
{
|
||||
path: 'setting-group',
|
||||
name: 'SettingGroup',
|
||||
component: () => import('../views/system/SettingGroup.vue'),
|
||||
meta: { title: '配置组管理' }
|
||||
path: 'setting-manage',
|
||||
name: 'SettingManage',
|
||||
component: () => import('../views/system/SettingManage.vue'),
|
||||
meta: { title: '配置管理' }
|
||||
},
|
||||
{
|
||||
path: 'setting-list',
|
||||
name: 'SettingList',
|
||||
component: () => import('../views/system/Setting.vue'),
|
||||
meta: { title: '配置管理' }
|
||||
path: 'menu-manage',
|
||||
name: 'MenuManage',
|
||||
component: () => import('../views/system/MenuManage.vue'),
|
||||
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',() => {
|
||||
|
||||
let userInfo = ref({})
|
||||
// 初始化时从localStorage读取用户信息
|
||||
const savedUserInfo = localStorage.getItem('userInfo')
|
||||
let userInfo = ref(savedUserInfo ? JSON.parse(savedUserInfo) : {})
|
||||
|
||||
function setUserInfo(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;
|
||||
}
|
||||
|
||||
/* 响应式工具类 */
|
||||
/* 可点击元素统一手型光标 */
|
||||
.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) {
|
||||
.hidden-xs {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+148
-27
@@ -1,27 +1,104 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
// 基础URL
|
||||
const baseUrl = 'https://apiservertest.s1f.ren' // SSL证书有问题
|
||||
// const baseUrl = 'http://apiservertest.s1f.ren' // HTTP版本
|
||||
// const baseUrl = 'https://cloudapi.007yjs.com' // 尝试备用地址
|
||||
import {getRefreshToken,refreshAccessToken} from "@/api/login.js";
|
||||
import { baseUrl, acsBaseUrl, noAuthUrls as noAuthUrlList, requestTimeout, acsRequestTimeout, TOKEN_KEY, TOKEN_EXPIRE_KEY, USER_INFO_KEY } from '@/config/env.js'
|
||||
|
||||
// 检查URL是否需要认证
|
||||
const urlNeedAuth = (url) => {
|
||||
// 这里可以添加不需要认证的URL列表
|
||||
const noAuthUrls = ['/v1/user/login', '/v1/user/check/get_code_img', '/v1/user/register']
|
||||
return !noAuthUrls.some(noAuthUrl => url.includes(noAuthUrl))
|
||||
return !noAuthUrlList.some(noAuthUrl => url.includes(noAuthUrl))
|
||||
}
|
||||
|
||||
// 检查token是否过期
|
||||
const isTokenExpired = () => {
|
||||
const token = localStorage.getItem('token')
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const expire = localStorage.getItem(TOKEN_EXPIRE_KEY)
|
||||
if (!token) return true
|
||||
|
||||
// 这里可以添加token过期检查逻辑,如果有JWT可以解析它
|
||||
// 简单实现,仅检查token是否存在
|
||||
return false
|
||||
// 检查过期时间
|
||||
if (expire) {
|
||||
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 {
|
||||
@@ -38,7 +115,7 @@ class Request {
|
||||
(config) => {
|
||||
// 在发送请求之前做些什么
|
||||
// 例如:添加 token
|
||||
const token = localStorage.getItem('token')
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
@@ -107,7 +184,7 @@ class Request {
|
||||
// 创建默认实例
|
||||
const request = new Request({
|
||||
baseURL: baseUrl,
|
||||
timeout: 50000,
|
||||
timeout: requestTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
@@ -118,23 +195,67 @@ export const baseURL = baseUrl
|
||||
|
||||
export const http2 = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: 30000,
|
||||
timeout: acsRequestTimeout,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
http2.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token'); // 假设 token 存储在 localStorage
|
||||
if(urlNeedAuth(config.url) && isTokenExpired()){
|
||||
if (token){
|
||||
localStorage.removeItem('token');
|
||||
ElMessage.warning('登陆过期,请重新登陆')
|
||||
http2.interceptors.request.use(async config => {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
|
||||
// 检查是否需要认证
|
||||
if (urlNeedAuth(config.url)) {
|
||||
// 检查token是否已过期
|
||||
if (isTokenExpired()) {
|
||||
if (token) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||
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}`
|
||||
}
|
||||
router.push('/login')
|
||||
return Promise.reject();
|
||||
}
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
config.url = config.url
|
||||
// 不需要认证的请求,不添加token
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -148,7 +269,7 @@ http2.interceptors.response.use(
|
||||
}
|
||||
const { status } = error.response;
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
ElMessage.warning('登陆过期,请重新登陆')
|
||||
router.push('/login')
|
||||
return Promise.reject();
|
||||
|
||||
+153
-3
@@ -18,7 +18,7 @@ export const formatDate = (dateStr) => {
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
/**
|
||||
* 时间格式转 Unix 时间戳(毫秒级)
|
||||
* 时间格式转 Unix 时间戳(秒级)
|
||||
* @param {string|Date} time - 输入时间(支持 '2025-10-28 00:00:00'、'2025/10/28'、Date 对象等)
|
||||
* @returns {number|null} 转换后的毫秒级时间戳(失败返回 null)
|
||||
*/
|
||||
@@ -50,10 +50,160 @@ export function timeToTimestamp(time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(timestamp / 1000); // 返回毫秒级时间戳(如 1751107200000)
|
||||
return Math.floor(timestamp / 1000); // 返回秒级时间戳(如 1751107200000)
|
||||
}
|
||||
|
||||
|
||||
export function reducenum(num){
|
||||
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'
|
||||
}
|
||||
|
||||
+10
-2
@@ -105,16 +105,24 @@ const forgetPassword = () => {
|
||||
const handleLogin = () => {
|
||||
loginFormRef.value?.validate(async valid =>{
|
||||
window.localStorage.removeItem('token')
|
||||
window.localStorage.removeItem('tokenExpire')
|
||||
window.localStorage.removeItem('userInfo')
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
let resp = await userLogin(loginForm.username, loginForm.password)
|
||||
console.log("login:",resp)
|
||||
loading.value = false
|
||||
if(resp.code === 200){
|
||||
|
||||
window.localStorage.setItem('token',resp.data.token)
|
||||
// 保存token和过期时间
|
||||
window.localStorage.setItem('token', resp.data.token)
|
||||
if (resp.data.expire) {
|
||||
window.localStorage.setItem('tokenExpire', resp.data.expire.toString())
|
||||
}
|
||||
|
||||
let userInfo = await getUserInfo()
|
||||
if(userInfo.data.is_admin){
|
||||
// 保存用户信息到localStorage
|
||||
window.localStorage.setItem('userInfo', JSON.stringify(userInfo.data))
|
||||
await router.push('/dashboard')
|
||||
} else {
|
||||
ElMessage.warning('你不是管理员,不能登陆到后台控制面板')
|
||||
|
||||
@@ -666,7 +666,7 @@ const toLoad = async (data) => {
|
||||
})
|
||||
form.server_id = 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 => {
|
||||
return {
|
||||
name: item.name,
|
||||
@@ -748,7 +748,7 @@ const fetchCategoryList = async (serverId) => {
|
||||
// 编辑镜像
|
||||
const handleEdit = async (data) => {
|
||||
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) {
|
||||
planlist.value = res.data.data.map(item => {
|
||||
return {
|
||||
@@ -874,7 +874,7 @@ const getit = async () => {
|
||||
|
||||
// 选择图片
|
||||
const picPagin = reactive({
|
||||
count: 50,
|
||||
count: 10,
|
||||
page: 1,
|
||||
key: '',
|
||||
user_type: 1
|
||||
|
||||
@@ -262,7 +262,7 @@ const categoryRules = {
|
||||
// 素材库相关
|
||||
const picSwitch = ref(false)
|
||||
const picPagin = reactive({
|
||||
count: 50,
|
||||
count: 10,
|
||||
page: 1,
|
||||
key: '',
|
||||
user_type: 1
|
||||
|
||||
@@ -244,7 +244,7 @@ const showNewCategoryInput = ref(false)
|
||||
const picSwitch = ref(false)
|
||||
const picLoading = ref(false)
|
||||
const picPagin = reactive({
|
||||
count: 20,
|
||||
count: 10,
|
||||
page: 1,
|
||||
key: '',
|
||||
user_type: 1
|
||||
@@ -314,7 +314,7 @@ const initData = async () => {
|
||||
// Fallback: fetch list and find item
|
||||
const listRes = await getUserMirrorList({
|
||||
server_id: serverId.value,
|
||||
count: 100,
|
||||
count: 10,
|
||||
page: 1
|
||||
})
|
||||
if (listRes.data.code === 200) {
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
<h3 class="tab-title">数据卷列表</h3>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="showAddVolumeDialog = true"
|
||||
@click="handleAddVolume"
|
||||
:icon="Plus"
|
||||
:disabled="vmInfo.state != 2"
|
||||
>
|
||||
@@ -671,8 +671,11 @@
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
|
||||
<el-form-item label="大小(GB)" prop="size">
|
||||
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
|
||||
<el-form-item label="大小" prop="size">
|
||||
<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>
|
||||
<template #footer>
|
||||
@@ -693,8 +696,11 @@
|
||||
>
|
||||
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
|
||||
|
||||
<el-form-item label="大小(GB)" prop="size">
|
||||
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
|
||||
<el-form-item label="大小" prop="size">
|
||||
<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>
|
||||
<template #footer>
|
||||
@@ -1067,6 +1073,7 @@ const showMigrateVolumeDialog = ref(false);
|
||||
const currentVolumeToEdit = ref(null);
|
||||
const volumeForm = reactive({
|
||||
size: 10,
|
||||
_sizeUnit: 'GB'
|
||||
});
|
||||
const volumeFormRef = ref(null);
|
||||
const volumeRules = {
|
||||
@@ -2371,6 +2378,7 @@ const handleAddVolume = () => {
|
||||
showAddVolumeDialog.value = true;
|
||||
// 重置表单
|
||||
volumeForm.size = 10;
|
||||
volumeForm._sizeUnit = 'GB';
|
||||
};
|
||||
|
||||
// 编辑数据卷
|
||||
@@ -2378,6 +2386,7 @@ const handleEditVolume = (volume) => {
|
||||
currentVolumeToEdit.value = volume;
|
||||
// 填充表单
|
||||
volumeForm.size = volume.size;
|
||||
volumeForm._sizeUnit = 'GB';
|
||||
showEditVolumeDialog.value = true;
|
||||
};
|
||||
|
||||
@@ -2404,9 +2413,10 @@ const submitAddVolume = async () => {
|
||||
if (valid) {
|
||||
addingVolume.value = true;
|
||||
try {
|
||||
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
|
||||
const res = await addVolume({
|
||||
instance_id: route.query.instance_id,
|
||||
size: String(volumeForm.size),
|
||||
size: String(sizeGb),
|
||||
user_id: user_id.value
|
||||
});
|
||||
console.log("添加数据卷112",res)
|
||||
@@ -2438,9 +2448,10 @@ const submitEditVolume = async () => {
|
||||
editingVolume.value = true;
|
||||
try {
|
||||
// 这里应该调用修改数据卷的API
|
||||
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
|
||||
const res = await updateVolume({
|
||||
volume_id: currentVolumeToEdit.value.id,
|
||||
size: volumeForm.size
|
||||
size: sizeGb
|
||||
});
|
||||
console.log("编辑数据卷数据:",res)
|
||||
|
||||
@@ -2770,4 +2781,7 @@ const fetchServersList = async () => {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-select { width: 90px; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -618,7 +618,7 @@ const fetchPlanList = async () => {
|
||||
try {
|
||||
const response = await getServerPlan({
|
||||
server_id: props.ID,
|
||||
count: 100
|
||||
count: 10
|
||||
});
|
||||
|
||||
if (response && response.data && response.data.code === 200) {
|
||||
|
||||
@@ -315,7 +315,11 @@
|
||||
class="data-table"
|
||||
>
|
||||
<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="created_at" label="创建时间" min-width="160" />
|
||||
</el-table>
|
||||
|
||||
@@ -1901,7 +1901,7 @@ const GetSpecs = async () => {
|
||||
try {
|
||||
let plans = await getServerPlan({
|
||||
server_id: route.query.server_id,
|
||||
count: 30
|
||||
count: 10
|
||||
});
|
||||
spec_list.value = plans.data.data;
|
||||
} catch (error) {
|
||||
@@ -2407,7 +2407,7 @@ const fetchContainerPlanList = async () => {
|
||||
try {
|
||||
const response = await getServerPlan({
|
||||
server_id: route.query.server_id,
|
||||
count: 100
|
||||
count: 10
|
||||
});
|
||||
console.log("获取容器套餐列表1111:",response);
|
||||
|
||||
@@ -2430,7 +2430,7 @@ const fetchContainerMirrorList = async () => {
|
||||
|
||||
containerMirrorLoading.value = true;
|
||||
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);
|
||||
|
||||
if (response && response.data && response.data.code === 200) {
|
||||
|
||||
@@ -119,7 +119,12 @@
|
||||
</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<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 }">
|
||||
@@ -156,6 +161,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getGroupBuyList,
|
||||
@@ -167,6 +173,8 @@ import {
|
||||
} from '@/api/admin/activity'
|
||||
import { getGroupBuyTypeList, getGroupBuyTypeTags, removeGroupBuy, clearAllGroupBuy, clearUserGroupBuy } from '@/api/groupBuy'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 数据状态
|
||||
const loading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
@@ -218,7 +226,7 @@ const fetchTags = async () => {
|
||||
// 根据 tag 获取拼团类型列表
|
||||
const fetchTypeListByTag = async (tag) => {
|
||||
try {
|
||||
const res = await getGroupBuyTypeList({ page: 1, count: 100, tag })
|
||||
const res = await getGroupBuyTypeList({ page: 1, count: 10, tag })
|
||||
if (res.code === 200) {
|
||||
typeList.value = res.data?.data || []
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -52,11 +52,17 @@
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格(分)" prop="price">
|
||||
<el-input-number v-model="form.price" :min="0" style="width: 100%" />
|
||||
<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">
|
||||
<el-input-number v-model="form.renewPrice" :min="0" style="width: 100%" />
|
||||
<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%" />
|
||||
@@ -257,4 +263,6 @@ onMounted(() => { fetchTags() })
|
||||
.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 }"
|
||||
>
|
||||
<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>
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.url" target="_blank" type="primary" v-if="row.url">
|
||||
@@ -146,6 +151,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||
import {
|
||||
Refresh, Download, Search, Delete, View, Warning,
|
||||
@@ -159,6 +165,8 @@ import {
|
||||
|
||||
} from '@/utils/acs/audit'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
domain: '',
|
||||
@@ -419,7 +427,7 @@ const getFullStatsData = async () => {
|
||||
// 获取第一页大量数据来进行统计,或者调用专门的统计接口
|
||||
const statsParams = {
|
||||
page: 1,
|
||||
count: 1000, // 获取大量数据进行统计
|
||||
count: 10, // 获取大量数据进行统计
|
||||
server_id: '',
|
||||
user_id: '',
|
||||
key: queryParams.domain || ''
|
||||
|
||||
@@ -35,7 +35,12 @@
|
||||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||
>
|
||||
<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>
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.url" target="_blank" type="danger" v-if="row.url">
|
||||
@@ -195,6 +200,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||
import {
|
||||
Refresh, Download, Search, Delete, View, Warning,
|
||||
@@ -208,6 +214,8 @@ import {
|
||||
|
||||
} from '@/utils/acs/audit'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
domain: '',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<el-icon><component :is="card.icon" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<!-- <div class="card-footer">
|
||||
<span>较昨日</span>
|
||||
<span :class="card.trend > 0 ? 'up' : 'down'">
|
||||
{{ card.trend > 0 ? '+' : '' }}{{ card.trend }}%
|
||||
@@ -23,13 +23,13 @@
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-inner" :style="{width: card.progress + '%', background: card.progressColor}"></div>
|
||||
</div>
|
||||
</div> -->
|
||||
</el-card>
|
||||
</el-col>
|
||||
</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-card class="chart-card" shadow="hover">
|
||||
<div class="chart-header">
|
||||
@@ -116,10 +116,137 @@
|
||||
<div class="chart-container" ref="customerChartRef"></div>
|
||||
</el-card>
|
||||
</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-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-card class="activity-card" shadow="hover">
|
||||
<div class="card-header-custom">
|
||||
@@ -207,28 +334,45 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-row> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
User, ShoppingCart, Money, DataAnalysis,
|
||||
MoreFilled, ArrowUp, ArrowDown, Right,
|
||||
Download, Refresh, Check, Delete, Plus,
|
||||
Setting, Calendar, Filter
|
||||
Setting, Calendar, Filter, Tickets, View
|
||||
} from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import Qrcode from '@/components/Qrcode.vue'
|
||||
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 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: '访问量',
|
||||
value: '8,846',
|
||||
title: '用户量',
|
||||
value: userCount.value.toLocaleString(),
|
||||
icon: 'User',
|
||||
trend: 12.5,
|
||||
class: 'visitors',
|
||||
@@ -237,7 +381,7 @@ const statisticsCards = ref([
|
||||
},
|
||||
{
|
||||
title: '订单量',
|
||||
value: '1,257',
|
||||
value: orderCount.value.toLocaleString(),
|
||||
icon: 'ShoppingCart',
|
||||
trend: 5.2,
|
||||
class: 'orders',
|
||||
@@ -245,9 +389,9 @@ const statisticsCards = ref([
|
||||
progressColor: 'rgba(82, 196, 26, 0.8)'
|
||||
},
|
||||
{
|
||||
title: '销售额',
|
||||
value: '¥ 125,430',
|
||||
icon: 'Money',
|
||||
title: '工单量',
|
||||
value: ticketCount.value.toLocaleString(),
|
||||
icon: 'Tickets',
|
||||
trend: -2.3,
|
||||
class: 'sales',
|
||||
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([
|
||||
{ name: '企业客户', value: 1048, percentage: 33, color: '#1890ff' },
|
||||
@@ -331,6 +619,10 @@ const getPriorityType = (priority) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 获取统计数据和列表数据
|
||||
fetchStatistics()
|
||||
fetchRecentLists()
|
||||
|
||||
initSalesChart()
|
||||
initCustomerChart()
|
||||
|
||||
@@ -531,15 +823,19 @@ watch(salesRange, (newVal) => {
|
||||
/* 统计卡片样式 */
|
||||
.stat-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
transition: all 0.3s;
|
||||
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 {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
@@ -570,10 +866,10 @@ watch(salesRange, (newVal) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
font-size: 28px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -635,8 +931,6 @@ watch(salesRange, (newVal) => {
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -744,8 +1038,6 @@ watch(salesRange, (newVal) => {
|
||||
|
||||
.activity-card, .todo-card {
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card-header-custom {
|
||||
@@ -870,6 +1162,149 @@ watch(salesRange, (newVal) => {
|
||||
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) {
|
||||
.dashboard-container {
|
||||
padding: 12px;
|
||||
@@ -888,5 +1323,30 @@ watch(salesRange, (newVal) => {
|
||||
.todo-list {
|
||||
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>
|
||||
@@ -141,17 +141,26 @@
|
||||
<el-radio label="percentage">百分比折扣</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<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%" />
|
||||
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额" prop="amount">
|
||||
<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 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-form-item>
|
||||
<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%" />
|
||||
<el-form-item label="最低消费" prop="min_amount">
|
||||
<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 label="最大抵扣(元)" prop="max_amount">
|
||||
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" />
|
||||
<el-form-item label="最大抵扣" prop="max_amount">
|
||||
<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 label="最大使用次数" prop="max_times">
|
||||
<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; }
|
||||
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>
|
||||
|
||||
@@ -389,7 +389,7 @@ const fetchVoucherListOptions = async () => {
|
||||
try {
|
||||
const res = await getDiscountCodeList({
|
||||
page: 1,
|
||||
count: 1000,
|
||||
count: 10,
|
||||
discount_type: 'coupon'
|
||||
})
|
||||
console.log('获取代金券列表:', res.data)
|
||||
@@ -407,7 +407,7 @@ const fetchProductList = async () => {
|
||||
try {
|
||||
const res = await getProductList({
|
||||
page: 1,
|
||||
count: 1000
|
||||
count: 10
|
||||
})
|
||||
console.log('获取商品列表:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
@@ -424,7 +424,7 @@ const fetchProductGroupList = async () => {
|
||||
try {
|
||||
const res = await getProductGroupList({
|
||||
page: 1,
|
||||
count: 1000
|
||||
count: 10
|
||||
})
|
||||
console.log('获取商品组列表:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
@@ -798,33 +798,6 @@ onMounted(() => {
|
||||
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) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
<el-table-column prop="discountId" label="代金券ID" width="120" v-if="!codeId" />
|
||||
<el-table-column label="用户名" min-width="150">
|
||||
<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>
|
||||
</el-table-column>
|
||||
<el-table-column label="手机号" min-width="150">
|
||||
@@ -239,6 +240,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Search, Plus, Refresh, User } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -261,6 +263,8 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
code_id: props.codeId || '',
|
||||
@@ -361,7 +365,7 @@ const fetchVoucherListOptions = async () => {
|
||||
try {
|
||||
const res = await getDiscountCodeList({
|
||||
page: 1,
|
||||
count: 1000,
|
||||
count: 10,
|
||||
discount_type: 'coupon'
|
||||
})
|
||||
console.log('获取代金券列表:', res.data)
|
||||
@@ -397,7 +401,7 @@ const fetchUserGroupList = async () => {
|
||||
try {
|
||||
const res = await getUserGroupList({
|
||||
page: 1,
|
||||
count: 10000,
|
||||
count: 10,
|
||||
key: ''
|
||||
})
|
||||
console.log('获取用户组列表:', res.data)
|
||||
@@ -803,33 +807,6 @@ onMounted(() => {
|
||||
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) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ const currentGroupBuy = ref(null)
|
||||
// 加载拼团列表
|
||||
const loadGroupBuyList = async () => {
|
||||
try {
|
||||
const resp = await getGroupBuyList({ page: 1, pageSize: 20 })
|
||||
const resp = await getGroupBuyList({ page: 1, pageSize: 10 })
|
||||
if (resp && resp.code === 200) {
|
||||
groupBuyList.value = resp.data || []
|
||||
}
|
||||
|
||||
@@ -177,30 +177,36 @@
|
||||
</el-radio-group>
|
||||
</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="selected-user-display" v-if="addForm.user_id">
|
||||
<el-tag type="primary" closable @close="clearSelectedUser">
|
||||
{{ getSelectedUserName() }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
<el-input
|
||||
:model-value="getSelectedUserName()"
|
||||
placeholder="请选择用户"
|
||||
readonly
|
||||
@click="openUserSelector"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
{{ addForm.user_id ? '重新选择用户' : '选择用户' }}
|
||||
<template #append>
|
||||
<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>
|
||||
</div>
|
||||
</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
|
||||
v-model="addForm.group_id"
|
||||
placeholder="请选择用户组"
|
||||
:disabled="addForm.target_type === 'user'"
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@@ -478,7 +484,7 @@ const fetchVoucherListOptions = async () => {
|
||||
try {
|
||||
const res = await getDiscountCodeList({
|
||||
page: 1,
|
||||
count: 1000,
|
||||
count: 10,
|
||||
discount_type: 'coupon'
|
||||
})
|
||||
console.log('获取代金券列表:', res.data)
|
||||
@@ -496,7 +502,7 @@ const fetchDiscountList = async () => {
|
||||
try {
|
||||
const res = await getDiscountCodeList({
|
||||
page: 1,
|
||||
count: 100,
|
||||
count: 10,
|
||||
discount_type: 'coupon'
|
||||
})
|
||||
console.log('获取代金券列表:', res.data)
|
||||
@@ -507,7 +513,7 @@ const fetchDiscountList = async () => {
|
||||
}
|
||||
const res2 = await getDiscountCodeList({
|
||||
page: 1,
|
||||
count: 100,
|
||||
count: 10,
|
||||
discount_type: 'code'
|
||||
})
|
||||
console.log('获取优惠码列表:', res2.data)
|
||||
@@ -527,7 +533,7 @@ const fetchVoucherOptions = async () => {
|
||||
const res = await getDiscountCodeList({
|
||||
discount_type: 'coupon',
|
||||
page: 1,
|
||||
count: 100
|
||||
count: 10
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
voucherOptions.value = res.data.data?.data || []
|
||||
@@ -543,7 +549,7 @@ const fetchCodeOptions = async () => {
|
||||
const res = await getDiscountCodeList({
|
||||
discount_type: 'code',
|
||||
page: 1,
|
||||
count: 100
|
||||
count: 10
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
codeOptions.value = res.data.data?.data || []
|
||||
@@ -560,7 +566,7 @@ const fetchUserList = async () => {
|
||||
try {
|
||||
const res = await getUserList({
|
||||
page: 1,
|
||||
count: 100,
|
||||
count: 10,
|
||||
key: ''
|
||||
})
|
||||
console.log('获取用户列表:', res.data)
|
||||
@@ -582,7 +588,7 @@ const fetchGroupOptions = async () => {
|
||||
try {
|
||||
const res = await getUserGroupList({
|
||||
page: 1,
|
||||
count: 100
|
||||
count: 10
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
groupOptions.value = res.data.data?.data || []
|
||||
@@ -653,9 +659,9 @@ const confirmUserSelection = (user) => {
|
||||
ElMessage.warning('请选择一个用户')
|
||||
return
|
||||
}
|
||||
addForm.user_id = user.UserId
|
||||
addForm.user_id = user.user_id
|
||||
// 将选中的用户添加到 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)
|
||||
}
|
||||
userSelectorVisible.value = false
|
||||
@@ -668,8 +674,9 @@ const clearSelectedUser = () => {
|
||||
|
||||
// 获取选中用户的显示名称
|
||||
const getSelectedUserName = () => {
|
||||
const user = userOptions.value.find(u => u.UserId === addForm.user_id)
|
||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${addForm.user_id}`
|
||||
if (!addForm.user_id) return ''
|
||||
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;
|
||||
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>
|
||||
|
||||
|
||||
@@ -105,14 +105,23 @@
|
||||
<el-form-item label="备注" prop="note">
|
||||
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
<el-form-item label="面额(元)" prop="amount">
|
||||
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="width: 100%" />
|
||||
<el-form-item label="面额" prop="amount">
|
||||
<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 label="最低消费(元)" prop="min_amount">
|
||||
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" />
|
||||
<el-form-item label="最低消费" prop="min_amount">
|
||||
<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 label="最大抵扣(元)" prop="max_amount">
|
||||
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" />
|
||||
<el-form-item label="最大抵扣" prop="max_amount">
|
||||
<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 label="最大使用次数" prop="max_times">
|
||||
<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-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)" prop="duration_days">
|
||||
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="width: 100%" />
|
||||
<el-form-item label="有效期" prop="duration_days">
|
||||
<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>
|
||||
</el-form-item>
|
||||
<el-form-item label="发放时间范围" prop="timeRange">
|
||||
@@ -295,9 +307,39 @@ const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
dialogVisible.value = true
|
||||
|
||||
// 转换日期字符串为日期选择器格式
|
||||
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, '-') : ''
|
||||
console.log('编辑代金券原始数据:', row)
|
||||
|
||||
// 转换时间为日期字符串(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, {
|
||||
code_id: row.id,
|
||||
@@ -309,12 +351,14 @@ const handleEdit = (row) => {
|
||||
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
|
||||
max_times: row.maxTimes || 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] : [],
|
||||
renew: row.renew || false,
|
||||
can_stacking: row.canStacking || false,
|
||||
can_combine: row.canCombine || false
|
||||
})
|
||||
|
||||
console.log('表单数据:', voucherForm)
|
||||
}
|
||||
|
||||
// 管理代金券
|
||||
@@ -507,6 +551,9 @@ onMounted(() => {
|
||||
margin-top: 24px;
|
||||
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>
|
||||
|
||||
@@ -43,7 +43,12 @@
|
||||
stripe
|
||||
>
|
||||
<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="email" label="邮箱" min-width="200" />
|
||||
<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>
|
||||
</template>
|
||||
</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">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
@@ -174,6 +184,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh, Download } from '@element-plus/icons-vue'
|
||||
import { getUserVoucherHistory, getDiscountCodeList } from '@/api/admin/discount'
|
||||
@@ -187,6 +198,8 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
user_id: undefined,
|
||||
@@ -397,7 +410,7 @@ const fetchUserList = async () => {
|
||||
try {
|
||||
const res = await getUserList({
|
||||
page: 1,
|
||||
count: 10000,
|
||||
count: 10,
|
||||
key: ''
|
||||
})
|
||||
UserOptions.value = res.data.data?.data || []
|
||||
@@ -412,7 +425,7 @@ const fetchDiscountList = async () => {
|
||||
const res = await getDiscountCodeList({
|
||||
discount_type: 'coupon',
|
||||
page: 1,
|
||||
count: 1000
|
||||
count: 10
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
discountOptions.value = res.data.data?.data || []
|
||||
|
||||
@@ -40,7 +40,12 @@
|
||||
style="width: 100%"
|
||||
>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
{{ row.discountId || '-' }}
|
||||
@@ -212,6 +217,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -230,6 +236,8 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
user_id: undefined,
|
||||
@@ -459,7 +467,7 @@ const fetchDiscountList = async () => {
|
||||
const res = await getDiscountCodeList({
|
||||
discount_type: 'coupon',
|
||||
page: 1,
|
||||
count: 1000
|
||||
count: 10
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
discountOptions.value = res.data.data?.data || []
|
||||
|
||||
+380
-31
@@ -22,6 +22,12 @@
|
||||
<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>搜索
|
||||
@@ -67,8 +73,18 @@
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="id" label="订单ID" width="100" />
|
||||
<el-table-column prop="name" label="订单名称" min-width="180" />
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<el-table-column prop="commodityId" label="商品ID" width="100" />
|
||||
<el-table-column 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">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.table || '未知' }}</el-tag>
|
||||
@@ -89,11 +105,22 @@
|
||||
<span>{{ row.payNum }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单状态" width="100">
|
||||
<el-table-column label="订单状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.state)">
|
||||
{{ getStatusText(row.state) }}
|
||||
</el-tag>
|
||||
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
||||
<el-tag :type="getStatusType(row.state)">
|
||||
{{ 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>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付方式" width="100">
|
||||
@@ -111,11 +138,12 @@
|
||||
<span>{{ formatDate(row.CreatedAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" link @click="handleView(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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -162,6 +190,10 @@
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.CreatedAt) }}</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 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>
|
||||
</el-dialog>
|
||||
@@ -186,28 +218,149 @@
|
||||
<el-input v-model="orderForm.table" placeholder="请输入所属表" />
|
||||
</el-form-item>
|
||||
<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 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 label="购买数量" prop="pay_num">
|
||||
<el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格(分)" prop="price">
|
||||
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="width: 100%" />
|
||||
<el-form-item label="价格" prop="price">
|
||||
<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 label="续费价格(分)" prop="renew_price">
|
||||
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="width: 100%" />
|
||||
<el-form-item label="续费价格" prop="renew_price">
|
||||
<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 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 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 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 label="订单状态" prop="state">
|
||||
<el-radio-group v-model="orderForm.state">
|
||||
@@ -233,14 +386,51 @@
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Search, Download, Refresh } from '@element-plus/icons-vue'
|
||||
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder } from '@/api/admin/order'
|
||||
import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close } from '@element-plus/icons-vue'
|
||||
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({
|
||||
@@ -249,7 +439,8 @@ const queryParams = reactive({
|
||||
key: '',
|
||||
state: '',
|
||||
user_id: '',
|
||||
user_key: ''
|
||||
user_key: '',
|
||||
error: null
|
||||
})
|
||||
|
||||
// 订单表单
|
||||
@@ -305,6 +496,18 @@ const detailDialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
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 () => {
|
||||
loading.value = true
|
||||
@@ -319,7 +522,17 @@ const fetchOrderList = async () => {
|
||||
const res = await getOrderList(params)
|
||||
console.log('订单列表数据:', res.data)
|
||||
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
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -330,16 +543,9 @@ const fetchOrderList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
// 格式化日期 - 使用工具函数
|
||||
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')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
return formatDateTool(dateStr)
|
||||
}
|
||||
|
||||
// 获取订单状态类型
|
||||
@@ -375,6 +581,7 @@ const resetQuery = () => {
|
||||
queryParams.state = ''
|
||||
queryParams.user_id = ''
|
||||
queryParams.user_key = ''
|
||||
queryParams.error = null
|
||||
queryParams.page = 1
|
||||
fetchOrderList()
|
||||
}
|
||||
@@ -399,6 +606,7 @@ const handleCurrentChange = (page) => {
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
dialogVisible.value = true
|
||||
clearAllSelections()
|
||||
Object.assign(orderForm, {
|
||||
order_id: undefined,
|
||||
name: '',
|
||||
@@ -437,6 +645,16 @@ const handleView = async (row) => {
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
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, {
|
||||
order_id: row.id,
|
||||
name: row.name,
|
||||
@@ -446,7 +664,7 @@ const handleEdit = (row) => {
|
||||
pay_num: row.payNum,
|
||||
price: row.price,
|
||||
renew_price: row.renewPrice,
|
||||
expire_time: row.expireTime ? new Date(row.expireTime).getTime() / 1000 : 0,
|
||||
expire_time: expireTimeMs,
|
||||
discount_code_id: 0, // 从详情接口获取
|
||||
coupon_id: 0, // 从详情接口获取
|
||||
state: row.state,
|
||||
@@ -454,6 +672,40 @@ const handleEdit = (row) => {
|
||||
args: row.args || '',
|
||||
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(() => {})
|
||||
}
|
||||
|
||||
// 删除订单
|
||||
@@ -497,6 +749,13 @@ const submitForm = () => {
|
||||
orderFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
// 处理过期时间:将毫秒级时间戳转换为秒级时间戳
|
||||
let expireTimeSeconds = 0
|
||||
if (orderForm.expire_time) {
|
||||
const timestamp = timeToTimestamp(new Date(orderForm.expire_time))
|
||||
expireTimeSeconds = timestamp || 0
|
||||
}
|
||||
|
||||
// 准备提交的数据
|
||||
const submitData = {
|
||||
name: orderForm.name,
|
||||
@@ -506,7 +765,7 @@ const submitForm = () => {
|
||||
pay_num: Number(orderForm.pay_num),
|
||||
price: Number(orderForm.price),
|
||||
renew_price: Number(orderForm.renew_price),
|
||||
expire_time: Number(orderForm.expire_time),
|
||||
expire_time: expireTimeSeconds,
|
||||
discount_code_id: Number(orderForm.discount_code_id),
|
||||
coupon_id: Number(orderForm.coupon_id),
|
||||
state: Number(orderForm.state),
|
||||
@@ -542,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(() => {
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
@@ -701,4 +1019,35 @@ onMounted(() => {
|
||||
0% { 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>
|
||||
|
||||
+2073
-66
File diff suppressed because it is too large
Load Diff
+1928
-109
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,
|
||||
Phone, OfficeBuilding, UploadFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/userStore.js'
|
||||
|
||||
// 是否处于编辑模式
|
||||
const isEditing = 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({
|
||||
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 userInfo = reactive(getSavedUserInfo())
|
||||
|
||||
// 表单数据
|
||||
const userForm = reactive({...userInfo})
|
||||
@@ -296,9 +328,9 @@ const handleAvatarSuccess = (res) => {
|
||||
// 获取用户信息
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
// 实际项目中,应该从后端获取用户信息并更新userInfo
|
||||
// 从store获取最新用户信息
|
||||
const savedInfo = getSavedUserInfo()
|
||||
Object.assign(userInfo, savedInfo)
|
||||
} catch (error) {
|
||||
ElMessage.error('获取用户信息失败')
|
||||
console.error(error)
|
||||
|
||||
@@ -349,33 +349,6 @@ onMounted(() => {
|
||||
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) {
|
||||
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>
|
||||
</el-form-item>
|
||||
<el-form-item label="管理员组" v-if="queryParams.owner_type === 'group'">
|
||||
<el-select v-model="queryParams.admin_group_id" placeholder="请选择管理员组" clearable filterable style="width: 200px">
|
||||
<el-option v-for="item in adminGroupOptions" :key="item.id" :label="`${item.name} (ID: ${item.id})`" :value="item.id" />
|
||||
</el-select>
|
||||
<div class="selector-inline">
|
||||
<el-tag v-if="queryParams.admin_group_id" type="success" closable @close="clearQueryGroup" style="margin-right: 8px;">
|
||||
{{ 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-button type="primary" @click="handleQuery">
|
||||
@@ -115,14 +121,35 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 用户选择弹窗 -->
|
||||
<el-dialog
|
||||
<!-- 用户选择弹窗 - 使用UserListSelector组件 -->
|
||||
<UserListSelector
|
||||
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="选择用户"
|
||||
width="800px"
|
||||
class="user-selector-dialog"
|
||||
>
|
||||
<!-- 搜索栏 -->
|
||||
<div class="selector-search">
|
||||
<el-input
|
||||
v-model="userSearchParams.key"
|
||||
@@ -142,7 +169,6 @@
|
||||
<el-button @click="resetUserSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 用户表格 -->
|
||||
<el-table
|
||||
v-loading="userSelectorLoading"
|
||||
:data="userSelectorList"
|
||||
@@ -164,7 +190,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="userSearchParams.page"
|
||||
v-model:page-size="userSearchParams.count"
|
||||
@@ -178,12 +204,12 @@
|
||||
/>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog> -->
|
||||
<!-- 分配权限对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
@@ -204,45 +230,80 @@
|
||||
<div class="form-tip">如果是 user 则填写 user_id,如果是 group 则填写 admin_group_id</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户" prop="user_id" v-if="permissionForm.owner_type === 'user'" >
|
||||
<div class="user_selector-inline">
|
||||
<el-tag v-if="permissionForm.user_id" type="primary" closable @close="clearFormUser" style="margin-right: 8px;">
|
||||
{{ getFormUserName() }}
|
||||
</el-tag>
|
||||
<el-button type="primary" plain @click="openFormUserSelector" size="default" :disabled="permissionForm.user_id">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ permissionForm.user_id ? '重新选择' : '选择用户' }}
|
||||
<div class="recommend-user-selector">
|
||||
<el-input
|
||||
:model-value="getFormUserName()"
|
||||
placeholder="点击选择用户"
|
||||
readonly
|
||||
@click="openFormUserSelector"
|
||||
: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>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<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%">
|
||||
<el-option v-for="item in adminGroupOptions" :key="item.id" :label="`${item.name} (ID: ${item.id})`" :value="item.id" />
|
||||
</el-select>
|
||||
<div class="recommend-user-selector">
|
||||
<el-input
|
||||
: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 label="路径权限" prop="permission_id">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<el-select
|
||||
v-model="permissionForm.permission_id"
|
||||
placeholder="请选择路径权限"
|
||||
filterable
|
||||
style="flex: 1"
|
||||
:loading="permissionLoading"
|
||||
<div class="recommend-user-selector">
|
||||
<el-input
|
||||
:model-value="getFormPermissionName()"
|
||||
placeholder="点击选择路径权限"
|
||||
readonly
|
||||
@click="openPermissionSelector"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in permissionOptions"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>
|
||||
<el-tag v-if="item.method" :type="getMethodTag(item.method)" size="small" style="margin-right: 8px;">{{ item.method }}</el-tag>
|
||||
{{ item.path }}
|
||||
</span>
|
||||
<span style="color: #999; font-size: 12px; margin-left: 12px;">{{ item.note || item.name || `ID: ${item.id}` }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-button @click="fetchPermissionList" :loading="permissionLoading" :icon="Refresh">刷新</el-button>
|
||||
<template #append>
|
||||
<el-button @click="openPermissionSelector">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button
|
||||
v-if="permissionForm.permission_id"
|
||||
type="danger"
|
||||
link
|
||||
@click="clearFormPermission"
|
||||
class="clear-btn"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-tip">共 {{ permissionOptions.length }} 个路径权限可选</div>
|
||||
</el-form-item>
|
||||
@@ -283,6 +344,9 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 {
|
||||
getPermissionListByAdmin,
|
||||
addPermissionAdmin,
|
||||
@@ -297,6 +361,8 @@ import { formatDate ,timeToTimestamp} from '@/utils/tool'
|
||||
|
||||
const selectorType = ref('query')
|
||||
const userSelectorVisible = ref(false)
|
||||
const groupSelectorVisible = ref(false)
|
||||
const permissionSelectorVisible = ref(false)
|
||||
const userSelectorList = ref([])
|
||||
const userSelectorTotal = ref(0)
|
||||
const userSearchParams = reactive({
|
||||
@@ -307,6 +373,8 @@ const userSearchParams = reactive({
|
||||
const selectedUserTemp = ref(null)
|
||||
const userSelectorLoading = ref(false)
|
||||
const UserOptions = ref([])
|
||||
const GroupOptions = ref([])
|
||||
const selectedPermission = ref(null)
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
owner_type: '',
|
||||
@@ -324,16 +392,125 @@ const getQueryUserName = () => {
|
||||
const user = UserOptions.value.find(u => u.UserId === 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 = () => {
|
||||
permissionForm.user_id = undefined
|
||||
}
|
||||
// 表单:获取显示名称
|
||||
const getFormUserName = () => {
|
||||
if (!permissionForm.user_id) return ''
|
||||
const user = UserOptions.value.find(u => u.UserId === 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 = () => {
|
||||
if (!selectedUserTemp.value) {
|
||||
ElMessage.warning('请选择一个用户')
|
||||
@@ -684,7 +861,7 @@ const fetchUserList = async () => {
|
||||
try {
|
||||
const res = await getUserList({
|
||||
page: 1,
|
||||
count: 10000,
|
||||
count: 10,
|
||||
key: ''
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
@@ -700,7 +877,7 @@ const fetchAdminGroupList = async () => {
|
||||
try {
|
||||
const res = await getAdminGroupList({
|
||||
page: 1,
|
||||
count: 1000
|
||||
count: 10
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
adminGroupOptions.value = res.data.data?.data || []
|
||||
@@ -716,7 +893,7 @@ const fetchPermissionList = async () => {
|
||||
try {
|
||||
const res = await getPermissionList({
|
||||
page: 1,
|
||||
count: 10000
|
||||
count: 10
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
permissionOptions.value = res.data.data?.list || []
|
||||
@@ -837,4 +1014,34 @@ onMounted(() => {
|
||||
:deep(.el-card__body) {
|
||||
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>
|
||||
|
||||
@@ -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 prop="type" label="文件类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getFileTypeColor(row.type)">
|
||||
<el-tag :type="getFileTypeColor(row.type, row.url, row.realName)">
|
||||
{{ row.type || '未知' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -112,7 +112,7 @@
|
||||
<div class="preview-label">文件预览</div>
|
||||
<div class="preview-content">
|
||||
<el-image
|
||||
v-if="isImageFile(fileDetail.type) && fileDetail.url"
|
||||
v-if="isImageFile(fileDetail.type, fileDetail.url, fileDetail.realName) && fileDetail.url"
|
||||
:src="fileDetail.url"
|
||||
fit="contain"
|
||||
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">{{ fileDetail.saveName }}</el-descriptions-item>
|
||||
<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 label="文件大小" label-align="right">{{ formatFileSize(fileDetail.size) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否公开" label-align="right">
|
||||
@@ -302,14 +302,37 @@ const uploadForm = reactive({
|
||||
const uploadFileList = ref([])
|
||||
|
||||
// 判断是否为图片文件
|
||||
const isImageFile = (type) => {
|
||||
const imageTypes = ['cover', 'image', 'avatar', 'photo', 'picture']
|
||||
return imageTypes.includes(type?.toLowerCase())
|
||||
const isImageFile = (type, url, realName) => {
|
||||
// 检查type字段
|
||||
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) => {
|
||||
if (isImageFile(type)) return 'success'
|
||||
const getFileTypeColor = (type, url, realName) => {
|
||||
if (isImageFile(type, url, realName)) return 'success'
|
||||
const colorMap = {
|
||||
'document': 'primary',
|
||||
'video': 'warning',
|
||||
@@ -394,8 +417,12 @@ const handleView = async (row) => {
|
||||
const res = await getFileDetail({ file_id: row.id })
|
||||
console.log('文件详情数据:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
fileDetail.value = res.data.data.data
|
||||
fileDetail.value.url = res.data.data.url
|
||||
// 确保正确设置文件详情和URL
|
||||
const fileData = res.data.data.data || res.data.data
|
||||
fileDetail.value = {
|
||||
...fileData,
|
||||
url: fileData.url || res.data.data.url || ''
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -510,23 +537,69 @@ const handleRemoveFile = (file, fileList) => {
|
||||
uploadFileList.value = fileList
|
||||
}
|
||||
|
||||
// 提交上传
|
||||
const handleSubmitUpload = () => {
|
||||
// 提交上传(批量上传:将所有文件合并为一次请求)
|
||||
const handleSubmitUpload = async () => {
|
||||
if (uploadFileList.value.length === 0) {
|
||||
ElMessage.warning('请至少选择一个文件')
|
||||
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) {
|
||||
ElMessage.info('所有文件已上传完成')
|
||||
ElMessage.info('没有可上传的有效文件')
|
||||
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) {
|
||||
ElMessage.warning(`文件 ${file.name} 大小超过 10MB`)
|
||||
}
|
||||
// 允许文件添加到列表,在上传时再进行验证
|
||||
// 允许文件添加到列表,在提交时再进行验证
|
||||
return true
|
||||
}
|
||||
|
||||
// 自定义上传方法
|
||||
// 自定义上传方法(保留为空壳,实际上传由 handleSubmitUpload 批量处理)
|
||||
const handleCustomUpload = async (options) => {
|
||||
const { file, onSuccess, onError } = options
|
||||
console.log('开始上传文件:', file)
|
||||
|
||||
// 在上传前进行验证
|
||||
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)
|
||||
}
|
||||
// 不做任何操作,所有上传由 handleSubmitUpload 统一批量处理
|
||||
// el-upload 的 auto-upload 已设为 false,此方法不会被自动调用
|
||||
}
|
||||
|
||||
// 上传成功
|
||||
const handleUploadSuccess = (response, file, fileList) => {
|
||||
console.log('上传成功文件:', file)
|
||||
console.log('上传成功文件列表:',fileList)
|
||||
|
||||
|
||||
// 成功回调只会在 code === 200 时触发
|
||||
// ElMessage.success(`文件 ${file.name} 上传成功`)
|
||||
// 更新文件列表状态
|
||||
|
||||
uploadFileList.value = fileList
|
||||
// 如果所有文件都上传成功,关闭对话框并刷新列表
|
||||
const allSuccess = fileList.every(f => f.status === 'success')
|
||||
|
||||
@@ -528,33 +528,6 @@ onMounted(() => {
|
||||
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) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -347,7 +347,8 @@ const messagesEqual = (oldMessages, newMessages) => {
|
||||
|
||||
// 获取工单详情
|
||||
const fetchTicketDetail = async (showLoading = true) => {
|
||||
const workId = route.query.id
|
||||
// 兼容 id 和 work_id 两种参数名
|
||||
const workId = route.query.id || route.query.work_id
|
||||
if (!workId) {
|
||||
// 没有ID时静默跳转到列表页
|
||||
router.replace('/ticket/list')
|
||||
@@ -418,7 +419,7 @@ const fetchTicketDetail = async (showLoading = true) => {
|
||||
const sendMessage = async () => {
|
||||
if ((!messageInput.value.trim() && selectedImages.value.length === 0) || isSending.value) return
|
||||
|
||||
const workId = route.query.id
|
||||
const workId = route.query.id || route.query.work_id
|
||||
const content = messageInput.value.trim() || 'empty'
|
||||
|
||||
try {
|
||||
@@ -433,10 +434,10 @@ const sendMessage = async () => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
|
||||
// 添加所有文件
|
||||
// 多个 file_names 和 files 条目在同一请求中
|
||||
inputFiles.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
formData.append('file_names', file.name)
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
// 设置上传类型为工单
|
||||
@@ -446,11 +447,11 @@ const sendMessage = async () => {
|
||||
const uploadRes = await uploadFile(formData)
|
||||
|
||||
if (uploadRes.data?.code === 200) {
|
||||
// 从返回的数据中提取文件ID(字段名是 id)
|
||||
// 从返回的数据中提取文件ID
|
||||
const data = uploadRes.data.data
|
||||
if (Array.isArray(data)) {
|
||||
fileIds = data.map(item => String(item.id))
|
||||
} else if (data.id) {
|
||||
} else if (data?.id) {
|
||||
fileIds = [String(data.id)]
|
||||
}
|
||||
|
||||
@@ -526,7 +527,7 @@ const handleStatusChange = async (newStatus) => {
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('work_id', route.query.id)
|
||||
formData.append('work_id', route.query.id || route.query.work_id)
|
||||
formData.append('Status', statusMap[newStatus])
|
||||
|
||||
const res = await updateTicketInfo(formData)
|
||||
@@ -553,7 +554,7 @@ const handleComplete = () => {
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await closeTicket(route.query.id)
|
||||
const res = await closeTicket(route.query.id || route.query.work_id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('工单已成功结束')
|
||||
ticketInfo.value.status = 'completed'
|
||||
@@ -731,9 +732,10 @@ const saveEditMessage = async () => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
|
||||
// 多个 file_names 和 files 条目在同一请求中
|
||||
editMessageFiles.value.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
formData.append('file_names', file.name)
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
formData.append('update_type', 'work_order')
|
||||
@@ -745,7 +747,7 @@ const saveEditMessage = async () => {
|
||||
const data = uploadRes.data.data
|
||||
if (Array.isArray(data)) {
|
||||
newFileIds = data.map(item => String(item.id))
|
||||
} else if (data.id) {
|
||||
} else if (data?.id) {
|
||||
newFileIds = [String(data.id)]
|
||||
}
|
||||
} else {
|
||||
@@ -892,7 +894,9 @@ const goToUserDetail = () => {
|
||||
// 定时刷新
|
||||
const startAutoRefresh = () => {
|
||||
refreshTimer.value = setInterval(() => {
|
||||
if (ticketInfo.value?.status !== 'completed') {
|
||||
// 只有当前路由仍在工单详情页且工单未完成时才刷新
|
||||
const workId = route.query.id || route.query.work_id
|
||||
if (route.path === '/ticket/detail' && workId && ticketInfo.value?.status !== 'completed') {
|
||||
fetchTicketDetail(false) // 定时刷新时不显示 loading
|
||||
}
|
||||
}, 10000)
|
||||
@@ -907,7 +911,7 @@ const stopAutoRefresh = () => {
|
||||
|
||||
// 监听路由query变化,重新加载数据
|
||||
watch(
|
||||
() => route.query.id,
|
||||
() => route.query.id || route.query.work_id,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
fetchTicketDetail()
|
||||
@@ -1319,4 +1323,202 @@ onBeforeUnmount(() => {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 平板尺寸响应式样式 */
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.page-header {
|
||||
padding: 12px 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ticket-title {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.quick-replies {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
.ticket-detail-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-left .el-button {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ticket-title {
|
||||
order: 4;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin-top: 8px;
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-right .ticket-id {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-right .el-select {
|
||||
width: 100px !important;
|
||||
}
|
||||
|
||||
.header-right .el-button {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.reply-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.quick-replies {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quick-replies .el-button {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.left-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.input-actions .el-button--primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 用户信息弹窗 */
|
||||
.user-popover {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popover-info {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 编辑消息弹窗 */
|
||||
:deep(.el-dialog) {
|
||||
width: 90% !important;
|
||||
margin: 5vh auto !important;
|
||||
}
|
||||
|
||||
.edit-images-container {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-preview-item,
|
||||
.add-image-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-left .el-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-left .el-button .el-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+587
-54
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="ticket-list-page">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<!-- 顶部状态标签栏 -->
|
||||
<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>
|
||||
@@ -19,44 +19,65 @@
|
||||
全部 <span class="count">{{ stats.total }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<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
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索工单号、标题、用户名"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<el-button icon="Refresh" @click="refreshList">刷新</el-button>
|
||||
</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>
|
||||
<span class="username">{{ row.username }}</span>
|
||||
<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>
|
||||
@@ -87,6 +108,41 @@
|
||||
</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
|
||||
@@ -99,18 +155,74 @@
|
||||
@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 } from 'vue'
|
||||
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()
|
||||
|
||||
@@ -122,9 +234,20 @@ const isLoading = ref(false)
|
||||
|
||||
// 工单数据
|
||||
const ticketList = ref([])
|
||||
const searchKeyword = 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('') // 默认不选择排序顺序
|
||||
@@ -138,6 +261,9 @@ const stats = reactive({
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 自动刷新定时器
|
||||
const autoRefreshTimer = ref(null)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -174,7 +300,9 @@ const fetchTicketList = async () => {
|
||||
currentPage.value,
|
||||
statusParam,
|
||||
sortBy.value,
|
||||
sortOrder.value
|
||||
sortOrder.value,
|
||||
selectedUser.value?.user_id,
|
||||
searchKeyword.value.trim()
|
||||
)
|
||||
|
||||
if (res.code === 200) {
|
||||
@@ -218,15 +346,72 @@ const fetchStats = async () => {
|
||||
}
|
||||
|
||||
// 过滤后的工单列表
|
||||
const filteredTickets = computed(() => {
|
||||
if (!searchKeyword.value) return ticketList.value
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
return ticketList.value.filter(ticket =>
|
||||
ticket.title.toLowerCase().includes(keyword) ||
|
||||
ticket.username.toLowerCase().includes(keyword) ||
|
||||
String(ticket.id).includes(keyword)
|
||||
)
|
||||
})
|
||||
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) => {
|
||||
@@ -234,6 +419,12 @@ const filterByStatus = (status) => {
|
||||
activeStatus.value = status
|
||||
currentPage.value = 1
|
||||
fetchTicketList()
|
||||
|
||||
// 切换状态时重新设置定时器
|
||||
stopAutoRefresh()
|
||||
if (status === 'pending') {
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// 排序变化处理
|
||||
@@ -242,9 +433,6 @@ const handleSortChange = () => {
|
||||
fetchTicketList()
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {}
|
||||
|
||||
// 分页处理
|
||||
const handleSizeChange = () => {
|
||||
currentPage.value = 1
|
||||
@@ -291,11 +479,49 @@ const handleComplete = (ticket) => {
|
||||
}).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()
|
||||
}
|
||||
})
|
||||
|
||||
// 当页面被激活时(从详情页返回时)
|
||||
@@ -305,6 +531,22 @@ onActivated(() => {
|
||||
refreshList()
|
||||
}
|
||||
isFirstLoad = false
|
||||
|
||||
// 重新启动自动刷新(如果是待处理状态)
|
||||
if (activeStatus.value === 'pending') {
|
||||
startAutoRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onBeforeUnmount(() => {
|
||||
stopAutoRefresh()
|
||||
if (userSearchTimer.value) {
|
||||
clearTimeout(userSearchTimer.value)
|
||||
}
|
||||
if (keywordSearchTimer.value) {
|
||||
clearTimeout(keywordSearchTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -317,36 +559,36 @@ onActivated(() => {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 50px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
justify-content: flex-start;
|
||||
padding: 14px 20px 0;
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: #f5f7fa;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-item.pending.active { background: #e6a23c; }
|
||||
@@ -359,10 +601,75 @@ onActivated(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
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 {
|
||||
@@ -378,8 +685,6 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
@@ -391,4 +696,232 @@ onActivated(() => {
|
||||
: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
@@ -3,6 +3,10 @@
|
||||
<!-- 顶部信息栏 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="router.go(-1)" link class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<h2 class="page-title">用户余额管理</h2>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ getCurrentUserName() }}</span>
|
||||
@@ -77,9 +81,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="Note" label="备注" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="PaymentOrderId" label="支付订单ID" width="150" show-overflow-tooltip>
|
||||
<el-table-column label="支付订单ID" width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.PaymentOrderId || '-' }}
|
||||
<el-link v-if="row.PaymentOrderId" type="primary" :underline="false" @click="router.push({ path: '/order/list', query: { key: row.PaymentOrderId } })">{{ row.PaymentOrderId }}</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="180">
|
||||
@@ -202,12 +207,13 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getUserBalance, getUserBalanceRecord, editUserBalance, addUserConsumption, getUserBalanceCount, getUserList, refundBalance } from '@/api/admin/user'
|
||||
import { Refresh, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { getUserBalanceRecord, editUserBalance, addUserConsumption, getUserBalanceCount, getUserList, refundBalance } from '@/api/admin/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 余额类型映射
|
||||
const userBalance = {
|
||||
@@ -305,7 +311,7 @@ const currentBalanceDisplay = computed(() => {
|
||||
// 获取用户列表(用于显示用户名)
|
||||
const getUserListData = async () => {
|
||||
try {
|
||||
const res = await getUserList({ page: 1, count: 1000, key: '' })
|
||||
const res = await getUserList({ page: 1, count: 10, key: '' })
|
||||
if (res.data.code === 200) {
|
||||
userList.value = res.data.data.data || []
|
||||
}
|
||||
@@ -590,7 +596,12 @@ watch(
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user