15 Commits

Author SHA1 Message Date
shiran 8c49c74b72 Merge pull request 'master' (#17) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m34s
Build and Deploy Vue3 / deploy (push) Successful in 1m57s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#17
2026-01-06 21:58:14 +08:00
shiran f8cac7e976 Merge pull request 'master' (#16) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m20s
Build and Deploy Vue3 / deploy (push) Successful in 1m52s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#16
2026-01-01 03:30:09 +08:00
shiran deebef26dd Merge pull request 'master' (#15) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m30s
Build and Deploy Vue3 / deploy (push) Successful in 2m12s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#15
2025-12-20 15:47:29 +08:00
shiran 5fb53a2fdd Merge pull request 'master' (#14) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 2m6s
Build and Deploy Vue3 / deploy (push) Successful in 2m12s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#14
2025-12-16 15:36:59 +08:00
shiran 8897a62dc7 Merge pull request '更新 .gitea/workflows/build-service-server.yaml' (#13) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m46s
Build and Deploy Vue3 / deploy (push) Successful in 2m8s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#13
2025-12-12 21:19:43 +08:00
shiran baec1e3685 Merge pull request 'master' (#12) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m44s
Build and Deploy Vue3 / deploy (push) Has been cancelled
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#12
2025-12-12 19:12:25 +08:00
shiran 4b73cb3ea0 Merge pull request 'fix:修改获取镜像套餐参数' (#9) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 17m43s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#9
2025-10-15 17:16:21 +08:00
shiran 8ba17ff6d0 Merge pull request 'fix:修改镜像展示label' (#8) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 22m3s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#8
2025-10-07 16:36:07 +08:00
wlkjyy 05ad6f8a44 Merge pull request 'fix: 修复了创建虚拟机无法获取镜像列表的BUG' (#7) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 9m0s
Reviewed-on: https://gitlab.s1f.top/lin/ApiServer-Web-admin_dashboard_pc/pulls/7
2025-10-07 00:24:13 +08:00
shiran fcfde5191e Merge pull request 'master' (#6) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 3m14s
Build and Deploy Vue3 / deploy (push) Successful in 17m10s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#6
2025-10-06 21:36:43 +08:00
shiran 41295f27f0 Merge pull request 'fix:edit' (#5) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m22s
Build and Deploy Vue3 / deploy (push) Successful in 1m51s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#5
2025-10-05 15:52:12 +08:00
shiran 225228f666 Merge pull request 'feat:添加服务器新建容器' (#4) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 20m53s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#4
2025-10-04 23:25:58 +08:00
shiran 2e79be0b0f Merge pull request 'feat:添加新增虚拟机' (#3) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 2m53s
Build and Deploy Vue3 / deploy (push) Successful in 24m39s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#3
2025-10-01 19:01:36 +08:00
shiran 8847848d59 Merge pull request 'master' (#2) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 1m47s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#2
2025-10-01 01:17:14 +08:00
shiran 8e698c2644 Merge pull request '更新 .gitea/workflows/build-service-server.yaml' (#1) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 4m13s
Build and Deploy Vue3 / deploy (push) Successful in 16m55s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#1
2025-09-29 23:21:02 +08:00
129 changed files with 3312 additions and 73214 deletions
-5
View File
@@ -17,12 +17,7 @@ store封装到src/store目录下。
注册侧边栏在/config/menus.js文件中。
新添加要求:
在遇到用户id需要填写和修改的弹窗将其修改为可预览样式
关于填写表单为推荐人id的需要使用组件AvatarSelector展示,如果是文件id或者是封面id 的也需要预览展示需要向头像列表组件一样,可以弄个文件组件/api/v1/admin/file/list这个是文件列表接口
规则:
1.只要涉及弹窗添加和修改xxxid类型的就需要生成一个弹窗组件并使用到页面中
## 1. 基础布局规范
```css
-1
View File
@@ -1 +0,0 @@
VITE_API_BASE_URL='https://apiservertest.s1f.ren'
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
runs-on: ninBo
steps:
- name: Download Artifact
uses: https://gitea.s1f.ren/actions/download-artifact@v3
uses: actions/download-artifact@v3
with:
name: vue3-build
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
runs-on: ninBo
steps:
- name: Download Artifact
uses: https://gitea.s1f.ren/actions/download-artifact@v3
uses: actions/download-artifact@v3
with:
name: vue3-build
View File
+931 -984
View File
File diff suppressed because it is too large Load Diff
+1 -72
View File
@@ -226,16 +226,11 @@ 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;
}
/* 表格扁平化 */
@@ -439,70 +434,4 @@ 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>
-757
View File
@@ -1,757 +0,0 @@
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' }
})
}
-8
View File
@@ -33,11 +33,3 @@ export const updateOrder = (data) => {
}
})
}
/**重试订单流程 */
export const retryOrderHook = (data) => {
return http2.post('/api/v1/admin/order/retry_hook', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
+79
View File
@@ -0,0 +1,79 @@
// 商品管理 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()
}
-109
View File
@@ -144,113 +144,4 @@ 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})
}
+4
View File
@@ -18,6 +18,10 @@ 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) => {
-111
View File
@@ -1,111 +0,0 @@
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' } })
-24
View File
@@ -1,24 +0,0 @@
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 })
-64
View File
@@ -1,64 +0,0 @@
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')
}
-10
View File
@@ -7,14 +7,4 @@ 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 })
}
+1 -3
View File
@@ -5,13 +5,11 @@ import request from "@/utils/request.js";
* @returns {Promise}
*/
export function getTickerList(count, page, status, orderBy, order, userId, keyword) {
export function getTickerList(count, page, status, orderBy, order) {
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)
}
+55 -136
View File
@@ -2,94 +2,65 @@
<el-dialog
:model-value="visible"
title="选择用户"
width="700px"
width="800px"
class="user-selector-dialog"
append-to-body
@update:model-value="handleVisibleChange"
>
<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 }"
<!-- 搜索栏 -->
<div class="selector-search">
<el-input
v-model="searchParams.key"
placeholder="搜索用户名或ID"
clearable
@keyup.enter="handleSearch"
style="width: 300px; margin-right: 12px"
>
<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"
/>
<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>
</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>
@@ -98,7 +69,7 @@
<script setup>
import { ref, reactive, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { Search } from '@element-plus/icons-vue'
import { getUserList } from '@/api/admin/user'
import { ElMessage } from 'element-plus'
@@ -126,7 +97,9 @@ const searchParams = reactive({
watch(() => props.visible, (newVal) => {
if (newVal) {
selectedUser.value = null
fetchUserList()
if (userList.value.length === 0) {
fetchUserList()
}
}
})
@@ -190,44 +163,11 @@ const confirmSelection = () => {
</script>
<style scoped>
.user-selector-content {
max-height: 500px;
overflow: hidden;
}
.selector-search {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 16px;
padding: 12px 0;
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 {
@@ -235,19 +175,6 @@ 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;
}
@@ -257,16 +184,8 @@ const confirmSelection = () => {
}
:deep(.current-row) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.current-row td) {
background-color: var(--el-color-primary-light-8) !important;
color: var(--el-color-primary);
}
:deep(.el-avatar) {
background-color: var(--el-color-primary-light-5);
color: #fff;
font-size: 12px;
font-weight: bold;
}
</style>
+7 -12
View File
@@ -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,10 +118,6 @@ import { closeAllMessage } from '../../utils/message'
currentCoverId: {
type: [String, Number],
default: ''
},
title: {
type: String,
default: '选择文件'
}
})
@@ -274,7 +270,6 @@ 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)
@@ -1,392 +0,0 @@
<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>
@@ -1,100 +0,0 @@
<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>
@@ -1,98 +0,0 @@
<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>
-131
View File
@@ -1,131 +0,0 @@
<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>
-684
View File
@@ -1,684 +0,0 @@
<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">
支持jpgpnggifwebp等图片格式单个文件不超过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>
-124
View File
@@ -1,124 +0,0 @@
<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>
@@ -1,83 +0,0 @@
<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>
-148
View File
@@ -1,148 +0,0 @@
<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>
@@ -1,159 +0,0 @@
<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>
-90
View File
@@ -1,90 +0,0 @@
<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>
@@ -1,360 +0,0 @@
<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>
-234
View File
@@ -1,234 +0,0 @@
<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>
@@ -1,312 +0,0 @@
<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>
-418
View File
@@ -1,418 +0,0 @@
<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>
@@ -1,112 +0,0 @@
<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>
-444
View File
@@ -1,444 +0,0 @@
<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>
-567
View File
@@ -1,567 +0,0 @@
<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>
@@ -1,134 +0,0 @@
<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>
@@ -1,143 +0,0 @@
<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>
@@ -1,144 +0,0 @@
<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>
-110
View File
@@ -1,110 +0,0 @@
<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>
@@ -1,134 +0,0 @@
<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>
-389
View File
@@ -1,389 +0,0 @@
<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>
@@ -1,51 +0,0 @@
<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>
+43 -394
View File
@@ -1,13 +1,9 @@
<template>
<div class="admin-layout" :class="{ 'sidebar-collapsed': isCollapsed, 'mobile-open': isMobileMenuOpen }">
<!-- 移动端遮罩层 -->
<div class="mobile-overlay" v-if="isMobileMenuOpen" @click="closeMobileMenu"></div>
<div class="admin-layout">
<!-- 侧边栏 -->
<div class="sidebar" :class="{ 'collapsed': isCollapsed }">
<div class="sidebar">
<div class="logo-container">
<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" />
<img src="@/assets/logo.png" alt="Logo" class="logo-img" />
</div>
<el-scrollbar class="sidebar-scrollbar">
<el-menu
@@ -17,20 +13,11 @@
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>
<!-- 主区域 -->
@@ -38,14 +25,10 @@
<!-- 顶部导航 -->
<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 hidden-mobile">
<div class="navbar-item">
<el-tooltip content="全屏" placement="bottom">
<el-button type="text" class="header-btn" @click="toggleFullScreen">
<el-icon :size="18"><full-screen /></el-icon>
@@ -56,9 +39,9 @@
<div class="navbar-item">
<el-dropdown trigger="click">
<div class="avatar-container">
<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>
<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>
</div>
<template #dropdown>
<el-dropdown-menu>
@@ -98,7 +81,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SidebarMenuItem from './SidebarMenuItem.vue'
import Breadcrumb from './Breadcrumb.vue'
@@ -109,10 +92,7 @@ import {
ArrowDown,
User,
Key,
SwitchButton,
Fold,
Expand,
Menu
SwitchButton
} from '@element-plus/icons-vue'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import { ElMessageBox } from 'element-plus'
@@ -125,46 +105,11 @@ 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) {
@@ -184,35 +129,9 @@ 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>
@@ -222,18 +141,6 @@ onUnmounted(() => {
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;
@@ -241,15 +148,7 @@ onUnmounted(() => {
background-color: #ffffff;
border-right: 1px solid #e1e8ed;
overflow: hidden;
z-index: 999;
transition: width 0.3s ease;
display: flex;
flex-direction: column;
position: relative;
}
.sidebar.collapsed {
width: 64px;
z-index: 20;
}
.logo-container {
@@ -260,7 +159,6 @@ onUnmounted(() => {
padding: 0 20px;
background-color: #ffffff;
border-bottom: 1px solid #e1e8ed;
flex-shrink: 0;
}
.logo-img {
@@ -269,15 +167,8 @@ onUnmounted(() => {
object-fit: contain;
}
.logo-img-mini {
height: 32px;
width: 32px;
object-fit: contain;
}
.sidebar-scrollbar {
flex: 1;
overflow: hidden;
height: calc(100vh - 70px);
}
.sidebar-menu {
@@ -287,32 +178,6 @@ onUnmounted(() => {
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;
@@ -320,7 +185,6 @@ onUnmounted(() => {
flex-direction: column;
background-color: #f0f2f5;
overflow: hidden;
min-width: 0;
}
/* 顶部导航栏样式 */
@@ -333,21 +197,18 @@ onUnmounted(() => {
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 {
@@ -425,63 +286,6 @@ onUnmounted(() => {
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;
@@ -542,232 +346,77 @@ onUnmounted(() => {
/* Element Plus 菜单项样式优化 */
:deep(.el-menu) {
border-right: none;
padding: 8px 0;
}
/* 一级菜单标题(有子菜单) */
:deep(.el-sub-menu__title) {
height: 48px;
line-height: 48px;
margin: 2px 8px;
padding: 0 16px !important;
border-radius: 6px;
transition: all 0.2s ease;
height: 50px;
line-height: 50px;
margin: 0;
padding: 0 20px;
transition: background-color 0.2s ease;
color: #34495e !important;
font-weight: 500;
font-size: 14px;
}
:deep(.el-sub-menu__title:hover) {
background-color: #f5f7fa !important;
background-color: #f8f9fa !important;
color: #2c3e50 !important;
}
/* 一级菜单项(无子菜单) */
: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;
:deep(.el-menu-item) {
height: 50px;
line-height: 50px;
margin: 0;
padding: 0 20px;
transition: background-color 0.2s ease;
color: #34495e !important;
font-weight: 500;
font-size: 14px;
}
:deep(.sidebar-menu > .el-menu-item:hover) {
background-color: #f5f7fa !important;
:deep(.el-menu-item:hover) {
background-color: #f8f9fa !important;
color: #2c3e50 !important;
}
:deep(.sidebar-menu > .el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.08) !important;
:deep(.el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.1) !important;
color: #2c3e50 !important;
font-weight: 600;
position: relative;
}
:deep(.sidebar-menu > .el-menu-item.is-active::before) {
:deep(.el-menu-item.is-active::before) {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
top: 0;
width: 3px;
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;
height: 100%;
background-color: #2c3e50;
}
/* 二级菜单中的子菜单标题(三级菜单父级) */
: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;
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: #2c3e50 !important;
background-color: #f8f9fa !important;
}
: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) {
background-color: #fafbfc !important;
margin: 0;
padding: 0;
}
: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) {
:deep(.el-sub-menu .el-menu-item) {
margin: 0;
padding-left: 48px !important;
background-color: transparent !important;
padding: 4px 0;
}
/* 三级菜单项 */
: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(.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::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) {
+29 -32
View File
@@ -1,25 +1,22 @@
<template>
<el-sub-menu v-if="hasChildren" :index="menu.path">
<template #title>
<el-icon v-if="menu.icon || menu.meta?.icon" class="menu-icon">
<el-icon v-if="menu.icon || menu.meta?.icon">
<component :is="menu.icon || menu.meta?.icon" />
</el-icon>
<span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
<span>{{ menu.title || menu.meta?.title }}</span>
</template>
<sidebar-menu-item
v-for="child in menu.children"
:key="child.path"
:menu="child"
:level="level + 1"
:menu="child"
/>
</el-sub-menu>
<el-menu-item v-else :index="menu.path">
<el-icon v-if="menu.icon || menu.meta?.icon" class="menu-icon">
<el-icon v-if="menu.icon || menu.meta?.icon">
<component :is="menu.icon || menu.meta?.icon" />
</el-icon>
<template #title>
<span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
</template>
<template #title>{{ menu.title || menu.meta?.title }}</template>
</el-menu-item>
</template>
@@ -32,10 +29,6 @@ const props = defineProps({
menu: {
type: Object,
required: true
},
level: {
type: Number,
default: 1
}
})
@@ -46,45 +39,49 @@ const hasChildren = computed(() => {
</script>
<style scoped>
/* 菜单图标样式 */
.menu-icon {
margin-right: 10px;
.el-icon {
margin-right: 12px;
width: 20px;
height: 20px;
text-align: center;
color: #7f8c8d;
transition: color 0.2s ease;
font-size: 18px;
flex-shrink: 0;
}
/* 菜单标题 */
.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) {
.el-menu-item .el-icon, :deep(.el-sub-menu__title .el-icon) {
color: #7f8c8d !important;
transition: color 0.2s ease;
}
.el-menu-item:hover .menu-icon,
:deep(.el-sub-menu__title:hover .menu-icon) {
.el-menu-item:hover .el-icon,
:deep(.el-sub-menu__title:hover .el-icon) {
color: #34495e !important;
}
/* 激活菜单项图标 */
.el-menu-item.is-active .menu-icon {
.el-menu-item.is-active .el-icon {
color: #2c3e50 !important;
}
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .menu-icon) {
:deep(.el-sub-menu.is-active > .el-sub-menu__title .el-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>
+37 -145
View File
@@ -1,9 +1,6 @@
<template>
<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-container">
<div class="tags-view-wrapper">
<div class="tags-view-scroll">
<router-link
v-for="tag in visitedViews"
@@ -26,10 +23,6 @@
</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">
@@ -72,20 +65,28 @@ 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)
}
@@ -93,11 +94,13 @@ const initTags = () => {
}
}
// 刷新选中的标签
const refreshSelectedTag = (view) => {
const { fullPath } = view
router.replace('/redirect' + fullPath)
}
// 关闭选中的标签
const closeSelectedTag = (view) => {
tagsViewStore.delVisitedView(view).then((visitedViews) => {
if (isActive(view)) {
@@ -106,11 +109,15 @@ const closeSelectedTag = (view) => {
})
}
// 关闭其他标签
const closeOthersTags = () => {
router.push(selectedTag.value)
tagsViewStore.delOthersViews(selectedTag.value)
tagsViewStore.delOthersViews(selectedTag.value).then(() => {
// moveToCurrentTag() // 如果有滚动逻辑
})
}
// 关闭左侧标签
const closeLeftTags = () => {
tagsViewStore.delLeftViews(selectedTag.value).then((visitedViews) => {
if (!visitedViews.find(i => i.path === route.path)) {
@@ -119,6 +126,7 @@ const closeLeftTags = () => {
})
}
// 关闭右侧标签
const closeRightTags = () => {
tagsViewStore.delRightViews(selectedTag.value).then((visitedViews) => {
if (!visitedViews.find(i => i.path === route.path)) {
@@ -127,17 +135,20 @@ 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 {
@@ -146,14 +157,17 @@ 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
@@ -167,112 +181,30 @@ 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>
@@ -283,21 +215,17 @@ 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%;
overflow-x: scroll;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
width: 100%;
display: flex;
align-items: center;
padding: 0 12px;
overflow-x: auto;
white-space: nowrap;
position: relative;
}
.tags-view-wrapper::-webkit-scrollbar {
@@ -305,51 +233,18 @@ onBeforeUnmount(() => {
}
.tags-view-scroll {
display: inline-flex;
display: 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;
@@ -357,8 +252,6 @@ onBeforeUnmount(() => {
transition: all 0.2s ease;
border: 1px solid transparent;
border-bottom: none;
flex-shrink: 0;
white-space: nowrap;
}
.tag {
@@ -433,7 +326,6 @@ onBeforeUnmount(() => {
background-color: rgba(231, 76, 60, 0.1);
}
/* 右键菜单 */
.contextmenu {
position: fixed;
z-index: 100;
@@ -469,4 +361,4 @@ onBeforeUnmount(() => {
.contextmenu li:hover .el-icon {
color: #2c3e50;
}
</style>
</style>
-55
View File
@@ -1,55 +0,0 @@
/**
* 环境配置文件
* 所有硬编码的 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
}
+19 -40
View File
@@ -39,16 +39,15 @@ export const menus = [
title: '商品管理',
icon: 'Goods',
children: [
{ path: '/product/manage', title: '商品管理' }
]
},
{
path: '/user-goods',
title: '用户商品管理',
icon: 'ShoppingCart',
children: [
{ path: '/user-goods/list', title: '所有商品' },
{ path: '/user-goods/vm-list', title: '云服务器' }
{
path: '/product/list',
title: '商品列表'
},
{
path: '/product/group',
title: '商品分组'
},
]
},
{
@@ -86,10 +85,12 @@ export const menus = [
{
path: '/activity/signin',
title: '签到活动'
},
{
path: '/activity/groupbuy',
title: '拼团管理'
},{
path:'/activity/groupbuy',
title:'拼团活动',
},{
path:'/activity/groupbuy-type',
title:'拼团类型'
}
]
},
@@ -141,24 +142,6 @@ 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: '系统管理',
@@ -183,16 +166,12 @@ export const menus = [
title: '域名白名单'
},
{
path: '/system/setting-manage',
title: '配置管理'
path: '/system/setting-group',
title: '配置管理'
},
{
path: '/system/menu',
title: '菜单管理',
children: [
{ path: '/system/menu-manage', title: '菜单列表' },
{ path: '/system/menu-permission', title: '菜单权限' }
]
path: '/system/setting-list',
title: '配置管理'
}
]
}
+37 -221
View File
@@ -228,50 +228,29 @@ const routes = [
{
path: 'product',
name: 'Product',
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',
meta: {
title: '商品管理',
icon: 'Goods'
},
redirect: '/product/list',
children: [
{
path: 'list',
name: 'UserGoodsList',
component: () => import('../views/product/UserGoodsList.vue'),
meta: { title: '所有商品' }
name: 'ProductList',
component: () => import('../views/product/ProductList.vue'),
meta: {
title: '商品列表'
}
},
{
path: 'detail/:id',
name: 'UserGoodsDetail',
component: () => import('../views/product/UserGoodsDetail.vue'),
meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' }
path: 'group',
name: 'ProductGroup',
component: () => import('../views/product/ProductGroup.vue'),
meta: {
title: '商品分组'
}
},
{
path: 'vm-list',
name: 'UserVmList',
component: () => import('../views/user-vm/UserVmList.vue'),
meta: { title: '云服务器' }
},
{
path: 'vm-detail',
name: 'UserVmDetail',
component: () => import('../views/user-vm/UserVmDetail.vue'),
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
}
]
},
// 订单管理路由
@@ -353,10 +332,18 @@ const routes = [
},
{
path: '/activity/groupbuy',
name: 'GroupBuyManage',
component: () => import('../views/activity/GroupBuyManage.vue'),
name: 'GroupBuyActivity',
component: () => import('../views/activity/GroupBuyActivity.vue'),
meta: {
title: '拼团管理'
title: '拼团活动'
}
},
{
path: '/activity/groupbuy-type',
name: 'GroupBuyType',
component: () => import('../views/activity/GroupBuyType.vue'),
meta: {
title: '拼团类型'
}
}
]
@@ -403,187 +390,16 @@ const routes = [
meta: { title: '域名白名单' }
},
{
path: 'setting-manage',
name: 'SettingManage',
component: () => import('../views/system/SettingManage.vue'),
path: 'setting-group',
name: 'SettingGroup',
component: () => import('../views/system/SettingGroup.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'
}
}
]
},
+2 -19
View File
@@ -4,28 +4,11 @@ import {ref} from "vue";
export const useUserStore = defineStore('userStore',() => {
// 初始化时从localStorage读取用户信息
const savedUserInfo = localStorage.getItem('userInfo')
let userInfo = ref(savedUserInfo ? JSON.parse(savedUserInfo) : {})
let userInfo = ref({})
function setUserInfo(u){
userInfo.value = u
// 同步保存到localStorage
if (u && Object.keys(u).length > 0) {
localStorage.setItem('userInfo', JSON.stringify(u))
}
}
// 清除用户信息
function clearUserInfo() {
userInfo.value = {}
localStorage.removeItem('userInfo')
}
// 获取用户头像
function getUserAvatar() {
return userInfo.value?.cover || ''
}
return {userInfo, setUserInfo, clearUserInfo, getUserAvatar}
return {userInfo,setUserInfo}
})
+1 -374
View File
@@ -114,384 +114,11 @@ 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) {
-160
View File
@@ -1,160 +0,0 @@
/**
* 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)
}
-157
View File
@@ -1,157 +0,0 @@
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
}
+27 -148
View File
@@ -1,104 +1,27 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
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 baseUrl = 'https://apiservertest.s1f.ren' // SSL证书有问题
// const baseUrl = 'http://apiservertest.s1f.ren' // HTTP版本
// const baseUrl = 'https://cloudapi.007yjs.com' // 尝试备用地址
// 检查URL是否需要认证
const urlNeedAuth = (url) => {
return !noAuthUrlList.some(noAuthUrl => url.includes(noAuthUrl))
// 这里可以添加不需要认证的URL列表
const noAuthUrls = ['/v1/user/login', '/v1/user/check/get_code_img', '/v1/user/register']
return !noAuthUrls.some(noAuthUrl => url.includes(noAuthUrl))
}
// 检查token是否过期
const isTokenExpired = () => {
const token = localStorage.getItem(TOKEN_KEY)
const expire = localStorage.getItem(TOKEN_EXPIRE_KEY)
const token = localStorage.getItem('token')
if (!token) return true
// 检查过期时间
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
}
// 这里可以添加token过期检查逻辑,如果有JWT可以解析它
// 简单实现,仅检查token是否存在
return false
}
class Request {
@@ -115,7 +38,7 @@ class Request {
(config) => {
// 在发送请求之前做些什么
// 例如:添加 token
const token = localStorage.getItem(TOKEN_KEY)
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@@ -184,7 +107,7 @@ class Request {
// 创建默认实例
const request = new Request({
baseURL: baseUrl,
timeout: requestTimeout,
timeout: 50000,
headers: {
'Content-Type': 'multipart/form-data'
}
@@ -195,67 +118,23 @@ export const baseURL = baseUrl
export const http2 = axios.create({
baseURL: baseUrl,
timeout: acsRequestTimeout,
timeout: 30000,
headers: {},
});
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}`
http2.interceptors.request.use(config => {
const token = localStorage.getItem('token'); // 假设 token 存储在 localStorage
if(urlNeedAuth(config.url) && isTokenExpired()){
if (token){
localStorage.removeItem('token');
ElMessage.warning('登陆过期,请重新登陆')
}
router.push('/login')
return Promise.reject();
}
// 不需要认证的请求,不添加token
config.headers.Authorization = `Bearer ${token}`;
config.url = config.url
return config
})
@@ -269,7 +148,7 @@ http2.interceptors.response.use(
}
const { status } = error.response;
if (status === 401) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('token');
ElMessage.warning('登陆过期,请重新登陆')
router.push('/login')
return Promise.reject();
+3 -153
View File
@@ -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,160 +50,10 @@ 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'
}
}
+2 -10
View File
@@ -105,24 +105,16 @@ 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){
// 保存token和过期时间
window.localStorage.setItem('token', resp.data.token)
if (resp.data.expire) {
window.localStorage.setItem('tokenExpire', resp.data.expire.toString())
}
window.localStorage.setItem('token',resp.data.token)
let userInfo = await getUserInfo()
if(userInfo.data.is_admin){
// 保存用户信息到localStorage
window.localStorage.setItem('userInfo', JSON.stringify(userInfo.data))
await router.push('/dashboard')
} else {
ElMessage.warning('你不是管理员,不能登陆到后台控制面板')
+3 -3
View File
@@ -666,7 +666,7 @@ const toLoad = async (data) => {
})
form.server_id = data
nowserver_id.value = data
let res = await getServerPlan({server_id:data,count:10})
let res = await getServerPlan({server_id:data,count:100})
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: 10})
let res = await getServerPlan({server_id: data.server_id,count: 100})
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: 10,
count: 50,
page: 1,
key: '',
user_type: 1
+1 -1
View File
@@ -262,7 +262,7 @@ const categoryRules = {
// 素材库相关
const picSwitch = ref(false)
const picPagin = reactive({
count: 10,
count: 50,
page: 1,
key: '',
user_type: 1
+2 -2
View File
@@ -244,7 +244,7 @@ const showNewCategoryInput = ref(false)
const picSwitch = ref(false)
const picLoading = ref(false)
const picPagin = reactive({
count: 10,
count: 20,
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: 10,
count: 100,
page: 1
})
if (listRes.data.code === 200) {
+7 -21
View File
@@ -467,7 +467,7 @@
<h3 class="tab-title">数据卷列表</h3>
<el-button
type="primary"
@click="handleAddVolume"
@click="showAddVolumeDialog = true"
:icon="Plus"
:disabled="vmInfo.state != 2"
>
@@ -671,11 +671,8 @@
width="500px"
>
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
<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 label="大小(GB)" prop="size">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
</el-form-item>
</el-form>
<template #footer>
@@ -696,11 +693,8 @@
>
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
<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 label="大小(GB)" prop="size">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
</el-form-item>
</el-form>
<template #footer>
@@ -1073,7 +1067,6 @@ const showMigrateVolumeDialog = ref(false);
const currentVolumeToEdit = ref(null);
const volumeForm = reactive({
size: 10,
_sizeUnit: 'GB'
});
const volumeFormRef = ref(null);
const volumeRules = {
@@ -2378,7 +2371,6 @@ const handleAddVolume = () => {
showAddVolumeDialog.value = true;
// 重置表单
volumeForm.size = 10;
volumeForm._sizeUnit = 'GB';
};
// 编辑数据卷
@@ -2386,7 +2378,6 @@ const handleEditVolume = (volume) => {
currentVolumeToEdit.value = volume;
// 填充表单
volumeForm.size = volume.size;
volumeForm._sizeUnit = 'GB';
showEditVolumeDialog.value = true;
};
@@ -2413,10 +2404,9 @@ 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(sizeGb),
size: String(volumeForm.size),
user_id: user_id.value
});
console.log("添加数据卷112",res)
@@ -2448,10 +2438,9 @@ 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: sizeGb
size: volumeForm.size
});
console.log("编辑数据卷数据:",res)
@@ -2781,7 +2770,4 @@ 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>
+1 -1
View File
@@ -618,7 +618,7 @@ const fetchPlanList = async () => {
try {
const response = await getServerPlan({
server_id: props.ID,
count: 10
count: 100
});
if (response && response.data && response.data.code === 200) {
+1 -5
View File
@@ -315,11 +315,7 @@
class="data-table"
>
<el-table-column prop="id" label="ID" width="80" />
<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="size" label="空间大小(MB)" width="140" />
<el-table-column prop="mount_path" label="挂载路径" min-width="200" />
<el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table>
+3 -3
View File
@@ -1901,7 +1901,7 @@ const GetSpecs = async () => {
try {
let plans = await getServerPlan({
server_id: route.query.server_id,
count: 10
count: 30
});
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: 10
count: 100
});
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: 10,key: '',class_id: ''});
const response = await getMirrorList({server_id: route.query.server_id, page: 1, count: 999,key: '',class_id: ''});
console.log("获取镜像列表1111",response);
if (response && response.data && response.data.code === 200) {
+2 -10
View File
@@ -119,12 +119,7 @@
</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="userId" label="用户ID" width="100" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column label="队长" width="80" align="center">
<template #default="{ row }">
@@ -161,7 +156,6 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getGroupBuyList,
@@ -173,8 +167,6 @@ 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)
@@ -226,7 +218,7 @@ const fetchTags = async () => {
// tag
const fetchTypeListByTag = async (tag) => {
try {
const res = await getGroupBuyTypeList({ page: 1, count: 10, tag })
const res = await getGroupBuyTypeList({ page: 1, count: 100, tag })
if (res.code === 200) {
typeList.value = res.data?.data || []
}
-921
View File
@@ -1,921 +0,0 @@
<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>
+4 -12
View File
@@ -52,17 +52,11 @@
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="价格" prop="price">
<div class="unit-input-row">
<el-input-number v-model="form.price" :min="0" style="flex:1" />
<span class="unit-text"></span>
</div>
<el-form-item label="价格(分)" prop="price">
<el-input-number v-model="form.price" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="续费价格" prop="renewPrice">
<div class="unit-input-row">
<el-input-number v-model="form.renewPrice" :min="0" style="flex:1" />
<span class="unit-text"></span>
</div>
<el-form-item label="续费价格(分)" prop="renewPrice">
<el-input-number v-model="form.renewPrice" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="拼团人数" prop="maxPerson">
<el-input-number v-model="form.maxPerson" :min="2" :max="100" style="width: 100%" />
@@ -263,6 +257,4 @@ 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>
+2 -10
View File
@@ -67,12 +67,7 @@
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<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="container_id" label="容器ID" width="280" show-overflow-tooltip />
<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">
@@ -151,7 +146,6 @@
<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,
@@ -165,8 +159,6 @@ import {
} from '@/utils/acs/audit'
const router = useRouter()
//
const queryParams = reactive({
domain: '',
@@ -427,7 +419,7 @@ const getFullStatsData = async () => {
//
const statsParams = {
page: 1,
count: 10, //
count: 1000, //
server_id: '',
user_id: '',
key: queryParams.domain || ''
+1 -9
View File
@@ -35,12 +35,7 @@
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<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="container_id" label="容器ID" width="280" show-overflow-tooltip />
<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">
@@ -200,7 +195,6 @@
<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,
@@ -214,8 +208,6 @@ import {
} from '@/utils/acs/audit'
const router = useRouter()
//
const queryParams = reactive({
domain: '',
+25 -485
View File
@@ -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,137 +116,10 @@
<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">
@@ -334,45 +207,28 @@
</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, Tickets, View
Setting, Calendar, Filter
} 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 = computed(() => [
const statisticsCards = ref([
{
title: '用户量',
value: userCount.value.toLocaleString(),
title: '访问量',
value: '8,846',
icon: 'User',
trend: 12.5,
class: 'visitors',
@@ -381,7 +237,7 @@ const statisticsCards = computed(() => [
},
{
title: '订单量',
value: orderCount.value.toLocaleString(),
value: '1,257',
icon: 'ShoppingCart',
trend: 5.2,
class: 'orders',
@@ -389,9 +245,9 @@ const statisticsCards = computed(() => [
progressColor: 'rgba(82, 196, 26, 0.8)'
},
{
title: '工单量',
value: ticketCount.value.toLocaleString(),
icon: 'Tickets',
title: '销售额',
value: '¥ 125,430',
icon: 'Money',
trend: -2.3,
class: 'sales',
progress: 52,
@@ -408,150 +264,6 @@ const statisticsCards = computed(() => [
}
])
//
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' },
@@ -619,10 +331,6 @@ const getPriorityType = (priority) => {
}
onMounted(() => {
//
fetchStatistics()
fetchRecentLists()
initSalesChart()
initCustomerChart()
@@ -823,19 +531,15 @@ 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(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
}
.card-top {
@@ -866,10 +570,10 @@ watch(salesRange, (newVal) => {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 4px;
font-size: 24px;
width: 56px;
height: 56px;
border-radius: 12px;
font-size: 28px;
color: #fff;
}
@@ -931,6 +635,8 @@ watch(salesRange, (newVal) => {
.chart-card {
margin-bottom: 24px;
border-radius: 12px;
border: none;
overflow: hidden;
}
@@ -1038,6 +744,8 @@ watch(salesRange, (newVal) => {
.activity-card, .todo-card {
height: 100%;
border-radius: 12px;
border: none;
}
.card-header-custom {
@@ -1162,149 +870,6 @@ 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;
@@ -1323,30 +888,5 @@ 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>
+6 -18
View File
@@ -141,26 +141,17 @@
<el-radio label="percentage">百分比折扣</el-radio>
</el-radio-group>
</el-form-item>
<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 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>
<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">
<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 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>
<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 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>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
@@ -660,9 +651,6 @@ 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>
+30 -3
View File
@@ -389,7 +389,7 @@ const fetchVoucherListOptions = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 10,
count: 1000,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
@@ -407,7 +407,7 @@ const fetchProductList = async () => {
try {
const res = await getProductList({
page: 1,
count: 10
count: 1000
})
console.log('获取商品列表:', res.data)
if (res.data.code === 200) {
@@ -424,7 +424,7 @@ const fetchProductGroupList = async () => {
try {
const res = await getProductGroupList({
page: 1,
count: 10
count: 1000
})
console.log('获取商品组列表:', res.data)
if (res.data.code === 200) {
@@ -798,6 +798,33 @@ 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;
}
+30 -7
View File
@@ -71,8 +71,7 @@
<el-table-column prop="discountId" label="代金券ID" width="120" v-if="!codeId" />
<el-table-column label="用户名" min-width="150">
<template #default="{ row }">
<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>
{{ row?.user?.user_name || '-' }}
</template>
</el-table-column>
<el-table-column label="手机号" min-width="150">
@@ -240,7 +239,6 @@
<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 {
@@ -263,8 +261,6 @@ const props = defineProps({
}
})
const router = useRouter()
//
const queryParams = reactive({
code_id: props.codeId || '',
@@ -365,7 +361,7 @@ const fetchVoucherListOptions = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 10,
count: 1000,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
@@ -401,7 +397,7 @@ const fetchUserGroupList = async () => {
try {
const res = await getUserGroupList({
page: 1,
count: 10,
count: 10000,
key: ''
})
console.log('获取用户组列表:', res.data)
@@ -807,6 +803,33 @@ 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;
}
+1 -1
View File
@@ -123,7 +123,7 @@ const currentGroupBuy = ref(null)
//
const loadGroupBuyList = async () => {
try {
const resp = await getGroupBuyList({ page: 1, pageSize: 10 })
const resp = await getGroupBuyList({ page: 1, pageSize: 20 })
if (resp && resp.code === 200) {
groupBuyList.value = resp.data || []
}
+25 -48
View File
@@ -177,36 +177,30 @@
</el-radio-group>
</el-form-item>
<el-form-item label="用户" prop="user_id" v-if="addForm.target_type === 'user'">
<el-form-item label="用户" prop="user_id">
<div class="user-selector-wrapper">
<el-input
:model-value="getSelectedUserName()"
placeholder="请选择用户"
readonly
@click="openUserSelector"
>
<template #append>
<el-button @click="openUserSelector">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<div class="selected-user-display" v-if="addForm.user_id">
<el-tag type="primary" closable @close="clearSelectedUser">
{{ getSelectedUserName() }}
</el-tag>
</div>
<el-button
v-if="addForm.user_id"
type="danger"
link
@click="clearSelectedUser"
class="clear-btn"
type="primary"
plain
@click="openUserSelector"
style="width: 100%"
>
清除
<el-icon><User /></el-icon>
{{ addForm.user_id ? '重新选择用户' : '选择用户' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="用户组" prop="group_id" v-if="addForm.target_type === 'group'">
<el-form-item label="用户组" prop="group_id">
<el-select
v-model="addForm.group_id"
placeholder="请选择用户组"
:disabled="addForm.target_type === 'user'"
filterable
clearable
style="width: 100%"
@@ -484,7 +478,7 @@ const fetchVoucherListOptions = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 10,
count: 1000,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
@@ -502,7 +496,7 @@ const fetchDiscountList = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 10,
count: 100,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
@@ -513,7 +507,7 @@ const fetchDiscountList = async () => {
}
const res2 = await getDiscountCodeList({
page: 1,
count: 10,
count: 100,
discount_type: 'code'
})
console.log('获取优惠码列表:', res2.data)
@@ -533,7 +527,7 @@ const fetchVoucherOptions = async () => {
const res = await getDiscountCodeList({
discount_type: 'coupon',
page: 1,
count: 10
count: 100
})
if (res.data.code === 200) {
voucherOptions.value = res.data.data?.data || []
@@ -549,7 +543,7 @@ const fetchCodeOptions = async () => {
const res = await getDiscountCodeList({
discount_type: 'code',
page: 1,
count: 10
count: 100
})
if (res.data.code === 200) {
codeOptions.value = res.data.data?.data || []
@@ -566,7 +560,7 @@ const fetchUserList = async () => {
try {
const res = await getUserList({
page: 1,
count: 10,
count: 100,
key: ''
})
console.log('获取用户列表:', res.data)
@@ -588,7 +582,7 @@ const fetchGroupOptions = async () => {
try {
const res = await getUserGroupList({
page: 1,
count: 10
count: 100
})
if (res.data.code === 200) {
groupOptions.value = res.data.data?.data || []
@@ -659,9 +653,9 @@ const confirmUserSelection = (user) => {
ElMessage.warning('请选择一个用户')
return
}
addForm.user_id = user.user_id
addForm.user_id = user.UserId
// userOptions
if (!userOptions.value.find(u => u.user_id === user.user_id)) {
if (!userOptions.value.find(u => u.UserId === user.UserId)) {
userOptions.value.push(user)
}
userSelectorVisible.value = false
@@ -674,9 +668,8 @@ const clearSelectedUser = () => {
//
const getSelectedUserName = () => {
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}`
const user = userOptions.value.find(u => u.UserId === addForm.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${addForm.user_id}`
}
//
@@ -947,21 +940,5 @@ 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>
+12 -59
View File
@@ -105,23 +105,14 @@
<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">
<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 label="面额(元)" prop="amount">
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="width: 100%" />
</el-form-item>
<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 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>
<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 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>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
@@ -129,11 +120,8 @@
<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">
<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>
<el-form-item label="有效期(天)" prop="duration_days">
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="width: 100%" />
<div class="form-tip">代金券领取后的有效持续时间</div>
</el-form-item>
<el-form-item label="发放时间范围" prop="timeRange">
@@ -307,39 +295,9 @@ const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
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 })
//
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, '-') : ''
Object.assign(voucherForm, {
code_id: row.id,
@@ -351,14 +309,12 @@ 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 ? Math.round(row.duration / 86400) : 30, //
duration_days: row.duration ? 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)
}
//
@@ -551,9 +507,6 @@ 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>
+4 -17
View File
@@ -43,12 +43,7 @@
stripe
>
<el-table-column prop="id" label="记录ID" width="80" fixed="left" />
<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="user_id" label="用户ID" width="100" />
<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" />
@@ -63,12 +58,7 @@
<span>¥{{ row.order_amount ? (row.order_amount / 100).toFixed(2) : '0.00' }}</span>
</template>
</el-table-column>
<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 prop="order_id" label="订单ID" width="150" />
<el-table-column label="使用状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
@@ -184,7 +174,6 @@
<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'
@@ -198,8 +187,6 @@ const props = defineProps({
}
})
const router = useRouter()
//
const queryParams = reactive({
user_id: undefined,
@@ -410,7 +397,7 @@ const fetchUserList = async () => {
try {
const res = await getUserList({
page: 1,
count: 10,
count: 10000,
key: ''
})
UserOptions.value = res.data.data?.data || []
@@ -425,7 +412,7 @@ const fetchDiscountList = async () => {
const res = await getDiscountCodeList({
discount_type: 'coupon',
page: 1,
count: 10
count: 1000
})
if (res.data.code === 200) {
discountOptions.value = res.data.data?.data || []
+2 -10
View File
@@ -40,12 +40,7 @@
style="width: 100%"
>
<el-table-column prop="Id" label="ID" width="80" />
<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 prop="UserId" label="用户ID" min-width="100" />
<el-table-column label="代金券ID" min-width="110" v-if="!codeId">
<template #default="{ row }">
{{ row.discountId || '-' }}
@@ -217,7 +212,6 @@
<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 {
@@ -236,8 +230,6 @@ const props = defineProps({
}
})
const router = useRouter()
//
const queryParams = reactive({
user_id: undefined,
@@ -467,7 +459,7 @@ const fetchDiscountList = async () => {
const res = await getDiscountCodeList({
discount_type: 'coupon',
page: 1,
count: 10
count: 1000
})
if (res.data.code === 200) {
discountOptions.value = res.data.data?.data || []
+31 -380
View File
@@ -22,12 +22,6 @@
<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>搜索
@@ -73,18 +67,8 @@
<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 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 prop="userId" label="用户ID" width="100" />
<el-table-column prop="commodityId" label="商品ID" width="100" />
<el-table-column label="表名" width="120">
<template #default="{ row }">
<el-tag size="small">{{ row.table || '未知' }}</el-tag>
@@ -105,22 +89,11 @@
<span>{{ row.payNum }}</span>
</template>
</el-table-column>
<el-table-column label="订单状态" width="120">
<el-table-column label="订单状态" width="100">
<template #default="{ row }">
<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>
<el-tag :type="getStatusType(row.state)">
{{ getStatusText(row.state) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="支付方式" width="100">
@@ -138,12 +111,11 @@
<span>{{ formatDate(row.CreatedAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<el-table-column label="操作" width="200" 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>
@@ -190,10 +162,6 @@
<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>
@@ -218,149 +186,28 @@
<el-input v-model="orderForm.table" placeholder="请输入所属表" />
</el-form-item>
<el-form-item label="用户ID" prop="user_id">
<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-input-number v-model="orderForm.user_id" :min="1" placeholder="请输入用户ID" style="width: 100%" />
</el-form-item>
<el-form-item label="商品ID" prop="commodity_id">
<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-input-number v-model="orderForm.commodity_id" :min="0" placeholder="请输入商品ID" style="width: 100%" />
</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">
<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 label="价格(分)" prop="price">
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="width: 100%" />
</el-form-item>
<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 label="续费价格(分)" prop="renew_price">
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="width: 100%" />
</el-form-item>
<el-form-item label="过期时间" prop="expire_time">
<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-input-number v-model="orderForm.expire_time" :min="0" placeholder="请输入过期时间(时间戳)" style="width: 100%" />
</el-form-item>
<el-form-item label="优惠码ID" prop="discount_code_id">
<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-input-number v-model="orderForm.discount_code_id" :min="0" placeholder="请输入优惠码ID" style="width: 100%" />
</el-form-item>
<el-form-item label="代金券ID" prop="coupon_id">
<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-input-number v-model="orderForm.coupon_id" :min="0" placeholder="请输入代金券ID (必填)" style="width: 100%" />
</el-form-item>
<el-form-item label="订单状态" prop="state">
<el-radio-group v-model="orderForm.state">
@@ -386,51 +233,14 @@
</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, 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()
import { Plus, Delete, Search, Download, Refresh } from '@element-plus/icons-vue'
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder } from '@/api/admin/order'
//
const queryParams = reactive({
@@ -439,8 +249,7 @@ const queryParams = reactive({
key: '',
state: '',
user_id: '',
user_key: '',
error: null
user_key: ''
})
//
@@ -496,18 +305,6 @@ 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
@@ -522,17 +319,7 @@ const fetchOrderList = async () => {
const res = await getOrderList(params)
console.log('订单列表数据:', res.data)
if (res.data.code === 200) {
// 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
orderList.value = res.data.data.list || []
total.value = res.data.data.all_count || 0
}
} catch (error) {
@@ -543,9 +330,16 @@ const fetchOrderList = async () => {
}
}
// - 使
//
const formatDate = (dateStr) => {
return formatDateTool(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}`
}
//
@@ -581,7 +375,6 @@ const resetQuery = () => {
queryParams.state = ''
queryParams.user_id = ''
queryParams.user_key = ''
queryParams.error = null
queryParams.page = 1
fetchOrderList()
}
@@ -606,7 +399,6 @@ const handleCurrentChange = (page) => {
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
clearAllSelections()
Object.assign(orderForm, {
order_id: undefined,
name: '',
@@ -645,16 +437,6 @@ 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,
@@ -664,7 +446,7 @@ const handleEdit = (row) => {
pay_num: row.payNum,
price: row.price,
renew_price: row.renewPrice,
expire_time: expireTimeMs,
expire_time: row.expireTime ? new Date(row.expireTime).getTime() / 1000 : 0,
discount_code_id: 0, //
coupon_id: 0, //
state: row.state,
@@ -672,40 +454,6 @@ 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(() => {})
}
//
@@ -749,13 +497,6 @@ 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,
@@ -765,7 +506,7 @@ const submitForm = () => {
pay_num: Number(orderForm.pay_num),
price: Number(orderForm.price),
renew_price: Number(orderForm.renew_price),
expire_time: expireTimeSeconds,
expire_time: Number(orderForm.expire_time),
discount_code_id: Number(orderForm.discount_code_id),
coupon_id: Number(orderForm.coupon_id),
state: Number(orderForm.state),
@@ -801,67 +542,8 @@ 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>
@@ -1019,35 +701,4 @@ 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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-418
View File
@@ -1,418 +0,0 @@
<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
@@ -1,204 +0,0 @@
<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
+16 -48
View File
@@ -184,57 +184,25 @@ 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()
// localStoragestore
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(getSavedUserInfo())
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 userForm = reactive({...userInfo})
@@ -328,9 +296,9 @@ const handleAvatarSuccess = (res) => {
//
const fetchUserInfo = async () => {
try {
// store
const savedInfo = getSavedUserInfo()
Object.assign(userInfo, savedInfo)
// API
await new Promise(resolve => setTimeout(resolve, 500))
// userInfo
} catch (error) {
ElMessage.error('获取用户信息失败')
console.error(error)
+27
View File
@@ -349,6 +349,33 @@ 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;
}
-400
View File
@@ -1,400 +0,0 @@
<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>
-610
View File
@@ -1,610 +0,0 @@
<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>
+46 -253
View File
@@ -24,15 +24,9 @@
</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;">
{{ 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-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>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
@@ -121,35 +115,14 @@
</div>
</el-card>
<!-- 用户选择弹窗 - 使用UserListSelector组件 -->
<UserListSelector
<!-- 用户选择弹窗 -->
<el-dialog
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"
@@ -169,6 +142,7 @@
<el-button @click="resetUserSearch">重置</el-button>
</div>
<!-- 用户表格 -->
<el-table
v-loading="userSelectorLoading"
:data="userSelectorList"
@@ -190,7 +164,7 @@
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="userSearchParams.page"
v-model:page-size="userSearchParams.count"
@@ -204,12 +178,12 @@
/>
<template #footer>
<el-button @click="userSelectorVisibleOld = false">取消</el-button>
<el-button @click="userSelectorVisible = false">取消</el-button>
<el-button type="primary" @click="confirmUserSelection" :disabled="!selectedUserTemp">
确定选择
</el-button>
</template>
</el-dialog> -->
</el-dialog>
<!-- 分配权限对话框 -->
<el-dialog
v-model="dialogVisible"
@@ -230,80 +204,45 @@
<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="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"
>
清除
<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 ? '重新选择' : '选择用户' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="管理员组" prop="admin_group_id" v-if="permissionForm.owner_type === 'group'">
<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-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>
</el-form-item>
<el-form-item label="路径权限" prop="permission_id">
<div class="recommend-user-selector">
<el-input
:model-value="getFormPermissionName()"
placeholder="点击选择路径权限"
readonly
@click="openPermissionSelector"
<div style="display: flex; gap: 8px;">
<el-select
v-model="permissionForm.permission_id"
placeholder="选择路径权限"
filterable
style="flex: 1"
:loading="permissionLoading"
>
<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>
<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>
</div>
<div class="form-tip"> {{ permissionOptions.length }} 个路径权限可选</div>
</el-form-item>
@@ -344,9 +283,6 @@
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,
@@ -361,8 +297,6 @@ 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({
@@ -373,8 +307,6 @@ 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: '',
@@ -392,125 +324,16 @@ 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('请选择一个用户')
@@ -861,7 +684,7 @@ const fetchUserList = async () => {
try {
const res = await getUserList({
page: 1,
count: 10,
count: 10000,
key: ''
})
if (res.data.code === 200) {
@@ -877,7 +700,7 @@ const fetchAdminGroupList = async () => {
try {
const res = await getAdminGroupList({
page: 1,
count: 10
count: 1000
})
if (res.data.code === 200) {
adminGroupOptions.value = res.data.data?.data || []
@@ -893,7 +716,7 @@ const fetchPermissionList = async () => {
try {
const res = await getPermissionList({
page: 1,
count: 10
count: 10000
})
if (res.data.code === 200) {
permissionOptions.value = res.data.data?.list || []
@@ -1014,34 +837,4 @@ 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>
+600
View File
@@ -0,0 +1,600 @@
<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>
+409
View File
@@ -0,0 +1,409 @@
<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
+88 -98
View File
@@ -56,7 +56,7 @@
</el-table-column>
<el-table-column prop="type" label="文件类型" width="120">
<template #default="{ row }">
<el-tag :type="getFileTypeColor(row.type, row.url, row.realName)">
<el-tag :type="getFileTypeColor(row.type)">
{{ 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, fileDetail.realName) && fileDetail.url"
v-if="isImageFile(fileDetail.type) && 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.url, fileDetail.realName)">{{ fileDetail.type || '未知' }}</el-tag>
<el-tag :type="getFileTypeColor(fileDetail.type)">{{ 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,37 +302,14 @@ const uploadForm = reactive({
const uploadFileList = ref([])
//
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 isImageFile = (type) => {
const imageTypes = ['cover', 'image', 'avatar', 'photo', 'picture']
return imageTypes.includes(type?.toLowerCase())
}
//
const getFileTypeColor = (type, url, realName) => {
if (isImageFile(type, url, realName)) return 'success'
const getFileTypeColor = (type) => {
if (isImageFile(type)) return 'success'
const colorMap = {
'document': 'primary',
'video': 'warning',
@@ -417,12 +394,8 @@ const handleView = async (row) => {
const res = await getFileDetail({ file_id: row.id })
console.log('文件详情数据:', res.data)
if (res.data.code === 200) {
// URL
const fileData = res.data.data.data || res.data.data
fileDetail.value = {
...fileData,
url: fileData.url || res.data.data.url || ''
}
fileDetail.value = res.data.data.data
fileDetail.value.url = res.data.data.url
detailDialogVisible.value = true
}
} catch (error) {
@@ -537,69 +510,23 @@ const handleRemoveFile = (file, fileList) => {
uploadFileList.value = fileList
}
//
const handleSubmitUpload = async () => {
//
const handleSubmitUpload = () => {
if (uploadFileList.value.length === 0) {
ElMessage.warning('请至少选择一个文件')
return
}
//
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
})
//
const filesToUpload = uploadFileList.value.filter(file =>
file.status !== 'success' && file.status !== 'uploading'
)
if (filesToUpload.length === 0) {
ElMessage.info('没有可上传的有效文件')
ElMessage.info('所有文件已上传完成')
return
}
// 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 || '上传失败,请重试')
}
//
uploadRef.value?.submit()
}
//
@@ -614,21 +541,84 @@ const beforeUpload = (file) => {
if (!isLt10M) {
ElMessage.warning(`文件 ${file.name} 大小超过 10MB`)
}
//
//
return true
}
// handleSubmitUpload
//
const handleCustomUpload = async (options) => {
// handleSubmitUpload
// el-upload auto-upload false
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)
}
}
//
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')
+27
View File
@@ -528,6 +528,33 @@ 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;
}
+12 -214
View File
@@ -347,8 +347,7 @@ const messagesEqual = (oldMessages, newMessages) => {
//
const fetchTicketDetail = async (showLoading = true) => {
// id work_id
const workId = route.query.id || route.query.work_id
const workId = route.query.id
if (!workId) {
// ID
router.replace('/ticket/list')
@@ -419,7 +418,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 || route.query.work_id
const workId = route.query.id
const content = messageInput.value.trim() || 'empty'
try {
@@ -434,10 +433,10 @@ const sendMessage = async () => {
try {
const formData = new FormData()
// file_names files
//
inputFiles.forEach((file) => {
formData.append('file_names', file.name)
formData.append('files', file)
formData.append('file_names', file.name)
})
//
@@ -447,11 +446,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)]
}
@@ -527,7 +526,7 @@ const handleStatusChange = async (newStatus) => {
try {
const formData = new FormData()
formData.append('work_id', route.query.id || route.query.work_id)
formData.append('work_id', route.query.id)
formData.append('Status', statusMap[newStatus])
const res = await updateTicketInfo(formData)
@@ -554,7 +553,7 @@ const handleComplete = () => {
type: 'warning'
}).then(async () => {
try {
const res = await closeTicket(route.query.id || route.query.work_id)
const res = await closeTicket(route.query.id)
if (res.code === 200) {
ElMessage.success('工单已成功结束')
ticketInfo.value.status = 'completed'
@@ -732,10 +731,9 @@ const saveEditMessage = async () => {
try {
const formData = new FormData()
// file_names files
editMessageFiles.value.forEach((file) => {
formData.append('file_names', file.name)
formData.append('files', file)
formData.append('file_names', file.name)
})
formData.append('update_type', 'work_order')
@@ -747,7 +745,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 {
@@ -894,9 +892,7 @@ const goToUserDetail = () => {
//
const startAutoRefresh = () => {
refreshTimer.value = setInterval(() => {
//
const workId = route.query.id || route.query.work_id
if (route.path === '/ticket/detail' && workId && ticketInfo.value?.status !== 'completed') {
if (ticketInfo.value?.status !== 'completed') {
fetchTicketDetail(false) // loading
}
}, 10000)
@@ -911,7 +907,7 @@ const stopAutoRefresh = () => {
// query
watch(
() => route.query.id || route.query.work_id,
() => route.query.id,
(newId) => {
if (newId) {
fetchTicketDetail()
@@ -1323,202 +1319,4 @@ 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>
+54 -587
View File
@@ -1,7 +1,7 @@
<template>
<div class="ticket-list-page">
<!-- 顶部状态标签 -->
<div class="status-bar">
<!-- 顶部工具 -->
<div class="toolbar">
<div class="status-tabs">
<div class="tab-item pending" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')">
待处理 <span class="count">{{ stats.pending }}</span>
@@ -19,65 +19,44 @@
全部 <span class="count">{{ stats.total }}</span>
</div>
</div>
</div>
<!-- 筛选工具栏 -->
<div class="filter-bar">
<el-select v-model="sortBy" placeholder="排序方式" clearable style="width: 140px" @change="handleSortChange">
<el-option label="不排序" value="" />
<el-option label="创建时间" value="created_at" />
<el-option label="更新时间" value="updated_at" />
<el-option label="工单号" value="id" />
</el-select>
<el-select v-model="sortOrder" placeholder="排序顺序" clearable style="width: 100px" @change="handleSortChange">
<el-option label="默认" value="" />
<el-option label="降序" value="desc" />
<el-option label="升序" value="asc" />
</el-select>
<el-input
:model-value="selectedUser ? selectedUser.user_name : ''"
placeholder="点击选择用户筛选"
readonly
style="width: 180px; cursor: pointer"
@click="showUserDialog = true"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
<template #suffix v-if="selectedUser">
<el-icon @click.stop="clearUserFilter" style="cursor: pointer"><Close /></el-icon>
</template>
</el-input>
<el-input
v-model="searchKeyword"
placeholder="搜索工单标题/内容"
clearable
style="width: 200px"
@input="handleKeywordSearch"
@clear="handleKeywordSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button icon="Refresh" @click="refreshList">刷新</el-button>
<div 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>
<!-- 工单表格PC端 -->
<!-- 工单表格 -->
<el-table
v-loading="isLoading"
:data="filteredTickets"
stripe
style="width: 100%"
@row-click="handleRowClick"
class="desktop-table"
>
<el-table-column prop="id" label="工单号" width="100" />
<el-table-column label="用户" width="180">
<template #default="{ row }">
<div class="user-info">
<el-avatar :size="32" :src="row.avatar">{{ row.username?.charAt(0) }}</el-avatar>
<el-link v-if="row.userId" type="primary" :underline="false" @click.stop="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.username }}</el-link>
<span v-else class="username">{{ row.username }}</span>
<span class="username">{{ row.username }}</span>
</div>
</template>
</el-table-column>
@@ -108,41 +87,6 @@
</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
@@ -155,74 +99,18 @@
@current-change="handlePageChange"
/>
</div>
<!-- 用户选择对话框 -->
<el-dialog
v-model="showUserDialog"
title="选择用户"
width="600px"
destroy-on-close
>
<div class="user-dialog-content">
<el-input
v-model="userSearchKeyword"
placeholder="输入用户名/手机号/邮箱搜索"
clearable
@input="handleUserSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="user-list-container" v-loading="isSearchingUser">
<!-- 调试信息 -->
<div style="padding: 8px; font-size: 12px; color: #909399; border-bottom: 1px solid #eee;">
搜索关键词: {{ userSearchKeyword }} | 用户数量: {{ userList.length }}
</div>
<div v-if="!userSearchKeyword" class="empty-hint">
请输入关键词搜索用户
</div>
<div v-else-if="userSearchKeyword && userList.length === 0 && !isSearchingUser" class="empty-hint">
未找到匹配的用户
</div>
<div v-if="userList.length > 0" class="user-list">
<div
v-for="user in userList"
:key="user.user_id"
class="user-list-item"
@click="selectUser(user)"
>
<el-avatar :size="40" :src="user.cover">{{ user.user_name?.charAt(0) }}</el-avatar>
<div class="user-list-info">
<div class="user-list-name">{{ user.user_name }}</div>
<div class="user-list-sub">
<span v-if="user.phone">手机: {{ user.phone }}</span>
<span v-else-if="user.email">邮箱: {{ user.email }}</span>
<span v-else>UID: {{ user.user_id }}</span>
</div>
</div>
<el-icon class="user-list-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue'
import { ref, reactive, computed, onMounted, onActivated } 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()
@@ -234,19 +122,8 @@ const isLoading = ref(false)
//
const ticketList = ref([])
const activeStatus = ref('pending') // ""
//
const searchKeyword = ref('')
const keywordSearchTimer = ref(null)
//
const userSearchKeyword = ref('')
const userList = ref([])
const selectedUser = ref(null)
const showUserDialog = ref(false)
const isSearchingUser = ref(false)
const userSearchTimer = ref(null)
const activeStatus = ref('pending') // ""
//
const sortBy = ref('') //
@@ -261,9 +138,6 @@ const stats = reactive({
total: 0
})
//
const autoRefreshTimer = ref(null)
@@ -300,9 +174,7 @@ const fetchTicketList = async () => {
currentPage.value,
statusParam,
sortBy.value,
sortOrder.value,
selectedUser.value?.user_id,
searchKeyword.value.trim()
sortOrder.value
)
if (res.code === 200) {
@@ -346,72 +218,15 @@ const fetchStats = async () => {
}
//
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 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 filterByStatus = (status) => {
@@ -419,12 +234,6 @@ const filterByStatus = (status) => {
activeStatus.value = status
currentPage.value = 1
fetchTicketList()
//
stopAutoRefresh()
if (status === 'pending') {
startAutoRefresh()
}
}
//
@@ -433,6 +242,9 @@ const handleSortChange = () => {
fetchTicketList()
}
//
const handleSearch = () => {}
//
const handleSizeChange = () => {
currentPage.value = 1
@@ -479,49 +291,11 @@ 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()
}
})
//
@@ -531,22 +305,6 @@ onActivated(() => {
refreshList()
}
isFirstLoad = false
//
if (activeStatus.value === 'pending') {
startAutoRefresh()
}
})
//
onBeforeUnmount(() => {
stopAutoRefresh()
if (userSearchTimer.value) {
clearTimeout(userSearchTimer.value)
}
if (keywordSearchTimer.value) {
clearTimeout(keywordSearchTimer.value)
}
})
</script>
@@ -559,36 +317,36 @@ onBeforeUnmount(() => {
background: #fff;
}
.status-bar {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: flex-start;
padding: 14px 20px 0;
padding: 0 20px;
height: 50px;
border-bottom: 1px solid #ebeef5;
}
.status-tabs {
display: flex;
gap: 6px;
gap: 8px;
}
.tab-item {
padding: 6px 16px;
border-radius: 20px;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #606266;
transition: all 0.2s;
user-select: none;
}
.tab-item:hover {
background: #f0f2f5;
background: #f5f7fa;
}
.tab-item.active {
background: #409eff;
color: #fff;
font-weight: 500;
}
.tab-item.pending.active { background: #e6a23c; }
@@ -601,75 +359,10 @@ onBeforeUnmount(() => {
font-weight: 500;
}
.filter-bar {
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 12px 20px;
border-bottom: 1px solid #ebeef5;
}
.user-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.user-list-container {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
.empty-hint {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: #909399;
font-size: 14px;
}
.user-list {
padding: 8px 0;
}
.user-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.user-list-item:hover {
background: #f5f7fa;
}
.user-list-info {
flex: 1;
min-width: 0;
}
.user-list-name {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.user-list-sub {
font-size: 12px;
color: #909399;
}
.user-list-arrow {
color: #c0c4cc;
font-size: 16px;
gap: 8px;
}
.user-info {
@@ -685,6 +378,8 @@ onBeforeUnmount(() => {
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding: 12px 20px;
border-top: 1px solid #ebeef5;
}
@@ -696,232 +391,4 @@ onBeforeUnmount(() => {
: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
+7 -18
View File
@@ -3,10 +3,6 @@
<!-- 顶部信息栏 -->
<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>
@@ -81,10 +77,9 @@
</template>
</el-table-column>
<el-table-column prop="Note" label="备注" min-width="200" show-overflow-tooltip />
<el-table-column label="支付订单ID" width="150" show-overflow-tooltip>
<el-table-column prop="PaymentOrderId" label="支付订单ID" width="150" show-overflow-tooltip>
<template #default="{ row }">
<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>
{{ row.PaymentOrderId || '-' }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
@@ -207,13 +202,12 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh, ArrowLeft } from '@element-plus/icons-vue'
import { getUserBalanceRecord, editUserBalance, addUserConsumption, getUserBalanceCount, getUserList, refundBalance } from '@/api/admin/user'
import { Refresh } from '@element-plus/icons-vue'
import { getUserBalance, getUserBalanceRecord, editUserBalance, addUserConsumption, getUserBalanceCount, getUserList, refundBalance } from '@/api/admin/user'
const route = useRoute()
const router = useRouter()
//
const userBalance = {
@@ -311,7 +305,7 @@ const currentBalanceDisplay = computed(() => {
//
const getUserListData = async () => {
try {
const res = await getUserList({ page: 1, count: 10, key: '' })
const res = await getUserList({ page: 1, count: 1000, key: '' })
if (res.data.code === 200) {
userList.value = res.data.data.data || []
}
@@ -596,12 +590,7 @@ watch(
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-btn {
font-size: 14px;
color: #606266;
gap: 24px;
}
.page-title {

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