96 Commits

Author SHA1 Message Date
shiran c43d1978a8 feat: 虚拟机流量精细化控制接入(接口新增,待联调)
Build and Deploy Vue3 / build (push) Successful in 1m37s
Build and Deploy Vue3 / deploy (push) Successful in 1m16s
1. userVm.js/kvmService.js 新增 traffic_policy 系列 API(GET/update/add_fixed/add_temporary)
2. UserVmList.vue/VmManage.vue 创建表单新增 traffic_max、traffic_exhausted_rx/tx_mbps 三个可选字段
3. UserVmDetail.vue/VmDetail.vue 修改带宽表单新增耗尽限速字段,并各增加流量策略 Tab(展示+修改策略+增加固定/临时流量)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 15:10:44 +08:00
shiran 475c62aefc feat: 添加用户ID和订单ID链接跳转功能
Build and Deploy Vue3 / build (push) Successful in 1m30s
Build and Deploy Vue3 / deploy (push) Successful in 1m15s
- 在团购活动页面中为用户ID添加点击跳转到用户详情的功能
- 在团购管理页面中为用户ID添加点击跳转到用户详情的功能
- 在审核相关页面中为容器ID添加点击跳转到容器详情的功能
- 在营销相关页面中为用户ID和订单ID添加点击跳转功能
- 在订单列表页面中为用户ID和商品ID添加点击跳转功能
- 在商品列表页面中为用户和商品添加点击跳转功能
- 在工单列表页面中为用户名添加点击跳转到用户详情的功能
- 在用户虚拟机列表中为商品和用户添加点击跳转功能
- 在用户余额页面中为支付订单ID添加点击跳转到订单详情的功能
- 统一使用el-link组件实现可点击的链接样式
- 添加useRouter依赖并创建router实例用于页面跳转
2026-04-24 18:06:29 +08:00
shiran c0daa6ed11 docs(readme): 更新文档说明
Build and Deploy Vue3 / build (push) Successful in 1m29s
Build and Deploy Vue3 / deploy (push) Successful in 1m14s
- 添加项目使用指南
- 完善API接口描述
- 修正错误的配置示例
2026-04-23 17:43:31 +08:00
lin 2e073c2b87 fix: 优化顶部标签页的滚动条
Build and Deploy Vue3 / build (push) Successful in 1m32s
Build and Deploy Vue3 / deploy (push) Successful in 1m13s
2026-04-21 15:16:32 +08:00
lin 13248468d3 fix: 用户虚拟机详情跳转参数问题
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 1m17s
2026-04-21 14:13:31 +08:00
lin ab7a8d5cfa fix: 用户余额管理页面添加返回按钮
Build and Deploy Vue3 / build (push) Successful in 1m24s
Build and Deploy Vue3 / deploy (push) Successful in 1m13s
2026-04-20 16:08:11 +08:00
lin 64d40cbbbf fix: 修改头像预览问题
Build and Deploy Vue3 / build (push) Successful in 1m25s
Build and Deploy Vue3 / deploy (push) Successful in 1m18s
2026-04-20 16:04:47 +08:00
lin d72a4f804e fix: 右侧记录栏添加已购商品
Build and Deploy Vue3 / build (push) Successful in 2m21s
Build and Deploy Vue3 / deploy (push) Successful in 1m12s
2026-04-20 11:32:14 +08:00
lin 8b2251ef97 feat: 增加菜单管理
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 1m15s
2026-04-18 16:24:57 +08:00
lin 2916c04ba5 fix: 参数范围值修改
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 1m28s
2026-04-17 19:27:25 +08:00
lin c7245cec67 fix: 用户商品模块
Build and Deploy Vue3 / build (push) Successful in 1m34s
Build and Deploy Vue3 / deploy (push) Successful in 1m10s
2026-04-16 15:43:08 +08:00
lin 985412c3bc fix: 虚拟机模块
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 3m5s
2026-04-16 13:22:39 +08:00
lin f53f63e679 fix: 虚拟机详情监控模块
Build and Deploy Vue3 / build (push) Successful in 1m30s
Build and Deploy Vue3 / deploy (push) Successful in 17m15s
2026-04-15 18:38:08 +08:00
lin cae1f847e4 fix: 网络模块
Build and Deploy Vue3 / build (push) Successful in 1m59s
Build and Deploy Vue3 / deploy (push) Successful in 1m21s
2026-04-15 16:46:28 +08:00
lin 5428f01cdf fix: 虚拟化平台模块
Build and Deploy Vue3 / build (push) Successful in 2m40s
Build and Deploy Vue3 / deploy (push) Has been cancelled
2026-04-15 16:41:06 +08:00
lin 7652b290b0 fix: 虚拟机模块
Build and Deploy Vue3 / build (push) Successful in 1m58s
Build and Deploy Vue3 / deploy (push) Successful in 1m13s
2026-04-15 16:35:41 +08:00
lin cf188bb94a fix: 数据迁移模块
Build and Deploy Vue3 / build (push) Successful in 1m30s
Build and Deploy Vue3 / deploy (push) Successful in 1m5s
2026-04-15 16:18:15 +08:00
lin b3ed406f84 fix: 提交修改
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s
2026-04-15 16:02:36 +08:00
lin 2f06aa9f5f style: 优化布局和交互(Loading/空状态/骨架屏)
Build and Deploy Vue3 / build (push) Successful in 1m51s
Build and Deploy Vue3 / deploy (push) Successful in 1m15s
2026-04-07 16:51:12 +08:00
lin f0e89695f4 fix: 修改新增用户商品的配置项逻辑
Build and Deploy Vue3 / build (push) Successful in 4m9s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s
2026-04-06 18:44:11 +08:00
lin c07e09c151 feat: 添加用户虚拟机商品管理
Build and Deploy Vue3 / build (push) Successful in 1m40s
Build and Deploy Vue3 / deploy (push) Successful in 1m8s
2026-03-31 15:13:04 +08:00
lin 71d3605f4f fix: 虚拟机添加强制操作
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s
2026-03-26 17:20:00 +08:00
lin b7e806cc80 fix: 创建数据卷的系统卷展示选择镜像
Build and Deploy Vue3 / build (push) Successful in 6m0s
Build and Deploy Vue3 / deploy (push) Successful in 58s
2026-03-26 17:06:12 +08:00
lin 1a4587f893 fix: 重构虚拟机内网外网参数设置选择网络
Build and Deploy Vue3 / build (push) Successful in 1m28s
Build and Deploy Vue3 / deploy (push) Successful in 1m1s
2026-03-26 16:36:25 +08:00
lin 40a5e486a6 feat: 对接用户组网管理
Build and Deploy Vue3 / build (push) Successful in 1m43s
Build and Deploy Vue3 / deploy (push) Successful in 1m7s
2026-03-24 18:57:52 +08:00
lin 3357566b02 fex: 样式修改
Build and Deploy Vue3 / build (push) Successful in 1m39s
Build and Deploy Vue3 / deploy (push) Successful in 1m0s
2026-03-21 19:07:04 +08:00
lin 25d782b050 feat: 将页面添加分页
Build and Deploy Vue3 / build (push) Successful in 1m35s
Build and Deploy Vue3 / deploy (push) Successful in 1m5s
2026-03-21 17:37:06 +08:00
lin 9edb59d16e fix: 修改内存的基础单位为kb
Build and Deploy Vue3 / build (push) Successful in 2m38s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s
2026-03-21 15:25:38 +08:00
lin cf19956b88 feat: 对接虚拟化平台管理
Build and Deploy Vue3 / build (push) Successful in 1m22s
Build and Deploy Vue3 / deploy (push) Successful in 1m2s
2026-03-19 18:13:24 +08:00
lin cd16ec17ae feat: 新增移动端配置信息
Build and Deploy Vue3 / build (push) Successful in 1m33s
Build and Deploy Vue3 / deploy (push) Successful in 1m15s
2026-03-17 18:40:12 +08:00
lin f4dbf17ce9 feat: 对接主控服务接口
Build and Deploy Vue3 / build (push) Successful in 2m29s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s
2026-03-14 15:45:07 +08:00
lin 25975c8b29 feat: 对接宿主机组映射管理
Build and Deploy Vue3 / build (push) Successful in 1m20s
Build and Deploy Vue3 / deploy (push) Successful in 1m0s
2026-03-13 17:33:02 +08:00
lin d650bfeb61 style: 对商品管理页面进行名称样式修改
Build and Deploy Vue3 / build (push) Successful in 1m15s
Build and Deploy Vue3 / deploy (push) Successful in 54s
2026-03-13 14:07:09 +08:00
lin 3e751d4c42 style: 修改值字段的文字展示样式
Build and Deploy Vue3 / build (push) Successful in 1m18s
Build and Deploy Vue3 / deploy (push) Successful in 56s
2026-03-11 18:58:01 +08:00
lin 193db5735f feat: 相近字排序
Build and Deploy Vue3 / build (push) Successful in 1m21s
Build and Deploy Vue3 / deploy (push) Successful in 56s
2026-03-11 16:55:17 +08:00
lin 09a83f4985 feat: 添加编辑配置是否开放接口接口
Build and Deploy Vue3 / build (push) Successful in 2m52s
Build and Deploy Vue3 / deploy (push) Successful in 55s
2026-03-11 14:55:27 +08:00
lin 20790cf029 style: 对list的类型值添加点击打开编辑弹窗效果
Build and Deploy Vue3 / build (push) Successful in 2m53s
Build and Deploy Vue3 / deploy (push) Successful in 56s
2026-03-11 12:48:21 +08:00
lin 86f3835e51 style: 表格值字段的list类型只展示文件数,添加note列
Build and Deploy Vue3 / build (push) Successful in 1m45s
Build and Deploy Vue3 / deploy (push) Successful in 1m2s
2026-03-11 10:27:29 +08:00
lin a2a7644a9f fix: 设置动态编辑配置图片
Build and Deploy Vue3 / build (push) Successful in 2m47s
Build and Deploy Vue3 / deploy (push) Successful in 55s
2026-03-10 19:25:43 +08:00
lin 3ca956d9f0 feat: 添加配置管理的批量删除,复选框
Build and Deploy Vue3 / build (push) Successful in 2m0s
Build and Deploy Vue3 / deploy (push) Successful in 53s
2026-03-10 16:23:14 +08:00
lin 5d16589e54 fix: 对象存储图片,没有jpg后缀不用做扩展名检测,不然图片不渲染
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 53s
2026-03-10 15:20:35 +08:00
lin 255bd9e832 fix: 修改编辑配置对接信息
Build and Deploy Vue3 / build (push) Successful in 1m38s
Build and Deploy Vue3 / deploy (push) Successful in 56s
2026-03-10 14:30:07 +08:00
lin 2e82ff8a34 fix: 新增配置添加确定对接函数
Build and Deploy Vue3 / build (push) Successful in 1m37s
Build and Deploy Vue3 / deploy (push) Successful in 55s
2026-03-10 14:20:47 +08:00
lin c100c37a32 style: 配置管理弄成树状视图
Build and Deploy Vue3 / build (push) Successful in 1m24s
Build and Deploy Vue3 / deploy (push) Successful in 57s
2026-03-10 13:56:14 +08:00
lin cdd8f86b92 feat: 管理员 配置信息类型新增file,file_list,string_list类型
Build and Deploy Vue3 / build (push) Successful in 1m34s
Build and Deploy Vue3 / deploy (push) Successful in 1m0s
2026-03-10 13:08:49 +08:00
lin fe29a8b3d0 feat: 添加新增套餐是否展示在首页参数
Build and Deploy Vue3 / build (push) Successful in 2m26s
Build and Deploy Vue3 / deploy (push) Successful in 55s
2026-03-10 10:49:59 +08:00
lin 2f38932878 feate:添加创建参数的必选参数
Build and Deploy Vue3 / build (push) Successful in 1m20s
Build and Deploy Vue3 / deploy (push) Successful in 1m24s
2026-02-05 17:00:39 +08:00
lin fdc9db9a9c fix:商品套餐添加固定价格
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 1m26s
2026-02-05 15:15:13 +08:00
lin 4d45cf535e fix:修改编辑参数值的展示
Build and Deploy Vue3 / build (push) Successful in 1m18s
Build and Deploy Vue3 / deploy (push) Successful in 1m27s
2026-02-05 12:54:49 +08:00
lin 9d8f23262b fix:修改用户选择展示的用户名称参数对接
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 1m25s
2026-02-04 16:04:22 +08:00
lin e96e9c4a7e feate:编辑套餐添加
Build and Deploy Vue3 / build (push) Successful in 2m56s
Build and Deploy Vue3 / deploy (push) Successful in 1m25s
2026-02-04 10:49:24 +08:00
lin 5a31de64b3 fix:修改json格式参数展示
Build and Deploy Vue3 / build (push) Successful in 1m17s
Build and Deploy Vue3 / deploy (push) Successful in 1m23s
2026-02-02 18:22:58 +08:00
lin b4260fedb8 fix:修改产品价格
Build and Deploy Vue3 / build (push) Successful in 4m46s
Build and Deploy Vue3 / deploy (push) Successful in 1m21s
2026-01-30 16:45:21 +08:00
lin 793a96a44f fix:修改视图样式
Build and Deploy Vue3 / build (push) Successful in 3m43s
Build and Deploy Vue3 / deploy (push) Successful in 1m24s
2026-01-29 18:13:25 +08:00
lin 043be60f4f feate:添加分组标签
Build and Deploy Vue3 / build (push) Successful in 4m59s
Build and Deploy Vue3 / deploy (push) Successful in 1m23s
2026-01-29 17:43:45 +08:00
lin 127d54eaa6 feate:新增套餐管理
Build and Deploy Vue3 / build (push) Successful in 1m11s
Build and Deploy Vue3 / deploy (push) Successful in 1m26s
2026-01-29 15:18:08 +08:00
lin ead7c5bba5 fix:工单样式兼容移动端
Build and Deploy Vue3 / build (push) Successful in 1m12s
Build and Deploy Vue3 / deploy (push) Successful in 1m19s
2026-01-23 14:24:29 +08:00
lin 5b5e0f62ec Merge branch 'master' of https://gitea.s1f.ren/lin/ApiServer-Web-admin_dashboard_pc
Build and Deploy Vue3 / build (push) Successful in 1m12s
Build and Deploy Vue3 / deploy (push) Successful in 1m17s
2026-01-23 13:21:02 +08:00
lin 9105503850 fix:修改仪表盘工单 2026-01-23 13:20:49 +08:00
shiran 20260e221c 更新 .gitea/workflows/build-service-server.yaml
Build and Deploy Vue3 / build (push) Successful in 1m13s
Build and Deploy Vue3 / deploy (push) Successful in 1m21s
2026-01-23 13:00:08 +08:00
shiran a270f58500 更新 .gitea/workflows/build-test-server.yaml
Build and Deploy Vue3 / deploy (push) Blocked by required conditions
Build and Deploy Vue3 / build (push) Has been cancelled
2026-01-23 12:59:40 +08:00
lin 7992ee9902 fix:修改用户列表的更多中的相关操作
Build and Deploy Vue3 / build (push) Successful in 1m15s
Build and Deploy Vue3 / deploy (push) Failing after 35s
2026-01-23 12:33:26 +08:00
lin 084aeebf13 fix:用户组列表成员弹窗限制高度
Build and Deploy Vue3 / build (push) Successful in 4m33s
Build and Deploy Vue3 / deploy (push) Successful in 1m19s
2026-01-21 10:57:06 +08:00
lin 1e79005440 fix:修改侧边栏样式
Build and Deploy Vue3 / build (push) Successful in 9m50s
Build and Deploy Vue3 / deploy (push) Successful in 1m31s
2026-01-20 18:25:38 +08:00
lin a6d4d70221 fix:修改样式
Build and Deploy Vue3 / build (push) Successful in 1m16s
Build and Deploy Vue3 / deploy (push) Successful in 1m22s
2026-01-20 17:59:31 +08:00
lin e3e70114fb fix:将侧边栏兼容移动端 2026-01-20 17:54:45 +08:00
lin 0b57581799 fix:修改对接订单列表参数
Build and Deploy Vue3 / build (push) Successful in 6m11s
Build and Deploy Vue3 / deploy (push) Failing after 1m35s
2026-01-19 18:32:24 +08:00
lin 36271b8bd0 fix:将填写弹窗修改为选择弹窗
Build and Deploy Vue3 / build (push) Successful in 6m17s
Build and Deploy Vue3 / deploy (push) Successful in 1m25s
2026-01-19 17:02:26 +08:00
lin cae89dd5ad fix:修改代金卷时间选择器
Build and Deploy Vue3 / build (push) Successful in 5m46s
Build and Deploy Vue3 / deploy (push) Failing after 1m41s
2026-01-08 18:40:32 +08:00
lin d3479fb0bb fix:修改参数值价格单位
Build and Deploy Vue3 / build (push) Successful in 9m19s
Build and Deploy Vue3 / deploy (push) Failing after 21s
2026-01-08 11:35:21 +08:00
lin 98cb0e1c8e fix:修改商品价格单元
Build and Deploy Vue3 / build (push) Successful in 1m11s
Build and Deploy Vue3 / deploy (push) Successful in 1m30s
2026-01-08 11:05:00 +08:00
lin 779359cec5 Merge branch 'master' of https://gitlab.s1f.top/lin/ApiServer-Web-admin_dashboard_pc
Build and Deploy Vue3 / build (push) Successful in 1m28s
Build and Deploy Vue3 / deploy (push) Failing after 1m41s
2026-01-08 10:50:32 +08:00
lin 60f141a0a9 fix:修改参数管理对接 2026-01-08 10:49:10 +08:00
wlkjyy fe1a118132 feat: 工单列表添加关键词搜索功能
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 1m31s
- 添加关键词搜索输入框,支持搜索工单标题/内容
- 300ms 防抖优化搜索性能
- 支持与用户筛选同时使用
2026-01-07 17:27:54 +08:00
wlkjyy 2ce2c1a31f feat: 工单系统优化 - 修复自动跳转问题并添加用户筛选功能
Build and Deploy Vue3 / build (push) Successful in 1m8s
Build and Deploy Vue3 / deploy (push) Successful in 1m28s
- 修复工单详情页定时刷新导致的自动跳转问题
- 添加用户搜索选择器,支持按用户筛选工单
- 优化用户搜索体验,使用对话框模式
- 修正API响应数据结构解析
2026-01-07 17:21:01 +08:00
lin 1655d86f6b fix:修改退款对接参数
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 1m28s
2026-01-05 15:28:32 +08:00
lin fcebebd216 feate:添加退款接口
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Failing after 1m41s
2026-01-05 15:19:48 +08:00
lin 5a93f4f8a8 feate:修改打包环境
Build and Deploy Vue3 / build (push) Successful in 5m30s
Build and Deploy Vue3 / deploy (push) Successful in 1m26s
2025-12-31 19:16:52 +08:00
lin 4d10deef86 feate:对接删除队伍接口
Build and Deploy Vue3 / build (push) Successful in 5m22s
Build and Deploy Vue3 / deploy (push) Successful in 3m12s
2025-12-31 19:07:19 +08:00
lin cf7ac515f6 fix:修改备注弄成添加字段json
Build and Deploy Vue3 / build (push) Successful in 1m7s
Build and Deploy Vue3 / deploy (push) Successful in 3m31s
2025-12-31 18:52:30 +08:00
lin 4ef208a662 fix:队伍名称修改对接
Build and Deploy Vue3 / build (push) Successful in 1m9s
Build and Deploy Vue3 / deploy (push) Successful in 10m59s
2025-12-31 15:04:45 +08:00
lin f6dcec75d7 feate:添加拼单类型接口
Build and Deploy Vue3 / build (push) Successful in 1m6s
Build and Deploy Vue3 / deploy (push) Successful in 10m44s
2025-12-31 13:07:05 +08:00
lin 4cc684eca6 Merge branch 'master' of https://gitlab.s1f.top/lin/ApiServer-Web-admin_dashboard_pc
Build and Deploy Vue3 / build (push) Successful in 1m29s
Build and Deploy Vue3 / deploy (push) Successful in 6m21s
2025-12-30 14:22:52 +08:00
lin 00ea1845a7 添加拼团活动 2025-12-30 14:22:44 +08:00
wlkjyy 0c6166b3c7 feat: 工单详情页增强 - 支持图片粘贴拖拽和消息编辑功能
Build and Deploy Vue3 / build (push) Successful in 2m54s
Build and Deploy Vue3 / deploy (push) Successful in 11m17s
- 支持剪贴板粘贴图片和文件拖拽上传
- 添加消息编辑功能,支持修改内容和管理图片
- 编辑对话框支持显示、删除原有图片和添加新图片
- 修复消息更新API响应检查逻辑
- 优化图片文件ID管理,支持保留和删除原始图片
2025-12-17 17:03:52 +08:00
wlkjyy 978b18d5d5 feat: 工单系统优化 - 改为列表形式,添加排序、状态修改、图片粘贴拖拽等功能
Build and Deploy Vue3 / build (push) Successful in 2m52s
Build and Deploy Vue3 / deploy (push) Successful in 2m37s
2025-12-17 15:42:14 +08:00
wlkjyy 54f78e15fe feat: 优化工单和用户管理功能
Build and Deploy Vue3 / build (push) Successful in 1m12s
Build and Deploy Vue3 / deploy (push) Successful in 5m2s
- 工单模块改为列表形式,支持点击进入详情页回复
- 新增工单列表页面(TicketList.vue)和详情页面(TicketDetail.vue)
- 工单详情页支持图片上传、快捷回复、定时刷新
- 消息按ID排序,时间显示优化(今天/昨天/星期/完整日期)
- 定时刷新时不显示loading,且只在数据变化时更新UI
- 用户列表直接使用API返回的cover字段作为头像,减少HTTP请求
- 修复用户余额页面balance_type参数undefined问题
2025-12-16 11:29:52 +08:00
wlkjyy ab2df50c0d 优化用户和工单管理功能
Build and Deploy Vue3 / build (push) Successful in 3m1s
Build and Deploy Vue3 / deploy (push) Successful in 1m50s
- 优化用户列表页面,移除头像批量加载导致的大量detail请求
- 移除工单列表自动刷新功能,避免页面跳转问题
- 将用户余额管理整合到用户列表操作菜单中
- 重构用户余额管理页面,采用现代化企业扁平化设计
- 移除用户余额管理独立菜单项
- 优化页面交互体验和视觉效果
2025-12-15 20:34:02 +08:00
wlkjyy 6859753470 feat: 工单模块改为列表形式,点击回复进入详情页
Build and Deploy Vue3 / build (push) Successful in 1m13s
Build and Deploy Vue3 / deploy (push) Successful in 2m31s
2025-12-15 16:25:16 +08:00
shiran 32bb4502e7 更新 .gitea/workflows/build-service-server.yaml
Build and Deploy Vue3 / build (push) Successful in 7m51s
Build and Deploy Vue3 / deploy (push) Successful in 12m49s
2025-12-12 21:19:12 +08:00
lin 4a13048718 fix:修改商品列表分页展示
Build and Deploy Vue3 / build (push) Successful in 2m47s
Build and Deploy Vue3 / deploy (push) Successful in 1m43s
2025-12-12 16:02:25 +08:00
lin b56359e572 fix:对接商品列表分页
Build and Deploy Vue3 / build (push) Successful in 2m53s
Build and Deploy Vue3 / deploy (push) Successful in 6m6s
2025-12-11 17:52:52 +08:00
lin 41d6492daf fix:修改库存控制修改操作
Build and Deploy Vue3 / build (push) Successful in 3m4s
Build and Deploy Vue3 / deploy (push) Successful in 4m49s
2025-12-11 10:14:57 +08:00
lin 14fcac3a24 fix:修改库存参数
Build and Deploy Vue3 / build (push) Successful in 1m50s
Build and Deploy Vue3 / deploy (push) Successful in 5m42s
2025-12-11 09:59:58 +08:00
lin 0fc582bc8c Merge branch 'qian' 2025-12-10 20:19:32 +08:00
wlkjyy 777022632c Merge pull request 'refactor: extract image form to standalone page and implement tags view store' (#11) from qian into master
Build and Deploy Vue3 / build (push) Successful in 2m58s
Build and Deploy Vue3 / deploy (push) Successful in 3m43s
Reviewed-on: https://gitlab.s1f.top/lin/ApiServer-Web-admin_dashboard_pc/pulls/11
2025-11-28 14:17:42 +08:00
130 changed files with 76678 additions and 4375 deletions
+5
View File
@@ -17,7 +17,12 @@ store封装到src/store目录下。
注册侧边栏在/config/menus.js文件中。 注册侧边栏在/config/menus.js文件中。
新添加要求:
在遇到用户id需要填写和修改的弹窗将其修改为可预览样式
关于填写表单为推荐人id的需要使用组件AvatarSelector展示,如果是文件id或者是封面id 的也需要预览展示需要向头像列表组件一样,可以弄个文件组件/api/v1/admin/file/list这个是文件列表接口
规则:
1.只要涉及弹窗添加和修改xxxid类型的就需要生成一个弹窗组件并使用到页面中
## 1. 基础布局规范 ## 1. 基础布局规范
```css ```css
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL='https://apiservertest.s1f.ren'
+2 -2
View File
@@ -37,10 +37,10 @@ jobs:
deploy: deploy:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ninBo
steps: steps:
- name: Download Artifact - name: Download Artifact
uses: actions/download-artifact@v3 uses: https://gitea.s1f.ren/actions/download-artifact@v3
with: with:
name: vue3-build name: vue3-build
+2 -2
View File
@@ -33,10 +33,10 @@ jobs:
deploy: deploy:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ninBo
steps: steps:
- name: Download Artifact - name: Download Artifact
uses: actions/download-artifact@v3 uses: https://gitea.s1f.ren/actions/download-artifact@v3
with: with:
name: vue3-build name: vue3-build
View File
+984 -931
View File
File diff suppressed because it is too large Load Diff
+72 -1
View File
@@ -226,11 +226,16 @@ html, body {
color: #3498db !important; color: #3498db !important;
} }
/* 卡片扁平化 */ /* 卡片扁平化 + 层次感 */
.el-card { .el-card {
border-radius: 0 !important; border-radius: 0 !important;
border: 1px solid #e1e8ed !important; border: 1px solid #e1e8ed !important;
box-shadow: none !important; box-shadow: none !important;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.el-card[shadow="hover"]:hover {
border-color: #c0c4cc !important;
box-shadow: 0 2px 12px rgba(44, 62, 80, 0.08) !important;
} }
/* 表格扁平化 */ /* 表格扁平化 */
@@ -434,4 +439,70 @@ html, body {
.el-dialog .el-form-item { .el-dialog .el-form-item {
margin-bottom: 20px; margin-bottom: 20px;
} }
/* Descriptions 描述列表增强 */
.el-descriptions {
--el-descriptions-item-bordered-label-background: #fafbfc;
}
.el-descriptions__label {
color: #606266 !important;
font-weight: 500 !important;
}
.el-descriptions__content {
color: #1d2129 !important;
}
/* Loading 遮罩增强 */
.el-loading-mask {
background-color: rgba(255, 255, 255, 0.85) !important;
}
.el-loading-spinner .circular {
width: 36px;
height: 36px;
}
.el-loading-spinner .el-loading-text {
color: #606266 !important;
font-size: 13px;
margin-top: 8px;
}
/* Message Box 增强 */
.el-message-box {
border-radius: 0 !important;
box-shadow: 0 4px 16px rgba(44, 62, 80, 0.15) !important;
}
.el-message-box__header {
padding: 16px 20px 12px !important;
}
.el-message-box__title {
font-weight: 600 !important;
color: #1d2129 !important;
}
.el-message-box__btns .el-button {
border-radius: 0 !important;
}
/* Alert 增强 */
.el-alert {
border-radius: 0 !important;
}
/* Tabs 增强 */
.el-tabs__item {
transition: color 0.2s ease !important;
}
.el-tabs__item.is-active {
font-weight: 600 !important;
}
/* Switch 开关增强 */
.el-switch {
--el-switch-on-color: #2c3e50;
}
/* 全局链接按钮悬浮下划线 */
.el-button.is-link:hover,
.el-button--primary.is-link:hover {
text-decoration: underline;
}
</style> </style>
+52
View File
@@ -15,3 +15,55 @@ export const addSignRewardType = (data) => {
} }
}) })
} }
// 拼团活动相关接口
/**获取拼团队伍列表 */
export const getGroupBuyList = () => {
return http2.get('/api/v1/users/activity/group_buy/list')
}
/**获取拼团队伍详情 */
export const getGroupBuyDetail = (groupBuyId) => {
return http2.get('/api/v1/users/activity/group_buy/detail', {
params: { group_buy_id: groupBuyId }
})
}
/**为队伍添加随机伪人 */
export const addRandomUser = (groupBuyId) => {
const formData = new FormData()
formData.append('group_buy_id', groupBuyId)
return http2.post('/api/v1/admin/activity/group_buy/add_random_user', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**创建随机伪人队伍 */
export const addRandomGroup = (data) => {
const formData = new FormData()
formData.append('name', data.name)
formData.append('group_buy_type_id', data.group_buy_type_id)
return http2.post('/api/v1/admin/activity/group_buy/add_random_group', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**导出成功队伍信息 */
export const exportIdcInfo = () => {
return http2.get('/api/v1/admin/activity/group_buy/export_idc_info')
}
/**为指定队伍下发订单 */
export const setOrder = (groupBuyId) => {
const formData = new FormData()
formData.append('group_buy_id', groupBuyId)
return http2.post('/api/v1/admin/activity/group_buy/set_order', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
+768
View File
@@ -0,0 +1,768 @@
import { http2 } from '@/utils/request.js'
/**
* ================================
* 主控服务管理 API
* ================================
*/
/** 获取 KVM 主控服务列表 */
export const getKvmServiceList = (params) => {
return http2.get('/api/v1/admin/server/host_service/list', { params })
}
/** 获取 KVM 主控服务详情 */
export const getKvmServiceDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/detail', { params })
}
/** 创建 KVM 主控服务 */
export const createKvmService = (data) => {
return http2.post('/api/v1/admin/server/host_service/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改 KVM 主控服务 */
export const updateKvmService = (id, data) => {
return http2.post(`/api/v1/admin/server/host_service/update?id=${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除 KVM 主控服务 */
export const deleteKvmService = (params) => {
return http2.delete('/api/v1/admin/server/host_service/delete', { params })
}
/**
* ================================
* 宿主机组映射管理 API
* ================================
*/
/** 获取本地主机组列表 */
export const getHostGroupList = (params) => {
return http2.get('/api/v1/admin/server/host_service/host_group/list', { params })
}
/** 从远程同步主机组到本地 */
export const syncHostGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/host_group/sync', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 绑定主机组到商品组或商品 */
export const bindHostGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/host_group/bind', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改本地主机组信息 */
export const updateHostGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/host_group/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 根据主机组树自动生成 GoodGroup/Goods/Args */
export const generateGoodsByHostGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/host_group/generate_goods', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除本地主机组 */
export const deleteHostGroup = (params) => {
return http2.delete('/api/v1/admin/server/host_service/host_group/delete', { params })
}
/**
* ================================
* 主控服务接口 - 远程宿主机组管理
* ================================
*/
/** 获取远程主机组列表 */
export const getRemoteHostGroupList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host_group/list', { params })
}
/** 获取远程主机组详情 */
export const getRemoteHostGroupDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host_group/detail', { params })
}
/** 获取远程主机组树形结构 */
export const getRemoteHostGroupTree = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host_group/tree', { params })
}
/** 获取主机组最优主机配置信息 */
export const getOptimalHostInfo = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host_group/optimal_host', { params })
}
/** 创建远程主机组 */
export const createRemoteHostGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/host_group/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改远程主机组 */
export const updateRemoteHostGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/host_group/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除远程主机组 */
export const deleteRemoteHostGroup = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/host_group/delete', { params })
}
/**
* ================================
* 主控服务接口 - 宿主机管理
* ================================
*/
/** 获取宿主机列表 */
export const getRemoteHostList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/list', { params })
}
/** 获取宿主机详情 */
export const getRemoteHostDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/detail', { params })
}
/** 获取宿主机指标数据 */
export const getRemoteHostMetrics = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/metrics', { params })
}
/** 查询历史指标(宿主机或虚拟机) */
export const getMetricsHistory = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/metrics_history', { params })
}
/** 新增宿主机 */
export const addRemoteHost = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/host/add', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改宿主机 */
export const updateRemoteHost = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/host/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除宿主机 */
export const deleteRemoteHost = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/host/delete', { params })
}
/** 创建宿主机注册令牌 */
export const createHostToken = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/host/create_token', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* ================================
* 主控服务接口 - 镜像管理
* ================================
*/
/** 获取镜像列表 */
export const getImageList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/image/list', { params })
}
/** 获取镜像详情 */
export const getImageDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/image/detail', { params })
}
/** 获取镜像在指定宿主机上的状态 */
export const getImageHostStatus = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/image/host_status', { params })
}
/** 创建镜像 */
export const createImage = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/image/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改镜像 */
export const updateImage = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/image/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除镜像 */
export const deleteImage = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/image/delete', { params })
}
/** 重新下载镜像 */
export const reloadImage = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/image/reload', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 向宿主机同步镜像 */
export const syncImageToHost = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/image/sync', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 指定宿主机重新下载指定镜像 */
export const reloadImageOnHost = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/image/reload_host', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 获取宿主机镜像列表与状态(对比) */
export const getImageCompareHost = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/image/compare_host', { params })
}
/**
* ================================
* 主控服务接口 - 网络管理
* ================================
*/
/** 获取网络列表 */
export const getNetworkList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/network/list', { params })
}
/** 获取网络详情 */
export const getNetworkDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/network/detail', { params })
}
/** 创建网络 */
export const createNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/network/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改网络 */
export const updateNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/network/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 批量创建网络 */
export const batchCreateNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/network/batch_create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除网络 */
export const deleteNetwork = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/network/delete', { params })
}
/**
* ================================
* 主控服务接口 - 数据卷管理
* ================================
*/
/** 获取数据卷列表 */
export const getVolumeList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/volume/list', { params })
}
/** 获取数据卷详情 */
export const getVolumeDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/volume/detail', { params })
}
/** 创建数据卷 */
export const createVolume = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/volume/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 调整数据卷大小 */
export const resizeVolume = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/volume/resize', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 挂载卷到虚拟机 */
export const mountVolume = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/volume/mount', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 卸载卷 */
export const unmountVolume = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/volume/unmount', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 迁移卷 */
export const transferVolume = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/volume/transfer', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除卷 */
export const deleteVolume = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/volume/delete', { params })
}
/**
* ================================
* 主控服务接口 - 虚拟机管理
* ================================
*/
/** 获取虚拟机列表 */
export const getVmList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/list', { params })
}
/** 获取虚拟机详情 */
export const getVmDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/detail', { params })
}
/** 获取虚拟机状态 */
export const getVmStatus = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/status', { params })
}
/** 获取虚拟机指标数据 */
export const getVmMetrics = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/metrics', { params })
}
/** 创建虚拟机 */
export const createVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改虚拟机 */
export const updateVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 重建虚拟机 */
export const rebuildVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/rebuild', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 重构虚拟机 */
export const refactorVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/refactor', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改虚拟机带宽 */
export const updateVmTraffic = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/update_traffic', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// ========== 流量策略 ==========
// 测试未通过(接口新增,待联调)
/** 获取虚拟机流量策略 */
export const getVmTrafficPolicy = (params) => http2.get('/api/v1/admin/server/host_service/point/vm/traffic_policy', { params })
/** 修改虚拟机流量策略 */
export const updateVmTrafficPolicy = (data) => http2.post('/api/v1/admin/server/host_service/point/vm/traffic_policy/update', data, { headers: { 'Content-Type': 'multipart/form-data' } })
/** 增加虚拟机固定流量上限 */
export const addVmFixedTraffic = (data) => http2.post('/api/v1/admin/server/host_service/point/vm/traffic_policy/add_fixed', data, { headers: { 'Content-Type': 'multipart/form-data' } })
/** 增加虚拟机一次性临时流量 */
export const addVmTemporaryTraffic = (data) => http2.post('/api/v1/admin/server/host_service/point/vm/traffic_policy/add_temporary', data, { headers: { 'Content-Type': 'multipart/form-data' } })
/** 启动虚拟机 */
export const startVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/start', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 停止虚拟机 */
export const stopVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/stop', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 重启虚拟机 */
export const rebootVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/reboot', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 暂停虚拟机 */
export const suspendVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/suspend', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 恢复虚拟机 */
export const resumeVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/resume', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 虚拟机进入救援系统 */
export const rescueVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/rescue', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 虚拟机退出救援系统 */
export const exitRescueVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/exit_rescue', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除虚拟机 */
export const deleteVm = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/vm/delete', { params })
}
/** 迁移虚拟机(更换宿主机) */
export const migrateVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/migrate', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 发起虚拟机数据迁移 */
export const dataMigrateVm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/data_migrate', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 获取虚拟机数据迁移进度 */
export const getDataMigrateProgress = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/data_migrate/progress', { params })
}
/** 中断虚拟机数据迁移 */
export const abortDataMigrate = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/data_migrate/abort', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* ================================
* 主控服务接口 - 安全组管理
* ================================
*/
/** 获取安全组列表 */
export const getSecurityGroupList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/post_group/list', { params })
}
/** 获取安全组详情 */
export const getSecurityGroupDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/post_group/detail', { params })
}
/** 创建安全组 */
export const createSecurityGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改安全组 */
export const updateSecurityGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 同步安全组 */
export const syncSecurityGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/sync', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 绑定安全组到虚拟机 */
export const bindSecurityGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/bind', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 解绑安全组 */
export const unbindSecurityGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/unbind', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除安全组 */
export const deleteSecurityGroup = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/post_group/delete', { params })
}
/** 开启安全组白名单 */
export const enableSecurityGroupWhitelist = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/enable_whitelist', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 关闭安全组白名单 */
export const disableSecurityGroupWhitelist = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/disable_whitelist', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 新增安全组规则 */
export const createSecurityGroupRule = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/create_rule', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改安全组规则 */
export const updateSecurityGroupRule = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/update_rule', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除安全组规则 */
export const deleteSecurityGroupRule = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/post_group/delete_rule', { params })
}
/** 应用安全组 */
export const applySecurityGroup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/apply', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* ================================
* 主控服务接口 - VNC 节点管理
* ================================
*/
/** 获取 VNC 节点列表 */
export const getVncNodeList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vnc/list', { params })
}
/** 获取虚拟机 VNC 连接信息 */
export const getVmVnc = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vnc/vm_vnc', { params })
}
/** 新增 VNC 节点 */
export const addVncNode = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vnc/add', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 测试 VNC 节点连接 */
export const testVncNode = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vnc/test', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改 VNC 节点 */
export const updateVncNode = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vnc/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除 VNC 节点 */
export const deleteVncNode = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/vnc/delete', { params })
}
/** 设置安全组共享状态 */
export const setSecurityGroupShared = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/post_group/set_shared', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// ========== 快照管理 ==========
/** 获取快照列表 */
export const getSnapshotList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/snapshot/list', { params })
}
/** 获取快照任务进度 */
export const getSnapshotProgress = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/snapshot/progress', { params })
}
/** 创建快照 */
export const createSnapshot = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/snapshot/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 恢复快照 */
export const restoreSnapshot = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/snapshot/restore', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除快照 */
export const deleteSnapshot = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/snapshot/delete', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// ========== 备份管理 ==========
/** 获取备份列表 */
export const getBackupList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/backup/list', { params })
}
/** 获取备份任务进度 */
export const getBackupProgress = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/backup/progress', { params })
}
/** 创建备份 */
export const createBackup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/backup/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 恢复备份 */
export const restoreBackup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/backup/restore', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除备份 */
export const deleteBackup = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/backup/delete', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 获取快照数量与上限 */
export const getSnapshotCount = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/snapshot/count', { params })
}
/** 设置快照数量上限 */
export const setSnapshotLimit = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/snapshot/set_limit', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 获取备份数量与上限 */
export const getBackupCount = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/backup/count', { params })
}
/** 设置备份数量上限 */
export const setBackupLimit = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/backup/set_limit', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* ================================
* 用户组网管理 (UserNetworking)
* ================================
*/
/** 获取组网列表 */
export const getUserNetworkingList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/networking/list', { params })
}
/** 获取组网详情 */
export const getUserNetworkingDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/networking/detail', { params })
}
/** 创建用户组网 */
export const createUserNetworking = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/networking/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 为虚拟机分配组网 IP */
export const assignUserNetworking = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/networking/assign', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除组网 */
export const deleteUserNetworking = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/networking/delete', { params })
}
/** 删除组网下的指定网络 */
export const removeUserNetworkingNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/networking/remove_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
+8
View File
@@ -33,3 +33,11 @@ export const updateOrder = (data) => {
} }
}) })
} }
/**重试订单流程 */
export const retryOrderHook = (data) => {
return http2.post('/api/v1/admin/order/retry_hook', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
+115 -1
View File
@@ -57,6 +57,10 @@ export const deleteProductGroup = (data) => {
export const getProductList = (params) => { export const getProductList = (params) => {
return http2.get('/api/v1/admin/good/goods/list', {params: params}) return http2.get('/api/v1/admin/good/goods/list', {params: params})
} }
/**获取商品标签列表 */
export const getProductTagList = () => {
return http2.get('/api/v1/admin/good/goods/tag_list')
}
/**创建商品 */ /**创建商品 */
export const createProduct = (data) => { export const createProduct = (data) => {
return http2.post('/api/v1/admin/good/goods/create', data,{ return http2.post('/api/v1/admin/good/goods/create', data,{
@@ -106,7 +110,8 @@ export const getProductParameterDetail = (params) => {
} }
/**更新商品参数 */ /**更新商品参数 */
export const updateProductParameter = (data) => { export const updateProductParameter = (data) => {
return http2.post('/api/v1/admin/good/spec/update', data,{ return http2.post('/api/v1/admin/good/spec/update', null, {
params: data,
headers:{ headers:{
'Content-Type':'multipart/form-data' 'Content-Type':'multipart/form-data'
} }
@@ -140,3 +145,112 @@ export const updateProductParameterValue = (data) => {
} }
}) })
} }
/**---------------------------------- */
/**商品套餐管理 */
/**获取商品套餐列表 */
export const getProductPlanList = (params) => {
return http2.get('/api/v1/admin/good/plan/list', {params: params})
}
/**获取商品套餐详情 */
export const getProductPlanDetail = (params) => {
return http2.get('/api/v1/admin/good/plan/detail', {params: params})
}
/**创建商品套餐 */
export const createProductPlan = (data) => {
return http2.post('/api/v1/admin/good/plan/create', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**更新商品套餐 */
export const updateProductPlan = (data) => {
return http2.post('/api/v1/admin/good/plan/update', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**删除商品套餐 */
export const deleteProductPlan = (params) => {
return http2.delete('/api/v1/admin/good/plan/delete', {params: params})
}
/**禁用商品套餐 */
export const disableProductPlan = (data) => {
return http2.post('/api/v1/admin/good/plan/disable', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**启用商品套餐 */
export const enableProductPlan = (data) => {
return http2.post('/api/v1/admin/good/plan/enable', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**禁用套餐固定价格 */
export const disablePlanFixedPrice = (data) => {
return http2.post('/api/v1/admin/good/plan/disable_fixed_price', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**启用套餐固定价格 */
export const enablePlanFixedPrice = (data) => {
return http2.post('/api/v1/admin/good/plan/enable_fixed_price', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**---------------------------------- */
/**商品分组标签管理 */
/**获取商品分组标签列表 */
export const getProductGroupTagList = (params) => {
return http2.get('/api/v1/admin/good/group_tag/list', {params: params})
}
/**获取商品分组标签详情 */
export const getProductGroupTagDetail = (params) => {
return http2.get('/api/v1/admin/good/group_tag/detail', {params: params})
}
/**创建商品分组标签 */
export const createProductGroupTag = (data) => {
return http2.post('/api/v1/admin/good/group_tag/create', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**更新商品分组标签 */
export const updateProductGroupTag = (data) => {
return http2.post('/api/v1/admin/good/group_tag/update', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**删除商品分组标签 */
export const deleteProductGroupTag = (params) => {
return http2.delete('/api/v1/admin/good/group_tag/delete', {params: params})
}
/**---------------------------------- */
/**已购商品管理 */
/**获取用户已购商品列表 */
export const getUserGoodsList = (params) => {
return http2.get('/api/v1/admin/good/user_goods/list', {params: params})
}
+11 -5
View File
@@ -18,10 +18,6 @@ export const addUserConsumption = (data) => {
}) })
} }
/**查询用户余额 */
export const getUserBalance = (data) => {
return http2.get('/api/v1/admin/user/balance/select?user_id='+data.user_id)
}
/**获取用户余额记录 */ /**获取用户余额记录 */
export const getUserBalanceRecord = (data) => { export const getUserBalanceRecord = (data) => {
@@ -53,7 +49,7 @@ export const updateUserInfo = (data) => {
/**删除用户 */ /**删除用户 */
export const deleteUser = (data) => { export const deleteUser = (data) => {
return http2.delete('/api/v1/admin/user/user/delete?group_id='+data.group_id) return http2.delete('/api/v1/admin/user/user/delete?user_id='+data.user_id)
} }
/**修改用户头像 */ /**修改用户头像 */
export const updateUserAvatar = (data) => { export const updateUserAvatar = (data) => {
@@ -163,3 +159,13 @@ export const addUserGroupMember = (data) => {
} }
}) })
} }
/**退款对应账单 */
export const refundBalance = (data) => {
return http2.get('/api/v1/admin/user/balance/refund', {
params:data,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
+118
View File
@@ -0,0 +1,118 @@
import { http2 } from '@/utils/request.js'
const BASE = '/api/v1/admin/good/user_vm'
const GOODS_BASE = '/api/v1/admin/good/user_goods'
const fd = (data) => {
const f = new FormData()
Object.entries(data).forEach(([k, v]) => {
if (v === undefined || v === null) return
if (Array.isArray(v)) {
v.forEach(item => f.append(k, item))
} else if (typeof v === 'boolean') {
f.append(k, v ? 'true' : 'false')
} else {
f.append(k, v)
}
})
return f
}
// ========== 用户虚拟机 ==========
export const getUserVmList = (params) => http2.get(`${BASE}/list`, { params })
export const getUserVmDetail = (params) => http2.get(`${BASE}/detail`, { params })
export const getUserVmVnc = (params) => http2.get(`${BASE}/vnc`, { params })
export const getUserVmHostImages = (params) => http2.get(`${BASE}/host_images`, { params })
export const getGoodHostGroupImages = (params) => http2.get(`${BASE}/good_host_group_images`, { params })
export const createUserVm = (data) => http2.post(`${BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const bindUserVm = (data) => http2.post(`${BASE}/bind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const transferUserVm = (data) => http2.post(`${BASE}/transfer`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const migrateUserVm = (data) => http2.post(`${BASE}/migrate`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserVmTraffic = (data) => http2.post(`${BASE}/update_traffic`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserVm = (data) => http2.post(`${BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const refactorUserVm = (data) => http2.post(`${BASE}/refactor`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const startUserVm = (data) => http2.post(`${BASE}/start`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const stopUserVm = (data) => http2.post(`${BASE}/stop`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const rebootUserVm = (data) => http2.post(`${BASE}/reboot`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const suspendUserVm = (data) => http2.post(`${BASE}/suspend`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const resumeUserVm = (data) => http2.post(`${BASE}/resume`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const rescueUserVm = (data) => http2.post(`${BASE}/rescue`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const exitRescueUserVm = (data) => http2.post(`${BASE}/exit_rescue`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const rebuildUserVm = (data) => http2.post(`${BASE}/rebuild`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVm = (params) => http2.delete(`${BASE}/delete`, { params })
// ========== 数据卷 ==========
export const getUserVmVolumeList = (params) => http2.get(`${BASE}/volume/list`, { params })
export const getUserVmVolumeDetail = (params) => http2.get(`${BASE}/volume/detail`, { params })
export const createUserVmVolume = (data) => http2.post(`${BASE}/volume/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const resizeUserVmVolume = (data) => http2.post(`${BASE}/volume/resize`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const mountUserVmVolume = (data) => http2.post(`${BASE}/volume/mount`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const unmountUserVmVolume = (data) => http2.post(`${BASE}/volume/unmount`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmVolume = (params) => http2.delete(`${BASE}/volume/delete`, { params })
// ========== 快照 ==========
export const getUserVmSnapshotList = (params) => http2.get(`${BASE}/snapshot/list`, { params })
export const getUserVmSnapshotProgress = (params) => http2.get(`${BASE}/snapshot/progress`, { params })
export const getUserVmSnapshotCount = (params) => http2.get(`${BASE}/snapshot/count`, { params })
export const createUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const restoreUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/restore`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/delete`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const setUserVmSnapshotLimit = (data) => http2.post(`${BASE}/snapshot/set_limit`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
// ========== 备份 ==========
export const getUserVmBackupList = (params) => http2.get(`${BASE}/backup/list`, { params })
export const getUserVmBackupProgress = (params) => http2.get(`${BASE}/backup/progress`, { params })
export const getUserVmBackupCount = (params) => http2.get(`${BASE}/backup/count`, { params })
export const createUserVmBackup = (data) => http2.post(`${BASE}/backup/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const restoreUserVmBackup = (data) => http2.post(`${BASE}/backup/restore`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmBackup = (data) => http2.post(`${BASE}/backup/delete`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const setUserVmBackupLimit = (data) => http2.post(`${BASE}/backup/set_limit`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
// ========== 安全组 ==========
export const getUserVmPostGroupList = (params) => http2.get(`${BASE}/post_group/list`, { params })
export const getUserVmPostGroupDetail = (params) => http2.get(`${BASE}/post_group/detail`, { params })
export const getUserVmPostGroupUserList = (params) => http2.get(`${BASE}/post_group/user_list`, { params })
export const createUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const bindUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/bind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const unbindUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/unbind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const applyUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/apply`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const setSharedUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/set_shared`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmPostGroup = (params) => http2.delete(`${BASE}/post_group/delete`, { params })
export const enableUserVmPostGroupWhitelist = (data) => http2.post(`${BASE}/post_group/enable_whitelist`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const disableUserVmPostGroupWhitelist = (data) => http2.post(`${BASE}/post_group/disable_whitelist`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const createUserVmPostGroupRule = (data) => http2.post(`${BASE}/post_group/create_rule`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserVmPostGroupRule = (data) => http2.post(`${BASE}/post_group/update_rule`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmPostGroupRule = (params) => http2.delete(`${BASE}/post_group/delete_rule`, { params })
// ========== 网络 ==========
export const getUserVmNetworkList = (params) => http2.get(`${BASE}/network/list`, { params })
export const getUserVmNetworkDetail = (params) => http2.get(`${BASE}/network/detail`, { params })
// ========== 组网 ==========
export const getUserVmNetworkingList = (params) => http2.get(`${BASE}/networking/list`, { params })
export const getUserVmNetworkingDetail = (params) => http2.get(`${BASE}/networking/detail`, { params })
export const createUserVmNetworking = (data) => http2.post(`${BASE}/networking/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const assignUserVmNetworking = (data) => http2.post(`${BASE}/networking/assign`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const removeUserVmNetworkingNetwork = (data) => http2.post(`${BASE}/networking/remove_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmNetworking = (params) => http2.delete(`${BASE}/networking/delete`, { params })
// ========== 用户商品 ==========
export const getUserGoodsList = (params) => http2.get(`${GOODS_BASE}/list`, { params })
export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`, { params })
export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params })
export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params })
// ========== 流量策略 ==========
// 测试未通过(接口新增,待联调)
export const getUserVmTrafficPolicy = (params) => http2.get(`${BASE}/traffic_policy`, { params })
export const updateUserVmTrafficPolicy = (data) => http2.post(`${BASE}/traffic_policy/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const addUserVmFixedTraffic = (data) => http2.post(`${BASE}/traffic_policy/add_fixed`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const addUserVmTemporaryTraffic = (data) => http2.post(`${BASE}/traffic_policy/add_temporary`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
// ========== 到期提醒 ==========
export const getExpireRemindList = (params) => http2.get(`${GOODS_BASE}/expire_remind/list`, { params })
export const sendExpireRemind = (data) => http2.post(`${GOODS_BASE}/expire_remind/send`, data, { headers: { 'Content-Type': 'application/json' } })
+24
View File
@@ -0,0 +1,24 @@
import { http2 } from '@/utils/request.js'
const fd = (data) => {
const f = new FormData()
Object.entries(data).forEach(([k, v]) => {
if (v === undefined || v === null || v === '') return
f.append(k, v)
})
return f
}
const BASE_GROUP = '/api/v1/admin/server/vnc_command/group'
const BASE_ITEM = '/api/v1/admin/server/vnc_command/item'
// 分组
export const getVncCommandGroupList = () => http2.get(`${BASE_GROUP}/list`)
export const createVncCommandGroup = (data) => http2.post(`${BASE_GROUP}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateVncCommandGroup = (data) => http2.post(`${BASE_GROUP}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteVncCommandGroup = (params) => http2.delete(`${BASE_GROUP}/delete`, { params })
// 指令项
export const createVncCommandItem = (data) => http2.post(`${BASE_ITEM}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateVncCommandItem = (data) => http2.post(`${BASE_ITEM}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteVncCommandItem = (params) => http2.delete(`${BASE_ITEM}/delete`, { params })
+64
View File
@@ -0,0 +1,64 @@
import { http2 } from "@/utils/request.js"
// ========== 后台菜单管理 ==========
/** 获取后台菜单列表 */
export const getWebRoutsList = (params) => {
return http2.get('/api/v1/admin/server/web_routs/list', { params })
}
/** 新增后台菜单 */
export const addWebRouts = (data) => {
return http2.post('/api/v1/admin/server/web_routs/add', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改后台菜单 */
export const updateWebRouts = (data) => {
return http2.post('/api/v1/admin/server/web_routs/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除后台菜单 */
export const deleteWebRouts = (data) => {
return http2.delete('/api/v1/admin/server/web_routs/delete', {
data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// ========== 后台菜单权限管理 ==========
/** 获取后台菜单权限列表 */
export const getWebRoutsPermissionList = (params) => {
return http2.get('/api/v1/admin/server/web_routs/permission/list', { params })
}
/** 新增后台菜单权限 */
export const addWebRoutsPermission = (data) => {
return http2.post('/api/v1/admin/server/web_routs/permission/add', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改后台菜单权限 */
export const updateWebRoutsPermission = (data) => {
return http2.post('/api/v1/admin/server/web_routs/permission/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除后台菜单权限 */
export const deleteWebRoutsPermission = (data) => {
return http2.delete('/api/v1/admin/server/web_routs/permission/delete', {
data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 获取当前用户的后台菜单权限树 */
export const getMyWebRoutsPermission = () => {
return http2.get('/api/v1/admin/server/web_routs/my')
}
+202
View File
@@ -0,0 +1,202 @@
import request from "@/utils/request.js";
/**
* 创建拼团
* @param {Object} data - 拼团数据
* @param {string} data.name - 拼团名称
* @param {number} data.maxPerson - 最大人数
* @param {string} data.cover - 封面图片URL
* @returns {Promise} 返回拼团详情
*/
export const createGroupBuy = (data) => {
return request.post("/api/v1/group-buy/create", data)
}
/**
* 检查拼团
* @param {string} groupBuyId - 拼团ID
* @returns {Promise} 返回检查结果
*/
export const checkGroupBuy = (groupBuyId) => {
return request.get(`/api/v1/group-buy/check/${groupBuyId}`)
}
/**
* 获取拼团详情
* @param {string} groupBuyId - 拼团ID
* @returns {Promise} 返回拼团详情
*/
export const getGroupBuyDetail = (groupBuyId) => {
return request.get(`/api/v1/group-buy/${groupBuyId}`)
}
/**
* 获取拼团列表
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.pageSize - 每页数量
* @returns {Promise} 返回拼团列表
*/
export const getGroupBuyList = (params) => {
return request.get("/api/v1/users/activity/group_buy/list", params)
}
/**
* 加入拼团
* @param {string} groupBuyId - 拼团ID
* @param {Object} data - 用户数据
* @returns {Promise} 返回加入结果
*/
export const joinGroupBuy = (groupBuyId, data) => {
return request.post(`/api/v1/group-buy/${groupBuyId}/join`, data)
}
/**
* 删除拼团
* @param {string} groupBuyId - 拼团ID
* @returns {Promise} 返回删除结果
*/
export const deleteGroupBuy = (groupBuyId) => {
return request.delete(`/api/v1/group-buy/${groupBuyId}`)
}
// ==================== 拼团类型管理接口 ====================
/**
* 获取拼团活动类型列表
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.count=10] - 每页条数
* @param {string} [params.key] - 关键词筛选
* @param {number} [params.expire_time] - 过期时间筛选(时间戳)
* @param {string} [params.tag] - 标签筛选
* @returns {Promise} 返回拼团类型列表
*/
export const getGroupBuyTypeList = (params) => {
return request.get("/api/v1/admin/activity/group_buy/type/list", params)
}
/**
* 获取拼团活动类型标签列表
* @returns {Promise} 返回标签列表
*/
export const getGroupBuyTypeTags = () => {
return request.get("/api/v1/admin/activity/group_buy/type/tags")
}
/**
* 新增拼团活动类型
* @param {Object} data - 类型数据
* @param {string} data.name - 名称
* @param {string} [data.note] - 备注
* @param {string} data.price - 价格(分)
* @param {string} [data.renew_price] - 续费价格(分)
* @param {string} data.max_person - 拼团需要人数
* @param {string} [data.tag] - 标签
* @param {number} [data.expire_time] - 活动过期时间
* @returns {Promise} 返回新增结果
*/
export const addGroupBuyType = (data) => {
return request.post("/api/v1/admin/activity/group_buy/type/add", data,{
})
}
/**
* 修改拼团活动类型
* @param {Object} data - 类型数据
* @param {string} data.id - ID编号
* @param {string} [data.name] - 名称
* @param {string} [data.note] - 备注
* @param {string} [data.price] - 价格(分)
* @param {string} [data.renew_price] - 续费价格(分)
* @param {string} [data.max_person] - 拼团需要人数
* @param {string} [data.tag] - 标签
* @param {number} [data.expire_time] - 活动过期时间
* @returns {Promise} 返回修改结果
*/
export const updateGroupBuyType = (data) => {
return request.post("/api/v1/admin/activity/group_buy/type/update", data)
}
/**
* 删除拼团活动类型
* @param {string} id - 类型ID
* @returns {Promise} 返回删除结果
*/
export const deleteGroupBuyType = (id) => {
return request.delete("/api/v1/admin/activity/group_buy/type/delete", { params: { id } })
}
// ==================== 拼团队伍管理接口 ====================
/**
* 检查队伍列表
* @returns {Promise} 返回队伍检查结果
*/
export const checkGroupBuyTeams = () => {
return request.get("/api/v1/admin/activity/group_buy/check")
}
/**
* 为队伍添加随机伪人
* @param {string} groupBuyId - 队伍ID
* @returns {Promise} 返回添加结果
*/
export const addRandomUser = (groupBuyId) => {
return request.post("/api/v1/admin/activity/group_buy/add_random_user", { group_buy_id: groupBuyId })
}
/**
* 创建随机伪人队伍
* @param {Object} data - 队伍数据
* @param {string} data.name - 队伍名称
* @param {string} data.group_buy_type_id - 队伍类型ID
* @returns {Promise} 返回创建结果
*/
export const addRandomGroup = (data) => {
return request.post("/api/v1/admin/activity/group_buy/add_random_group", data)
}
/**
* 导出成功队伍信息
* @returns {Promise} 返回导出数据
*/
export const exportGroupBuyIdcInfo = () => {
return request.get("/api/v1/admin/activity/group_buy/export_idc_info")
}
/**
* 为指定队伍下发订单
* @param {string} groupBuyId - 队伍ID
* @returns {Promise} 返回下发结果
*/
export const setGroupBuyOrder = (groupBuyId) => {
return request.post("/api/v1/admin/activity/group_buy/set_order", { group_buy_id: groupBuyId })
}
/**
* 删除指定队伍
* @param {string} groupBuyId - 队伍ID
* @returns {Promise} 返回删除结果
*/
export const removeGroupBuy = (groupBuyId) => {
return request.delete("/api/v1/admin/activity/group_buy/remove", { params: { group_buy_id: groupBuyId } })
}
/**
* 清除所有队伍
* @returns {Promise} 返回清除结果
*/
export const clearAllGroupBuy = () => {
return request.delete("/api/v1/admin/activity/group_buy/clear")
}
/**
* 清除指定用户的所有队伍
* @param {string} userId - 用户ID
* @returns {Promise} 返回清除结果
*/
export const clearUserGroupBuy = (userId) => {
return request.delete("/api/v1/admin/activity/group_buy/user_clear", { params: { user_id: userId } })
}
+10
View File
@@ -8,3 +8,13 @@ export const userLogin = (username,password) => {
export const getUserInfo = () => { export const getUserInfo = () => {
return request.get("/api/v1/users/info/info") return request.get("/api/v1/users/info/info")
} }
// 获取交换token(用于无感刷新)
export const getRefreshToken = (domain) => {
return request.get("/api/v1/users/info/refresh_token", { domain })
}
// 使用交换token获取新的access token
export const refreshAccessToken = (refresh_token) => {
return request.post("/api/v1/user/refresh_token", { refresh_token })
}
+9 -2
View File
@@ -5,8 +5,15 @@ import request from "@/utils/request.js";
* @returns {Promise} * @returns {Promise}
*/ */
export function getTickerList(count, page, status) { export function getTickerList(count, page, status, orderBy, order, userId, keyword) {
return request.get('/api/v1/admin/work_order/list', { count, page, status }) const params = { count, page }
if (status !== undefined && status !== '') params.status = status
if (orderBy) params.orderBy = orderBy
if (order) params.order = order
if (userId) params.user_id = userId
if (keyword) params.keyword = keyword
console.log('工单列表请求参数:', params) // 调试日志
return request.get('/api/v1/admin/work_order/list', params)
} }
// 待处理 // 待处理
+136 -55
View File
@@ -2,65 +2,94 @@
<el-dialog <el-dialog
:model-value="visible" :model-value="visible"
title="选择用户" title="选择用户"
width="800px" width="700px"
class="user-selector-dialog" class="user-selector-dialog"
append-to-body append-to-body
@update:model-value="handleVisibleChange" @update:model-value="handleVisibleChange"
> >
<!-- 搜索栏 --> <div class="user-selector-content">
<div class="selector-search"> <!-- 搜索栏 -->
<el-input <div class="selector-search">
v-model="searchParams.key" <el-input
placeholder="搜索用户名或ID" v-model="searchParams.key"
clearable placeholder="搜索用户名、邮箱或ID"
@keyup.enter="handleSearch" clearable
style="width: 300px; margin-right: 12px" @keyup.enter="handleSearch"
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #append>
<el-button @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button @click="handleReset" class="reset-btn">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
<!-- 用户表格 -->
<el-table
v-loading="loading"
:data="userList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
max-height="350"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
> >
<template #prefix> <el-table-column prop="user_id" label="用户ID" width="100" />
<el-icon><Search /></el-icon> <el-table-column prop="user_name" label="用户名" min-width="130">
</template> <template #default="{ row }">
</el-input> <div class="user-name-cell">
<el-button type="primary" @click="handleSearch"> <el-avatar v-if="row.cover" :src="row.cover" :size="28" />
<el-icon><Search /></el-icon> <el-avatar v-else :size="28">
搜索 {{ row.user_name?.charAt(0)?.toUpperCase() || 'U' }}
</el-button> </el-avatar>
<el-button @click="handleReset">重置</el-button> <span class="user-name">{{ row.user_name || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180">
<template #default="{ row }">
<span class="text-ellipsis">{{ row.email || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.disable" type="danger" size="small">禁用</el-tag>
<el-tag v-else type="success" size="small">正常</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
background
small
class="selector-pagination"
/>
</div> </div>
<!-- 用户表格 -->
<el-table
v-loading="loading"
:data="userList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%; margin-top: 16px"
:height="400"
>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="UserId" label="用户ID" width="100" />
<el-table-column prop="UserName" label="用户名" min-width="150" />
<el-table-column prop="Email" label="邮箱" min-width="180" />
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
background
class="selector-pagination"
/>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<span v-if="selectedUser" class="selected-info">
已选择: <el-tag type="primary" size="small">{{ selectedUser.user_name }} (ID: {{ selectedUser.user_id }})</el-tag>
</span>
<el-button @click="closeDialog">取消</el-button> <el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="confirmSelection" :disabled="!selectedUser"> <el-button type="primary" @click="confirmSelection" :disabled="!selectedUser">
确定选择 确定
</el-button> </el-button>
</div> </div>
</template> </template>
@@ -69,7 +98,7 @@
<script setup> <script setup>
import { ref, reactive, watch } from 'vue' import { ref, reactive, watch } from 'vue'
import { Search } from '@element-plus/icons-vue' import { Search, Refresh } from '@element-plus/icons-vue'
import { getUserList } from '@/api/admin/user' import { getUserList } from '@/api/admin/user'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -97,9 +126,7 @@ const searchParams = reactive({
watch(() => props.visible, (newVal) => { watch(() => props.visible, (newVal) => {
if (newVal) { if (newVal) {
selectedUser.value = null selectedUser.value = null
if (userList.value.length === 0) { fetchUserList()
fetchUserList()
}
} }
}) })
@@ -163,11 +190,44 @@ const confirmSelection = () => {
</script> </script>
<style scoped> <style scoped>
.user-selector-content {
max-height: 500px;
overflow: hidden;
}
.selector-search { .selector-search {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 0; gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
margin-bottom: 16px;
}
.search-input {
flex: 1;
max-width: 350px;
}
.reset-btn {
flex-shrink: 0;
}
.user-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.user-name {
font-weight: 500;
color: #303133;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.selector-pagination { .selector-pagination {
@@ -175,6 +235,19 @@ const confirmSelection = () => {
justify-content: flex-end; justify-content: flex-end;
} }
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.selected-info {
margin-right: auto;
color: #606266;
font-size: 13px;
}
:deep(.el-table__row) { :deep(.el-table__row) {
cursor: pointer; cursor: pointer;
} }
@@ -184,8 +257,16 @@ const confirmSelection = () => {
} }
:deep(.current-row) { :deep(.current-row) {
background-color: var(--el-color-primary-light-8) !important; background-color: var(--el-color-primary-light-9) !important;
}
:deep(.current-row td) {
color: var(--el-color-primary); color: var(--el-color-primary);
font-weight: bold; }
:deep(.el-avatar) {
background-color: var(--el-color-primary-light-5);
color: #fff;
font-size: 12px;
} }
</style> </style>
+12 -7
View File
@@ -1,20 +1,20 @@
<template> <template>
<el-dialog <el-dialog
v-model="visible" v-model="visible"
title="选择头像" :title="title"
width="800px" width="800px"
append-to-body append-to-body
@close="handleClose" @close="handleClose"
> >
<div class="avatar-selector"> <div class="avatar-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick"> <el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 用户文件列表 --> <!-- 文件列表 -->
<el-tab-pane label="用户文件" name="userFiles"> <el-tab-pane label="文件" name="userFiles">
<div class="file-list-container"> <div class="file-list-container">
<div class="file-list-header"> <div class="file-list-header">
<h4>用户文件列表</h4> <h4>文件列表</h4>
<el-button type="primary" @click="switchToUpload" :icon="Upload"> <el-button type="primary" @click="switchToUpload" :icon="Upload">
上传新头像 上传新文件
</el-button> </el-button>
</div> </div>
<div class="file-grid" v-loading="loading"> <div class="file-grid" v-loading="loading">
@@ -58,8 +58,8 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 上传头像 --> <!-- 上传文件 -->
<el-tab-pane label="上传头像" name="upload"> <el-tab-pane label="上传文件" name="upload">
<div class="upload-section"> <div class="upload-section">
<el-upload <el-upload
:http-request="handleUpload" :http-request="handleUpload"
@@ -118,6 +118,10 @@ import { closeAllMessage } from '../../utils/message'
currentCoverId: { currentCoverId: {
type: [String, Number], type: [String, Number],
default: '' default: ''
},
title: {
type: String,
default: '选择文件'
} }
}) })
@@ -270,6 +274,7 @@ import { closeAllMessage } from '../../utils/message'
formData.append('files', file) formData.append('files', file)
formData.append('file_names', file.name) formData.append('file_names', file.name)
formData.append('update_type', 'cover') formData.append('update_type', 'cover')
formData.append('open_down', 'true')
try { try {
const res = await uploadFile(formData) const res = await uploadFile(formData)
@@ -0,0 +1,392 @@
<template>
<el-dialog
v-model="visible"
title="选择优惠码"
width="900px"
append-to-body
@close="handleClose"
>
<div class="discount-code-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 选择优惠码 -->
<el-tab-pane label="选择优惠码" name="selectCode">
<div class="code-list-container">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" :model="searchParams" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchParams.key"
placeholder="搜索优惠码名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">
搜索
</el-button>
<el-button @click="handleReset" :icon="Refresh">
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 优惠码列表表格 -->
<el-table
v-loading="loading"
:data="codeList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="id" label="优惠码ID" width="100" align="center" />
<el-table-column prop="name" label="优惠码名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="code" label="优惠码" width="150" show-overflow-tooltip>
<template #default="{ row }">
<el-tag type="success" effect="plain">{{ row.code }}</el-tag>
</template>
</el-table-column>
<el-table-column label="优惠类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.percentage > 0 ? 'warning' : 'primary'" size="small">
{{ row.percentage > 0 ? '折扣' : '固定金额' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="优惠值" width="100" align="right">
<template #default="{ row }">
<span v-if="row.percentage > 0" class="discount-value">{{ row.percentage }}%</span>
<span v-else class="discount-value">¥{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="最低消费" width="100" align="right">
<template #default="{ row }">
<span v-if="row.minAmount">¥{{ (row.minAmount / 100).toFixed(2) }}</span>
<span v-else>无限制</span>
</template>
</el-table-column>
<el-table-column label="使用次数" width="100" align="center">
<template #default="{ row }">
{{ row.userTimes || 0 }} / {{ row.maxTimes || '∞' }}
</template>
</el-table-column>
<el-table-column label="有效期" width="160" align="center">
<template #default="{ row }">
<span :class="{ 'expired': isExpired(row.endTime) }">
{{ formatDate(row.endTime) }}
</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<el-empty v-if="codeList.length === 0 && !loading" description="暂无优惠码数据" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleConfirm"
:disabled="!selectedCode"
>
确定选择
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getDiscountCodeList } from '@/api/admin/discount'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 当前已选中的优惠码ID(用于回显)
currentCodeId: {
type: [String, Number],
default: ''
},
// 类型过滤:discount_code - 优惠码
codeType: {
type: String,
default: 'code'
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm'])
// 响应式数据
const visible = ref(false)
const activeTab = ref('selectCode')
const loading = ref(false)
const codeList = ref([])
const total = ref(0)
const selectedCode = ref(null)
// 搜索参数
const searchParams = reactive({
key: '',
page: 1,
count: 10
})
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
// 重置状态
activeTab.value = 'selectCode'
selectedCode.value = null
searchParams.page = 1
fetchCodeList()
}
})
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取优惠码列表
const fetchCodeList = async () => {
loading.value = true
codeList.value = []
try {
const params = {
page: searchParams.page,
count: searchParams.count,
discount_type: props.codeType
}
if (searchParams.key) {
params.key = searchParams.key
}
const res = await getDiscountCodeList(params)
if (res.data.code === 200) {
codeList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0
// 如果有当前选中的优惠码ID,自动选中
if (props.currentCodeId) {
const currentCode = codeList.value.find(
code => code.id === props.currentCodeId
)
if (currentCode) {
selectedCode.value = currentCode
}
}
} else {
ElMessage.error(res.data.msg || '获取优惠码列表失败')
}
} catch (error) {
console.error('获取优惠码列表失败:', error)
ElMessage.error('获取优惠码列表失败')
} finally {
loading.value = false
}
}
// 处理标签页切换
const handleTabClick = (tab) => {
if (tab.paneName === 'selectCode') {
fetchCodeList()
}
}
// 搜索
const handleSearch = () => {
searchParams.page = 1
fetchCodeList()
}
// 重置搜索
const handleReset = () => {
searchParams.key = ''
searchParams.page = 1
fetchCodeList()
}
// 分页处理
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
fetchCodeList()
}
const handlePageChange = (page) => {
searchParams.page = page
fetchCodeList()
}
// 选择优惠码
const handleCurrentChange = (row) => {
selectedCode.value = row
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedCode.value && row.id === selectedCode.value.id) {
return 'selected-row'
}
return ''
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedCode.value = null
codeList.value = []
searchParams.key = ''
searchParams.page = 1
total.value = 0
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 判断是否过期
const isExpired = (endTime) => {
if (!endTime) return false
return new Date(endTime) < new Date()
}
// 确认选择
const handleConfirm = () => {
if (selectedCode.value) {
emit('confirm', selectedCode.value)
handleClose()
} else {
ElMessage.warning('请选择一个优惠码')
}
}
</script>
<style scoped>
.discount-code-selector {
min-height: 450px;
}
.code-list-container {
padding: 10px 0;
}
.filter-section {
margin-bottom: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.discount-value {
color: #e6a23c;
font-weight: 600;
}
.expired {
color: #f56c6c;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 表格样式 */
:deep(.el-table__row) {
cursor: pointer;
}
:deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
:deep(.selected-row) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.selected-row td) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.el-table__body tr.current-row > td) {
background-color: var(--el-color-primary-light-8) !important;
}
/* 标签页样式 */
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
:deep(.el-tabs__item) {
font-size: 15px;
padding: 0 24px;
}
:deep(.el-tabs__item.is-active) {
font-weight: 600;
}
</style>
@@ -0,0 +1,100 @@
<template>
<el-dialog v-model="visible" title="选择宿主机组" width="650px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索宿主机组名称" clearable style="width:200px" @keyup.enter="handleSearch" @clear="handleSearch" />
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="filteredList" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column prop="serviceId" label="服务ID" width="80" />
</el-table>
<div class="pagination-wrapper" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getHostGroupList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
currentId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const selectedItem = ref(null)
const keyword = ref('')
const page = ref(1)
const pageSize = 10
const total = ref(0)
const filteredList = computed(() => {
if (!keyword.value) return list.value
const kw = keyword.value.toLowerCase()
return list.value.filter(i => (i.name || '').toLowerCase().includes(kw))
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) { page.value = 1; loadList() }
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
loading.value = true
try {
const res = await getHostGroupList({ service_id: props.serviceId, page: page.value, count: pageSize })
const body = res?.data
if (body?.code === 200 && body?.data) {
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
list.value = items.map(i => ({
id: i.id,
name: i.name ?? i.Name,
note: i.note ?? i.Note,
serviceId: i.serviceId ?? i.service_id ?? 0,
serviceHostGroupId: i.serviceHostGroupId ?? 0
}))
total.value = body.data.total ?? body.data.all_count ?? list.value.length
}
} catch { /* ignore */ }
finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) {
emit('confirm', selectedItem.value)
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
@@ -0,0 +1,98 @@
<template>
<el-dialog v-model="visible" title="选择宿主机" width="700px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索宿主机名称/IP" clearable style="width:200px" @keyup.enter="loadList" @clear="loadList" />
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="filteredList" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" min-width="130" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getRemoteHostList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
hostGroupId: { type: Number, default: 0 },
currentId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const selectedItem = ref(null)
const keyword = ref('')
const page = ref(1)
const pageSize = 10
const total = ref(0)
const filteredList = computed(() => {
if (!keyword.value) return list.value
const kw = keyword.value.toLowerCase()
return list.value.filter(i => (i.name || '').toLowerCase().includes(kw) || (i.ip || '').includes(kw))
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) { page.value = 1; loadList() }
})
watch(visible, (val) => emit('update:modelValue', val))
const loadList = async () => {
loading.value = true
try {
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
if (props.hostGroupId) params.host_group_id = props.hostGroupId
const res = await getRemoteHostList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
const hosts = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
list.value = hosts.map(i => ({
id: i.id, name: i.name, ip: i.ip, is_active: i.is_active ?? true,
host_group_id: i.host_group_id
}))
total.value = inner.total ?? list.value.length
}
} catch { /* ignore */ }
finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) { emit('confirm', selectedItem.value); visible.value = false }
}
const handleClose = () => { selectedItem.value = null }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div class="icon-selector">
<el-input
:model-value="modelValue"
placeholder="点击选择图标"
readonly
@click="popoverVisible = true"
>
<template #prefix>
<el-icon v-if="modelValue" :size="18">
<component :is="modelValue" />
</el-icon>
</template>
<template #suffix>
<el-icon v-if="modelValue" class="clear-btn" @click.stop="handleClear"><CircleClose /></el-icon>
</template>
</el-input>
<el-dialog v-model="popoverVisible" title="选择图标" width="680px" append-to-body>
<el-input v-model="searchKey" placeholder="搜索图标名称" clearable class="icon-search">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="icon-grid">
<div
v-for="name in filteredIcons"
:key="name"
class="icon-item"
:class="{ active: modelValue === name }"
@click="handleSelect(name)"
>
<el-icon :size="22"><component :is="name" /></el-icon>
<span class="icon-name">{{ name }}</span>
</div>
</div>
<div v-if="filteredIcons.length === 0" class="icon-empty">
未找到匹配的图标
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { Search, CircleClose } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const popoverVisible = ref(false)
const searchKey = ref('')
const allIcons = Object.keys(ElementPlusIconsVue).sort()
const filteredIcons = computed(() => {
if (!searchKey.value) return allIcons
const key = searchKey.value.toLowerCase()
return allIcons.filter(name => name.toLowerCase().includes(key))
})
const handleSelect = (name) => {
emit('update:modelValue', name)
popoverVisible.value = false
searchKey.value = ''
}
const handleClear = () => {
emit('update:modelValue', '')
}
</script>
<style scoped>
.icon-selector { width: 100%; }
.icon-search { margin-bottom: 12px; }
.icon-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
max-height: 400px;
overflow-y: auto;
padding: 4px;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 4px;
border: 1px solid #ebeef5;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.icon-item:hover {
border-color: #409eff;
background: #ecf5ff;
color: #409eff;
}
.icon-item.active {
border-color: #409eff;
background: #409eff;
color: #fff;
}
.icon-name {
font-size: 11px;
text-align: center;
line-height: 1.2;
word-break: break-all;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-empty {
text-align: center;
color: #909399;
padding: 40px 0;
font-size: 14px;
}
.clear-btn {
cursor: pointer;
color: #c0c4cc;
transition: color 0.2s;
}
.clear-btn:hover { color: #f56c6c; }
</style>
+684
View File
@@ -0,0 +1,684 @@
<template>
<el-dialog
v-model="visible"
title="选择图片"
width="900px"
append-to-body
@close="handleClose"
>
<div class="image-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 文件库 -->
<el-tab-pane label="文件库" name="fileLibrary">
<div class="file-list-container">
<div class="file-list-header">
<h4>图片文件库</h4>
<div class="header-actions">
<span v-if="props.multiple && selectedIds.size > 0" class="selected-count">
已选 {{ selectedIds.size }} 个文件
</span>
<el-button type="primary" @click="switchToUpload" :icon="Upload">
上传新图片
</el-button>
</div>
</div>
<!-- 搜索过滤 -->
<div class="filter-section">
<el-input
v-model="searchKeyword"
placeholder="搜索文件名"
:prefix-icon="Search"
clearable
@input="handleSearch"
style="width: 300px;"
/>
</div>
<div class="file-grid" v-loading="loading">
<div
v-for="file in filteredFileList"
:key="file.id"
class="file-item"
:class="{ 'selected': props.multiple ? selectedIds.has(file.id) : selectedId === file.id }"
@click="selectFile(file)"
>
<div class="file-check-badge" v-if="props.multiple && selectedIds.has(file.id)">
<el-icon><Select /></el-icon>
</div>
<div class="file-preview">
<img
:src="processImageUrl(file.url)"
:alt="file.realName"
@error="handleImageError"
/>
</div>
<div class="file-info">
<p class="file-name" :title="file.realName">{{ file.realName }}</p>
<p class="file-size">{{ formatFileSize(file.size) }}</p>
</div>
</div>
</div>
<el-empty v-if="filteredFileList.length === 0 && !loading" description="暂无图片文件" />
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[12, 24, 36, 48]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</el-tab-pane>
<!-- 上传图片 -->
<el-tab-pane label="上传图片" name="upload">
<div class="upload-section">
<el-upload
:auto-upload="false"
:show-file-list="false"
:on-change="handleFileChange"
accept="image/*"
multiple
drag
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持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
@@ -0,0 +1,124 @@
<template>
<el-dialog v-model="visible" title="选择镜像" width="700px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 120px" @change="handleSearch">
<el-option label="Linux" value="linux" />
<el-option label="Windows" value="windows" />
</el-select>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="200" show-overflow-tooltip />
<el-table-column label="系统" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ row.os_type }}</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getImageList } from '@/api/admin/kvmService'
import { getUserVmHostImages, getGoodHostGroupImages } from '@/api/admin/userVm'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
goodId: { type: Number, default: 0 },
currentId: { type: Number, default: 0 },
useUserVmApi: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const selectedItem = ref(null)
const keyword = ref('')
const filterOsType = ref('')
const page = ref(1)
const pageSize = 10
const total = ref(0)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) { page.value = 1; loadList() }
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
loading.value = true
try {
let res
if (props.goodId > 0) {
const params = { good_id: props.goodId, page: page.value, count: pageSize }
if (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value
res = await getGoodHostGroupImages(params)
} else if (props.useUserVmApi) {
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
if (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value
res = await getUserVmHostImages(params)
} else {
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
if (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value
res = await getImageList(params)
}
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
let items = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
if (props.useUserVmApi || props.goodId > 0) {
items = items.map(item => item.image || item).filter(Boolean)
}
list.value = items
total.value = inner.total ?? inner.all_count ?? list.value.length
}
} catch { /* ignore */ }
finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) {
emit('confirm', selectedItem.value)
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
@@ -0,0 +1,83 @@
<template>
<el-dialog v-model="visible" title="选择主控服务" width="640px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-input v-model="keyword" placeholder="搜索服务名称/地址" clearable style="width:220px"
@keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="handleRefresh" :loading="loading">刷新</el-button>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row
@current-change="row => selected = row" :height="320" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="服务名称" min-width="160" show-overflow-tooltip />
<el-table-column label="地址" min-width="180">
<template #default="{ row }">
<span style="font-family:monospace;color:#409eff">{{ row.host }}:{{ row.port }}</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无主控服务" />
<div class="selector-footer-bar">
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getKvmServiceList } from '@/api/admin/kvmService'
const props = defineProps({ modelValue: { type: Boolean, default: false } })
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selected = ref(null)
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
loading.value = true
try {
const params = { page: page.value, count: pageSize.value }
if (keyword.value) params.key = keyword.value
const res = await getKvmServiceList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
list.value = raw.map(s => ({ id: s.id ?? s.Id, name: s.name ?? s.Name, host: s.host ?? s.Host, port: s.port ?? s.Port, note: s.note ?? s.Note }))
total.value = inner.all_count ?? inner.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; loadList() }
const handleRefresh = () => { keyword.value = ''; page.value = 1; loadList() }
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
</style>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="menu-path-selector">
<el-input
:model-value="modelValue"
placeholder="点击从菜单中选择路径,或手动输入"
clearable
@input="$emit('update:modelValue', $event)"
>
<template #append>
<el-button @click="dialogVisible = true">
<el-icon><FolderOpened /></el-icon>
</el-button>
</template>
</el-input>
<el-dialog v-model="dialogVisible" title="选择菜单路径" width="550px" append-to-body>
<el-input v-model="searchKey" placeholder="搜索菜单名称或路径" clearable class="path-search">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="menu-tree">
<el-tree
:data="filteredMenuTree"
:props="{ label: 'label', children: 'children' }"
node-key="path"
:default-expand-all="!!searchKey"
:expand-on-click-node="false"
highlight-current
@node-click="handleNodeClick"
>
<template #default="{ data }">
<div class="tree-node" :class="{ 'is-selected': modelValue === data.path, 'no-path': !data.path }">
<el-icon v-if="data.icon" :size="16" style="margin-right: 6px; flex-shrink: 0;">
<component :is="data.icon" />
</el-icon>
<span class="node-title">{{ data.title }}</span>
<el-tag v-if="data.path" size="small" type="info" class="node-path">{{ data.path }}</el-tag>
</div>
</template>
</el-tree>
</div>
<div v-if="filteredMenuTree.length === 0" class="tree-empty">
未找到匹配的菜单
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Search, FolderOpened } from '@element-plus/icons-vue'
import { menus } from '@/config/menus'
const props = defineProps({
modelValue: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const dialogVisible = ref(false)
const searchKey = ref('')
const buildTreeData = (menuList) => {
return menuList.map(item => {
const node = {
path: item.path || '',
title: item.title,
icon: item.icon || '',
label: item.title
}
if (item.children?.length) {
node.children = buildTreeData(item.children)
}
return node
})
}
const menuTree = computed(() => buildTreeData(menus))
const filterTree = (nodes, keyword) => {
const key = keyword.toLowerCase()
const result = []
for (const node of nodes) {
const titleMatch = node.title?.toLowerCase().includes(key)
const pathMatch = node.path?.toLowerCase().includes(key)
let filteredChildren = []
if (node.children?.length) {
filteredChildren = filterTree(node.children, keyword)
}
if (titleMatch || pathMatch || filteredChildren.length > 0) {
result.push({
...node,
children: filteredChildren.length > 0 ? filteredChildren : node.children
})
}
}
return result
}
const filteredMenuTree = computed(() => {
if (!searchKey.value) return menuTree.value
return filterTree(menuTree.value, searchKey.value)
})
const handleNodeClick = (data) => {
if (!data.path) return
emit('update:modelValue', data.path)
dialogVisible.value = false
searchKey.value = ''
}
</script>
<style scoped>
.menu-path-selector { width: 100%; }
.path-search { margin-bottom: 12px; }
.menu-tree {
max-height: 400px;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 8px 0;
}
.tree-node {
display: flex;
align-items: center;
padding: 2px 4px;
width: 100%;
border-radius: 4px;
}
.tree-node.is-selected {
background: #ecf5ff;
color: #409eff;
}
.tree-node.no-path {
color: #909399;
cursor: default;
}
.node-title { margin-right: 8px; font-size: 13px; }
.node-path { flex-shrink: 0; }
.tree-empty {
text-align: center;
color: #909399;
padding: 40px 0;
font-size: 14px;
}
:deep(.el-tree-node__content) { height: 36px; }
:deep(.el-tree-node__content:hover) { background-color: #f5f7fa; }
</style>
@@ -0,0 +1,159 @@
<template>
<el-dialog v-model="visible" title="选择网络" width="800px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-if="!filterType" v-model="typeFilter" placeholder="网络类型" clearable style="width: 130px" @change="handleSearch">
<el-option label="网桥(Bridge)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
<el-tag v-else type="success" size="small">{{ filterType === 'bridge' ? '网桥' : filterType === 'nat' ? '内网' : filterType }}</el-tag>
<el-select v-if="!filterUsed" v-model="usedFilter" placeholder="占用状态" clearable style="width: 130px" @change="handleSearch">
<el-option label="未占用" value="false" />
<el-option label="已占用" value="true" />
</el-select>
<el-tag v-else :type="filterUsed === 'false' ? 'success' : 'info'" size="small">{{ filterUsed === 'false' ? '仅未占用' : '仅已占用' }}</el-tag>
<el-select v-model="ipVersionFilter" placeholder="IP版本" clearable style="width: 110px" @change="handleSearch">
<el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" />
</el-select>
<el-button :icon="Refresh" @click="loadList" circle />
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
:height="340" :row-class-name="rowClassName" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">
{{ row.type === 'bridge' ? '网桥' : 'NAT' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip />
<el-table-column prop="gateway" label="网关" width="130" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="bridge_name" label="网桥名称" width="100" />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag>
<el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag>
<el-tag v-else type="info" size="small">-</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
</div>
<template #footer>
<div style="display: flex; justify-content: space-between; width: 100%">
<el-button type="success" @click="handleCreate">创建网络</el-button>
<div style="display: flex; gap: 8px">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getNetworkList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
hostId: { type: Number, default: 0 },
filterType: { type: String, default: '' },
filterUsed: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const typeFilter = ref('')
const usedFilter = ref('')
const ipVersionFilter = ref('')
const selectedItem = ref(null)
const type = ref('bridge')
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
page.value = 1
keyword.value = ''
typeFilter.value = props.filterType || ''
usedFilter.value = props.filterUsed || ''
ipVersionFilter.value = ''
selectedItem.value = null
loadList()
}
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
if (!props.serviceId || !props.hostId) return
loading.value = true
try {
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value }
const effectiveType = props.filterType || typeFilter.value || type.value
if (effectiveType) params.type = effectiveType
if (keyword.value) params.keyword = keyword.value
const effectiveUsed = props.filterUsed || usedFilter.value
if (effectiveUsed) params.used = effectiveUsed
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
const res = await getNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const items = inner.data || inner.networks || (Array.isArray(inner) ? inner : [])
list.value = items.map(item => ({
...item,
_used: item.used !== undefined ? item.used
: effectiveUsed === 'true' ? true
: effectiveUsed === 'false' ? false
: null
}))
total.value = inner.meta?.count ?? inner.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) {
emit('confirm', selectedItem.value)
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
const handleCreate = () => {
emit('create')
}
defineExpose({ loadList })
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<el-dialog v-model="visible" title="选择订单" width="800px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-input v-model="keyword" placeholder="搜索订单名称/ID" clearable style="width:220px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="handleRefresh" :loading="loading">刷新</el-button>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row @current-change="row => selected = row" :height="360" stripe size="small">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="订单名称" min-width="200" show-overflow-tooltip />
<el-table-column label="价格" width="100">
<template #default="{ row }">¥{{ ((row.price || 0) / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.state === 1 ? 'success' : row.state === 0 ? 'warning' : 'info'" size="small">
{{ row.state === 1 ? '已支付' : row.state === 0 ? '待支付' : '已失效' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" width="160">
<template #default="{ row }">{{ formatTime(row.expireTime || row.expire_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无订单" />
<div class="selector-selected" v-if="selected">
<el-tag type="primary" size="large" closable @close="selected = null">已选{{ selected.name }} (ID: {{ selected.id }})</el-tag>
</div>
<div class="selector-footer-bar">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }" @current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getOrderList } from '@/api/admin/order'
import dayjs from 'dayjs'
const props = defineProps({ modelValue: { type: Boolean, default: false } })
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selected = ref(null)
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '-'
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
loading.value = true
try {
const params = { page: page.value, count: pageSize.value }
if (keyword.value) params.key = keyword.value
const res = await getOrderList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.list || d.data || (Array.isArray(d) ? d : [])
total.value = d.all_count ?? d.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; loadList() }
const handleRefresh = () => { keyword.value = ''; page.value = 1; loadList() }
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-selected { margin-top: 12px; }
.selector-footer-bar { display: flex; justify-content: flex-end; align-items: center; margin-top: 10px; }
</style>
@@ -0,0 +1,360 @@
<template>
<el-dialog
v-model="visible"
title="选择路径权限"
width="900px"
append-to-body
@close="handleClose"
>
<div class="permission-selector">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" :model="searchParams" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchParams.key"
placeholder="搜索路径或名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="请求方法">
<el-select
v-model="searchParams.method"
placeholder="全部方法"
clearable
style="width: 120px"
>
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
<el-option label="PATCH" value="PATCH" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">
搜索
</el-button>
<el-button @click="handleReset" :icon="Refresh">
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 权限列表表格 -->
<el-table
v-loading="loading"
:data="filteredList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="400"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="method" label="方法" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.method" :type="getMethodTag(row.method)" size="small">
{{ row.method }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="250" show-overflow-tooltip />
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 已选信息 -->
<div class="selected-info" v-if="selectedPermission">
<el-alert type="success" :closable="false">
<template #title>
<div class="selected-content">
<span>已选择: </span>
<el-tag v-if="selectedPermission.method" :type="getMethodTag(selectedPermission.method)" size="small" style="margin-right: 8px;">
{{ selectedPermission.method }}
</el-tag>
<span class="selected-path">{{ selectedPermission.path }}</span>
<span class="selected-name" v-if="selectedPermission.name"> - {{ selectedPermission.name }}</span>
</div>
</template>
</el-alert>
</div>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedPermission">
确认选择
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getPermissionList } from '@/api/admin/Permission'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
currentPermissionId: {
type: Number,
default: null
}
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 搜索参数
const searchParams = reactive({
key: '',
method: '',
page: 1,
count: 10
})
// 状态
const loading = ref(false)
const permissionList = ref([])
const total = ref(0)
const selectedPermission = ref(null)
// 过滤后的列表
const filteredList = computed(() => {
let list = permissionList.value
// 关键词过滤
if (searchParams.key) {
const keyword = searchParams.key.toLowerCase()
list = list.filter(item =>
(item.path && item.path.toLowerCase().includes(keyword)) ||
(item.name && item.name.toLowerCase().includes(keyword)) ||
(item.note && item.note.toLowerCase().includes(keyword))
)
}
// 方法过滤
if (searchParams.method) {
list = list.filter(item => item.method === searchParams.method)
}
return list
})
// 获取方法标签颜色
const getMethodTag = (method) => {
const tagMap = {
'GET': 'success',
'POST': 'primary',
'PUT': 'warning',
'DELETE': 'danger',
'PATCH': 'info'
}
return tagMap[method?.toUpperCase()] || 'info'
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedPermission.value && row.id === selectedPermission.value.id) {
return 'selected-row'
}
if (props.currentPermissionId && row.id === props.currentPermissionId) {
return 'current-row'
}
return ''
}
// 获取权限列表
const fetchPermissionList = async () => {
loading.value = true
try {
const res = await getPermissionList({
page: 1,
count: 10
})
if (res.data.code === 200) {
permissionList.value = res.data.data?.list || []
total.value = permissionList.value.length
} else {
ElMessage.error(res.data.message || '获取权限列表失败')
}
} catch (error) {
console.error('获取权限列表失败:', error)
ElMessage.error('获取权限列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
searchParams.page = 1
}
// 重置
const handleReset = () => {
searchParams.key = ''
searchParams.method = ''
searchParams.page = 1
}
// 分页
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
}
const handlePageChange = (page) => {
searchParams.page = page
}
// 选择行
const handleCurrentChange = (row) => {
selectedPermission.value = row
}
// 确认选择
const handleConfirm = () => {
if (selectedPermission.value) {
emit('confirm', selectedPermission.value)
handleClose()
}
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
selectedPermission.value = null
handleReset()
}
// 监听弹窗打开
watch(() => props.modelValue, (val) => {
if (val) {
fetchPermissionList()
// 如果有当前选中的ID,尝试预选
if (props.currentPermissionId) {
const found = permissionList.value.find(p => p.id === props.currentPermissionId)
if (found) {
selectedPermission.value = found
}
}
}
})
</script>
<style scoped>
.permission-selector {
padding: 0;
}
.filter-section {
margin-bottom: 16px;
padding: 16px;
background: #fafbfc;
border-radius: 4px;
}
.search-form {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.selected-info {
margin-top: 16px;
}
.selected-content {
display: flex;
align-items: center;
gap: 4px;
}
.selected-path {
font-weight: 500;
color: #303133;
}
.selected-name {
color: #909399;
}
:deep(.el-table .selected-row) {
background-color: #ecf5ff !important;
}
:deep(.el-table .current-row) {
background-color: #f0f9eb !important;
}
:deep(.el-table .selected-row td),
:deep(.el-table .current-row td) {
background-color: inherit !important;
}
/* 移动端适配 */
@media (max-width: 768px) {
:deep(.el-dialog) {
width: 95% !important;
margin: 2vh auto !important;
}
.filter-section {
padding: 12px;
}
.search-form {
flex-direction: column;
}
.search-form .el-form-item {
margin-right: 0;
margin-bottom: 8px;
width: 100%;
}
.search-form .el-input,
.search-form .el-select {
width: 100% !important;
}
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<template>
<el-dialog v-model="visible" title="选择套餐" width="700px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建套餐</el-button>
<span style="color:#909399;font-size:13px" v-if="goodId">商品 ID: {{ goodId }}</span>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row @current-change="row => selected = row" :height="300" stripe size="small">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="套餐名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="note" label="说明" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.disable ? 'danger' : 'success'" size="small">{{ row.disable ? '禁用' : '启用' }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无套餐" />
<div class="selector-footer-bar">
<span v-if="selected" style="color:#606266;font-size:13px">已选{{ selected.name }} (ID: {{ selected.id }})</span>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }" @current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
<!-- 新建套餐弹窗 -->
<el-dialog v-model="showCreate" title="新建套餐" width="680px" append-to-body destroy-on-close class="scrollable-dialog">
<el-form :model="createForm" label-width="90px">
<el-form-item label="套餐名称" required><el-input v-model="createForm.name" placeholder="请输入套餐名称" /></el-form-item>
<el-form-item label="说明"><el-input v-model="createForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" /></el-form-item>
<el-form-item label="参数配置">
<div style="width:100%">
<div v-if="!goodId" style="color:#c0c4cc;font-size:13px">请先选择商品</div>
<div v-else-if="createSpecLoading" style="color:#909399;font-size:13px">加载参数中...</div>
<div v-else-if="createSpecList.length === 0" style="color:#909399;font-size:13px">该商品暂无参数</div>
<div v-else>
<div v-for="spec in createSpecList" :key="spec.id" style="margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #f5f5f5">
<div style="font-size:13px;font-weight:500;color:#303133;margin-bottom:6px">
{{ spec.name }}
<el-tag v-if="spec.must" size="small" type="danger" style="margin-left:4px">必填</el-tag>
</div>
<template v-if="spec.type === 'select' && spec.attrs && spec.attrs.length > 0">
<el-radio-group v-model="createSpecValues[spec.id]" size="small" @change="buildCreateArgsJson">
<el-radio-button v-for="attr in spec.attrs" :key="attr.id" :value="attr.id">{{ attr.name }}</el-radio-button>
</el-radio-group>
</template>
<template v-else-if="spec.type === 'number'">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<el-input-number
v-model="createDisplayValues[spec.id]"
:min="hasUnit(spec) ? fromBaseUnit(spec.min ?? 0, createDisplayUnits[spec.id], getArgKey(spec)) : (spec.min ?? 0)"
:max="hasUnit(spec) ? fromBaseUnit(spec.max ?? 0, createDisplayUnits[spec.id], getArgKey(spec)) : (spec.max ?? 0)"
:step="hasUnit(spec) ? (fromBaseUnit(spec.step ?? 1, createDisplayUnits[spec.id], getArgKey(spec)) || 1) : (spec.step ?? 1)"
:step-strictly="true"
size="small"
@change="onCreateNumberChange(spec)"
style="width:180px"
/>
<el-select v-if="hasUnit(spec)" :model-value="createDisplayUnits[spec.id]" size="small" style="width:90px" @change="(newUnit) => onCreateUnitChange(spec, newUnit)">
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
</el-select>
<span style="font-size:12px;color:#909399">范围: {{ spec.min ?? 0 }} ~ {{ spec.max ?? 0 }}
<template v-if="hasUnit(spec)"> {{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }}</span>
</div>
</template>
<template v-else>
<el-input v-model="createSpecValues[spec.id]" placeholder="请输入值" size="small" style="width:200px" @input="buildCreateArgsJson" />
</template>
</div>
<div v-if="createForm.args" style="margin-top:8px">
<div style="font-size:12px;color:#909399;margin-bottom:4px">参数 JSON</div>
<el-input v-model="createForm.args" type="textarea" :rows="3" readonly style="font-family:monospace;font-size:12px" />
</div>
</div>
</div>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="createForm.index" :min="0" controls-position="right" style="width:120px" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getProductPlanList, createProductPlan, getProductParameterList } from '@/api/admin/product'
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
const props = defineProps({
modelValue: { type: Boolean, default: false },
goodId: { type: [Number, String], default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const selected = ref(null)
const showCreate = ref(false)
const createLoading = ref(false)
const createForm = reactive({ name: '', note: '', index: 0, args: '' })
const createSpecList = ref([])
const createSpecLoading = ref(false)
const createSpecValues = reactive({})
const createDisplayValues = reactive({})
const createDisplayUnits = reactive({})
watch(showCreate, (v) => {
if (v && props.goodId) loadCreateSpec()
})
const loadCreateSpec = async () => {
createSpecLoading.value = true
try {
const res = await getProductParameterList({ good_id: props.goodId })
if (res?.data?.code === 200) {
createSpecList.value = res.data.data || []
for (const spec of createSpecList.value) {
if (spec.type === 'number') {
if (createSpecValues[spec.id] === undefined) createSpecValues[spec.id] = spec.min ?? 0
if (hasUnit(spec)) {
createDisplayUnits[spec.id] = getParamDefaultUnit(spec)
createDisplayValues[spec.id] = fromBaseUnit(spec.min ?? 0, createDisplayUnits[spec.id], getArgKey(spec))
} else {
createDisplayValues[spec.id] = spec.min ?? 0
}
}
}
}
} catch { createSpecList.value = [] } finally { createSpecLoading.value = false }
}
const onCreateNumberChange = (spec) => {
if (hasUnit(spec)) {
const argKey = getArgKey(spec)
const unit = createDisplayUnits[spec.id]
createSpecValues[spec.id] = Math.round(toBaseUnit(createDisplayValues[spec.id] || 0, unit, argKey))
} else {
createSpecValues[spec.id] = createDisplayValues[spec.id]
}
buildCreateArgsJson()
}
const onCreateUnitChange = (spec, newUnit) => {
const argKey = getArgKey(spec)
const oldUnit = createDisplayUnits[spec.id]
const oldDisplay = createDisplayValues[spec.id] || 0
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
createDisplayUnits[spec.id] = newUnit
createDisplayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
createSpecValues[spec.id] = Math.round(baseValue)
buildCreateArgsJson()
}
const buildCreateArgsJson = () => {
const result = []
for (const spec of createSpecList.value) {
const val = createSpecValues[spec.id]
if (val === undefined || val === null || val === '') continue
if (spec.type === 'select') {
const attr = spec.attrs?.find(a => a.id === val)
if (attr) result.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value, number: 0, key: getArgKey(spec) || undefined })
} else if (spec.type === 'number') {
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: '', number: val, key: getArgKey(spec) || undefined })
} else {
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val), number: 0, key: getArgKey(spec) || undefined })
}
}
createForm.args = result.length > 0 ? JSON.stringify(result) : ''
}
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
loading.value = true
try {
const params = { page: page.value, count: pageSize.value }
if (props.goodId) params.good_id = props.goodId
const res = await getProductPlanList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || (Array.isArray(d) ? d : [])
total.value = d.all_count ?? d.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const submitCreate = async () => {
if (!createForm.name) { ElMessage.warning('请输入套餐名称'); return }
if (!props.goodId) { ElMessage.warning('请先选择商品'); return }
createLoading.value = true
try {
const fd = new FormData()
fd.append('good_id', props.goodId)
fd.append('name', createForm.name)
if (createForm.note) fd.append('note', createForm.note)
fd.append('index', createForm.index)
if (createForm.args) fd.append('args', createForm.args)
const res = await createProductPlan(fd)
if (res?.data?.code === 200) {
ElMessage.success('创建成功')
showCreate.value = false
Object.assign(createForm, { name: '', note: '', index: 0, args: '' })
for (const k in createSpecValues) delete createSpecValues[k]
for (const k in createDisplayValues) delete createDisplayValues[k]
for (const k in createDisplayUnits) delete createDisplayUnits[k]
loadList()
} else ElMessage.error(res?.data?.message || '创建失败')
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
}
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
</style>
@@ -0,0 +1,312 @@
<template>
<el-dialog
v-model="visible"
title="选择商品组"
width="800px"
append-to-body
@close="handleClose"
>
<div class="group-selector">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" class="search-form">
<el-form-item>
<el-input
v-model="keyword"
placeholder="搜索商品组名称"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">搜索</el-button>
<el-button @click="handleReset" :icon="Refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 商品组列表表格 -->
<el-table
v-loading="loading"
:data="groupList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="商品组名称" min-width="180" show-overflow-tooltip />
<el-table-column label="父级ID" width="80" align="center">
<template #default="{ row }">
{{ row.parentId || '-' }}
</template>
</el-table-column>
<el-table-column label="标签" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.tag" size="small" type="info">{{ row.tag?.name || row.tag }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.disable ? 'danger' : 'success'" size="small">
{{ row.disable ? '禁用' : '启用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<el-empty v-if="groupList.length === 0 && !loading" description="暂无商品组数据" />
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleConfirm"
:disabled="!selectedGroup"
>
确定选择
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getProductGroupList } from '@/api/admin/product'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 当前已选中的商品组ID(用于回显)
currentGroupId: {
type: [String, Number],
default: ''
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm'])
// 响应式数据
const visible = ref(false)
const loading = ref(false)
const groupList = ref([])
const total = ref(0)
const selectedGroup = ref(null)
const keyword = ref('')
// 搜索参数
const searchParams = reactive({
page: 1,
count: 10
})
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
selectedGroup.value = null
keyword.value = ''
searchParams.page = 1
fetchGroupList()
}
})
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取商品组列表
const fetchGroupList = async () => {
loading.value = true
groupList.value = []
try {
const params = {
page: searchParams.page,
count: searchParams.count
}
if (keyword.value.trim()) {
params.keyword = keyword.value.trim()
}
const res = await getProductGroupList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
const items = Array.isArray(inner) ? inner : (inner.data || inner.list || [])
// 过滤掉已删除的
groupList.value = items.filter(item => !item.delete)
total.value = inner.all_count ?? inner.total ?? groupList.value.length
// 如果有当前选中的商品组ID,自动选中
if (props.currentGroupId) {
const current = groupList.value.find(
g => g.id === Number(props.currentGroupId)
)
if (current) {
selectedGroup.value = current
}
}
} else {
ElMessage.error(body?.message || '获取商品组列表失败')
}
} catch (error) {
console.error('获取商品组列表失败:', error)
ElMessage.error('获取商品组列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
searchParams.page = 1
fetchGroupList()
}
// 重置搜索
const handleReset = () => {
keyword.value = ''
searchParams.page = 1
fetchGroupList()
}
// 分页处理
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
fetchGroupList()
}
const handlePageChange = (page) => {
searchParams.page = page
fetchGroupList()
}
// 选择商品组
const handleCurrentChange = (row) => {
selectedGroup.value = row
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedGroup.value && row.id === selectedGroup.value.id) {
return 'selected-row'
}
return ''
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedGroup.value = null
groupList.value = []
keyword.value = ''
searchParams.page = 1
total.value = 0
}
// 确认选择
const handleConfirm = () => {
if (selectedGroup.value) {
emit('confirm', selectedGroup.value)
handleClose()
} else {
ElMessage.warning('请选择一个商品组')
}
}
</script>
<style scoped>
.group-selector {
min-height: 420px;
}
.filter-section {
margin-bottom: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.text-muted {
color: #c0c4cc;
font-size: 12px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 表格样式 */
:deep(.el-table__row) {
cursor: pointer;
}
:deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
:deep(.selected-row) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.selected-row td) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.el-table__body tr.current-row > td) {
background-color: var(--el-color-primary-light-8) !important;
}
</style>
+418
View File
@@ -0,0 +1,418 @@
<template>
<el-dialog
v-model="visible"
title="选择商品"
width="900px"
append-to-body
@close="handleClose"
>
<div class="product-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 选择商品 -->
<el-tab-pane label="选择商品" name="selectProduct">
<div class="product-list-container">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" :model="searchParams" class="search-form">
<el-form-item label="商品分组">
<el-select
v-model="searchParams.good_group_id"
placeholder="全部分组"
clearable
style="width: 150px"
>
<el-option
v-for="item in groupOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="商品标签">
<el-select
v-model="searchParams.tag"
placeholder="全部标签"
:clearable="!defaultTag"
:disabled="!!defaultTag"
style="width: 150px"
>
<el-option
v-for="item in tagOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">
搜索
</el-button>
<el-button @click="handleReset" :icon="Refresh">
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 商品列表表格 -->
<el-table
v-loading="loading"
:data="productList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="id" label="商品ID" width="100" align="center" />
<el-table-column label="商品图片" width="80" align="center">
<template #default="{ row }">
<el-image
:src="row.image || '/logo.svg'"
fit="cover"
style="width: 50px; height: 50px; border-radius: 4px;"
/>
</template>
</el-table-column>
<el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="table" label="所属表" width="120" show-overflow-tooltip />
<el-table-column label="价格" width="100" align="right">
<template #default="{ row }">
<span class="price">¥{{ (row.price / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.inventory > 0 ? 'success' : 'danger'" size="small">
{{ row.inventory }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<el-empty v-if="productList.length === 0 && !loading" description="暂无商品数据" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleConfirm"
:disabled="!selectedProduct"
>
确定选择
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getProductList, getProductGroupList, getProductTagList } from '@/api/admin/product'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 当前已选中的商品ID(用于回显)
currentProductId: {
type: [String, Number],
default: ''
},
// 默认标签过滤(设置后自动锁定该标签)
defaultTag: {
type: String,
default: ''
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm'])
// 响应式数据
const visible = ref(false)
const activeTab = ref('selectProduct')
const loading = ref(false)
const productList = ref([])
const groupOptions = ref([])
const tagOptions = ref([])
const total = ref(0)
const selectedProduct = ref(null)
// 搜索参数
const searchParams = reactive({
good_group_id: '',
tag: '',
page: 1,
count: 10
})
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
activeTab.value = 'selectProduct'
selectedProduct.value = null
searchParams.page = 1
if (props.defaultTag) {
searchParams.tag = props.defaultTag
}
fetchGroupList()
fetchTagList()
fetchProductList()
}
})
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取商品分组列表
const fetchGroupList = async () => {
try {
const res = await getProductGroupList({ page: 1, count: 10 })
if (res.data.code === 200) {
groupOptions.value = res.data.data.data || []
}
} catch (error) {
console.error('获取分组列表失败:', error)
}
}
// 获取商品标签列表
const fetchTagList = async () => {
try {
const res = await getProductTagList()
if (res.data.code === 200) {
tagOptions.value = res.data.data || []
}
} catch (error) {
console.error('获取标签列表失败:', error)
}
}
// 获取商品列表
const fetchProductList = async () => {
loading.value = true
productList.value = []
try {
const params = {
page: searchParams.page,
count: searchParams.count
}
if (searchParams.good_group_id) {
params.good_group_id = searchParams.good_group_id
}
if (searchParams.tag) {
params.tag = searchParams.tag
}
const res = await getProductList(params)
if (res.data.code === 200) {
const allData = res.data.data.data || []
// 过滤掉已删除的数据(兼容 delete 字段不存在的情况)
productList.value = allData.filter(item => item.delete !== true)
total.value = res.data.data.all_count ?? allData.length
// cover 字段直接是图片 URL,无需再请求 file detail
productList.value.forEach(item => {
if (item.cover) item.image = item.cover
})
// 如果有当前选中的商品ID,自动选中
if (props.currentProductId) {
const currentProduct = productList.value.find(
product => product.id === props.currentProductId
)
if (currentProduct) {
selectedProduct.value = currentProduct
}
}
} else {
ElMessage.error(res.data.msg || '获取商品列表失败')
}
} catch (error) {
console.error('获取商品列表失败:', error)
ElMessage.error('获取商品列表失败')
} finally {
loading.value = false
}
}
// 处理标签页切换
const handleTabClick = (tab) => {
if (tab.paneName === 'selectProduct') {
fetchProductList()
}
}
// 搜索
const handleSearch = () => {
searchParams.page = 1
fetchProductList()
}
// 重置搜索
const handleReset = () => {
searchParams.good_group_id = ''
searchParams.tag = props.defaultTag || ''
searchParams.page = 1
fetchProductList()
}
// 分页处理
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
fetchProductList()
}
const handlePageChange = (page) => {
searchParams.page = page
fetchProductList()
}
// 选择商品
const handleCurrentChange = (row) => {
selectedProduct.value = row
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedProduct.value && row.id === selectedProduct.value.id) {
return 'selected-row'
}
return ''
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedProduct.value = null
productList.value = []
searchParams.good_group_id = ''
searchParams.tag = props.defaultTag || ''
searchParams.page = 1
total.value = 0
}
// 确认选择
const handleConfirm = () => {
if (selectedProduct.value) {
emit('confirm', selectedProduct.value)
handleClose()
} else {
ElMessage.warning('请选择一个商品')
}
}
</script>
<style scoped>
.product-selector {
min-height: 450px;
}
.product-list-container {
padding: 10px 0;
}
.filter-section {
margin-bottom: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.price {
color: #f56c6c;
font-weight: 600;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 表格样式 */
:deep(.el-table__row) {
cursor: pointer;
}
:deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
:deep(.selected-row) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.selected-row td) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.el-table__body tr.current-row > td) {
background-color: var(--el-color-primary-light-8) !important;
}
/* 标签页样式 */
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
:deep(.el-tabs__item) {
font-size: 15px;
padding: 0 24px;
}
:deep(.el-tabs__item.is-active) {
font-weight: 600;
}
</style>
@@ -0,0 +1,112 @@
<template>
<el-dialog v-model="visible" title="选择安全组" width="700px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索安全组" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button :icon="Refresh" @click="loadList" circle />
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
:height="340" :row-class-name="rowClassName" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="方向" width="80">
<template #default="{ row }">
<el-tag :type="row.direction === 'in' ? 'primary' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : '出站' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="锁定" width="80">
<template #default="{ row }">
<el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="白名单" width="80">
<template #default="{ row }">
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip />
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
</div>
<template #footer>
<div style="display: flex; justify-content: space-between; width: 100%">
<el-button type="success" @click="handleCreate">创建安全组</el-button>
<div style="display: flex; gap: 8px">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getSecurityGroupList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selectedItem = ref(null)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) { page.value = 1; keyword.value = ''; selectedItem.value = null; loadList() }
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
if (!props.serviceId) return
loading.value = true
try {
const params = { service_id: props.serviceId, page: page.value, page_size: pageSize.value }
if (keyword.value) params.keyword = keyword.value
const res = await getSecurityGroupList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
list.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : [])
total.value = inner.meta?.count ?? inner.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) { emit('confirm', selectedItem.value); visible.value = false }
}
const handleClose = () => { selectedItem.value = null }
const handleCreate = () => {
visible.value = false
emit('create')
}
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+444
View File
@@ -0,0 +1,444 @@
<template>
<el-dialog
v-model="visible"
:title="adminGroup ? '选择管理员组' : '选择用户组'"
width="900px"
append-to-body
@close="handleClose"
>
<div class="user-group-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 选择用户组 -->
<el-tab-pane :label="adminGroup ? '选择管理员组' : '选择用户组'" name="selectGroup">
<div class="group-list-container">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" :model="searchParams" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchParams.key"
:placeholder="adminGroup ? '搜索管理员组名称' : '搜索用户组名称'"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">
搜索
</el-button>
<el-button @click="handleReset" :icon="Refresh">
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 管理员组列表表格 -->
<el-table
v-if="adminGroup"
v-loading="loading"
:data="groupList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="组名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="group-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="auth" label="权限标识" min-width="120" show-overflow-tooltip />
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
{{ row.note || '-' }}
</template>
</el-table-column>
</el-table>
<!-- 用户组列表表格 -->
<el-table
v-else
v-loading="loading"
:data="groupList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="组ID" width="100" align="center">
<template #default="{ row }">
{{ row.group_id || row.GroupId || row.id || row.Id }}
</template>
</el-table-column>
<el-table-column label="组名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="group-name">{{ row.group_name || row.name || row.Name }}</span>
</template>
</el-table-column>
<el-table-column label="升级金额" width="120" align="right">
<template #default="{ row }">
<span v-if="row.floor_price || row.FloorPrice" class="price-text">
¥{{ row.floor_price || row.FloorPrice }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="下一级组ID" width="100" align="center">
<template #default="{ row }">
{{ row.higher_level_id || row.HigherLevelId || '-' }}
</template>
</el-table-column>
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="(row.fixed || row.Fixed) ? 'warning' : 'success'" size="small">
{{ (row.fixed || row.Fixed) ? '固定' : '可升级' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="成员数量" width="100" align="center">
<template #default="{ row }">
<el-tag type="info" size="small" effect="plain">
{{ row.member_count || row.MemberCount || 0 }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<el-empty v-if="groupList.length === 0 && !loading" :description="adminGroup ? '暂无管理员组数据' : '暂无用户组数据'" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleConfirm"
:disabled="!selectedGroup"
>
确定选择
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getUserGroupList } from '@/api/admin/user'
import { getAdminGroupList } from '@/api/admin/group'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 当前已选中的用户组ID(用于回显)
currentGroupId: {
type: [String, Number],
default: ''
},
// 排除的用户组ID(避免选择自己作为下一级)
excludeGroupId: {
type: [String, Number],
default: ''
},
// 是否请求管理员组接口
adminGroup: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm'])
// 响应式数据
const visible = ref(false)
const activeTab = ref('selectGroup')
const loading = ref(false)
const groupList = ref([])
const total = ref(0)
const selectedGroup = ref(null)
// 搜索参数
const searchParams = reactive({
key: '',
page: 1,
count: 10
})
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
// 重置状态
activeTab.value = 'selectGroup'
selectedGroup.value = null
searchParams.page = 1
fetchGroupList()
}
})
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取用户组列表
const fetchGroupList = async () => {
loading.value = true
groupList.value = []
try {
const params = {
page: searchParams.page,
count: searchParams.count
}
const res = props.adminGroup ? await getAdminGroupList(params) : await getUserGroupList(params)
if (res.data.code === 200) {
let responseData = res.data?.data || res.data
if (props.adminGroup) {
groupList.value = responseData?.data || []
total.value = responseData?.total || groupList.value.length
} else if (Array.isArray(responseData)) {
groupList.value = responseData
total.value = responseData.length
} else if (responseData.list) {
groupList.value = responseData.list || []
total.value = responseData.total || responseData.all_count || 0
} else if (responseData.data && Array.isArray(responseData.data)) {
groupList.value = responseData.data
total.value = responseData.all_count || responseData.data.length
} else {
groupList.value = []
total.value = 0
}
// 过滤掉排除的用户组
if (props.excludeGroupId) {
groupList.value = groupList.value.filter(item => {
const itemId = item.group_id || item.GroupId || item.id || item.Id
return itemId !== props.excludeGroupId
})
}
// 关键词过滤
if (searchParams.key) {
const keyword = searchParams.key.toLowerCase()
groupList.value = groupList.value.filter(item => {
const name = (item.group_name || item.name || item.Name || '').toLowerCase()
const id = String(item.group_id || item.GroupId || item.id || item.Id)
return name.includes(keyword) || id.includes(keyword)
})
}
// 如果有当前选中的用户组ID,自动选中
if (props.currentGroupId) {
const currentGroup = groupList.value.find(item => {
const itemId = item.group_id || item.GroupId || item.id || item.Id
return itemId === props.currentGroupId
})
if (currentGroup) {
selectedGroup.value = currentGroup
}
}
} else {
ElMessage.error(res.data.msg || '获取用户组列表失败')
}
} catch (error) {
console.error('获取用户组列表失败:', error)
ElMessage.error('获取用户组列表失败')
} finally {
loading.value = false
}
}
// 处理标签页切换
const handleTabClick = (tab) => {
if (tab.paneName === 'selectGroup') {
fetchGroupList()
}
}
// 搜索
const handleSearch = () => {
searchParams.page = 1
fetchGroupList()
}
// 重置搜索
const handleReset = () => {
searchParams.key = ''
searchParams.page = 1
fetchGroupList()
}
// 分页处理
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
fetchGroupList()
}
const handlePageChange = (page) => {
searchParams.page = page
fetchGroupList()
}
// 选择用户组
const handleCurrentChange = (row) => {
selectedGroup.value = row
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedGroup.value) {
const selectedId = selectedGroup.value.group_id || selectedGroup.value.GroupId || selectedGroup.value.id || selectedGroup.value.Id
const rowId = row.group_id || row.GroupId || row.id || row.Id
if (rowId === selectedId) {
return 'selected-row'
}
}
return ''
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedGroup.value = null
groupList.value = []
searchParams.key = ''
searchParams.page = 1
total.value = 0
}
// 确认选择
const handleConfirm = () => {
if (selectedGroup.value) {
emit('confirm', selectedGroup.value)
handleClose()
} else {
ElMessage.warning('请选择一个用户组')
}
}
</script>
<style scoped>
.user-group-selector {
min-height: 450px;
}
.group-list-container {
padding: 10px 0;
}
.filter-section {
margin-bottom: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.group-name {
font-weight: 500;
color: #2c3e50;
}
.price-text {
color: #f56c6c;
font-weight: 500;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 表格样式 */
:deep(.el-table__row) {
cursor: pointer;
}
:deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
:deep(.selected-row) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.selected-row td) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.el-table__body tr.current-row > td) {
background-color: var(--el-color-primary-light-8) !important;
}
/* 标签页样式 */
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
:deep(.el-tabs__item) {
font-size: 15px;
padding: 0 24px;
}
:deep(.el-tabs__item.is-active) {
font-weight: 600;
}
</style>
+567
View File
@@ -0,0 +1,567 @@
<template>
<el-dialog
v-model="visible"
title="选择用户"
width="900px"
append-to-body
@close="handleClose"
>
<div class="user-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 选择用户 -->
<el-tab-pane label="选择用户" name="selectUser">
<div class="user-list-container">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" :model="searchParams" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchParams.key"
placeholder="搜索用户名或ID"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="用户状态">
<el-select
v-model="searchParams.status"
placeholder="全部状态"
clearable
style="width: 120px"
>
<el-option label="正常" value="1" />
<el-option label="禁用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">
搜索
</el-button>
<el-button @click="handleReset" :icon="Refresh">
重置
</el-button>
<el-button type="success" @click="switchToAdd" :icon="Plus">
添加新用户
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 用户列表表格 -->
<el-table
v-loading="loading"
:data="userList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="user_id" label="用户ID" width="100" align="center" />
<el-table-column prop="user_name" label="用户名" min-width="120">
<template #default="{ row }">
<div class="user-info-cell">
<el-avatar :size="32" :src="row.cover">
<el-icon><User /></el-icon>
</el-avatar>
<span class="user-name">{{ row.user_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" show-overflow-tooltip />
<!-- <el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column> -->
<el-table-column prop="created_at" label="注册时间" width="160" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" />
</div>
</el-tab-pane>
<!-- 添加用户 -->
<el-tab-pane label="添加用户" name="addUser">
<div class="add-user-section">
<el-form
ref="addFormRef"
:model="addForm"
:rules="addFormRules"
label-width="100px"
class="add-user-form"
>
<el-form-item label="用户名" prop="user_name">
<el-input
v-model="addForm.user_name"
placeholder="请输入用户名"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="addForm.email"
placeholder="请输入邮箱地址"
type="email"
/>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="addForm.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="addForm.password"
placeholder="请输入密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="addForm.confirmPassword"
placeholder="请再次输入密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleAddUser" :loading="addLoading">
<el-icon><Plus /></el-icon>
立即创建
</el-button>
<el-button @click="resetAddForm">
<el-icon><Refresh /></el-icon>
重置表单
</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleConfirm"
:disabled="!selectedUser"
v-if="activeTab === 'selectUser'"
>
确定选择
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
import { getUserList, createTask } from '@/api/admin/user'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 当前已选中的用户ID(用于回显)
currentUserId: {
type: [String, Number],
default: ''
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm'])
// 响应式数据
const visible = ref(false)
const activeTab = ref('selectUser')
const loading = ref(false)
const addLoading = ref(false)
const userList = ref([])
const total = ref(0)
const selectedUser = ref(null)
const addFormRef = ref(null)
// 搜索参数
const searchParams = reactive({
key: '',
status: '',
page: 1,
count: 10
})
// 添加用户表单
const addForm = reactive({
user_name: '',
email: '',
phone: '',
password: '',
confirmPassword: ''
})
// 密码确认验证
const validateConfirmPassword = (rule, value, callback) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== addForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
// 添加用户表单验证规则
const addFormRules = {
user_name: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '用户名长度在 2 到 50 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
}
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
// 重置状态
activeTab.value = 'selectUser'
selectedUser.value = null
searchParams.page = 1
fetchUserList()
}
})
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取用户列表
const fetchUserList = async () => {
loading.value = true
userList.value = []
try {
const params = {
page: searchParams.page,
count: searchParams.count,
key: searchParams.key || ''
}
const res = await getUserList(params)
if (res.data.code === 200) {
userList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0
// 如果有当前选中的用户ID,自动选中
if (props.currentUserId) {
const currentUser = userList.value.find(
user => user.user_id === props.currentUserId
)
if (currentUser) {
selectedUser.value = currentUser
}
}
} else {
ElMessage.error(res.data.msg || '获取用户列表失败')
}
} catch (error) {
console.error('获取用户列表失败:', error)
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
}
// 处理标签页切换
const handleTabClick = (tab) => {
if (tab.paneName === 'selectUser') {
fetchUserList()
}
}
// 搜索
const handleSearch = () => {
searchParams.page = 1
fetchUserList()
}
// 重置搜索
const handleReset = () => {
searchParams.key = ''
searchParams.status = ''
searchParams.page = 1
fetchUserList()
}
// 分页处理
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
fetchUserList()
}
const handlePageChange = (page) => {
searchParams.page = page
fetchUserList()
}
// 切换到添加用户标签页
const switchToAdd = () => {
activeTab.value = 'addUser'
}
// 选择用户
const handleCurrentChange = (row) => {
selectedUser.value = row
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedUser.value && row.user_id === selectedUser.value.user_id) {
return 'selected-row'
}
return ''
}
// 添加用户
const handleAddUser = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate(async (valid) => {
if (!valid) return
addLoading.value = true
try {
const formData = new FormData()
formData.append('user_name', addForm.user_name)
formData.append('email', addForm.email)
if (addForm.phone) {
formData.append('phone', addForm.phone)
}
formData.append('password', addForm.password)
const res = await createTask(formData)
if (res.data.code === 200) {
ElMessage.success('用户创建成功')
// 获取新创建的用户信息
const newUser = res.data.data
// 自动选择新创建的用户
if (newUser) {
selectedUser.value = {
user_id: newUser.user_id || newUser.id,
user_name: newUser.user_name || addForm.user_name,
email: newUser.email || addForm.email,
phone: newUser.phone || addForm.phone,
...newUser
}
// 触发确认事件并关闭弹窗
emit('confirm', selectedUser.value)
handleClose()
} else {
// 如果没有返回用户信息,切换到选择标签页并刷新列表
activeTab.value = 'selectUser'
searchParams.page = 1
await fetchUserList()
}
// 重置表单
resetAddForm()
} else {
ElMessage.error(res.data.msg || '用户创建失败')
}
} catch (error) {
console.error('用户创建失败:', error)
ElMessage.error('用户创建失败')
} finally {
addLoading.value = false
}
})
}
// 重置添加表单
const resetAddForm = () => {
addForm.user_name = ''
addForm.email = ''
addForm.phone = ''
addForm.password = ''
addForm.confirmPassword = ''
addFormRef.value?.resetFields()
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedUser.value = null
userList.value = []
searchParams.key = ''
searchParams.status = ''
searchParams.page = 1
total.value = 0
resetAddForm()
}
// 确认选择
const handleConfirm = () => {
if (selectedUser.value) {
emit('confirm', selectedUser.value)
handleClose()
} else {
ElMessage.warning('请选择一个用户')
}
}
</script>
<style scoped>
.user-selector {
min-height: 450px;
}
.user-list-container {
padding: 10px 0;
}
.filter-section {
margin-bottom: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.user-info-cell {
display: flex;
align-items: center;
gap: 10px;
}
.user-name {
font-weight: 500;
color: #303133;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.add-user-section {
padding: 30px 60px;
}
.add-user-form {
max-width: 500px;
margin: 0 auto;
}
.add-user-form :deep(.el-input) {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 表格样式 */
:deep(.el-table__row) {
cursor: pointer;
}
:deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
:deep(.selected-row) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.selected-row td) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.el-table__body tr.current-row > td) {
background-color: var(--el-color-primary-light-8) !important;
}
/* 标签页样式 */
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
:deep(.el-tabs__item) {
font-size: 15px;
padding: 0 24px;
}
:deep(.el-tabs__item.is-active) {
font-weight: 600;
}
</style>
@@ -0,0 +1,134 @@
<template>
<el-dialog v-model="visible" title="选择网络" width="800px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-tag v-if="filterType" :type="filterType === 'bridge' ? 'success' : 'warning'" size="small" effect="dark">{{ filterType === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
<el-tag v-if="filterUnused" type="success" size="small" effect="dark">仅未占用</el-tag>
<el-select v-model="ipVersionFilter" placeholder="IP版本" clearable style="width: 110px" @change="handleSearch">
<el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" />
</el-select>
<el-button :icon="Refresh" @click="loadList" circle />
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
:height="340" :row-class-name="rowClassName" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">
{{ row.type === 'bridge' ? '网桥' : 'NAT' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip />
<el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="bridge_name" label="网桥名称" width="100" />
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
</div>
<template #footer>
<div style="display: flex; justify-content: space-between; width: 100%">
<el-button v-if="props.showCreateButton" type="success" @click="handleCreate">创建网络</el-button>
<div style="display: flex; gap: 8px">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getUserVmNetworkList } from '@/api/admin/userVm'
const props = defineProps({
modelValue: { type: Boolean, default: false },
userGoodsId: { type: Number, default: 0 },
filterType: { type: String, default: '' },
filterUnused: { type: Boolean, default: false },
showCreateButton: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const ipVersionFilter = ref('')
const selectedItem = ref(null)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
page.value = 1
keyword.value = ''
ipVersionFilter.value = ''
selectedItem.value = null
loadList()
}
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
if (!props.userGoodsId) return
loading.value = true
try {
const params = { user_goods_id: props.userGoodsId, page: page.value, count: pageSize.value }
if (keyword.value) params.key = keyword.value
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
const res = await getUserVmNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
let all = inner.data || (Array.isArray(inner) ? inner : [])
if (props.filterType) {
all = all.filter(n => n.type === props.filterType)
}
if (props.filterUnused) {
all = all.filter(n => !n.vm_id)
}
list.value = all
total.value = inner.meta?.count ?? inner.total ?? all.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) {
emit('confirm', selectedItem.value)
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
const handleCreate = () => {
visible.value = false
emit('create')
}
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
@@ -0,0 +1,143 @@
<template>
<el-dialog v-model="visible" title="选择安全组" width="640px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-input v-model="keyword" placeholder="搜索安全组名称" clearable style="width:200px"
@keyup.enter="loadList" @clear="loadList">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="showCreate = true">新增安全组</el-button>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row
@current-change="row => selected = row" :height="280" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="方向" width="80">
<template #default="{ row }">
<el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">
{{ row.direction === 'in' ? '入站' : row.direction === 'out' ? '出站' : (row.direction || '-') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="白名单" width="80">
<template #default="{ row }">
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="共享" width="70">
<template #default="{ row }">
<el-tag :type="row.shared ? 'success' : 'info'" size="small">{{ row.shared ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无安全组" />
<div class="selector-footer-bar">
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
<!-- 新增安全组弹窗 -->
<el-dialog v-model="showCreate" title="新增安全组" width="440px" append-to-body destroy-on-close>
<el-form :model="createForm" label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="createForm.name" placeholder="安全组名称" />
</el-form-item>
<el-form-item label="方向">
<el-select v-model="createForm.direction" style="width:100%">
<el-option label="入站 (in)" value="in" />
<el-option label="出站 (out)" value="out" />
</el-select>
</el-form-item>
<el-form-item label="锁定">
<el-switch v-model="createForm.lock" />
</el-form-item>
<el-form-item label="白名单">
<el-switch v-model="createForm.drop_all" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getUserVmPostGroupUserList, createUserVmPostGroup } from '@/api/admin/userVm'
const props = defineProps({
modelValue: { type: Boolean, default: false },
userGoodsId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selected = ref(null)
const showCreate = ref(false)
const createLoading = ref(false)
const createForm = reactive({ name: '', direction: 'in', lock: false, drop_all: false })
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
if (!props.userGoodsId) return
loading.value = true
try {
const params = { user_goods_id: props.userGoodsId, page: page.value, page_size: pageSize.value }
if (keyword.value) params.keyword = keyword.value
const res = await getUserVmPostGroupUserList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.groups || d.data || (Array.isArray(d) ? d : [])
total.value = d.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const submitCreate = async () => {
if (!createForm.name) { ElMessage.warning('请输入名称'); return }
createLoading.value = true
try {
const res = await createUserVmPostGroup({
user_goods_id: props.userGoodsId,
name: createForm.name,
direction: createForm.direction,
lock: createForm.lock,
drop_all: createForm.drop_all
})
if (res?.data?.code === 200) {
ElMessage.success('创建成功')
showCreate.value = false
Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false })
loadList()
} else ElMessage.error(res?.data?.message || '创建失败')
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
}
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
</style>
@@ -0,0 +1,144 @@
<template>
<el-dialog v-model="visible" title="选择数据卷进行挂载" width="680px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-input v-model="keyword" placeholder="搜索数据卷名称" clearable style="width:200px"
@keyup.enter="loadList" @clear="loadList">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建数据卷</el-button>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row
@current-change="row => selected = row" :height="280" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="大小" width="80">
<template #default="{ row }">{{ row.size }} GB</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'ready' ? 'success' : 'info'" size="small">{{ row.status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="挂载" width="80">
<template #default="{ row }">
<el-tag :type="row.is_mount ? 'success' : 'info'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无数据卷" />
<div class="selector-footer-bar">
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected || !!selected.is_mount" @click="handleConfirm">
{{ selected?.is_mount ? '已挂载' : '确定挂载' }}
</el-button>
</template>
</el-dialog>
<!-- 新建数据卷弹窗 -->
<el-dialog v-model="showCreate" title="新建数据卷" width="440px" append-to-body destroy-on-close>
<el-form :model="createForm" label-width="100px">
<el-form-item label="名称" required><el-input v-model="createForm.name" placeholder="数据卷名称" /></el-form-item>
<el-form-item label="大小">
<div class="unit-input-row">
<el-input-number v-model="createForm.size" :min="1" controls-position="right" style="flex:1" />
<el-select v-model="createForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</el-form-item>
<el-form-item label="目标设备名"><el-input v-model="createForm.target_device" placeholder="不填自动生成" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getUserVmVolumeList, createUserVmVolume } from '@/api/admin/userVm'
const props = defineProps({
modelValue: { type: Boolean, default: false },
userGoodsId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selected = ref(null)
const showCreate = ref(false)
const createLoading = ref(false)
const createForm = reactive({ name: '', size: 10, _sizeUnit: 'GB', target_device: '' })
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
if (!props.userGoodsId) return
loading.value = true
try {
const res = await getUserVmVolumeList({ user_goods_id: props.userGoodsId, page: page.value, count: pageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || (Array.isArray(d) ? d : [])
total.value = d.all_count ?? d.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const submitCreate = async () => {
if (!createForm.name) { ElMessage.warning('请输入名称'); return }
createLoading.value = true
try {
const sizeGb = createForm._sizeUnit === 'TB' ? createForm.size * 1024 : createForm.size
const res = await createUserVmVolume({
user_goods_id: props.userGoodsId,
name: createForm.name,
size: sizeGb,
target_device: createForm.target_device
})
if (res?.data?.code === 200) {
ElMessage.success('创建成功')
showCreate.value = false
Object.assign(createForm, { name: '', size: 10, _sizeUnit: 'GB', target_device: '' })
loadList()
} else ElMessage.error(res?.data?.message || '创建失败')
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
}
const handleClose = () => { visible.value = false }
const handleConfirm = () => {
if (selected.value && !selected.value.is_mount) {
emit('confirm', selected.value)
handleClose()
}
}
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-select { width: 90px; flex-shrink: 0; }
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<el-dialog v-model="visible" title="选择虚拟机" width="700px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-select v-model="hostIdFilter" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="loadList">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="配置" min-width="120">
<template #default="{ row }">
{{ row.vcpu }} / {{ formatMem(row.memory) }}
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { getRemoteHostList, getVmList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
hostId: { type: Number, default: 0 },
currentId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const selectedItem = ref(null)
const hostIdFilter = ref('')
const hostOptions = ref([])
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) { loadHostOptions(); if (props.hostId) { hostIdFilter.value = props.hostId; loadList() } }
})
watch(visible, (val) => emit('update:modelValue', val))
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: props.serviceId, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
if (!hostIdFilter.value && hostOptions.value.length) hostIdFilter.value = hostOptions.value[0].id
if (hostIdFilter.value) loadList()
}
} catch { /* ignore */ }
}
const loadList = async () => {
if (!hostIdFilter.value) return
loading.value = true
try {
const res = await getVmList({ service_id: props.serviceId, host_id: hostIdFilter.value, page: 1, count: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
list.value = inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { /* ignore */ }
finally { loading.value = false }
}
const formatMem = (kb) => {
if (!kb) return '-'
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
return kb + ' KB'
}
const statusType = (s) => ({ running: 'success', ready: 'success', stopped: 'danger', error: 'danger', paused: 'warning' }[s] || 'info')
const statusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停' }[s] || s || '-')
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) {
emit('confirm', selectedItem.value)
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
@@ -0,0 +1,134 @@
<template>
<el-dialog v-model="visible" title="选择数据卷" width="750px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索数据卷" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="handleSearch">
<el-option label="就绪" value="ready" />
<el-option label="等待中" value="pending" />
</el-select>
<el-button :icon="Refresh" @click="loadList" circle />
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
:height="340" :row-class-name="rowClassName" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="大小" width="90">
<template #default="{ row }">{{ row.size ? row.size + ' GB' : '-' }}</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="挂载" width="80">
<template #default="{ row }">
<el-tag :type="row.is_mount ? 'warning' : 'success'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="180" show-overflow-tooltip />
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
</div>
<template #footer>
<div style="display: flex; justify-content: space-between; width: 100%">
<el-button type="success" @click="handleCreate">创建数据卷</el-button>
<div style="display: flex; gap: 8px">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getVolumeList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
hostId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const statusFilter = ref('')
const selectedItem = ref(null)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
page.value = 1
keyword.value = ''
statusFilter.value = ''
selectedItem.value = null
loadList()
}
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
if (!props.serviceId || !props.hostId) return
loading.value = true
try {
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, count: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (statusFilter.value) params.status = statusFilter.value
const res = await getVolumeList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
list.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : [])
total.value = inner.meta?.count ?? inner.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const statusType = (s) => ({ ready: 'success', pending: 'warning', error: 'danger' }[s] || 'info')
const statusLabel = (s) => ({ ready: '就绪', pending: '等待中', creating: '创建中', error: '错误' }[s] || s || '-')
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) {
emit('confirm', selectedItem.value)
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
const handleCreate = () => {
visible.value = false
emit('create')
}
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+389
View File
@@ -0,0 +1,389 @@
<template>
<el-dialog
v-model="visible"
title="选择代金券"
width="900px"
append-to-body
@close="handleClose"
>
<div class="voucher-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 选择代金券 -->
<el-tab-pane label="选择代金券" name="selectVoucher">
<div class="voucher-list-container">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" :model="searchParams" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchParams.key"
placeholder="搜索代金券名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">
搜索
</el-button>
<el-button @click="handleReset" :icon="Refresh">
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 代金券列表表格 -->
<el-table
v-loading="loading"
:data="voucherList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="id" label="代金券ID" width="100" align="center" />
<el-table-column prop="name" label="代金券名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="code" label="代金券码" width="150" show-overflow-tooltip>
<template #default="{ row }">
<el-tag type="warning" effect="plain">{{ row.code }}</el-tag>
</template>
</el-table-column>
<el-table-column label="优惠类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.percentage > 0 ? 'warning' : 'primary'" size="small">
{{ row.percentage > 0 ? '折扣' : '固定金额' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="面值" width="100" align="right">
<template #default="{ row }">
<span v-if="row.percentage > 0" class="voucher-value">{{ row.percentage }}%</span>
<span v-else class="voucher-value">¥{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="最低消费" width="100" align="right">
<template #default="{ row }">
<span v-if="row.minAmount">¥{{ (row.minAmount / 100).toFixed(2) }}</span>
<span v-else>无限制</span>
</template>
</el-table-column>
<el-table-column label="使用次数" width="100" align="center">
<template #default="{ row }">
{{ row.userTimes || 0 }} / {{ row.maxTimes || '∞' }}
</template>
</el-table-column>
<el-table-column label="有效期" width="160" align="center">
<template #default="{ row }">
<span :class="{ 'expired': isExpired(row.endTime) }">
{{ formatDate(row.endTime) }}
</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<el-empty v-if="voucherList.length === 0 && !loading" description="暂无代金券数据" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleConfirm"
:disabled="!selectedVoucher"
>
确定选择
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getDiscountCodeList } from '@/api/admin/discount'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 当前已选中的代金券ID(用于回显)
currentVoucherId: {
type: [String, Number],
default: ''
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm'])
// 响应式数据
const visible = ref(false)
const activeTab = ref('selectVoucher')
const loading = ref(false)
const voucherList = ref([])
const total = ref(0)
const selectedVoucher = ref(null)
// 搜索参数
const searchParams = reactive({
key: '',
page: 1,
count: 10
})
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
// 重置状态
activeTab.value = 'selectVoucher'
selectedVoucher.value = null
searchParams.page = 1
fetchVoucherList()
}
})
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取代金券列表
const fetchVoucherList = async () => {
loading.value = true
voucherList.value = []
try {
const params = {
page: searchParams.page,
count: searchParams.count,
discount_type: 'coupon' // 代金券类型
}
if (searchParams.key) {
params.key = searchParams.key
}
const res = await getDiscountCodeList(params)
if (res.data.code === 200) {
voucherList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0
// 如果有当前选中的代金券ID,自动选中
if (props.currentVoucherId) {
const currentVoucher = voucherList.value.find(
voucher => voucher.id === props.currentVoucherId
)
if (currentVoucher) {
selectedVoucher.value = currentVoucher
}
}
} else {
ElMessage.error(res.data.msg || '获取代金券列表失败')
}
} catch (error) {
console.error('获取代金券列表失败:', error)
ElMessage.error('获取代金券列表失败')
} finally {
loading.value = false
}
}
// 处理标签页切换
const handleTabClick = (tab) => {
if (tab.paneName === 'selectVoucher') {
fetchVoucherList()
}
}
// 搜索
const handleSearch = () => {
searchParams.page = 1
fetchVoucherList()
}
// 重置搜索
const handleReset = () => {
searchParams.key = ''
searchParams.page = 1
fetchVoucherList()
}
// 分页处理
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
fetchVoucherList()
}
const handlePageChange = (page) => {
searchParams.page = page
fetchVoucherList()
}
// 选择代金券
const handleCurrentChange = (row) => {
selectedVoucher.value = row
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedVoucher.value && row.id === selectedVoucher.value.id) {
return 'selected-row'
}
return ''
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedVoucher.value = null
voucherList.value = []
searchParams.key = ''
searchParams.page = 1
total.value = 0
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 判断是否过期
const isExpired = (endTime) => {
if (!endTime) return false
return new Date(endTime) < new Date()
}
// 确认选择
const handleConfirm = () => {
if (selectedVoucher.value) {
emit('confirm', selectedVoucher.value)
handleClose()
} else {
ElMessage.warning('请选择一个代金券')
}
}
</script>
<style scoped>
.voucher-selector {
min-height: 450px;
}
.voucher-list-container {
padding: 10px 0;
}
.filter-section {
margin-bottom: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.voucher-value {
color: #f56c6c;
font-weight: 600;
font-size: 15px;
}
.expired {
color: #909399;
text-decoration: line-through;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 表格样式 */
:deep(.el-table__row) {
cursor: pointer;
}
:deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
:deep(.selected-row) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.selected-row td) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.el-table__body tr.current-row > td) {
background-color: var(--el-color-primary-light-8) !important;
}
/* 标签页样式 */
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
:deep(.el-tabs__item) {
font-size: 15px;
padding: 0 24px;
}
:deep(.el-tabs__item.is-active) {
font-weight: 600;
}
</style>
@@ -0,0 +1,51 @@
<template>
<div>
<div class="selector-field-row">
<el-input
:model-value="displayText"
readonly
:placeholder="placeholder"
style="flex:1"
/>
<el-button
type="primary"
:disabled="disabled"
style="margin-left:8px"
@click="$emit('select')"
>{{ buttonText }}</el-button>
<el-button
v-if="clearable && modelValue"
style="margin-left:4px"
@click="$emit('update:modelValue', null); $emit('clear')"
>清除</el-button>
</div>
<div v-if="hint" :style="{ fontSize: '12px', color: hintType === 'disabled' ? '#c0c4cc' : '#909399', marginTop: '4px' }">
{{ hint }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: [Number, String, Object], default: null },
displayText: { type: String, default: '' },
placeholder: { type: String, default: '请选择' },
buttonText: { type: String, default: '选择' },
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
hint: { type: String, default: '' },
hintType: { type: String, default: 'normal' }
})
defineEmits(['select', 'clear', 'update:modelValue'])
</script>
<style scoped>
.selector-field-row {
display: flex;
align-items: center;
width: 100%;
}
</style>
+394 -43
View File
@@ -1,9 +1,13 @@
<template> <template>
<div class="admin-layout"> <div class="admin-layout" :class="{ 'sidebar-collapsed': isCollapsed, 'mobile-open': isMobileMenuOpen }">
<!-- 移动端遮罩层 -->
<div class="mobile-overlay" v-if="isMobileMenuOpen" @click="closeMobileMenu"></div>
<!-- 侧边栏 --> <!-- 侧边栏 -->
<div class="sidebar"> <div class="sidebar" :class="{ 'collapsed': isCollapsed }">
<div class="logo-container"> <div class="logo-container">
<img src="@/assets/logo.png" alt="Logo" class="logo-img" /> <img src="@/assets/logo.png" alt="Logo" class="logo-img" v-show="!isCollapsed" />
<img src="@/assets/logo.svg" alt="Logo" class="logo-img-mini" v-show="isCollapsed" />
</div> </div>
<el-scrollbar class="sidebar-scrollbar"> <el-scrollbar class="sidebar-scrollbar">
<el-menu <el-menu
@@ -13,11 +17,20 @@
text-color="#34495e" text-color="#34495e"
active-text-color="#2c3e50" active-text-color="#2c3e50"
:unique-opened="true" :unique-opened="true"
:collapse="isCollapsed"
:collapse-transition="false"
router router
> >
<sidebar-menu-item v-for="menu in menus" :key="menu.path" :menu="menu" /> <sidebar-menu-item v-for="menu in menus" :key="menu.path" :menu="menu" />
</el-menu> </el-menu>
</el-scrollbar> </el-scrollbar>
<!-- 收缩按钮 -->
<div class="collapse-btn" @click="toggleCollapse">
<el-icon :size="18">
<Fold v-if="!isCollapsed" />
<Expand v-else />
</el-icon>
</div>
</div> </div>
<!-- 主区域 --> <!-- 主区域 -->
@@ -25,10 +38,14 @@
<!-- 顶部导航 --> <!-- 顶部导航 -->
<div class="navbar"> <div class="navbar">
<div class="navbar-left"> <div class="navbar-left">
<!-- 移动端菜单按钮 -->
<el-button type="text" class="mobile-menu-btn" @click="toggleMobileMenu">
<el-icon :size="22"><Menu /></el-icon>
</el-button>
<breadcrumb /> <breadcrumb />
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
<div class="navbar-item"> <div class="navbar-item hidden-mobile">
<el-tooltip content="全屏" placement="bottom"> <el-tooltip content="全屏" placement="bottom">
<el-button type="text" class="header-btn" @click="toggleFullScreen"> <el-button type="text" class="header-btn" @click="toggleFullScreen">
<el-icon :size="18"><full-screen /></el-icon> <el-icon :size="18"><full-screen /></el-icon>
@@ -39,9 +56,9 @@
<div class="navbar-item"> <div class="navbar-item">
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<div class="avatar-container"> <div class="avatar-container">
<el-avatar :size="32" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" /> <el-avatar :size="32" :src="userStore.getUserAvatar() || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" />
<span class="username">{{ userStore.userInfo.user_name }}</span> <span class="username hidden-mobile">{{ userStore.userInfo.user_name }}</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon> <el-icon class="el-icon--right hidden-mobile"><arrow-down /></el-icon>
</div> </div>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
@@ -81,7 +98,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import SidebarMenuItem from './SidebarMenuItem.vue' import SidebarMenuItem from './SidebarMenuItem.vue'
import Breadcrumb from './Breadcrumb.vue' import Breadcrumb from './Breadcrumb.vue'
@@ -92,7 +109,10 @@ import {
ArrowDown, ArrowDown,
User, User,
Key, Key,
SwitchButton SwitchButton,
Fold,
Expand,
Menu
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
@@ -105,11 +125,46 @@ const router = useRouter()
// 侧边栏菜单数据 // 侧边栏菜单数据
const menus = ref(menuConfig) const menus = ref(menuConfig)
// 侧边栏收缩状态
const isCollapsed = ref(false)
// 移动端菜单状态
const isMobileMenuOpen = ref(false)
// 检测是否是移动端
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768
// 移动端默认收起侧边栏
if (isMobile.value) {
isCollapsed.value = false
isMobileMenuOpen.value = false
}
}
// 获取当前激活的菜单项 // 获取当前激活的菜单项
const activeMenu = computed(() => { const activeMenu = computed(() => {
return route.path return route.path
}) })
// 切换侧边栏收缩
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// 保存状态到localStorage
localStorage.setItem('sidebarCollapsed', isCollapsed.value)
}
// 切换移动端菜单
const toggleMobileMenu = () => {
isMobileMenuOpen.value = !isMobileMenuOpen.value
}
// 关闭移动端菜单
const closeMobileMenu = () => {
isMobileMenuOpen.value = false
}
// 切换全屏 // 切换全屏
const toggleFullScreen = () => { const toggleFullScreen = () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
@@ -129,9 +184,35 @@ const handleLogout = () => {
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('tokenExpire')
localStorage.removeItem('userInfo')
userStore.clearUserInfo()
router.push('/login') router.push('/login')
}).catch(() => {}) }).catch(() => {})
} }
// 监听路由变化,移动端自动关闭菜单
router.afterEach(() => {
if (isMobile.value) {
closeMobileMenu()
}
})
onMounted(() => {
// 恢复侧边栏状态
const savedState = localStorage.getItem('sidebarCollapsed')
if (savedState !== null) {
isCollapsed.value = savedState === 'true'
}
// 检测设备类型
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script> </script>
<style scoped> <style scoped>
@@ -141,6 +222,18 @@ const handleLogout = () => {
width: 100%; width: 100%;
} }
/* 移动端遮罩层 */
.mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 998;
}
/* 侧边栏样式 */ /* 侧边栏样式 */
.sidebar { .sidebar {
width: 260px; width: 260px;
@@ -148,7 +241,15 @@ const handleLogout = () => {
background-color: #ffffff; background-color: #ffffff;
border-right: 1px solid #e1e8ed; border-right: 1px solid #e1e8ed;
overflow: hidden; overflow: hidden;
z-index: 20; z-index: 999;
transition: width 0.3s ease;
display: flex;
flex-direction: column;
position: relative;
}
.sidebar.collapsed {
width: 64px;
} }
.logo-container { .logo-container {
@@ -159,6 +260,7 @@ const handleLogout = () => {
padding: 0 20px; padding: 0 20px;
background-color: #ffffff; background-color: #ffffff;
border-bottom: 1px solid #e1e8ed; border-bottom: 1px solid #e1e8ed;
flex-shrink: 0;
} }
.logo-img { .logo-img {
@@ -167,8 +269,15 @@ const handleLogout = () => {
object-fit: contain; object-fit: contain;
} }
.logo-img-mini {
height: 32px;
width: 32px;
object-fit: contain;
}
.sidebar-scrollbar { .sidebar-scrollbar {
height: calc(100vh - 70px); flex: 1;
overflow: hidden;
} }
.sidebar-menu { .sidebar-menu {
@@ -178,6 +287,32 @@ const handleLogout = () => {
padding: 0; padding: 0;
} }
/* 收缩按钮 */
.collapse-btn {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e1e8ed;
cursor: pointer;
color: #7f8c8d;
transition: all 0.2s ease;
flex-shrink: 0;
}
.collapse-btn:hover {
color: #2c3e50;
background-color: #f8f9fa;
}
/* 移动端菜单按钮 */
.mobile-menu-btn {
display: none;
margin-right: 12px;
padding: 8px;
color: #34495e;
}
/* 主容器样式 */ /* 主容器样式 */
.main-container { .main-container {
flex: 1; flex: 1;
@@ -185,6 +320,7 @@ const handleLogout = () => {
flex-direction: column; flex-direction: column;
background-color: #f0f2f5; background-color: #f0f2f5;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
/* 顶部导航栏样式 */ /* 顶部导航栏样式 */
@@ -197,18 +333,21 @@ const handleLogout = () => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
z-index: 10; z-index: 10;
flex-shrink: 0;
} }
.navbar-left { .navbar-left {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1; flex: 1;
min-width: 0;
} }
.navbar-right { .navbar-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-shrink: 0;
} }
.navbar-item { .navbar-item {
@@ -286,6 +425,63 @@ const handleLogout = () => {
opacity: 0; opacity: 0;
} }
/* 移动端隐藏元素 */
.hidden-mobile {
display: flex;
}
/* 移动端响应式 */
@media (max-width: 768px) {
.mobile-overlay {
display: block;
}
.sidebar {
position: fixed;
left: -260px;
top: 0;
bottom: 0;
transition: left 0.3s ease;
}
.sidebar.collapsed {
width: 260px;
left: -260px;
}
.admin-layout.mobile-open .sidebar {
left: 0;
}
.admin-layout.mobile-open .sidebar.collapsed {
left: 0;
}
.collapse-btn {
display: none;
}
.mobile-menu-btn {
display: flex;
}
.hidden-mobile {
display: none !important;
}
.navbar {
padding: 0 12px;
}
.content-container {
padding: 12px;
}
.main-container {
width: 100%;
}
}
:deep(.el-dropdown-menu) { :deep(.el-dropdown-menu) {
border-radius: 0; border-radius: 0;
border: 1px solid #e1e8ed; border: 1px solid #e1e8ed;
@@ -346,77 +542,232 @@ const handleLogout = () => {
/* Element Plus 菜单项样式优化 */ /* Element Plus 菜单项样式优化 */
:deep(.el-menu) { :deep(.el-menu) {
border-right: none; border-right: none;
padding: 8px 0;
} }
/* 一级菜单标题(有子菜单) */
:deep(.el-sub-menu__title) { :deep(.el-sub-menu__title) {
height: 50px; height: 48px;
line-height: 50px; line-height: 48px;
margin: 0; margin: 2px 8px;
padding: 0 20px; padding: 0 16px !important;
transition: background-color 0.2s ease; border-radius: 6px;
transition: all 0.2s ease;
color: #34495e !important; color: #34495e !important;
font-weight: 500;
font-size: 14px;
} }
:deep(.el-sub-menu__title:hover) { :deep(.el-sub-menu__title:hover) {
background-color: #f8f9fa !important; background-color: #f5f7fa !important;
color: #2c3e50 !important; color: #2c3e50 !important;
} }
:deep(.el-menu-item) { /* 一级菜单项(无子菜单) */
height: 50px; :deep(.sidebar-menu > .el-menu-item) {
line-height: 50px; height: 48px;
margin: 0; line-height: 48px;
padding: 0 20px; margin: 2px 8px;
transition: background-color 0.2s ease; padding: 0 16px !important;
border-radius: 6px;
transition: all 0.2s ease;
color: #34495e !important; color: #34495e !important;
font-weight: 500;
font-size: 14px;
} }
:deep(.el-menu-item:hover) { :deep(.sidebar-menu > .el-menu-item:hover) {
background-color: #f8f9fa !important; background-color: #f5f7fa !important;
color: #2c3e50 !important; color: #2c3e50 !important;
} }
:deep(.el-menu-item.is-active) { :deep(.sidebar-menu > .el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.1) !important; background-color: rgba(44, 62, 80, 0.08) !important;
color: #2c3e50 !important; color: #2c3e50 !important;
font-weight: 600; font-weight: 600;
position: relative;
} }
:deep(.el-menu-item.is-active::before) { :deep(.sidebar-menu > .el-menu-item.is-active::before) {
content: ''; content: '';
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 50%;
transform: translateY(-50%);
width: 3px; width: 3px;
height: 100%; height: 24px;
background-color: #2c3e50;
border-radius: 0 2px 2px 0;
}
/* 展开的一级菜单标题 */
:deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
color: #2c3e50 !important;
font-weight: 600;
background-color: #f5f7fa !important;
}
/* 二级菜单容器 */
:deep(.sidebar-menu > .el-sub-menu > .el-menu) {
background-color: transparent !important;
padding: 4px 0 8px 0;
}
/* 二级菜单项 */
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item) {
height: 40px;
line-height: 40px;
margin: 2px 8px 2px 16px;
padding: 0 16px 0 28px !important;
border-radius: 6px;
font-size: 13px;
color: #606266 !important;
background-color: transparent !important;
position: relative;
font-weight: 400;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item::before) {
content: '';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 4px;
background-color: #c0c4cc;
border-radius: 50%;
transition: all 0.2s ease;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item:hover) {
background-color: #f5f7fa !important;
color: #2c3e50 !important;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item:hover::before) {
background-color: #7f8c8d;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.08) !important;
color: #2c3e50 !important;
font-weight: 500;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active::before) {
width: 6px;
height: 6px;
background-color: #2c3e50; background-color: #2c3e50;
} }
:deep(.el-sub-menu.is-active > .el-sub-menu__title) { /* 二级菜单中的子菜单标题(三级菜单父级) */
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title) {
height: 40px;
line-height: 40px;
margin: 2px 8px 2px 16px;
padding: 0 16px 0 28px !important;
border-radius: 6px;
font-size: 13px;
color: #606266 !important;
font-weight: 400;
position: relative;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title::before) {
content: '';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 4px;
background-color: #c0c4cc;
border-radius: 50%;
transition: all 0.2s ease;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title:hover) {
background-color: #f5f7fa !important;
color: #2c3e50 !important; color: #2c3e50 !important;
background-color: #f8f9fa !important;
} }
:deep(.el-sub-menu .el-menu) { :deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title:hover::before) {
background-color: #fafbfc !important; background-color: #7f8c8d;
margin: 0;
padding: 0;
} }
:deep(.el-sub-menu .el-menu-item) { :deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu.is-opened > .el-sub-menu__title) {
margin: 0; color: #2c3e50 !important;
padding-left: 48px !important; font-weight: 500;
background-color: #f5f7fa !important;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu.is-opened > .el-sub-menu__title::before) {
width: 6px;
height: 6px;
background-color: #2c3e50;
}
/* 三级菜单容器 */
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu) {
background-color: transparent !important; background-color: transparent !important;
padding: 4px 0;
} }
:deep(.el-sub-menu .el-menu-item.is-active) { /* 三级菜单项 */
background-color: rgba(44, 62, 80, 0.12) !important; :deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item) {
height: 36px;
line-height: 36px;
margin: 2px 8px 2px 28px;
padding: 0 16px 0 24px !important;
border-radius: 6px;
font-size: 13px;
color: #909399 !important;
background-color: transparent !important;
position: relative;
font-weight: 400;
} }
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item::before) {
content: '-';
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #c0c4cc;
font-size: 12px;
transition: all 0.2s ease;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item:hover) {
background-color: #f5f7fa !important;
color: #606266 !important;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item:hover::before) {
color: #7f8c8d;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.08) !important;
color: #2c3e50 !important;
font-weight: 500;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active::before) {
content: '•';
color: #2c3e50;
font-size: 14px;
}
/* 子菜单箭头图标 */
:deep(.el-sub-menu__icon-arrow) { :deep(.el-sub-menu__icon-arrow) {
color: #909399 !important;
transition: all 0.2s ease;
font-size: 12px;
}
:deep(.el-sub-menu:hover > .el-sub-menu__title .el-sub-menu__icon-arrow) {
color: #7f8c8d !important; color: #7f8c8d !important;
transition: transform 0.2s ease;
} }
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) { :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
+31 -28
View File
@@ -1,22 +1,25 @@
<template> <template>
<el-sub-menu v-if="hasChildren" :index="menu.path"> <el-sub-menu v-if="hasChildren" :index="menu.path">
<template #title> <template #title>
<el-icon v-if="menu.icon || menu.meta?.icon"> <el-icon v-if="menu.icon || menu.meta?.icon" class="menu-icon">
<component :is="menu.icon || menu.meta?.icon" /> <component :is="menu.icon || menu.meta?.icon" />
</el-icon> </el-icon>
<span>{{ menu.title || menu.meta?.title }}</span> <span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
</template> </template>
<sidebar-menu-item <sidebar-menu-item
v-for="child in menu.children" v-for="child in menu.children"
:key="child.path" :key="child.path"
:menu="child" :menu="child"
:level="level + 1"
/> />
</el-sub-menu> </el-sub-menu>
<el-menu-item v-else :index="menu.path"> <el-menu-item v-else :index="menu.path">
<el-icon v-if="menu.icon || menu.meta?.icon"> <el-icon v-if="menu.icon || menu.meta?.icon" class="menu-icon">
<component :is="menu.icon || menu.meta?.icon" /> <component :is="menu.icon || menu.meta?.icon" />
</el-icon> </el-icon>
<template #title>{{ menu.title || menu.meta?.title }}</template> <template #title>
<span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
</template>
</el-menu-item> </el-menu-item>
</template> </template>
@@ -29,6 +32,10 @@ const props = defineProps({
menu: { menu: {
type: Object, type: Object,
required: true required: true
},
level: {
type: Number,
default: 1
} }
}) })
@@ -39,49 +46,45 @@ const hasChildren = computed(() => {
</script> </script>
<style scoped> <style scoped>
.el-icon { /* 菜单图标样式 */
margin-right: 12px; .menu-icon {
margin-right: 10px;
width: 20px; width: 20px;
height: 20px; height: 20px;
text-align: center; text-align: center;
color: #7f8c8d; color: #7f8c8d;
transition: color 0.2s ease; transition: color 0.2s ease;
font-size: 18px; font-size: 18px;
flex-shrink: 0;
} }
.el-menu-item .el-icon, :deep(.el-sub-menu__title .el-icon) { /* 菜单标题 */
.menu-title {
font-size: inherit;
letter-spacing: 0.2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 图标交互状态 */
.el-menu-item .menu-icon,
:deep(.el-sub-menu__title .menu-icon) {
color: #7f8c8d !important; color: #7f8c8d !important;
transition: color 0.2s ease; transition: color 0.2s ease;
} }
.el-menu-item:hover .el-icon, .el-menu-item:hover .menu-icon,
:deep(.el-sub-menu__title:hover .el-icon) { :deep(.el-sub-menu__title:hover .menu-icon) {
color: #34495e !important; color: #34495e !important;
} }
/* 激活菜单项图标 */ /* 激活菜单项图标 */
.el-menu-item.is-active .el-icon { .el-menu-item.is-active .menu-icon {
color: #2c3e50 !important; color: #2c3e50 !important;
} }
:deep(.el-sub-menu.is-active > .el-sub-menu__title .el-icon) { :deep(.el-sub-menu.is-opened > .el-sub-menu__title .menu-icon) {
color: #2c3e50 !important; color: #2c3e50 !important;
} }
/* 菜单文字样式 */
.el-menu-item span, :deep(.el-sub-menu__title span) {
font-size: 14px;
letter-spacing: 0.2px;
}
/* 子菜单项样式优化 */
:deep(.el-sub-menu .el-menu-item) {
font-size: 13px;
padding-left: 48px !important;
}
:deep(.el-sub-menu .el-menu-item .el-icon) {
font-size: 16px;
margin-right: 10px;
}
</style> </style>
+144 -36
View File
@@ -1,6 +1,9 @@
<template> <template>
<div class="tags-view-container"> <div class="tags-view-container"
<div class="tags-view-wrapper"> @mouseenter="hovered = true" @mouseleave="hovered = false">
<div class="tags-view-wrapper" ref="scrollWrapperRef"
@wheel.prevent="handleWheel"
@scroll="onScroll">
<div class="tags-view-scroll"> <div class="tags-view-scroll">
<router-link <router-link
v-for="tag in visitedViews" v-for="tag in visitedViews"
@@ -24,6 +27,10 @@
</div> </div>
</div> </div>
<div class="scroll-track" :class="{ visible: hovered && hasOverflow }">
<div class="scroll-thumb" :style="thumbStyle" @mousedown="onThumbDown"></div>
</div>
<!-- 右键菜单 --> <!-- 右键菜单 -->
<ul v-show="visible" :style="{left: left+'px', top: top+'px'}" class="contextmenu"> <ul v-show="visible" :style="{left: left+'px', top: top+'px'}" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)"> <li @click="refreshSelectedTag(selectedTag)">
@@ -65,28 +72,20 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const tagsViewStore = useTagsViewStore() const tagsViewStore = useTagsViewStore()
// 访问过的标签 (从 store 获取)
const visitedViews = computed(() => tagsViewStore.visitedViews) const visitedViews = computed(() => tagsViewStore.visitedViews)
const affixTags = computed(() => tagsViewStore.affixTags) const affixTags = computed(() => tagsViewStore.affixTags)
// 右键菜单
const visible = ref(false) const visible = ref(false)
const top = ref(0) const top = ref(0)
const left = ref(0) const left = ref(0)
const selectedTag = ref({}) const selectedTag = ref({})
// 初始化标签
const initTags = () => { const initTags = () => {
// 如果当前路由不在访问过的标签中,添加它
if (route.name) { if (route.name) {
tagsViewStore.addVisitedView(route) tagsViewStore.addVisitedView(route)
} }
// 添加固定标签(仪表盘)
const dashboardRoute = router.getRoutes().find(r => r.name === 'Dashboard') const dashboardRoute = router.getRoutes().find(r => r.name === 'Dashboard')
if (dashboardRoute) { if (dashboardRoute) {
// 注意:这里我们直接修改 store 的 affixTags,或者 store 应该提供一个 action
// 简单起见,我们假设 store 的 affixTags 是可以直接修改的 ref,或者我们在 store 中添加初始化逻辑
// 但为了保持一致性,我们这里只处理 visitedViews 的添加
if (!tagsViewStore.affixTags.some(tag => tag.path === dashboardRoute.path)) { if (!tagsViewStore.affixTags.some(tag => tag.path === dashboardRoute.path)) {
tagsViewStore.affixTags.push(dashboardRoute) tagsViewStore.affixTags.push(dashboardRoute)
} }
@@ -94,13 +93,11 @@ const initTags = () => {
} }
} }
// 刷新选中的标签
const refreshSelectedTag = (view) => { const refreshSelectedTag = (view) => {
const { fullPath } = view const { fullPath } = view
router.replace('/redirect' + fullPath) router.replace('/redirect' + fullPath)
} }
// 关闭选中的标签
const closeSelectedTag = (view) => { const closeSelectedTag = (view) => {
tagsViewStore.delVisitedView(view).then((visitedViews) => { tagsViewStore.delVisitedView(view).then((visitedViews) => {
if (isActive(view)) { if (isActive(view)) {
@@ -109,15 +106,11 @@ const closeSelectedTag = (view) => {
}) })
} }
// 关闭其他标签
const closeOthersTags = () => { const closeOthersTags = () => {
router.push(selectedTag.value) router.push(selectedTag.value)
tagsViewStore.delOthersViews(selectedTag.value).then(() => { tagsViewStore.delOthersViews(selectedTag.value)
// moveToCurrentTag() // 如果有滚动逻辑
})
} }
// 关闭左侧标签
const closeLeftTags = () => { const closeLeftTags = () => {
tagsViewStore.delLeftViews(selectedTag.value).then((visitedViews) => { tagsViewStore.delLeftViews(selectedTag.value).then((visitedViews) => {
if (!visitedViews.find(i => i.path === route.path)) { if (!visitedViews.find(i => i.path === route.path)) {
@@ -126,7 +119,6 @@ const closeLeftTags = () => {
}) })
} }
// 关闭右侧标签
const closeRightTags = () => { const closeRightTags = () => {
tagsViewStore.delRightViews(selectedTag.value).then((visitedViews) => { tagsViewStore.delRightViews(selectedTag.value).then((visitedViews) => {
if (!visitedViews.find(i => i.path === route.path)) { if (!visitedViews.find(i => i.path === route.path)) {
@@ -135,20 +127,17 @@ const closeRightTags = () => {
}) })
} }
// 关闭所有标签
const closeAllTags = () => { const closeAllTags = () => {
tagsViewStore.delAllViews().then((visitedViews) => { tagsViewStore.delAllViews().then((visitedViews) => {
toLastView(visitedViews) toLastView(visitedViews)
}) })
} }
// 跳转到最后一个标签或首页
const toLastView = (visitedViews, view) => { const toLastView = (visitedViews, view) => {
const latestView = visitedViews.slice(-1)[0] const latestView = visitedViews.slice(-1)[0]
if (latestView) { if (latestView) {
router.push(latestView) router.push(latestView)
} else { } else {
// 如果没有标签,则跳转到首页
if (view && view.name === 'Dashboard') { if (view && view.name === 'Dashboard') {
router.push('/redirect' + '/dashboard') router.push('/redirect' + '/dashboard')
} else { } else {
@@ -157,17 +146,14 @@ const toLastView = (visitedViews, view) => {
} }
} }
// 判断是否是当前激活的标签
const isActive = (tag) => { const isActive = (tag) => {
return tag.path === route.path return tag.path === route.path
} }
// 判断是否是固定标签
const isAffix = (tag) => { const isAffix = (tag) => {
return tag.meta && tag.meta.affix return tag.meta && tag.meta.affix
} }
// 打开右键菜单
const openMenu = (e, tag) => { const openMenu = (e, tag) => {
const menuMinWidth = 125 const menuMinWidth = 125
const offsetLeft = e.clientX const offsetLeft = e.clientX
@@ -181,30 +167,112 @@ const openMenu = (e, tag) => {
selectedTag.value = tag selectedTag.value = tag
} }
// 关闭右键菜单 // ---- 滚动 & 滚动条 ----
const scrollWrapperRef = ref(null)
const hovered = ref(false)
const hasOverflow = ref(false)
const thumbStyle = ref({ width: '0px', left: '0px' })
const handleWheel = (e) => {
if (scrollWrapperRef.value) {
scrollWrapperRef.value.scrollLeft += e.deltaY || e.deltaX
}
}
const refreshState = () => {
const el = scrollWrapperRef.value
if (!el) return
const { scrollLeft, scrollWidth, clientWidth } = el
const maxScroll = scrollWidth - clientWidth
hasOverflow.value = maxScroll > 1
if (!hasOverflow.value) {
thumbStyle.value = { width: '0px', left: '0px' }
return
}
const trackWidth = clientWidth
const thumbW = Math.max((clientWidth / scrollWidth) * trackWidth, 30)
const scrollRatio = maxScroll > 0 ? scrollLeft / maxScroll : 0
const thumbLeft = scrollRatio * (trackWidth - thumbW)
thumbStyle.value = { width: thumbW + 'px', left: thumbLeft + 'px' }
}
const onScroll = () => {
refreshState()
}
const scrollToActiveTag = () => {
const el = scrollWrapperRef.value
if (!el) return
const activeEl = el.querySelector('.active-tag')
if (!activeEl) return
const wrapperRect = el.getBoundingClientRect()
const tagRect = activeEl.getBoundingClientRect()
if (tagRect.left < wrapperRect.left + 28) {
el.scrollLeft -= (wrapperRect.left + 28 - tagRect.left + 12)
} else if (tagRect.right > wrapperRect.right - 28) {
el.scrollLeft += (tagRect.right - wrapperRect.right + 28 + 12)
}
}
const onThumbDown = (e) => {
e.preventDefault()
const el = scrollWrapperRef.value
if (!el) return
const startX = e.clientX
const startScroll = el.scrollLeft
const maxScroll = el.scrollWidth - el.clientWidth
const trackWidth = el.clientWidth
const thumbW = Math.max((el.clientWidth / el.scrollWidth) * trackWidth, 30)
const movable = trackWidth - thumbW
const onMove = (ev) => {
const dx = ev.clientX - startX
const scrollDelta = movable > 0 ? (dx / movable) * maxScroll : 0
el.scrollLeft = Math.min(Math.max(startScroll + scrollDelta, 0), maxScroll)
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
watch(visitedViews, () => nextTick(() => { refreshState(); scrollToActiveTag() }), { deep: true })
const closeMenu = () => { const closeMenu = () => {
visible.value = false visible.value = false
} }
// 监听路由变化,添加标签
watch(route, (newRoute) => { watch(route, (newRoute) => {
if (newRoute.name) { if (newRoute.name) {
tagsViewStore.addVisitedView(newRoute) tagsViewStore.addVisitedView(newRoute)
} }
nextTick(scrollToActiveTag)
}) })
// 点击其他区域关闭右键菜单
const handleClickOutside = () => { const handleClickOutside = () => {
closeMenu() closeMenu()
} }
const onResize = () => {
refreshState()
scrollToActiveTag()
}
onMounted(() => { onMounted(() => {
initTags() initTags()
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
nextTick(() => { refreshState(); scrollToActiveTag() })
window.addEventListener('resize', onResize)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', onResize)
}) })
</script> </script>
@@ -215,17 +283,21 @@ onBeforeUnmount(() => {
background-color: #ffffff; background-color: #ffffff;
border-bottom: 1px solid #e1e8ed; border-bottom: 1px solid #e1e8ed;
z-index: 10; z-index: 10;
display: flex;
align-items: stretch;
position: relative;
overflow: hidden;
} }
/* 标签滚动区域 */
.tags-view-wrapper { .tags-view-wrapper {
flex: 1;
min-width: 0;
height: 100%; height: 100%;
width: 100%; overflow-x: scroll;
display: flex; overflow-y: hidden;
align-items: center; scrollbar-width: none;
padding: 0 12px; -ms-overflow-style: none;
overflow-x: auto;
white-space: nowrap;
position: relative;
} }
.tags-view-wrapper::-webkit-scrollbar { .tags-view-wrapper::-webkit-scrollbar {
@@ -233,18 +305,51 @@ onBeforeUnmount(() => {
} }
.tags-view-scroll { .tags-view-scroll {
display: flex; display: inline-flex;
align-items: center; align-items: center;
height: 100%; height: 100%;
padding: 0 8px;
gap: 4px; gap: 4px;
} }
/* 底部自定义滚动条(在容器上,不在滚动区域内) */
.scroll-track {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
opacity: 0;
transition: opacity 0.25s;
pointer-events: none;
z-index: 5;
}
.scroll-track.visible {
opacity: 1;
pointer-events: auto;
}
.scroll-thumb {
position: absolute;
top: 0;
height: 100%;
border-radius: 3px;
background: rgba(180,188,199,0.45);
transition: background 0.15s;
cursor: pointer;
}
.scroll-thumb:hover {
background: rgba(180,188,199,0.65);
}
/* 标签样式 */
.tag, .active-tag { .tag, .active-tag {
height: 32px; height: 32px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
margin-right: 0;
border-radius: 0; border-radius: 0;
font-size: 13px; font-size: 13px;
text-decoration: none; text-decoration: none;
@@ -252,6 +357,8 @@ onBeforeUnmount(() => {
transition: all 0.2s ease; transition: all 0.2s ease;
border: 1px solid transparent; border: 1px solid transparent;
border-bottom: none; border-bottom: none;
flex-shrink: 0;
white-space: nowrap;
} }
.tag { .tag {
@@ -326,6 +433,7 @@ onBeforeUnmount(() => {
background-color: rgba(231, 76, 60, 0.1); background-color: rgba(231, 76, 60, 0.1);
} }
/* 右键菜单 */
.contextmenu { .contextmenu {
position: fixed; position: fixed;
z-index: 100; z-index: 100;
+55
View File
@@ -0,0 +1,55 @@
/**
* 环境配置文件
* 所有硬编码的 URL / 域名 / 环境变量统一在此管理
*/
// 当前环境
const isDevelopment = import.meta.env.MODE === 'development'
// API 基础地址
// 开发环境使用 vite 代理 (baseUrl 为空),生产环境使用实际地址
const API_BASE_MAP = {
development: import.meta.env.VITE_API_BASE_URL || 'https://apiservertest.s1f.ren', // 直接请求后端,不走 vite proxy
production: import.meta.env.VITE_API_BASE_URL || 'https://cloudapi.007yjs.com',
staging: import.meta.env.VITE_API_BASE_URL || 'https://apiservertest.s1f.ren'
}
// 获取当前环境的 API 基础地址
const currentEnv = import.meta.env.VITE_APP_ENV || import.meta.env.MODE || 'development'
export const baseUrl = API_BASE_MAP[currentEnv] || API_BASE_MAP.development
// ACS 服务基础地址
export const acsBaseUrl = baseUrl
// 网站标题
export const siteTitle = '007UI管理系统'
// 请求超时时间(毫秒)
export const requestTimeout = 50000
export const acsRequestTimeout = 30000
// Token 存储键名
export const TOKEN_KEY = 'token'
export const TOKEN_EXPIRE_KEY = 'tokenExpire'
export const USER_INFO_KEY = 'userInfo'
// 不需要 token 认证的 URL 前缀
export const noAuthUrls = [
'/v1/user/login',
'/v1/user/check/get_code_img',
'/v1/user/register',
'/v1/user/refresh_token'
]
export default {
isDevelopment,
baseUrl,
acsBaseUrl,
siteTitle,
requestTimeout,
acsRequestTimeout,
TOKEN_KEY,
TOKEN_EXPIRE_KEY,
USER_INFO_KEY,
noAuthUrls
}
+48 -19
View File
@@ -6,8 +6,14 @@ export const menus = [
}, },
{ {
path: '/ticket', path: '/ticket',
title: '工单理', title: '工单理',
icon: 'DataBoard' icon: 'Tickets',
children: [
{
path: '/ticket/list',
title: '工单列表'
}
]
}, },
{ {
path: '/user', path: '/user',
@@ -18,10 +24,6 @@ export const menus = [
path: '/user/list', path: '/user/list',
title: '用户列表' title: '用户列表'
}, },
{
path: '/user/balance',
title: '用户余额管理'
},
{ {
path: '/user/group', path: '/user/group',
title: '用户组管理' title: '用户组管理'
@@ -37,15 +39,16 @@ export const menus = [
title: '商品管理', title: '商品管理',
icon: 'Goods', icon: 'Goods',
children: [ children: [
{ { path: '/product/manage', title: '商品管理' }
path: '/product/list', ]
title: '商品列表' },
}, {
{ path: '/user-goods',
path: '/product/group', title: '用户商品管理',
title: '商品分组' icon: 'ShoppingCart',
}, children: [
{ path: '/user-goods/list', title: '所有商品' },
{ path: '/user-goods/vm-list', title: '云服务器' }
] ]
}, },
{ {
@@ -83,6 +86,10 @@ export const menus = [
{ {
path: '/activity/signin', path: '/activity/signin',
title: '签到活动' title: '签到活动'
},
{
path: '/activity/groupbuy',
title: '拼团管理'
} }
] ]
}, },
@@ -134,6 +141,24 @@ export const menus = [
} }
] ]
}, },
{
title: '虚拟化平台管理',
icon: 'Platform',
children: [
{
path: '/virtualization/kvm-service',
title: '主控服务管理'
},
{
path: '/virtualization/host-group-mapping',
title: '宿主机组映射管理'
},
{
path: '/virtualization/vnc-command',
title: 'VNC指令管理'
}
]
},
{ {
path: '/system', path: '/system',
title: '系统管理', title: '系统管理',
@@ -158,12 +183,16 @@ export const menus = [
title: '域名白名单' title: '域名白名单'
}, },
{ {
path: '/system/setting-group', path: '/system/setting-manage',
title: '配置管理' title: '配置管理'
}, },
{ {
path: '/system/setting-list', path: '/system/menu',
title: '配置管理' title: '菜单管理',
children: [
{ path: '/system/menu-manage', title: '菜单列表' },
{ path: '/system/menu-permission', title: '菜单权限' }
]
} }
] ]
} }
+246 -26
View File
@@ -39,7 +39,27 @@ const routes = [
title: '工单管理', title: '工单管理',
icon: 'Tickets' icon: 'Tickets'
}, },
component: () => import('../views/ticket/TicketChat.vue'), redirect: '/ticket/list',
children: [
{
path: 'list',
name: 'TicketList',
component: () => import('../views/ticket/TicketList.vue'),
meta: {
title: '工单列表'
}
},
{
path: 'detail',
name: 'TicketDetail',
component: () => import('../views/ticket/TicketDetail.vue'),
meta: {
title: '工单详情',
hidden: true,
activeMenu: '/ticket/list'
}
}
]
}, },
// ACS管理路由 // ACS管理路由
@@ -208,29 +228,50 @@ const routes = [
{ {
path: 'product', path: 'product',
name: 'Product', name: 'Product',
meta: { meta: { title: '商品管理', icon: 'Goods' },
title: '商品管理', redirect: '/product/manage',
icon: 'Goods' children: [
}, {
redirect: '/product/list', path: 'manage',
name: 'ProductManage',
component: () => import('../views/product/ProductGroup.vue'),
meta: { title: '商品管理' }
},
{ path: 'list', redirect: '/product/manage' },
{ path: 'group', redirect: '/product/manage' }
]
},
// 用户商品管理路由
{
path: 'user-goods',
name: 'UserGoods',
meta: { title: '用户商品管理', icon: 'ShoppingCart' },
redirect: '/user-goods/list',
children: [ children: [
{ {
path: 'list', path: 'list',
name: 'ProductList', name: 'UserGoodsList',
component: () => import('../views/product/ProductList.vue'), component: () => import('../views/product/UserGoodsList.vue'),
meta: { meta: { title: '所有商品' }
title: '商品列表'
}
}, },
{ {
path: 'group', path: 'detail/:id',
name: 'ProductGroup', name: 'UserGoodsDetail',
component: () => import('../views/product/ProductGroup.vue'), component: () => import('../views/product/UserGoodsDetail.vue'),
meta: { meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' }
title: '商品分组'
}
}, },
{
path: 'vm-list',
name: 'UserVmList',
component: () => import('../views/user-vm/UserVmList.vue'),
meta: { title: '云服务器' }
},
{
path: 'vm-detail',
name: 'UserVmDetail',
component: () => import('../views/user-vm/UserVmDetail.vue'),
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
}
] ]
}, },
// 订单管理路由 // 订单管理路由
@@ -309,6 +350,14 @@ const routes = [
meta: { meta: {
title: '签到活动' title: '签到活动'
} }
},
{
path: '/activity/groupbuy',
name: 'GroupBuyManage',
component: () => import('../views/activity/GroupBuyManage.vue'),
meta: {
title: '拼团管理'
}
} }
] ]
}, },
@@ -354,16 +403,187 @@ const routes = [
meta: { title: '域名白名单' } meta: { title: '域名白名单' }
}, },
{ {
path: 'setting-group', path: 'setting-manage',
name: 'SettingGroup', name: 'SettingManage',
component: () => import('../views/system/SettingGroup.vue'), component: () => import('../views/system/SettingManage.vue'),
meta: { title: '配置管理' } meta: { title: '配置管理' }
}, },
{ {
path: 'setting-list', path: 'menu-manage',
name: 'SettingList', name: 'MenuManage',
component: () => import('../views/system/Setting.vue'), component: () => import('../views/system/MenuManage.vue'),
meta: { title: '配置管理' } meta: { title: '菜单管理' }
},
{
path: 'menu-permission',
name: 'MenuPermission',
component: () => import('../views/system/MenuPermission.vue'),
meta: { title: '菜单权限' }
}
]
},
{
path: 'virtualization',
name: 'Virtualization',
meta: {
title: '虚拟化平台管理',
icon: 'Platform'
},
redirect: '/virtualization/kvm-service',
children: [
{
path: 'kvm-service',
name: 'KvmService',
component: () => import('../views/virtualization/KvmService.vue'),
meta: {
title: '主控服务管理'
}
},
{
path: 'kvm-service-detail',
name: 'KvmServiceDetail',
component: () => import('../views/virtualization/KvmServiceDetail.vue'),
meta: {
title: '主控服务详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'host-group-mapping',
name: 'HostGroupMapping',
component: () => import('../views/virtualization/HostGroupMapping.vue'),
meta: {
title: '宿主机组映射管理'
}
},
{
path: 'host-manage',
name: 'HostManage',
component: () => import('../views/virtualization/HostManage.vue'),
meta: {
title: '宿主机管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'image-manage',
name: 'ImageManage',
component: () => import('../views/virtualization/ImageManage.vue'),
meta: {
title: '镜像管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'network-manage',
name: 'NetworkManage',
component: () => import('../views/virtualization/NetworkManage.vue'),
meta: {
title: '网络管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'volume-manage',
name: 'VolumeManage',
component: () => import('../views/virtualization/VolumeManage.vue'),
meta: {
title: '数据卷管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'vm-manage',
name: 'VmManage',
component: () => import('../views/virtualization/VmManage.vue'),
meta: {
title: '虚拟机管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'security-group',
name: 'SecurityGroupManage',
component: () => import('../views/virtualization/SecurityGroupManage.vue'),
meta: {
title: '安全组管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'vnc-node',
name: 'VncNodeManage',
component: () => import('../views/virtualization/VncNodeManage.vue'),
meta: {
title: 'VNC节点管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'vnc-command',
name: 'VncCommandManage',
component: () => import('../views/virtualization/VncCommandManage.vue'),
meta: {
title: 'VNC指令管理'
}
},
{
path: 'host-detail',
name: 'VirtHostDetail',
component: () => import('../views/virtualization/HostDetail.vue'),
meta: {
title: '宿主机详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'image-detail',
name: 'VirtImageDetail',
component: () => import('../views/virtualization/ImageDetail.vue'),
meta: {
title: '镜像详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'vm-detail',
name: 'VirtVmDetail',
component: () => import('../views/virtualization/VmDetail.vue'),
meta: {
title: '虚拟机详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'security-group-detail',
name: 'VirtSecurityGroupDetail',
component: () => import('../views/virtualization/SecurityGroupDetail.vue'),
meta: {
title: '安全组详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'volume-detail',
name: 'VirtVolumeDetail',
component: () => import('../views/virtualization/VolumeDetail.vue'),
meta: {
title: '数据卷详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
} }
] ]
}, },
+19 -2
View File
@@ -4,11 +4,28 @@ import {ref} from "vue";
export const useUserStore = defineStore('userStore',() => { export const useUserStore = defineStore('userStore',() => {
let userInfo = ref({}) // 初始化时从localStorage读取用户信息
const savedUserInfo = localStorage.getItem('userInfo')
let userInfo = ref(savedUserInfo ? JSON.parse(savedUserInfo) : {})
function setUserInfo(u){ function setUserInfo(u){
userInfo.value = u userInfo.value = u
// 同步保存到localStorage
if (u && Object.keys(u).length > 0) {
localStorage.setItem('userInfo', JSON.stringify(u))
}
} }
return {userInfo,setUserInfo} // 清除用户信息
function clearUserInfo() {
userInfo.value = {}
localStorage.removeItem('userInfo')
}
// 获取用户头像
function getUserAvatar() {
return userInfo.value?.cover || ''
}
return {userInfo, setUserInfo, clearUserInfo, getUserAvatar}
}) })
+374 -1
View File
@@ -114,11 +114,384 @@ body {
padding-right: 10px; padding-right: 10px;
} }
/* 响应式工具类 */ /* 可点击元素统一手型光标 */
.el-button,
.el-button--link,
.el-tag.is-closable .el-tag__close,
.el-dropdown,
.el-dropdown-menu__item,
.el-switch,
.el-checkbox,
.el-radio,
.el-select .el-input__wrapper,
.el-table__body tr.el-table__row {
cursor: pointer;
}
.back-btn {
cursor: pointer;
}
/* ==================== 全局弹窗卡片样式 ==================== */
/* 自动为所有未手动分区的弹窗表单添加卡片背景 */
.el-dialog:not(.tk-dialog):not(.token-dialog):not(.token-result-dialog) .el-dialog__body > .el-form {
background: #fafbfc;
border-radius: 8px;
padding: 20px 20px 4px;
border: 1px solid #f0f2f5;
}
/* 统一弹窗 footer 按钮对齐 */
.el-dialog .el-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
}
.tk-dialog .el-dialog__body {
max-height: 70vh;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.tk-dialog .el-dialog__body::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.tk-dialog .el-form {
padding: 0 4px;
}
.tk-section {
background: #fafbfc;
border-radius: 8px;
padding: 20px 20px 4px;
margin-bottom: 16px;
border: 1px solid #f0f2f5;
}
.tk-section-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin-bottom: 18px;
padding-left: 10px;
border-left: 3px solid #409eff;
line-height: 1;
}
.tk-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.tk-resource-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 24px;
}
.tk-resource-grid .el-form-item {
margin-bottom: 18px;
}
.tk-resource-grid .el-form-item .el-form-item__label {
width: 80px !important;
}
.tk-resource-grid .el-form-item .el-form-item__content {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.tk-resource-grid .el-input-number {
flex: 1;
min-width: 0;
}
.tk-unit-select {
width: 68px;
flex-shrink: 0;
}
.tk-res-unit {
font-size: 13px;
color: #909399;
flex-shrink: 0;
white-space: nowrap;
}
.tk-inline-unit {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.tk-inline-unit .el-input-number,
.tk-inline-unit .el-input,
.tk-inline-unit .el-select {
flex: 1;
min-width: 0;
}
/* ==================== 全局页面布局组件 ==================== */
/* 页面头部 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #ebeef5;
}
.page-header .header-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-header .header-info h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1d2129;
}
.page-header .sub-info {
font-size: 13px;
color: #909399;
margin-top: 2px;
}
.page-header .header-right {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* 嵌入式工具栏 */
.embedded-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
/* 通用工具栏 */
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
/* 筛选栏 */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
/* 筛选区域(卡片式) */
.filter-section {
margin-bottom: 16px;
}
/* 分页 */
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 8px;
}
/* 绑定选择器行 */
.bind-selector-row {
display: flex;
align-items: center;
width: 100%;
}
/* 详情操作按钮组 */
.detail-actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
/* ==================== 全局表格增强 ==================== */
.el-table {
--el-table-header-bg-color: #fafafa;
--el-table-row-hover-bg-color: #f5f7fa;
--el-table-border-color: #ebeef5;
}
.el-table th.el-table__cell {
font-weight: 600 !important;
color: #1d2129 !important;
font-size: 13px !important;
border-bottom: 2px solid #e1e8ed !important;
}
.el-table td.el-table__cell {
border-bottom: 1px solid #f0f2f5 !important;
color: #34495e !important;
transition: background-color 0.15s ease;
}
.el-table .el-table__empty-block {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.el-table .el-table__empty-text {
color: #909399;
font-size: 14px;
line-height: 1.6;
}
/* 表格固定列阴影 */
.el-table__fixed {
box-shadow: 4px 0 8px -4px rgba(0, 0, 0, 0.1);
}
.el-table__fixed-right {
box-shadow: -4px 0 8px -4px rgba(0, 0, 0, 0.1);
}
/* ==================== 全局骨架屏样式 ==================== */
@keyframes tk-skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-container {
padding: 20px;
}
.skeleton-row {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.skeleton-row:last-child {
border-bottom: none;
}
.skeleton-cell {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: tk-skeleton-loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
/* ==================== 全局过渡动画 ==================== */
.el-table,
.el-card,
.el-tag,
.el-button {
transition: all 0.2s ease;
}
/* ==================== 通用文本类 ==================== */
.text-muted {
color: #c0c4cc;
}
.mono-text {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: #409eff;
font-size: 13px;
}
/* ==================== 视觉增强 ==================== */
/* 卡片式筛选区域 */
.filter-card {
background: #ffffff;
border: 1px solid #ebeef5;
padding: 16px 20px;
margin-bottom: 16px;
}
/* 操作栏 */
.action-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* 通用结果/令牌展示 */
.tk-result-wrapper { text-align: center; }
.tk-result-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; text-align: left; }
.tk-result-icon { font-size: 36px; color: #e6a23c; background: #fdf6ec; border-radius: 50%; padding: 10px; }
.tk-result-name { font-size: 16px; font-weight: 600; color: #1d2129; }
.tk-result-meta { font-size: 13px; color: #909399; margin-top: 2px; }
.tk-token-block { background: #1d2129; border-radius: 8px; padding: 16px; margin-bottom: 16px; text-align: left; }
.tk-token-label { font-size: 11px; color: #909399; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
.tk-token-value { font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 13px; color: #67c23a; word-break: break-all; line-height: 1.6; user-select: all; }
.tk-copy-btn { width: 100%; }
/* 表单提示 */
.form-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
/* 资源信息标签组 */
.resource-info {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* ==================== 响应式工具类 ==================== */
/* 表格横向滚动提示 */
.el-table {
overflow: visible;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.hidden-xs { .hidden-xs {
display: none !important; display: none !important;
} }
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.page-header .header-right {
width: 100%;
flex-wrap: wrap;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.filter-bar .el-input,
.filter-bar .el-select {
width: 100% !important;
}
.pagination-wrapper {
justify-content: center;
}
.pagination-wrapper .el-pagination {
flex-wrap: wrap;
justify-content: center;
}
/* 弹窗在移动端更宽 */
.el-dialog {
width: 92% !important;
margin: 5vh auto !important;
}
/* 表格小屏字号调整 */
.el-table td.el-table__cell {
font-size: 13px !important;
}
/* 表单小屏行距压缩 */
.el-form-item {
margin-bottom: 16px;
}
/* tk-resource-grid 在移动端变为单列 */
.tk-resource-grid {
grid-template-columns: 1fr;
}
}
/* 中等屏幕适配 */
@media (max-width: 1200px) {
.el-table .el-table__body-wrapper {
overflow-x: auto;
}
} }
@media (min-width: 768px) and (max-width: 992px) { @media (min-width: 768px) and (max-width: 992px) {
+160
View File
@@ -0,0 +1,160 @@
/**
* Dynamic Unit System
*
* Handles dynamic unit conversion and display for product parameters.
* Base units: storage=GB, bandwidth=Mbps, cpu=Core
*/
const UNIT_CONVERSIONS = {
cpu: { Core: 1 },
bandwidth_up: { Mbps: 1, Gbps: 1000 },
bandwidth_down: { Mbps: 1, Gbps: 1000 },
storage: { GB: 1, TB: 1024 },
ipv4: { '个': 1 },
ipv6: { '个': 1 },
custom: {}
}
const BASE_UNITS = {
cpu: 'Core',
bandwidth_up: 'Mbps',
bandwidth_down: 'Mbps',
storage: 'GB',
ipv4: '个',
ipv6: '个',
custom: ''
}
const DEFAULT_DISPLAY_UNITS = {
cpu: 'Core',
bandwidth_up: 'Mbps',
bandwidth_down: 'Mbps',
storage: 'GB',
ipv4: '个',
ipv6: '个',
custom: ''
}
const ARG_KEY_OPTIONS = [
{ label: 'CPU (cpu)', value: 'cpu' },
{ label: 'IPv4', value: 'ipv4' },
{ label: 'IPv6', value: 'ipv6' },
{ label: '上行带宽 (bandwidth_up)', value: 'bandwidth_up' },
{ label: '下行带宽 (bandwidth_down)', value: 'bandwidth_down' },
{ label: '存储空间 (storage)', value: 'storage' },
{ label: '自定义 (custom)', value: 'custom' }
]
/**
* Convert value between units
* @param {number} value
* @param {string} fromUnit
* @param {string} toUnit
* @param {string} argKey - e.g. 'storage', 'bandwidth_up'
*/
export function convertUnit(value, fromUnit, toUnit, argKey) {
if (value === null || value === undefined || fromUnit === toUnit) return value
const conversions = UNIT_CONVERSIONS[argKey]
if (!conversions || !conversions[fromUnit] || !conversions[toUnit]) return value
const baseValue = value * conversions[fromUnit]
return baseValue / conversions[toUnit]
}
/**
* Convert from display unit to base unit for storage/submission
*/
export function toBaseUnit(value, displayUnit, argKey) {
const baseUnit = BASE_UNITS[argKey]
if (!baseUnit || !displayUnit) return value
return convertUnit(value, displayUnit, baseUnit, argKey)
}
/**
* Convert from base unit to display unit for showing in UI
*/
export function fromBaseUnit(value, displayUnit, argKey) {
const baseUnit = BASE_UNITS[argKey]
if (!baseUnit || !displayUnit) return value
return convertUnit(value, baseUnit, displayUnit, argKey)
}
/**
* Get base unit string for a given argKey
*/
export function getBaseUnit(argKey) {
return BASE_UNITS[argKey] || ''
}
/**
* Get default display unit for a given argKey
*/
export function getDefaultDisplayUnit(argKey) {
return DEFAULT_DISPLAY_UNITS[argKey] || ''
}
/**
* Get all available units for a parameter type
*/
export function getAvailableUnits(argKey) {
const conversions = UNIT_CONVERSIONS[argKey]
return conversions ? Object.keys(conversions) : []
}
/**
* Get argKey select options
*/
export function getArgKeyOptions() {
return ARG_KEY_OPTIONS
}
/**
* Check if a parameter has dynamic unit enabled.
* Returns true when arg_key maps to a known unit type with multiple selectable units.
*/
export function hasUnit(param) {
if (!param) return false
const argKey = param.argKey || param.arg_key || param.key || ''
if (!argKey || !(argKey in UNIT_CONVERSIONS)) return false
return Object.keys(UNIT_CONVERSIONS[argKey]).length > 1
}
/**
* Get the argKey from a parameter object (handles camelCase, snake_case, and plain key)
*/
export function getArgKey(param) {
if (!param) return ''
return param.argKey || param.arg_key || param.key || ''
}
/**
* Get the available units from a parameter object
*/
export function getParamUnits(param) {
if (!hasUnit(param)) return []
const argKey = getArgKey(param)
const paramUnits = param.availableUnits || param.available_units
if (paramUnits && paramUnits.length > 0) return paramUnits
return getAvailableUnits(argKey)
}
/**
* Get the default unit from a parameter object
*/
export function getParamDefaultUnit(param) {
if (!hasUnit(param)) return ''
const argKey = getArgKey(param)
return param.defaultUnit || param.default_unit || getDefaultDisplayUnit(argKey)
}
/**
* Validate if a unit is valid for a parameter type
*/
export function isValidUnit(unit, argKey) {
const conversions = UNIT_CONVERSIONS[argKey]
return conversions && Object.prototype.hasOwnProperty.call(conversions, unit)
}
export function formatValueWithUnit(value, unit) {
if (value === null || value === undefined || value === '') return '-'
return unit ? `${value} ${unit}` : String(value)
}
+157
View File
@@ -0,0 +1,157 @@
const ERROR_CODE_MAP = {
// 主控服务
kvm_service_list_error: '获取主控服务列表失败',
kvm_service_detail_error: '获取主控服务详情失败',
kvm_service_create_error: '创建主控服务失败',
kvm_service_update_error: '修改主控服务失败',
kvm_service_delete_error: '删除主控服务失败',
// 宿主机组(本地)
kvm_host_group_list_error: '获取宿主机组列表失败',
kvm_host_group_sync_error: '同步宿主机组失败',
kvm_host_group_bind_error: '绑定宿主机组失败',
kvm_host_group_update_error: '修改宿主机组失败',
kvm_host_group_delete_error: '删除宿主机组失败',
kvm_host_group_generate_error: '生成商品失败',
kvm_host_group_optimal_error: '获取最优主机失败',
// 宿主机组(远程)
kvm_remote_host_group_list_error: '获取远程宿主机组列表失败',
kvm_remote_host_group_detail_error: '获取远程宿主机组详情失败',
kvm_remote_host_group_tree_error: '获取远程宿主机组树失败',
kvm_remote_host_group_create_error: '创建远程宿主机组失败',
kvm_remote_host_group_update_error: '修改远程宿主机组失败',
kvm_remote_host_group_delete_error: '删除远程宿主机组失败',
// 宿主机
kvm_host_list_error: '获取宿主机列表失败',
kvm_host_detail_error: '获取宿主机详情失败',
kvm_host_metrics_error: '获取宿主机指标失败',
kvm_host_add_error: '新增宿主机失败',
kvm_host_update_error: '修改宿主机失败',
kvm_host_delete_error: '删除宿主机失败',
// 镜像
kvm_image_list_error: '获取镜像列表失败',
kvm_image_detail_error: '获取镜像详情失败',
kvm_image_host_status_error: '获取镜像宿主机状态失败',
kvm_image_create_error: '创建镜像失败',
kvm_image_update_error: '修改镜像失败',
kvm_image_delete_error: '删除镜像失败',
kvm_image_reload_error: '重新下载镜像失败',
kvm_image_sync_error: '同步镜像到宿主机失败',
kvm_image_reload_host_error: '重新下载镜像到宿主机失败',
// 网络
kvm_network_list_error: '获取网络列表失败',
kvm_network_detail_error: '获取网络详情失败',
kvm_network_create_error: '创建网络失败',
kvm_network_update_error: '修改网络失败',
kvm_network_delete_error: '删除网络失败',
// 数据卷
kvm_volume_list_error: '获取数据卷列表失败',
kvm_volume_detail_error: '获取数据卷详情失败',
kvm_volume_create_error: '创建数据卷失败',
kvm_volume_resize_error: '调整数据卷大小失败',
kvm_volume_mount_error: '挂载数据卷失败',
kvm_volume_unmount_error: '卸载数据卷失败',
kvm_volume_transfer_error: '迁移数据卷失败',
kvm_volume_delete_error: '删除数据卷失败',
// 虚拟机
kvm_vm_list_error: '获取虚拟机列表失败',
kvm_vm_detail_error: '获取虚拟机详情失败',
kvm_vm_status_error: '获取虚拟机状态失败',
kvm_vm_metrics_error: '获取虚拟机指标失败',
kvm_vm_create_error: '创建虚拟机失败',
kvm_vm_update_error: '修改虚拟机失败',
kvm_vm_rebuild_error: '重建虚拟机失败',
kvm_vm_refactor_error: '重构虚拟机失败',
kvm_vm_update_traffic_error: '修改虚拟机带宽失败',
kvm_vm_start_error: '启动虚拟机失败',
kvm_vm_stop_error: '停止虚拟机失败',
kvm_vm_reboot_error: '重启虚拟机失败',
kvm_vm_suspend_error: '暂停虚拟机失败',
kvm_vm_resume_error: '恢复虚拟机失败',
kvm_vm_rescue_error: '进入救援系统失败',
kvm_vm_exit_rescue_error: '退出救援系统失败',
kvm_vm_delete_error: '删除虚拟机失败',
// 安全组
kvm_post_group_list_error: '获取安全组列表失败',
kvm_post_group_detail_error: '获取安全组详情失败',
kvm_post_group_create_error: '创建安全组失败',
kvm_post_group_update_error: '修改安全组失败',
kvm_post_group_sync_error: '同步安全组失败',
kvm_post_group_bind_error: '绑定安全组失败',
kvm_post_group_unbind_error: '解绑安全组失败',
kvm_post_group_delete_error: '删除安全组失败',
kvm_post_group_enable_whitelist_error: '开启安全组白名单失败',
kvm_post_group_disable_whitelist_error: '关闭安全组白名单失败',
kvm_post_group_create_rule_error: '新增安全组规则失败',
kvm_post_group_update_rule_error: '修改安全组规则失败',
kvm_post_group_delete_rule_error: '删除安全组规则失败',
kvm_post_group_apply_error: '应用安全组失败',
kvm_security_group_list_error: '获取安全组列表失败',
kvm_security_group_detail_error: '获取安全组详情失败',
kvm_security_group_create_error: '创建安全组失败',
kvm_security_group_update_error: '修改安全组失败',
kvm_security_group_delete_error: '删除安全组失败',
// VNC
kvm_vnc_list_error: '获取VNC节点列表失败',
kvm_vnc_add_error: '新增VNC节点失败',
kvm_vnc_test_error: '测试VNC节点连接失败',
kvm_vnc_update_error: '修改VNC节点失败',
kvm_vnc_delete_error: '删除VNC节点失败',
kvm_vnc_vm_vnc_error: '获取VNC连接信息失败',
}
/**
* 从嵌套的 RPC 错误字符串中提取有意义的中文描述
*/
function parseRpcError(err) {
if (!err) return ''
const descMatch = err.match(/desc\s*=\s*(.+)/)
if (descMatch) {
const descContent = descMatch[1]
const jsonMatch = descContent.match(/body=(\{.+\})/)
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[1])
if (parsed.message) return parsed.message
} catch { /* ignore */ }
}
const clean = descContent.trim()
if (clean && !clean.startsWith('http')) return clean
}
return ''
}
/**
* 统一提取 API 响应中的错误信息
* @param {object} body - axios response.data (即 { code, message, error, data })
* @param {string} fallback - 兜底文案
* @returns {string} 中文错误描述
*/
export function extractApiError(body, fallback = '操作失败') {
if (!body) return fallback
// 识别数据库唯一约束冲突
if (body.error && body.error.includes('duplicate key value violates unique constraint')) {
const nameMatch = body.error.match(/create \w+ \[(.+?)\] error/)
const hint = nameMatch ? `${nameMatch[1]}」已存在,请勿重复生成` : '数据已存在,请勿重复操作'
return hint
}
const rpcMsg = parseRpcError(body.error)
if (rpcMsg) return rpcMsg
const mapped = ERROR_CODE_MAP[body.message]
if (mapped) return mapped
if (body.message && !/^[a-z_]+$/.test(body.message)) return body.message
return fallback
}
+151 -29
View File
@@ -1,26 +1,104 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '@/router' import router from '@/router'
import {getRefreshToken,refreshAccessToken} from "@/api/login.js";
// 基础URL import { baseUrl, acsBaseUrl, noAuthUrls as noAuthUrlList, requestTimeout, acsRequestTimeout, TOKEN_KEY, TOKEN_EXPIRE_KEY, USER_INFO_KEY } from '@/config/env.js'
const baseUrl = 'https://apiservertest.s1f.ren'
// const baseUrl = 'https://cloudapi.007yjs.com'
// 检查URL是否需要认证 // 检查URL是否需要认证
const urlNeedAuth = (url) => { const urlNeedAuth = (url) => {
// 这里可以添加不需要认证的URL列表 return !noAuthUrlList.some(noAuthUrl => url.includes(noAuthUrl))
const noAuthUrls = ['/v1/user/login', '/v1/user/check/get_code_img', '/v1/user/register']
return !noAuthUrls.some(noAuthUrl => url.includes(noAuthUrl))
} }
// 检查token是否过期 // 检查token是否过期
const isTokenExpired = () => { const isTokenExpired = () => {
const token = localStorage.getItem('token') const token = localStorage.getItem(TOKEN_KEY)
const expire = localStorage.getItem(TOKEN_EXPIRE_KEY)
if (!token) return true if (!token) return true
// 这里可以添加token过期检查逻辑,如果有JWT可以解析它 // 检查过期时间
// 简单实现,仅检查token是否存在 if (expire) {
return false const expireTime = parseInt(expire) * 1000 // 转换为毫秒
const now = Date.now()
return now >= expireTime
}
// 没有过期时间时,默认认为Token已过期(因为无法验证有效性)
return true
}
// 检查token是否即将过期(5分钟内)
const isTokenExpiringSoon = () => {
const expire = localStorage.getItem(TOKEN_EXPIRE_KEY)
if (!expire) return false
const expireTime = parseInt(expire) * 1000 // 转换为毫秒
const now = Date.now()
const fiveMinutes = 5 * 60 * 1000 // 5分钟
// 如果已过期,返回false(由isTokenExpired处理)
if (now >= expireTime) return false
// 如果在5分钟内过期,返回true
return (expireTime - now) <= fiveMinutes
}
// 正在刷新token的标志
let isRefreshing = false
// 等待刷新token的请求队列
let refreshSubscribers = []
// 添加请求到队列
const subscribeTokenRefresh = (callback) => {
refreshSubscribers.push(callback)
}
// 刷新token后执行队列中的请求
const onTokenRefreshed = (newToken) => {
refreshSubscribers.forEach(callback => callback(newToken))
refreshSubscribers = []
}
// 执行token刷新
const doRefreshToken = async () => {
try {
const domain = window.location.hostname
// 获取交换token
const refreshTokenRes = await getRefreshToken(domain,{
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
})
if (refreshTokenRes.data?.code === 200 && refreshTokenRes.data?.data?.refresh_token) {
// 使用交换token获取新的access token
const newTokenRes = await refreshAccessToken(refreshTokenRes.data.data.refresh_token)
if (newTokenRes.data?.code === 200 && newTokenRes.data?.data?.token) {
const { token, expire } = newTokenRes.data.data
localStorage.setItem(TOKEN_KEY, token)
if (expire) {
localStorage.setItem(TOKEN_EXPIRE_KEY, expire.toString())
}
return token
}
}
// 刷新失败,触发登出逻辑
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(TOKEN_EXPIRE_KEY)
localStorage.removeItem(USER_INFO_KEY)
ElMessage.warning('登录过期,请重新登录')
router.push('/login')
return null
} catch (error) {
console.error('Token刷新失败:', error)
// 刷新失败,触发登出逻辑
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(TOKEN_EXPIRE_KEY)
localStorage.removeItem(USER_INFO_KEY)
ElMessage.warning('登录过期,请重新登录')
router.push('/login')
return null
}
} }
class Request { class Request {
@@ -37,7 +115,7 @@ class Request {
(config) => { (config) => {
// 在发送请求之前做些什么 // 在发送请求之前做些什么
// 例如:添加 token // 例如:添加 token
const token = localStorage.getItem('token') const token = localStorage.getItem(TOKEN_KEY)
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
@@ -93,8 +171,8 @@ class Request {
} }
// DELETE 请求 // DELETE 请求
delete(url,data={}, config = {}) { delete(url, config = {}) {
return this.instance.delete(url,data, config) return this.instance.delete(url, config)
} }
// PATCH 请求 // PATCH 请求
@@ -106,7 +184,7 @@ class Request {
// 创建默认实例 // 创建默认实例
const request = new Request({ const request = new Request({
baseURL: baseUrl, baseURL: baseUrl,
timeout: 50000, timeout: requestTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'
} }
@@ -117,23 +195,67 @@ export const baseURL = baseUrl
export const http2 = axios.create({ export const http2 = axios.create({
baseURL: baseUrl, baseURL: baseUrl,
timeout: 30000, timeout: acsRequestTimeout,
headers: {}, headers: {},
}); });
http2.interceptors.request.use(config => { http2.interceptors.request.use(async config => {
const token = localStorage.getItem('token'); // 假设 token 存储在 localStorage const token = localStorage.getItem(TOKEN_KEY)
if(urlNeedAuth(config.url) && isTokenExpired()){
if (token){ // 检查是否需要认证
localStorage.removeItem('token'); if (urlNeedAuth(config.url)) {
ElMessage.warning('登陆过期,请重新登陆') // 检查token是否已过期
} if (isTokenExpired()) {
router.push('/login') if (token) {
return Promise.reject(); localStorage.removeItem(TOKEN_KEY)
} localStorage.removeItem(TOKEN_EXPIRE_KEY)
config.headers.Authorization = `Bearer ${token}`; localStorage.removeItem(USER_INFO_KEY)
ElMessage.warning('登录过期,请重新登录')
}
router.push('/login')
return Promise.reject(new Error('Token已过期'))
}
// 检查token是否即将过期,进行无感刷新
if (isTokenExpiringSoon() && !isRefreshing) {
isRefreshing = true
try {
const newToken = await doRefreshToken()
if (newToken) {
console.log('Token已无感刷新')
onTokenRefreshed(newToken)
config.headers.Authorization = `Bearer ${newToken}`
} else {
// 刷新失败,doRefreshToken已处理登出逻辑,直接拒绝请求
return Promise.reject(new Error('Token刷新失败'))
}
} catch (error) {
console.error('Token刷新异常:', error)
// 刷新异常,doRefreshToken已处理登出逻辑,直接拒绝请求
return Promise.reject(error)
} finally {
isRefreshing = false
}
} else if (isRefreshing) {
// 正在刷新,等待刷新完成
return new Promise((resolve, reject) => {
subscribeTokenRefresh((newToken) => {
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`
// 重新发送原始请求
resolve(config)
} else {
reject(new Error('Token刷新失败'))
}
})
})
} else {
// 正常情况,直接使用token
config.headers.Authorization = `Bearer ${token}`
}
}
// 不需要认证的请求,不添加token
config.url = config.url
return config return config
}) })
@@ -147,7 +269,7 @@ http2.interceptors.response.use(
} }
const { status } = error.response; const { status } = error.response;
if (status === 401) { if (status === 401) {
localStorage.removeItem('token'); localStorage.removeItem(TOKEN_KEY);
ElMessage.warning('登陆过期,请重新登陆') ElMessage.warning('登陆过期,请重新登陆')
router.push('/login') router.push('/login')
return Promise.reject(); return Promise.reject();
+152 -2
View File
@@ -18,7 +18,7 @@ export const formatDate = (dateStr) => {
return `${year}-${month}-${day} ${hours}:${minutes}` return `${year}-${month}-${day} ${hours}:${minutes}`
} }
/** /**
* 时间格式转 Unix 时间戳(秒级) * 时间格式转 Unix 时间戳(秒级)
* @param {string|Date} time - 输入时间(支持 '2025-10-28 00:00:00'、'2025/10/28'、Date 对象等) * @param {string|Date} time - 输入时间(支持 '2025-10-28 00:00:00'、'2025/10/28'、Date 对象等)
* @returns {number|null} 转换后的毫秒级时间戳(失败返回 null) * @returns {number|null} 转换后的毫秒级时间戳(失败返回 null)
*/ */
@@ -50,10 +50,160 @@ export function timeToTimestamp(time) {
return null; return null;
} }
return Math.floor(timestamp / 1000); // 返回秒级时间戳(如 1751107200000 return Math.floor(timestamp / 1000); // 返回秒级时间戳(如 1751107200000
} }
export function reducenum(num){ export function reducenum(num){
return num / 100 return num / 100
} }
/**
* 分转元显示(返回 ¥xx.xx 或 '-'
*/
export function formatPrice(fen, fallback = '-') {
if (!fen && fen !== 0) return fallback
return '¥' + (fen / 100).toFixed(2)
}
/**
* 元转分(四舍五入取整)
*/
export function yuanToFen(yuan) {
return Math.round((yuan || 0) * 100)
}
/**
* 格式化到期时间(year < 2000 视为永久)
*/
export function formatExpireTime(t) {
if (!t) return '-'
const d = new Date(t)
if (isNaN(d.getTime())) return '-'
if (d.getFullYear() < 2000) return '永久'
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/**
* 将 ISO 格式时间字符串转换为毫秒级时间戳(用于时间选择器)
* @param {string|Date|number} time - 输入时间(支持 ISO 格式字符串如 '2023-11-08T01:10:00+08:00'、Date 对象、时间戳等)
* @returns {number|null} 转换后的毫秒级时间戳(失败或无效时间返回 null)
*/
export function isoToMilliseconds(time) {
// 处理空值
if (!time || time === null || time === undefined) {
return null
}
// 处理特殊的无效时间标识
if (typeof time === 'string' && (time === '0001-01-01T00:00:00Z' || time === '0001-01-01T00:00:00+00:00')) {
return null
}
// 如果已经是数字(时间戳),直接返回
if (typeof time === 'number') {
// 如果是秒级时间戳(小于 13 位),转换为毫秒
if (time < 1000000000000) {
return time * 1000
}
return time
}
// 处理 Date 对象
if (time instanceof Date) {
const timestamp = time.getTime()
return isNaN(timestamp) ? null : timestamp
}
// 处理字符串格式
if (typeof time === 'string') {
try {
const date = new Date(time)
const timestamp = date.getTime()
// 检查是否为有效时间
if (isNaN(timestamp)) {
return null
}
return timestamp
} catch (error) {
console.error('时间转换失败:', error)
return null
}
}
return null
}
/**
* 格式化时间为 "YYYY-MM-DD HH:mm:ss" 格式(用于接口提交)
* @param {string|Date|number} time
* @returns {string} 格式化后的时间字符串,无效时返回 ''
*/
export function formatToApiTime(time) {
if (!time) return ''
const d = time instanceof Date ? time : new Date(time)
if (isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
// ========== 虚拟机状态映射 ==========
const VM_STATUS_MAP = {
pending: { label: '等待中', type: 'info' },
creating: { label: '创建中', type: 'warning' },
ready: { label: '就绪', type: 'success' },
running: { label: '运行中', type: 'success' },
stopped: { label: '已停止', type: 'danger' },
stop: { label: '已停止', type: 'danger' },
shutoff: { label: '已关闭', type: 'danger' },
error: { label: '错误', type: 'danger' },
paused: { label: '已暂停', type: 'warning' },
reboot: { label: '重启中', type: 'warning' },
poweroff: { label: '已关机', type: 'info' },
unknown: { label: '未知', type: 'info' }
}
/**
* 获取虚拟机状态标签文字
*/
export function vmStatusLabel(status) {
return VM_STATUS_MAP[status]?.label || status || '-'
}
/**
* 获取虚拟机状态 Tag 类型
*/
export function vmStatusType(status) {
return VM_STATUS_MAP[status]?.type || 'info'
}
// ========== 磁盘状态映射 ==========
const VOLUME_STATUS_MAP = {
pending: { label: '等待中', type: 'info' },
creating: { label: '创建中', type: 'warning' },
ready: { label: '就绪', type: 'success' },
in_use: { label: '使用中', type: 'success' },
attaching: { label: '挂载中', type: 'warning' },
detaching: { label: '卸载中', type: 'warning' },
resizing: { label: '扩容中', type: 'warning' },
deleting: { label: '删除中', type: 'danger' },
error: { label: '错误', type: 'danger' },
unknown: { label: '未知', type: 'info' }
}
/**
* 获取磁盘状态标签文字
*/
export function volumeStatusLabel(status) {
return VOLUME_STATUS_MAP[status]?.label || status || '-'
}
/**
* 获取磁盘状态 Tag 类型
*/
export function volumeStatusType(status) {
return VOLUME_STATUS_MAP[status]?.type || 'info'
}
+9 -1
View File
@@ -105,16 +105,24 @@ const forgetPassword = () => {
const handleLogin = () => { const handleLogin = () => {
loginFormRef.value?.validate(async valid =>{ loginFormRef.value?.validate(async valid =>{
window.localStorage.removeItem('token') window.localStorage.removeItem('token')
window.localStorage.removeItem('tokenExpire')
window.localStorage.removeItem('userInfo')
if (valid) { if (valid) {
loading.value = true loading.value = true
let resp = await userLogin(loginForm.username, loginForm.password) let resp = await userLogin(loginForm.username, loginForm.password)
console.log("login:",resp) console.log("login:",resp)
loading.value = false loading.value = false
if(resp.code === 200){ if(resp.code === 200){
// 保存token和过期时间
window.localStorage.setItem('token', resp.data.token)
if (resp.data.expire) {
window.localStorage.setItem('tokenExpire', resp.data.expire.toString())
}
window.localStorage.setItem('token',resp.data.token)
let userInfo = await getUserInfo() let userInfo = await getUserInfo()
if(userInfo.data.is_admin){ if(userInfo.data.is_admin){
// 保存用户信息到localStorage
window.localStorage.setItem('userInfo', JSON.stringify(userInfo.data))
await router.push('/dashboard') await router.push('/dashboard')
} else { } else {
ElMessage.warning('你不是管理员,不能登陆到后台控制面板') ElMessage.warning('你不是管理员,不能登陆到后台控制面板')
+3 -3
View File
@@ -666,7 +666,7 @@ const toLoad = async (data) => {
}) })
form.server_id = data form.server_id = data
nowserver_id.value = data nowserver_id.value = data
let res = await getServerPlan({server_id:data,count:100}) let res = await getServerPlan({server_id:data,count:10})
planlist.value = res.data.data.map(item => { planlist.value = res.data.data.map(item => {
return { return {
name: item.name, name: item.name,
@@ -748,7 +748,7 @@ const fetchCategoryList = async (serverId) => {
// 编辑镜像 // 编辑镜像
const handleEdit = async (data) => { const handleEdit = async (data) => {
try { try {
let res = await getServerPlan({server_id: data.server_id,count: 100}) let res = await getServerPlan({server_id: data.server_id,count: 10})
if (res.data && res.data.data) { if (res.data && res.data.data) {
planlist.value = res.data.data.map(item => { planlist.value = res.data.data.map(item => {
return { return {
@@ -874,7 +874,7 @@ const getit = async () => {
// 选择图片 // 选择图片
const picPagin = reactive({ const picPagin = reactive({
count: 50, count: 10,
page: 1, page: 1,
key: '', key: '',
user_type: 1 user_type: 1
+1 -1
View File
@@ -262,7 +262,7 @@ const categoryRules = {
// 素材库相关 // 素材库相关
const picSwitch = ref(false) const picSwitch = ref(false)
const picPagin = reactive({ const picPagin = reactive({
count: 50, count: 10,
page: 1, page: 1,
key: '', key: '',
user_type: 1 user_type: 1
+2 -2
View File
@@ -244,7 +244,7 @@ const showNewCategoryInput = ref(false)
const picSwitch = ref(false) const picSwitch = ref(false)
const picLoading = ref(false) const picLoading = ref(false)
const picPagin = reactive({ const picPagin = reactive({
count: 20, count: 10,
page: 1, page: 1,
key: '', key: '',
user_type: 1 user_type: 1
@@ -314,7 +314,7 @@ const initData = async () => {
// Fallback: fetch list and find item // Fallback: fetch list and find item
const listRes = await getUserMirrorList({ const listRes = await getUserMirrorList({
server_id: serverId.value, server_id: serverId.value,
count: 100, count: 10,
page: 1 page: 1
}) })
if (listRes.data.code === 200) { if (listRes.data.code === 200) {
+21 -7
View File
@@ -467,7 +467,7 @@
<h3 class="tab-title">数据卷列表</h3> <h3 class="tab-title">数据卷列表</h3>
<el-button <el-button
type="primary" type="primary"
@click="showAddVolumeDialog = true" @click="handleAddVolume"
:icon="Plus" :icon="Plus"
:disabled="vmInfo.state != 2" :disabled="vmInfo.state != 2"
> >
@@ -671,8 +671,11 @@
width="500px" width="500px"
> >
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef"> <el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
<el-form-item label="大小(GB)" prop="size"> <el-form-item label="大小" prop="size">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" /> <div class="unit-input-row">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" style="flex:1" />
<el-select v-model="volumeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -693,8 +696,11 @@
> >
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef"> <el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
<el-form-item label="大小(GB)" prop="size"> <el-form-item label="大小" prop="size">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" /> <div class="unit-input-row">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" style="flex:1" />
<el-select v-model="volumeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -1067,6 +1073,7 @@ const showMigrateVolumeDialog = ref(false);
const currentVolumeToEdit = ref(null); const currentVolumeToEdit = ref(null);
const volumeForm = reactive({ const volumeForm = reactive({
size: 10, size: 10,
_sizeUnit: 'GB'
}); });
const volumeFormRef = ref(null); const volumeFormRef = ref(null);
const volumeRules = { const volumeRules = {
@@ -2371,6 +2378,7 @@ const handleAddVolume = () => {
showAddVolumeDialog.value = true; showAddVolumeDialog.value = true;
// 重置表单 // 重置表单
volumeForm.size = 10; volumeForm.size = 10;
volumeForm._sizeUnit = 'GB';
}; };
// 编辑数据卷 // 编辑数据卷
@@ -2378,6 +2386,7 @@ const handleEditVolume = (volume) => {
currentVolumeToEdit.value = volume; currentVolumeToEdit.value = volume;
// 填充表单 // 填充表单
volumeForm.size = volume.size; volumeForm.size = volume.size;
volumeForm._sizeUnit = 'GB';
showEditVolumeDialog.value = true; showEditVolumeDialog.value = true;
}; };
@@ -2404,9 +2413,10 @@ const submitAddVolume = async () => {
if (valid) { if (valid) {
addingVolume.value = true; addingVolume.value = true;
try { try {
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
const res = await addVolume({ const res = await addVolume({
instance_id: route.query.instance_id, instance_id: route.query.instance_id,
size: String(volumeForm.size), size: String(sizeGb),
user_id: user_id.value user_id: user_id.value
}); });
console.log("添加数据卷112",res) console.log("添加数据卷112",res)
@@ -2438,9 +2448,10 @@ const submitEditVolume = async () => {
editingVolume.value = true; editingVolume.value = true;
try { try {
// 这里应该调用修改数据卷的API // 这里应该调用修改数据卷的API
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
const res = await updateVolume({ const res = await updateVolume({
volume_id: currentVolumeToEdit.value.id, volume_id: currentVolumeToEdit.value.id,
size: volumeForm.size size: sizeGb
}); });
console.log("编辑数据卷数据:",res) console.log("编辑数据卷数据:",res)
@@ -2770,4 +2781,7 @@ const fetchServersList = async () => {
font-weight: 600; font-weight: 600;
color: #303133; color: #303133;
} }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-select { width: 90px; flex-shrink: 0; }
</style> </style>
+1 -1
View File
@@ -618,7 +618,7 @@ const fetchPlanList = async () => {
try { try {
const response = await getServerPlan({ const response = await getServerPlan({
server_id: props.ID, server_id: props.ID,
count: 100 count: 10
}); });
if (response && response.data && response.data.code === 200) { if (response && response.data && response.data.code === 200) {
+5 -1
View File
@@ -315,7 +315,11 @@
class="data-table" class="data-table"
> >
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="size" label="空间大小(MB)" width="140" /> <el-table-column prop="size" label="空间大小(MB)" width="140">
<template #default="{ row }">
{{ row.size != null && row.size !== '' ? `${row.size} MB` : '-' }}
</template>
</el-table-column>
<el-table-column prop="mount_path" label="挂载路径" min-width="200" /> <el-table-column prop="mount_path" label="挂载路径" min-width="200" />
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table> </el-table>
+3 -3
View File
@@ -1901,7 +1901,7 @@ const GetSpecs = async () => {
try { try {
let plans = await getServerPlan({ let plans = await getServerPlan({
server_id: route.query.server_id, server_id: route.query.server_id,
count: 30 count: 10
}); });
spec_list.value = plans.data.data; spec_list.value = plans.data.data;
} catch (error) { } catch (error) {
@@ -2407,7 +2407,7 @@ const fetchContainerPlanList = async () => {
try { try {
const response = await getServerPlan({ const response = await getServerPlan({
server_id: route.query.server_id, server_id: route.query.server_id,
count: 100 count: 10
}); });
console.log("获取容器套餐列表1111",response); console.log("获取容器套餐列表1111",response);
@@ -2430,7 +2430,7 @@ const fetchContainerMirrorList = async () => {
containerMirrorLoading.value = true; containerMirrorLoading.value = true;
try { try {
const response = await getMirrorList({server_id: route.query.server_id, page: 1, count: 999,key: '',class_id: ''}); const response = await getMirrorList({server_id: route.query.server_id, page: 1, count: 10,key: '',class_id: ''});
console.log("获取镜像列表1111",response); console.log("获取镜像列表1111",response);
if (response && response.data && response.data.code === 200) { if (response && response.data && response.data.code === 200) {
+598
View File
@@ -0,0 +1,598 @@
<template>
<div class="group-buy-container">
<el-card class="header-card">
<div class="header-actions">
<el-button type="primary" icon="Plus" @click="openCreateDialog">
创建随机队伍
</el-button>
<el-button type="success" icon="Download" @click="handleExport" :loading="exportLoading">
导出成功队伍
</el-button>
<el-button type="info" icon="Refresh" @click="fetchGroupList" :loading="loading">
刷新列表
</el-button>
<el-button type="danger" @click="handleClearAll">
清除所有队伍
</el-button>
<el-button type="warning" @click="showClearUserDialog = true">
清除用户队伍
</el-button>
</div>
</el-card>
<el-card class="table-card">
<el-table :data="groupList" v-loading="loading" stripe border>
<el-table-column prop="id" label="队伍ID" />
<el-table-column prop="name" label="队伍名称" min-width="150" />
<el-table-column prop="currentMembers" label="当前人数" width="100" align="center" />
<el-table-column prop="maxMembers" label="需要人数" width="100" align="center" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'pending'"
type="primary"
size="small"
@click="handleAddRandomUser(row)"
:loading="row.addingUser"
>
添加伪人
</el-button>
<el-button
v-if="row.status === 'success'"
type="success"
size="small"
@click="handleSetOrder(row)"
:loading="row.settingOrder"
>
下发订单
</el-button>
<el-button
type="info"
size="small"
@click="handleViewMembers(row)"
>
查看详情
</el-button>
<el-button
type="danger"
size="small"
@click="handleRemoveGroup(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建随机队伍对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建随机伪人队伍"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" :rules="createRules" ref="createFormRef" label-width="100px">
<el-form-item label="队伍名称" prop="name">
<el-input v-model="createForm.name" placeholder="请输入队伍名称" />
</el-form-item>
<el-form-item label="标签" prop="tag">
<el-select v-model="createForm.tag" placeholder="请选择标签" style="width: 100%" @change="handleTagChange">
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
</el-select>
</el-form-item>
<el-form-item label="拼团类型" prop="groupBuyTypeId">
<el-select v-model="createForm.groupBuyTypeId" placeholder="请先选择标签" :disabled="!createForm.tag" style="width: 100%">
<el-option v-for="item in typeList" :key="item.id" :label="`${item.name} (${item.maxPerson}人)`" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreate" :loading="createLoading">
创建
</el-button>
</template>
</el-dialog>
<!-- 查看成员对话框 -->
<el-dialog
v-model="showMembersDialog"
title="队伍成员列表"
width="700px"
>
<el-table :data="currentMembers" border stripe>
<el-table-column label="头像" width="80" align="center">
<template #default="{ row }">
<el-avatar :size="50" :src="row.cover" v-if="row.cover">
<img src="https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png" />
</el-avatar>
<el-avatar :size="50" v-else>
{{ row.username?.charAt(0) || '?' }}
</el-avatar>
</template>
</el-table-column>
<el-table-column label="用户ID" width="100">
<template #default="{ row }">
<el-link v-if="row.userId" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.userId }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column label="队长" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.teamLeader" type="warning" size="small">队长</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button type="danger" size="small" @click="handleClearUserGroups(row.userId)">清除队伍</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- 清除用户队伍对话框 -->
<el-dialog
v-model="showClearUserDialog"
title="清除指定用户的所有队伍"
width="400px"
:close-on-click-modal="false"
>
<el-form :model="clearUserForm" label-width="80px">
<el-form-item label="用户ID">
<el-input v-model="clearUserForm.userId" placeholder="请输入用户ID" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showClearUserDialog = false">取消</el-button>
<el-button type="danger" @click="handleClearUserSubmit" :loading="clearUserLoading">确认清除</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getGroupBuyList,
getGroupBuyDetail,
addRandomUser,
addRandomGroup,
exportIdcInfo,
setOrder
} from '@/api/admin/activity'
import { getGroupBuyTypeList, getGroupBuyTypeTags, removeGroupBuy, clearAllGroupBuy, clearUserGroupBuy } from '@/api/groupBuy'
const router = useRouter()
//
const loading = ref(false)
const exportLoading = ref(false)
const createLoading = ref(false)
const groupList = ref([])
//
const showCreateDialog = ref(false)
const showMembersDialog = ref(false)
const showClearUserDialog = ref(false)
const currentMembers = ref([])
const clearUserLoading = ref(false)
const clearUserForm = reactive({ userId: '' })
//
const createFormRef = ref(null)
const createForm = reactive({
name: '',
tag: '',
groupBuyTypeId: ''
})
const typeList = ref([])
const tagList = ref([])
const createRules = {
name: [
{ required: true, message: '请输入队伍名称', trigger: 'blur' }
],
tag: [
{ required: true, message: '请选择标签', trigger: 'change' }
],
groupBuyTypeId: [
{ required: true, message: '请选择拼团类型', trigger: 'change' }
]
}
//
const fetchTags = async () => {
try {
const res = await getGroupBuyTypeTags()
if (res.code === 200) {
tagList.value = res.data || []
}
} catch (error) {
console.error('获取标签失败:', error)
}
}
// tag
const fetchTypeListByTag = async (tag) => {
try {
const res = await getGroupBuyTypeList({ page: 1, count: 10, tag })
if (res.code === 200) {
typeList.value = res.data?.data || []
}
} catch (error) {
console.error('获取拼团类型失败:', error)
}
}
// tag
const handleTagChange = (tag) => {
createForm.groupBuyTypeId = ''
typeList.value = []
if (tag) {
fetchTypeListByTag(tag)
}
}
//
const openCreateDialog = () => {
createForm.name = ''
createForm.tag = ''
createForm.groupBuyTypeId = ''
typeList.value = []
fetchTags()
showCreateDialog.value = true
}
//
const fetchGroupList = async () => {
loading.value = true
try {
const res = await getGroupBuyList()
if (res.data.code === 200) {
const allGroups = res.data.data.group_buy_list || []
const lackGroups = res.data.data.lack_group_buy_list || []
const successGroups = res.data.data.success_group_buy_list || []
// ID
const successIds = successGroups.map(g => g.group_buy_id)
const lackIds = lackGroups.map(g => g.group_buy_id)
//
groupList.value = allGroups.map(group => {
let status = 'empty'
if (successIds.includes(group.group_buy_id)) {
status = 'success'
} else if (lackIds.includes(group.group_buy_id)) {
status = 'pending'
}
return {
id: group.group_buy_id,
name: group.name,
type: group.maxPerson === 5 ? 0 : 1,
currentMembers: group.users?.length || 0,
maxMembers: group.maxPerson,
status: status,
createTime: group.createTime || '-',
members: group.users || [],
addingUser: false,
settingOrder: false
}
})
ElMessage.success(`加载成功,共 ${allGroups.length} 个队伍`)
} else {
ElMessage.error(res.message || '获取队伍列表失败')
}
} catch (error) {
console.error('获取队伍列表出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
loading.value = false
}
}
//
const getStatusText = (status) => {
const statusMap = {
'empty': '空队伍',
'pending': '进行中',
'success': '已满员'
}
return statusMap[status] || status
}
//
const getStatusType = (status) => {
const typeMap = {
'empty': 'info',
'pending': 'warning',
'success': 'success'
}
return typeMap[status] || ''
}
//
const handleCreate = async () => {
if (!createFormRef.value) return
await createFormRef.value.validate(async (valid) => {
if (valid) {
createLoading.value = true
console.log("队伍名称:",createForm.name)
try {
const res = await addRandomGroup({ name: createForm.name, group_buy_type_id: String(createForm.groupBuyTypeId) })
console.log('创建队伍响应:', res)
if (res.data.code === 200) {
ElMessage.success(`创建成功!队伍ID: ${res.data.group_buy_id}`)
showCreateDialog.value = false
createForm.name = ''
createForm.groupBuyTypeId = ''
fetchGroupList()
} else {
ElMessage.error(res.message || '创建失败')
}
} catch (error) {
console.error('创建队伍出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
createLoading.value = false
}
}
})
}
//
const handleAddRandomUser = async (row) => {
row.addingUser = true
try {
const res = await addRandomUser(row.id)
if (res.data.code === 200) {
ElMessage.success('添加伪人成功')
fetchGroupList()
} else {
ElMessage.error(res.message || '添加伪人失败')
}
} catch (error) {
console.error('添加伪人出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
row.addingUser = false
}
}
//
const handleSetOrder = async (row) => {
try {
await ElMessageBox.confirm(
'确定要为该队伍下发订单吗?',
'确认操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
row.settingOrder = true
try {
const res = await setOrder(row.id)
if (res.data.code === 200) {
ElMessage.success('订单下发成功')
fetchGroupList()
} else {
ElMessage.error(res.message || '订单下发失败')
}
} catch (error) {
console.error('下发订单出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
row.settingOrder = false
}
} catch {
//
}
}
//
const handleViewMembers = async (row) => {
try {
//
const res = await getGroupBuyDetail(row.id)
if (res && res.data && res.data.code === 200) {
const detail = res.data.data
// 使
currentMembers.value = (detail.users || []).map(member => ({
userId: member.user_id,
username: member.user_name || `用户${member.user_id}`,
cover: member.cover || '',
teamLeader: member.team_leader || false,
idcUid: member.idc_uid || '-',
idcPhone: member.idc_phone || '-'
}))
//
row.name = detail.name
row.currentMembers = detail.users?.length || 0
row.maxMembers = detail.maxPerson
row.members = detail.users || []
} else {
// 使
currentMembers.value = row.members.map(member => ({
userId: member.user_id,
username: member.user_name || `用户${member.user_id}`,
cover: member.cover || '',
teamLeader: member.team_leader || false,
idcUid: member.idc_uid || '-',
idcPhone: member.idc_phone || '-'
}))
}
showMembersDialog.value = true
} catch (error) {
console.error('获取成员信息失败:', error)
// 使
currentMembers.value = row.members.map(member => ({
userId: member.user_id,
username: member.user_name || `用户${member.user_id}`,
cover: member.cover || '',
teamLeader: member.team_leader || false,
idcUid: member.idc_uid || '-',
idcPhone: member.idc_phone || '-'
}))
showMembersDialog.value = true
}
}
//
const handleExport = async () => {
exportLoading.value = true
try {
const res = await exportIdcInfo()
if (res.data && res.data.code === 200) {
// dataJSON
const jsonStr = JSON.stringify(res.data.data, null, 2)
const blob = new Blob([jsonStr], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `拼团成功队伍_${new Date().getTime()}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} else {
ElMessage.error(res.data?.message || '导出失败')
}
} catch (error) {
console.error('导出出错:', error)
ElMessage.error('导出失败,请稍后重试')
} finally {
exportLoading.value = false
}
}
onMounted(() => {
fetchGroupList()
})
//
const handleRemoveGroup = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该队伍吗?', '确认删除', { type: 'warning' })
const res = await removeGroupBuy(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchGroupList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch { /* 取消 */ }
}
//
const handleClearAll = async () => {
try {
await ElMessageBox.confirm('确定要清除所有队伍吗?此操作不可恢复!', '危险操作', { type: 'error', confirmButtonText: '确定清除' })
const res = await clearAllGroupBuy()
if (res.code === 200) {
ElMessage.success('已清除所有队伍')
fetchGroupList()
} else {
ElMessage.error(res.message || '清除失败')
}
} catch { /* 取消 */ }
}
//
const handleClearUserGroups = async (userId) => {
try {
await ElMessageBox.confirm(`确定要清除用户 ${userId} 的所有队伍吗?`, '确认操作', { type: 'warning' })
const res = await clearUserGroupBuy(userId)
if (res.code === 200) {
ElMessage.success('清除成功')
showMembersDialog.value = false
fetchGroupList()
} else {
ElMessage.error(res.message || '清除失败')
}
} catch { /* 取消 */ }
}
//
const handleClearUserSubmit = async () => {
if (!clearUserForm.userId) {
ElMessage.warning('请输入用户ID')
return
}
clearUserLoading.value = true
try {
const res = await clearUserGroupBuy(clearUserForm.userId)
if (res.code === 200) {
ElMessage.success('清除成功')
showClearUserDialog.value = false
clearUserForm.userId = ''
fetchGroupList()
} else {
ElMessage.error(res.message || '清除失败')
}
} catch (error) {
console.error('清除用户队伍失败:', error)
ElMessage.error('网络错误')
} finally {
clearUserLoading.value = false
}
}
</script>
<style scoped>
.group-buy-container {
padding: 20px;
}
.header-card {
margin-bottom: 20px;
}
.header-actions {
display: flex;
gap: 12px;
}
.table-card {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
:deep(.el-table) {
font-size: 14px;
}
:deep(.el-table th) {
background-color: #f5f7fa;
color: #606266;
font-weight: 600;
}
:deep(.el-button) {
transition: all 0.3s;
}
:deep(.el-button:hover) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
+921
View File
@@ -0,0 +1,921 @@
<template>
<div class="group-buy-manage-container">
<el-card class="main-container" shadow="never">
<el-tabs v-model="activeTab" class="group-buy-tabs">
<!-- 拼团活动标签页 -->
<el-tab-pane label="拼团活动" name="activity">
<div class="header-actions">
<el-button type="primary" icon="Plus" @click="openCreateDialog">
创建随机队伍
</el-button>
<el-button type="success" icon="Download" @click="handleExport" :loading="exportLoading">
导出成功队伍
</el-button>
<el-button type="info" icon="Refresh" @click="fetchGroupList" :loading="activityLoading">
刷新列表
</el-button>
<el-button type="danger" @click="handleClearAll">
清除所有队伍
</el-button>
<el-button type="warning" @click="showClearUserDialog = true">
清除用户队伍
</el-button>
</div>
<div class="table-section">
<el-table :data="groupList" v-loading="activityLoading" stripe border>
<el-table-column prop="id" label="队伍ID" />
<el-table-column prop="name" label="队伍名称" min-width="150" />
<el-table-column prop="currentMembers" label="当前人数" width="100" align="center" />
<el-table-column prop="maxMembers" label="需要人数" width="100" align="center" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'pending'"
type="primary"
size="small"
@click="handleAddRandomUser(row)"
:loading="row.addingUser"
>
添加伪人
</el-button>
<el-button
v-if="row.status === 'success'"
type="success"
size="small"
@click="handleSetOrder(row)"
:loading="row.settingOrder"
>
下发订单
</el-button>
<el-button
type="info"
size="small"
@click="handleViewMembers(row)"
>
查看详情
</el-button>
<el-button
type="danger"
size="small"
@click="handleRemoveGroup(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- 拼团类型标签页 -->
<el-tab-pane label="拼团类型" name="type">
<div class="header-actions">
<el-button type="primary" icon="Plus" @click="handleAddType">新增类型</el-button>
<el-select v-model="searchTag" placeholder="请选择标签" style="width: 180px; margin-left: 12px" @change="handleTagChange">
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
</el-select>
<el-input v-model="searchKey" placeholder="关键词搜索" style="width: 200px; margin-left: 12px" clearable :disabled="!searchTag" @keyup.enter="fetchTypeList" />
<el-button type="info" icon="Refresh" @click="fetchTypeList" :loading="typeLoading" :disabled="!searchTag" style="margin-left: 12px">刷新</el-button>
</div>
<div class="table-section">
<el-empty v-if="!searchTag" description="请先选择标签" />
<template v-else>
<el-table :data="typeTableData" v-loading="typeLoading" stripe border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="价格" width="120">
<template #default="{ row }">¥{{ (row.price / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="续费价格" width="120">
<template #default="{ row }">¥{{ (row.renewPrice / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="maxPerson" label="拼团人数" width="100" align="center" />
<el-table-column prop="tag" label="标签" width="120">
<template #default="{ row }">
<el-tag v-if="row.tag" type="info">{{ row.tag }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="过期时间" width="180">
<template #default="{ row }">{{ row.expireTime ? formatTime(row.expireTime) : '永久' }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEditType(row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDeleteType(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="typePage" v-model:page-size="typePageSize" :total="typeTotal" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="fetchTypeList" @current-change="fetchTypeList" />
</div>
</template>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 创建随机队伍对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建随机伪人队伍"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" :rules="createRules" ref="createFormRef" label-width="100px">
<el-form-item label="队伍名称" prop="name">
<el-input v-model="createForm.name" placeholder="请输入队伍名称" />
</el-form-item>
<el-form-item label="标签" prop="tag">
<el-select v-model="createForm.tag" placeholder="请选择标签" style="width: 100%" @change="handleCreateTagChange">
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
</el-select>
</el-form-item>
<el-form-item label="拼团类型" prop="groupBuyTypeId">
<el-select v-model="createForm.groupBuyTypeId" placeholder="请先选择标签" :disabled="!createForm.tag" style="width: 100%">
<el-option v-for="item in createTypeList" :key="item.id" :label="`${item.name} (${item.maxPerson}人)`" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreate" :loading="createLoading">
创建
</el-button>
</template>
</el-dialog>
<!-- 查看成员对话框 -->
<el-dialog
v-model="showMembersDialog"
title="队伍成员列表"
width="700px"
>
<el-table :data="currentMembers" border stripe>
<el-table-column label="头像" width="80" align="center">
<template #default="{ row }">
<el-avatar :size="50" :src="row.cover" v-if="row.cover">
<img src="https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png" />
</el-avatar>
<el-avatar :size="50" v-else>
{{ row.username?.charAt(0) || '?' }}
</el-avatar>
</template>
</el-table-column>
<el-table-column label="用户ID" width="100">
<template #default="{ row }">
<el-link v-if="row.userId" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.userId }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column label="队长" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.teamLeader" type="warning" size="small">队长</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button type="danger" size="small" @click="handleClearUserGroups(row.userId)">清除队伍</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- 清除用户队伍对话框 -->
<el-dialog
v-model="showClearUserDialog"
title="清除指定用户的所有队伍"
width="400px"
:close-on-click-modal="false"
>
<el-form :model="clearUserForm" label-width="80px">
<el-form-item label="用户ID">
<el-input v-model="clearUserForm.userId" placeholder="请输入用户ID" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showClearUserDialog = false">取消</el-button>
<el-button type="danger" @click="handleClearUserSubmit" :loading="clearUserLoading">确认清除</el-button>
</template>
</el-dialog>
<!-- 拼团类型表单对话框 -->
<el-dialog v-model="typeDialogVisible" :title="isEditType ? '编辑拼团类型' : '新增拼团类型'" width="500px" :close-on-click-modal="false">
<el-form :model="typeForm" :rules="typeRules" ref="typeFormRef" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="typeForm.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="价格" prop="price">
<div class="unit-input-row">
<el-input-number v-model="typeForm.price" :min="0" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="续费价格" prop="renewPrice">
<div class="unit-input-row">
<el-input-number v-model="typeForm.renewPrice" :min="0" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="拼团人数" prop="maxPerson">
<el-input-number v-model="typeForm.maxPerson" :min="2" :max="100" style="width: 100%" />
</el-form-item>
<el-form-item label="标签" prop="tag">
<el-select v-model="typeForm.tag" placeholder="选择标签" filterable allow-create style="width: 100%">
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
</el-select>
</el-form-item>
<el-form-item label="过期时间" prop="expireTime">
<el-date-picker v-model="typeForm.expireTime" type="datetime" placeholder="选择过期时间" style="width: 100%" />
</el-form-item>
<el-form-item label="备注字段">
<div class="note-fields-container">
<el-button type="primary" size="small" @click="addNoteField" style="margin-bottom: 10px">+ 添加字段</el-button>
<el-table :data="typeForm.noteFields" border size="small" v-if="typeForm.noteFields.length">
<el-table-column label="名称" min-width="120">
<template #default="{ row }">
<el-input v-model="row.label" placeholder="如:内存" size="small" />
</template>
</el-table-column>
<el-table-column label="默认值" min-width="120">
<template #default="{ row }">
<el-input v-model="row.defaultValue" placeholder="如:20GB" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<template #default="{ $index }">
<el-button type="danger" size="small" link @click="removeNoteField($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="typeDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleTypeSubmit" :loading="typeSubmitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getGroupBuyList,
getGroupBuyDetail,
addRandomUser,
addRandomGroup,
exportIdcInfo,
setOrder
} from '@/api/admin/activity'
import {
getGroupBuyTypeList,
getGroupBuyTypeTags,
removeGroupBuy,
clearAllGroupBuy,
clearUserGroupBuy,
addGroupBuyType,
updateGroupBuyType,
deleteGroupBuyType
} from '@/api/groupBuy'
const route = useRoute()
const router = useRouter()
//
const activeTab = ref('activity')
// ==================== ====================
const activityLoading = ref(false)
const exportLoading = ref(false)
const createLoading = ref(false)
const groupList = ref([])
const showCreateDialog = ref(false)
const showMembersDialog = ref(false)
const showClearUserDialog = ref(false)
const currentMembers = ref([])
const clearUserLoading = ref(false)
const clearUserForm = reactive({ userId: '' })
const createFormRef = ref(null)
const createForm = reactive({
name: '',
tag: '',
groupBuyTypeId: ''
})
const createTypeList = ref([])
const tagList = ref([])
const createRules = {
name: [
{ required: true, message: '请输入队伍名称', trigger: 'blur' }
],
tag: [
{ required: true, message: '请选择标签', trigger: 'change' }
],
groupBuyTypeId: [
{ required: true, message: '请选择拼团类型', trigger: 'change' }
]
}
// ==================== ====================
const typeLoading = ref(false)
const typeTableData = ref([])
const typeTotal = ref(0)
const typePage = ref(1)
const typePageSize = ref(10)
const searchKey = ref('')
const searchTag = ref('')
const typeDialogVisible = ref(false)
const isEditType = ref(false)
const typeSubmitLoading = ref(false)
const typeFormRef = ref(null)
const typeForm = reactive({
id: '',
name: '',
price: 0,
renewPrice: 0,
maxPerson: 5,
tag: '',
expireTime: null,
noteFields: []
})
const typeRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
renewPrice: [{ required: true, message: '请输入续费价格', trigger: 'blur' }],
maxPerson: [{ required: true, message: '请输入拼团人数', trigger: 'blur' }],
tag: [{ required: true, message: '请选择标签', trigger: 'change' }],
expireTime: [{ required: true, message: '请选择过期时间', trigger: 'change' }]
}
// ==================== ====================
const formatTime = (timeStr) => {
if (!timeStr) return '-'
return new Date(timeStr).toLocaleString('zh-CN')
}
//
const fetchTags = async () => {
try {
const res = await getGroupBuyTypeTags()
if (res.code === 200) {
tagList.value = res.data || []
}
} catch (error) {
console.error('获取标签失败:', error)
}
}
// ==================== ====================
// tag
const fetchCreateTypeListByTag = async (tag) => {
try {
const res = await getGroupBuyTypeList({ page: 1, count: 10, tag })
if (res.code === 200) {
createTypeList.value = res.data?.data || []
}
} catch (error) {
console.error('获取拼团类型失败:', error)
}
}
// tag
const handleCreateTagChange = (tag) => {
createForm.groupBuyTypeId = ''
createTypeList.value = []
if (tag) {
fetchCreateTypeListByTag(tag)
}
}
//
const openCreateDialog = () => {
createForm.name = ''
createForm.tag = ''
createForm.groupBuyTypeId = ''
createTypeList.value = []
fetchTags()
showCreateDialog.value = true
}
//
const fetchGroupList = async () => {
activityLoading.value = true
try {
const res = await getGroupBuyList()
if (res.data.code === 200) {
const allGroups = res.data.data.group_buy_list || []
const lackGroups = res.data.data.lack_group_buy_list || []
const successGroups = res.data.data.success_group_buy_list || []
const successIds = successGroups.map(g => g.group_buy_id)
const lackIds = lackGroups.map(g => g.group_buy_id)
groupList.value = allGroups.map(group => {
let status = 'empty'
if (successIds.includes(group.group_buy_id)) {
status = 'success'
} else if (lackIds.includes(group.group_buy_id)) {
status = 'pending'
}
return {
id: group.group_buy_id,
name: group.name,
type: group.maxPerson === 5 ? 0 : 1,
currentMembers: group.users?.length || 0,
maxMembers: group.maxPerson,
status: status,
createTime: group.createTime || '-',
members: group.users || [],
addingUser: false,
settingOrder: false
}
})
ElMessage.success(`加载成功,共 ${allGroups.length} 个队伍`)
} else {
ElMessage.error(res.message || '获取队伍列表失败')
}
} catch (error) {
console.error('获取队伍列表出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
activityLoading.value = false
}
}
const getStatusText = (status) => {
const statusMap = {
'empty': '空队伍',
'pending': '进行中',
'success': '已满员'
}
return statusMap[status] || status
}
const getStatusType = (status) => {
const typeMap = {
'empty': 'info',
'pending': 'warning',
'success': 'success'
}
return typeMap[status] || ''
}
const handleCreate = async () => {
if (!createFormRef.value) return
await createFormRef.value.validate(async (valid) => {
if (valid) {
createLoading.value = true
try {
const res = await addRandomGroup({ name: createForm.name, group_buy_type_id: String(createForm.groupBuyTypeId) })
if (res.data.code === 200) {
ElMessage.success(`创建成功!队伍ID: ${res.data.group_buy_id}`)
showCreateDialog.value = false
createForm.name = ''
createForm.groupBuyTypeId = ''
fetchGroupList()
} else {
ElMessage.error(res.message || '创建失败')
}
} catch (error) {
console.error('创建队伍出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
createLoading.value = false
}
}
})
}
const handleAddRandomUser = async (row) => {
row.addingUser = true
try {
const res = await addRandomUser(row.id)
if (res.data.code === 200) {
ElMessage.success('添加伪人成功')
fetchGroupList()
} else {
ElMessage.error(res.message || '添加伪人失败')
}
} catch (error) {
console.error('添加伪人出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
row.addingUser = false
}
}
const handleSetOrder = async (row) => {
try {
await ElMessageBox.confirm(
'确定要为该队伍下发订单吗?',
'确认操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
row.settingOrder = true
try {
const res = await setOrder(row.id)
if (res.data.code === 200) {
ElMessage.success('订单下发成功')
fetchGroupList()
} else {
ElMessage.error(res.message || '订单下发失败')
}
} catch (error) {
console.error('下发订单出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
row.settingOrder = false
}
} catch {
//
}
}
const handleViewMembers = async (row) => {
try {
const res = await getGroupBuyDetail(row.id)
if (res && res.data && res.data.code === 200) {
const detail = res.data.data
currentMembers.value = (detail.users || []).map(member => ({
userId: member.user_id,
username: member.user_name || `用户${member.user_id}`,
cover: member.cover || '',
teamLeader: member.team_leader || false,
idcUid: member.idc_uid || '-',
idcPhone: member.idc_phone || '-'
}))
row.name = detail.name
row.currentMembers = detail.users?.length || 0
row.maxMembers = detail.maxPerson
row.members = detail.users || []
} else {
currentMembers.value = row.members.map(member => ({
userId: member.user_id,
username: member.user_name || `用户${member.user_id}`,
cover: member.cover || '',
teamLeader: member.team_leader || false,
idcUid: member.idc_uid || '-',
idcPhone: member.idc_phone || '-'
}))
}
showMembersDialog.value = true
} catch (error) {
console.error('获取成员信息失败:', error)
currentMembers.value = row.members.map(member => ({
userId: member.user_id,
username: member.user_name || `用户${member.user_id}`,
cover: member.cover || '',
teamLeader: member.team_leader || false,
idcUid: member.idc_uid || '-',
idcPhone: member.idc_phone || '-'
}))
showMembersDialog.value = true
}
}
const handleExport = async () => {
exportLoading.value = true
try {
const res = await exportIdcInfo()
if (res.data && res.data.code === 200) {
const jsonStr = JSON.stringify(res.data.data, null, 2)
const blob = new Blob([jsonStr], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `拼团成功队伍_${new Date().getTime()}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} else {
ElMessage.error(res.data?.message || '导出失败')
}
} catch (error) {
console.error('导出出错:', error)
ElMessage.error('导出失败,请稍后重试')
} finally {
exportLoading.value = false
}
}
const handleRemoveGroup = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该队伍吗?', '确认删除', { type: 'warning' })
const res = await removeGroupBuy(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchGroupList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch { /* 取消 */ }
}
const handleClearAll = async () => {
try {
await ElMessageBox.confirm('确定要清除所有队伍吗?此操作不可恢复!', '危险操作', { type: 'error', confirmButtonText: '确定清除' })
const res = await clearAllGroupBuy()
if (res.code === 200) {
ElMessage.success('已清除所有队伍')
fetchGroupList()
} else {
ElMessage.error(res.message || '清除失败')
}
} catch { /* 取消 */ }
}
const handleClearUserGroups = async (userId) => {
try {
await ElMessageBox.confirm(`确定要清除用户 ${userId} 的所有队伍吗?`, '确认操作', { type: 'warning' })
const res = await clearUserGroupBuy(userId)
if (res.code === 200) {
ElMessage.success('清除成功')
showMembersDialog.value = false
fetchGroupList()
} else {
ElMessage.error(res.message || '清除失败')
}
} catch { /* 取消 */ }
}
const handleClearUserSubmit = async () => {
if (!clearUserForm.userId) {
ElMessage.warning('请输入用户ID')
return
}
clearUserLoading.value = true
try {
const res = await clearUserGroupBuy(clearUserForm.userId)
if (res.code === 200) {
ElMessage.success('清除成功')
showClearUserDialog.value = false
clearUserForm.userId = ''
fetchGroupList()
} else {
ElMessage.error(res.message || '清除失败')
}
} catch (error) {
console.error('清除用户队伍失败:', error)
ElMessage.error('网络错误')
} finally {
clearUserLoading.value = false
}
}
// ==================== ====================
const fetchTypeList = async () => {
typeLoading.value = true
try {
const res = await getGroupBuyTypeList({ page: typePage.value, count: typePageSize.value, key: searchKey.value || undefined, tag: searchTag.value || undefined })
if (res.code === 200) {
typeTableData.value = res.data?.data || []
typeTotal.value = res.data?.all_count || 0
} else {
ElMessage.error(res.data.message || '获取列表失败')
}
} catch (error) {
console.error('获取列表失败:', error)
ElMessage.error('网络错误')
} finally {
typeLoading.value = false
}
}
const handleTagChange = (tag) => {
typePage.value = 1
searchKey.value = ''
typeTableData.value = []
typeTotal.value = 0
if (tag) {
fetchTypeList()
}
}
const handleAddType = () => {
isEditType.value = false
Object.assign(typeForm, { id: '', name: '', price: 0, renewPrice: 0, maxPerson: 5, tag: '', expireTime: null, noteFields: [] })
typeDialogVisible.value = true
}
const handleEditType = (row) => {
isEditType.value = true
let noteFields = []
try {
noteFields = row.note ? JSON.parse(row.note) : []
} catch { noteFields = [] }
Object.assign(typeForm, { id: row.id, name: row.name, price: row.price, renewPrice: row.renewPrice, maxPerson: row.maxPerson, tag: row.tag || '', expireTime: row.expireTime || null, noteFields })
typeDialogVisible.value = true
}
const addNoteField = () => {
typeForm.noteFields.push({ label: '', defaultValue: '' })
}
const removeNoteField = (index) => {
typeForm.noteFields.splice(index, 1)
}
const handleTypeSubmit = async () => {
if (!typeFormRef.value) return
await typeFormRef.value.validate(async (valid) => {
if (!valid) return
typeSubmitLoading.value = true
try {
const noteJson = JSON.stringify(typeForm.noteFields.filter(f => f.label))
const data = {
name: typeForm.name,
price: String(typeForm.price),
renew_price: String(typeForm.renewPrice),
max_person: String(typeForm.maxPerson),
tag: typeForm.tag,
expire_time: typeForm.expireTime ? Math.floor(new Date(typeForm.expireTime).getTime() / 1000) : 0,
note: noteJson
}
if (isEditType.value) data.id = String(typeForm.id)
const res = isEditType.value ? await updateGroupBuyType(data) : await addGroupBuyType(data)
if (res.code === 200) {
ElMessage.success(isEditType.value ? '修改成功' : '新增成功')
typeDialogVisible.value = false
fetchTypeList()
fetchTags()
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('网络错误')
} finally {
typeSubmitLoading.value = false
}
})
}
const handleDeleteType = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该拼团类型吗?', '确认删除', { type: 'warning' })
const res = await deleteGroupBuyType(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchTypeList()
fetchTags()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch { /* 取消 */ }
}
//
watch(activeTab, (newVal) => {
if (newVal === 'activity') {
fetchGroupList()
} else if (newVal === 'type') {
fetchTags()
}
})
//
onMounted(() => {
fetchGroupList()
fetchTags()
//
if (route.query.tab === 'type') {
activeTab.value = 'type'
}
})
</script>
<style scoped>
.group-buy-manage-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
.group-buy-tabs {
padding: 0 20px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
margin: 0 -20px;
padding-left: 20px;
padding-right: 20px;
}
.table-section {
padding: 20px 0;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.note-fields-container {
width: 100%;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) {
padding: 0;
}
:deep(.el-tabs__header) {
margin: 0;
padding: 0 0 0 0;
border-bottom: 1px solid #e1e8ed;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
:deep(.el-tabs__item) {
padding: 0 20px;
height: 50px;
line-height: 50px;
font-size: 14px;
font-weight: 500;
}
:deep(.el-tabs__item.is-active) {
color: #2c3e50;
font-weight: 600;
}
:deep(.el-tabs__active-bar) {
background-color: #2c3e50;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
+268
View File
@@ -0,0 +1,268 @@
<template>
<div class="group-buy-type-container">
<el-card class="header-card">
<div class="header-actions">
<el-button type="primary" icon="Plus" @click="handleAdd">新增类型</el-button>
<el-select v-model="searchTag" placeholder="请选择标签" style="width: 180px; margin-left: 12px" @change="handleTagChange">
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
</el-select>
<el-input v-model="searchKey" placeholder="关键词搜索" style="width: 200px; margin-left: 12px" clearable :disabled="!searchTag" @keyup.enter="fetchList" />
<el-button type="info" icon="Refresh" @click="fetchList" :loading="loading" :disabled="!searchTag" style="margin-left: 12px">刷新</el-button>
</div>
</el-card>
<el-card class="table-card">
<el-empty v-if="!searchTag" description="请先选择标签" />
<template v-else>
<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="价格" width="120">
<template #default="{ row }">¥{{ (row.price / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="续费价格" width="120">
<template #default="{ row }">¥{{ (row.renewPrice / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="maxPerson" label="拼团人数" width="100" align="center" />
<el-table-column prop="tag" label="标签" width="120">
<template #default="{ row }">
<el-tag v-if="row.tag" type="info">{{ row.tag }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="过期时间" width="180">
<template #default="{ row }">{{ row.expireTime ? formatTime(row.expireTime) : '永久' }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="fetchList" @current-change="fetchList" />
</div>
</template>
</el-card>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑拼团类型' : '新增拼团类型'" width="500px" :close-on-click-modal="false">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="价格" prop="price">
<div class="unit-input-row">
<el-input-number v-model="form.price" :min="0" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="续费价格" prop="renewPrice">
<div class="unit-input-row">
<el-input-number v-model="form.renewPrice" :min="0" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="拼团人数" prop="maxPerson">
<el-input-number v-model="form.maxPerson" :min="2" :max="100" style="width: 100%" />
</el-form-item>
<el-form-item label="标签" prop="tag">
<el-select v-model="form.tag" placeholder="选择标签" filterable allow-create style="width: 100%">
<el-option v-for="tag in tagList" :key="tag" :label="tag" :value="tag" />
</el-select>
</el-form-item>
<el-form-item label="过期时间" prop="expireTime">
<el-date-picker v-model="form.expireTime" type="datetime" placeholder="选择过期时间" style="width: 100%" />
</el-form-item>
<el-form-item label="备注字段">
<div class="note-fields-container">
<el-button type="primary" size="small" @click="addNoteField" style="margin-bottom: 10px">+ 添加字段</el-button>
<el-table :data="form.noteFields" border size="small" v-if="form.noteFields.length">
<el-table-column label="名称" min-width="120">
<template #default="{ row }">
<el-input v-model="row.label" placeholder="如:内存" size="small" />
</template>
</el-table-column>
<el-table-column label="默认值" min-width="120">
<template #default="{ row }">
<el-input v-model="row.defaultValue" placeholder="如:20GB" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<template #default="{ $index }">
<el-button type="danger" size="small" link @click="removeNoteField($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getGroupBuyTypeList, getGroupBuyTypeTags, addGroupBuyType, updateGroupBuyType, deleteGroupBuyType } from '@/api/groupBuy'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const searchKey = ref('')
const searchTag = ref('')
const tagList = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref(null)
const form = reactive({ id: '', name: '', price: 0, renewPrice: 0, maxPerson: 5, tag: '', expireTime: null, noteFields: [] })
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
renewPrice: [{ required: true, message: '请输入续费价格', trigger: 'blur' }],
maxPerson: [{ required: true, message: '请输入拼团人数', trigger: 'blur' }],
tag: [{ required: true, message: '请选择标签', trigger: 'change' }],
expireTime: [{ required: true, message: '请选择过期时间', trigger: 'change' }]
}
//
const addNoteField = () => {
form.noteFields.push({ label: '', defaultValue: '' })
}
//
const removeNoteField = (index) => {
form.noteFields.splice(index, 1)
}
const formatTime = (timeStr) => {
if (!timeStr) return '-'
return new Date(timeStr).toLocaleString('zh-CN')
}
const fetchList = async () => {
loading.value = true
try {
const res = await getGroupBuyTypeList({ page: page.value, count: pageSize.value, key: searchKey.value || undefined, tag: searchTag.value || undefined })
console.log("获取拼团类型列表数据:",res)
if (res.code === 200) {
tableData.value = res.data?.data || []
total.value = res.data?.all_count || 0
} else {
ElMessage.error(res.data.message || '获取列表失败')
}
} catch (error) {
console.error('获取列表失败:', error)
ElMessage.error('网络错误')
} finally {
loading.value = false
}
}
const fetchTags = async () => {
try {
const res = await getGroupBuyTypeTags()
if (res.code === 200) tagList.value = res.data || []
} catch (error) {
console.error('获取标签失败:', error)
}
}
//
const handleTagChange = (tag) => {
page.value = 1
searchKey.value = ''
tableData.value = []
total.value = 0
if (tag) {
fetchList()
}
}
const handleAdd = () => {
isEdit.value = false
Object.assign(form, { id: '', name: '', price: 0, renewPrice: 0, maxPerson: 5, tag: '', expireTime: null, noteFields: [] })
dialogVisible.value = true
}
const handleEdit = (row) => {
isEdit.value = true
let noteFields = []
try {
noteFields = row.note ? JSON.parse(row.note) : []
} catch { noteFields = [] }
Object.assign(form, { id: row.id, name: row.name, price: row.price, renewPrice: row.renewPrice, maxPerson: row.maxPerson, tag: row.tag || '', expireTime: row.expireTime || null, noteFields })
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const noteJson = JSON.stringify(form.noteFields.filter(f => f.label))
const data = {
name: form.name,
price: String(form.price),
renew_price: String(form.renewPrice),
max_person: String(form.maxPerson),
tag: form.tag,
expire_time: form.expireTime ? Math.floor(new Date(form.expireTime).getTime() / 1000) : 0,
note: noteJson
}
if (isEdit.value) data.id = String(form.id)
const res = isEdit.value ? await updateGroupBuyType(data) : await addGroupBuyType(data)
if (res.code === 200) {
ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
dialogVisible.value = false
fetchList()
fetchTags()
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('网络错误')
} finally {
submitLoading.value = false
}
})
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该拼团类型吗?', '确认删除', { type: 'warning' })
const res = await deleteGroupBuyType(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
fetchTags()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch { /* 取消 */ }
}
onMounted(() => { fetchTags() })
</script>
<style scoped>
.group-buy-type-container { padding: 20px; }
.header-card { margin-bottom: 20px; }
.header-actions { display: flex; align-items: center; }
.table-card { box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); }
.pagination-wrapper { margin-top: 20px; display: flex; justify-content: flex-end; }
.note-fields-container { width: 100%; }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
+10 -2
View File
@@ -67,7 +67,12 @@
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }" :header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column prop="container_id" label="容器ID" width="280" show-overflow-tooltip /> <el-table-column label="容器ID" width="280" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.container_id" type="primary" :underline="false" @click="router.push({ path: '/servers/container', query: { container_id: row.container_id } })">{{ row.container_id }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="url" label="访问地址" min-width="200" show-overflow-tooltip> <el-table-column prop="url" label="访问地址" min-width="200" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<el-link :href="row.url" target="_blank" type="primary" v-if="row.url"> <el-link :href="row.url" target="_blank" type="primary" v-if="row.url">
@@ -146,6 +151,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted, computed } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { import {
Refresh, Download, Search, Delete, View, Warning, Refresh, Download, Search, Delete, View, Warning,
@@ -159,6 +165,8 @@ import {
} from '@/utils/acs/audit' } from '@/utils/acs/audit'
const router = useRouter()
// //
const queryParams = reactive({ const queryParams = reactive({
domain: '', domain: '',
@@ -419,7 +427,7 @@ const getFullStatsData = async () => {
// //
const statsParams = { const statsParams = {
page: 1, page: 1,
count: 1000, // count: 10, //
server_id: '', server_id: '',
user_id: '', user_id: '',
key: queryParams.domain || '' key: queryParams.domain || ''
+9 -1
View File
@@ -35,7 +35,12 @@
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }" :header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column prop="container_id" label="容器ID" width="280" show-overflow-tooltip /> <el-table-column label="容器ID" width="280" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.container_id" type="primary" :underline="false" @click="router.push({ path: '/servers/container', query: { container_id: row.container_id } })">{{ row.container_id }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="url" label="违规地址" min-width="200" show-overflow-tooltip> <el-table-column prop="url" label="违规地址" min-width="200" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<el-link :href="row.url" target="_blank" type="danger" v-if="row.url"> <el-link :href="row.url" target="_blank" type="danger" v-if="row.url">
@@ -195,6 +200,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { import {
Refresh, Download, Search, Delete, View, Warning, Refresh, Download, Search, Delete, View, Warning,
@@ -208,6 +214,8 @@ import {
} from '@/utils/acs/audit' } from '@/utils/acs/audit'
const router = useRouter()
// //
const queryParams = reactive({ const queryParams = reactive({
domain: '', domain: '',
+485 -25
View File
@@ -13,7 +13,7 @@
<el-icon><component :is="card.icon" /></el-icon> <el-icon><component :is="card.icon" /></el-icon>
</div> </div>
</div> </div>
<div class="card-footer"> <!-- <div class="card-footer">
<span>较昨日</span> <span>较昨日</span>
<span :class="card.trend > 0 ? 'up' : 'down'"> <span :class="card.trend > 0 ? 'up' : 'down'">
{{ card.trend > 0 ? '+' : '' }}{{ card.trend }}% {{ card.trend > 0 ? '+' : '' }}{{ card.trend }}%
@@ -23,13 +23,13 @@
</div> </div>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-inner" :style="{width: card.progress + '%', background: card.progressColor}"></div> <div class="progress-inner" :style="{width: card.progress + '%', background: card.progressColor}"></div>
</div> </div> -->
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<!-- 图表部分 --> <!-- 图表部分 -->
<el-row :gutter="24" class="chart-row"> <!-- <el-row :gutter="24" class="chart-row">
<el-col :xs="24" :sm="24" :md="24" :lg="16" :xl="16"> <el-col :xs="24" :sm="24" :md="24" :lg="16" :xl="16">
<el-card class="chart-card" shadow="hover"> <el-card class="chart-card" shadow="hover">
<div class="chart-header"> <div class="chart-header">
@@ -116,10 +116,137 @@
<div class="chart-container" ref="customerChartRef"></div> <div class="chart-container" ref="customerChartRef"></div>
</el-card> </el-card>
</el-col> </el-col>
</el-row> -->
<!-- 数据列表区域 -->
<el-row :gutter="24" class="list-row">
<!-- 最近用户 -->
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
<el-card class="list-card" shadow="hover" v-loading="listLoading">
<div class="card-header-custom">
<div class="header-left">
<el-icon class="header-icon user-icon"><User /></el-icon>
<h3>最近用户</h3>
</div>
<el-link type="primary" :underline="false" class="view-all" @click="goToUserList">
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
</el-link>
</div>
<div class="list-content">
<div v-if="recentUsers.length === 0" class="empty-tip">暂无数据</div>
<div v-for="item in recentUsers" :key="item.id" class="list-item" @click="goToUserDetail(item.id)">
<div class="item-main">
<div class="item-title">{{ item.name }}</div>
<div class="item-sub">{{ item.email }}</div>
</div>
<div class="item-extra">
<div class="item-id">ID: {{ item.id }}</div>
<div class="item-time">{{ formatDate(item.createdAt) }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- 最近订单 -->
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
<el-card class="list-card" shadow="hover" v-loading="listLoading">
<div class="card-header-custom">
<div class="header-left">
<el-icon class="header-icon order-icon"><ShoppingCart /></el-icon>
<h3>最近订单</h3>
</div>
<el-link type="primary" :underline="false" class="view-all" @click="goToOrderList">
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
</el-link>
</div>
<div class="list-content">
<div v-if="recentOrders.length === 0" class="empty-tip">暂无数据</div>
<div v-for="item in recentOrders" :key="item.id" class="list-item" @click="showOrderDetail(item)">
<div class="item-main">
<div class="item-title">{{ item.name }}</div>
<div class="item-sub">用户ID: {{ item.userId }}</div>
</div>
<div class="item-extra">
<el-tag :type="getOrderStatusType(item.state)" size="small">
{{ getOrderStatusText(item.state) }}
</el-tag>
<div class="item-price">¥{{ (item.price / 100).toFixed(2) }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- 最近工单 -->
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
<el-card class="list-card" shadow="hover" v-loading="listLoading">
<div class="card-header-custom">
<div class="header-left">
<el-icon class="header-icon ticket-icon"><Tickets /></el-icon>
<h3>最近工单</h3>
</div>
<el-link type="primary" :underline="false" class="view-all" @click="goToTicketList">
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
</el-link>
</div>
<div class="list-content">
<div v-if="recentTickets.length === 0" class="empty-tip">暂无数据</div>
<div v-for="item in recentTickets" :key="item.id" class="list-item" @click="goToTicketDetail(item.id)">
<div class="item-main">
<div class="item-title">{{ item.title || '工单 #' + item.id }}</div>
<div class="item-sub">用户ID: {{ item.userId }}</div>
</div>
<div class="item-extra">
<el-tag :type="getTicketStatusType(item.status)" size="small">
{{ getTicketStatusText(item.status) }}
</el-tag>
<div class="item-time">{{ formatDate(item.createdAt) }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row> </el-row>
<!-- 订单详情弹窗 -->
<el-dialog
v-model="orderDetailVisible"
title="订单详情"
width="600px"
append-to-body
class="order-detail-dialog"
>
<el-descriptions :column="2" border v-if="currentOrder">
<el-descriptions-item label="订单ID">{{ currentOrder.id }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ currentOrder.name }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentOrder.userId }}</el-descriptions-item>
<el-descriptions-item label="商品ID">{{ currentOrder.commodityId }}</el-descriptions-item>
<el-descriptions-item label="订单金额">
<span class="detail-price">¥{{ (currentOrder.price / 100).toFixed(2) }}</span>
</el-descriptions-item>
<el-descriptions-item label="续费价格">
<span class="detail-renew-price">¥{{ (currentOrder.renewPrice / 100).toFixed(2) }}</span>
</el-descriptions-item>
<el-descriptions-item label="数量">{{ currentOrder.payNum }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag :type="getOrderStatusType(currentOrder.state)">
{{ getOrderStatusText(currentOrder.state) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="支付方式">{{ currentOrder.payType || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentOrder.createdAt) }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<div class="dialog-footer">
<el-button @click="orderDetailVisible = false">关闭</el-button>
<el-button type="primary" @click="goToOrderList">查看全部订单</el-button>
</div>
</template>
</el-dialog>
<!-- 最近活动和待办事项 --> <!-- 最近活动和待办事项 -->
<el-row :gutter="24" class="activity-row"> <!-- <el-row :gutter="24" class="activity-row">
<el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12"> <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
<el-card class="activity-card" shadow="hover"> <el-card class="activity-card" shadow="hover">
<div class="card-header-custom"> <div class="card-header-custom">
@@ -207,28 +334,45 @@
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { import {
User, ShoppingCart, Money, DataAnalysis, User, ShoppingCart, Money, DataAnalysis,
MoreFilled, ArrowUp, ArrowDown, Right, MoreFilled, ArrowUp, ArrowDown, Right,
Download, Refresh, Check, Delete, Plus, Download, Refresh, Check, Delete, Plus,
Setting, Calendar, Filter Setting, Calendar, Filter, Tickets, View
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import Qrcode from '@/components/Qrcode.vue' import Qrcode from '@/components/Qrcode.vue'
import {useUserStore} from "@/store/userStore.js"; import {useUserStore} from "@/store/userStore.js";
import { getUserList } from '@/api/admin/user'
import { getOrderList } from '@/api/admin/order'
import { getTicketCount, getTickerList } from '@/api/ticket'
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter()
//
const userCount = ref(0)
const orderCount = ref(0)
const ticketCount = ref(0)
//
const recentUsers = ref([])
const recentOrders = ref([])
const recentTickets = ref([])
const listLoading = ref(false)
// //
const statisticsCards = ref([ const statisticsCards = computed(() => [
{ {
title: '访问量', title: '用户量',
value: '8,846', value: userCount.value.toLocaleString(),
icon: 'User', icon: 'User',
trend: 12.5, trend: 12.5,
class: 'visitors', class: 'visitors',
@@ -237,7 +381,7 @@ const statisticsCards = ref([
}, },
{ {
title: '订单量', title: '订单量',
value: '1,257', value: orderCount.value.toLocaleString(),
icon: 'ShoppingCart', icon: 'ShoppingCart',
trend: 5.2, trend: 5.2,
class: 'orders', class: 'orders',
@@ -245,9 +389,9 @@ const statisticsCards = ref([
progressColor: 'rgba(82, 196, 26, 0.8)' progressColor: 'rgba(82, 196, 26, 0.8)'
}, },
{ {
title: '销售额', title: '工单量',
value: '¥ 125,430', value: ticketCount.value.toLocaleString(),
icon: 'Money', icon: 'Tickets',
trend: -2.3, trend: -2.3,
class: 'sales', class: 'sales',
progress: 52, progress: 52,
@@ -264,6 +408,150 @@ const statisticsCards = ref([
} }
]) ])
//
const fetchStatistics = async () => {
try {
//
const userRes = await getUserList({ page: 1, count: 10, key: '' })
console.log("用户数量,",userRes)
if (userRes.data?.code === 200) {
userCount.value = userRes.data.data.all_count || 0
}
//
const orderRes = await getOrderList({ page: 1, count: 10 })
console.log("订单数量,",orderRes)
if (orderRes.data?.code === 200) {
orderCount.value = orderRes.data.data.all_count || 0
}
//
const ticketRes = await getTicketCount()
console.log("工单数量,",ticketRes)
if (ticketRes.code === 200) {
ticketCount.value = ticketRes.data?.all_count || 0
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
//
const fetchRecentLists = async () => {
listLoading.value = true
try {
//
const userRes = await getUserList({ page: 1, count: 10, key: '' })
if (userRes.data?.code === 200) {
recentUsers.value = (userRes.data.data.data || []).map(user => ({
id: user.user_id,
name: user.user_name,
email: user.email || '未设置',
phone: user.phone || '未设置',
createdAt: user.created_at
}))
}
//
const orderRes = await getOrderList({ page: 1, count: 10 })
if (orderRes.data?.code === 200) {
recentOrders.value = (orderRes.data.data.list || []).map(order => ({
id: order.id,
name: order.name,
userId: order.userId,
commodityId: order.commodityId,
price: order.price,
renewPrice: order.renewPrice || 0,
payNum: order.payNum || 1,
state: order.state,
payType: order.payType,
createdAt: order.CreatedAt
}))
}
//
const ticketRes = await getTickerList(5, 1)
console.log("最近工单,",ticketRes)
if (ticketRes.code === 200) {
recentTickets.value = (ticketRes.data.data?.list || ticketRes.data.data || []).map(ticket => ({
id: ticket.work_id || ticket.id,
title: ticket.title,
status: ticket.status,
userId: ticket.user?.userId,
createdAt: ticket.created_at || ticket.CreatedAt
}))
}
} catch (error) {
console.error('获取列表数据失败:', error)
} finally {
listLoading.value = false
}
}
//
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
//
const getOrderStatusText = (state) => {
const statusMap = { 0: '待支付', 1: '已支付', 2: '已失效' }
return statusMap[state] || '未知'
}
const getOrderStatusType = (state) => {
const typeMap = { 0: 'warning', 1: 'success', 2: 'info' }
return typeMap[state] || 'info'
}
//
const getTicketStatusText = (status) => {
const statusMap = { 0: '待处理', 1: '处理中', 2: '已回复', 3: '已解决' }
return statusMap[status] || '未知'
}
const getTicketStatusType = (status) => {
const typeMap = { 0: 'danger', 1: 'warning', 2: 'primary', 3: 'success' }
return typeMap[status] || 'info'
}
//
const goToUserDetail = (userId) => {
router.push({ path: '/user/detail', query: { user_id: userId } })
}
const goToUserList = () => {
router.push('/user/list')
}
const goToOrderList = () => {
router.push('/order/list')
}
const goToTicketList = () => {
router.push('/ticket/list')
}
const goToTicketDetail = (ticketId) => {
router.push({ path: '/ticket/detail', query: { work_id: ticketId } })
}
//
const orderDetailVisible = ref(false)
const currentOrder = ref(null)
const showOrderDetail = (order) => {
currentOrder.value = order
orderDetailVisible.value = true
}
// //
const customerData = ref([ const customerData = ref([
{ name: '企业客户', value: 1048, percentage: 33, color: '#1890ff' }, { name: '企业客户', value: 1048, percentage: 33, color: '#1890ff' },
@@ -331,6 +619,10 @@ const getPriorityType = (priority) => {
} }
onMounted(() => { onMounted(() => {
//
fetchStatistics()
fetchRecentLists()
initSalesChart() initSalesChart()
initCustomerChart() initCustomerChart()
@@ -531,15 +823,19 @@ watch(salesRange, (newVal) => {
/* 统计卡片样式 */ /* 统计卡片样式 */
.stat-card { .stat-card {
margin-bottom: 24px; margin-bottom: 24px;
border-radius: 12px;
border: none;
transition: all 0.3s; transition: all 0.3s;
overflow: hidden; overflow: hidden;
border-left: 3px solid transparent !important;
} }
.stat-card.visitors { border-left-color: #1890ff !important; }
.stat-card.orders { border-left-color: #52c41a !important; }
.stat-card.sales { border-left-color: #faad14 !important; }
.stat-card.conversion { border-left-color: #722ed1 !important; }
.stat-card:hover { .stat-card:hover {
transform: translateY(-5px); transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
} }
.card-top { .card-top {
@@ -570,10 +866,10 @@ watch(salesRange, (newVal) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 56px; width: 48px;
height: 56px; height: 48px;
border-radius: 12px; border-radius: 4px;
font-size: 28px; font-size: 24px;
color: #fff; color: #fff;
} }
@@ -635,8 +931,6 @@ watch(salesRange, (newVal) => {
.chart-card { .chart-card {
margin-bottom: 24px; margin-bottom: 24px;
border-radius: 12px;
border: none;
overflow: hidden; overflow: hidden;
} }
@@ -744,8 +1038,6 @@ watch(salesRange, (newVal) => {
.activity-card, .todo-card { .activity-card, .todo-card {
height: 100%; height: 100%;
border-radius: 12px;
border: none;
} }
.card-header-custom { .card-header-custom {
@@ -870,6 +1162,149 @@ watch(salesRange, (newVal) => {
gap: 8px; gap: 8px;
} }
/* 列表卡片样式 */
.list-row {
margin-bottom: 24px;
}
.list-card {
margin-bottom: 24px;
height: 410px;
display: flex;
flex-direction: column;
}
.list-card :deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.header-icon {
font-size: 20px;
margin-right: 8px;
}
.user-icon {
color: #1890ff;
}
.order-icon {
color: #52c41a;
}
.ticket-icon {
color: #faad14;
}
.list-content {
padding: 0 20px 20px;
flex: 1;
overflow-y: auto;
}
.empty-tip {
text-align: center;
color: #909399;
padding: 60px 0;
font-size: 14px;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background-color 0.2s;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background-color: #fafafa;
margin: 0 -20px;
padding: 12px 20px;
}
.item-main {
flex: 1;
min-width: 0;
}
.item-title {
font-size: 14px;
font-weight: 500;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.item-sub {
font-size: 12px;
color: #8c8c8c;
}
.item-extra {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
margin-left: 12px;
}
.item-id {
font-size: 12px;
color: #8c8c8c;
}
.item-time {
font-size: 12px;
color: #8c8c8c;
}
.item-price {
font-size: 14px;
font-weight: 600;
color: #f56c6c;
}
/* 订单详情弹窗样式 */
.order-detail-dialog :deep(.el-descriptions__label) {
width: 100px;
font-weight: 500;
color: #606266;
}
.order-detail-dialog :deep(.el-descriptions__content) {
color: #2c3e50;
}
.detail-price {
color: #f56c6c;
font-weight: 600;
font-size: 16px;
}
.detail-renew-price {
color: #409eff;
font-weight: 500;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-container { .dashboard-container {
padding: 12px; padding: 12px;
@@ -888,5 +1323,30 @@ watch(salesRange, (newVal) => {
.todo-list { .todo-list {
height: 320px; height: 320px;
} }
.list-card {
height: auto;
min-height: 300px;
}
.list-content {
min-height: 200px;
max-height: 1000px;
}
.order-detail-dialog :deep(.el-dialog) {
width: 90% !important;
margin: 5vh auto !important;
}
.order-detail-dialog :deep(.el-descriptions) {
--el-descriptions-item-bordered-label-background: #fafafa;
}
.order-detail-dialog :deep(.el-descriptions__label),
.order-detail-dialog :deep(.el-descriptions__content) {
padding: 8px 12px;
font-size: 13px;
}
} }
</style> </style>
+18 -6
View File
@@ -141,17 +141,26 @@
<el-radio label="percentage">百分比折扣</el-radio> <el-radio label="percentage">百分比折扣</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额(元)" prop="amount"> <el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额" prop="amount">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item> </el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage"> <el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" /> <el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="最低消费(元)" prop="min_amount"> <el-form-item label="最低消费" prop="min_amount">
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item> </el-form-item>
<el-form-item label="最大抵扣(元)" prop="max_amount"> <el-form-item label="最大抵扣" prop="max_amount">
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item> </el-form-item>
<el-form-item label="最大使用次数" prop="max_times"> <el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" /> <el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
@@ -651,6 +660,9 @@ onMounted(() => {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style> </style>
<style> <style>
+3 -30
View File
@@ -389,7 +389,7 @@ const fetchVoucherListOptions = async () => {
try { try {
const res = await getDiscountCodeList({ const res = await getDiscountCodeList({
page: 1, page: 1,
count: 1000, count: 10,
discount_type: 'coupon' discount_type: 'coupon'
}) })
console.log('获取代金券列表:', res.data) console.log('获取代金券列表:', res.data)
@@ -407,7 +407,7 @@ const fetchProductList = async () => {
try { try {
const res = await getProductList({ const res = await getProductList({
page: 1, page: 1,
count: 1000 count: 10
}) })
console.log('获取商品列表:', res.data) console.log('获取商品列表:', res.data)
if (res.data.code === 200) { if (res.data.code === 200) {
@@ -424,7 +424,7 @@ const fetchProductGroupList = async () => {
try { try {
const res = await getProductGroupList({ const res = await getProductGroupList({
page: 1, page: 1,
count: 1000 count: 10
}) })
console.log('获取商品组列表:', res.data) console.log('获取商品组列表:', res.data)
if (res.data.code === 200) { if (res.data.code === 200) {
@@ -798,33 +798,6 @@ onMounted(() => {
padding: 0; padding: 0;
} }
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
+7 -30
View File
@@ -71,7 +71,8 @@
<el-table-column prop="discountId" label="代金券ID" width="120" v-if="!codeId" /> <el-table-column prop="discountId" label="代金券ID" width="120" v-if="!codeId" />
<el-table-column label="用户名" min-width="150"> <el-table-column label="用户名" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
{{ row?.user?.user_name || '-' }} <el-link v-if="row.userId && row?.user?.user_name" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.user.user_name }}</el-link>
<span v-else>{{ row?.user?.user_name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="手机号" min-width="150"> <el-table-column label="手机号" min-width="150">
@@ -239,6 +240,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Search, Plus, Refresh, User } from '@element-plus/icons-vue' import { Delete, Search, Plus, Refresh, User } from '@element-plus/icons-vue'
import { import {
@@ -261,6 +263,8 @@ const props = defineProps({
} }
}) })
const router = useRouter()
// //
const queryParams = reactive({ const queryParams = reactive({
code_id: props.codeId || '', code_id: props.codeId || '',
@@ -361,7 +365,7 @@ const fetchVoucherListOptions = async () => {
try { try {
const res = await getDiscountCodeList({ const res = await getDiscountCodeList({
page: 1, page: 1,
count: 1000, count: 10,
discount_type: 'coupon' discount_type: 'coupon'
}) })
console.log('获取代金券列表:', res.data) console.log('获取代金券列表:', res.data)
@@ -397,7 +401,7 @@ const fetchUserGroupList = async () => {
try { try {
const res = await getUserGroupList({ const res = await getUserGroupList({
page: 1, page: 1,
count: 10000, count: 10,
key: '' key: ''
}) })
console.log('获取用户组列表:', res.data) console.log('获取用户组列表:', res.data)
@@ -803,33 +807,6 @@ onMounted(() => {
padding: 0; padding: 0;
} }
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="group-buy-manage">
<el-card>
<template #header>
<div class="card-header">
<span>拼团管理</span>
<el-button type="primary" @click="showCreateDialog">创建拼团</el-button>
</div>
</template>
<!-- 拼团列表 -->
<el-table :data="groupBuyList" style="width: 100%">
<el-table-column prop="group_buy_id" label="拼团ID" width="180" />
<el-table-column prop="name" label="拼团名称" width="150" />
<el-table-column prop="maxPerson" label="最大人数" width="100" />
<el-table-column label="当前人数" width="100">
<template #default="{ row }">
{{ row.users?.length || 0 }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" fixed="right" width="200">
<template #default="{ row }">
<el-button link type="primary" @click="checkGroupBuy(row.group_buy_id)">
检查
</el-button>
<el-button link type="primary" @click="viewDetail(row)">
详情
</el-button>
<el-button link type="danger" @click="deleteGroupBuy(row.group_buy_id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建拼团对话框 -->
<el-dialog v-model="createDialogVisible" title="创建拼团" width="500px">
<el-form :model="createForm" label-width="100px">
<el-form-item label="拼团名称">
<el-input v-model="createForm.name" placeholder="请输入拼团名称" />
</el-form-item>
<el-form-item label="最大人数">
<el-input-number v-model="createForm.maxPerson" :min="2" :max="100" />
</el-form-item>
<el-form-item label="封面图片">
<el-input v-model="createForm.cover" placeholder="请输入封面图片URL" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreate" :loading="creating">
创建
</el-button>
</template>
</el-dialog>
<!-- 拼团详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="拼团详情" width="600px">
<el-descriptions :column="2" border v-if="currentGroupBuy">
<el-descriptions-item label="拼团ID">
{{ currentGroupBuy.group_buy_id }}
</el-descriptions-item>
<el-descriptions-item label="拼团名称">
{{ currentGroupBuy.name }}
</el-descriptions-item>
<el-descriptions-item label="最大人数">
{{ currentGroupBuy.maxPerson }}
</el-descriptions-item>
<el-descriptions-item label="当前人数">
{{ currentGroupBuy.users?.length || 0 }}
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ currentGroupBuy.createTime }}
</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 20px">
<h4>参与用户</h4>
<el-table :data="currentGroupBuy?.users || []" style="width: 100%">
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column prop="user_name" label="用户名" />
<el-table-column label="团长" width="80">
<template #default="{ row }">
<el-tag v-if="row.team_leader" type="success"></el-tag>
<el-tag v-else type="info"></el-tag>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createGroupBuy as createGroupBuyApi,
checkGroupBuy as checkGroupBuyApi,
getGroupBuyList,
getGroupBuyDetail,
deleteGroupBuy as deleteGroupBuyApi
} from '@/api/groupBuy.js'
//
const groupBuyList = ref([])
//
const createDialogVisible = ref(false)
const creating = ref(false)
const createForm = ref({
name: '',
maxPerson: 5,
cover: ''
})
//
const detailDialogVisible = ref(false)
const currentGroupBuy = ref(null)
//
const loadGroupBuyList = async () => {
try {
const resp = await getGroupBuyList({ page: 1, pageSize: 10 })
if (resp && resp.code === 200) {
groupBuyList.value = resp.data || []
}
} catch (error) {
console.error('加载拼团列表失败:', error)
}
}
//
const showCreateDialog = () => {
createForm.value = {
name: '',
maxPerson: 5,
cover: ''
}
createDialogVisible.value = true
}
//
const handleCreate = async () => {
if (!createForm.value.name) {
ElMessage.warning('请输入拼团名称')
return
}
creating.value = true
try {
const resp = await createGroupBuyApi(createForm.value)
console.log('创建拼团响应:', resp)
if (resp && resp.code === 200) {
ElMessage.success('创建成功')
createDialogVisible.value = false
//
if (resp.data && resp.data.group_buy_id) {
groupBuyList.value.unshift(resp.data)
} else {
//
await loadGroupBuyList()
}
} else {
ElMessage.error(resp?.message || '创建失败')
}
} catch (error) {
console.error('创建拼团失败:', error)
ElMessage.error('创建失败')
} finally {
creating.value = false
}
}
//
const checkGroupBuy = async (groupBuyId) => {
try {
const resp = await checkGroupBuyApi(groupBuyId)
console.log('检查拼团响应:', resp)
if (resp && resp.code === 200) {
ElMessage.success(`检查结果: ${resp.data}`)
} else {
ElMessage.error(resp?.message || '检查失败')
}
} catch (error) {
console.error('检查拼团失败:', error)
ElMessage.error('检查失败')
}
}
//
const viewDetail = async (row) => {
try {
const resp = await getGroupBuyDetail(row.group_buy_id)
if (resp && resp.code === 200) {
currentGroupBuy.value = resp.data
detailDialogVisible.value = true
} else {
// 使
currentGroupBuy.value = row
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取详情失败:', error)
// 使
currentGroupBuy.value = row
detailDialogVisible.value = true
}
}
//
const deleteGroupBuy = async (groupBuyId) => {
try {
await ElMessageBox.confirm('确定要删除这个拼团吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const resp = await deleteGroupBuyApi(groupBuyId)
if (resp && resp.code === 200) {
ElMessage.success('删除成功')
await loadGroupBuyList()
} else {
ElMessage.error(resp?.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除拼团失败:', error)
ElMessage.error('删除失败')
}
}
}
onMounted(() => {
loadGroupBuyList()
})
</script>
<style scoped>
.group-buy-manage {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+48 -25
View File
@@ -177,30 +177,36 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="用户" prop="user_id"> <el-form-item label="用户" prop="user_id" v-if="addForm.target_type === 'user'">
<div class="user-selector-wrapper"> <div class="user-selector-wrapper">
<div class="selected-user-display" v-if="addForm.user_id"> <el-input
<el-tag type="primary" closable @close="clearSelectedUser"> :model-value="getSelectedUserName()"
{{ getSelectedUserName() }} placeholder="请选择用户"
</el-tag> readonly
</div>
<el-button
type="primary"
plain
@click="openUserSelector" @click="openUserSelector"
style="width: 100%"
> >
<el-icon><User /></el-icon> <template #append>
{{ addForm.user_id ? '重新选择用户' : '选择用户' }} <el-button @click="openUserSelector">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button
v-if="addForm.user_id"
type="danger"
link
@click="clearSelectedUser"
class="clear-btn"
>
清除
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="用户组" prop="group_id"> <el-form-item label="用户组" prop="group_id" v-if="addForm.target_type === 'group'">
<el-select <el-select
v-model="addForm.group_id" v-model="addForm.group_id"
placeholder="请选择用户组" placeholder="请选择用户组"
:disabled="addForm.target_type === 'user'"
filterable filterable
clearable clearable
style="width: 100%" style="width: 100%"
@@ -478,7 +484,7 @@ const fetchVoucherListOptions = async () => {
try { try {
const res = await getDiscountCodeList({ const res = await getDiscountCodeList({
page: 1, page: 1,
count: 1000, count: 10,
discount_type: 'coupon' discount_type: 'coupon'
}) })
console.log('获取代金券列表:', res.data) console.log('获取代金券列表:', res.data)
@@ -496,7 +502,7 @@ const fetchDiscountList = async () => {
try { try {
const res = await getDiscountCodeList({ const res = await getDiscountCodeList({
page: 1, page: 1,
count: 100, count: 10,
discount_type: 'coupon' discount_type: 'coupon'
}) })
console.log('获取代金券列表:', res.data) console.log('获取代金券列表:', res.data)
@@ -507,7 +513,7 @@ const fetchDiscountList = async () => {
} }
const res2 = await getDiscountCodeList({ const res2 = await getDiscountCodeList({
page: 1, page: 1,
count: 100, count: 10,
discount_type: 'code' discount_type: 'code'
}) })
console.log('获取优惠码列表:', res2.data) console.log('获取优惠码列表:', res2.data)
@@ -527,7 +533,7 @@ const fetchVoucherOptions = async () => {
const res = await getDiscountCodeList({ const res = await getDiscountCodeList({
discount_type: 'coupon', discount_type: 'coupon',
page: 1, page: 1,
count: 100 count: 10
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
voucherOptions.value = res.data.data?.data || [] voucherOptions.value = res.data.data?.data || []
@@ -543,7 +549,7 @@ const fetchCodeOptions = async () => {
const res = await getDiscountCodeList({ const res = await getDiscountCodeList({
discount_type: 'code', discount_type: 'code',
page: 1, page: 1,
count: 100 count: 10
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
codeOptions.value = res.data.data?.data || [] codeOptions.value = res.data.data?.data || []
@@ -560,7 +566,7 @@ const fetchUserList = async () => {
try { try {
const res = await getUserList({ const res = await getUserList({
page: 1, page: 1,
count: 100, count: 10,
key: '' key: ''
}) })
console.log('获取用户列表:', res.data) console.log('获取用户列表:', res.data)
@@ -582,7 +588,7 @@ const fetchGroupOptions = async () => {
try { try {
const res = await getUserGroupList({ const res = await getUserGroupList({
page: 1, page: 1,
count: 100 count: 10
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
groupOptions.value = res.data.data?.data || [] groupOptions.value = res.data.data?.data || []
@@ -653,9 +659,9 @@ const confirmUserSelection = (user) => {
ElMessage.warning('请选择一个用户') ElMessage.warning('请选择一个用户')
return return
} }
addForm.user_id = user.UserId addForm.user_id = user.user_id
// userOptions // userOptions
if (!userOptions.value.find(u => u.UserId === user.UserId)) { if (!userOptions.value.find(u => u.user_id === user.user_id)) {
userOptions.value.push(user) userOptions.value.push(user)
} }
userSelectorVisible.value = false userSelectorVisible.value = false
@@ -668,8 +674,9 @@ const clearSelectedUser = () => {
// //
const getSelectedUserName = () => { const getSelectedUserName = () => {
const user = userOptions.value.find(u => u.UserId === addForm.user_id) if (!addForm.user_id) return ''
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${addForm.user_id}` const user = userOptions.value.find(u => u.user_id === addForm.user_id)
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${addForm.user_id}`
} }
// //
@@ -940,5 +947,21 @@ onMounted(() => {
margin-top: 24px; margin-top: 24px;
justify-content: flex-end; justify-content: flex-end;
} }
/* 用户选择器样式 */
.user-selector-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.user-selector-wrapper .el-input {
flex: 1;
}
.user-selector-wrapper .clear-btn {
flex-shrink: 0;
}
</style> </style>
+59 -12
View File
@@ -105,14 +105,23 @@
<el-form-item label="备注" prop="note"> <el-form-item label="备注" prop="note">
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" /> <el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item> </el-form-item>
<el-form-item label="面额(元)" prop="amount"> <el-form-item label="面额" prop="amount">
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item> </el-form-item>
<el-form-item label="最低消费(元)" prop="min_amount"> <el-form-item label="最低消费" prop="min_amount">
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item> </el-form-item>
<el-form-item label="最大抵扣(元)" prop="max_amount"> <el-form-item label="最大抵扣" prop="max_amount">
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item> </el-form-item>
<el-form-item label="最大使用次数" prop="max_times"> <el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" /> <el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
@@ -120,8 +129,11 @@
<el-form-item label="单用户最大次数" prop="user_times"> <el-form-item label="单用户最大次数" prop="user_times">
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" /> <el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="有效期(天)" prop="duration_days"> <el-form-item label="有效期" prop="duration_days">
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="flex:1" />
<span class="unit-text"></span>
</div>
<div class="form-tip">代金券领取后的有效持续时间</div> <div class="form-tip">代金券领取后的有效持续时间</div>
</el-form-item> </el-form-item>
<el-form-item label="发放时间范围" prop="timeRange"> <el-form-item label="发放时间范围" prop="timeRange">
@@ -295,9 +307,39 @@ const handleEdit = (row) => {
dialogType.value = 'edit' dialogType.value = 'edit'
dialogVisible.value = true dialogVisible.value = true
// console.log('编辑代金券原始数据:', row)
const startTime = row.startTime ? new Date(row.startTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
const endTime = row.endTime ? new Date(row.endTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : '' // YYYY-MM-DD HH:mm:ss
let startTime = ''
let endTime = ''
if (row.startTime) {
// "2026-01-08T00:00:00+08:00"
const start = new Date(row.startTime)
if (!isNaN(start.getTime())) {
startTime = start.getFullYear() + '-' +
String(start.getMonth() + 1).padStart(2, '0') + '-' +
String(start.getDate()).padStart(2, '0') + ' ' +
String(start.getHours()).padStart(2, '0') + ':' +
String(start.getMinutes()).padStart(2, '0') + ':' +
String(start.getSeconds()).padStart(2, '0')
}
}
if (row.endTime) {
// "2026-02-25T00:00:00+08:00"
const end = new Date(row.endTime)
if (!isNaN(end.getTime())) {
endTime = end.getFullYear() + '-' +
String(end.getMonth() + 1).padStart(2, '0') + '-' +
String(end.getDate()).padStart(2, '0') + ' ' +
String(end.getHours()).padStart(2, '0') + ':' +
String(end.getMinutes()).padStart(2, '0') + ':' +
String(end.getSeconds()).padStart(2, '0')
}
}
console.log('转换后的时间:', { startTime, endTime })
Object.assign(voucherForm, { Object.assign(voucherForm, {
code_id: row.id, code_id: row.id,
@@ -309,12 +351,14 @@ const handleEdit = (row) => {
max_amount: row.maxAmount ? row.maxAmount / 100 : 0, max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
max_times: row.maxTimes || 0, max_times: row.maxTimes || 0,
user_times: row.userTimes || 0, user_times: row.userTimes || 0,
duration_days: row.duration ? row.duration / 86400 : 30, // duration_days: row.duration ? Math.round(row.duration / 86400) : 30, //
timeRange: startTime && endTime ? [startTime, endTime] : [], timeRange: startTime && endTime ? [startTime, endTime] : [],
renew: row.renew || false, renew: row.renew || false,
can_stacking: row.canStacking || false, can_stacking: row.canStacking || false,
can_combine: row.canCombine || false can_combine: row.canCombine || false
}) })
console.log('表单数据:', voucherForm)
} }
// //
@@ -507,6 +551,9 @@ onMounted(() => {
margin-top: 24px; margin-top: 24px;
justify-content: flex-end; justify-content: flex-end;
} }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style> </style>
<style> <style>
+17 -4
View File
@@ -43,7 +43,12 @@
stripe stripe
> >
<el-table-column prop="id" label="记录ID" width="80" fixed="left" /> <el-table-column prop="id" label="记录ID" width="80" fixed="left" />
<el-table-column prop="user_id" label="用户ID" width="100" /> <el-table-column label="用户ID" width="100">
<template #default="{ row }">
<el-link v-if="row.user_id" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.user_id } })">{{ row.user_id }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="150" /> <el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="email" label="邮箱" min-width="200" /> <el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="discount_id" label="代金券ID" width="120" v-if="!codeId" /> <el-table-column prop="discount_id" label="代金券ID" width="120" v-if="!codeId" />
@@ -58,7 +63,12 @@
<span>¥{{ row.order_amount ? (row.order_amount / 100).toFixed(2) : '0.00' }}</span> <span>¥{{ row.order_amount ? (row.order_amount / 100).toFixed(2) : '0.00' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="order_id" label="订单ID" width="150" /> <el-table-column label="订单ID" width="150">
<template #default="{ row }">
<el-link v-if="row.order_id" type="primary" :underline="false" @click="router.push({ path: '/order/list', query: { key: row.order_id } })">{{ row.order_id }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="使用状态" width="100"> <el-table-column label="使用状态" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getStatusType(row.status)"> <el-tag :type="getStatusType(row.status)">
@@ -174,6 +184,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue' import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Search, Refresh, Download } from '@element-plus/icons-vue' import { Search, Refresh, Download } from '@element-plus/icons-vue'
import { getUserVoucherHistory, getDiscountCodeList } from '@/api/admin/discount' import { getUserVoucherHistory, getDiscountCodeList } from '@/api/admin/discount'
@@ -187,6 +198,8 @@ const props = defineProps({
} }
}) })
const router = useRouter()
// //
const queryParams = reactive({ const queryParams = reactive({
user_id: undefined, user_id: undefined,
@@ -397,7 +410,7 @@ const fetchUserList = async () => {
try { try {
const res = await getUserList({ const res = await getUserList({
page: 1, page: 1,
count: 10000, count: 10,
key: '' key: ''
}) })
UserOptions.value = res.data.data?.data || [] UserOptions.value = res.data.data?.data || []
@@ -412,7 +425,7 @@ const fetchDiscountList = async () => {
const res = await getDiscountCodeList({ const res = await getDiscountCodeList({
discount_type: 'coupon', discount_type: 'coupon',
page: 1, page: 1,
count: 1000 count: 10
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
discountOptions.value = res.data.data?.data || [] discountOptions.value = res.data.data?.data || []
+10 -2
View File
@@ -40,7 +40,12 @@
style="width: 100%" style="width: 100%"
> >
<el-table-column prop="Id" label="ID" width="80" /> <el-table-column prop="Id" label="ID" width="80" />
<el-table-column prop="UserId" label="用户ID" min-width="100" /> <el-table-column label="用户ID" min-width="100">
<template #default="{ row }">
<el-link v-if="row.UserId" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.UserId } })">{{ row.UserId }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="代金券ID" min-width="110" v-if="!codeId"> <el-table-column label="代金券ID" min-width="110" v-if="!codeId">
<template #default="{ row }"> <template #default="{ row }">
{{ row.discountId || '-' }} {{ row.discountId || '-' }}
@@ -212,6 +217,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue' import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
import { import {
@@ -230,6 +236,8 @@ const props = defineProps({
} }
}) })
const router = useRouter()
// //
const queryParams = reactive({ const queryParams = reactive({
user_id: undefined, user_id: undefined,
@@ -459,7 +467,7 @@ const fetchDiscountList = async () => {
const res = await getDiscountCodeList({ const res = await getDiscountCodeList({
discount_type: 'coupon', discount_type: 'coupon',
page: 1, page: 1,
count: 1000 count: 10
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
discountOptions.value = res.data.data?.data || [] discountOptions.value = res.data.data?.data || []
+437 -38
View File
@@ -5,6 +5,36 @@
<!-- 搜索和操作栏 --> <!-- 搜索和操作栏 -->
<div class="filter-section"> <div class="filter-section">
<div class="filter-content"> <div class="filter-content">
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="关键词">
<el-input v-model="queryParams.key" placeholder="订单名称/ID" clearable style="width: 150px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="用户ID">
<el-input v-model="queryParams.user_id" placeholder="用户ID" clearable style="width: 120px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="用户关键词">
<el-input v-model="queryParams.user_key" placeholder="用户名/手机号/邮箱" clearable style="width: 180px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.state" placeholder="全部" clearable style="width: 120px">
<el-option label="待支付" value="0" />
<el-option label="已支付" value="1" />
<el-option label="已失效" value="2" />
</el-select>
</el-form-item>
<el-form-item label="错误信息">
<el-select v-model="queryParams.error" placeholder="全部" clearable style="width: 140px">
<el-option label="有错误的订单" :value="true" />
<el-option label="无错误的订单" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar"> <div class="action-bar">
<el-button type="primary" @click="handleAdd"> <el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增订单 <el-icon><Plus /></el-icon>新增订单
@@ -43,8 +73,18 @@
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column prop="id" label="订单ID" width="100" /> <el-table-column prop="id" label="订单ID" width="100" />
<el-table-column prop="name" label="订单名称" min-width="180" /> <el-table-column prop="name" label="订单名称" min-width="180" />
<el-table-column prop="userId" label="用户ID" width="100" /> <el-table-column label="用户ID" width="100">
<el-table-column prop="commodityId" label="商品ID" width="100" /> <template #default="{ row }">
<el-link v-if="row.userId" type="primary" :underline="false" @click.stop="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.userId }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="商品ID" width="100">
<template #default="{ row }">
<el-link v-if="row.commodityId" type="primary" :underline="false" @click.stop="router.push({ path: '/user-goods/list', query: { good_id: row.commodityId } })">{{ row.commodityId }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="表名" width="120"> <el-table-column label="表名" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small">{{ row.table || '未知' }}</el-tag> <el-tag size="small">{{ row.table || '未知' }}</el-tag>
@@ -65,11 +105,22 @@
<span>{{ row.payNum }}</span> <span>{{ row.payNum }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="订单状态" width="100"> <el-table-column label="订单状态" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getStatusType(row.state)"> <div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
{{ getStatusText(row.state) }} <el-tag :type="getStatusType(row.state)">
</el-tag> {{ getStatusText(row.state) }}
</el-tag>
<el-tag v-if="row.error" type="danger" size="small">异常</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="错误信息" min-width="250">
<template #default="{ row }">
<el-tooltip v-if="row.error" :content="row.error" placement="top" :show-after="300">
<span class="error-text">{{ row.error }}</span>
</el-tooltip>
<span v-else class="text-muted">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="支付方式" width="100"> <el-table-column label="支付方式" width="100">
@@ -87,11 +138,12 @@
<span>{{ formatDate(row.CreatedAt) }}</span> <span>{{ formatDate(row.CreatedAt) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" fixed="right"> <el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<el-button type="primary" link @click="handleView(row)">查看</el-button> <el-button type="primary" link @click="handleView(row)">查看</el-button>
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button> <el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.error" type="danger" link @click="handleRetryOrder(row)">重试流程</el-button>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -138,6 +190,10 @@
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.CreatedAt) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatDate(orderDetail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(orderDetail.UpdatedAt) }}</el-descriptions-item> <el-descriptions-item label="更新时间">{{ formatDate(orderDetail.UpdatedAt) }}</el-descriptions-item>
<el-descriptions-item label="参数信息">{{ orderDetail.args || '-' }}</el-descriptions-item> <el-descriptions-item label="参数信息">{{ orderDetail.args || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="orderDetail.error" label="错误信息" :span="2">
<el-tag type="danger" size="small" style="margin-right: 6px;">异常</el-tag>
<span style="color: #f56c6c;">{{ orderDetail.error }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item> <el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-dialog> </el-dialog>
@@ -162,28 +218,149 @@
<el-input v-model="orderForm.table" placeholder="请输入所属表" /> <el-input v-model="orderForm.table" placeholder="请输入所属表" />
</el-form-item> </el-form-item>
<el-form-item label="用户ID" prop="user_id"> <el-form-item label="用户ID" prop="user_id">
<el-input-number v-model="orderForm.user_id" :min="1" placeholder="请输入用户ID" style="width: 100%" /> <el-input
v-if="selectedUserInfo"
:model-value="`${selectedUserInfo.user_name} (ID: ${orderForm.user_id})`"
readonly
style="width: 100%"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearUser"><Close /></el-icon>
</template>
<template #append>
<el-button @click="userSelectorVisible = true">
<el-icon><User /></el-icon>
</el-button>
</template>
</el-input>
<el-input
v-else
placeholder="请选择用户"
readonly
style="width: 100%"
@click="userSelectorVisible = true"
>
<template #append>
<el-button @click="userSelectorVisible = true">
<el-icon><User /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item label="商品ID" prop="commodity_id"> <el-form-item label="商品ID" prop="commodity_id">
<el-input-number v-model="orderForm.commodity_id" :min="0" placeholder="请输入商品ID" style="width: 100%" /> <el-input
v-if="selectedProductInfo"
:model-value="`${selectedProductInfo.name} (ID: ${orderForm.commodity_id})`"
readonly
style="width: 100%"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearProduct"><Close /></el-icon>
</template>
<template #append>
<el-button @click="productSelectorVisible = true">
<el-icon><ShoppingCart /></el-icon>
</el-button>
</template>
</el-input>
<el-input
v-else
placeholder="请选择商品"
readonly
style="width: 100%"
@click="productSelectorVisible = true"
>
<template #append>
<el-button @click="productSelectorVisible = true">
<el-icon><ShoppingCart /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item label="购买数量" prop="pay_num"> <el-form-item label="购买数量" prop="pay_num">
<el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" /> <el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="价格(分)" prop="price"> <el-form-item label="价格" prop="price">
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item> </el-form-item>
<el-form-item label="续费价格(分)" prop="renew_price"> <el-form-item label="续费价格" prop="renew_price">
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="width: 100%" /> <div class="unit-input-row">
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item> </el-form-item>
<el-form-item label="过期时间" prop="expire_time"> <el-form-item label="过期时间" prop="expire_time">
<el-input-number v-model="orderForm.expire_time" :min="0" placeholder="请输入过期时间(时间戳)" style="width: 100%" /> <el-date-picker
v-model="orderForm.expire_time"
type="datetime"
placeholder="请选择过期时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="x"
style="width: 100%"
/>
</el-form-item> </el-form-item>
<el-form-item label="优惠码ID" prop="discount_code_id"> <el-form-item label="优惠码ID" prop="discount_code_id">
<el-input-number v-model="orderForm.discount_code_id" :min="0" placeholder="请输入优惠码ID" style="width: 100%" /> <el-input
v-if="selectedDiscountCodeInfo"
:model-value="`${selectedDiscountCodeInfo.name || selectedDiscountCodeInfo.code} (ID: ${orderForm.discount_code_id})`"
readonly
style="width: 100%"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearDiscountCode"><Close /></el-icon>
</template>
<template #append>
<el-button @click="discountCodeSelectorVisible = true">
<el-icon><Ticket /></el-icon>
</el-button>
</template>
</el-input>
<el-input
v-else
placeholder="请选择优惠码(可选)"
readonly
style="width: 100%"
@click="discountCodeSelectorVisible = true"
>
<template #append>
<el-button @click="discountCodeSelectorVisible = true">
<el-icon><Ticket /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item label="代金券ID" prop="coupon_id"> <el-form-item label="代金券ID" prop="coupon_id">
<el-input-number v-model="orderForm.coupon_id" :min="0" placeholder="请输入代金券ID (必填)" style="width: 100%" /> <el-input
v-if="selectedVoucherInfo"
:model-value="`${selectedVoucherInfo.name || selectedVoucherInfo.code} (ID: ${orderForm.coupon_id})`"
readonly
style="width: 100%"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearVoucher"><Close /></el-icon>
</template>
<template #append>
<el-button @click="voucherSelectorVisible = true">
<el-icon><Money /></el-icon>
</el-button>
</template>
</el-input>
<el-input
v-else
placeholder="请选择代金券(可选)"
readonly
style="width: 100%"
@click="voucherSelectorVisible = true"
>
<template #append>
<el-button @click="voucherSelectorVisible = true">
<el-icon><Money /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item label="订单状态" prop="state"> <el-form-item label="订单状态" prop="state">
<el-radio-group v-model="orderForm.state"> <el-radio-group v-model="orderForm.state">
@@ -209,20 +386,61 @@
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 用户选择器 -->
<UserListSelector
v-model="userSelectorVisible"
:current-user-id="orderForm.user_id"
@confirm="handleUserSelect"
/>
<!-- 商品选择器 -->
<ProductSelector
v-model="productSelectorVisible"
:current-product-id="orderForm.commodity_id"
@confirm="handleProductSelect"
/>
<!-- 优惠码选择器 -->
<DiscountCodeSelector
v-model="discountCodeSelectorVisible"
:current-code-id="orderForm.discount_code_id"
@confirm="handleDiscountCodeSelect"
/>
<!-- 代金券选择器 -->
<VoucherSelector
v-model="voucherSelectorVisible"
:current-voucher-id="orderForm.coupon_id"
@confirm="handleVoucherSelect"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Download, Refresh } from '@element-plus/icons-vue' import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close } from '@element-plus/icons-vue'
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder } from '@/api/admin/order' import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder, retryOrderHook } from '@/api/admin/order'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import ProductSelector from '@/components/admin/ProductSelector.vue'
import DiscountCodeSelector from '@/components/admin/DiscountCodeSelector.vue'
import VoucherSelector from '@/components/admin/VoucherSelector.vue'
import { isoToMilliseconds, timeToTimestamp, formatDate as formatDateTool } from '@/utils/tool'
const router = useRouter()
const route = useRoute()
// //
const queryParams = reactive({ const queryParams = reactive({
page: 1, page: 1,
count: 10 count: 10,
key: '',
state: '',
user_id: '',
user_key: '',
error: null
}) })
// //
@@ -278,14 +496,43 @@ const detailDialogVisible = ref(false)
const dialogType = ref('add') const dialogType = ref('add')
const orderFormRef = ref(null) const orderFormRef = ref(null)
//
const userSelectorVisible = ref(false)
const productSelectorVisible = ref(false)
const discountCodeSelectorVisible = ref(false)
const voucherSelectorVisible = ref(false)
//
const selectedUserInfo = ref(null)
const selectedProductInfo = ref(null)
const selectedDiscountCodeInfo = ref(null)
const selectedVoucherInfo = ref(null)
// //
const fetchOrderList = async () => { const fetchOrderList = async () => {
loading.value = true loading.value = true
try { try {
const res = await getOrderList(queryParams) //
const params = {}
Object.keys(queryParams).forEach(key => {
if (queryParams[key] !== '' && queryParams[key] !== null && queryParams[key] !== undefined) {
params[key] = queryParams[key]
}
})
const res = await getOrderList(params)
console.log('订单列表数据:', res.data) console.log('订单列表数据:', res.data)
if (res.data.code === 200) { if (res.data.code === 200) {
orderList.value = res.data.data.list || [] // ISO
const list = (res.data.data.list || []).map(item => {
if (item.expireTime) {
//
item._originalExpireTime = item.expireTime
//
item._expireTimeMs = isoToMilliseconds(item.expireTime)
}
return item
})
orderList.value = list
total.value = res.data.data.all_count || 0 total.value = res.data.data.all_count || 0
} }
} catch (error) { } catch (error) {
@@ -296,16 +543,9 @@ const fetchOrderList = async () => {
} }
} }
// // - 使
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
if (!dateStr) return '-' return formatDateTool(dateStr)
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
} }
// //
@@ -337,10 +577,11 @@ const handleQuery = () => {
// //
const resetQuery = () => { const resetQuery = () => {
queryParams.order_no = '' queryParams.key = ''
queryParams.state = ''
queryParams.user_id = '' queryParams.user_id = ''
queryParams.status = '' queryParams.user_key = ''
queryParams.dateRange = [] queryParams.error = null
queryParams.page = 1 queryParams.page = 1
fetchOrderList() fetchOrderList()
} }
@@ -365,6 +606,7 @@ const handleCurrentChange = (page) => {
const handleAdd = () => { const handleAdd = () => {
dialogType.value = 'add' dialogType.value = 'add'
dialogVisible.value = true dialogVisible.value = true
clearAllSelections()
Object.assign(orderForm, { Object.assign(orderForm, {
order_id: undefined, order_id: undefined,
name: '', name: '',
@@ -403,6 +645,16 @@ const handleView = async (row) => {
const handleEdit = (row) => { const handleEdit = (row) => {
dialogType.value = 'edit' dialogType.value = 'edit'
dialogVisible.value = true dialogVisible.value = true
clearAllSelections()
// 使ISO
let expireTimeMs = null
if (row._expireTimeMs !== undefined) {
expireTimeMs = row._expireTimeMs
} else if (row.expireTime) {
expireTimeMs = isoToMilliseconds(row.expireTime)
}
Object.assign(orderForm, { Object.assign(orderForm, {
order_id: row.id, order_id: row.id,
name: row.name, name: row.name,
@@ -412,7 +664,7 @@ const handleEdit = (row) => {
pay_num: row.payNum, pay_num: row.payNum,
price: row.price, price: row.price,
renew_price: row.renewPrice, renew_price: row.renewPrice,
expire_time: row.expireTime ? new Date(row.expireTime).getTime() / 1000 : 0, expire_time: expireTimeMs,
discount_code_id: 0, // discount_code_id: 0, //
coupon_id: 0, // coupon_id: 0, //
state: row.state, state: row.state,
@@ -420,6 +672,40 @@ const handleEdit = (row) => {
args: row.args || '', args: row.args || '',
note: row.note || '' note: row.note || ''
}) })
// ID
if (row.userId) {
selectedUserInfo.value = { user_id: row.userId, user_name: `用户${row.userId}` }
}
if (row.commodityId) {
selectedProductInfo.value = { id: row.commodityId, name: `商品${row.commodityId}` }
}
}
//
const handleRetryOrder = (row) => {
ElMessageBox.confirm(
`确认对订单「${row.name}」(ID: ${row.id}) 重试流程吗?`,
'重试订单流程',
{
confirmButtonText: '确认重试',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const res = await retryOrderHook({ order_id: row.id })
if (res.data.code === 200) {
ElMessage.success('重试流程已触发')
fetchOrderList()
} else {
ElMessage.error(res.data.message || '重试失败')
}
} catch (error) {
console.error('重试订单流程失败:', error)
ElMessage.error(error.response?.data?.message || '重试订单流程失败')
}
}).catch(() => {})
} }
// //
@@ -463,6 +749,13 @@ const submitForm = () => {
orderFormRef.value?.validate(async (valid) => { orderFormRef.value?.validate(async (valid) => {
if (valid) { if (valid) {
try { try {
//
let expireTimeSeconds = 0
if (orderForm.expire_time) {
const timestamp = timeToTimestamp(new Date(orderForm.expire_time))
expireTimeSeconds = timestamp || 0
}
// //
const submitData = { const submitData = {
name: orderForm.name, name: orderForm.name,
@@ -472,7 +765,7 @@ const submitForm = () => {
pay_num: Number(orderForm.pay_num), pay_num: Number(orderForm.pay_num),
price: Number(orderForm.price), price: Number(orderForm.price),
renew_price: Number(orderForm.renew_price), renew_price: Number(orderForm.renew_price),
expire_time: Number(orderForm.expire_time), expire_time: expireTimeSeconds,
discount_code_id: Number(orderForm.discount_code_id), discount_code_id: Number(orderForm.discount_code_id),
coupon_id: Number(orderForm.coupon_id), coupon_id: Number(orderForm.coupon_id),
state: Number(orderForm.state), state: Number(orderForm.state),
@@ -508,8 +801,67 @@ const submitForm = () => {
}) })
} }
//
const handleUserSelect = (user) => {
orderForm.user_id = user.user_id
selectedUserInfo.value = user
}
const clearUser = () => {
orderForm.user_id = undefined
selectedUserInfo.value = null
}
//
const handleProductSelect = (product) => {
orderForm.commodity_id = product.id
selectedProductInfo.value = product
//
if (product.table) {
orderForm.table = product.table
}
}
const clearProduct = () => {
orderForm.commodity_id = 0
selectedProductInfo.value = null
}
//
const handleDiscountCodeSelect = (code) => {
orderForm.discount_code_id = code.id
selectedDiscountCodeInfo.value = code
}
const clearDiscountCode = () => {
orderForm.discount_code_id = 0
selectedDiscountCodeInfo.value = null
}
//
const handleVoucherSelect = (voucher) => {
orderForm.coupon_id = voucher.id
selectedVoucherInfo.value = voucher
}
const clearVoucher = () => {
orderForm.coupon_id = 0
selectedVoucherInfo.value = null
}
//
const clearAllSelections = () => {
selectedUserInfo.value = null
selectedProductInfo.value = null
selectedDiscountCodeInfo.value = null
selectedVoucherInfo.value = null
}
// //
onMounted(() => { onMounted(() => {
if (route.query.key) queryParams.key = String(route.query.key)
if (route.query.user_id) queryParams.user_id = String(route.query.user_id)
if (route.query.state) queryParams.state = String(route.query.state)
fetchOrderList() fetchOrderList()
}) })
</script> </script>
@@ -532,13 +884,29 @@ onMounted(() => {
.filter-content { .filter-content {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
align-items: center; align-items: flex-start;
padding: 16px 20px; padding: 16px 20px;
gap: 20px; gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filter-form {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 8px;
}
.filter-form :deep(.el-form-item__label) {
font-size: 13px;
}
.action-bar { .action-bar {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -651,4 +1019,35 @@ onMounted(() => {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }
/* 选择器清除图标样式 */
.clear-icon {
cursor: pointer;
color: #909399;
transition: color 0.2s;
}
.clear-icon:hover {
color: #f56c6c;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
.error-text {
color: #f56c6c;
font-size: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
cursor: pointer;
}
.text-muted {
color: #c0c4cc;
}
</style> </style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+418
View File
@@ -0,0 +1,418 @@
<template>
<div class="goods-detail-page">
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" link class="back-btn">
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表
</el-button>
<el-divider direction="vertical" />
<span class="page-title">所有商品详情</span>
</div>
<div class="header-right">
<el-button type="primary" plain @click="loadDetail" :loading="loading">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</div>
</div>
<div class="main-content" v-loading="loading">
<!-- 空状态 -->
<el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
<el-button type="primary" @click="loadDetail">重新加载</el-button>
</el-empty>
<el-card class="profile-card" shadow="hover" v-if="detail">
<div class="profile-header">
<div class="profile-basic">
<div class="icon-wrapper">
<el-icon :size="48" color="#409eff"><Monitor /></el-icon>
</div>
<div class="identity">
<div class="name-row">
<h1 class="name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
<el-button size="small" type="primary" plain @click="openEdit">编辑</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">删除</el-button>
</div>
<div class="id-row">
<span class="label">ID:</span>
<span class="value">{{ detail.id || goodsId }}</span>
<el-divider direction="vertical" />
<span class="label">用户ID:</span>
<span class="value">{{ detail.userId || detail.user_id || '-' }}</span>
<el-divider direction="vertical" />
<span class="label">到期:</span>
<span class="value">{{ formatExpireTime(detail.expireTime || detail.expire_time) }}</span>
<el-divider direction="vertical" />
<span class="label">续费价:</span>
<span class="value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</span>
</div>
</div>
</div>
<div class="profile-stats">
<div class="stat-item">
<div class="stat-label">套餐ID</div>
<div class="stat-value">{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
</div>
<div class="stat-item">
<div class="stat-label">备注</div>
<div class="stat-value note-value">{{ detail.note || '-' }}</div>
</div>
</div>
</div>
<el-divider style="margin: 16px 0 12px" />
<el-descriptions :column="3" border size="small" style="width:100%">
<el-descriptions-item label="商品ID">{{ detail.goodId || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item>
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item>
<el-descriptions-item label="基础价格">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="hover" v-if="detail" class="related-card">
<template #header>
<span class="card-title">关联信息</span>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
<el-form :model="editForm" label-width="110px">
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
<el-form-item label="续费价格">
<div class="unit-input-row">
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="基础价格">
<div class="unit-input-row">
<el-input-number v-model="editForm.base_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="到期时间"><el-date-picker v-model="editForm.expire_time" type="datetime" placeholder="选择到期时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /></el-form-item>
<el-form-item label="归属项">
<div style="width:100%">
<template v-if="detail?.good?.table === 'kvm_service'">
<div class="selector-row" style="margin-bottom:8px">
<el-input :model-value="editForm._serviceName || (editForm._serviceId ? `主控服务 #${editForm._serviceId}` : '')"
readonly placeholder="1. 选择主控服务" style="flex:1" />
<el-button type="primary" @click="showServiceSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm._serviceId" @click="editForm._serviceId = 0; editForm._serviceName = ''; editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
<div class="selector-row">
<el-input :model-value="editForm._itemName || (editForm.item_id ? `虚拟机 #${editForm.item_id}` : '')"
readonly placeholder="2. 选择虚拟机" style="flex:1" />
<el-button type="primary" @click="showVmSelector = true" :disabled="!editForm._serviceId" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm.item_id" @click="editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
<div style="font-size:12px;color:#909399;margin-top:4px">归属项为虚拟机ID需先选择主控服务</div>
</template>
<el-input-number v-else v-model="editForm.item_id" :min="0" controls-position="right" style="width:100%" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitEdit">确定</el-button>
</template>
</el-dialog>
<VmSelectorPopup v-model="showVmSelector" :service-id="editForm._serviceId || 0"
@confirm="vm => { editForm.item_id = vm.id; editForm._itemName = vm.name }" />
<KvmServiceSelector v-model="showServiceSelector"
@confirm="s => { editForm._serviceId = s.id; editForm._serviceName = s.name; editForm.item_id = 0; editForm._itemName = '' }" />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Monitor } from '@element-plus/icons-vue'
import { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const goodsId = computed(() => parseInt(route.params.id) || 0)
const loading = ref(false)
const submitLoading = ref(false)
const detail = ref(null)
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
if (d.year() < 2000) return '永久'
return d.format('YYYY-MM-DD HH:mm:ss')
}
const goBack = () => router.push('/user-goods/list')
const loadDetail = async () => {
if (!goodsId.value) return
loading.value = true
try {
const res = await getUserGoodsDetail({ id: goodsId.value })
if (res?.data?.code === 200 && res?.data?.data) {
detail.value = res.data.data.data ?? res.data.data
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) }
finally { loading.value = false }
}
const editVisible = ref(false)
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
const showVmSelector = ref(false)
const showServiceSelector = ref(false)
const openEdit = () => {
const rawRenew = detail.value?.renewPrice || detail.value?.renew_price || 0
const rawBase = detail.value?.basePrice || detail.value?.base_price || 0
Object.assign(editForm, {
note: detail.value?.note || '',
renew_price: rawRenew / 100,
base_price: rawBase / 100,
expire_time: detail.value?.expireTime || detail.value?.expire_time
? dayjs(detail.value?.expireTime || detail.value?.expire_time).format('YYYY-MM-DD HH:mm:ss')
: '',
item_id: detail.value?.itemId || detail.value?.item_id || 0,
_serviceId: 0,
_serviceName: '',
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
})
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
editVisible.value = true
}
const submitEdit = async () => {
submitLoading.value = true
try {
const data = { id: goodsId.value }
if (editForm.note !== undefined) data.note = editForm.note
if (editForm.renew_price) data.renew_price = Math.round(editForm.renew_price * 100)
if (editForm.base_price) data.base_price = Math.round(editForm.base_price * 100)
if (editForm.expire_time) data.expire_time = editForm.expire_time
if (editForm.item_id) data.item_id = editForm.item_id
const res = await updateUserGoods(data)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '修改失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) }
finally { submitLoading.value = false }
}
const handleDelete = () => {
ElMessageBox.confirm('确定删除该用户商品吗?', '删除确认', { type: 'warning' })
.then(async () => {
try {
const res = await deleteUserGoods({ id: goodsId.value })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
onMounted(loadDetail)
watch(goodsId, (newId, oldId) => {
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
})
</script>
<style scoped>
.goods-detail-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border-bottom: 1px solid #e1e8ed;
}
.header-left {
display: flex;
align-items: center;
gap: 0;
}
.back-btn {
font-size: 14px;
color: #606266;
}
.back-btn:hover {
color: #409eff;
}
.page-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-right {
display: flex;
gap: 8px;
}
.main-content {
padding: 20px;
min-height: 300px;
}
.profile-card {
margin-bottom: 0;
border: 1px solid #e1e8ed;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
}
.profile-basic {
display: flex;
align-items: center;
gap: 20px;
}
.icon-wrapper {
width: 80px;
height: 80px;
border-radius: 12px;
background: linear-gradient(135deg, #e8f4fd 0%, #d6eaff 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.identity {
display: flex;
flex-direction: column;
gap: 8px;
}
.name-row {
display: flex;
align-items: center;
gap: 10px;
}
.name {
font-size: 22px;
font-weight: 600;
color: #303133;
margin: 0;
}
.id-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #909399;
flex-wrap: wrap;
}
.id-row .label {
color: #909399;
}
.id-row .value {
color: #606266;
font-weight: 500;
}
.profile-stats {
display: flex;
gap: 24px;
flex-shrink: 0;
}
.stat-item {
text-align: center;
min-width: 80px;
padding: 10px 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.stat-value {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.note-value {
font-weight: 400;
font-size: 13px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.related-card {
margin-top: 20px;
border: 1px solid #e1e8ed;
border-radius: 8px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.selector-row {
display: flex;
align-items: center;
width: 100%;
}
:deep(.el-descriptions__label) {
font-weight: 500;
color: #606266;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,204 @@
<template>
<div>
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="tagQueryParams" class="search-form">
<el-form-item label="关键词">
<el-input v-model="tagQueryParams.key" placeholder="搜索标签名称" clearable style="width: 180px" @keyup.enter="fetchTagList" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchTagList">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="resetTagQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleAddTag">
<el-icon><Plus /></el-icon>新增标签
</el-button>
<el-button type="success" @click="fetchTagList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</div>
<div class="table-section">
<el-table
v-loading="tagLoading"
:data="tagList"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="标签ID" width="100" />
<el-table-column prop="name" label="标签名称" min-width="200" />
<el-table-column prop="index" label="排序" width="100" sortable />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEditTag(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteTag(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无标签数据" :image-size="80" />
</template>
</el-table>
<el-pagination
v-model:current-page="tagQueryParams.page"
v-model:page-size="tagQueryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="tagTotal"
@size-change="handleTagSizeChange"
@current-change="handleTagCurrentChange"
background
class="pagination"
/>
</div>
<!-- 分组标签表单对话框 -->
<el-dialog
v-model="tagDialogVisible"
:title="tagDialogType === 'add' ? '新增分组标签' : '编辑分组标签'"
width="500px"
append-to-body
>
<el-form
ref="tagFormRef"
:model="tagForm"
:rules="tagRules"
label-width="100px"
>
<el-form-item label="标签名称" prop="name">
<el-input v-model="tagForm.name" placeholder="请输入标签名称" />
</el-form-item>
<el-form-item label="排序索引" prop="index">
<el-input-number v-model="tagForm.index" :min="0" :max="9999" placeholder="数值越小越靠前" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="tagDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitTagForm">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
getProductGroupTagList,
createProductGroupTag,
updateProductGroupTag,
deleteProductGroupTag
} from '@/api/admin/product'
const tagQueryParams = reactive({ page: 1, count: 10, key: '' })
const tagLoading = ref(false)
const tagList = ref([])
const tagTotal = ref(0)
const tagDialogVisible = ref(false)
const tagDialogType = ref('add')
const tagFormRef = ref(null)
const tagForm = reactive({ id: undefined, name: '', index: 0 })
const tagRules = { name: [{ required: true, message: '请输入标签名称', trigger: 'blur' }] }
const fetchTagList = async () => {
tagLoading.value = true
try {
const params = { ...tagQueryParams }
if (!params.key) delete params.key
const res = await getProductGroupTagList(params)
if (res.data.code === 200) {
const data = res.data.data
if (Array.isArray(data)) {
tagList.value = data
tagTotal.value = data.length
} else if (data && data.list) {
tagList.value = data.list
tagTotal.value = data.all_count || data.total || data.list.length
} else if (data && data.data) {
tagList.value = data.data
tagTotal.value = data.total || data.data.length
} else {
tagList.value = []
tagTotal.value = 0
}
}
} catch (error) {
console.error('获取标签列表失败:', error)
ElMessage.error('获取标签列表失败')
} finally {
tagLoading.value = false
}
}
const resetTagQuery = () => { tagQueryParams.key = ''; tagQueryParams.page = 1; fetchTagList() }
const handleTagSizeChange = (size) => { tagQueryParams.count = size; fetchTagList() }
const handleTagCurrentChange = (page) => { tagQueryParams.page = page; fetchTagList() }
const handleAddTag = () => {
tagDialogType.value = 'add'
tagDialogVisible.value = true
Object.assign(tagForm, { id: undefined, name: '', index: 0 })
tagFormRef.value?.resetFields()
}
const handleEditTag = (row) => {
tagDialogType.value = 'edit'
tagDialogVisible.value = true
Object.assign(tagForm, { id: row.id, name: row.name, index: row.index || 0 })
}
const handleDeleteTag = (row) => {
ElMessageBox.confirm(`确认删除标签 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteProductGroupTag({ id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchTagList() }
} catch (error) { ElMessage.error('删除失败') }
}).catch(() => {})
}
const submitTagForm = () => {
tagFormRef.value?.validate(async (valid) => {
if (valid) {
try {
let res
if (tagDialogType.value === 'add') {
res = await createProductGroupTag({ name: tagForm.name, index: tagForm.index })
} else {
res = await updateProductGroupTag({ id: tagForm.id, name: tagForm.name, index: tagForm.index })
}
if (res.data.code === 200) {
ElMessage.success(tagDialogType.value === 'add' ? '新增成功' : '修改成功')
tagDialogVisible.value = false
fetchTagList()
}
} catch (error) { ElMessage.error('操作失败') }
}
})
}
defineExpose({ fetchTagList })
</script>
<style scoped>
.filter-section { padding: 0; border-bottom: 1px solid #e1e8ed; background: #fafbfc; }
.filter-content { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; gap: 20px; flex-wrap: wrap; }
.search-form { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0; }
.search-form :deep(.el-form-item) { margin-bottom: 0; margin-right: 0; }
.action-bar { display: flex; gap: 12px; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
.table-section { padding: 0; }
.action-buttons { display: flex; gap: 4px; align-items: center; flex-wrap: nowrap; }
.action-buttons .el-button { padding: 4px 8px; }
.pagination { margin-top: 20px; padding: 16px 20px; border-top: 1px solid #e1e8ed; background: #fafbfc; justify-content: flex-end; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0; }
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+48 -16
View File
@@ -184,25 +184,57 @@ import {
User, Edit, Document, Timer, EditPen, Message, User, Edit, Document, Timer, EditPen, Message,
Phone, OfficeBuilding, UploadFilled Phone, OfficeBuilding, UploadFilled
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/userStore.js'
// //
const isEditing = ref(false) const isEditing = ref(false)
const loading = ref(false) const loading = ref(false)
const userStore = useUserStore()
// 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({ const userInfo = reactive(getSavedUserInfo())
username: 'admin',
realName: '管理员',
email: 'admin@example.com',
phone: '13800138000',
department: '技术部',
position: '系统管理员',
role: '超级管理员',
createTime: '2023-01-01 00:00:00',
lastLogin: '2023-06-15 10:30:45',
bio: '系统管理员,负责系统的日常维护和管理工作。拥有丰富的系统管理经验,精通Linux服务器配置和维护,熟悉网络安全,对系统性能优化有独到见解。',
avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
})
// //
const userForm = reactive({...userInfo}) const userForm = reactive({...userInfo})
@@ -296,9 +328,9 @@ const handleAvatarSuccess = (res) => {
// //
const fetchUserInfo = async () => { const fetchUserInfo = async () => {
try { try {
// API // store
await new Promise(resolve => setTimeout(resolve, 500)) const savedInfo = getSavedUserInfo()
// userInfo Object.assign(userInfo, savedInfo)
} catch (error) { } catch (error) {
ElMessage.error('获取用户信息失败') ElMessage.error('获取用户信息失败')
console.error(error) console.error(error)
-27
View File
@@ -349,33 +349,6 @@ onMounted(() => {
gap: 12px; gap: 12px;
} }
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
+400
View File
@@ -0,0 +1,400 @@
<template>
<div class="menu-manage-container">
<el-card class="main-container" shadow="never">
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="关键词">
<el-input v-model="queryParams.key" placeholder="菜单名称/路径" clearable style="width: 180px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="父级菜单">
<el-select v-model="queryParams.parent_id" placeholder="全部" clearable style="width: 160px">
<el-option v-for="m in parentMenuOptions" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-radio-group v-model="viewMode" size="default" @change="handleViewModeChange">
<el-radio-button value="list">
<el-icon style="vertical-align: -2px;"><Grid /></el-icon> 列表视图
</el-radio-button>
<el-radio-button value="tree">
<el-icon style="vertical-align: -2px;"><Connection /></el-icon> 树状视图
</el-radio-button>
</el-radio-group>
<el-button v-if="viewMode === 'list'" type="primary" @click="handleAdd(null)">
<el-icon><Plus /></el-icon>新增顶级菜单
</el-button>
<el-button type="success" @click="handleRefresh">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</div>
<!-- 列表视图 -->
<div v-if="viewMode === 'list'" class="table-section">
<el-table
v-loading="loading"
:data="menuList"
style="width: 100%"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="菜单名称" min-width="180" />
<el-table-column prop="path" label="路径" min-width="200">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.path || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="icon" label="图标" width="120">
<template #default="{ row }">
<div v-if="row.icon" style="display: flex; align-items: center; gap: 6px;">
<el-icon><component :is="row.icon" /></el-icon>
<span>{{ row.icon }}</span>
</div>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="parentId" label="父级ID" width="80">
<template #default="{ row }">
{{ row.parentId ?? '-' }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleAdd(row)">添加子菜单</el-button>
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无菜单数据" :image-size="80" />
</template>
</el-table>
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</div>
<!-- 树状视图 -->
<div v-if="viewMode === 'tree'" v-loading="myPermLoading" class="tree-section">
<el-tree
v-if="myPermTree.length > 0"
:data="myPermTree"
node-key="id"
default-expand-all
:expand-on-click-node="false"
>
<template #default="{ data }">
<div class="perm-tree-node">
<el-icon v-if="data.icon" style="margin-right: 6px; flex-shrink: 0;"><component :is="data.icon" /></el-icon>
<span class="perm-tree-title">{{ data.title }}</span>
<el-tag size="small" type="info" style="margin-left: 8px;">{{ data.path || '-' }}</el-tag>
<el-tag :type="data.enable ? 'success' : 'danger'" size="small" style="margin-left: 6px;">
{{ data.enable ? '启用' : '禁用' }}
</el-tag>
</div>
</template>
</el-tree>
<el-empty v-if="!myPermLoading && myPermTree.length === 0" description="暂无菜单权限数据" :image-size="80" />
</div>
</el-card>
<!-- 菜单表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增菜单' : '编辑菜单'"
width="550px"
append-to-body
>
<el-form ref="formRef" :model="menuForm" :rules="menuRules" label-width="100px">
<el-form-item label="菜单名称" prop="title">
<el-input v-model="menuForm.title" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单路径" prop="path">
<MenuPathSelector v-model="menuForm.path" />
</el-form-item>
<el-form-item label="菜单图标" prop="icon">
<IconSelector v-model="menuForm.icon" />
</el-form-item>
<el-form-item label="父级菜单">
<el-select v-model="menuForm.parent_id" placeholder="无(顶级菜单)" clearable style="width: 100%">
<el-option label="无(顶级菜单)" :value="0" />
<el-option v-for="m in parentMenuOptions" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Refresh, Grid, Connection } from '@element-plus/icons-vue'
import { getWebRoutsList, addWebRouts, updateWebRouts, deleteWebRouts, getMyWebRoutsPermission } from '@/api/admin/webRouts'
import { formatDate as formatDateTool } from '@/utils/tool'
import IconSelector from '@/components/admin/IconSelector.vue'
import MenuPathSelector from '@/components/admin/MenuPathSelector.vue'
const loading = ref(false)
const menuList = ref([])
const parentMenuOptions = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogType = ref('add')
const formRef = ref(null)
const viewMode = ref('list')
const queryParams = reactive({
page: 1,
count: 10,
key: '',
parent_id: null
})
const menuForm = reactive({
id: undefined,
title: '',
path: '',
icon: '',
parent_id: 0
})
const menuRules = {
title: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入菜单路径', trigger: 'blur' }]
}
const formatDate = (dateStr) => formatDateTool(dateStr)
const flattenForParent = (list) => {
const result = []
for (const item of list) {
result.push(item)
if (item.children?.length) {
result.push(...flattenForParent(item.children))
}
}
return result
}
const fetchMenuList = async () => {
loading.value = true
try {
const params = {}
Object.keys(queryParams).forEach(key => {
if (queryParams[key] !== '' && queryParams[key] !== null && queryParams[key] !== undefined) {
params[key] = queryParams[key]
}
})
const res = await getWebRoutsList(params)
if (res.data.code === 200) {
menuList.value = res.data.data?.list || []
total.value = res.data.data?.all_count || 0
parentMenuOptions.value = flattenForParent(menuList.value)
}
} catch (error) {
console.error('获取菜单列表失败:', error)
ElMessage.error('获取菜单列表失败')
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.page = 1
fetchMenuList()
}
const resetQuery = () => {
queryParams.key = ''
queryParams.parent_id = null
queryParams.page = 1
fetchMenuList()
}
const handleSizeChange = (size) => {
queryParams.count = size
fetchMenuList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchMenuList()
}
const handleAdd = (parentRow) => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(menuForm, {
id: undefined,
title: '',
path: '',
icon: '',
parent_id: parentRow?.id || 0
})
formRef.value?.resetFields()
}
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
Object.assign(menuForm, {
id: row.id,
title: row.title,
path: row.path,
icon: row.icon,
parent_id: row.parentId || 0
})
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除菜单「${row.title}」吗?删除后其子菜单也将受到影响。`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteWebRouts({ id: row.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchMenuList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
const submitForm = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return
try {
const submitData = {
title: menuForm.title,
path: menuForm.path,
icon: menuForm.icon
}
if (menuForm.parent_id) {
submitData.parent_id = menuForm.parent_id
}
let res
if (dialogType.value === 'add') {
res = await addWebRouts(submitData)
} else {
submitData.id = menuForm.id
res = await updateWebRouts(submitData)
}
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchMenuList()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
const myPermLoading = ref(false)
const myPermTree = ref([])
const fetchMyPermission = async () => {
myPermLoading.value = true
try {
const res = await getMyWebRoutsPermission()
if (res.data.code === 200) {
myPermTree.value = res.data.data || []
} else {
ElMessage.error(res.data.message || '获取失败')
}
} catch (error) {
console.error('获取我的菜单权限失败:', error)
ElMessage.error('获取我的菜单权限失败')
} finally {
myPermLoading.value = false
}
}
const handleViewModeChange = (mode) => {
if (mode === 'list') {
fetchMenuList()
} else {
fetchMyPermission()
}
}
const handleRefresh = () => {
if (viewMode.value === 'list') {
fetchMenuList()
} else {
fetchMyPermission()
}
}
onMounted(() => {
fetchMenuList()
})
</script>
<style scoped>
.menu-manage-container { padding: 0; }
.main-container { border: 1px solid #e1e8ed; background: #ffffff; }
.filter-section { padding: 0; border-bottom: 1px solid #e1e8ed; background: #fafbfc; }
.filter-content { display: flex; justify-content: space-between; align-items: flex-start; padding: 16px 20px; gap: 20px; flex-wrap: wrap; }
.filter-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.filter-form :deep(.el-form-item) { margin-bottom: 0; margin-right: 8px; }
.filter-form :deep(.el-form-item__label) { font-size: 13px; }
.action-bar { display: flex; gap: 12px; flex-shrink: 0; }
.table-section { padding: 0; }
.action-buttons { display: flex; gap: 8px; align-items: center; }
.text-muted { color: #c0c4cc; }
.pagination { margin-top: 20px; padding: 16px 20px; border-top: 1px solid #e1e8ed; background: #fafbfc; justify-content: flex-end; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; }
:deep(.el-card__body) { padding: 0; }
:deep(.el-table th) { background: #f8f9fa !important; border-bottom: 2px solid #e1e8ed; color: #2c3e50; font-weight: 600; font-size: 13px; }
:deep(.el-table td) { border-bottom: 1px solid #f0f2f5; color: #34495e; }
:deep(.el-table tr:hover > td) { background-color: #f8f9fa !important; }
.tree-section { padding: 16px 20px; min-height: 300px; }
.perm-tree-node { display: flex; align-items: center; padding: 2px 0; width: 100%; }
.perm-tree-title { font-size: 13px; font-weight: 500; }
.tree-section :deep(.el-tree-node__content) { height: 38px; }
.tree-section :deep(.el-tree-node__content:hover) { background-color: #f5f7fa; }
.action-bar { display: flex; gap: 12px; flex-shrink: 0; align-items: center; }
</style>
+610
View File
@@ -0,0 +1,610 @@
<template>
<div class="menu-permission-container">
<el-card class="main-container" shadow="never">
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="类型">
<el-select v-model="queryParams.owner_type" placeholder="请选择类型" clearable style="width: 130px" @change="handleOwnerTypeChange">
<el-option label="用户" value="user" />
<el-option label="管理员组" value="group" />
</el-select>
</el-form-item>
<el-form-item label="用户" v-if="queryParams.owner_type === 'user'">
<div class="selector-inline">
<el-tag v-if="queryParams.user_id" type="primary" closable @close="clearQueryUser" style="margin-right: 8px;">
{{ queryUserName || `用户 #${queryParams.user_id}` }}
</el-tag>
<el-button type="primary" plain @click="openUserSelector('query')" size="default">
<el-icon><User /></el-icon>
{{ queryParams.user_id ? '重新选择' : '选择用户' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="管理员组" v-if="queryParams.owner_type === 'group'">
<div class="selector-inline">
<el-tag v-if="queryParams.admin_group_id" type="success" closable @close="clearQueryGroup" style="margin-right: 8px;">
{{ queryGroupName || `组 #${queryParams.admin_group_id}` }}
</el-tag>
<el-button type="success" plain @click="openGroupSelector('query')" size="default">
<el-icon><User /></el-icon>
{{ queryParams.admin_group_id ? '重新选择' : '选择管理员组' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>分配权限
</el-button>
<el-button type="success" @click="handleRefresh">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</div>
<div class="table-section">
<el-table
v-loading="loading"
:data="permissionList"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="所属类型" width="120">
<template #default="{ row }">
<el-tag :type="getOwnerType(row) === 'user' ? 'primary' : 'success'" size="small">
{{ getOwnerType(row) === 'user' ? '用户' : '管理员组' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="所属对象" width="150">
<template #default="{ row }">
<span v-if="row.userId">用户 #{{ row.userId }}</span>
<span v-else-if="row.adminGroupId">管理员组 #{{ row.adminGroupId }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="菜单" min-width="200">
<template #default="{ row }">
<div v-if="row.webRouts">
<span style="font-weight: 500;">{{ row.webRouts.title }}</span>
<el-tag size="small" type="info" style="margin-left: 8px;">{{ row.webRouts.path }}</el-tag>
</div>
<span v-else class="text-muted">菜单ID: {{ row.webRoutsId }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.enable ? 'success' : 'danger'" size="small">
{{ row.enable ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleToggleEnable(row)">
{{ row.enable ? '禁用' : '启用' }}
</el-button>
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无权限数据" :image-size="80" />
</template>
</el-table>
</div>
</el-card>
<!-- 用户选择弹窗 -->
<UserListSelector
v-model="userSelectorVisible"
:current-user-id="selectorTarget === 'query' ? queryParams.user_id : permForm.user_id"
@confirm="handleUserConfirm"
/>
<!-- 管理员组选择弹窗 -->
<UserGroupSelector
v-model="groupSelectorVisible"
:current-group-id="selectorTarget === 'query' ? queryParams.admin_group_id : permForm.admin_group_id"
admin-group
@confirm="handleGroupConfirm"
/>
<!-- 菜单选择弹窗 -->
<el-dialog v-model="menuSelectorVisible" title="选择菜单" width="700px" append-to-body @open="openMenuSelector">
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<el-input v-model="menuSearchKey" placeholder="搜索菜单名称或路径" clearable style="flex: 1;" @keyup.enter="fetchMenuSelectorList">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="fetchMenuSelectorList">搜索</el-button>
<el-button type="success" @click="fetchMenuSelectorList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-table
v-loading="menuSelectorLoading"
:data="menuSelectorFlatList"
highlight-current-row
@current-change="handleMenuCurrentChange"
:height="400"
style="width: 100%"
row-key="id"
>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="菜单名称" min-width="160">
<template #default="{ row }">
<span :style="{ paddingLeft: (row._level || 0) * 20 + 'px' }">
<span v-if="row._level" style="color: #c0c4cc; margin-right: 4px;"></span>
{{ row.title }}
</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="180">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.path || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="icon" label="图标" width="80">
<template #default="{ row }">
<el-icon v-if="row.icon"><component :is="row.icon" /></el-icon>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="menuSelectorVisible = false">取消</el-button>
<el-button type="primary" :disabled="!menuSelectorTemp" @click="confirmMenuSelect">确定选择</el-button>
</template>
</el-dialog>
<!-- 分配/编辑权限对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '分配菜单权限' : '编辑菜单权限'"
width="600px"
append-to-body
>
<el-form ref="formRef" :model="permForm" :rules="formRules" label-width="120px">
<el-form-item label="所属类型" prop="owner_type">
<el-select v-model="permForm.owner_type" placeholder="请选择" style="width: 100%" :disabled="dialogType === 'edit'" @change="handleFormOwnerTypeChange">
<el-option label="用户" value="user" />
<el-option label="管理员组" value="group" />
</el-select>
</el-form-item>
<el-form-item label="用户" prop="user_id" v-if="permForm.owner_type === 'user'">
<div class="selector-inline" style="width: 100%;">
<el-tag v-if="permForm.user_id" type="primary" closable @close="permForm.user_id = null" style="margin-right: 8px;">
{{ formUserName || `用户 #${permForm.user_id}` }}
</el-tag>
<el-button type="primary" plain @click="openUserSelector('form')">
<el-icon><User /></el-icon>
{{ permForm.user_id ? '重新选择' : '选择用户' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="管理员组" prop="admin_group_id" v-if="permForm.owner_type === 'group'">
<div class="selector-inline" style="width: 100%;">
<el-tag v-if="permForm.admin_group_id" type="success" closable @close="permForm.admin_group_id = null" style="margin-right: 8px;">
{{ formGroupName || `组 #${permForm.admin_group_id}` }}
</el-tag>
<el-button type="success" plain @click="openGroupSelector('form')">
<el-icon><User /></el-icon>
{{ permForm.admin_group_id ? '重新选择' : '选择管理员组' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="菜单" prop="web_routs_id">
<div class="selector-inline" style="width: 100%;">
<el-tag v-if="permForm.web_routs_id" closable @close="clearFormMenu" style="margin-right: 8px;">
{{ formMenuName || `菜单 #${permForm.web_routs_id}` }}
</el-tag>
<el-button plain @click="menuSelectorVisible = true">
<el-icon><Menu /></el-icon>
{{ permForm.web_routs_id ? '重新选择' : '选择菜单' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="permForm.enable" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Refresh, User, Menu } from '@element-plus/icons-vue'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
import {
getWebRoutsList,
getWebRoutsPermissionList,
addWebRoutsPermission,
updateWebRoutsPermission,
deleteWebRoutsPermission
} from '@/api/admin/webRouts'
import { formatDate as formatDateTool } from '@/utils/tool'
const loading = ref(false)
const permissionList = ref([])
const dialogVisible = ref(false)
const dialogType = ref('add')
const formRef = ref(null)
const userSelectorVisible = ref(false)
const groupSelectorVisible = ref(false)
const menuSelectorVisible = ref(false)
const menuSelectorLoading = ref(false)
const menuSelectorTemp = ref(null)
const menuSearchKey = ref('')
const selectorTarget = ref('query')
const queryUserName = ref('')
const queryGroupName = ref('')
const formUserName = ref('')
const formGroupName = ref('')
const formMenuName = ref('')
const queryParams = reactive({
owner_type: 'group',
user_id: null,
admin_group_id: null
})
const permForm = reactive({
id: undefined,
web_routs_id: null,
enable: true,
owner_type: 'group',
admin_group_id: null,
user_id: null
})
const formRules = {
owner_type: [{ required: true, message: '请选择所属类型', trigger: 'change' }],
web_routs_id: [{ required: true, message: '请选择菜单', trigger: 'change' }],
user_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
admin_group_id: [{ required: true, message: '请选择管理员组', trigger: 'change' }]
}
const formatDate = (dateStr) => formatDateTool(dateStr)
const getOwnerType = (row) => {
if (row.userId) return 'user'
if (row.adminGroupId) return 'group'
return 'unknown'
}
const fetchPermissionList = async () => {
loading.value = true
try {
const params = {}
if (queryParams.owner_type) params.owner_type = queryParams.owner_type
if (queryParams.owner_type === 'user' && queryParams.user_id) params.user_id = queryParams.user_id
if (queryParams.owner_type === 'group' && queryParams.admin_group_id) params.admin_group_id = queryParams.admin_group_id
const res = await getWebRoutsPermissionList(params)
if (res.data.code === 200) {
permissionList.value = res.data.data || []
}
} catch (error) {
console.error('获取权限列表失败:', error)
ElMessage.error('获取权限列表失败')
} finally {
loading.value = false
}
}
const flattenMenuTree = (list, level = 0) => {
const result = []
for (const item of list) {
result.push({ ...item, _level: level, children: undefined })
if (item.children?.length) {
result.push(...flattenMenuTree(item.children, level + 1))
}
}
return result
}
const menuSelectorFlatList = ref([])
const openMenuSelector = () => {
menuSelectorTemp.value = null
menuSearchKey.value = ''
fetchMenuSelectorList()
}
const fetchMenuSelectorList = async () => {
menuSelectorLoading.value = true
try {
const params = { page: 1, count: 10 }
if (menuSearchKey.value) params.key = menuSearchKey.value
const res = await getWebRoutsList(params)
if (res.data.code === 200) {
const treeList = res.data.data?.list || []
menuSelectorFlatList.value = flattenMenuTree(treeList)
}
} catch (error) {
console.error('获取菜单列表失败:', error)
} finally {
menuSelectorLoading.value = false
}
}
const handleMenuCurrentChange = (row) => {
menuSelectorTemp.value = row
}
const confirmMenuSelect = () => {
if (!menuSelectorTemp.value) return
permForm.web_routs_id = menuSelectorTemp.value.id
formMenuName.value = `${menuSelectorTemp.value.title} (${menuSelectorTemp.value.path})`
menuSelectorVisible.value = false
menuSelectorTemp.value = null
menuSearchKey.value = ''
}
const clearFormMenu = () => {
permForm.web_routs_id = null
formMenuName.value = ''
}
const handleOwnerTypeChange = () => {
queryParams.user_id = null
queryParams.admin_group_id = null
queryUserName.value = ''
queryGroupName.value = ''
}
const handleFormOwnerTypeChange = () => {
permForm.user_id = null
permForm.admin_group_id = null
formUserName.value = ''
formGroupName.value = ''
}
const canQuery = () => {
if (queryParams.owner_type === 'user' && !queryParams.user_id) {
ElMessage.warning('请先选择用户')
return false
}
if (queryParams.owner_type === 'group' && !queryParams.admin_group_id) {
ElMessage.warning('请先选择管理员组')
return false
}
if (!queryParams.owner_type) {
ElMessage.warning('请先选择类型')
return false
}
return true
}
const handleQuery = () => {
if (!canQuery()) return
fetchPermissionList()
}
const handleRefresh = () => {
if (!canQuery()) return
fetchPermissionList()
}
const resetQuery = () => {
queryParams.owner_type = 'group'
queryParams.user_id = null
queryParams.admin_group_id = null
queryUserName.value = ''
queryGroupName.value = ''
permissionList.value = []
}
const clearQueryUser = () => {
queryParams.user_id = null
queryUserName.value = ''
}
const clearQueryGroup = () => {
queryParams.admin_group_id = null
queryGroupName.value = ''
}
const openUserSelector = (target) => {
selectorTarget.value = target
userSelectorVisible.value = true
}
const openGroupSelector = (target) => {
selectorTarget.value = target
groupSelectorVisible.value = true
}
const handleUserConfirm = (user) => {
const id = user.user_id || user.UserId || user.userId
const name = user.user_name || user.UserName || user.userName || `用户 #${id}`
if (selectorTarget.value === 'query') {
queryParams.user_id = id
queryUserName.value = name
} else {
permForm.user_id = id
formUserName.value = name
}
}
const handleGroupConfirm = (group) => {
const name = group.name || group.groupName || `组 #${group.id}`
const id = group.id
if (selectorTarget.value === 'query') {
queryParams.admin_group_id = id
queryGroupName.value = name
} else {
permForm.admin_group_id = id
formGroupName.value = name
}
}
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(permForm, {
id: undefined,
web_routs_id: null,
enable: true,
owner_type: 'group',
admin_group_id: null,
user_id: null
})
formUserName.value = ''
formGroupName.value = ''
formMenuName.value = ''
formRef.value?.resetFields()
}
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
const ownerType = row.userId ? 'user' : 'group'
Object.assign(permForm, {
id: row.id,
web_routs_id: row.webRoutsId,
enable: row.enable,
owner_type: ownerType,
admin_group_id: row.adminGroupId || null,
user_id: row.userId || null
})
formUserName.value = row.userId ? `用户 #${row.userId}` : ''
formGroupName.value = row.adminGroupId ? `组 #${row.adminGroupId}` : ''
if (row.webRouts) {
formMenuName.value = `${row.webRouts.title} (${row.webRouts.path})`
} else {
formMenuName.value = row.webRoutsId ? `菜单 #${row.webRoutsId}` : ''
}
}
const handleToggleEnable = async (row) => {
const newEnable = !row.enable
const action = newEnable ? '启用' : '禁用'
try {
await ElMessageBox.confirm(`确认${action}该菜单权限吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await updateWebRoutsPermission({
id: row.id,
enable: newEnable
})
if (res.data.code === 200) {
ElMessage.success(`${action}成功`)
fetchPermissionList()
} else {
ElMessage.error(res.data.message || `${action}失败`)
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.message || `${action}失败`)
}
}
}
const handleDelete = (row) => {
ElMessageBox.confirm('确认删除该菜单权限吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteWebRoutsPermission({ id: row.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchPermissionList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
const submitForm = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return
try {
const submitData = {
web_routs_id: permForm.web_routs_id,
enable: permForm.enable,
owner_type: permForm.owner_type
}
if (permForm.owner_type === 'user') {
submitData.user_id = permForm.user_id
} else {
submitData.admin_group_id = permForm.admin_group_id
}
let res
if (dialogType.value === 'add') {
res = await addWebRoutsPermission(submitData)
} else {
submitData.id = permForm.id
res = await updateWebRoutsPermission(submitData)
}
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '分配成功' : '修改成功')
dialogVisible.value = false
fetchPermissionList()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
</script>
<style scoped>
.menu-permission-container { padding: 0; }
.main-container { border: 1px solid #e1e8ed; background: #ffffff; }
.filter-section { padding: 0; border-bottom: 1px solid #e1e8ed; background: #fafbfc; }
.filter-content { display: flex; justify-content: space-between; align-items: flex-start; padding: 16px 20px; gap: 20px; flex-wrap: wrap; }
.filter-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.filter-form :deep(.el-form-item) { margin-bottom: 0; margin-right: 8px; }
.filter-form :deep(.el-form-item__label) { font-size: 13px; }
.action-bar { display: flex; gap: 12px; flex-shrink: 0; }
.selector-inline { display: flex; align-items: center; }
.table-section { padding: 0; }
.action-buttons { display: flex; gap: 8px; align-items: center; }
.text-muted { color: #c0c4cc; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; }
:deep(.el-card__body) { padding: 0; }
:deep(.el-table th) { background: #f8f9fa !important; border-bottom: 2px solid #e1e8ed; color: #2c3e50; font-weight: 600; font-size: 13px; }
:deep(.el-table td) { border-bottom: 1px solid #f0f2f5; color: #34495e; }
:deep(.el-table tr:hover > td) { background-color: #f8f9fa !important; }
</style>
+253 -46
View File
@@ -24,9 +24,15 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="管理员组" v-if="queryParams.owner_type === 'group'"> <el-form-item label="管理员组" v-if="queryParams.owner_type === 'group'">
<el-select v-model="queryParams.admin_group_id" placeholder="请选择管理员组" clearable filterable style="width: 200px"> <div class="selector-inline">
<el-option v-for="item in adminGroupOptions" :key="item.id" :label="`${item.name} (ID: ${item.id})`" :value="item.id" /> <el-tag v-if="queryParams.admin_group_id" type="success" closable @close="clearQueryGroup" style="margin-right: 8px;">
</el-select> {{ getQueryGroupName() }}
</el-tag>
<el-button type="success" plain @click="openQueryGroupSelector" size="default">
<el-icon><User /></el-icon>
{{ queryParams.admin_group_id ? '重新选择' : '选择管理员组' }}
</el-button>
</div>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleQuery"> <el-button type="primary" @click="handleQuery">
@@ -115,14 +121,35 @@
</div> </div>
</el-card> </el-card>
<!-- 用户选择弹窗 --> <!-- 用户选择弹窗 - 使用UserListSelector组件 -->
<el-dialog <UserListSelector
v-model="userSelectorVisible" v-model="userSelectorVisible"
:current-user-id="selectorType === 'query' ? queryParams.user_id : permissionForm.user_id"
@confirm="handleUserSelectorConfirm"
/>
<!-- 管理员组选择弹窗 - 使用UserGroupSelector组件 -->
<UserGroupSelector
v-model="groupSelectorVisible"
:current-group-id="selectorType === 'query' ? queryParams.admin_group_id : permissionForm.admin_group_id"
admin-group
@confirm="handleGroupSelectorConfirm"
/>
<!-- 路径权限选择弹窗 -->
<PermissionPathSelector
v-model="permissionSelectorVisible"
:current-permission-id="permissionForm.permission_id"
@confirm="handlePermissionSelectorConfirm"
/>
<!-- 旧的用户选择弹窗 - 已废弃 -->
<!-- <el-dialog
v-model="userSelectorVisibleOld"
title="选择用户" title="选择用户"
width="800px" width="800px"
class="user-selector-dialog" class="user-selector-dialog"
> >
<!-- 搜索栏 -->
<div class="selector-search"> <div class="selector-search">
<el-input <el-input
v-model="userSearchParams.key" v-model="userSearchParams.key"
@@ -142,7 +169,6 @@
<el-button @click="resetUserSearch">重置</el-button> <el-button @click="resetUserSearch">重置</el-button>
</div> </div>
<!-- 用户表格 -->
<el-table <el-table
v-loading="userSelectorLoading" v-loading="userSelectorLoading"
:data="userSelectorList" :data="userSelectorList"
@@ -164,7 +190,7 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<!-- 分页 -->
<el-pagination <el-pagination
v-model:current-page="userSearchParams.page" v-model:current-page="userSearchParams.page"
v-model:page-size="userSearchParams.count" v-model:page-size="userSearchParams.count"
@@ -178,12 +204,12 @@
/> />
<template #footer> <template #footer>
<el-button @click="userSelectorVisible = false">取消</el-button> <el-button @click="userSelectorVisibleOld = false">取消</el-button>
<el-button type="primary" @click="confirmUserSelection" :disabled="!selectedUserTemp"> <el-button type="primary" @click="confirmUserSelection" :disabled="!selectedUserTemp">
确定选择 确定选择
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog> -->
<!-- 分配权限对话框 --> <!-- 分配权限对话框 -->
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
@@ -204,45 +230,80 @@
<div class="form-tip">如果是 user 则填写 user_id如果是 group 则填写 admin_group_id</div> <div class="form-tip">如果是 user 则填写 user_id如果是 group 则填写 admin_group_id</div>
</el-form-item> </el-form-item>
<el-form-item label="用户" prop="user_id" v-if="permissionForm.owner_type === 'user'" > <el-form-item label="用户" prop="user_id" v-if="permissionForm.owner_type === 'user'" >
<div class="user_selector-inline"> <div class="recommend-user-selector">
<el-tag v-if="permissionForm.user_id" type="primary" closable @close="clearFormUser" style="margin-right: 8px;"> <el-input
{{ getFormUserName() }} :model-value="getFormUserName()"
</el-tag> placeholder="点击选择用户"
<el-button type="primary" plain @click="openFormUserSelector" size="default" :disabled="permissionForm.user_id"> readonly
<el-icon><User /></el-icon> @click="openFormUserSelector"
{{ permissionForm.user_id ? '重新选择' : '选择用户' }} :disabled="!!permissionForm.id"
>
<template #append>
<el-button @click="openFormUserSelector" :disabled="!!permissionForm.id">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button
v-if="permissionForm.user_id && !permissionForm.id"
type="danger"
link
@click="clearFormUser"
class="clear-btn"
>
清除
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="管理员组" prop="admin_group_id" v-if="permissionForm.owner_type === 'group'"> <el-form-item label="管理员组" prop="admin_group_id" v-if="permissionForm.owner_type === 'group'">
<el-select v-model="permissionForm.admin_group_id" placeholder="请选择管理员组" filterable style="width: 100%"> <div class="recommend-user-selector">
<el-option v-for="item in adminGroupOptions" :key="item.id" :label="`${item.name} (ID: ${item.id})`" :value="item.id" /> <el-input
</el-select> :model-value="getFormGroupName()"
placeholder="点击选择管理员组"
readonly
@click="openFormGroupSelector"
:disabled="!!permissionForm.id"
>
<template #append>
<el-button @click="openFormGroupSelector" :disabled="!!permissionForm.id">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button
v-if="permissionForm.admin_group_id && !permissionForm.id"
type="danger"
link
@click="clearFormGroup"
class="clear-btn"
>
清除
</el-button>
</div>
</el-form-item> </el-form-item>
<el-form-item label="路径权限" prop="permission_id"> <el-form-item label="路径权限" prop="permission_id">
<div style="display: flex; gap: 8px;"> <div class="recommend-user-selector">
<el-select <el-input
v-model="permissionForm.permission_id" :model-value="getFormPermissionName()"
placeholder="选择路径权限" placeholder="点击选择路径权限"
filterable readonly
style="flex: 1" @click="openPermissionSelector"
:loading="permissionLoading"
> >
<el-option <template #append>
v-for="item in permissionOptions" <el-button @click="openPermissionSelector">
:key="item.id" <el-icon><Search /></el-icon>
:value="item.id" </el-button>
> </template>
<div style="display: flex; justify-content: space-between; align-items: center;"> </el-input>
<span> <el-button
<el-tag v-if="item.method" :type="getMethodTag(item.method)" size="small" style="margin-right: 8px;">{{ item.method }}</el-tag> v-if="permissionForm.permission_id"
{{ item.path }} type="danger"
</span> link
<span style="color: #999; font-size: 12px; margin-left: 12px;">{{ item.note || item.name || `ID: ${item.id}` }}</span> @click="clearFormPermission"
</div> class="clear-btn"
</el-option> >
</el-select> 清除
<el-button @click="fetchPermissionList" :loading="permissionLoading" :icon="Refresh">刷新</el-button> </el-button>
</div> </div>
<div class="form-tip"> {{ permissionOptions.length }} 个路径权限可选</div> <div class="form-tip"> {{ permissionOptions.length }} 个路径权限可选</div>
</el-form-item> </el-form-item>
@@ -283,6 +344,9 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, User } from '@element-plus/icons-vue' import { Plus, Search, Refresh, User } from '@element-plus/icons-vue'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
import PermissionPathSelector from '@/components/admin/PermissionPathSelector.vue'
import { import {
getPermissionListByAdmin, getPermissionListByAdmin,
addPermissionAdmin, addPermissionAdmin,
@@ -297,6 +361,8 @@ import { formatDate ,timeToTimestamp} from '@/utils/tool'
const selectorType = ref('query') const selectorType = ref('query')
const userSelectorVisible = ref(false) const userSelectorVisible = ref(false)
const groupSelectorVisible = ref(false)
const permissionSelectorVisible = ref(false)
const userSelectorList = ref([]) const userSelectorList = ref([])
const userSelectorTotal = ref(0) const userSelectorTotal = ref(0)
const userSearchParams = reactive({ const userSearchParams = reactive({
@@ -307,6 +373,8 @@ const userSearchParams = reactive({
const selectedUserTemp = ref(null) const selectedUserTemp = ref(null)
const userSelectorLoading = ref(false) const userSelectorLoading = ref(false)
const UserOptions = ref([]) const UserOptions = ref([])
const GroupOptions = ref([])
const selectedPermission = ref(null)
// //
const queryParams = reactive({ const queryParams = reactive({
owner_type: '', owner_type: '',
@@ -324,16 +392,125 @@ const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id) const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}` return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
} }
//
const clearQueryGroup = () => {
queryParams.admin_group_id = undefined
}
//
const getQueryGroupName = () => {
const group = GroupOptions.value.find(g => g.id === queryParams.admin_group_id) ||
adminGroupOptions.value.find(g => g.id === queryParams.admin_group_id)
return group ? `${group.name} (ID: ${group.id})` : `管理员组ID: ${queryParams.admin_group_id}`
}
//
const openQueryGroupSelector = () => {
selectorType.value = 'query'
groupSelectorVisible.value = true
}
// //
const clearFormUser = () => { const clearFormUser = () => {
permissionForm.user_id = undefined permissionForm.user_id = undefined
} }
// //
const getFormUserName = () => { const getFormUserName = () => {
if (!permissionForm.user_id) return ''
const user = UserOptions.value.find(u => u.UserId === permissionForm.user_id) const user = UserOptions.value.find(u => u.UserId === permissionForm.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${permissionForm.user_id}` return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${permissionForm.user_id}`
} }
//
//
const getFormGroupName = () => {
if (!permissionForm.admin_group_id) return ''
const group = GroupOptions.value.find(g => g.id === permissionForm.admin_group_id)
return group ? `${group.name} (ID: ${group.id})` : `管理员组ID: ${permissionForm.admin_group_id}`
}
//
const getFormPermissionName = () => {
if (!permissionForm.permission_id) return ''
if (selectedPermission.value && selectedPermission.value.id === permissionForm.permission_id) {
const p = selectedPermission.value
return `${p.method || ''} ${p.path}${p.name ? ' - ' + p.name : ''}`
}
const perm = permissionOptions.value.find(p => p.id === permissionForm.permission_id)
return perm ? `${perm.method || ''} ${perm.path}${perm.name ? ' - ' + perm.name : ''}` : `权限ID: ${permissionForm.permission_id}`
}
//
const clearFormGroup = () => {
permissionForm.admin_group_id = undefined
}
//
const clearFormPermission = () => {
permissionForm.permission_id = undefined
selectedPermission.value = null
}
//
const openFormGroupSelector = () => {
selectorType.value = 'form'
groupSelectorVisible.value = true
}
//
const openPermissionSelector = () => {
permissionSelectorVisible.value = true
}
//
const handleGroupSelectorConfirm = (group) => {
if (group) {
const groupId = group.id || group.Id
const groupName = group.name || group.Name
if (selectorType.value === 'query') {
queryParams.admin_group_id = groupId
if (!GroupOptions.value.find(g => g.id === groupId)) {
GroupOptions.value.push({ id: groupId, name: groupName })
}
fetchAdminPermissionList()
} else {
permissionForm.admin_group_id = groupId
if (!GroupOptions.value.find(g => g.id === groupId)) {
GroupOptions.value.push({ id: groupId, name: groupName })
}
}
}
groupSelectorVisible.value = false
}
//
const handlePermissionSelectorConfirm = (permission) => {
if (permission) {
permissionForm.permission_id = permission.id
selectedPermission.value = permission
}
permissionSelectorVisible.value = false
}
// UserListSelector
const handleUserSelectorConfirm = (user) => {
if (user) {
const userId = user.user_id || user.UserId
const userName = user.user_name || user.UserName
if (selectorType.value === 'query') {
queryParams.user_id = userId
if (!UserOptions.value.find(u => u.UserId === userId)) {
UserOptions.value.push({ UserId: userId, UserName: userName })
}
fetchAdminPermissionList()
} else if (selectorType.value === 'form') {
permissionForm.user_id = userId
if (!UserOptions.value.find(u => u.UserId === userId)) {
UserOptions.value.push({ UserId: userId, UserName: userName })
}
}
}
userSelectorVisible.value = false
}
//
const confirmUserSelection = () => { const confirmUserSelection = () => {
if (!selectedUserTemp.value) { if (!selectedUserTemp.value) {
ElMessage.warning('请选择一个用户') ElMessage.warning('请选择一个用户')
@@ -684,7 +861,7 @@ const fetchUserList = async () => {
try { try {
const res = await getUserList({ const res = await getUserList({
page: 1, page: 1,
count: 10000, count: 10,
key: '' key: ''
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
@@ -700,7 +877,7 @@ const fetchAdminGroupList = async () => {
try { try {
const res = await getAdminGroupList({ const res = await getAdminGroupList({
page: 1, page: 1,
count: 1000 count: 10
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
adminGroupOptions.value = res.data.data?.data || [] adminGroupOptions.value = res.data.data?.data || []
@@ -716,7 +893,7 @@ const fetchPermissionList = async () => {
try { try {
const res = await getPermissionList({ const res = await getPermissionList({
page: 1, page: 1,
count: 10000 count: 10
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
permissionOptions.value = res.data.data?.list || [] permissionOptions.value = res.data.data?.list || []
@@ -837,4 +1014,34 @@ onMounted(() => {
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
/* 推介人选择器样式 - 与UserList.vue保持一致 */
.recommend-user-selector {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.recommend-user-selector .el-input {
flex: 1;
}
.recommend-user-selector .clear-btn {
flex-shrink: 0;
}
.selector-inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.user_selector-inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
</style> </style>
-600
View File
@@ -1,600 +0,0 @@
<template>
<div class="setting-container">
<!-- 主容器 -->
<el-card class="main-container" shadow="never">
<!-- 搜索和操作栏 -->
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="queryParams" class="search-form">
<el-form-item label="配置组">
<el-select v-model="queryParams.group_id" placeholder="请选择配置组" clearable style="width: 200px" @change="handleQuery">
<el-option
v-for="group in groupList"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</el-form-item>
<el-form-item label="关键词筛选">
<el-input v-model="queryParams.key" placeholder="请输入关键词" clearable style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增配置
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</div>
</div>
<!-- 配置列表 -->
<div class="table-section">
<el-table
v-loading="loading"
:data="settingList"
@selection-change="handleSelectionChange"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="value" label="值" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.type === 'bool'">{{ row.value ? '' : '' }}</span>
<span v-else>{{ row.value }}</span>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="getTypeColor(row.type)">
{{ row.type || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="settingGroupID" label="配置组" width="150" />
<el-table-column label="是否开放" width="100">
<template #default="{ row }">
<el-switch
v-model="row.open"
@change="handleToggleOpen(row)"
:disabled="toggleLoading === row.id"
/>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="200" show-overflow-tooltip />
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</div>
</el-card>
<!-- 配置表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
destroy-on-close
>
<el-form
ref="settingFormRef"
:model="settingForm"
:rules="settingRules"
label-width="120px"
>
<el-form-item label="配置组" prop="settingGroupID">
<el-select v-model="settingForm.settingGroupID" placeholder="请选择配置组" style="width: 100%">
<el-option
v-for="group in groupList"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="settingForm.name" placeholder="请输入配置名称" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="settingForm.type" placeholder="请选择类型" style="width: 100%" @change="handleTypeChange">
<el-option label="字符串 (string)" value="string" />
<el-option label="整数 (int)" value="int" />
<el-option label="浮点数 (float)" value="float" />
<el-option label="布尔值 (bool)" value="bool" />
</el-select>
</el-form-item>
<el-form-item label="值" prop="value">
<el-input
v-if="settingForm.type === 'string'"
v-model="settingForm.value"
type="textarea"
:rows="3"
placeholder="请输入配置值"
/>
<el-input-number
v-else-if="settingForm.type === 'int'"
v-model="settingForm.value"
:controls="false"
placeholder="请输入整数"
style="width: 100%"
/>
<el-input-number
v-else-if="settingForm.type === 'float'"
v-model="settingForm.value"
:controls="false"
:precision="2"
placeholder="请输入浮点数"
style="width: 100%"
/>
<el-switch
v-else-if="settingForm.type === 'bool'"
v-model="settingForm.value"
/>
<el-input
v-else
v-model="settingForm.value"
placeholder="请输入配置值"
/>
</el-form-item>
<el-form-item label="是否开放访问">
<el-switch v-model="settingForm.open" />
<span style="margin-left: 10px; color: #909399; font-size: 12px;">
开启后允许公开访问
</span>
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input
v-model="settingForm.note"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Delete } from '@element-plus/icons-vue'
import {
getSettingList,
getSettingInfo,
createSetting,
updateSetting,
setSettingOpen,
deleteSetting
} from '@/api/admin/setting'
import { getSettingGroupList } from '@/api/admin/setting'
//
const queryParams = reactive({
group_id: undefined,
key: '',
page: 1,
count: 10
})
//
const settingForm = reactive({
id: undefined,
name: '',
value: '',
type: 'string',
settingGroupID: undefined,
open: false,
note: ''
})
const settingRules = {
name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' }
],
value: [
{ required: true, message: '请输入配置值', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择配置类型', trigger: 'change' }
],
settingGroupID: [
{ required: true, message: '请选择配置组', trigger: 'change' }
]
}
//
const loading = ref(false)
const settingList = ref([])
const groupList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增配置')
const settingFormRef = ref(null)
const toggleLoading = ref(null)
//
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
//
const getTypeColor = (type) => {
const colorMap = {
'string': 'primary',
'int': 'success',
'float': 'warning',
'bool': 'info'
}
return colorMap[type] || ''
}
//
const fetchGroupList = async () => {
try {
const res = await getSettingGroupList({ page: 1, count: 1000 })
if (res.data.code === 200) {
groupList.value = res.data.data.data || []
}
} catch (error) {
console.error('获取配置组列表失败:', error)
}
}
//
const fetchSettingList = async () => {
loading.value = true
try {
const params = { ...queryParams }
if (!params.group_id) {
delete params.group_id
}
const res = await getSettingList(params)
console.log('配置列表数据:', res.data)
if (res.data.code === 200) {
settingList.value = res.data.data.data || []
total.value = res.data.data.all_count || 0
}
} catch (error) {
console.error('获取配置列表失败:', error)
ElMessage.error('获取配置列表失败')
} finally {
loading.value = false
}
}
//
const handleQuery = () => {
queryParams.page = 1
fetchSettingList()
}
//
const resetQuery = () => {
queryParams.group_id = undefined
queryParams.key = ''
queryParams.page = 1
fetchSettingList()
}
//
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
//
const handleSizeChange = (size) => {
queryParams.count = size
fetchSettingList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchSettingList()
}
//
const handleTypeChange = (type) => {
//
if (type === 'bool') {
settingForm.value = false
} else if (type === 'int' || type === 'float') {
settingForm.value = 0
} else {
settingForm.value = ''
}
}
//
const handleAdd = () => {
dialogTitle.value = '新增配置'
Object.assign(settingForm, {
id: undefined,
name: '',
value: '',
type: 'string',
setting_group_id: undefined,
open: false,
note: ''
})
dialogVisible.value = true
}
//
const handleEdit = async (row) => {
dialogTitle.value = '编辑配置'
try {
const res = await getSettingInfo({ id: row.id })
console.log('配置详情数据:', res)
if (res.data.code === 200) {
const data = res.data.data
Object.assign(settingForm, {
id: data.id,
name: data.name || '',
value: data.value,
type: data.type || 'string',
settingGroupID: data.settingGroupID,
open: data.open || false,
note: data.note || ''
})
console.log('配置详情数据:', settingForm)
//
if (data.type === 'bool') {
settingForm.value = data.value === true || data.value === 'true' || data.value === 1
} else if (data.type === 'int') {
settingForm.value = parseInt(data.value) || 0
} else if (data.type === 'float') {
settingForm.value = parseFloat(data.value) || 0
}
dialogVisible.value = true
}
} catch (error) {
console.error('获取配置详情失败:', error)
ElMessage.error('获取配置详情失败')
}
}
//
const handleToggleOpen = async (row) => {
toggleLoading.value = row.id
try {
const res = await setSettingOpen({
id: row.id,
open: row.open
})
if (res.data.code === 200) {
ElMessage.success('修改成功')
} else {
//
row.open = !row.open
ElMessage.error(res.data.message || '修改失败')
}
} catch (error) {
//
row.open = !row.open
console.error('修改失败:', error)
ElMessage.error(error.response?.data?.message || '修改失败')
} finally {
toggleLoading.value = null
}
}
//
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除配置 "${row.name}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteSetting({ id: row.id })
console.log('删除配置响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchSettingList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
//
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const deletePromises = selectedRows.value.map(row =>
deleteSetting({ id: row.id })
)
await Promise.all(deletePromises)
ElMessage.success('批量删除成功')
fetchSettingList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败')
}
}).catch(() => {})
}
//
const submitForm = () => {
settingFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
name: settingForm.name,
value: String(settingForm.value),
type: settingForm.type,
setting_group_id: settingForm.settingGroupID,
open: settingForm.open,
note: settingForm.note
}
if (settingForm.id) {
submitData.id = settingForm.id
}
console.log('提交配置数据:', submitData)
const res = settingForm.id
? await updateSetting(submitData)
: await createSetting(submitData)
if (res.data.code === 200) {
ElMessage.success(settingForm.id ? '修改成功' : '创建成功')
dialogVisible.value = false
fetchSettingList()
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error(error.response?.data?.message || '提交失败')
}
}
})
}
//
onMounted(() => {
fetchGroupList()
fetchSettingList()
})
</script>
<style scoped>
.setting-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
.filter-section {
padding: 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
}
.filter-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.search-form {
margin: 0;
flex: 1;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-section {
padding: 0;
}
.pagination {
margin-top: 20px;
padding: 16px 20px;
border-top: 1px solid #e1e8ed;
background: #fafbfc;
justify-content: flex-end;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) {
padding: 0;
}
</style>
-409
View File
@@ -1,409 +0,0 @@
<template>
<div class="setting-group-container">
<!-- 主容器 -->
<el-card class="main-container" shadow="never">
<!-- 搜索和操作栏 -->
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="queryParams" class="search-form">
<el-form-item label="关键词筛选">
<el-input v-model="queryParams.key" placeholder="请输入关键词" clearable style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增配置组
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</div>
</div>
<!-- 配置组列表 -->
<div class="table-section">
<el-table
v-loading="loading"
:data="groupList"
@selection-change="handleSelectionChange"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="200" />
<el-table-column prop="note" label="备注" min-width="250" show-overflow-tooltip />
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatDate(row.UpdatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</div>
</el-card>
<!-- 配置组表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
destroy-on-close
>
<el-form
ref="groupFormRef"
:model="groupForm"
:rules="groupRules"
label-width="100px"
>
<el-form-item label="名称" prop="name">
<el-input v-model="groupForm.name" placeholder="请输入配置组名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input
v-model="groupForm.note"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Delete } from '@element-plus/icons-vue'
import {
getSettingGroupList,
getSettingGroupInfo,
createSettingGroup,
updateSettingGroup,
deleteSettingGroup
} from '@/api/admin/setting'
//
const queryParams = reactive({
key: '',
page: 1,
count: 10
})
//
const groupForm = reactive({
id: undefined,
name: '',
note: ''
})
const groupRules = {
name: [
{ required: true, message: '请输入配置组名称', trigger: 'blur' }
]
}
//
const loading = ref(false)
const groupList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增配置组')
const groupFormRef = ref(null)
//
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
//
const fetchGroupList = async () => {
loading.value = true
try {
const res = await getSettingGroupList(queryParams)
console.log('配置组列表数据:', res.data)
if (res.data.code === 200) {
groupList.value = res.data.data.data || []
total.value = res.data.data.all_count || 0
}
} catch (error) {
console.error('获取配置组列表失败:', error)
ElMessage.error('获取配置组列表失败')
} finally {
loading.value = false
}
}
//
const handleQuery = () => {
queryParams.page = 1
fetchGroupList()
}
//
const resetQuery = () => {
queryParams.key = ''
queryParams.page = 1
fetchGroupList()
}
//
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
//
const handleSizeChange = (size) => {
queryParams.count = size
fetchGroupList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchGroupList()
}
//
const handleAdd = () => {
dialogTitle.value = '新增配置组'
Object.assign(groupForm, {
id: undefined,
name: '',
note: ''
})
dialogVisible.value = true
}
//
const handleEdit = async (row) => {
dialogTitle.value = '编辑配置组'
try {
const res = await getSettingGroupInfo({ setting_group_id: row.id })
console.log('配置组详情数据:', res.data)
if (res.data.code === 200) {
Object.assign(groupForm, {
id: res.data.data.id,
name: res.data.data.name || '',
note: res.data.data.note || ''
})
dialogVisible.value = true
}
} catch (error) {
console.error('获取配置组详情失败:', error)
ElMessage.error('获取配置组详情失败')
}
}
//
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除配置组 "${row.name}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteSettingGroup({ setting_group_id: row.id })
console.log('删除配置组响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchGroupList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
//
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const deletePromises = selectedRows.value.map(row =>
deleteSettingGroup({ setting_group_id: row.id })
)
await Promise.all(deletePromises)
ElMessage.success('批量删除成功')
fetchGroupList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败')
}
}).catch(() => {})
}
//
const submitForm = () => {
groupFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
name: groupForm.name,
note: groupForm.note
}
if (groupForm.id) {
submitData.id = groupForm.id
}
console.log('提交配置组数据:', submitData)
const res = groupForm.id
? await updateSettingGroup(submitData)
: await createSettingGroup(submitData)
if (res.data.code === 200) {
ElMessage.success(groupForm.id ? '修改成功' : '创建成功')
dialogVisible.value = false
fetchGroupList()
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error(error.response?.data?.message || '提交失败')
}
}
})
}
//
onMounted(() => {
fetchGroupList()
})
</script>
<style scoped>
.setting-group-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
.filter-section {
padding: 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
}
.filter-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.search-form {
margin: 0;
flex: 1;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-section {
padding: 0;
}
.pagination {
margin-top: 20px;
padding: 16px 20px;
border-top: 1px solid #e1e8ed;
background: #fafbfc;
justify-content: flex-end;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) {
padding: 0;
}
</style>
File diff suppressed because it is too large Load Diff
+97 -87
View File
@@ -56,7 +56,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="type" label="文件类型" width="120"> <el-table-column prop="type" label="文件类型" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getFileTypeColor(row.type)"> <el-tag :type="getFileTypeColor(row.type, row.url, row.realName)">
{{ row.type || '未知' }} {{ row.type || '未知' }}
</el-tag> </el-tag>
</template> </template>
@@ -112,7 +112,7 @@
<div class="preview-label">文件预览</div> <div class="preview-label">文件预览</div>
<div class="preview-content"> <div class="preview-content">
<el-image <el-image
v-if="isImageFile(fileDetail.type) && fileDetail.url" v-if="isImageFile(fileDetail.type, fileDetail.url, fileDetail.realName) && fileDetail.url"
:src="fileDetail.url" :src="fileDetail.url"
fit="contain" fit="contain"
style="max-width: 100%; max-height: 400px; border-radius: 8px;" style="max-width: 100%; max-height: 400px; border-radius: 8px;"
@@ -140,7 +140,7 @@
<el-descriptions-item label="真实文件名" label-align="right" :span="2">{{ fileDetail.realName }}</el-descriptions-item> <el-descriptions-item label="真实文件名" label-align="right" :span="2">{{ fileDetail.realName }}</el-descriptions-item>
<el-descriptions-item label="保存名称" label-align="right">{{ fileDetail.saveName }}</el-descriptions-item> <el-descriptions-item label="保存名称" label-align="right">{{ fileDetail.saveName }}</el-descriptions-item>
<el-descriptions-item label="文件类型" label-align="right"> <el-descriptions-item label="文件类型" label-align="right">
<el-tag :type="getFileTypeColor(fileDetail.type)">{{ fileDetail.type || '未知' }}</el-tag> <el-tag :type="getFileTypeColor(fileDetail.type, fileDetail.url, fileDetail.realName)">{{ fileDetail.type || '未知' }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="文件大小" label-align="right">{{ formatFileSize(fileDetail.size) }}</el-descriptions-item> <el-descriptions-item label="文件大小" label-align="right">{{ formatFileSize(fileDetail.size) }}</el-descriptions-item>
<el-descriptions-item label="是否公开" label-align="right"> <el-descriptions-item label="是否公开" label-align="right">
@@ -302,14 +302,37 @@ const uploadForm = reactive({
const uploadFileList = ref([]) const uploadFileList = ref([])
// //
const isImageFile = (type) => { const isImageFile = (type, url, realName) => {
const imageTypes = ['cover', 'image', 'avatar', 'photo', 'picture'] // type
return imageTypes.includes(type?.toLowerCase()) const imageTypes = ['cover', 'image', 'avatar', 'photo', 'picture', 'work_order']
if (type && imageTypes.includes(type?.toLowerCase())) {
return true
}
//
if (realName) {
const extension = realName.split('.').pop()?.toLowerCase()
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico']
if (extension && imageExtensions.includes(extension)) {
return true
}
}
// URL
if (url) {
const urlExtension = url.split('.').pop()?.toLowerCase().split('?')[0]
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico']
if (urlExtension && imageExtensions.includes(urlExtension)) {
return true
}
}
return false
} }
// //
const getFileTypeColor = (type) => { const getFileTypeColor = (type, url, realName) => {
if (isImageFile(type)) return 'success' if (isImageFile(type, url, realName)) return 'success'
const colorMap = { const colorMap = {
'document': 'primary', 'document': 'primary',
'video': 'warning', 'video': 'warning',
@@ -394,8 +417,12 @@ const handleView = async (row) => {
const res = await getFileDetail({ file_id: row.id }) const res = await getFileDetail({ file_id: row.id })
console.log('文件详情数据:', res.data) console.log('文件详情数据:', res.data)
if (res.data.code === 200) { if (res.data.code === 200) {
fileDetail.value = res.data.data.data // URL
fileDetail.value.url = res.data.data.url const fileData = res.data.data.data || res.data.data
fileDetail.value = {
...fileData,
url: fileData.url || res.data.data.url || ''
}
detailDialogVisible.value = true detailDialogVisible.value = true
} }
} catch (error) { } catch (error) {
@@ -510,23 +537,69 @@ const handleRemoveFile = (file, fileList) => {
uploadFileList.value = fileList uploadFileList.value = fileList
} }
// //
const handleSubmitUpload = () => { const handleSubmitUpload = async () => {
if (uploadFileList.value.length === 0) { if (uploadFileList.value.length === 0) {
ElMessage.warning('请至少选择一个文件') ElMessage.warning('请至少选择一个文件')
return return
} }
//
const filesToUpload = uploadFileList.value.filter(file => //
file.status !== 'success' && file.status !== 'uploading' const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']
) const filesToUpload = uploadFileList.value.filter(file => {
if (file.status === 'success') return false
const raw = file.raw
if (!raw) return false
const isValidType = validTypes.includes(raw.type)
const isLt10M = raw.size / 1024 / 1024 < 10
if (!isValidType) {
ElMessage.warning(`文件 ${raw.name} 格式不符合要求,已跳过`)
return false
}
if (!isLt10M) {
ElMessage.warning(`文件 ${raw.name} 大小超过 10MB,已跳过`)
return false
}
return true
})
if (filesToUpload.length === 0) { if (filesToUpload.length === 0) {
ElMessage.info('所有文件已上传完成') ElMessage.info('没有可上传的有效文件')
return return
} }
//
uploadRef.value?.submit()
// FormData file_names files
const formData = new FormData()
filesToUpload.forEach(file => {
formData.append('file_names', file.raw.name)
formData.append('files', file.raw)
})
//
if (uploadForm.update_type) {
formData.append('update_type', uploadForm.update_type)
}
//
formData.append('open_down', uploadForm.open_down ? 'true' : 'false')
try {
const res = await uploadFile(formData)
if (res && res.data && res.data.code === 200) {
ElMessage.success(`成功上传 ${filesToUpload.length} 个文件`)
setTimeout(() => {
uploadDialogVisible.value = false
uploadFileList.value = []
fetchFileList()
}, 500)
} else {
const errorMsg = res?.data?.message || res?.data?.msg || '上传失败'
ElMessage.error(errorMsg)
}
} catch (error) {
console.error('批量上传失败:', error)
ElMessage.error(error?.response?.data?.message || error?.message || '上传失败,请重试')
}
} }
// //
@@ -541,84 +614,21 @@ const beforeUpload = (file) => {
if (!isLt10M) { if (!isLt10M) {
ElMessage.warning(`文件 ${file.name} 大小超过 10MB`) ElMessage.warning(`文件 ${file.name} 大小超过 10MB`)
} }
// //
return true return true
} }
// // handleSubmitUpload
const handleCustomUpload = async (options) => { const handleCustomUpload = async (options) => {
const { file, onSuccess, onError } = options // handleSubmitUpload
console.log('开始上传文件:', file) // el-upload auto-upload false
//
const isValidType = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'].includes(file.type)
const isLt10M = file.size / 1024 / 1024 < 10
if (!isValidType) {
const error = new Error(`文件 ${file.name} 格式不符合要求(仅支持 JPG/PNG/GIF/PDF/DOC/DOCX`)
// on-error error
error.isValidation = true
onError(error, file)
return
}
if (!isLt10M) {
const error = new Error(`文件 ${file.name} 大小超过 10MB`)
error.isValidation = true
onError(error, file)
return
}
try {
const formData = new FormData()
// API files
formData.append('files', file)
// API
formData.append('file_names', file.name)
//
if (uploadForm.update_type) {
formData.append('update_type', uploadForm.update_type)
}
//
formData.append('open_down', uploadForm.open_down ? '1' : '0')
console.log('上传参数:', {
files: file.name,
file_names: [file.name],
update_type: uploadForm.update_type,
open_down: uploadForm.open_down
})
const res = await uploadFile(formData)
console.log('上传响应:', res)
//
if (res && res.data && res.data.code === 200) {
onSuccess(res.data.data, file)
} else {
const errorMsg = res?.data?.message || res?.data?.msg || '上传失败'
const error = new Error(errorMsg)
onError(error, file)
}
} catch (error) {
console.error('上传文件失败:', error)
const err = new Error(error?.response?.data?.message || error?.message || '上传失败')
onError(err, file)
}
} }
// //
const handleUploadSuccess = (response, file, fileList) => { const handleUploadSuccess = (response, file, fileList) => {
console.log('上传成功文件:', file)
console.log('上传成功文件列表:',fileList)
// code === 200
// ElMessage.success(` ${file.name} `)
//
uploadFileList.value = fileList uploadFileList.value = fileList
// //
const allSuccess = fileList.every(f => f.status === 'success') const allSuccess = fileList.every(f => f.status === 'success')
-27
View File
@@ -528,33 +528,6 @@ onMounted(() => {
gap: 12px; gap: 12px;
} }
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
File diff suppressed because it is too large Load Diff
+927
View File
@@ -0,0 +1,927 @@
<template>
<div class="ticket-list-page">
<!-- 顶部状态标签栏 -->
<div class="status-bar">
<div class="status-tabs">
<div class="tab-item pending" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')">
待处理 <span class="count">{{ stats.pending }}</span>
</div>
<div class="tab-item processing" :class="{ active: activeStatus === 'processing' }" @click="filterByStatus('processing')">
处理中 <span class="count">{{ stats.processing }}</span>
</div>
<div class="tab-item replied" :class="{ active: activeStatus === 'replied' }" @click="filterByStatus('replied')">
已回复 <span class="count">{{ stats.replied }}</span>
</div>
<div class="tab-item completed" :class="{ active: activeStatus === 'completed' }" @click="filterByStatus('completed')">
已完成 <span class="count">{{ stats.completed }}</span>
</div>
<div class="tab-item" :class="{ active: activeStatus === '' }" @click="filterByStatus('')">
全部 <span class="count">{{ stats.total }}</span>
</div>
</div>
</div>
<!-- 筛选工具栏 -->
<div class="filter-bar">
<el-select v-model="sortBy" placeholder="排序方式" clearable style="width: 140px" @change="handleSortChange">
<el-option label="不排序" value="" />
<el-option label="创建时间" value="created_at" />
<el-option label="更新时间" value="updated_at" />
<el-option label="工单号" value="id" />
</el-select>
<el-select v-model="sortOrder" placeholder="排序顺序" clearable style="width: 100px" @change="handleSortChange">
<el-option label="默认" value="" />
<el-option label="降序" value="desc" />
<el-option label="升序" value="asc" />
</el-select>
<el-input
:model-value="selectedUser ? selectedUser.user_name : ''"
placeholder="点击选择用户筛选"
readonly
style="width: 180px; cursor: pointer"
@click="showUserDialog = true"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
<template #suffix v-if="selectedUser">
<el-icon @click.stop="clearUserFilter" style="cursor: pointer"><Close /></el-icon>
</template>
</el-input>
<el-input
v-model="searchKeyword"
placeholder="搜索工单标题/内容"
clearable
style="width: 200px"
@input="handleKeywordSearch"
@clear="handleKeywordSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button icon="Refresh" @click="refreshList">刷新</el-button>
</div>
<!-- 工单表格PC端 -->
<el-table
v-loading="isLoading"
:data="filteredTickets"
stripe
style="width: 100%"
@row-click="handleRowClick"
class="desktop-table"
>
<el-table-column prop="id" label="工单号" width="100" />
<el-table-column label="用户" width="180">
<template #default="{ row }">
<div class="user-info">
<el-avatar :size="32" :src="row.avatar">{{ row.username?.charAt(0) }}</el-avatar>
<el-link v-if="row.userId" type="primary" :underline="false" @click.stop="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.username }}</el-link>
<span v-else class="username">{{ row.username }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="工单标题" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column prop="lastReplyTime" label="最后回复" width="180" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click.stop="goToDetail(row)">
回复
</el-button>
<el-button
v-if="row.status !== 'completed'"
type="success"
size="small"
@click.stop="handleComplete(row)"
>
结束
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 移动端卡片列表 -->
<div class="mobile-ticket-list" v-loading="isLoading">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="ticket-card"
@click="goToDetail(ticket)"
>
<div class="ticket-card-header">
<span class="ticket-card-id">#{{ ticket.id }}</span>
<el-tag :type="getStatusType(ticket.status)" size="small">
{{ getStatusText(ticket.status) }}
</el-tag>
</div>
<div class="ticket-card-user">
<el-avatar :size="28" :src="ticket.avatar">{{ ticket.username?.charAt(0) }}</el-avatar>
<span class="ticket-card-username">{{ ticket.username }}</span>
</div>
<div class="ticket-card-title">{{ ticket.title }}</div>
<div class="ticket-card-footer">
<span class="ticket-card-time">{{ ticket.createTime }}</span>
<div class="ticket-card-actions">
<el-button type="primary" size="small" @click.stop="goToDetail(ticket)">回复</el-button>
<el-button
v-if="ticket.status !== 'completed'"
type="success"
size="small"
@click.stop="handleComplete(ticket)"
>结束</el-button>
</div>
</div>
</div>
<el-empty v-if="filteredTickets.length === 0 && !isLoading" description="暂无工单数据" />
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 用户选择对话框 -->
<el-dialog
v-model="showUserDialog"
title="选择用户"
width="600px"
destroy-on-close
>
<div class="user-dialog-content">
<el-input
v-model="userSearchKeyword"
placeholder="输入用户名/手机号/邮箱搜索"
clearable
@input="handleUserSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="user-list-container" v-loading="isSearchingUser">
<!-- 调试信息 -->
<div style="padding: 8px; font-size: 12px; color: #909399; border-bottom: 1px solid #eee;">
搜索关键词: {{ userSearchKeyword }} | 用户数量: {{ userList.length }}
</div>
<div v-if="!userSearchKeyword" class="empty-hint">
请输入关键词搜索用户
</div>
<div v-else-if="userSearchKeyword && userList.length === 0 && !isSearchingUser" class="empty-hint">
未找到匹配的用户
</div>
<div v-if="userList.length > 0" class="user-list">
<div
v-for="user in userList"
:key="user.user_id"
class="user-list-item"
@click="selectUser(user)"
>
<el-avatar :size="40" :src="user.cover">{{ user.user_name?.charAt(0) }}</el-avatar>
<div class="user-list-info">
<div class="user-list-name">{{ user.user_name }}</div>
<div class="user-list-sub">
<span v-if="user.phone">手机: {{ user.phone }}</span>
<span v-else-if="user.email">邮箱: {{ user.email }}</span>
<span v-else>UID: {{ user.user_id }}</span>
</div>
</div>
<el-icon class="user-list-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, User, Close, ArrowRight } from '@element-plus/icons-vue'
import {
getTickerList,
closeTicket,
getTicketCount
} from '@/api/ticket'
import { getUserList } from '@/api/admin/user'
const router = useRouter()
//
const currentPage = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
const isLoading = ref(false)
//
const ticketList = ref([])
const activeStatus = ref('pending') // ""
//
const searchKeyword = ref('')
const keywordSearchTimer = ref(null)
//
const userSearchKeyword = ref('')
const userList = ref([])
const selectedUser = ref(null)
const showUserDialog = ref(false)
const isSearchingUser = ref(false)
const userSearchTimer = ref(null)
//
const sortBy = ref('') //
const sortOrder = ref('') //
//
const stats = reactive({
pending: 0,
processing: 0,
replied: 0,
completed: 0,
total: 0
})
//
const autoRefreshTimer = ref(null)
//
const convertStatusToString = (status) => {
const statusMap = { 0: 'pending', 1: 'processing', 2: 'replied', 3: 'completed' }
return statusMap[status] || 'processing'
}
const getStatusText = (status) => {
const statusMap = { pending: '待处理', processing: '处理中', replied: '已回复', completed: '已完成' }
return statusMap[status] || status
}
const getStatusType = (status) => {
const typeMap = { pending: 'warning', processing: 'primary', replied: 'info', completed: 'success' }
return typeMap[status] || ''
}
//
const fetchTicketList = async () => {
try {
isLoading.value = true
let statusParam = ''
if (activeStatus.value) {
const statusMap = { pending: '0', processing: '1', replied: '2', completed: '3' }
statusParam = statusMap[activeStatus.value] || ''
}
console.log('调用getTickerList,排序参数:', { sortBy: sortBy.value, sortOrder: sortOrder.value })
const res = await getTickerList(
pageSize.value,
currentPage.value,
statusParam,
sortBy.value,
sortOrder.value,
selectedUser.value?.user_id,
searchKeyword.value.trim()
)
if (res.code === 200) {
ticketList.value = (res.data.data || []).map(item => ({
id: item.work_id,
title: item.name,
username: item.user?.userName || `用户${item.user?.userId || 'Unknown'}`,
userId: item.user?.userId,
avatar: item.user?.coverUrl || '',
createTime: new Date(item.created_at).toLocaleString(),
lastReplyTime: new Date(item.update_time).toLocaleString(),
status: convertStatusToString(item.status)
}))
totalCount.value = res.data.all_count || 0
} else {
ElMessage.error(res.message || '获取工单列表失败')
}
} catch (error) {
console.error('获取工单列表出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
isLoading.value = false
}
}
//
const fetchStats = async () => {
try {
const res = await getTicketCount()
if (res.code === 200) {
const data = res.data
stats.total = data.all_count
stats.pending = data.wait_count
stats.replied = data.reply_count
stats.completed = data.close_count
stats.processing = data.all_count - data.wait_count - data.reply_count - data.close_count
}
} catch (error) {
console.error('获取统计数据出错:', error)
}
}
//
const filteredTickets = computed(() => ticketList.value)
//
const handleUserSearch = () => {
if (userSearchTimer.value) {
clearTimeout(userSearchTimer.value)
}
const keyword = userSearchKeyword.value.trim()
if (!keyword) {
userList.value = []
return
}
userSearchTimer.value = setTimeout(async () => {
try {
isSearchingUser.value = true
const res = await getUserList({ page: 1, count: 10, key: keyword })
console.log('用户搜索响应:', res)
if (res.data?.code === 200) {
// res.data.data.data
userList.value = res.data.data?.data || []
console.log('用户列表更新:', userList.value)
} else {
ElMessage.error(res.data?.message || '搜索用户失败')
userList.value = []
}
} catch (error) {
console.error('搜索用户出错:', error)
ElMessage.error('搜索用户失败')
userList.value = []
} finally {
isSearchingUser.value = false
}
}, 300)
}
//
const selectUser = (user) => {
selectedUser.value = user
showUserDialog.value = false
userSearchKeyword.value = ''
userList.value = []
currentPage.value = 1
fetchTicketList()
}
//
const clearUserFilter = () => {
selectedUser.value = null
currentPage.value = 1
fetchTicketList()
}
//
const handleKeywordSearch = () => {
if (keywordSearchTimer.value) {
clearTimeout(keywordSearchTimer.value)
}
keywordSearchTimer.value = setTimeout(() => {
currentPage.value = 1
fetchTicketList()
}, 300)
}
//
const filterByStatus = (status) => {
if (activeStatus.value === status) return
activeStatus.value = status
currentPage.value = 1
fetchTicketList()
//
stopAutoRefresh()
if (status === 'pending') {
startAutoRefresh()
}
}
//
const handleSortChange = () => {
currentPage.value = 1
fetchTicketList()
}
//
const handleSizeChange = () => {
currentPage.value = 1
fetchTicketList()
}
const handlePageChange = () => {
fetchTicketList()
}
//
const refreshList = () => {
fetchTicketList()
fetchStats()
}
//
const goToDetail = (row) => {
router.push({ path: '/ticket/detail', query: { id: row.id } })
}
const handleRowClick = (row) => {
goToDetail(row)
}
//
const handleComplete = (ticket) => {
ElMessageBox.confirm('确定要结束此工单吗?结束后将无法继续回复。', '确认操作', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await closeTicket(ticket.id)
if (res.code === 200) {
ElMessage.success('工单已成功结束')
refreshList()
} else {
ElMessage.error(res.message || '结束工单失败')
}
} catch (error) {
ElMessage.error('网络错误,请稍后重试')
}
}).catch(() => {})
}
//
const startAutoRefresh = () => {
if (autoRefreshTimer.value) return
autoRefreshTimer.value = setInterval(() => {
if (activeStatus.value === 'pending') {
// loading
const originalLoading = isLoading.value
fetchTicketList().finally(() => {
isLoading.value = originalLoading
})
fetchStats()
}
}, 30000) // 30
}
//
const stopAutoRefresh = () => {
if (autoRefreshTimer.value) {
clearInterval(autoRefreshTimer.value)
autoRefreshTimer.value = null
}
}
let isFirstLoad = true
//
watch(showUserDialog, (newVal) => {
if (!newVal) {
//
userSearchKeyword.value = ''
userList.value = []
}
})
onMounted(() => {
fetchTicketList()
fetchStats()
//
if (activeStatus.value === 'pending') {
startAutoRefresh()
}
})
//
onActivated(() => {
//
if (!isFirstLoad) {
refreshList()
}
isFirstLoad = false
//
if (activeStatus.value === 'pending') {
startAutoRefresh()
}
})
//
onBeforeUnmount(() => {
stopAutoRefresh()
if (userSearchTimer.value) {
clearTimeout(userSearchTimer.value)
}
if (keywordSearchTimer.value) {
clearTimeout(keywordSearchTimer.value)
}
})
</script>
<style scoped>
.ticket-list-page {
padding: 0;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
background: #fff;
}
.status-bar {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 14px 20px 0;
}
.status-tabs {
display: flex;
gap: 6px;
}
.tab-item {
padding: 6px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
color: #606266;
transition: all 0.2s;
user-select: none;
}
.tab-item:hover {
background: #f0f2f5;
}
.tab-item.active {
background: #409eff;
color: #fff;
font-weight: 500;
}
.tab-item.pending.active { background: #e6a23c; }
.tab-item.processing.active { background: #409eff; }
.tab-item.replied.active { background: #909399; }
.tab-item.completed.active { background: #67c23a; }
.tab-item .count {
margin-left: 4px;
font-weight: 500;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 12px 20px;
border-bottom: 1px solid #ebeef5;
}
.user-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.user-list-container {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
.empty-hint {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: #909399;
font-size: 14px;
}
.user-list {
padding: 8px 0;
}
.user-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.user-list-item:hover {
background: #f5f7fa;
}
.user-list-info {
flex: 1;
min-width: 0;
}
.user-list-name {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.user-list-sub {
font-size: 12px;
color: #909399;
}
.user-list-arrow {
color: #c0c4cc;
font-size: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.username {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pagination-wrapper {
padding: 12px 20px;
border-top: 1px solid #ebeef5;
}
:deep(.el-table) {
flex: 1;
}
:deep(.el-table tr) {
cursor: pointer;
}
/* 移动端卡片列表 */
.mobile-ticket-list {
display: none;
flex-direction: column;
gap: 12px;
padding: 12px;
overflow-y: auto;
flex: 1;
}
.ticket-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
}
.ticket-card:active {
background: #f5f7fa;
}
.ticket-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ticket-card-id {
font-size: 12px;
color: #909399;
}
.ticket-card-user {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ticket-card-username {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.ticket-card-title {
font-size: 14px;
color: #303133;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.ticket-card-time {
font-size: 12px;
color: #909399;
}
.ticket-card-actions {
display: flex;
gap: 8px;
}
/* 大屏平板尺寸响应式样式 (1020px - 1280px) */
@media (max-width: 1280px) and (min-width: 1021px) {
.filter-bar {
padding: 10px 16px;
gap: 8px;
}
.filter-bar .el-select {
width: 120px !important;
}
.filter-bar .el-input {
min-width: 160px;
}
:deep(.el-table) {
font-size: 13px;
}
:deep(.el-table .el-table__cell) {
padding: 10px 8px;
}
}
/* 平板尺寸响应式样式 (769px - 1020px) */
@media (max-width: 1020px) and (min-width: 769px) {
.status-bar {
padding: 10px 16px 0;
}
.status-tabs {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.status-tabs::-webkit-scrollbar {
height: 4px;
}
.status-tabs::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 2px;
}
.filter-bar {
padding: 10px 16px;
gap: 8px;
}
.filter-bar .el-select {
width: 120px !important;
}
.filter-bar .el-input {
flex: 1;
min-width: 150px;
}
:deep(.el-table) {
font-size: 13px;
}
:deep(.el-table .el-table__cell) {
padding: 8px 6px;
}
}
/* 移动端响应式样式 */
@media (max-width: 768px) {
.ticket-list-page {
height: auto;
min-height: calc(100vh - 60px);
}
.status-bar {
padding: 10px 12px 0;
}
.status-tabs {
width: 100%;
overflow-x: auto;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.status-tabs::-webkit-scrollbar {
display: none;
}
.tab-item {
flex-shrink: 0;
padding: 8px 12px;
font-size: 13px;
}
.filter-bar {
padding: 10px 12px;
gap: 8px;
}
.filter-bar .el-select,
.filter-bar .el-input {
flex: 1;
min-width: 120px;
}
.filter-bar .el-button {
flex-shrink: 0;
}
/* 隐藏PC端表格,显示移动端卡片 */
:deep(.el-table) {
display: none !important;
}
.mobile-ticket-list {
display: flex;
}
.pagination-wrapper {
padding: 12px;
justify-content: center;
}
.pagination-wrapper :deep(.el-pagination) {
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.pagination-wrapper :deep(.el-pagination__sizes),
.pagination-wrapper :deep(.el-pagination__jump) {
display: none;
}
/* 用户选择弹窗移动端适配 */
:deep(.el-dialog) {
width: 90% !important;
margin: 5vh auto !important;
}
}
@media (max-width: 480px) {
.tab-item {
padding: 6px 10px;
font-size: 12px;
}
.tab-item .count {
display: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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