115 Commits

Author SHA1 Message Date
shiran bdf6dd9382 feat: 优惠管理合并重构与商品续费价格参数
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 39s
- 合并优惠码/代金券为商品管理下优惠管理页面,卡片化展示与过期遮罩

- 用户组新增优惠绑定,商品关联改用懒加载树选择器

- 商品/套餐表单新增 renew_price、renew_recommend_rebate、renew_fixed_price

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 17:06:23 +08:00
shiran 5e81d33285 refactor: 商品管理参数对齐与表单优化 - 移除冗余ProductList.vue - 表单新增table/soldOut/maxPerUser/sendNotice - 表单改Tab栏布局 - 修复attrs.phase改为range
Build and Deploy Vue3 / build (push) Successful in 1m35s
Build and Deploy Vue3 / deploy (push) Successful in 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 16:12:06 +08:00
shiran 38c63cc451 feat: 邮箱平台管理与商品购买限制 - 新增邮箱平台主控服务管理(页面/API/路由/菜单) - 商品与套餐表单新增max_per_user单用户购买限制 - 邮件主控控制台跳转改为/ui/index.html?token=
Build and Deploy Vue3 / build (push) Successful in 1m47s
Build and Deploy Vue3 / deploy (push) Successful in 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 15:18:29 +08:00
shiran 4180f73c53 feat(admin): 订单管理重构、设置管理增强、短信签名模板管理及通知渠道优化
Build and Deploy Vue3 / build (push) Successful in 1m27s
Build and Deploy Vue3 / deploy (push) Successful in 36s
- 订单列表重构为卡片式布局并新增筛选功能

- 设置管理支持struct/struct_list类型配置

- 新增短信签名和模板独立管理页面

- 通知渠道新增短信渠道配置

- 产品参数管理优化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 18:27:23 +08:00
shiran 3227a50f9a feat(vm): 虚拟机详情页新增 SSH 连接按钮
Build and Deploy Vue3 / build (push) Successful in 1m41s
Build and Deploy Vue3 / deploy (push) Failing after 32s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:33:32 +08:00
shiran 86794145f1 feat(admin): 新增短信平台管理功能
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 35s
- 新增短信主控服务和额度商品的API接口
- 添加短信平台管理菜单项,包含主控服务管理和额度商品管理子菜单
- 实现短信平台管理相关路由配置
- 创建短信额度商品管理页面,支持额度类型配置、商品管理等功
2026-06-07 18:25:13 +08:00
shiran 84769954c4 feat(system): 管理员权限页重构与用户选择器升级
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 36s
- 重构 PermissionAdmin.vue:卡片式权限类型选择、拥有者名称解析、过期标识

- getUserList API 改用 params 对象,支持 is_admin 筛选

- UserList 新增管理员/普通用户身份筛选

- UserListSelector 重构为卡片网格布局,选中角标、动画提示条

- UserSelector 搜索栏加入身份筛选

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:59:24 +08:00
shiran a827fc5c41 feat(system): 通知管理与文件选择器来源筛选
Build and Deploy Vue3 / build (push) Successful in 1m27s
Build and Deploy Vue3 / deploy (push) Successful in 34s
- 新增通知管理(渠道卡片化、模板 CRUD、参数按钮插入)

- ImageSelector/AvatarSelector 增加上传来源 is_admin 筛选

- 宿主机详情页实时指标与硬件/网卡 IPv6 展示优化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 16:38:47 +08:00
shiran 0829dc9ce4 feat(monitor): 监控时间选择器统一为相对时间+自定义范围双模式
Build and Deploy Vue3 / build (push) Successful in 2m32s
Build and Deploy Vue3 / deploy (push) Successful in 32s
- VmMonitor/VmDetail/UserVmDetail/HostDetail 四个页面统一改造
- 支持「最近」模式(动态计算时间范围)和「自定义」模式(固定日期范围)
- 每小时流量图表同步应用双模式选择器
- 移除旧的 monitorShortcuts 快捷方式

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 18:28:12 +08:00
shiran d01c4e2e34 fix(monitor): 虚拟机指标单位矫正与监控体验优化
Build and Deploy Vue3 / build (push) Successful in 1m28s
Build and Deploy Vue3 / deploy (push) Successful in 33s
VmMonitor: 同指标多VM合并为一张多折线图;net_rx/net_tx按Bytes/s直接使用不再差分;时间选择器改为相对时间动态计算;新增自动刷新。VmDetail/UserVmDetail: 磁盘IOPS改为磁盘IO速率;磁盘I/O改为磁盘读写量;网络流量改为网络速率。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 18:13:49 +08:00
shiran f667fe420a feat(host-detail): 新增虚拟机监控 tab
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 32s
支持多选虚拟机和监控指标(CPU/内存/磁盘IO/网络),基于 metrics_history 接口渲染 ECharts 图表;时间范围可选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:59:53 +08:00
shiran 09fb74cd0d feat(kvm): 宿主机管理树表点击体验优化
Build and Deploy Vue3 / build (push) Successful in 1m25s
Build and Deploy Vue3 / deploy (push) Successful in 33s
宿主机组行整行可点击触发展开/折叠;宿主机行点击名称直接跳转详情页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:52:30 +08:00
shiran d2af66f8c8 fix(ci): 修复 pnpm v9 构建脚本权限错误
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 33s
使用 --ignore-scripts 跳过安装时的脚本检查,之后通过 pnpm rebuild 执行必要的 native 构建。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:45:37 +08:00
shiran c18622226e feat(admin): 工单管理 UI 优化与回复模板、文件管理增强
Build and Deploy Vue3 / build (push) Failing after 48s
Build and Deploy Vue3 / deploy (push) Has been skipped
工单列表与详情 UI/交互优化及新工单提醒;新增回复模板与工单类型管理;文件管理增加管理员筛选并优化详情展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:28:11 +08:00
shiran 928d14aada refactor(admin): 重构系统配置管理页面为分组 Tab 布局
Build and Deploy Vue3 / build (push) Successful in 1m33s
Build and Deploy Vue3 / deploy (push) Successful in 32s
优化配置组切换与搜索操作栏,提升配置项浏览与管理效率。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:24:29 +08:00
shiran 1b44186e44 fix(admin): 修正设置主IP确认文案,移除CloudInit重建描述
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 34s
VmDetail 与 UserVmDetail 设置主IP提示与实际行为一致,仅提示将重启虚拟机。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 16:05:07 +08:00
shiran 765f925482 feat(admin): 主机组映射展示全部主控并展开加载主机组
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 30s
主机组映射页改为卡片列表展示所有主控服务,展开后按需请求主机组;套餐管理增加必填参数未配置提醒。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 18:31:56 +08:00
shiran 9974a82ac8 chore(ci): 构建产物改为dist.tar.gz部署(上传单包+远程解压),测试部署前ssh mkdir,生产ssh-keyscan前置到Set up SSH
Build and Deploy Vue3 / build (push) Successful in 1m33s
Build and Deploy Vue3 / deploy (push) Successful in 29s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:18:44 +08:00
shiran 61d777af8e chore(ci): 部署目标合并为单服务器变量WEB_SERVICE_SERVER_IP,部署路径改为/home/www/web-online/admin.007yjs.com/
Build and Deploy Vue3 / build (push) Successful in 1m37s
Build and Deploy Vue3 / deploy (push) Successful in 1m21s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 15:35:41 +08:00
shiran a443e4f147 feat(admin): KSM内存去重管理+监控图表增强+额度统计UI重构+流量管理合并 -- 缘由: 后端新增KSM状态/配置接口,监控数据改为绝对值,额度统计需可视化 -- 预期: HostDetail支持KSM查看/启停/调参,内存图表改为绝对值+磁盘IOPS图+流量趋势图,额度统计改为环形进度卡片,流量策略与统计合并为流量管理tab,订单代金券改为非必填,VmManage显示累计流量
Build and Deploy Vue3 / build (push) Failing after 48s
Build and Deploy Vue3 / deploy (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 16:41:00 +08:00
shiran a5f8a9ef13 feat(admin+user): 虚拟机断网/恢复网络+每小时流量图表+宿主机额度统计 -- 缘由: 后端新增disconnect/connect_network,traffic_hourly,quota_stats接口,VM新增network_disabled字段 -- 预期: VmDetail/UserVmDetail/用户详情支持断网恢复操作并显示断网状态,VmDetail新增流量统计tab,HostDetail新增额度统计tab
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 16:29:18 +08:00
shiran 564e6cc017 feat(admin/vm-network): 网络列表增加is_primary主IP标识+设为主IP+重置MAC地址 -- 缘由: 后端新增network/set_primary和vm/reset_mac接口 -- 预期: VmDetail与UserVmDetail网络列表显示主IP标签,非主IP行有设为主IP按钮,更多菜单增加重置MAC地址
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 10:59:45 +08:00
shiran 98678859cb fix(admin/host): 硬盘IO限制带宽字段改为动态单位选择(B/s~GB/s), 带宽与IOPS分组渲染修复标签溢出 -- 缘由: 带宽字段直接展示bytes原始值且标签过长导致换行错乱 -- 预期: 带宽字段参照内存/磁盘方式通过下拉选择单位(默认MB/s), IOPS独立分组, 标签简化为读取/写入/突发读取/突发写入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 13:20:32 +08:00
shiran dc63020943 feat(admin/host): 宿主机表单与详情增加硬盘IO限制8字段(可折叠动态展示) -- 缘由: 后端新增 read/write_bytes_sec, read/write_iops_sec 及突发对应字段 -- 预期: HostManage/HostDetail/HostTreeManage 的新增/编辑/令牌表单含可折叠IO参数区, 详情页可展开查看IO限制值
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 13:00:26 +08:00
shiran 59c5d16082 feat(admin/product): 商品表单与列表增加 sold_out 售罄字段 -- 缘由: 后端新增 sold_out 布尔字段, 管理员可手动设置商品售罄状态 -- 预期: 商品创建/编辑表单含售罄开关, 列表显示售罄/在售标签
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 16:44:00 +08:00
shiran ea571563e0 fix(admin): 安全组选择器新增成功未关弹框 + 概览vCPU/带宽合并布局
缘由:
1) UserVmSecurityGroupSelector 中新增安全组成功后,若后端返回码非严格 200
   则 showCreate 不会置 false、列表不会刷新、无成功反馈。
2) /user-goods/vm-detail 概览第一行 vCPU、内存、下行带宽、上行带宽 各占一格,
   需求要求 vCPU+内存 合并为一格、上行+下行带宽 合并为一格并加编辑按钮。

修改:
- UserVmSecurityGroupSelector.vue submitCreate:响应码判断放宽为 200/201/2xx;
  catch 块提取 e.response.data.message 作更精确的错误信息。
- UserVmDetail.vue config-row 第一行:vCPU/内存 合并为「vCPU / 内存」单格;
  带宽合并为「带宽 ↓ / ↑」单格并内嵌修改按钮(handleMoreCmd('updateTraffic'));
  原第二行重复的用户名/远程端口 cell 移至第一行,外网IP/内网IP 独占第二行。

预期:
- 安全组选择器中新增安全组后弹框关闭、列表刷新、显示成功消息。
- 概览第一行信息密度提升,带宽格可一键触发修改带宽弹框。

测试:admin_dashboard_pc 本地 HMR 通过,无编译/lint 报错。
安全组新增接口联调需在有后端环境下验证实际 response code。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 16:27:52 +08:00
shiran e38ea4cc32 feat(admin/vm): 流量概览展示新字段 + 修改入口改挂 traffic_policy 接口
缘由:上次(802eaa3)我把概览"流量上限"的"修改"按钮挂到旧 update_traffic 接口
(兼容字段 traffic_max),与 docs/2026.05.08.12.37-add.json 中真正的流量策略接口
(traffic_policy/update + add_fixed + add_temporary)路径错位;同时 vm 详情返回
已新增 traffic_max_mb / temporary_traffic_mb / temporary_cycle_start / traffic_used /
traffic_exhausted_rx_mbps / traffic_exhausted_tx_mbps 等字段,概览未体现。

修改:
- UserVmDetail.vue & VmDetail.vue 概览将"流量上限"单值 cell 改为分段展示:
  主行 已用/总量;副行 基础 + 临时(含周期起始日期);按钮组「修改」「加临时」。
- 主行/副行字段来源 add.json 新字段,旧 traffic_max 仅作 fallback。
- 「修改」按钮改挂 openTrafficPolicyDialog / openVmTrafficPolicyDialog
  (对应 user_vm/traffic_policy/update 与 host_service/point/vm/traffic_policy/update);
  「加临时」直达 openAddTrafficDialog('temporary') / openVmAddTrafficDialog('temporary')。
- openTrafficPolicyDialog / openVmTrafficPolicyDialog 增加 vm / detail 字段 fallback,
  并在 trafficPolicy 未加载时异步触发 loadTrafficPolicy,避免懒加载导致初值全 0。
- 新增 formatTrafficMb helper(VmDetail.vue)处理 MB 自适应单位、对 0 友好输出。
- 新增 .traffic-cell 系列样式。

预期:
- 详情概览能直接看到 总/已用/基础/临时/周期 五个关键信息。
- 概览"修改"走 add.json 中的新流量策略接口,与"流量策略" tab 行为一致。
- 旧 dropdown 中"修改带宽"路径保留(不删除),用于纯带宽场景。

未测试:admin_dashboard_pc 本地 HMR 已更新,无编译/控制台报错。新流量策略接口与
真实 vm.value 字段填充尚需联调验证(特别是 traffic_used 单位假设为 MB,若实际为
字节需调整 formatTraffic / detailTrafficUsedMb 的换算)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 11:10:17 +08:00
shiran 0dcce0822d fix(admin/user-vm): 流量上限修改按钮回调名修正为 handleMoreCmd
缘由:上一次提交(802eaa3)在 UserVmDetail.vue 流量上限单元格内追加的"修改"按钮误用了不存在的 handleCommand,点击时报 _ctx.handleCommand is not a function。该文件中触发 updateTraffic 的实际函数名为 handleMoreCmd(行 1329),dropdown 的 @command 也是绑到该函数。

修改:仅将 132 行附近的 @click="handleCommand('updateTraffic')" 改为 @click="handleMoreCmd('updateTraffic')"。

预期:流量上限单元格的"修改"按钮可正常触发 trafficVisible 弹窗,与 dropdown "修改带宽"行为一致。

测试:admin_dashboard_pc 本地 dev 已 HMR 更新,未见编译/控制台报错。VmDetail.vue 同名样式与按钮独立、不受影响。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 17:12:44 +08:00
shiran 802eaa396b feat(admin/user-vm): 流量上限展示加修改入口、网络tab加删除网络操作
缘由:
1) 虚拟机详情页(UserVmDetail.vue / VmDetail.vue)中"流量上限"原仅展示无修改入口,对应 docs/2026.05.08.12.37-update.json 中 update_traffic 接口已支持 traffic_max + traffic_exhausted_rx/tx_mbps 修改,但用户需从"更多 dropdown"绕一道才能到达。
2) /user-goods/vm-detail 网络管理 tab 缺少"删除网络"操作。

修改:
- UserVmDetail.vue:流量上限单元格内追加"修改"小按钮,复用既有 updateTraffic 弹窗(已覆盖 update_traffic 全部新字段,不动接口逻辑);网络表格新增"操作"列+删除按钮,调用 host_service/point/network/delete;row 上若缺 service_id/host_id 用 getUserVmNetworkDetail 反查兜底,仍取不到则提示并阻止;二次确认弹窗明示该操作会影响所有绑定该网络的虚拟机。
- VmDetail.vue:流量上限单元格内追加"修改"小按钮,复用 handleUpdateTraffic(host_service/point/vm/update_traffic)。

预期:
- 详情页用户在"流量上限"位置可一键进入修改弹窗,无需走 dropdown。
- vm-detail 网络tab 表格每行可触发"删除网络"流程,含强提示与兜底取值。
- 不引入新依赖;trafficVisible 弹窗保持向 docs 字段对齐;UI 微调仅限新增样式 .cfg-edit-btn 与一列操作列。

未测试:未在 admin_dashboard_pc 本地 dev 验证(终端仅运行 user_dashboard_pc),需联调 update_traffic 与 point/network/delete 实际返回。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 17:09:23 +08:00
shiran 3d783cd224 fix(order): 新增/编辑订单提交时 price/renew_price 由"分"换算为"元"
缘由:
后端 /api/v1/admin/order/create 与 /api/v1/admin/order/update 入参单位由"分"改为
"元"(用户 2026-05-12 确认两端都已生效)。前端列表与表单内部仍按"分"持有,提交时未
做换算,导致表单里的 9900(分)被后端当作 9900 元入库 = 990000 分,下次列表读取后
表现为"价格额外 *100"。

改动:
- src/views/order/OrderList.vue:submitForm 构造 submitData 时把 price 与 renew_price
  统一 /100,create 与 update 路径共享。输入框旁的"分"单位文案暂不改(用户明确仅
  要求 /100),UI 一致性问题(列表显示元、弹窗输入分)作为另一个 issue 留待后续。

预期:
- 编辑订单不改价直接保存:原 9900 分 → 提交 99 元 → 入库 9900 分(一致,修复前会
  入成 990000 分)。
- 编辑订单改价:用户在"分"单位输入框填 12000 → 提交 120 元 → 入库 12000 分。
- 新增订单:同上链路一致。

测试:未脚本化。建议人工核对一次"编辑→不改→保存→列表金额无变化"作为最低验收。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 16:29:34 +08:00
shiran 7394afb83f chore(admin): 站点图标改用 logo.ico,与 user_dashboard 统一
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m17s
缘由:
用户 2026-05-11 要求三个前端项目使用同一 logo 图标,源文件位于
ApiServer-Web-user_dashboard_pc/public/logo.ico。
admin 原本只有 public/logo.svg(491 字节),需要新增 logo.ico 并切换 link。

修改:
- public/logo.ico (new):
  从 user_dashboard_pc/public/logo.ico 拷贝同源副本(267,837 字节)。
- index.html L5:
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
  → <link rel="icon" type="image/x-icon" href="/logo.ico" />
  原 logo.svg 保留未删(用户未要求清理,避免越权)。

预期:
- 浏览器标签页 favicon 与 user_dashboard、home 三端统一。
- Vite 静态资源走 public/ 路径,重启 dev / 强制刷新即可生效。

测试:
- 仅文件 + link 改动,lint 通过,未做运行时复现。请用户重启 vite dev / 强制刷新观察。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:33:50 +08:00
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
148 changed files with 83884 additions and 6605 deletions
+5
View File
@@ -17,7 +17,12 @@ store封装到src/store目录下。
注册侧边栏在/config/menus.js文件中。
新添加要求:
在遇到用户id需要填写和修改的弹窗将其修改为可预览样式
关于填写表单为推荐人id的需要使用组件AvatarSelector展示,如果是文件id或者是封面id 的也需要预览展示需要向头像列表组件一样,可以弄个文件组件/api/v1/admin/file/list这个是文件列表接口
规则:
1.只要涉及弹窗添加和修改xxxid类型的就需要生成一个弹窗组件并使用到页面中
## 1. 基础布局规范
```css
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL='https://apiservertest.s1f.ren'
+14 -10
View File
@@ -18,7 +18,8 @@ jobs:
- name: Install dependencies
run: |
pnpm install
pnpm install --ignore-scripts
pnpm rebuild
- name: 替换域名
run: |
@@ -28,19 +29,22 @@ jobs:
run: |
pnpm build
- name: Compress artifacts
run: |
tar -czf dist.tar.gz -C ./dist .
- name: Save artifact
uses: actions/upload-artifact@v3
with:
name: vue3-build
path: |
./dist
path: dist.tar.gz
deploy:
needs: build
runs-on: hongKong
runs-on: ninBo
steps:
- name: Download Artifact
uses: actions/download-artifact@v3
uses: https://gitea.s1f.ren/actions/download-artifact@v3
with:
name: vue3-build
@@ -49,11 +53,11 @@ jobs:
mkdir -p ~/.ssh
echo "${{ secrets.PUBLICT_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP }} >> ~/.ssh/known_hosts
- name: Deploy to server
run: |
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP_1 }} >> ~/.ssh/known_hosts
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP_1 }}:/home/www/admin.007yjs.com/
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP_2 }} >> ~/.ssh/known_hosts
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP_2 }}:/home/www/admin.007yjs.com/
DEPLOY_DIR="/home/www/web-online/admin.007yjs.com/"
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }} "mkdir -p $DEPLOY_DIR"
scp -o StrictHostKeyChecking=no dist.tar.gz ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }}:$DEPLOY_DIR
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }} "cd $DEPLOY_DIR && tar -xzf dist.tar.gz && rm -f dist.tar.gz"
+13 -7
View File
@@ -18,25 +18,29 @@ jobs:
- name: Install dependencies
run: |
pnpm install
pnpm install --ignore-scripts
pnpm rebuild
- name: Build project
run: |
pnpm build
- name: Compress artifacts
run: |
tar -czf dist.tar.gz -C ./dist .
- name: Save artifact
uses: actions/upload-artifact@v3
with:
name: vue3-build
path: |
./dist
path: dist.tar.gz
deploy:
needs: build
runs-on: ubuntu-latest
runs-on: ninBo
steps:
- name: Download Artifact
uses: actions/download-artifact@v3
uses: https://gitea.s1f.ren/actions/download-artifact@v3
with:
name: vue3-build
@@ -49,5 +53,7 @@ jobs:
- name: Deploy to server
run: |
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }}:/www/wwwroot/apiserver_admin.s1f.ren/
DEPLOY_DIR="/www/wwwroot/apiserver_admin.s1f.ren/"
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }} "mkdir -p $DEPLOY_DIR"
scp -o StrictHostKeyChecking=no dist.tar.gz ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }}:$DEPLOY_DIR
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }} "cd $DEPLOY_DIR && tar -xzf dist.tar.gz && rm -f dist.tar.gz"
View File
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/x-icon" href="/logo.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="007UI - 高端蓝色扁平化后台管理系统模板" />
<meta name="keywords" content="管理系统,后台,模板,Vue3,ElementPlus" />
+984 -931
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

+72 -1
View File
@@ -226,11 +226,16 @@ html, body {
color: #3498db !important;
}
/* 卡片扁平化 */
/* 卡片扁平化 + 层次感 */
.el-card {
border-radius: 0 !important;
border: 1px solid #e1e8ed !important;
box-shadow: none !important;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.el-card[shadow="hover"]:hover {
border-color: #c0c4cc !important;
box-shadow: 0 2px 12px rgba(44, 62, 80, 0.08) !important;
}
/* 表格扁平化 */
@@ -434,4 +439,70 @@ html, body {
.el-dialog .el-form-item {
margin-bottom: 20px;
}
/* Descriptions 描述列表增强 */
.el-descriptions {
--el-descriptions-item-bordered-label-background: #fafbfc;
}
.el-descriptions__label {
color: #606266 !important;
font-weight: 500 !important;
}
.el-descriptions__content {
color: #1d2129 !important;
}
/* Loading 遮罩增强 */
.el-loading-mask {
background-color: rgba(255, 255, 255, 0.85) !important;
}
.el-loading-spinner .circular {
width: 36px;
height: 36px;
}
.el-loading-spinner .el-loading-text {
color: #606266 !important;
font-size: 13px;
margin-top: 8px;
}
/* Message Box 增强 */
.el-message-box {
border-radius: 0 !important;
box-shadow: 0 4px 16px rgba(44, 62, 80, 0.15) !important;
}
.el-message-box__header {
padding: 16px 20px 12px !important;
}
.el-message-box__title {
font-weight: 600 !important;
color: #1d2129 !important;
}
.el-message-box__btns .el-button {
border-radius: 0 !important;
}
/* Alert 增强 */
.el-alert {
border-radius: 0 !important;
}
/* Tabs 增强 */
.el-tabs__item {
transition: color 0.2s ease !important;
}
.el-tabs__item.is-active {
font-weight: 600 !important;
}
/* Switch 开关增强 */
.el-switch {
--el-switch-on-color: #2c3e50;
}
/* 全局链接按钮悬浮下划线 */
.el-button.is-link:hover,
.el-button--primary.is-link:hover {
text-decoration: underline;
}
</style>
+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'
}
})
}
+818
View File
@@ -0,0 +1,818 @@
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 })
}
/** 设置主IP */
export const setNetworkPrimary = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/network/set_primary', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 重置虚拟机MAC地址 */
export const resetVmMac = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/reset_mac', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
/** 断开虚拟机外部网络 */
export const disconnectVmNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/disconnect_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 恢复虚拟机外部网络 */
export const connectVmNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/connect_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 查询虚拟机每小时流量 */
export const getVmTrafficHourly = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/traffic_hourly', { params })
}
/** 获取宿主机额度统计 */
export const getHostQuotaStats = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/quota_stats', { params })
}
/** 获取宿主机 KSM 状态 */
export const getHostKsmStatus = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/ksm/status', { params })
}
/** 配置宿主机 KSM */
export const configureHostKsm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/host/ksm/configure', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* ================================
* 主控服务接口 - 数据卷管理
* ================================
*/
/** 获取数据卷列表 */
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' }
})
}
+43
View File
@@ -0,0 +1,43 @@
import { http2 } from '@/utils/request.js'
const formHeaders = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
// ========== 邮件主控服务 ==========
export const getMailServiceList = (params) => {
return http2.get('/api/v1/admin/server/mail_service/list', { params })
}
export const getMailServiceDetail = (params) => {
return http2.get('/api/v1/admin/server/mail_service/detail', { params })
}
export const createMailService = (data) => {
return http2.post('/api/v1/admin/server/mail_service/create', data, formHeaders)
}
export const updateMailService = (data) => {
return http2.post('/api/v1/admin/server/mail_service/update', data, formHeaders)
}
export const deleteMailService = (data) => {
return http2.delete('/api/v1/admin/server/mail_service/delete', { data, ...formHeaders })
}
// ========== 邮件额度商品 ==========
export const getMailGoodsList = (params) => {
return http2.get('/api/v1/admin/server/mail_service/goods/list', { params })
}
export const createMailGoods = (data) => {
return http2.post('/api/v1/admin/server/mail_service/goods/create', data, formHeaders)
}
export const updateMailGoods = (data) => {
return http2.post('/api/v1/admin/server/mail_service/goods/update', data, formHeaders)
}
export const deleteMailGoods = (data) => {
return http2.delete('/api/v1/admin/server/mail_service/goods/delete', { data, ...formHeaders })
}
+48
View File
@@ -0,0 +1,48 @@
import { http2 } from '@/utils/request.js'
// ========== 通知渠道配置 ==========
/** 获取全部通知渠道配置列表(无分页) */
export const getNoticeChannelList = () => {
return http2.get('/api/v1/admin/notice_message/channel/list')
}
/** 修改通知渠道配置 */
export const updateNoticeChannel = (data) => {
return http2.post('/api/v1/admin/notice_message/channel/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// ========== 通知模板管理 ==========
/** 获取全部通知模板列表(无分页) */
export const getNoticeTemplateList = () => {
return http2.get('/api/v1/admin/notice_message/template/list')
}
/** 添加通知模板 */
export const addNoticeTemplate = (data) => {
return http2.post('/api/v1/admin/notice_message/template/add', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 修改通知模板 */
export const updateNoticeTemplate = (data) => {
return http2.post('/api/v1/admin/notice_message/template/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除通知模板 */
export const deleteNoticeTemplate = (params) => {
return http2.delete('/api/v1/admin/notice_message/template/delete', { params })
}
/** 使用默认参数预览渲染模板 */
export const previewNoticeTemplate = (data) => {
return http2.post('/api/v1/admin/notice_message/template/default_msg', 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) => {
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) => {
return http2.post('/api/v1/admin/good/goods/create', data,{
@@ -106,7 +110,8 @@ export const getProductParameterDetail = (params) => {
}
/**更新商品参数 */
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:{
'Content-Type':'multipart/form-data'
}
@@ -139,4 +144,113 @@ export const updateProductParameterValue = (data) => {
'Content-Type':'multipart/form-data'
}
})
}
/**---------------------------------- */
/**商品套餐管理 */
/**获取商品套餐列表 */
export const getProductPlanList = (params) => {
return http2.get('/api/v1/admin/good/plan/list', {params: params})
}
/**获取商品套餐详情 */
export const getProductPlanDetail = (params) => {
return http2.get('/api/v1/admin/good/plan/detail', {params: params})
}
/**创建商品套餐 */
export const createProductPlan = (data) => {
return http2.post('/api/v1/admin/good/plan/create', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**更新商品套餐 */
export const updateProductPlan = (data) => {
return http2.post('/api/v1/admin/good/plan/update', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**删除商品套餐 */
export const deleteProductPlan = (params) => {
return http2.delete('/api/v1/admin/good/plan/delete', {params: params})
}
/**禁用商品套餐 */
export const disableProductPlan = (data) => {
return http2.post('/api/v1/admin/good/plan/disable', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**启用商品套餐 */
export const enableProductPlan = (data) => {
return http2.post('/api/v1/admin/good/plan/enable', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**禁用套餐固定价格 */
export const disablePlanFixedPrice = (data) => {
return http2.post('/api/v1/admin/good/plan/disable_fixed_price', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**启用套餐固定价格 */
export const enablePlanFixedPrice = (data) => {
return http2.post('/api/v1/admin/good/plan/enable_fixed_price', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**---------------------------------- */
/**商品分组标签管理 */
/**获取商品分组标签列表 */
export const getProductGroupTagList = (params) => {
return http2.get('/api/v1/admin/good/group_tag/list', {params: params})
}
/**获取商品分组标签详情 */
export const getProductGroupTagDetail = (params) => {
return http2.get('/api/v1/admin/good/group_tag/detail', {params: params})
}
/**创建商品分组标签 */
export const createProductGroupTag = (data) => {
return http2.post('/api/v1/admin/good/group_tag/create', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**更新商品分组标签 */
export const updateProductGroupTag = (data) => {
return http2.post('/api/v1/admin/good/group_tag/update', data,{
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**删除商品分组标签 */
export const deleteProductGroupTag = (params) => {
return http2.delete('/api/v1/admin/good/group_tag/delete', {params: params})
}
/**---------------------------------- */
/**已购商品管理 */
/**获取用户已购商品列表 */
export const getUserGoodsList = (params) => {
return http2.get('/api/v1/admin/good/user_goods/list', {params: params})
}
+145
View File
@@ -0,0 +1,145 @@
import { http2 } from '@/utils/request.js'
const formHeaders = { headers: { 'Content-Type': 'multipart/form-data' } }
// ========== 短信主控服务 ==========
export const getSmsServiceList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/list', { params })
}
export const createSmsService = (data) => {
return http2.post('/api/v1/admin/server/sms_service/create', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const updateSmsService = (data) => {
return http2.post('/api/v1/admin/server/sms_service/update', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const deleteSmsService = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/delete', {
data,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const setDefaultSmsService = (data) => {
return http2.post('/api/v1/admin/server/sms_service/set_default', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
// ========== 短信额度商品 ==========
export const getSmsGoodsList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/goods/list', { params })
}
export const createSmsGoods = (data) => {
return http2.post('/api/v1/admin/server/sms_service/goods/create', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const updateSmsGoods = (data) => {
return http2.post('/api/v1/admin/server/sms_service/goods/update', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export const deleteSmsGoods = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/goods/delete', {
data,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
// ========== 短信签名管理 ==========
export const getSmsSignatureList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/signature/list', { params })
}
export const getSmsSignatureDetail = (params) => {
return http2.get('/api/v1/admin/server/sms_service/signature/detail', { params })
}
export const createSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/create', data, formHeaders)
}
export const updateSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/update', data, formHeaders)
}
export const deleteSmsSignature = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/signature/delete', { data, ...formHeaders })
}
export const submitSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/submit', data, formHeaders)
}
export const approveSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/approve', data, formHeaders)
}
export const rejectSmsSignature = (data) => {
return http2.post('/api/v1/admin/server/sms_service/signature/reject', data, formHeaders)
}
// ========== 短信模板管理 ==========
export const getSmsTemplateList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/template/list', { params })
}
export const getSmsTemplateDetail = (params) => {
return http2.get('/api/v1/admin/server/sms_service/template/detail', { params })
}
export const createSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/create', data, formHeaders)
}
export const updateSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/update', data, formHeaders)
}
export const deleteSmsTemplate = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/template/delete', { data, ...formHeaders })
}
export const submitSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/submit', data, formHeaders)
}
export const approveSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/approve', data, formHeaders)
}
export const rejectSmsTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/reject', data, formHeaders)
}
// ========== 推荐模板管理 ==========
export const getSmsRecommendedTemplateList = (params) => {
return http2.get('/api/v1/admin/server/sms_service/template/recommended/list', { params })
}
export const createSmsRecommendedTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/recommended/create', data, formHeaders)
}
export const updateSmsRecommendedTemplate = (data) => {
return http2.post('/api/v1/admin/server/sms_service/template/recommended/update', data, formHeaders)
}
export const deleteSmsRecommendedTemplate = (data) => {
return http2.delete('/api/v1/admin/server/sms_service/template/recommended/delete', { data, ...formHeaders })
}
+13 -7
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) => {
@@ -38,8 +34,8 @@ export const getUserInfo = (data) => {
}
/**获取用户列表 */
export const getUserList = (data) => {
return http2.get('/api/v1/admin/user/user/list?page=' + data.page + '&count=' + data.count + '&key=' + data.key)
export const getUserList = (params) => {
return http2.get('/api/v1/admin/user/user/list', { params })
}
/**更新用户信息 */
@@ -53,7 +49,7 @@ export const updateUserInfo = (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) => {
@@ -162,4 +158,14 @@ export const addUserGroupMember = (data) => {
'Content-Type':'multipart/form-data'
}
})
}
/**退款对应账单 */
export const refundBalance = (data) => {
return http2.get('/api/v1/admin/user/balance/refund', {
params:data,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
+30
View File
@@ -0,0 +1,30 @@
import { http2 } from '@/utils/request.js'
const formHeaders = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
// ========== 用户组优惠(用户组 与 商品/商品组 绑定优惠) ==========
/** 获取用户组优惠列表(可按 user_group_id / good_id / good_group_id 过滤) */
export const getUserGroupDiscountList = (params) => {
return http2.get('/api/v1/admin/user_group/discount/list', { params })
}
/** 获取用户组优惠详情 */
export const getUserGroupDiscountDetail = (params) => {
return http2.get('/api/v1/admin/user_group/discount/detail', { params })
}
/** 添加用户组优惠(将用户组与商品/商品组绑定优惠) */
export const addUserGroupDiscount = (data) => {
return http2.post('/api/v1/admin/user_group/discount/add', data, formHeaders)
}
/** 修改用户组优惠(可改绑商品/商品组) */
export const updateUserGroupDiscount = (data) => {
return http2.post('/api/v1/admin/user_group/discount/update', data, formHeaders)
}
/** 删除用户组优惠(解绑用户组与商品/商品组的优惠) */
export const deleteUserGroupDiscount = (params) => {
return http2.delete('/api/v1/admin/user_group/discount/delete', { params })
}
+123
View File
@@ -0,0 +1,123 @@
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 setUserVmNetworkPrimary = (data) => http2.post(`${BASE}/network/set_primary`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const resetUserVmMac = (params) => http2.post(`${BASE}/reset_mac`, null, { params })
export const disconnectUserVmNetwork = (data) => http2.post(`${BASE}/disconnect_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const connectUserVmNetwork = (data) => http2.post(`${BASE}/connect_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
// ========== 组网 ==========
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 getUserVmTrafficHourly = (params) => http2.get(`${BASE}/traffic_hourly`, { 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
@@ -7,4 +7,14 @@ export const userLogin = (username,password) => {
export const getUserInfo = () => {
return request.get("/api/v1/users/info/info")
}
// 获取交换token(用于无感刷新)
export const getRefreshToken = (domain) => {
return request.get("/api/v1/users/info/refresh_token", { domain })
}
// 使用交换token获取新的access token
export const refreshAccessToken = (refresh_token) => {
return request.post("/api/v1/user/refresh_token", { refresh_token })
}
+40 -8
View File
@@ -5,12 +5,14 @@ import request from "@/utils/request.js";
* @returns {Promise}
*/
export function getTickerList(count, page, status, orderBy, order) {
export function getTickerList(count, page, status, orderBy, order, userId, keyword, type) {
const params = { count, page }
if (status !== undefined && status !== '') params.status = status
if (orderBy) params.orderBy = orderBy
if (order) params.order = order
console.log('工单列表请求参数:', params) // 调试日志
if (userId) params.user_id = userId
if (keyword) params.keyword = keyword
if (type) params.type = type
return request.get('/api/v1/admin/work_order/list', params)
}
@@ -41,12 +43,16 @@ export function getTicketDetail(work_id) {
// 回复工单
export function replyTicket(work_id, content, files) {
return request.post('/api/v1/admin/work_order/reply', { work_id, content, files })
return request.post('/api/v1/admin/work_order/reply', { work_id, content, files }, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 关闭工单
export function closeTicket(work_id) {
return request.post('/api/v1/admin/work_order/close', { work_id })
return request.post('/api/v1/admin/work_order/close', { work_id }, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function getFile(file_id) {
@@ -104,10 +110,9 @@ export function updateTicketType(data) {
}
/**删除工单类型 */
export function deleteTicketType(data) {
return request.delete('/api/v1/admin/work_order/delete_type', data,{
headers:{
'Content-Type':'multipart/form-data'
}
return request.delete('/api/v1/admin/work_order/delete_type', {
data: data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**获取工单类型列表 */
@@ -122,3 +127,30 @@ export function updateTicketReplayInfo(data){
}
})
}
/**获取回复模板列表 */
export function getReplyTemplateList(params = {}) {
return request.get('/api/v1/admin/work_order/reply_template/list', params)
}
/**创建回复模板 */
export function createReplyTemplate(data) {
return request.post('/api/v1/admin/work_order/reply_template/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**修改回复模板 */
export function updateReplyTemplate(data) {
return request.post('/api/v1/admin/work_order/reply_template/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**删除回复模板 */
export function deleteReplyTemplate(data) {
return request.delete('/api/v1/admin/work_order/reply_template/delete', {
data: data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
+158 -60
View File
@@ -2,65 +2,98 @@
<el-dialog
:model-value="visible"
title="选择用户"
width="800px"
width="700px"
class="user-selector-dialog"
append-to-body
@update:model-value="handleVisibleChange"
>
<!-- 搜索栏 -->
<div class="selector-search">
<el-input
v-model="searchParams.key"
placeholder="搜索用户名或ID"
clearable
@keyup.enter="handleSearch"
style="width: 300px; margin-right: 12px"
<div class="user-selector-content">
<!-- 搜索栏 -->
<div class="selector-search">
<el-input
v-model="searchParams.key"
placeholder="搜索用户名、邮箱或ID"
clearable
@keyup.enter="handleSearch"
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #append>
<el-button @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-select v-model="searchParams.is_admin" placeholder="全部身份" clearable style="width: 120px" @change="handleSearch">
<el-option label="管理员" :value="true" />
<el-option label="普通用户" :value="false" />
</el-select>
<el-button @click="handleReset" class="reset-btn">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
<!-- 用户表格 -->
<el-table
v-loading="loading"
:data="userList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
max-height="350"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column prop="user_name" label="用户名" min-width="130">
<template #default="{ row }">
<div class="user-name-cell">
<el-avatar v-if="row.cover" :src="row.cover" :size="28" />
<el-avatar v-else :size="28">
{{ row.user_name?.charAt(0)?.toUpperCase() || 'U' }}
</el-avatar>
<span class="user-name">{{ row.user_name || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180">
<template #default="{ row }">
<span class="text-ellipsis">{{ row.email || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.disable" type="danger" size="small">禁用</el-tag>
<el-tag v-else type="success" size="small">正常</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
background
small
class="selector-pagination"
/>
</div>
<!-- 用户表格 -->
<el-table
v-loading="loading"
:data="userList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%; margin-top: 16px"
:height="400"
>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="UserId" label="用户ID" width="100" />
<el-table-column prop="UserName" label="用户名" min-width="150" />
<el-table-column prop="Email" label="邮箱" min-width="180" />
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
background
class="selector-pagination"
/>
<template #footer>
<div class="dialog-footer">
<span v-if="selectedUser" class="selected-info">
已选择: <el-tag type="primary" size="small">{{ selectedUser.user_name }} (ID: {{ selectedUser.user_id }})</el-tag>
</span>
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="confirmSelection" :disabled="!selectedUser">
确定选择
确定
</el-button>
</div>
</template>
@@ -69,7 +102,7 @@
<script setup>
import { ref, reactive, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getUserList } from '@/api/admin/user'
import { ElMessage } from 'element-plus'
@@ -89,6 +122,7 @@ const selectedUser = ref(null)
const searchParams = reactive({
key: '',
is_admin: undefined,
page: 1,
count: 10
})
@@ -97,9 +131,7 @@ const searchParams = reactive({
watch(() => props.visible, (newVal) => {
if (newVal) {
selectedUser.value = null
if (userList.value.length === 0) {
fetchUserList()
}
fetchUserList()
}
})
@@ -134,6 +166,7 @@ const handleSearch = () => {
const handleReset = () => {
searchParams.key = ''
searchParams.is_admin = undefined
searchParams.page = 1
fetchUserList()
}
@@ -163,29 +196,94 @@ const confirmSelection = () => {
</script>
<style scoped>
.user-selector-content {
max-height: 520px;
overflow: hidden;
padding: 4px 0;
}
.selector-search {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
gap: 10px;
padding: 14px 16px;
background: linear-gradient(135deg, #f7f8fa 0%, #f0f2f5 100%);
border-radius: 10px;
border: 1px solid #ebeef5;
margin-bottom: 16px;
}
.search-input {
flex: 1;
min-width: 0;
}
.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 {
margin-top: 16px;
margin-top: 14px;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.selected-info {
margin-right: auto;
color: #409eff;
font-size: 13px;
font-weight: 500;
}
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table__row) {
cursor: pointer;
transition: background-color .15s;
}
:deep(.el-table__row):hover {
background-color: #f5f7fa;
:deep(.el-table__row:hover > td) {
background-color: #f0f7ff !important;
}
:deep(.current-row) {
background-color: var(--el-color-primary-light-8) !important;
:deep(.current-row > td) {
background-color: var(--el-color-primary-light-9) !important;
}
:deep(.current-row td) {
color: var(--el-color-primary);
font-weight: bold;
}
:deep(.el-avatar) {
background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary));
color: #fff;
font-size: 12px;
font-weight: 600;
}
</style>
+40 -13
View File
@@ -1,21 +1,27 @@
<template>
<el-dialog
v-model="visible"
title="选择头像"
:title="title"
width="800px"
append-to-body
@close="handleClose"
>
<div class="avatar-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 用户文件列表 -->
<el-tab-pane label="用户文件" name="userFiles">
<!-- 文件列表 -->
<el-tab-pane label="文件" name="userFiles">
<div class="file-list-container">
<div class="file-list-header">
<h4>用户文件列表</h4>
<el-button type="primary" @click="switchToUpload" :icon="Upload">
上传新头像
</el-button>
<h4>文件列表</h4>
<div class="header-actions">
<el-select v-model="sourceFilter" placeholder="上传来源" clearable size="default" style="width: 120px" @change="handleSourceChange">
<el-option label="管理员" :value="true" />
<el-option label="用户" :value="false" />
</el-select>
<el-button type="primary" @click="switchToUpload" :icon="Upload">
上传新文件
</el-button>
</div>
</div>
<div class="file-grid" v-loading="loading">
<div
@@ -58,8 +64,8 @@
</div>
</el-tab-pane>
<!-- 上传头像 -->
<el-tab-pane label="上传头像" name="upload">
<!-- 上传文件 -->
<el-tab-pane label="上传文件" name="upload">
<div class="upload-section">
<el-upload
:http-request="handleUpload"
@@ -118,6 +124,10 @@ import { closeAllMessage } from '../../utils/message'
currentCoverId: {
type: [String, Number],
default: ''
},
title: {
type: String,
default: '选择文件'
}
})
@@ -133,6 +143,7 @@ import { closeAllMessage } from '../../utils/message'
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const sourceFilter = ref(undefined)
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
@@ -140,6 +151,7 @@ import { closeAllMessage } from '../../utils/message'
if (newVal) {
selectedId.value = props.currentCoverId
currentPage.value = 1
sourceFilter.value = undefined
fetchFileList()
}
})
@@ -157,10 +169,11 @@ import { closeAllMessage } from '../../utils/message'
fileList.value = [] // 清空列表
try {
const res = await getFileList({
page: currentPage.value,
count: pageSize.value
})
const params = { page: currentPage.value, count: pageSize.value }
if (sourceFilter.value !== undefined && sourceFilter.value !== null && sourceFilter.value !== '') {
params.is_admin = sourceFilter.value
}
const res = await getFileList(params)
console.log("获取文件列表:", res)
@@ -215,6 +228,12 @@ import { closeAllMessage } from '../../utils/message'
fetchFileList()
}
// 来源筛选变化
const handleSourceChange = () => {
currentPage.value = 1
fetchFileList()
}
// 切换到上传标签页
const switchToUpload = () => {
activeTab.value = 'upload'
@@ -270,6 +289,7 @@ import { closeAllMessage } from '../../utils/message'
formData.append('files', file)
formData.append('file_names', file.name)
formData.append('update_type', 'cover')
formData.append('open_down', 'true')
try {
const res = await uploadFile(formData)
@@ -307,6 +327,7 @@ import { closeAllMessage } from '../../utils/message'
fileList.value = []
currentPage.value = 1
total.value = 0
sourceFilter.value = undefined
}
// 确认选择
@@ -342,6 +363,12 @@ import { closeAllMessage } from '../../utils/message'
margin: 0;
color: #303133;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.file-grid {
display: grid;
@@ -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>
+699
View File
@@ -0,0 +1,699 @@
<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;"
/>
<el-select v-model="sourceFilter" placeholder="上传来源" clearable style="width: 130px; margin-left: 12px;" @change="handleSourceChange">
<el-option label="管理员" :value="true" />
<el-option label="用户" :value="false" />
</el-select>
</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 sourceFilter = ref(undefined)
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 = ''
sourceFilter.value = undefined
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 params = { page: currentPage.value, count: pageSize.value }
if (sourceFilter.value !== undefined && sourceFilter.value !== null && sourceFilter.value !== '') {
params.is_admin = sourceFilter.value
}
const res = await getFileList(params)
// 如果有更新的请求发起,丢弃当前结果
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 handleSourceChange = () => {
currentPage.value = 1
fetchFileList()
}
// 分页处理
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 = ''
sourceFilter.value = undefined
// 清理待上传文件的预览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;
display: flex;
align-items: center;
}
.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>
+273
View File
@@ -0,0 +1,273 @@
<template>
<el-dialog v-model="visible" title="选择用户" width="860px" append-to-body @close="handleClose" class="user-selector-dialog">
<div class="uls-root">
<el-tabs v-model="activeTab" @tab-click="handleTabClick" class="uls-tabs">
<!-- ====== 选择用户 ====== -->
<el-tab-pane name="selectUser">
<template #label>
<span class="tab-lbl">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/></svg>
选择用户
</span>
</template>
<!-- 搜索栏 -->
<div class="uls-search">
<el-input v-model="searchParams.key" placeholder="搜索用户名 / 邮箱 / ID" clearable @keyup.enter="handleSearch" class="uls-search-input" :prefix-icon="Search" />
<el-select v-model="searchParams.is_admin" placeholder="全部身份" clearable style="width: 120px" @change="handleSearch">
<el-option label="管理员" :value="true" />
<el-option label="普通用户" :value="false" />
</el-select>
<el-button type="primary" @click="handleSearch" :icon="Search" circle />
<el-button @click="handleReset" :icon="Refresh" circle />
</div>
<!-- 已选提示 -->
<transition name="fade">
<div class="uls-selected-bar" v-if="selectedUser">
<div class="uls-sel-left">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#67c23a"/></svg>
<span>已选择</span>
<el-avatar :size="24" :src="selectedUser.cover" class="sel-avatar">{{ selectedUser.user_name?.charAt(0)?.toUpperCase() || 'U' }}</el-avatar>
<b>{{ selectedUser.user_name }}</b>
<span class="sel-id">#{{ selectedUser.user_id }}</span>
</div>
<el-button size="small" link type="danger" @click="selectedUser = null">取消选择</el-button>
</div>
</transition>
<!-- 用户卡片列表 -->
<div class="uls-grid" v-loading="loading">
<div
v-for="user in userList" :key="user.user_id"
class="uls-card" :class="{ active: selectedUser?.user_id === user.user_id }"
@click="handleCurrentChange(user)"
>
<div class="uls-card-check" v-if="selectedUser?.user_id === user.user_id">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#fff"/></svg>
</div>
<el-avatar :size="42" :src="user.cover" class="uls-avatar">{{ user.user_name?.charAt(0)?.toUpperCase() || 'U' }}</el-avatar>
<div class="uls-card-body">
<div class="uls-card-name">
{{ user.user_name }}
<el-tag v-if="user.is_admin" type="warning" size="small" effect="dark" round class="admin-tag">管理员</el-tag>
</div>
<div class="uls-card-meta">
<span class="meta-id">#{{ user.user_id }}</span>
<span v-if="user.email" class="meta-email">{{ user.email }}</span>
<span v-if="user.phone" class="meta-phone">{{ user.phone }}</span>
</div>
</div>
</div>
</div>
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" :image-size="80" />
<!-- 分页 -->
<div class="uls-pagination" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[12, 24, 48]"
:total="total"
layout="total, sizes, prev, pager, next"
background small
@size-change="s => { searchParams.count = s; searchParams.page = 1; fetchUserList() }"
@current-change="p => { searchParams.page = p; fetchUserList() }"
/>
</div>
</el-tab-pane>
<!-- ====== 添加用户 ====== -->
<el-tab-pane name="addUser">
<template #label>
<span class="tab-lbl">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/></svg>
添加用户
</span>
</template>
<div class="uls-add-section">
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="90px" class="uls-add-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" :icon="Plus">立即创建</el-button>
<el-button @click="resetAddForm" :icon="Refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<div class="uls-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'
const props = defineProps({
modelValue: { type: Boolean, default: false },
currentUserId: { type: [String, Number], default: '' }
})
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: '', is_admin: undefined, page: 1, count: 12 })
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' }]
}
watch(() => props.modelValue, (v) => {
visible.value = v
if (v) { activeTab.value = 'selectUser'; selectedUser.value = null; searchParams.page = 1; fetchUserList() }
})
watch(visible, (v) => emit('update:modelValue', v))
const fetchUserList = async () => {
loading.value = true; userList.value = []
try {
const params = { page: searchParams.page, count: searchParams.count, key: searchParams.key || '' }
if (searchParams.is_admin !== undefined && searchParams.is_admin !== null && searchParams.is_admin !== '') params.is_admin = searchParams.is_admin
const res = await getUserList(params)
if (res.data.code === 200) {
userList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0
if (props.currentUserId) { const u = userList.value.find(u => u.user_id === props.currentUserId); if (u) selectedUser.value = u }
}
} catch { 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.is_admin = undefined; searchParams.page = 1; fetchUserList() }
const handleCurrentChange = (row) => { selectedUser.value = (selectedUser.value?.user_id === row.user_id) ? null : row }
const switchToAdd = () => { activeTab.value = 'addUser' }
const handleAddUser = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate(async (valid) => {
if (!valid) return
addLoading.value = true
try {
const fd = new FormData()
fd.append('user_name', addForm.user_name); fd.append('email', addForm.email)
if (addForm.phone) fd.append('phone', addForm.phone)
fd.append('password', addForm.password)
const res = await createTask(fd)
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 { ElMessage.error('创建失败') }
finally { addLoading.value = false }
})
}
const resetAddForm = () => { Object.assign(addForm, { user_name: '', email: '', phone: '', password: '', confirmPassword: '' }); addFormRef.value?.resetFields() }
const handleClose = () => { visible.value = false; selectedUser.value = null; userList.value = []; searchParams.key = ''; searchParams.is_admin = undefined; searchParams.page = 1; total.value = 0; resetAddForm() }
const handleConfirm = () => { if (selectedUser.value) { emit('confirm', selectedUser.value); handleClose() } else ElMessage.warning('请选择一个用户') }
</script>
<style scoped>
.uls-root { min-height: 460px; }
/* 标签页 */
.tab-lbl { display: inline-flex; align-items: center; gap: 5px; }
:deep(.el-tabs__item) { font-size: 15px; padding: 0 20px; }
:deep(.el-tabs__item.is-active) { font-weight: 600; }
/* 搜索栏 */
.uls-search { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.uls-search-input { flex: 1; max-width: 320px; }
/* 已选提示条 */
.uls-selected-bar { display: flex; align-items: center; justify-content: space-between; padding: 8px 14px; margin-bottom: 12px; background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%); border: 1px solid #c2e7b0; border-radius: 8px; }
.uls-sel-left { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #1d2129; }
.uls-sel-left b { font-weight: 600; }
.sel-id { color: #86909c; font-size: 12px; }
.sel-avatar { flex-shrink: 0; font-size: 11px; background: linear-gradient(135deg, #67c23a, #85ce61); color: #fff; }
.fade-enter-active, .fade-leave-active { transition: all .25s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-6px); }
/* 卡片网格 */
.uls-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; max-height: 340px; overflow-y: auto; padding: 2px; }
.uls-card { position: relative; display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 2px solid #ebeef5; border-radius: 10px; cursor: pointer; transition: all .2s ease; background: #fff; }
.uls-card:hover { border-color: #b3d8ff; background: #f5faff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(64,158,255,.1); }
.uls-card.active { border-color: #409eff; background: #ecf5ff; }
.uls-card-check { position: absolute; top: -1px; right: -1px; width: 24px; height: 24px; background: #409eff; border-radius: 0 8px 0 8px; display: flex; align-items: center; justify-content: center; }
.uls-avatar { flex-shrink: 0; font-size: 16px; font-weight: 700; background: linear-gradient(135deg, #c6e2ff, #409eff); color: #fff; }
.uls-card.active .uls-avatar { background: linear-gradient(135deg, #409eff, #337ecc); }
.uls-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.uls-card-name { font-size: 14px; font-weight: 600; color: #1d2129; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.admin-tag { transform: scale(.85); transform-origin: left center; }
.uls-card-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 11px; color: #a8abb2; line-height: 1.3; }
.meta-id { color: #86909c; font-weight: 500; }
.meta-email, .meta-phone { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }
/* 分页 */
.uls-pagination { margin-top: 14px; display: flex; justify-content: flex-end; }
/* 添加用户 */
.uls-add-section { padding: 24px 40px; }
.uls-add-form { max-width: 460px; margin: 0 auto; }
/* 底部 */
.uls-footer { display: flex; justify-content: flex-end; gap: 10px; }
/* 滚动条美化 */
.uls-grid::-webkit-scrollbar { width: 5px; }
.uls-grid::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 4px; }
.uls-grid::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
</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,149 @@
<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
})
const code = res?.data?.code
if (code === 200 || code === 201 || (code >= 200 && code < 300)) {
ElMessage.success('创建成功')
showCreate.value = false
Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false })
await loadList()
} else {
ElMessage.error(res?.data?.message || res?.data?.error || '创建失败')
}
} catch (e) {
const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || '创建失败'
ElMessage.error(msg)
} 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>
+393 -57
View File
@@ -1,9 +1,13 @@
<template>
<div class="admin-layout">
<div class="admin-layout" :class="{ 'sidebar-collapsed': isCollapsed, 'mobile-open': isMobileMenuOpen }">
<!-- 移动端遮罩层 -->
<div class="mobile-overlay" v-if="isMobileMenuOpen" @click="closeMobileMenu"></div>
<!-- 侧边栏 -->
<div class="sidebar">
<div class="sidebar" :class="{ 'collapsed': isCollapsed }">
<div class="logo-container">
<img src="@/assets/logo.png" alt="Logo" class="logo-img" />
<img src="@/assets/logo.png" alt="Logo" class="logo-img" v-show="!isCollapsed" />
<img src="@/assets/logo.svg" alt="Logo" class="logo-img-mini" v-show="isCollapsed" />
</div>
<el-scrollbar class="sidebar-scrollbar">
<el-menu
@@ -13,11 +17,20 @@
text-color="#34495e"
active-text-color="#2c3e50"
:unique-opened="true"
:collapse="isCollapsed"
:collapse-transition="false"
router
>
<sidebar-menu-item v-for="menu in menus" :key="menu.path" :menu="menu" />
</el-menu>
</el-scrollbar>
<!-- 收缩按钮 -->
<div class="collapse-btn" @click="toggleCollapse">
<el-icon :size="18">
<Fold v-if="!isCollapsed" />
<Expand v-else />
</el-icon>
</div>
</div>
<!-- 主区域 -->
@@ -25,23 +38,23 @@
<!-- 顶部导航 -->
<div class="navbar">
<div class="navbar-left">
<!-- 移动端菜单按钮 -->
<el-button type="text" class="mobile-menu-btn" @click="toggleMobileMenu">
<el-icon :size="22"><Menu /></el-icon>
</el-button>
<breadcrumb />
</div>
<div class="navbar-right">
<div class="navbar-item">
<el-tooltip content="全屏" placement="bottom">
<el-button type="text" class="header-btn" @click="toggleFullScreen">
<el-icon :size="18"><full-screen /></el-icon>
</el-button>
</el-tooltip>
<GlobalSearch />
</div>
<div class="navbar-item">
<el-dropdown trigger="click">
<div class="avatar-container">
<el-avatar :size="32" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
<span class="username">{{ userStore.userInfo.user_name }}</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
<el-avatar :size="32" :src="userStore.getUserAvatar() || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" />
<span class="username hidden-mobile">{{ userStore.userInfo.user_name }}</span>
<el-icon class="el-icon--right hidden-mobile"><arrow-down /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
@@ -81,18 +94,21 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SidebarMenuItem from './SidebarMenuItem.vue'
import Breadcrumb from './Breadcrumb.vue'
import TagsView from './TagsView.vue'
import GlobalSearch from './GlobalSearch.vue'
import { menus as menuConfig } from '@/config/menus'
import {
FullScreen,
ArrowDown,
User,
Key,
SwitchButton
SwitchButton,
Fold,
Expand,
Menu
} from '@element-plus/icons-vue'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import { ElMessageBox } from 'element-plus'
@@ -105,20 +121,44 @@ const router = useRouter()
// 侧边栏菜单数据
const menus = ref(menuConfig)
// 侧边栏收缩状态
const isCollapsed = ref(false)
// 移动端菜单状态
const isMobileMenuOpen = ref(false)
// 检测是否是移动端
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768
// 移动端默认收起侧边栏
if (isMobile.value) {
isCollapsed.value = false
isMobileMenuOpen.value = false
}
}
// 获取当前激活的菜单项
const activeMenu = computed(() => {
return route.path
})
// 切换全屏
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
// 切换侧边栏收缩
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// 保存状态到localStorage
localStorage.setItem('sidebarCollapsed', isCollapsed.value)
}
// 切换移动端菜单
const toggleMobileMenu = () => {
isMobileMenuOpen.value = !isMobileMenuOpen.value
}
// 关闭移动端菜单
const closeMobileMenu = () => {
isMobileMenuOpen.value = false
}
// 退出登录
@@ -129,9 +169,35 @@ const handleLogout = () => {
type: 'warning'
}).then(() => {
localStorage.removeItem('token')
localStorage.removeItem('tokenExpire')
localStorage.removeItem('userInfo')
userStore.clearUserInfo()
router.push('/login')
}).catch(() => {})
}
// 监听路由变化,移动端自动关闭菜单
router.afterEach(() => {
if (isMobile.value) {
closeMobileMenu()
}
})
onMounted(() => {
// 恢复侧边栏状态
const savedState = localStorage.getItem('sidebarCollapsed')
if (savedState !== null) {
isCollapsed.value = savedState === 'true'
}
// 检测设备类型
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
@@ -141,6 +207,18 @@ const handleLogout = () => {
width: 100%;
}
/* 移动端遮罩层 */
.mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 998;
}
/* 侧边栏样式 */
.sidebar {
width: 260px;
@@ -148,7 +226,15 @@ const handleLogout = () => {
background-color: #ffffff;
border-right: 1px solid #e1e8ed;
overflow: hidden;
z-index: 20;
z-index: 999;
transition: width 0.3s ease;
display: flex;
flex-direction: column;
position: relative;
}
.sidebar.collapsed {
width: 64px;
}
.logo-container {
@@ -159,6 +245,7 @@ const handleLogout = () => {
padding: 0 20px;
background-color: #ffffff;
border-bottom: 1px solid #e1e8ed;
flex-shrink: 0;
}
.logo-img {
@@ -167,8 +254,15 @@ const handleLogout = () => {
object-fit: contain;
}
.logo-img-mini {
height: 32px;
width: 32px;
object-fit: contain;
}
.sidebar-scrollbar {
height: calc(100vh - 70px);
flex: 1;
overflow: hidden;
}
.sidebar-menu {
@@ -178,6 +272,32 @@ const handleLogout = () => {
padding: 0;
}
/* 收缩按钮 */
.collapse-btn {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e1e8ed;
cursor: pointer;
color: #7f8c8d;
transition: all 0.2s ease;
flex-shrink: 0;
}
.collapse-btn:hover {
color: #2c3e50;
background-color: #f8f9fa;
}
/* 移动端菜单按钮 */
.mobile-menu-btn {
display: none;
margin-right: 12px;
padding: 8px;
color: #34495e;
}
/* 主容器样式 */
.main-container {
flex: 1;
@@ -185,6 +305,7 @@ const handleLogout = () => {
flex-direction: column;
background-color: #f0f2f5;
overflow: hidden;
min-width: 0;
}
/* 顶部导航栏样式 */
@@ -197,18 +318,21 @@ const handleLogout = () => {
align-items: center;
justify-content: space-between;
z-index: 10;
flex-shrink: 0;
}
.navbar-left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.navbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.navbar-item {
@@ -286,6 +410,63 @@ const handleLogout = () => {
opacity: 0;
}
/* 移动端隐藏元素 */
.hidden-mobile {
display: flex;
}
/* 移动端响应式 */
@media (max-width: 768px) {
.mobile-overlay {
display: block;
}
.sidebar {
position: fixed;
left: -260px;
top: 0;
bottom: 0;
transition: left 0.3s ease;
}
.sidebar.collapsed {
width: 260px;
left: -260px;
}
.admin-layout.mobile-open .sidebar {
left: 0;
}
.admin-layout.mobile-open .sidebar.collapsed {
left: 0;
}
.collapse-btn {
display: none;
}
.mobile-menu-btn {
display: flex;
}
.hidden-mobile {
display: none !important;
}
.navbar {
padding: 0 12px;
}
.content-container {
padding: 12px;
}
.main-container {
width: 100%;
}
}
:deep(.el-dropdown-menu) {
border-radius: 0;
border: 1px solid #e1e8ed;
@@ -346,77 +527,232 @@ const handleLogout = () => {
/* Element Plus 菜单项样式优化 */
:deep(.el-menu) {
border-right: none;
padding: 8px 0;
}
/* 一级菜单标题(有子菜单) */
:deep(.el-sub-menu__title) {
height: 50px;
line-height: 50px;
margin: 0;
padding: 0 20px;
transition: background-color 0.2s ease;
height: 48px;
line-height: 48px;
margin: 2px 8px;
padding: 0 16px !important;
border-radius: 6px;
transition: all 0.2s ease;
color: #34495e !important;
font-weight: 500;
font-size: 14px;
}
:deep(.el-sub-menu__title:hover) {
background-color: #f8f9fa !important;
background-color: #f5f7fa !important;
color: #2c3e50 !important;
}
:deep(.el-menu-item) {
height: 50px;
line-height: 50px;
margin: 0;
padding: 0 20px;
transition: background-color 0.2s ease;
/* 一级菜单项(无子菜单) */
:deep(.sidebar-menu > .el-menu-item) {
height: 48px;
line-height: 48px;
margin: 2px 8px;
padding: 0 16px !important;
border-radius: 6px;
transition: all 0.2s ease;
color: #34495e !important;
font-weight: 500;
font-size: 14px;
}
:deep(.el-menu-item:hover) {
background-color: #f8f9fa !important;
:deep(.sidebar-menu > .el-menu-item:hover) {
background-color: #f5f7fa !important;
color: #2c3e50 !important;
}
:deep(.el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.1) !important;
:deep(.sidebar-menu > .el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.08) !important;
color: #2c3e50 !important;
font-weight: 600;
position: relative;
}
:deep(.el-menu-item.is-active::before) {
:deep(.sidebar-menu > .el-menu-item.is-active::before) {
content: '';
position: absolute;
left: 0;
top: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 100%;
height: 24px;
background-color: #2c3e50;
border-radius: 0 2px 2px 0;
}
/* 展开的一级菜单标题 */
:deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
color: #2c3e50 !important;
font-weight: 600;
background-color: #f5f7fa !important;
}
/* 二级菜单容器 */
:deep(.sidebar-menu > .el-sub-menu > .el-menu) {
background-color: transparent !important;
padding: 4px 0 8px 0;
}
/* 二级菜单项 */
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item) {
height: 40px;
line-height: 40px;
margin: 2px 8px 2px 16px;
padding: 0 16px 0 28px !important;
border-radius: 6px;
font-size: 13px;
color: #606266 !important;
background-color: transparent !important;
position: relative;
font-weight: 400;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item::before) {
content: '';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 4px;
background-color: #c0c4cc;
border-radius: 50%;
transition: all 0.2s ease;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item:hover) {
background-color: #f5f7fa !important;
color: #2c3e50 !important;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item:hover::before) {
background-color: #7f8c8d;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.08) !important;
color: #2c3e50 !important;
font-weight: 500;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active::before) {
width: 6px;
height: 6px;
background-color: #2c3e50;
}
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
/* 二级菜单中的子菜单标题(三级菜单父级) */
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title) {
height: 40px;
line-height: 40px;
margin: 2px 8px 2px 16px;
padding: 0 16px 0 28px !important;
border-radius: 6px;
font-size: 13px;
color: #606266 !important;
font-weight: 400;
position: relative;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title::before) {
content: '';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 4px;
background-color: #c0c4cc;
border-radius: 50%;
transition: all 0.2s ease;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title:hover) {
background-color: #f5f7fa !important;
color: #2c3e50 !important;
background-color: #f8f9fa !important;
}
:deep(.el-sub-menu .el-menu) {
background-color: #fafbfc !important;
margin: 0;
padding: 0;
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-sub-menu__title:hover::before) {
background-color: #7f8c8d;
}
:deep(.el-sub-menu .el-menu-item) {
margin: 0;
padding-left: 48px !important;
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu.is-opened > .el-sub-menu__title) {
color: #2c3e50 !important;
font-weight: 500;
background-color: #f5f7fa !important;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu.is-opened > .el-sub-menu__title::before) {
width: 6px;
height: 6px;
background-color: #2c3e50;
}
/* 三级菜单容器 */
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu) {
background-color: transparent !important;
padding: 4px 0;
}
:deep(.el-sub-menu .el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.12) !important;
/* 三级菜单项 */
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item) {
height: 36px;
line-height: 36px;
margin: 2px 8px 2px 28px;
padding: 0 16px 0 24px !important;
border-radius: 6px;
font-size: 13px;
color: #909399 !important;
background-color: transparent !important;
position: relative;
font-weight: 400;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item::before) {
content: '-';
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #c0c4cc;
font-size: 12px;
transition: all 0.2s ease;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item:hover) {
background-color: #f5f7fa !important;
color: #606266 !important;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item:hover::before) {
color: #7f8c8d;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active) {
background-color: rgba(44, 62, 80, 0.08) !important;
color: #2c3e50 !important;
font-weight: 500;
}
:deep(.sidebar-menu > .el-sub-menu > .el-menu > .el-sub-menu > .el-menu > .el-menu-item.is-active::before) {
content: '•';
color: #2c3e50;
font-size: 14px;
}
/* 子菜单箭头图标 */
:deep(.el-sub-menu__icon-arrow) {
color: #909399 !important;
transition: all 0.2s ease;
font-size: 12px;
}
:deep(.el-sub-menu:hover > .el-sub-menu__title .el-sub-menu__icon-arrow) {
color: #7f8c8d !important;
transition: transform 0.2s ease;
}
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
+581
View File
@@ -0,0 +1,581 @@
<template>
<div class="global-search">
<el-tooltip content="全局搜索" placement="bottom">
<el-button type="text" class="header-btn" @click="openSearch">
<el-icon :size="18"><Search /></el-icon>
</el-button>
</el-tooltip>
<el-dialog
v-model="visible"
:show-close="false"
:append-to-body="true"
class="search-dialog"
width="680px"
top="12vh"
@opened="focusInput"
>
<div class="search-header">
<el-icon :size="20" class="search-prefix"><Search /></el-icon>
<input
ref="searchInput"
v-model="keyword"
class="search-input"
placeholder="搜索用户、订单、工单、用户商品..."
@keydown.enter="handleSearch"
@keydown.escape="visible = false"
/>
<span v-if="keyword" class="search-clear" @click="clearSearch">
<el-icon :size="16"><CircleClose /></el-icon>
</span>
<span class="search-shortcut">ESC</span>
</div>
<div v-if="hasSearched" class="search-body">
<el-tabs v-model="activeTab" class="search-tabs">
<el-tab-pane name="user">
<template #label>
<span class="tab-label">用户 <em v-if="results.user.total > 0">{{ results.user.total }}</em></span>
</template>
<div class="result-list" v-loading="results.user.loading">
<div v-if="results.user.list.length === 0 && !results.user.loading" class="empty-tip">未找到相关用户</div>
<div
v-for="item in results.user.list"
:key="item.user_id"
class="result-item"
@click="goToUser(item)"
>
<el-avatar :size="32" :src="item.cover || ''" class="result-avatar">
{{ (item.user_name || '')[0] }}
</el-avatar>
<div class="result-info">
<span class="result-title" v-html="highlight(item.user_name)"></span>
<span class="result-desc">ID: {{ item.user_id }} · {{ item.phone || item.email || '—' }}</span>
</div>
<el-icon class="result-arrow"><ArrowRight /></el-icon>
</div>
</div>
<div class="result-pagination" v-if="results.user.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.user.total" :page-size="pageSize" v-model:current-page="results.user.page" @current-change="(p) => { results.user.page = p; searchUsers(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="order">
<template #label>
<span class="tab-label">订单 <em v-if="results.order.total > 0">{{ results.order.total }}</em></span>
</template>
<div class="result-list" v-loading="results.order.loading">
<div v-if="results.order.list.length === 0 && !results.order.loading" class="empty-tip">未找到相关订单</div>
<div
v-for="item in results.order.list"
:key="item.id"
class="result-item"
@click="goToOrder(item)"
>
<div class="result-icon order-icon">
<el-icon :size="18"><Document /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.name || ('#' + item.id))"></span>
<span class="result-desc">用户ID: {{ item.userId }} · ¥{{ (item.price / 100).toFixed(2) }} · {{ item.type }}</span>
</div>
<el-tag size="small" :type="orderStatusType(item.state)">{{ orderStatusText(item.state) }}</el-tag>
</div>
</div>
<div class="result-pagination" v-if="results.order.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.order.total" :page-size="pageSize" v-model:current-page="results.order.page" @current-change="(p) => { results.order.page = p; searchOrders(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="ticket">
<template #label>
<span class="tab-label">工单 <em v-if="results.ticket.total > 0">{{ results.ticket.total }}</em></span>
</template>
<div class="result-list" v-loading="results.ticket.loading">
<div v-if="results.ticket.list.length === 0 && !results.ticket.loading" class="empty-tip">未找到相关工单</div>
<div
v-for="item in results.ticket.list"
:key="item.work_id"
class="result-item"
@click="goToTicket(item)"
>
<div class="result-icon ticket-icon">
<el-icon :size="18"><ChatDotSquare /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.name)"></span>
<span class="result-desc">{{ item.user?.userName || ('用户' + item.user?.userId) }} · {{ formatTime(item.created_at) }}</span>
</div>
<el-tag size="small" :type="ticketStatusType(item.status)">{{ ticketStatusText(item.status) }}</el-tag>
</div>
</div>
<div class="result-pagination" v-if="results.ticket.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.ticket.total" :page-size="pageSize" v-model:current-page="results.ticket.page" @current-change="(p) => { results.ticket.page = p; searchTickets(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="goods">
<template #label>
<span class="tab-label">用户商品 <em v-if="results.goods.total > 0">{{ results.goods.total }}</em></span>
</template>
<div class="result-list" v-loading="results.goods.loading">
<div v-if="results.goods.list.length === 0 && !results.goods.loading" class="empty-tip">未找到相关用户商品</div>
<div
v-for="item in results.goods.list"
:key="item.id"
class="result-item"
@click="goToGoods(item)"
>
<div class="result-icon goods-icon">
<el-icon :size="18"><Box /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
<span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span>
</div>
<el-icon class="result-arrow"><ArrowRight /></el-icon>
</div>
</div>
<div class="result-pagination" v-if="results.goods.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.goods.total" :page-size="pageSize" v-model:current-page="results.goods.page" @current-change="(p) => { results.goods.page = p; searchGoods(keyword.trim()) }" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<div v-else class="search-placeholder">
<el-icon :size="48" class="placeholder-icon"><Search /></el-icon>
<p>输入关键词后按回车搜索</p>
<div class="search-tips">
<span>支持搜索用户名/手机号订单号工单标题商品名称</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search, CircleClose, ArrowRight, Document, ChatDotSquare, Box } from '@element-plus/icons-vue'
import { getUserList } from '@/api/admin/user.js'
import { getOrderList } from '@/api/admin/order.js'
import { getTickerList } from '@/api/ticket.js'
import { getUserGoodsList } from '@/api/admin/userVm.js'
const router = useRouter()
const visible = ref(false)
const keyword = ref('')
const activeTab = ref('user')
const hasSearched = ref(false)
const searchInput = ref(null)
const pageSize = 10
const results = reactive({
user: { list: [], total: 0, loading: false, page: 1 },
order: { list: [], total: 0, loading: false, page: 1 },
ticket: { list: [], total: 0, loading: false, page: 1 },
goods: { list: [], total: 0, loading: false, page: 1 }
})
const openSearch = () => {
visible.value = true
}
const focusInput = () => {
searchInput.value?.focus()
}
const clearSearch = () => {
keyword.value = ''
hasSearched.value = false
searchInput.value?.focus()
}
const handleSearch = () => {
const key = keyword.value.trim()
if (!key) return
hasSearched.value = true
results.user.page = 1
results.order.page = 1
results.ticket.page = 1
results.goods.page = 1
searchUsers(key)
searchOrders(key)
searchTickets(key)
searchGoods(key)
}
const searchUsers = async (key) => {
results.user.loading = true
results.user.list = []
try {
const res = await getUserList({ page: results.user.page, count: pageSize, key })
if (res.data?.code === 200) {
results.user.list = res.data.data?.data || []
results.user.total = res.data.data?.all_count || results.user.list.length
}
} catch (e) { /* ignore */ }
results.user.loading = false
}
const searchOrders = async (key) => {
results.order.loading = true
results.order.list = []
try {
const res = await getOrderList({ page: results.order.page, count: pageSize, keyword: key })
if (res.data?.code === 200) {
results.order.list = res.data.data?.list || []
results.order.total = res.data.data?.all_count || results.order.list.length
}
} catch (e) { /* ignore */ }
results.order.loading = false
}
const searchTickets = async (key) => {
results.ticket.loading = true
results.ticket.list = []
try {
const res = await getTickerList(pageSize, results.ticket.page, '', '', '', '', key)
if (res?.code === 200) {
results.ticket.list = res.data?.data || []
results.ticket.total = res.data?.all_count || results.ticket.list.length
}
} catch (e) { /* ignore */ }
results.ticket.loading = false
}
const searchGoods = async (key) => {
results.goods.loading = true
results.goods.list = []
try {
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, keyword: key })
if (res.data?.code === 200) {
results.goods.list = res.data.data?.data || []
results.goods.total = res.data.data?.all_count || results.goods.list.length
}
} catch (e) { /* ignore */ }
results.goods.loading = false
}
const highlight = (text) => {
if (!text || !keyword.value) return text
const key = keyword.value.trim()
if (!key) return text
const regex = new RegExp(`(${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return String(text).replace(regex, '<mark>$1</mark>')
}
const formatTime = (time) => {
if (!time) return '—'
const d = new Date(time)
if (isNaN(d.getTime())) return '—'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const goToUser = (item) => {
visible.value = false
router.push({ path: '/user/detail', query: { user_id: item.user_id } })
}
const goToOrder = (item) => {
visible.value = false
router.push({ path: '/order/list', query: { keyword: keyword.value } })
}
const goToTicket = (item) => {
visible.value = false
router.push({ path: '/ticket/detail', query: { id: item.work_id } })
}
const goToGoods = (item) => {
visible.value = false
const tag = (item.tag || item.good?.tag || '').toLowerCase()
if (tag === '云服务器') {
router.push({ path: '/user-goods/vm-detail', query: { id: item.id } })
} else {
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
}
}
const orderStatusText = (status) => {
const map = { 0: '待支付', 1: '已完成', 2: '已取消', 3: '已退款' }
return map[status] || '未知'
}
const orderStatusType = (status) => {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger' }
return map[status] || 'info'
}
const ticketStatusText = (status) => {
const map = { 0: '待处理', 1: '处理中', 2: '已回复', 3: '已关闭' }
return map[status] || '未知'
}
const ticketStatusType = (status) => {
const map = { 0: 'danger', 1: 'warning', 2: 'success', 3: 'info' }
return map[status] || 'info'
}
const handleKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
openSearch()
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<style scoped>
.global-search {
display: flex;
align-items: center;
}
.header-btn {
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
color: #34495e;
transition: all 0.2s ease;
border-radius: 0;
}
.header-btn:hover {
background-color: #f8f9fa;
color: #2c3e50;
}
</style>
<style>
.search-dialog .el-dialog__header {
display: none;
}
.search-dialog .el-dialog__body {
padding: 0;
}
.search-dialog .el-dialog {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.search-header {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e8ecf0;
gap: 12px;
}
.search-prefix {
color: #909399;
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: 16px;
color: #2c3e50;
background: transparent;
line-height: 1.5;
}
.search-input::placeholder {
color: #a8abb2;
}
.search-clear {
cursor: pointer;
color: #909399;
display: flex;
align-items: center;
transition: color 0.2s;
}
.search-clear:hover {
color: #606266;
}
.search-shortcut {
font-size: 12px;
color: #a8abb2;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 2px 6px;
flex-shrink: 0;
}
.search-body {
max-height: 460px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.search-tabs {
height: 100%;
}
.search-tabs .el-tabs__header {
padding: 0 20px;
margin-bottom: 0;
}
.search-tabs .el-tabs__content {
max-height: 400px;
overflow-y: auto;
}
.tab-label em {
font-style: normal;
font-size: 11px;
background: #409eff;
color: #fff;
border-radius: 8px;
padding: 1px 6px;
margin-left: 4px;
vertical-align: middle;
}
.result-list {
padding: 8px 12px;
min-height: 80px;
}
.result-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
gap: 12px;
}
.result-item:hover {
background: #f5f7fa;
}
.result-avatar {
flex-shrink: 0;
background: #ecf5ff;
color: #409eff;
font-size: 14px;
}
.result-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.order-icon {
background: #fdf6ec;
color: #e6a23c;
}
.ticket-icon {
background: #f0f9eb;
color: #67c23a;
}
.goods-icon {
background: #ecf5ff;
color: #409eff;
}
.result-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.result-title {
font-size: 14px;
color: #2c3e50;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-title :deep(mark) {
background: #fff3cd;
color: #e6a23c;
padding: 0 2px;
border-radius: 2px;
}
.result-desc {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-arrow {
color: #c0c4cc;
flex-shrink: 0;
}
.result-pagination {
display: flex;
justify-content: center;
padding: 8px 12px 12px;
border-top: 1px solid #f0f2f5;
}
.empty-tip {
text-align: center;
color: #909399;
font-size: 14px;
padding: 32px 0;
}
.search-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: #909399;
}
.placeholder-icon {
color: #dcdfe6;
margin-bottom: 12px;
}
.search-placeholder p {
margin: 0 0 8px;
font-size: 14px;
}
.search-tips {
font-size: 12px;
color: #a8abb2;
}
</style>
+32 -29
View File
@@ -1,22 +1,25 @@
<template>
<el-sub-menu v-if="hasChildren" :index="menu.path">
<template #title>
<el-icon v-if="menu.icon || menu.meta?.icon">
<el-icon v-if="menu.icon || menu.meta?.icon" class="menu-icon">
<component :is="menu.icon || menu.meta?.icon" />
</el-icon>
<span>{{ menu.title || menu.meta?.title }}</span>
<span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
</template>
<sidebar-menu-item
v-for="child in menu.children"
:key="child.path"
:menu="child"
:menu="child"
:level="level + 1"
/>
</el-sub-menu>
<el-menu-item v-else :index="menu.path">
<el-icon v-if="menu.icon || menu.meta?.icon">
<el-icon v-if="menu.icon || menu.meta?.icon" class="menu-icon">
<component :is="menu.icon || menu.meta?.icon" />
</el-icon>
<template #title>{{ menu.title || menu.meta?.title }}</template>
<template #title>
<span class="menu-title">{{ menu.title || menu.meta?.title }}</span>
</template>
</el-menu-item>
</template>
@@ -29,6 +32,10 @@ const props = defineProps({
menu: {
type: Object,
required: true
},
level: {
type: Number,
default: 1
}
})
@@ -39,49 +46,45 @@ const hasChildren = computed(() => {
</script>
<style scoped>
.el-icon {
margin-right: 12px;
/* 菜单图标样式 */
.menu-icon {
margin-right: 10px;
width: 20px;
height: 20px;
text-align: center;
color: #7f8c8d;
transition: color 0.2s ease;
font-size: 18px;
flex-shrink: 0;
}
.el-menu-item .el-icon, :deep(.el-sub-menu__title .el-icon) {
/* 菜单标题 */
.menu-title {
font-size: inherit;
letter-spacing: 0.2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 图标交互状态 */
.el-menu-item .menu-icon,
:deep(.el-sub-menu__title .menu-icon) {
color: #7f8c8d !important;
transition: color 0.2s ease;
}
.el-menu-item:hover .el-icon,
:deep(.el-sub-menu__title:hover .el-icon) {
.el-menu-item:hover .menu-icon,
:deep(.el-sub-menu__title:hover .menu-icon) {
color: #34495e !important;
}
/* 激活菜单项图标 */
.el-menu-item.is-active .el-icon {
.el-menu-item.is-active .menu-icon {
color: #2c3e50 !important;
}
:deep(.el-sub-menu.is-active > .el-sub-menu__title .el-icon) {
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .menu-icon) {
color: #2c3e50 !important;
}
/* 菜单文字样式 */
.el-menu-item span, :deep(.el-sub-menu__title span) {
font-size: 14px;
letter-spacing: 0.2px;
}
/* 子菜单项样式优化 */
:deep(.el-sub-menu .el-menu-item) {
font-size: 13px;
padding-left: 48px !important;
}
:deep(.el-sub-menu .el-menu-item .el-icon) {
font-size: 16px;
margin-right: 10px;
}
</style>
+145 -37
View File
@@ -1,6 +1,9 @@
<template>
<div class="tags-view-container">
<div class="tags-view-wrapper">
<div class="tags-view-container"
@mouseenter="hovered = true" @mouseleave="hovered = false">
<div class="tags-view-wrapper" ref="scrollWrapperRef"
@wheel.prevent="handleWheel"
@scroll="onScroll">
<div class="tags-view-scroll">
<router-link
v-for="tag in visitedViews"
@@ -23,6 +26,10 @@
</router-link>
</div>
</div>
<div class="scroll-track" :class="{ visible: hovered && hasOverflow }">
<div class="scroll-thumb" :style="thumbStyle" @mousedown="onThumbDown"></div>
</div>
<!-- 右键菜单 -->
<ul v-show="visible" :style="{left: left+'px', top: top+'px'}" class="contextmenu">
@@ -65,28 +72,20 @@ const router = useRouter()
const route = useRoute()
const tagsViewStore = useTagsViewStore()
// 访问过的标签 (从 store 获取)
const visitedViews = computed(() => tagsViewStore.visitedViews)
const affixTags = computed(() => tagsViewStore.affixTags)
// 右键菜单
const visible = ref(false)
const top = ref(0)
const left = ref(0)
const selectedTag = ref({})
// 初始化标签
const initTags = () => {
// 如果当前路由不在访问过的标签中,添加它
if (route.name) {
tagsViewStore.addVisitedView(route)
}
// 添加固定标签(仪表盘)
const dashboardRoute = router.getRoutes().find(r => r.name === 'Dashboard')
if (dashboardRoute) {
// 注意:这里我们直接修改 store 的 affixTags,或者 store 应该提供一个 action
// 简单起见,我们假设 store 的 affixTags 是可以直接修改的 ref,或者我们在 store 中添加初始化逻辑
// 但为了保持一致性,我们这里只处理 visitedViews 的添加
if (!tagsViewStore.affixTags.some(tag => tag.path === dashboardRoute.path)) {
tagsViewStore.affixTags.push(dashboardRoute)
}
@@ -94,13 +93,11 @@ const initTags = () => {
}
}
// 刷新选中的标签
const refreshSelectedTag = (view) => {
const { fullPath } = view
router.replace('/redirect' + fullPath)
}
// 关闭选中的标签
const closeSelectedTag = (view) => {
tagsViewStore.delVisitedView(view).then((visitedViews) => {
if (isActive(view)) {
@@ -109,15 +106,11 @@ const closeSelectedTag = (view) => {
})
}
// 关闭其他标签
const closeOthersTags = () => {
router.push(selectedTag.value)
tagsViewStore.delOthersViews(selectedTag.value).then(() => {
// moveToCurrentTag() // 如果有滚动逻辑
})
tagsViewStore.delOthersViews(selectedTag.value)
}
// 关闭左侧标签
const closeLeftTags = () => {
tagsViewStore.delLeftViews(selectedTag.value).then((visitedViews) => {
if (!visitedViews.find(i => i.path === route.path)) {
@@ -126,7 +119,6 @@ const closeLeftTags = () => {
})
}
// 关闭右侧标签
const closeRightTags = () => {
tagsViewStore.delRightViews(selectedTag.value).then((visitedViews) => {
if (!visitedViews.find(i => i.path === route.path)) {
@@ -135,20 +127,17 @@ const closeRightTags = () => {
})
}
// 关闭所有标签
const closeAllTags = () => {
tagsViewStore.delAllViews().then((visitedViews) => {
toLastView(visitedViews)
})
}
// 跳转到最后一个标签或首页
const toLastView = (visitedViews, view) => {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
router.push(latestView)
} else {
// 如果没有标签,则跳转到首页
if (view && view.name === 'Dashboard') {
router.push('/redirect' + '/dashboard')
} else {
@@ -157,17 +146,14 @@ const toLastView = (visitedViews, view) => {
}
}
// 判断是否是当前激活的标签
const isActive = (tag) => {
return tag.path === route.path
}
// 判断是否是固定标签
const isAffix = (tag) => {
return tag.meta && tag.meta.affix
}
// 打开右键菜单
const openMenu = (e, tag) => {
const menuMinWidth = 125
const offsetLeft = e.clientX
@@ -181,30 +167,112 @@ const openMenu = (e, tag) => {
selectedTag.value = tag
}
// 关闭右键菜单
// ---- 滚动 & 滚动条 ----
const scrollWrapperRef = ref(null)
const hovered = ref(false)
const hasOverflow = ref(false)
const thumbStyle = ref({ width: '0px', left: '0px' })
const handleWheel = (e) => {
if (scrollWrapperRef.value) {
scrollWrapperRef.value.scrollLeft += e.deltaY || e.deltaX
}
}
const refreshState = () => {
const el = scrollWrapperRef.value
if (!el) return
const { scrollLeft, scrollWidth, clientWidth } = el
const maxScroll = scrollWidth - clientWidth
hasOverflow.value = maxScroll > 1
if (!hasOverflow.value) {
thumbStyle.value = { width: '0px', left: '0px' }
return
}
const trackWidth = clientWidth
const thumbW = Math.max((clientWidth / scrollWidth) * trackWidth, 30)
const scrollRatio = maxScroll > 0 ? scrollLeft / maxScroll : 0
const thumbLeft = scrollRatio * (trackWidth - thumbW)
thumbStyle.value = { width: thumbW + 'px', left: thumbLeft + 'px' }
}
const onScroll = () => {
refreshState()
}
const scrollToActiveTag = () => {
const el = scrollWrapperRef.value
if (!el) return
const activeEl = el.querySelector('.active-tag')
if (!activeEl) return
const wrapperRect = el.getBoundingClientRect()
const tagRect = activeEl.getBoundingClientRect()
if (tagRect.left < wrapperRect.left + 28) {
el.scrollLeft -= (wrapperRect.left + 28 - tagRect.left + 12)
} else if (tagRect.right > wrapperRect.right - 28) {
el.scrollLeft += (tagRect.right - wrapperRect.right + 28 + 12)
}
}
const onThumbDown = (e) => {
e.preventDefault()
const el = scrollWrapperRef.value
if (!el) return
const startX = e.clientX
const startScroll = el.scrollLeft
const maxScroll = el.scrollWidth - el.clientWidth
const trackWidth = el.clientWidth
const thumbW = Math.max((el.clientWidth / el.scrollWidth) * trackWidth, 30)
const movable = trackWidth - thumbW
const onMove = (ev) => {
const dx = ev.clientX - startX
const scrollDelta = movable > 0 ? (dx / movable) * maxScroll : 0
el.scrollLeft = Math.min(Math.max(startScroll + scrollDelta, 0), maxScroll)
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
watch(visitedViews, () => nextTick(() => { refreshState(); scrollToActiveTag() }), { deep: true })
const closeMenu = () => {
visible.value = false
}
// 监听路由变化,添加标签
watch(route, (newRoute) => {
if (newRoute.name) {
tagsViewStore.addVisitedView(newRoute)
}
nextTick(scrollToActiveTag)
})
// 点击其他区域关闭右键菜单
const handleClickOutside = () => {
closeMenu()
}
const onResize = () => {
refreshState()
scrollToActiveTag()
}
onMounted(() => {
initTags()
document.addEventListener('click', handleClickOutside)
nextTick(() => { refreshState(); scrollToActiveTag() })
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', onResize)
})
</script>
@@ -215,17 +283,21 @@ onBeforeUnmount(() => {
background-color: #ffffff;
border-bottom: 1px solid #e1e8ed;
z-index: 10;
display: flex;
align-items: stretch;
position: relative;
overflow: hidden;
}
/* 标签滚动区域 */
.tags-view-wrapper {
flex: 1;
min-width: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
padding: 0 12px;
overflow-x: auto;
white-space: nowrap;
position: relative;
overflow-x: scroll;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tags-view-wrapper::-webkit-scrollbar {
@@ -233,18 +305,51 @@ onBeforeUnmount(() => {
}
.tags-view-scroll {
display: flex;
display: inline-flex;
align-items: center;
height: 100%;
padding: 0 8px;
gap: 4px;
}
/* 底部自定义滚动条(在容器上,不在滚动区域内) */
.scroll-track {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
opacity: 0;
transition: opacity 0.25s;
pointer-events: none;
z-index: 5;
}
.scroll-track.visible {
opacity: 1;
pointer-events: auto;
}
.scroll-thumb {
position: absolute;
top: 0;
height: 100%;
border-radius: 3px;
background: rgba(180,188,199,0.45);
transition: background 0.15s;
cursor: pointer;
}
.scroll-thumb:hover {
background: rgba(180,188,199,0.65);
}
/* 标签样式 */
.tag, .active-tag {
height: 32px;
display: inline-flex;
align-items: center;
padding: 0 12px;
margin-right: 0;
border-radius: 0;
font-size: 13px;
text-decoration: none;
@@ -252,6 +357,8 @@ onBeforeUnmount(() => {
transition: all 0.2s ease;
border: 1px solid transparent;
border-bottom: none;
flex-shrink: 0;
white-space: nowrap;
}
.tag {
@@ -326,6 +433,7 @@ onBeforeUnmount(() => {
background-color: rgba(231, 76, 60, 0.1);
}
/* 右键菜单 */
.contextmenu {
position: fixed;
z-index: 100;
@@ -361,4 +469,4 @@ onBeforeUnmount(() => {
.contextmenu li:hover .el-icon {
color: #2c3e50;
}
</style>
</style>
+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
}
+87 -29
View File
@@ -12,6 +12,14 @@ export const menus = [
{
path: '/ticket/list',
title: '工单列表'
},
{
path: '/ticket/types',
title: '工单类型'
},
{
path: '/ticket/templates',
title: '回复模板'
}
]
},
@@ -39,15 +47,17 @@ export const menus = [
title: '商品管理',
icon: 'Goods',
children: [
{
path: '/product/list',
title: '商品列表'
},
{
path: '/product/group',
title: '商品分组'
},
{ path: '/product/manage', title: '商品管理' },
{ path: '/product/discount', title: '优惠管理' }
]
},
{
path: '/user-goods',
title: '用户商品管理',
icon: 'ShoppingCart',
children: [
{ path: '/user-goods/list', title: '所有商品' },
{ path: '/user-goods/vm-list', title: '云服务器' }
]
},
{
@@ -61,22 +71,6 @@ export const menus = [
}
]
},
{
path: '/marketing',
title: '优惠营销',
icon: 'Present',
children: [
{
path: '/marketing/discount',
title: '优惠码管理'
},
{
path: '/marketing/voucher',
title: '代金券管理'
},
]
},
{
path: '/activity',
title: '活动管理',
@@ -85,6 +79,10 @@ export const menus = [
{
path: '/activity/signin',
title: '签到活动'
},
{
path: '/activity/groupbuy',
title: '拼团管理'
}
]
},
@@ -136,6 +134,58 @@ export const menus = [
}
]
},
{
path: '/sms',
title: '短信平台管理',
icon: 'ChatDotRound',
children: [
{
path: '/sms/service',
title: '主控服务管理'
},
{
path: '/sms/goods',
title: '额度商品管理'
},
{
path: '/sms/signature',
title: '签名管理'
},
{
path: '/sms/template',
title: '模板管理'
}
]
},
{
path: '/mail',
title: '邮箱平台管理',
icon: 'Message',
children: [
{
path: '/mail/service',
title: '主控服务管理'
}
]
},
{
title: '虚拟化平台管理',
icon: 'Platform',
children: [
{
path: '/virtualization/kvm-service',
title: '主控服务管理'
},
{
path: '/virtualization/host-group-mapping',
title: '宿主机组映射管理'
},
{
path: '/virtualization/vnc-command',
title: 'VNC指令管理'
}
]
},
{
path: '/system',
title: '系统管理',
@@ -160,12 +210,20 @@ export const menus = [
title: '域名白名单'
},
{
path: '/system/setting-group',
title: '配置管理'
path: '/system/setting-manage',
title: '配置管理'
},
{
path: '/system/setting-list',
title: '配置管理'
path: '/system/notice-channel',
title: '通知管理'
},
{
path: '/system/menu',
title: '菜单管理',
children: [
{ path: '/system/menu-manage', title: '菜单列表' },
{ path: '/system/menu-permission', title: '菜单权限' }
]
}
]
}
+320 -55
View File
@@ -58,6 +58,22 @@ const routes = [
hidden: true,
activeMenu: '/ticket/list'
}
},
{
path: 'types',
name: 'TicketTypes',
component: () => import('../views/ticket/TicketTypes.vue'),
meta: {
title: '工单类型管理'
}
},
{
path: 'templates',
name: 'TicketTemplates',
component: () => import('../views/ticket/TicketTemplates.vue'),
meta: {
title: '回复模板管理'
}
}
]
},
@@ -228,29 +244,62 @@ const routes = [
{
path: 'product',
name: 'Product',
meta: {
title: '商品管理',
icon: 'Goods'
},
redirect: '/product/list',
meta: { title: '商品管理', icon: 'Goods' },
redirect: '/product/manage',
children: [
{
path: 'manage',
name: 'ProductManage',
component: () => import('../views/product/ProductGroup.vue'),
meta: { title: '商品管理' }
},
{
path: 'discount',
name: 'DiscountManage',
component: () => import('../views/product/DiscountManage.vue'),
meta: { title: '优惠管理' }
},
{
path: 'discount/voucher/:id/manage',
name: 'VoucherManagement',
component: () => import('../views/marketing/VoucherManagement.vue'),
meta: { title: '代金券分发管理', hidden: true, activeMenu: '/product/discount' }
},
{ path: 'list', redirect: '/product/manage' },
{ path: 'group', redirect: '/product/manage' }
]
},
// 用户商品管理路由
{
path: 'user-goods',
name: 'UserGoods',
meta: { title: '用户商品管理', icon: 'ShoppingCart' },
redirect: '/user-goods/list',
children: [
{
path: 'list',
name: 'ProductList',
component: () => import('../views/product/ProductList.vue'),
meta: {
title: '商品列表'
}
name: 'UserGoodsList',
component: () => import('../views/product/UserGoodsList.vue'),
meta: { title: '所有商品' }
},
{
path: 'group',
name: 'ProductGroup',
component: () => import('../views/product/ProductGroup.vue'),
meta: {
title: '商品分组'
}
path: 'detail/:id',
name: 'UserGoodsDetail',
component: () => import('../views/product/UserGoodsDetail.vue'),
meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' }
},
{
path: 'vm-list',
name: 'UserVmList',
component: () => import('../views/user-vm/UserVmList.vue'),
meta: { title: '云服务器' }
},
{
path: 'vm-detail',
name: 'UserVmDetail',
component: () => import('../views/user-vm/UserVmDetail.vue'),
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
}
]
},
// 订单管理路由
@@ -273,43 +322,20 @@ const routes = [
}
]
},
// 优惠营销路由
// 优惠营销路由(已合并至 /product/discount,保留重定向兼容旧链接)
{
path: 'marketing',
name: 'Marketing',
meta: {
title: '优惠营销',
icon: 'Present'
icon: 'Present',
hidden: true
},
redirect: '/marketing/discount',
redirect: '/product/discount',
children: [
{
path: 'discount',
name: 'DiscountCode',
component: () => import('../views/marketing/DiscountCode.vue'),
meta: {
title: '优惠码管理'
}
},
{
path: 'voucher',
name: 'Voucher',
component: () => import('../views/marketing/Voucher.vue'),
meta: {
title: '代金券管理'
}
},
{
path: 'voucher/:id/manage',
name: 'VoucherManagement',
component: () => import('../views/marketing/VoucherManagement.vue'),
meta: {
title: '代金券详情管理',
hidden: true,
activeMenu: '/marketing/voucher'
}
},
{ path: 'discount', redirect: '/product/discount' },
{ path: 'voucher', redirect: '/product/discount' },
{ path: 'voucher/:id/manage', redirect: to => `/product/discount/voucher/${to.params.id}/manage` }
]
},
// 活动管理路由
@@ -329,6 +355,14 @@ const routes = [
meta: {
title: '签到活动'
}
},
{
path: '/activity/groupbuy',
name: 'GroupBuyManage',
component: () => import('../views/activity/GroupBuyManage.vue'),
meta: {
title: '拼团管理'
}
}
]
},
@@ -374,16 +408,247 @@ const routes = [
meta: { title: '域名白名单' }
},
{
path: 'setting-group',
name: 'SettingGroup',
component: () => import('../views/system/SettingGroup.vue'),
meta: { title: '配置管理' }
path: 'setting-manage',
name: 'SettingManage',
component: () => import('../views/system/SettingManage.vue'),
meta: { title: '配置管理' }
},
{
path: 'setting-list',
name: 'SettingList',
component: () => import('../views/system/Setting.vue'),
meta: { title: '配置管理' }
path: 'notice-channel',
name: 'NoticeChannel',
component: () => import('../views/system/NoticeChannel.vue'),
meta: { title: '通知管理' }
},
{
path: 'menu-manage',
name: 'MenuManage',
component: () => import('../views/system/MenuManage.vue'),
meta: { title: '菜单管理' }
},
{
path: 'menu-permission',
name: 'MenuPermission',
component: () => import('../views/system/MenuPermission.vue'),
meta: { title: '菜单权限' }
}
]
},
{
path: 'virtualization',
name: 'Virtualization',
meta: {
title: '虚拟化平台管理',
icon: 'Platform'
},
redirect: '/virtualization/kvm-service',
children: [
{
path: 'kvm-service',
name: 'KvmService',
component: () => import('../views/virtualization/KvmService.vue'),
meta: {
title: '主控服务管理'
}
},
{
path: 'kvm-service-detail',
name: 'KvmServiceDetail',
component: () => import('../views/virtualization/KvmServiceDetail.vue'),
meta: {
title: '主控服务详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'host-group-mapping',
name: 'HostGroupMapping',
component: () => import('../views/virtualization/HostGroupMapping.vue'),
meta: {
title: '宿主机组映射管理'
}
},
{
path: 'host-manage',
name: 'HostManage',
component: () => import('../views/virtualization/HostManage.vue'),
meta: {
title: '宿主机管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'image-manage',
name: 'ImageManage',
component: () => import('../views/virtualization/ImageManage.vue'),
meta: {
title: '镜像管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'network-manage',
name: 'NetworkManage',
component: () => import('../views/virtualization/NetworkManage.vue'),
meta: {
title: '网络管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'volume-manage',
name: 'VolumeManage',
component: () => import('../views/virtualization/VolumeManage.vue'),
meta: {
title: '数据卷管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'vm-manage',
name: 'VmManage',
component: () => import('../views/virtualization/VmManage.vue'),
meta: {
title: '虚拟机管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'security-group',
name: 'SecurityGroupManage',
component: () => import('../views/virtualization/SecurityGroupManage.vue'),
meta: {
title: '安全组管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'vnc-node',
name: 'VncNodeManage',
component: () => import('../views/virtualization/VncNodeManage.vue'),
meta: {
title: 'VNC节点管理',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'vnc-command',
name: 'VncCommandManage',
component: () => import('../views/virtualization/VncCommandManage.vue'),
meta: {
title: 'VNC指令管理'
}
},
{
path: 'host-detail',
name: 'VirtHostDetail',
component: () => import('../views/virtualization/HostDetail.vue'),
meta: {
title: '宿主机详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'image-detail',
name: 'VirtImageDetail',
component: () => import('../views/virtualization/ImageDetail.vue'),
meta: {
title: '镜像详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'vm-detail',
name: 'VirtVmDetail',
component: () => import('../views/virtualization/VmDetail.vue'),
meta: {
title: '虚拟机详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'security-group-detail',
name: 'VirtSecurityGroupDetail',
component: () => import('../views/virtualization/SecurityGroupDetail.vue'),
meta: {
title: '安全组详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'volume-detail',
name: 'VirtVolumeDetail',
component: () => import('../views/virtualization/VolumeDetail.vue'),
meta: {
title: '数据卷详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
}
]
},
// 短信平台管理路由
{
path: 'sms',
name: 'Sms',
meta: {
title: '短信平台管理',
icon: 'ChatDotRound'
},
redirect: '/sms/service',
children: [
{
path: 'service',
name: 'SmsService',
component: () => import('../views/sms/SmsService.vue'),
meta: { title: '主控服务管理' }
},
{
path: 'goods',
name: 'SmsGoods',
component: () => import('../views/sms/SmsGoods.vue'),
meta: { title: '额度商品管理' }
},
{
path: 'signature',
name: 'SmsSignature',
component: () => import('../views/sms/SmsSignature.vue'),
meta: { title: '签名管理' }
},
{
path: 'template',
name: 'SmsTemplateMgr',
component: () => import('../views/sms/SmsTemplate.vue'),
meta: { title: '模板管理' }
}
]
},
// 邮箱平台管理路由
{
path: 'mail',
name: 'Mail',
meta: {
title: '邮箱平台管理',
icon: 'Message'
},
redirect: '/mail/service',
children: [
{
path: 'service',
name: 'MailService',
component: () => import('../views/mail/MailService.vue'),
meta: { title: '主控服务管理' }
}
]
},
+19 -2
View File
@@ -4,11 +4,28 @@ import {ref} from "vue";
export const useUserStore = defineStore('userStore',() => {
let userInfo = ref({})
// 初始化时从localStorage读取用户信息
const savedUserInfo = localStorage.getItem('userInfo')
let userInfo = ref(savedUserInfo ? JSON.parse(savedUserInfo) : {})
function setUserInfo(u){
userInfo.value = u
// 同步保存到localStorage
if (u && Object.keys(u).length > 0) {
localStorage.setItem('userInfo', JSON.stringify(u))
}
}
return {userInfo,setUserInfo}
// 清除用户信息
function clearUserInfo() {
userInfo.value = {}
localStorage.removeItem('userInfo')
}
// 获取用户头像
function getUserAvatar() {
return userInfo.value?.cover || ''
}
return {userInfo, setUserInfo, clearUserInfo, getUserAvatar}
})
+374 -1
View File
@@ -114,11 +114,384 @@ body {
padding-right: 10px;
}
/* 响应式工具类 */
/* 可点击元素统一手型光标 */
.el-button,
.el-button--link,
.el-tag.is-closable .el-tag__close,
.el-dropdown,
.el-dropdown-menu__item,
.el-switch,
.el-checkbox,
.el-radio,
.el-select .el-input__wrapper,
.el-table__body tr.el-table__row {
cursor: pointer;
}
.back-btn {
cursor: pointer;
}
/* ==================== 全局弹窗卡片样式 ==================== */
/* 自动为所有未手动分区的弹窗表单添加卡片背景 */
.el-dialog:not(.tk-dialog):not(.token-dialog):not(.token-result-dialog) .el-dialog__body > .el-form {
background: #fafbfc;
border-radius: 8px;
padding: 20px 20px 4px;
border: 1px solid #f0f2f5;
}
/* 统一弹窗 footer 按钮对齐 */
.el-dialog .el-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
}
.tk-dialog .el-dialog__body {
max-height: 70vh;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.tk-dialog .el-dialog__body::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.tk-dialog .el-form {
padding: 0 4px;
}
.tk-section {
background: #fafbfc;
border-radius: 8px;
padding: 20px 20px 4px;
margin-bottom: 16px;
border: 1px solid #f0f2f5;
}
.tk-section-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin-bottom: 18px;
padding-left: 10px;
border-left: 3px solid #409eff;
line-height: 1;
}
.tk-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.tk-resource-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 24px;
}
.tk-resource-grid .el-form-item {
margin-bottom: 18px;
}
.tk-resource-grid .el-form-item .el-form-item__label {
width: 80px !important;
}
.tk-resource-grid .el-form-item .el-form-item__content {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.tk-resource-grid .el-input-number {
flex: 1;
min-width: 0;
}
.tk-unit-select {
width: 68px;
flex-shrink: 0;
}
.tk-res-unit {
font-size: 13px;
color: #909399;
flex-shrink: 0;
white-space: nowrap;
}
.tk-inline-unit {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.tk-inline-unit .el-input-number,
.tk-inline-unit .el-input,
.tk-inline-unit .el-select {
flex: 1;
min-width: 0;
}
/* ==================== 全局页面布局组件 ==================== */
/* 页面头部 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #ebeef5;
}
.page-header .header-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-header .header-info h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1d2129;
}
.page-header .sub-info {
font-size: 13px;
color: #909399;
margin-top: 2px;
}
.page-header .header-right {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* 嵌入式工具栏 */
.embedded-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
/* 通用工具栏 */
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
/* 筛选栏 */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
/* 筛选区域(卡片式) */
.filter-section {
margin-bottom: 16px;
}
/* 分页 */
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 8px;
}
/* 绑定选择器行 */
.bind-selector-row {
display: flex;
align-items: center;
width: 100%;
}
/* 详情操作按钮组 */
.detail-actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
/* ==================== 全局表格增强 ==================== */
.el-table {
--el-table-header-bg-color: #fafafa;
--el-table-row-hover-bg-color: #f5f7fa;
--el-table-border-color: #ebeef5;
}
.el-table th.el-table__cell {
font-weight: 600 !important;
color: #1d2129 !important;
font-size: 13px !important;
border-bottom: 2px solid #e1e8ed !important;
}
.el-table td.el-table__cell {
border-bottom: 1px solid #f0f2f5 !important;
color: #34495e !important;
transition: background-color 0.15s ease;
}
.el-table .el-table__empty-block {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.el-table .el-table__empty-text {
color: #909399;
font-size: 14px;
line-height: 1.6;
}
/* 表格固定列阴影 */
.el-table__fixed {
box-shadow: 4px 0 8px -4px rgba(0, 0, 0, 0.1);
}
.el-table__fixed-right {
box-shadow: -4px 0 8px -4px rgba(0, 0, 0, 0.1);
}
/* ==================== 全局骨架屏样式 ==================== */
@keyframes tk-skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-container {
padding: 20px;
}
.skeleton-row {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.skeleton-row:last-child {
border-bottom: none;
}
.skeleton-cell {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: tk-skeleton-loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
/* ==================== 全局过渡动画 ==================== */
.el-table,
.el-card,
.el-tag,
.el-button {
transition: all 0.2s ease;
}
/* ==================== 通用文本类 ==================== */
.text-muted {
color: #c0c4cc;
}
.mono-text {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: #409eff;
font-size: 13px;
}
/* ==================== 视觉增强 ==================== */
/* 卡片式筛选区域 */
.filter-card {
background: #ffffff;
border: 1px solid #ebeef5;
padding: 16px 20px;
margin-bottom: 16px;
}
/* 操作栏 */
.action-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* 通用结果/令牌展示 */
.tk-result-wrapper { text-align: center; }
.tk-result-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; text-align: left; }
.tk-result-icon { font-size: 36px; color: #e6a23c; background: #fdf6ec; border-radius: 50%; padding: 10px; }
.tk-result-name { font-size: 16px; font-weight: 600; color: #1d2129; }
.tk-result-meta { font-size: 13px; color: #909399; margin-top: 2px; }
.tk-token-block { background: #1d2129; border-radius: 8px; padding: 16px; margin-bottom: 16px; text-align: left; }
.tk-token-label { font-size: 11px; color: #909399; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
.tk-token-value { font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 13px; color: #67c23a; word-break: break-all; line-height: 1.6; user-select: all; }
.tk-copy-btn { width: 100%; }
/* 表单提示 */
.form-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
/* 资源信息标签组 */
.resource-info {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* ==================== 响应式工具类 ==================== */
/* 表格横向滚动提示 */
.el-table {
overflow: visible;
}
@media (max-width: 768px) {
.hidden-xs {
display: none !important;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.page-header .header-right {
width: 100%;
flex-wrap: wrap;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.filter-bar .el-input,
.filter-bar .el-select {
width: 100% !important;
}
.pagination-wrapper {
justify-content: center;
}
.pagination-wrapper .el-pagination {
flex-wrap: wrap;
justify-content: center;
}
/* 弹窗在移动端更宽 */
.el-dialog {
width: 92% !important;
margin: 5vh auto !important;
}
/* 表格小屏字号调整 */
.el-table td.el-table__cell {
font-size: 13px !important;
}
/* 表单小屏行距压缩 */
.el-form-item {
margin-bottom: 16px;
}
/* tk-resource-grid 在移动端变为单列 */
.tk-resource-grid {
grid-template-columns: 1fr;
}
}
/* 中等屏幕适配 */
@media (max-width: 1200px) {
.el-table .el-table__body-wrapper {
overflow-x: auto;
}
}
@media (min-width: 768px) and (max-width: 992px) {
+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
}
+150 -28
View File
@@ -1,26 +1,104 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
// 基础URL
const baseUrl = 'https://apiservertest.s1f.ren'
// const baseUrl = 'https://cloudapi.007yjs.com'
import {getRefreshToken,refreshAccessToken} from "@/api/login.js";
import { baseUrl, acsBaseUrl, noAuthUrls as noAuthUrlList, requestTimeout, acsRequestTimeout, TOKEN_KEY, TOKEN_EXPIRE_KEY, USER_INFO_KEY } from '@/config/env.js'
// 检查URL是否需要认证
const urlNeedAuth = (url) => {
// 这里可以添加不需要认证的URL列表
const noAuthUrls = ['/v1/user/login', '/v1/user/check/get_code_img', '/v1/user/register']
return !noAuthUrls.some(noAuthUrl => url.includes(noAuthUrl))
return !noAuthUrlList.some(noAuthUrl => url.includes(noAuthUrl))
}
// 检查token是否过期
const isTokenExpired = () => {
const token = localStorage.getItem('token')
const token = localStorage.getItem(TOKEN_KEY)
const expire = localStorage.getItem(TOKEN_EXPIRE_KEY)
if (!token) return true
// 这里可以添加token过期检查逻辑,如果有JWT可以解析它
// 简单实现,仅检查token是否存在
return false
// 检查过期时间
if (expire) {
const expireTime = parseInt(expire) * 1000 // 转换为毫秒
const now = Date.now()
return now >= expireTime
}
// 没有过期时间时,默认认为Token已过期(因为无法验证有效性)
return true
}
// 检查token是否即将过期(5分钟内)
const isTokenExpiringSoon = () => {
const expire = localStorage.getItem(TOKEN_EXPIRE_KEY)
if (!expire) return false
const expireTime = parseInt(expire) * 1000 // 转换为毫秒
const now = Date.now()
const fiveMinutes = 5 * 60 * 1000 // 5分钟
// 如果已过期,返回false(由isTokenExpired处理)
if (now >= expireTime) return false
// 如果在5分钟内过期,返回true
return (expireTime - now) <= fiveMinutes
}
// 正在刷新token的标志
let isRefreshing = false
// 等待刷新token的请求队列
let refreshSubscribers = []
// 添加请求到队列
const subscribeTokenRefresh = (callback) => {
refreshSubscribers.push(callback)
}
// 刷新token后执行队列中的请求
const onTokenRefreshed = (newToken) => {
refreshSubscribers.forEach(callback => callback(newToken))
refreshSubscribers = []
}
// 执行token刷新
const doRefreshToken = async () => {
try {
const domain = window.location.hostname
// 获取交换token
const refreshTokenRes = await getRefreshToken(domain,{
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
})
if (refreshTokenRes.data?.code === 200 && refreshTokenRes.data?.data?.refresh_token) {
// 使用交换token获取新的access token
const newTokenRes = await refreshAccessToken(refreshTokenRes.data.data.refresh_token)
if (newTokenRes.data?.code === 200 && newTokenRes.data?.data?.token) {
const { token, expire } = newTokenRes.data.data
localStorage.setItem(TOKEN_KEY, token)
if (expire) {
localStorage.setItem(TOKEN_EXPIRE_KEY, expire.toString())
}
return token
}
}
// 刷新失败,触发登出逻辑
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(TOKEN_EXPIRE_KEY)
localStorage.removeItem(USER_INFO_KEY)
ElMessage.warning('登录过期,请重新登录')
router.push('/login')
return null
} catch (error) {
console.error('Token刷新失败:', error)
// 刷新失败,触发登出逻辑
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(TOKEN_EXPIRE_KEY)
localStorage.removeItem(USER_INFO_KEY)
ElMessage.warning('登录过期,请重新登录')
router.push('/login')
return null
}
}
class Request {
@@ -37,7 +115,7 @@ class Request {
(config) => {
// 在发送请求之前做些什么
// 例如:添加 token
const token = localStorage.getItem('token')
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@@ -93,8 +171,8 @@ class Request {
}
// DELETE 请求
delete(url,data={}, config = {}) {
return this.instance.delete(url,data, config)
delete(url, config = {}) {
return this.instance.delete(url, config)
}
// PATCH 请求
@@ -106,7 +184,7 @@ class Request {
// 创建默认实例
const request = new Request({
baseURL: baseUrl,
timeout: 50000,
timeout: requestTimeout,
headers: {
'Content-Type': 'multipart/form-data'
}
@@ -117,23 +195,67 @@ export const baseURL = baseUrl
export const http2 = axios.create({
baseURL: baseUrl,
timeout: 30000,
timeout: acsRequestTimeout,
headers: {},
});
http2.interceptors.request.use(config => {
const token = localStorage.getItem('token'); // 假设 token 存储在 localStorage
if(urlNeedAuth(config.url) && isTokenExpired()){
if (token){
localStorage.removeItem('token');
ElMessage.warning('登陆过期,请重新登陆')
http2.interceptors.request.use(async config => {
const token = localStorage.getItem(TOKEN_KEY)
// 检查是否需要认证
if (urlNeedAuth(config.url)) {
// 检查token是否已过期
if (isTokenExpired()) {
if (token) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(TOKEN_EXPIRE_KEY)
localStorage.removeItem(USER_INFO_KEY)
ElMessage.warning('登录过期,请重新登录')
}
router.push('/login')
return Promise.reject(new Error('Token已过期'))
}
// 检查token是否即将过期,进行无感刷新
if (isTokenExpiringSoon() && !isRefreshing) {
isRefreshing = true
try {
const newToken = await doRefreshToken()
if (newToken) {
console.log('Token已无感刷新')
onTokenRefreshed(newToken)
config.headers.Authorization = `Bearer ${newToken}`
} else {
// 刷新失败,doRefreshToken已处理登出逻辑,直接拒绝请求
return Promise.reject(new Error('Token刷新失败'))
}
} catch (error) {
console.error('Token刷新异常:', error)
// 刷新异常,doRefreshToken已处理登出逻辑,直接拒绝请求
return Promise.reject(error)
} finally {
isRefreshing = false
}
} else if (isRefreshing) {
// 正在刷新,等待刷新完成
return new Promise((resolve, reject) => {
subscribeTokenRefresh((newToken) => {
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`
// 重新发送原始请求
resolve(config)
} else {
reject(new Error('Token刷新失败'))
}
})
})
} else {
// 正常情况,直接使用token
config.headers.Authorization = `Bearer ${token}`
}
router.push('/login')
return Promise.reject();
}
config.headers.Authorization = `Bearer ${token}`;
config.url = config.url
// 不需要认证的请求,不添加token
return config
})
@@ -147,7 +269,7 @@ http2.interceptors.response.use(
}
const { status } = error.response;
if (status === 401) {
localStorage.removeItem('token');
localStorage.removeItem(TOKEN_KEY);
ElMessage.warning('登陆过期,请重新登陆')
router.push('/login')
return Promise.reject();
+153 -3
View File
@@ -18,7 +18,7 @@ export const formatDate = (dateStr) => {
return `${year}-${month}-${day} ${hours}:${minutes}`
}
/**
* 时间格式转 Unix 时间戳(秒级)
* 时间格式转 Unix 时间戳(秒级)
* @param {string|Date} time - 输入时间(支持 '2025-10-28 00:00:00'、'2025/10/28'、Date 对象等)
* @returns {number|null} 转换后的毫秒级时间戳(失败返回 null)
*/
@@ -50,10 +50,160 @@ export function timeToTimestamp(time) {
return null;
}
return Math.floor(timestamp / 1000); // 返回秒级时间戳(如 1751107200000
return Math.floor(timestamp / 1000); // 返回秒级时间戳(如 1751107200000
}
export function reducenum(num){
return num / 100
}
}
/**
* 分转元显示(返回 ¥xx.xx 或 '-'
*/
export function formatPrice(fen, fallback = '-') {
if (!fen && fen !== 0) return fallback
return '¥' + (fen / 100).toFixed(2)
}
/**
* 元转分(四舍五入取整)
*/
export function yuanToFen(yuan) {
return Math.round((yuan || 0) * 100)
}
/**
* 格式化到期时间(year < 2000 视为永久)
*/
export function formatExpireTime(t) {
if (!t) return '-'
const d = new Date(t)
if (isNaN(d.getTime())) return '-'
if (d.getFullYear() < 2000) return '永久'
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/**
* 将 ISO 格式时间字符串转换为毫秒级时间戳(用于时间选择器)
* @param {string|Date|number} time - 输入时间(支持 ISO 格式字符串如 '2023-11-08T01:10:00+08:00'、Date 对象、时间戳等)
* @returns {number|null} 转换后的毫秒级时间戳(失败或无效时间返回 null)
*/
export function isoToMilliseconds(time) {
// 处理空值
if (!time || time === null || time === undefined) {
return null
}
// 处理特殊的无效时间标识
if (typeof time === 'string' && (time === '0001-01-01T00:00:00Z' || time === '0001-01-01T00:00:00+00:00')) {
return null
}
// 如果已经是数字(时间戳),直接返回
if (typeof time === 'number') {
// 如果是秒级时间戳(小于 13 位),转换为毫秒
if (time < 1000000000000) {
return time * 1000
}
return time
}
// 处理 Date 对象
if (time instanceof Date) {
const timestamp = time.getTime()
return isNaN(timestamp) ? null : timestamp
}
// 处理字符串格式
if (typeof time === 'string') {
try {
const date = new Date(time)
const timestamp = date.getTime()
// 检查是否为有效时间
if (isNaN(timestamp)) {
return null
}
return timestamp
} catch (error) {
console.error('时间转换失败:', error)
return null
}
}
return null
}
/**
* 格式化时间为 "YYYY-MM-DD HH:mm:ss" 格式(用于接口提交)
* @param {string|Date|number} time
* @returns {string} 格式化后的时间字符串,无效时返回 ''
*/
export function formatToApiTime(time) {
if (!time) return ''
const d = time instanceof Date ? time : new Date(time)
if (isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
// ========== 虚拟机状态映射 ==========
const VM_STATUS_MAP = {
pending: { label: '等待中', type: 'info' },
creating: { label: '创建中', type: 'warning' },
ready: { label: '就绪', type: 'success' },
running: { label: '运行中', type: 'success' },
stopped: { label: '已停止', type: 'danger' },
stop: { label: '已停止', type: 'danger' },
shutoff: { label: '已关闭', type: 'danger' },
error: { label: '错误', type: 'danger' },
paused: { label: '已暂停', type: 'warning' },
reboot: { label: '重启中', type: 'warning' },
poweroff: { label: '已关机', type: 'info' },
unknown: { label: '未知', type: 'info' }
}
/**
* 获取虚拟机状态标签文字
*/
export function vmStatusLabel(status) {
return VM_STATUS_MAP[status]?.label || status || '-'
}
/**
* 获取虚拟机状态 Tag 类型
*/
export function vmStatusType(status) {
return VM_STATUS_MAP[status]?.type || 'info'
}
// ========== 磁盘状态映射 ==========
const VOLUME_STATUS_MAP = {
pending: { label: '等待中', type: 'info' },
creating: { label: '创建中', type: 'warning' },
ready: { label: '就绪', type: 'success' },
in_use: { label: '使用中', type: 'success' },
attaching: { label: '挂载中', type: 'warning' },
detaching: { label: '卸载中', type: 'warning' },
resizing: { label: '扩容中', type: 'warning' },
deleting: { label: '删除中', type: 'danger' },
error: { label: '错误', type: 'danger' },
unknown: { label: '未知', type: 'info' }
}
/**
* 获取磁盘状态标签文字
*/
export function volumeStatusLabel(status) {
return VOLUME_STATUS_MAP[status]?.label || status || '-'
}
/**
* 获取磁盘状态 Tag 类型
*/
export function volumeStatusType(status) {
return VOLUME_STATUS_MAP[status]?.type || 'info'
}
+10 -2
View File
@@ -105,16 +105,24 @@ const forgetPassword = () => {
const handleLogin = () => {
loginFormRef.value?.validate(async valid =>{
window.localStorage.removeItem('token')
window.localStorage.removeItem('tokenExpire')
window.localStorage.removeItem('userInfo')
if (valid) {
loading.value = true
let resp = await userLogin(loginForm.username, loginForm.password)
console.log("login:",resp)
loading.value = false
if(resp.code === 200){
window.localStorage.setItem('token',resp.data.token)
// 保存token和过期时间
window.localStorage.setItem('token', resp.data.token)
if (resp.data.expire) {
window.localStorage.setItem('tokenExpire', resp.data.expire.toString())
}
let userInfo = await getUserInfo()
if(userInfo.data.is_admin){
// 保存用户信息到localStorage
window.localStorage.setItem('userInfo', JSON.stringify(userInfo.data))
await router.push('/dashboard')
} else {
ElMessage.warning('你不是管理员,不能登陆到后台控制面板')
+3 -3
View File
@@ -666,7 +666,7 @@ const toLoad = async (data) => {
})
form.server_id = data
nowserver_id.value = data
let res = await getServerPlan({server_id:data,count:100})
let res = await getServerPlan({server_id:data,count:10})
planlist.value = res.data.data.map(item => {
return {
name: item.name,
@@ -748,7 +748,7 @@ const fetchCategoryList = async (serverId) => {
// 编辑镜像
const handleEdit = async (data) => {
try {
let res = await getServerPlan({server_id: data.server_id,count: 100})
let res = await getServerPlan({server_id: data.server_id,count: 10})
if (res.data && res.data.data) {
planlist.value = res.data.data.map(item => {
return {
@@ -874,7 +874,7 @@ const getit = async () => {
// 选择图片
const picPagin = reactive({
count: 50,
count: 10,
page: 1,
key: '',
user_type: 1
+1 -1
View File
@@ -262,7 +262,7 @@ const categoryRules = {
// 素材库相关
const picSwitch = ref(false)
const picPagin = reactive({
count: 50,
count: 10,
page: 1,
key: '',
user_type: 1
+2 -2
View File
@@ -244,7 +244,7 @@ const showNewCategoryInput = ref(false)
const picSwitch = ref(false)
const picLoading = ref(false)
const picPagin = reactive({
count: 20,
count: 10,
page: 1,
key: '',
user_type: 1
@@ -314,7 +314,7 @@ const initData = async () => {
// Fallback: fetch list and find item
const listRes = await getUserMirrorList({
server_id: serverId.value,
count: 100,
count: 10,
page: 1
})
if (listRes.data.code === 200) {
+21 -7
View File
@@ -467,7 +467,7 @@
<h3 class="tab-title">数据卷列表</h3>
<el-button
type="primary"
@click="showAddVolumeDialog = true"
@click="handleAddVolume"
:icon="Plus"
:disabled="vmInfo.state != 2"
>
@@ -671,8 +671,11 @@
width="500px"
>
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
<el-form-item label="大小(GB)" prop="size">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
<el-form-item label="大小" prop="size">
<div class="unit-input-row">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" style="flex:1" />
<el-select v-model="volumeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -693,8 +696,11 @@
>
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
<el-form-item label="大小(GB)" prop="size">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
<el-form-item label="大小" prop="size">
<div class="unit-input-row">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" style="flex:1" />
<el-select v-model="volumeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -1067,6 +1073,7 @@ const showMigrateVolumeDialog = ref(false);
const currentVolumeToEdit = ref(null);
const volumeForm = reactive({
size: 10,
_sizeUnit: 'GB'
});
const volumeFormRef = ref(null);
const volumeRules = {
@@ -2371,6 +2378,7 @@ const handleAddVolume = () => {
showAddVolumeDialog.value = true;
// 重置表单
volumeForm.size = 10;
volumeForm._sizeUnit = 'GB';
};
// 编辑数据卷
@@ -2378,6 +2386,7 @@ const handleEditVolume = (volume) => {
currentVolumeToEdit.value = volume;
// 填充表单
volumeForm.size = volume.size;
volumeForm._sizeUnit = 'GB';
showEditVolumeDialog.value = true;
};
@@ -2404,9 +2413,10 @@ const submitAddVolume = async () => {
if (valid) {
addingVolume.value = true;
try {
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
const res = await addVolume({
instance_id: route.query.instance_id,
size: String(volumeForm.size),
size: String(sizeGb),
user_id: user_id.value
});
console.log("添加数据卷112",res)
@@ -2438,9 +2448,10 @@ const submitEditVolume = async () => {
editingVolume.value = true;
try {
// 这里应该调用修改数据卷的API
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
const res = await updateVolume({
volume_id: currentVolumeToEdit.value.id,
size: volumeForm.size
size: sizeGb
});
console.log("编辑数据卷数据:",res)
@@ -2770,4 +2781,7 @@ const fetchServersList = async () => {
font-weight: 600;
color: #303133;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-select { width: 90px; flex-shrink: 0; }
</style>
+1 -1
View File
@@ -618,7 +618,7 @@ const fetchPlanList = async () => {
try {
const response = await getServerPlan({
server_id: props.ID,
count: 100
count: 10
});
if (response && response.data && response.data.code === 200) {
+5 -1
View File
@@ -315,7 +315,11 @@
class="data-table"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="size" label="空间大小(MB)" width="140" />
<el-table-column prop="size" label="空间大小(MB)" width="140">
<template #default="{ row }">
{{ row.size != null && row.size !== '' ? `${row.size} MB` : '-' }}
</template>
</el-table-column>
<el-table-column prop="mount_path" label="挂载路径" min-width="200" />
<el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table>
+3 -3
View File
@@ -1901,7 +1901,7 @@ const GetSpecs = async () => {
try {
let plans = await getServerPlan({
server_id: route.query.server_id,
count: 30
count: 10
});
spec_list.value = plans.data.data;
} catch (error) {
@@ -2407,7 +2407,7 @@ const fetchContainerPlanList = async () => {
try {
const response = await getServerPlan({
server_id: route.query.server_id,
count: 100
count: 10
});
console.log("获取容器套餐列表1111",response);
@@ -2430,7 +2430,7 @@ const fetchContainerMirrorList = async () => {
containerMirrorLoading.value = true;
try {
const response = await getMirrorList({server_id: route.query.server_id, page: 1, count: 999,key: '',class_id: ''});
const response = await getMirrorList({server_id: route.query.server_id, page: 1, count: 10,key: '',class_id: ''});
console.log("获取镜像列表1111",response);
if (response && response.data && response.data.code === 200) {
+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 }"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="container_id" label="容器ID" width="280" show-overflow-tooltip />
<el-table-column label="容器ID" width="280" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.container_id" type="primary" :underline="false" @click="router.push({ path: '/servers/container', query: { container_id: row.container_id } })">{{ row.container_id }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="url" label="访问地址" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link :href="row.url" target="_blank" type="primary" v-if="row.url">
@@ -146,6 +151,7 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import {
Refresh, Download, Search, Delete, View, Warning,
@@ -159,6 +165,8 @@ import {
} from '@/utils/acs/audit'
const router = useRouter()
//
const queryParams = reactive({
domain: '',
@@ -419,7 +427,7 @@ const getFullStatsData = async () => {
//
const statsParams = {
page: 1,
count: 1000, //
count: 10, //
server_id: '',
user_id: '',
key: queryParams.domain || ''
+9 -1
View File
@@ -35,7 +35,12 @@
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="container_id" label="容器ID" width="280" show-overflow-tooltip />
<el-table-column label="容器ID" width="280" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.container_id" type="primary" :underline="false" @click="router.push({ path: '/servers/container', query: { container_id: row.container_id } })">{{ row.container_id }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="url" label="违规地址" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link :href="row.url" target="_blank" type="danger" v-if="row.url">
@@ -195,6 +200,7 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import {
Refresh, Download, Search, Delete, View, Warning,
@@ -208,6 +214,8 @@ import {
} from '@/utils/acs/audit'
const router = useRouter()
//
const queryParams = reactive({
domain: '',
+485 -25
View File
@@ -13,7 +13,7 @@
<el-icon><component :is="card.icon" /></el-icon>
</div>
</div>
<div class="card-footer">
<!-- <div class="card-footer">
<span>较昨日</span>
<span :class="card.trend > 0 ? 'up' : 'down'">
{{ card.trend > 0 ? '+' : '' }}{{ card.trend }}%
@@ -23,13 +23,13 @@
</div>
<div class="progress-bar">
<div class="progress-inner" :style="{width: card.progress + '%', background: card.progressColor}"></div>
</div>
</div> -->
</el-card>
</el-col>
</el-row>
<!-- 图表部分 -->
<el-row :gutter="24" class="chart-row">
<!-- <el-row :gutter="24" class="chart-row">
<el-col :xs="24" :sm="24" :md="24" :lg="16" :xl="16">
<el-card class="chart-card" shadow="hover">
<div class="chart-header">
@@ -116,10 +116,137 @@
<div class="chart-container" ref="customerChartRef"></div>
</el-card>
</el-col>
</el-row> -->
<!-- 数据列表区域 -->
<el-row :gutter="24" class="list-row">
<!-- 最近用户 -->
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
<el-card class="list-card" shadow="hover" v-loading="listLoading">
<div class="card-header-custom">
<div class="header-left">
<el-icon class="header-icon user-icon"><User /></el-icon>
<h3>最近用户</h3>
</div>
<el-link type="primary" :underline="false" class="view-all" @click="goToUserList">
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
</el-link>
</div>
<div class="list-content">
<div v-if="recentUsers.length === 0" class="empty-tip">暂无数据</div>
<div v-for="item in recentUsers" :key="item.id" class="list-item" @click="goToUserDetail(item.id)">
<div class="item-main">
<div class="item-title">{{ item.name }}</div>
<div class="item-sub">{{ item.email }}</div>
</div>
<div class="item-extra">
<div class="item-id">ID: {{ item.id }}</div>
<div class="item-time">{{ formatDate(item.createdAt) }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- 最近订单 -->
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
<el-card class="list-card" shadow="hover" v-loading="listLoading">
<div class="card-header-custom">
<div class="header-left">
<el-icon class="header-icon order-icon"><ShoppingCart /></el-icon>
<h3>最近订单</h3>
</div>
<el-link type="primary" :underline="false" class="view-all" @click="goToOrderList">
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
</el-link>
</div>
<div class="list-content">
<div v-if="recentOrders.length === 0" class="empty-tip">暂无数据</div>
<div v-for="item in recentOrders" :key="item.id" class="list-item" @click="showOrderDetail(item)">
<div class="item-main">
<div class="item-title">{{ item.name }}</div>
<div class="item-sub">用户ID: {{ item.userId }}</div>
</div>
<div class="item-extra">
<el-tag :type="getOrderStatusType(item.state)" size="small">
{{ getOrderStatusText(item.state) }}
</el-tag>
<div class="item-price">¥{{ (item.price / 100).toFixed(2) }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- 最近工单 -->
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
<el-card class="list-card" shadow="hover" v-loading="listLoading">
<div class="card-header-custom">
<div class="header-left">
<el-icon class="header-icon ticket-icon"><Tickets /></el-icon>
<h3>最近工单</h3>
</div>
<el-link type="primary" :underline="false" class="view-all" @click="goToTicketList">
查看全部 <el-icon class="el-icon--right"><Right /></el-icon>
</el-link>
</div>
<div class="list-content">
<div v-if="recentTickets.length === 0" class="empty-tip">暂无数据</div>
<div v-for="item in recentTickets" :key="item.id" class="list-item" @click="goToTicketDetail(item.id)">
<div class="item-main">
<div class="item-title">{{ item.title || '工单 #' + item.id }}</div>
<div class="item-sub">用户ID: {{ item.userId }}</div>
</div>
<div class="item-extra">
<el-tag :type="getTicketStatusType(item.status)" size="small">
{{ getTicketStatusText(item.status) }}
</el-tag>
<div class="item-time">{{ formatDate(item.createdAt) }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 订单详情弹窗 -->
<el-dialog
v-model="orderDetailVisible"
title="订单详情"
width="600px"
append-to-body
class="order-detail-dialog"
>
<el-descriptions :column="2" border v-if="currentOrder">
<el-descriptions-item label="订单ID">{{ currentOrder.id }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ currentOrder.name }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentOrder.userId }}</el-descriptions-item>
<el-descriptions-item label="商品ID">{{ currentOrder.commodityId }}</el-descriptions-item>
<el-descriptions-item label="订单金额">
<span class="detail-price">¥{{ (currentOrder.price / 100).toFixed(2) }}</span>
</el-descriptions-item>
<el-descriptions-item label="续费价格">
<span class="detail-renew-price">¥{{ (currentOrder.renewPrice / 100).toFixed(2) }}</span>
</el-descriptions-item>
<el-descriptions-item label="数量">{{ currentOrder.payNum }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag :type="getOrderStatusType(currentOrder.state)">
{{ getOrderStatusText(currentOrder.state) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="支付方式">{{ currentOrder.payType || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentOrder.createdAt) }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<div class="dialog-footer">
<el-button @click="orderDetailVisible = false">关闭</el-button>
<el-button type="primary" @click="goToOrderList">查看全部订单</el-button>
</div>
</template>
</el-dialog>
<!-- 最近活动和待办事项 -->
<el-row :gutter="24" class="activity-row">
<!-- <el-row :gutter="24" class="activity-row">
<el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
<el-card class="activity-card" shadow="hover">
<div class="card-header-custom">
@@ -207,28 +334,45 @@
</div>
</el-card>
</el-col>
</el-row>
</el-row> -->
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
User, ShoppingCart, Money, DataAnalysis,
MoreFilled, ArrowUp, ArrowDown, Right,
Download, Refresh, Check, Delete, Plus,
Setting, Calendar, Filter
Setting, Calendar, Filter, Tickets, View
} from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import Qrcode from '@/components/Qrcode.vue'
import {useUserStore} from "@/store/userStore.js";
import { getUserList } from '@/api/admin/user'
import { getOrderList } from '@/api/admin/order'
import { getTicketCount, getTickerList } from '@/api/ticket'
const userStore = useUserStore()
const router = useRouter()
//
const userCount = ref(0)
const orderCount = ref(0)
const ticketCount = ref(0)
//
const recentUsers = ref([])
const recentOrders = ref([])
const recentTickets = ref([])
const listLoading = ref(false)
//
const statisticsCards = ref([
const statisticsCards = computed(() => [
{
title: '访问量',
value: '8,846',
title: '用户量',
value: userCount.value.toLocaleString(),
icon: 'User',
trend: 12.5,
class: 'visitors',
@@ -237,7 +381,7 @@ const statisticsCards = ref([
},
{
title: '订单量',
value: '1,257',
value: orderCount.value.toLocaleString(),
icon: 'ShoppingCart',
trend: 5.2,
class: 'orders',
@@ -245,9 +389,9 @@ const statisticsCards = ref([
progressColor: 'rgba(82, 196, 26, 0.8)'
},
{
title: '销售额',
value: '¥ 125,430',
icon: 'Money',
title: '工单量',
value: ticketCount.value.toLocaleString(),
icon: 'Tickets',
trend: -2.3,
class: 'sales',
progress: 52,
@@ -264,6 +408,150 @@ const statisticsCards = ref([
}
])
//
const fetchStatistics = async () => {
try {
//
const userRes = await getUserList({ page: 1, count: 10, key: '' })
console.log("用户数量,",userRes)
if (userRes.data?.code === 200) {
userCount.value = userRes.data.data.all_count || 0
}
//
const orderRes = await getOrderList({ page: 1, count: 10 })
console.log("订单数量,",orderRes)
if (orderRes.data?.code === 200) {
orderCount.value = orderRes.data.data.all_count || 0
}
//
const ticketRes = await getTicketCount()
console.log("工单数量,",ticketRes)
if (ticketRes.code === 200) {
ticketCount.value = ticketRes.data?.all_count || 0
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
//
const fetchRecentLists = async () => {
listLoading.value = true
try {
//
const userRes = await getUserList({ page: 1, count: 10, key: '' })
if (userRes.data?.code === 200) {
recentUsers.value = (userRes.data.data.data || []).map(user => ({
id: user.user_id,
name: user.user_name,
email: user.email || '未设置',
phone: user.phone || '未设置',
createdAt: user.created_at
}))
}
//
const orderRes = await getOrderList({ page: 1, count: 10 })
if (orderRes.data?.code === 200) {
recentOrders.value = (orderRes.data.data.list || []).map(order => ({
id: order.id,
name: order.name,
userId: order.userId,
commodityId: order.commodityId,
price: order.price,
renewPrice: order.renewPrice || 0,
payNum: order.payNum || 1,
state: order.state,
payType: order.payType,
createdAt: order.CreatedAt
}))
}
//
const ticketRes = await getTickerList(5, 1)
console.log("最近工单,",ticketRes)
if (ticketRes.code === 200) {
recentTickets.value = (ticketRes.data.data?.list || ticketRes.data.data || []).map(ticket => ({
id: ticket.work_id || ticket.id,
title: ticket.title,
status: ticket.status,
userId: ticket.user?.userId,
createdAt: ticket.created_at || ticket.CreatedAt
}))
}
} catch (error) {
console.error('获取列表数据失败:', error)
} finally {
listLoading.value = false
}
}
//
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
//
const getOrderStatusText = (state) => {
const statusMap = { 0: '待支付', 1: '已支付', 2: '已失效' }
return statusMap[state] || '未知'
}
const getOrderStatusType = (state) => {
const typeMap = { 0: 'warning', 1: 'success', 2: 'info' }
return typeMap[state] || 'info'
}
//
const getTicketStatusText = (status) => {
const statusMap = { 0: '待处理', 1: '处理中', 2: '已回复', 3: '已解决' }
return statusMap[status] || '未知'
}
const getTicketStatusType = (status) => {
const typeMap = { 0: 'danger', 1: 'warning', 2: 'primary', 3: 'success' }
return typeMap[status] || 'info'
}
//
const goToUserDetail = (userId) => {
router.push({ path: '/user/detail', query: { user_id: userId } })
}
const goToUserList = () => {
router.push('/user/list')
}
const goToOrderList = () => {
router.push('/order/list')
}
const goToTicketList = () => {
router.push('/ticket/list')
}
const goToTicketDetail = (ticketId) => {
router.push({ path: '/ticket/detail', query: { work_id: ticketId } })
}
//
const orderDetailVisible = ref(false)
const currentOrder = ref(null)
const showOrderDetail = (order) => {
currentOrder.value = order
orderDetailVisible.value = true
}
//
const customerData = ref([
{ name: '企业客户', value: 1048, percentage: 33, color: '#1890ff' },
@@ -331,6 +619,10 @@ const getPriorityType = (priority) => {
}
onMounted(() => {
//
fetchStatistics()
fetchRecentLists()
initSalesChart()
initCustomerChart()
@@ -531,15 +823,19 @@ watch(salesRange, (newVal) => {
/* 统计卡片样式 */
.stat-card {
margin-bottom: 24px;
border-radius: 12px;
border: none;
transition: all 0.3s;
overflow: hidden;
border-left: 3px solid transparent !important;
}
.stat-card.visitors { border-left-color: #1890ff !important; }
.stat-card.orders { border-left-color: #52c41a !important; }
.stat-card.sales { border-left-color: #faad14 !important; }
.stat-card.conversion { border-left-color: #722ed1 !important; }
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.card-top {
@@ -570,10 +866,10 @@ watch(salesRange, (newVal) => {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 12px;
font-size: 28px;
width: 48px;
height: 48px;
border-radius: 4px;
font-size: 24px;
color: #fff;
}
@@ -635,8 +931,6 @@ watch(salesRange, (newVal) => {
.chart-card {
margin-bottom: 24px;
border-radius: 12px;
border: none;
overflow: hidden;
}
@@ -744,8 +1038,6 @@ watch(salesRange, (newVal) => {
.activity-card, .todo-card {
height: 100%;
border-radius: 12px;
border: none;
}
.card-header-custom {
@@ -870,6 +1162,149 @@ watch(salesRange, (newVal) => {
gap: 8px;
}
/* 列表卡片样式 */
.list-row {
margin-bottom: 24px;
}
.list-card {
margin-bottom: 24px;
height: 410px;
display: flex;
flex-direction: column;
}
.list-card :deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.header-icon {
font-size: 20px;
margin-right: 8px;
}
.user-icon {
color: #1890ff;
}
.order-icon {
color: #52c41a;
}
.ticket-icon {
color: #faad14;
}
.list-content {
padding: 0 20px 20px;
flex: 1;
overflow-y: auto;
}
.empty-tip {
text-align: center;
color: #909399;
padding: 60px 0;
font-size: 14px;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background-color 0.2s;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background-color: #fafafa;
margin: 0 -20px;
padding: 12px 20px;
}
.item-main {
flex: 1;
min-width: 0;
}
.item-title {
font-size: 14px;
font-weight: 500;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.item-sub {
font-size: 12px;
color: #8c8c8c;
}
.item-extra {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
margin-left: 12px;
}
.item-id {
font-size: 12px;
color: #8c8c8c;
}
.item-time {
font-size: 12px;
color: #8c8c8c;
}
.item-price {
font-size: 14px;
font-weight: 600;
color: #f56c6c;
}
/* 订单详情弹窗样式 */
.order-detail-dialog :deep(.el-descriptions__label) {
width: 100px;
font-weight: 500;
color: #606266;
}
.order-detail-dialog :deep(.el-descriptions__content) {
color: #2c3e50;
}
.detail-price {
color: #f56c6c;
font-weight: 600;
font-size: 16px;
}
.detail-renew-price {
color: #409eff;
font-weight: 500;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.dashboard-container {
padding: 12px;
@@ -888,5 +1323,30 @@ watch(salesRange, (newVal) => {
.todo-list {
height: 320px;
}
.list-card {
height: auto;
min-height: 300px;
}
.list-content {
min-height: 200px;
max-height: 1000px;
}
.order-detail-dialog :deep(.el-dialog) {
width: 90% !important;
margin: 5vh auto !important;
}
.order-detail-dialog :deep(.el-descriptions) {
--el-descriptions-item-bordered-label-background: #fafafa;
}
.order-detail-dialog :deep(.el-descriptions__label),
.order-detail-dialog :deep(.el-descriptions__content) {
padding: 8px 12px;
font-size: 13px;
}
}
</style>
+420
View File
@@ -0,0 +1,420 @@
<template>
<div class="mail-service-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M22 6L12 13 2 6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>
<h2 class="header-title">邮件主控服务管理</h2>
<p class="header-desc">管理邮件平台的主控服务实例每个服务对应一个 mail-server 节点</p>
</div>
</div>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增服务
</el-button>
</div>
<!-- 搜索栏 -->
<div class="filter-bar">
<el-input
v-model="queryParams.key"
placeholder="搜索名称 / 说明 / 地址"
clearable
style="width: 300px"
@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 @click="handleReset">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="服务名称" min-width="150">
<template #default="{ row }">
<div class="service-name-cell">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#67c23a" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M22 6L12 13 2 6"/>
</svg>
<span class="name-text">{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-text">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="host" label="服务地址" min-width="220">
<template #default="{ row }">
<el-tag type="info" effect="plain" class="host-tag">{{ row.host }}</el-tag>
</template>
</el-table-column>
<el-table-column label="SMTP 配置" min-width="200">
<template #default="{ row }">
<div v-if="row.smtpHost" class="smtp-info">
<span class="smtp-host">{{ row.smtpHost }}:{{ row.smtpPort || '-' }}</span>
<el-tag v-if="row.smtpTlsEnable" type="success" size="small" effect="plain">TLS</el-tag>
<el-tag v-else type="info" size="small" effect="plain">无TLS</el-tag>
</div>
<span v-else class="note-text">-</span>
</template>
</el-table-column>
<el-table-column prop="serviceToken" label="Service Token" min-width="180">
<template #default="{ row }">
<div class="token-cell">
<span v-if="!row._showToken" class="token-mask">{{ maskToken(row.serviceToken) }}</span>
<span v-else class="token-full">{{ row.serviceToken }}</span>
<el-button link size="small" @click="row._showToken = !row._showToken">
{{ row._showToken ? '隐藏' : '显示' }}
</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="170" align="center">
<template #default="{ row }">{{ formatTime(row.createdAt || row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="success" size="small" @click="openConsole(row)">控制台</el-button>
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-popconfirm title="确认删除该邮件服务?" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑邮件主控服务' : '新增邮件主控服务'"
width="600px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="服务名称" prop="name">
<el-input v-model="form.name" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="说明" prop="note">
<el-input v-model="form.note" type="textarea" :rows="2" placeholder="服务说明(可选)" />
</el-form-item>
<el-form-item label="服务地址" prop="host">
<el-input v-model="form.host" placeholder="https://mail.example.com" />
</el-form-item>
<el-form-item label="Service Token" prop="service_token">
<el-input v-model="form.service_token" placeholder="mail-server 的 SERVICE_TOKEN" show-password />
</el-form-item>
<el-divider content-position="left">SMTP 配置可选</el-divider>
<el-form-item label="SMTP 地址" prop="smtp_host">
<el-input v-model="form.smtp_host" placeholder="smtp.example.com" />
</el-form-item>
<el-form-item label="SMTP 端口" prop="smtp_port">
<el-input-number v-model="form.smtp_port" :min="1" :max="65535" placeholder="465" style="width: 100%" />
</el-form-item>
<el-form-item label="启用 TLS" prop="smtp_tls_enable">
<el-switch v-model="form.smtp_tls_enable" active-text="启用" inactive-text="关闭" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import {
getMailServiceList,
createMailService,
updateMailService,
deleteMailService
} from '@/api/admin/mailService.js'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const queryParams = reactive({
page: 1,
count: 10,
key: ''
})
const form = ref({
id: null,
name: '',
note: '',
host: '',
service_token: '',
smtp_host: '',
smtp_port: null,
smtp_tls_enable: false
})
const rules = {
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
service_token: [{ required: true, message: '请输入 Service Token', trigger: 'blur' }]
}
const formatTime = (t) => {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const maskToken = (token) => {
if (!token) return '-'
if (token.length <= 8) return '****'
return token.slice(0, 4) + '****' + token.slice(-4)
}
const fetchList = async () => {
loading.value = true
try {
const res = await getMailServiceList(queryParams)
const body = res.data
if (body.code === 200) {
const d = body.data?.data || body.data || []
tableData.value = (Array.isArray(d) ? d : []).map(item => ({ ...item, _showToken: false }))
total.value = body.data?.all_count || body.total || 0
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page = 1
fetchList()
}
const handleReset = () => {
queryParams.key = ''
queryParams.page = 1
fetchList()
}
const handleAdd = () => {
isEdit.value = false
form.value = { id: null, name: '', note: '', host: '', service_token: '', smtp_host: '', smtp_port: null, smtp_tls_enable: false }
dialogVisible.value = true
}
const handleEdit = (row) => {
isEdit.value = true
form.value = {
id: row.id,
name: row.name,
note: row.note || '',
host: row.host,
service_token: row.serviceToken || row.service_token || '',
smtp_host: row.smtpHost || row.smtp_host || '',
smtp_port: row.smtpPort || row.smtp_port || null,
smtp_tls_enable: !!(row.smtpTlsEnable ?? row.smtp_tls_enable)
}
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value?.validate()
submitting.value = true
try {
const params = new URLSearchParams()
if (isEdit.value) params.append('id', form.value.id)
params.append('name', form.value.name)
params.append('note', form.value.note)
params.append('host', form.value.host)
params.append('service_token', form.value.service_token)
if (form.value.smtp_host) params.append('smtp_host', form.value.smtp_host)
if (form.value.smtp_port) params.append('smtp_port', form.value.smtp_port)
params.append('smtp_tls_enable', form.value.smtp_tls_enable ? 'true' : 'false')
const fn = isEdit.value ? updateMailService : createMailService
const res = await fn(params)
if (res.data.code === 200) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await deleteMailService(params)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
const openConsole = (row) => {
const base = (row.host || '').replace(/\/+$/, '')
if (!base) return ElMessage.warning('该服务未配置地址')
window.open(`${base}/ui/index.html?token=${encodeURIComponent(row.serviceToken || '')}`, '_blank')
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.mail-service-page {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4f8 100%);
border-radius: 12px;
border: 1px solid #e0ecf5;
}
.header-info {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.header-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.header-desc {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.service-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.name-text {
font-weight: 500;
color: #303133;
}
.note-text {
color: #909399;
font-size: 13px;
}
.host-tag {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
}
.smtp-info {
display: flex;
align-items: center;
gap: 8px;
}
.smtp-host {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #606266;
}
.token-cell {
display: flex;
align-items: center;
gap: 8px;
}
.token-mask {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #909399;
}
.token-full {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #303133;
word-break: break-all;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding: 16px 0;
}
</style>
-665
View File
@@ -1,665 +0,0 @@
<template>
<div class="discount-code-container">
<!-- 主容器 -->
<el-card class="main-container" shadow="never">
<!-- 搜索和操作栏 -->
<div class="filter-section">
<div class="filter-content">
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增优惠码
</el-button>
<el-button type="success" @click="fetchDiscountList">
<el-icon><Refresh /></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">
<!-- 骨架屏 -->
<div v-if="loading" class="skeleton-container">
<div v-for="i in 5" :key="i" class="skeleton-row">
<div class="skeleton-cell skeleton-checkbox"></div>
<div class="skeleton-cell skeleton-id"></div>
<div class="skeleton-cell skeleton-code"></div>
<div class="skeleton-cell skeleton-name"></div>
<div class="skeleton-cell skeleton-type"></div>
<div class="skeleton-cell skeleton-value"></div>
<div class="skeleton-cell skeleton-min"></div>
<div class="skeleton-cell skeleton-max"></div>
<div class="skeleton-cell skeleton-times"></div>
<div class="skeleton-cell skeleton-action"></div>
</div>
</div>
<el-table
v-else
v-loading="loading"
:data="discountList"
@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="code" label="优惠码" min-width="150" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="优惠类型" width="120">
<template #default="{ row }">
<el-tag :type="row.percentage ? 'success' : 'primary'">
{{ row.percentage ? '百分比折扣' : '固定金额' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="优惠值" width="120">
<template #default="{ row }">
<span v-if="row.percentage" class="discount-value">{{ (row.percentage / 100).toFixed(0) }}%</span>
<span v-else class="amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="最低消费" width="120">
<template #default="{ row }">
¥{{ (row.minAmount / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="最大抵扣" width="120">
<template #default="{ row }">
<span v-if="row.maxAmount">¥{{ (row.maxAmount / 100).toFixed(2) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="maxTimes" label="最大使用次数" width="120" />
<el-table-column prop="userTimes" label="单用户次数" width="120" />
<el-table-column label="可叠加" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.canStacking" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="续费可用" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.renew" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleView(row)">查看</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</div>
</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="dialogType === 'add' ? '新增优惠码' : '编辑优惠码'"
width="700px"
append-to-body
>
<el-form
ref="discountFormRef"
:model="discountForm"
:rules="discountRules"
label-width="140px"
>
<el-form-item label="优惠码" prop="code">
<el-input v-model="discountForm.code" placeholder="请输入优惠码" />
</el-form-item>
<el-form-item label="优惠码名称" prop="name">
<el-input v-model="discountForm.name" placeholder="请输入优惠码名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input v-model="discountForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="优惠类型" prop="discount_mode">
<el-radio-group v-model="discountForm.discount_mode">
<el-radio label="amount">固定金额</el-radio>
<el-radio label="percentage">百分比折扣</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额(元)" prop="amount">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="width: 100%" />
</el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
</el-form-item>
<el-form-item label="最低消费(元)" prop="min_amount">
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" />
</el-form-item>
<el-form-item label="最大抵扣(元)" prop="max_amount">
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="单用户最大次数" prop="user_times">
<el-input-number v-model="discountForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="有效期" prop="timeRange">
<el-date-picker
v-model="discountForm.timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:teleported="true"
popper-class="discount-date-picker"
placement="top-start"
:editable="true"
:clearable="true"
style="width: 100%"
@keyup.enter="handleDatePickerEnter"
/>
</el-form-item>
<el-form-item label="续费可用" prop="renew">
<el-switch v-model="discountForm.renew" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="同类型可叠加" prop="can_stacking">
<el-switch v-model="discountForm.can_stacking" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="其他类型可叠加" prop="can_combine">
<el-switch v-model="discountForm.can_combine" active-text="是" inactive-text="否" />
</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>
<!-- 详情查看对话框 -->
<DiscountDetailDialog
v-model="detailDialogVisible"
type="code"
:detail-data="currentDetail"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Refresh, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import {
getDiscountCodeList,
getDiscountCodeDetail,
createDiscountCode,
updateDiscountCode,
deleteDiscountCode
} from '@/api/admin/discount'
import { timeToTimestamp } from '@/utils/tool'
import DiscountDetailDialog from '@/components/marketing/DiscountDetailDialog.vue'
//
const queryParams = reactive({
discount_type: 'code', // code
page: 1,
count: 10
})
//
const discountForm = reactive({
code_id: undefined,
discount_type: 'code', // code
code: '',
name: '',
note: '',
discount_mode: 'amount', // amount percentage
amount: 0,
percentage: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
const discountRules = {
code: [
{ required: true, message: '请输入优惠码', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入优惠码名称', trigger: 'blur' }
],
discount_mode: [
{ required: true, message: '请选择优惠类型', trigger: 'change' }
]
}
//
const loading = ref(false)
const discountList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogType = ref('add')
const discountFormRef = ref(null)
const detailDialogVisible = ref(false)
const currentDetail = ref(null)
//
const fetchDiscountList = async () => {
loading.value = true
try {
const res = await getDiscountCodeList(queryParams)
console.log('优惠码列表数据:', res.data)
if (res.data.code === 200) {
discountList.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 handleSelectionChange = (selection) => {
selectedRows.value = selection
}
//
const handleSizeChange = (size) => {
queryParams.count = size
fetchDiscountList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchDiscountList()
}
//
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(discountForm, {
code_id: undefined,
discount_type: 'code',
code: '',
name: '',
note: '',
discount_mode: 'amount',
amount: 0,
percentage: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
discountFormRef.value?.resetFields()
}
//
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
//
const startTime = row.startTime ? new Date(row.startTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
const endTime = row.endTime ? new Date(row.endTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
Object.assign(discountForm, {
code_id: row.id,
discount_type: 'code',
code: row.code,
name: row.name,
note: row.note || '',
discount_mode: row.percentage ? 'percentage' : 'amount',
amount: row.amount ? row.amount / 100 : 0,
percentage: row.percentage ? row.percentage / 100 : 0,
min_amount: row.minAmount ? row.minAmount / 100 : 0,
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
max_times: row.maxTimes || 0,
user_times: row.userTimes || 0,
timeRange: startTime && endTime ? [startTime, endTime] : [],
renew: row.renew || false,
can_stacking: row.canStacking || false,
can_combine: row.canCombine || false
})
}
//
const handleView = async (row) => {
try {
const res = await getDiscountCodeDetail({ code_id: row.id })
console.log('优惠码详情:', res.data)
if (res.data.code === 200) {
currentDetail.value = res.data.data
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取优惠码详情失败:', error)
ElMessage.error('获取优惠码详情失败')
}
}
//
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除优惠码 ${row.code} 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteDiscountCode({ code_id: row.id })
console.log('删除响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchDiscountList()
}
} 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 () => {
loading.value = true
try {
const deletePromises = selectedRows.value.map(row =>
deleteDiscountCode({ code_id: row.id })
)
const results = await Promise.allSettled(deletePromises)
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
const failCount = results.length - successCount
if (failCount === 0) {
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
} else if (successCount === 0) {
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
} else {
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount}`)
}
fetchDiscountList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除操作异常')
} finally {
loading.value = false
}
}).catch(() => {})
}
//
const handleDatePickerEnter = (event) => {
//
const datePicker = event.target.closest('.el-date-editor')
if (datePicker) {
//
event.target.blur()
}
}
//
const submitForm = () => {
discountFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
discount_type: 'code',
code: discountForm.code,
name: discountForm.name,
note: discountForm.note,
min_amount: Math.round(discountForm.min_amount * 100),
max_amount: Math.round(discountForm.max_amount * 100),
max_times: discountForm.max_times || 0,
user_times: discountForm.user_times || 0,
renew: discountForm.renew,
can_stacking: discountForm.can_stacking,
can_combine: discountForm.can_combine
}
// amountpercentage
if (discountForm.discount_mode === 'percentage') {
submitData.percentage = Math.round(discountForm.percentage * 100)
submitData.amount = 0
} else {
submitData.amount = Math.round(discountForm.amount * 100)
submitData.percentage = 0
}
//
if (discountForm.timeRange && discountForm.timeRange.length === 2) {
submitData.start_time = timeToTimestamp(discountForm.timeRange[0])
submitData.end_time = timeToTimestamp(discountForm.timeRange[1])
} else {
submitData.start_time = ''
submitData.end_time = ''
}
// code_id
if (dialogType.value === 'edit') {
submitData.code_id = discountForm.code_id
}
console.log('提交优惠码数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await createDiscountCode(submitData)
} else {
res = await updateDiscountCode(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchDiscountList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
})
}
//
onMounted(() => {
fetchDiscountList()
})
</script>
<style scoped>
.discount-code-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: flex-end;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-section {
padding: 0;
}
.action-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
.discount-value {
color: #67c23a;
font-weight: bold;
font-size: 14px;
}
.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;
}
/* 表格样式优化 */
: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;
}
/* 骨架屏样式 */
.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%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-checkbox { width: 55px; }
.skeleton-id { width: 80px; }
.skeleton-code { width: 150px; }
.skeleton-name { width: 180px; }
.skeleton-type { width: 120px; }
.skeleton-value { width: 120px; }
.skeleton-min { width: 120px; }
.skeleton-max { width: 120px; }
.skeleton-times { width: 120px; }
.skeleton-action { width: 200px; height: 32px; }
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
<style>
/* 时间选择器弹出层样式 - 非 scoped */
.discount-date-picker {
z-index: 9999 !important;
}
.discount-date-picker .el-picker-panel {
max-width: 90vw;
}
</style>
+231 -182
View File
@@ -161,47 +161,39 @@
</el-select>
</el-form-item>
<el-form-item label="选择类型" prop="select_type" v-if="dialogType === 'add'">
<el-radio-group v-model="form.select_type" @change="handleSelectTypeChange">
<el-radio value="product">商品</el-radio>
<el-radio value="product_group">商品组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="选择商品" prop="selected_product" v-if="dialogType === 'add' && form.select_type === 'product'">
<el-select
v-model="form.selected_product"
placeholder="请选择商品"
filterable
clearable
style="width: 100%"
@change="handleProductChange"
>
<el-option
v-for="item in productOptions"
:key="item.id"
:label="`${item.name} (ID: ${item.id})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="选择商品组" prop="selected_group" v-if="dialogType === 'add' && form.select_type === 'product_group'">
<el-select
v-model="form.selected_group"
placeholder="请选择商品组"
filterable
clearable
style="width: 100%"
@change="handleProductGroupChange"
>
<el-option
v-for="item in productGroupOptions"
:key="item.id"
:label="`${item.name} (ID: ${item.id})`"
:value="item.id"
/>
</el-select>
<el-form-item label="选择关联对象" v-if="dialogType === 'add'">
<div class="goods-tree-wrapper">
<div class="goods-tree-toolbar">
<span class="tree-tip">可自由勾选商品组与商品展开层级查看下属内容</span>
<div class="tree-summary">
已选 <b>{{ checkedSummary.groupCount }}</b> 个商品组 / <b>{{ checkedSummary.productCount }}</b> 个商品
</div>
</div>
<el-tree
ref="goodsTreeRef"
:props="treeProps"
:load="loadTreeNode"
lazy
show-checkbox
check-strictly
node-key="key"
class="goods-tree"
@check="handleTreeCheck"
>
<template #default="{ data }">
<span class="tree-node">
<el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
{{ data.nodeType === 'group' ? '组' : '品' }}
</el-tag>
<span class="tree-node-label">{{ data.label }}</span>
<span class="tree-node-id">ID: {{ data.rawId }}</span>
<span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price">
¥{{ (data.price / 100).toFixed(2) }}
</span>
</span>
</template>
</el-tree>
</div>
</el-form-item>
<!-- 编辑模式显示字段 -->
@@ -234,7 +226,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, onMounted, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Search, Plus, Refresh } from '@element-plus/icons-vue'
import {
@@ -276,36 +268,27 @@ const form = reactive({
code_id: undefined,
goods_id: undefined,
goods_name: '',
goods_type: '',
select_type: 'product', // product product_group
selected_product: undefined, // ID
selected_group: undefined // ID
goods_type: ''
})
const formRules = {
code_id: [
{ required: true, message: '请选择代金券', trigger: 'change' }
],
select_type: [
{ required: true, message: '请选择类型', trigger: 'change' }
],
selected_product: [
{ required: true, message: '请选择商品', trigger: 'change' }
],
selected_group: [
{ required: true, message: '请选择商品组', trigger: 'change' }
],
goods_id: [
{ required: true, message: '请输入商品ID', trigger: 'blur' }
],
goods_name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' }
],
goods_type: [
{ required: true, message: '请选择商品类型', trigger: 'change' }
]
}
//
const goodsTreeRef = ref(null)
const treeProps = {
label: 'label',
children: 'children',
isLeaf: 'isLeaf'
}
const checkedSummary = reactive({ groupCount: 0, productCount: 0 })
//
const loading = ref(false)
const goodsList = ref([])
@@ -389,7 +372,7 @@ const fetchVoucherListOptions = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 1000,
count: 10,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
@@ -407,7 +390,7 @@ const fetchProductList = async () => {
try {
const res = await getProductList({
page: 1,
count: 1000
count: 10
})
console.log('获取商品列表:', res.data)
if (res.data.code === 200) {
@@ -424,7 +407,7 @@ const fetchProductGroupList = async () => {
try {
const res = await getProductGroupList({
page: 1,
count: 1000
count: 10
})
console.log('获取商品组列表:', res.data)
if (res.data.code === 200) {
@@ -436,33 +419,79 @@ const fetchProductGroupList = async () => {
}
}
//
const handleSelectTypeChange = (type) => {
form.selected_product = undefined
form.selected_group = undefined
form.goods_id = undefined
form.goods_name = ''
form.goods_type = ''
}
//
// node.level === 0
const loadTreeNode = async (node, resolve) => {
try {
// level=1
if (node.level === 0) {
const res = await getProductGroupList({ level: 1 })
if (res.data.code === 200) {
const groups = res.data.data?.data || []
return resolve(groups.map(buildGroupNode))
}
return resolve([])
}
//
const handleProductChange = (productId) => {
const product = productOptions.value.find(item => item.id === productId)
if (product) {
form.goods_id = product.id
form.goods_name = product.goodsName || product.name || ''
form.goods_type = 'product'
// +
if (node.data?.nodeType === 'group') {
const groupId = node.data.rawId
const childLevel = (node.data.level || 1) + 1
const tasks = [
getProductList({ good_group_id: groupId, delete: false })
]
//
if (node.data.existSub) {
tasks.push(getProductGroupList({ parent_id: groupId, level: childLevel }))
}
const results = await Promise.all(tasks)
const productRes = results[0]
const productNodes = (productRes.data.code === 200 ? (productRes.data.data?.data || []) : [])
.map(buildProductNode)
let groupNodes = []
if (node.data.existSub && results[1]?.data.code === 200) {
groupNodes = (results[1].data.data?.data || []).map(buildGroupNode)
}
return resolve([...groupNodes, ...productNodes])
}
return resolve([])
} catch (error) {
console.error('加载层级数据失败:', error)
ElMessage.error('加载层级数据失败')
return resolve([])
}
}
//
const handleProductGroupChange = (groupId) => {
const group = productGroupOptions.value.find(item => item.id === groupId)
if (group) {
form.goods_id = group.id
form.goods_name = group.name || ''
form.goods_type = 'product_group'
}
//
const buildGroupNode = (group) => ({
key: `group_${group.id}`,
rawId: group.id,
nodeType: 'group',
label: group.name,
level: group.level || 1,
existSub: group.existSub || false,
isLeaf: false
})
//
const buildProductNode = (product) => ({
key: `product_${product.id}`,
rawId: product.id,
nodeType: 'product',
label: product.name,
price: product.price,
isLeaf: true
})
//
const handleTreeCheck = () => {
const nodes = goodsTreeRef.value?.getCheckedNodes() || []
checkedSummary.groupCount = nodes.filter(n => n.nodeType === 'group').length
checkedSummary.productCount = nodes.filter(n => n.nodeType === 'product').length
}
//
@@ -527,16 +556,15 @@ const handleAdd = () => {
code_id: queryParams.code_id ? Number(queryParams.code_id) : undefined,
goods_id: undefined,
goods_name: '',
goods_type: '',
select_type: 'product',
selected_product: undefined,
selected_group: undefined
goods_type: ''
})
checkedSummary.groupCount = 0
checkedSummary.productCount = 0
formRef.value?.resetFields()
//
fetchProductList()
fetchProductGroupList()
//
nextTick(() => {
goodsTreeRef.value?.setCheckedKeys([])
})
}
//
@@ -641,80 +669,77 @@ const handleBatchDelete = () => {
//
const submitForm = () => {
//
if (dialogType.value === 'add') {
if (!form.code_id) {
ElMessage.warning('请选择代金券')
return
}
if (form.select_type === 'product' && !form.selected_product) {
ElMessage.warning('请选择商品')
return
}
if (form.select_type === 'product_group' && !form.selected_group) {
ElMessage.warning('请选择商品组')
return
}
if (!form.goods_id || !form.goods_name || !form.goods_type) {
ElMessage.warning('请先选择商品或商品组')
return
}
if (!form.code_id) {
ElMessage.warning('请选择代金券')
return
}
if (dialogType.value === 'add') {
//
const checkedNodes = goodsTreeRef.value?.getCheckedNodes() || []
const goodIds = checkedNodes.filter(n => n.nodeType === 'product').map(n => n.rawId)
const goodGroupIds = checkedNodes.filter(n => n.nodeType === 'group').map(n => n.rawId)
if (goodIds.length === 0 && goodGroupIds.length === 0) {
ElMessage.warning('请至少勾选一个商品或商品组')
return
}
submitAdd(goodIds, goodGroupIds)
return
}
//
formRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
code_id: String(form.code_id)
}
// good_id good_group_id
if (dialogType.value === 'add') {
if (form.select_type === 'product') {
// good_id
submitData.good_ids = String(form.goods_id)
} else if (form.select_type === 'product_group') {
// good_group_id
submitData.good_group_ids = String(form.goods_id)
}
} else {
// discount_good_id
submitData.discount_good_id = String(form.id)
// goods_type good_id good_group_id
if (form.goods_type === 'product') {
submitData.good_id = String(form.goods_id)
} else if (form.goods_type === 'product_group') {
submitData.good_group_id = String(form.goods_id)
} else {
// 使 good_id
submitData.good_id = String(form.goods_id)
}
}
console.log('提交商品关联数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await addDiscountGoods(submitData)
} else {
res = await updateDiscountGoods(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
if (!valid) return
try {
const submitData = {
code_id: String(form.code_id),
discount_good_id: String(form.id)
}
if (form.goods_type === 'product_group') {
submitData.good_group_id = String(form.goods_id)
} else {
submitData.good_id = String(form.goods_id)
}
const res = await updateDiscountGoods(submitData)
if (res.data.code === 200) {
ElMessage.success('修改成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
// good_ids / good_group_ids
const submitAdd = async (goodIds, goodGroupIds) => {
try {
const submitData = { code_id: String(form.code_id) }
if (goodIds.length > 0) {
submitData.good_ids = goodIds.join(',')
}
if (goodGroupIds.length > 0) {
submitData.good_group_ids = goodGroupIds.join(',')
}
console.log('提交商品关联数据:', submitData)
const res = await addDiscountGoods(submitData)
if (res.data.code === 200) {
ElMessage.success('新增成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
//
onMounted(() => {
fetchVoucherListOptions()
@@ -798,31 +823,55 @@ onMounted(() => {
padding: 0;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
/* 折叠层级选择器样式 */
.goods-tree-wrapper {
width: 100%;
border: 1px solid #e1e8ed;
border-radius: 6px;
overflow: hidden;
}
.goods-tree-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafbfc;
border-bottom: 1px solid #e1e8ed;
font-size: 12px;
color: #909399;
}
.tree-summary b {
color: #409eff;
}
.goods-tree {
max-height: 320px;
overflow-y: auto;
padding: 8px;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.tree-node-label {
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
.tree-node-id {
color: #909399;
font-size: 12px;
}
: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-node-price {
color: #f56c6c;
font-size: 12px;
font-weight: bold;
}
:deep(.el-card__body) {
+7 -30
View File
@@ -71,7 +71,8 @@
<el-table-column prop="discountId" label="代金券ID" width="120" v-if="!codeId" />
<el-table-column label="用户名" min-width="150">
<template #default="{ row }">
{{ row?.user?.user_name || '-' }}
<el-link v-if="row.userId && row?.user?.user_name" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.user.user_name }}</el-link>
<span v-else>{{ row?.user?.user_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="手机号" min-width="150">
@@ -239,6 +240,7 @@
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Search, Plus, Refresh, User } from '@element-plus/icons-vue'
import {
@@ -261,6 +263,8 @@ const props = defineProps({
}
})
const router = useRouter()
//
const queryParams = reactive({
code_id: props.codeId || '',
@@ -361,7 +365,7 @@ const fetchVoucherListOptions = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 1000,
count: 10,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
@@ -397,7 +401,7 @@ const fetchUserGroupList = async () => {
try {
const res = await getUserGroupList({
page: 1,
count: 10000,
count: 10,
key: ''
})
console.log('获取用户组列表:', res.data)
@@ -803,33 +807,6 @@ onMounted(() => {
padding: 0;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) {
padding: 0;
}
+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>
+49 -26
View File
@@ -177,30 +177,36 @@
</el-radio-group>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<el-form-item label="用户" prop="user_id" v-if="addForm.target_type === 'user'">
<div class="user-selector-wrapper">
<div class="selected-user-display" v-if="addForm.user_id">
<el-tag type="primary" closable @close="clearSelectedUser">
{{ getSelectedUserName() }}
</el-tag>
</div>
<el-button
type="primary"
plain
<el-input
:model-value="getSelectedUserName()"
placeholder="请选择用户"
readonly
@click="openUserSelector"
style="width: 100%"
>
<el-icon><User /></el-icon>
{{ addForm.user_id ? '重新选择用户' : '选择用户' }}
<template #append>
<el-button @click="openUserSelector">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button
v-if="addForm.user_id"
type="danger"
link
@click="clearSelectedUser"
class="clear-btn"
>
清除
</el-button>
</div>
</el-form-item>
<el-form-item label="用户组" prop="group_id">
<el-form-item label="用户组" prop="group_id" v-if="addForm.target_type === 'group'">
<el-select
v-model="addForm.group_id"
placeholder="请选择用户组"
:disabled="addForm.target_type === 'user'"
filterable
clearable
style="width: 100%"
@@ -353,7 +359,7 @@ const editForm = reactive({
const editRules = {
discount_id: [
{ required: true, message: '请输入代金券ID', trigger: 'blur' }
{ required: false, message: '请输入代金券ID', trigger: 'blur' }
],
use_times: [
{ required: true, message: '请输入已使用次数', trigger: 'blur' }
@@ -478,7 +484,7 @@ const fetchVoucherListOptions = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 1000,
count: 10,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
@@ -496,7 +502,7 @@ const fetchDiscountList = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 100,
count: 10,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
@@ -507,7 +513,7 @@ const fetchDiscountList = async () => {
}
const res2 = await getDiscountCodeList({
page: 1,
count: 100,
count: 10,
discount_type: 'code'
})
console.log('获取优惠码列表:', res2.data)
@@ -527,7 +533,7 @@ const fetchVoucherOptions = async () => {
const res = await getDiscountCodeList({
discount_type: 'coupon',
page: 1,
count: 100
count: 10
})
if (res.data.code === 200) {
voucherOptions.value = res.data.data?.data || []
@@ -543,7 +549,7 @@ const fetchCodeOptions = async () => {
const res = await getDiscountCodeList({
discount_type: 'code',
page: 1,
count: 100
count: 10
})
if (res.data.code === 200) {
codeOptions.value = res.data.data?.data || []
@@ -560,7 +566,7 @@ const fetchUserList = async () => {
try {
const res = await getUserList({
page: 1,
count: 100,
count: 10,
key: ''
})
console.log('获取用户列表:', res.data)
@@ -582,7 +588,7 @@ const fetchGroupOptions = async () => {
try {
const res = await getUserGroupList({
page: 1,
count: 100
count: 10
})
if (res.data.code === 200) {
groupOptions.value = res.data.data?.data || []
@@ -653,9 +659,9 @@ const confirmUserSelection = (user) => {
ElMessage.warning('请选择一个用户')
return
}
addForm.user_id = user.UserId
addForm.user_id = user.user_id
// userOptions
if (!userOptions.value.find(u => u.UserId === user.UserId)) {
if (!userOptions.value.find(u => u.user_id === user.user_id)) {
userOptions.value.push(user)
}
userSelectorVisible.value = false
@@ -668,8 +674,9 @@ const clearSelectedUser = () => {
//
const getSelectedUserName = () => {
const user = userOptions.value.find(u => u.UserId === addForm.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${addForm.user_id}`
if (!addForm.user_id) return ''
const user = userOptions.value.find(u => u.user_id === addForm.user_id)
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${addForm.user_id}`
}
//
@@ -940,5 +947,21 @@ onMounted(() => {
margin-top: 24px;
justify-content: flex-end;
}
/* 用户选择器样式 */
.user-selector-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.user-selector-wrapper .el-input {
flex: 1;
}
.user-selector-wrapper .clear-btn {
flex-shrink: 0;
}
</style>
-522
View File
@@ -1,522 +0,0 @@
<template>
<div class="voucher-container">
<!-- 搜索和操作栏 -->
<el-card class="filter-container" shadow="never">
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增代金券
</el-button>
<el-button type="success" @click="fetchVoucherList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</el-card>
<!-- 代金券列表 -->
<el-card class="table-container" shadow="never">
<el-table
v-loading="loading"
:data="voucherList"
@selection-change="handleSelectionChange"
style="width: 100%"
>
<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 label="面额" width="120">
<template #default="{ row }">
<span class="amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="最低消费" width="120">
<template #default="{ row }">
¥{{ (row.minAmount / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="最大抵扣" width="120">
<template #default="{ row }">
<span v-if="row.maxAmount">¥{{ (row.maxAmount / 100).toFixed(2) }}</span>
<span v-else>无限制</span>
</template>
</el-table-column>
<el-table-column prop="maxTimes" label="最大使用次数" width="130">
<template #default="{ row }">
{{ row.maxTimes || '无限制' }}
</template>
</el-table-column>
<el-table-column prop="userTimes" label="单用户次数" width="120">
<template #default="{ row }">
{{ row.userTimes || '无限制' }}
</template>
</el-table-column>
<el-table-column label="有效期(天)" width="100">
<template #default="{ row }">
{{ row.duration ? (row.duration / 86400).toFixed(0) : '-' }}
</template>
</el-table-column>
<el-table-column label="续费可用" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.renew" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link @click="handleManage(row)">管理</el-button>
<el-button type="success" link @click="handleView(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"
/>
</el-card>
<!-- 代金券表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增代金券' : '编辑代金券'"
width="700px"
>
<el-form
ref="voucherFormRef"
:model="voucherForm"
:rules="voucherRules"
label-width="140px"
>
<el-form-item label="代金券名称" prop="name">
<el-input v-model="voucherForm.name" placeholder="请输入代金券名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="面额(元)" prop="amount">
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="width: 100%" />
</el-form-item>
<el-form-item label="最低消费(元)" prop="min_amount">
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" />
</el-form-item>
<el-form-item label="最大抵扣(元)" prop="max_amount">
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="单用户最大次数" prop="user_times">
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="有效期(天)" prop="duration_days">
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="width: 100%" />
<div class="form-tip">代金券领取后的有效持续时间</div>
</el-form-item>
<el-form-item label="发放时间范围" prop="timeRange">
<el-date-picker
v-model="voucherForm.timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:teleported="true"
popper-class="voucher-date-picker"
placement="top-start"
:editable="true"
:clearable="true"
style="width: 100%"
@keyup.enter="handleDatePickerEnter"
/>
<div class="form-tip">代金券可以发放给用户的时间范围</div>
</el-form-item>
<el-form-item label="续费可用" prop="renew">
<el-switch v-model="voucherForm.renew" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="同类型可叠加" prop="can_stacking">
<el-switch v-model="voucherForm.can_stacking" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="其他类型可叠加" prop="can_combine">
<el-switch v-model="voucherForm.can_combine" active-text="是" inactive-text="否" />
</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>
<!-- 详情查看对话框 -->
<DiscountDetailDialog
v-model="detailDialogVisible"
type="coupon"
:detail-data="currentDetail"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Refresh, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import {
getDiscountCodeList,
getDiscountCodeDetail,
createDiscountCode,
updateDiscountCode,
deleteDiscountCode
} from '@/api/admin/discount'
import { timeToTimestamp } from '@/utils/tool'
import DiscountDetailDialog from '@/components/marketing/DiscountDetailDialog.vue'
const router = useRouter()
//
const queryParams = reactive({
discount_type: 'coupon', // coupon
page: 1,
count: 10
})
//
const voucherForm = reactive({
code_id: undefined,
discount_type: 'coupon', // coupon
name: '',
note: '',
amount: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
duration_days: 30, // 30
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
const voucherRules = {
name: [
{ required: true, message: '请输入代金券名称', trigger: 'blur' }
],
amount: [
{ required: true, message: '请输入面额', trigger: 'blur' }
],
duration_days: [
{ required: true, message: '请输入有效期天数', trigger: 'blur' }
]
}
//
const loading = ref(false)
const voucherList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogType = ref('add')
const voucherFormRef = ref(null)
const detailDialogVisible = ref(false)
const currentDetail = ref(null)
//
const fetchVoucherList = async () => {
loading.value = true
try {
const res = await getDiscountCodeList(queryParams)
console.log('代金券列表数据:', res.data)
if (res.data.code === 200) {
voucherList.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 handleSelectionChange = (selection) => {
selectedRows.value = selection
}
//
const handleSizeChange = (size) => {
queryParams.count = size
fetchVoucherList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchVoucherList()
}
//
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(voucherForm, {
code_id: undefined,
discount_type: 'coupon',
name: '',
note: '',
amount: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
duration_days: 30,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
voucherFormRef.value?.resetFields()
}
//
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
//
const startTime = row.startTime ? new Date(row.startTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
const endTime = row.endTime ? new Date(row.endTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
Object.assign(voucherForm, {
code_id: row.id,
discount_type: 'coupon',
name: row.name,
note: row.note || '',
amount: row.amount ? row.amount / 100 : 0,
min_amount: row.minAmount ? row.minAmount / 100 : 0,
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
max_times: row.maxTimes || 0,
user_times: row.userTimes || 0,
duration_days: row.duration ? row.duration / 86400 : 30, //
timeRange: startTime && endTime ? [startTime, endTime] : [],
renew: row.renew || false,
can_stacking: row.canStacking || false,
can_combine: row.canCombine || false
})
}
//
const handleManage = (row) => {
router.push(`/marketing/voucher/${row.id}/manage`)
}
//
const handleView = async (row) => {
try {
const res = await getDiscountCodeDetail({ code_id: row.id })
console.log('代金券详情:', res.data)
if (res.data.code === 200) {
currentDetail.value = res.data.data
detailDialogVisible.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 deleteDiscountCode({ code_id: row.id })
console.log('删除响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchVoucherList()
}
} 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 () => {
loading.value = true
try {
const deletePromises = selectedRows.value.map(row =>
deleteDiscountCode({ code_id: row.id })
)
const results = await Promise.allSettled(deletePromises)
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
const failCount = results.length - successCount
if (failCount === 0) {
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
} else if (successCount === 0) {
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
} else {
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount}`)
}
fetchVoucherList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除操作异常')
} finally {
loading.value = false
}
}).catch(() => {})
}
//
const handleDatePickerEnter = (event) => {
//
const datePicker = event.target.closest('.el-date-editor')
if (datePicker) {
//
event.target.blur()
}
}
//
const submitForm = () => {
voucherFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
discount_type: 'coupon',
name: voucherForm.name,
note: voucherForm.note,
amount: Math.round(voucherForm.amount * 100),
percentage: 0, // 0
min_amount: Math.round(voucherForm.min_amount * 100),
max_amount: Math.round(voucherForm.max_amount * 100),
max_times: voucherForm.max_times || 0,
user_times: voucherForm.user_times || 0,
duration: voucherForm.duration_days * 86400, //
renew: voucherForm.renew,
can_stacking: voucherForm.can_stacking,
can_combine: voucherForm.can_combine
}
//
if (voucherForm.timeRange && voucherForm.timeRange.length === 2) {
submitData.start_time = timeToTimestamp(voucherForm.timeRange[0])
submitData.end_time = timeToTimestamp(voucherForm.timeRange[1])
} else {
submitData.start_time = ''
submitData.end_time = ''
}
// code_id
if (dialogType.value === 'edit') {
submitData.code_id = voucherForm.code_id
}
console.log('提交代金券数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await createDiscountCode(submitData)
} else {
res = await updateDiscountCode(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchVoucherList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
})
}
//
onMounted(() => {
fetchVoucherList()
})
</script>
<style scoped>
.voucher-container {
padding: 0;
}
.filter-container {
margin-bottom: 20px;
border-radius: 8px;
}
.action-bar {
display: flex;
gap: 12px;
}
.table-container {
border-radius: 8px;
}
.amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.pagination {
margin-top: 24px;
justify-content: flex-end;
}
</style>
<style>
/* 时间选择器弹出层样式 - 非 scoped */
.voucher-date-picker {
z-index: 9999 !important;
}
.voucher-date-picker .el-picker-panel {
max-width: 90vw;
}
</style>
+17 -4
View File
@@ -43,7 +43,12 @@
stripe
>
<el-table-column prop="id" label="记录ID" width="80" fixed="left" />
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column label="用户ID" width="100">
<template #default="{ row }">
<el-link v-if="row.user_id" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.user_id } })">{{ row.user_id }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="discount_id" label="代金券ID" width="120" v-if="!codeId" />
@@ -58,7 +63,12 @@
<span>¥{{ row.order_amount ? (row.order_amount / 100).toFixed(2) : '0.00' }}</span>
</template>
</el-table-column>
<el-table-column prop="order_id" label="订单ID" width="150" />
<el-table-column label="订单ID" width="150">
<template #default="{ row }">
<el-link v-if="row.order_id" type="primary" :underline="false" @click="router.push({ path: '/order/list', query: { key: row.order_id } })">{{ row.order_id }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="使用状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
@@ -174,6 +184,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, Refresh, Download } from '@element-plus/icons-vue'
import { getUserVoucherHistory, getDiscountCodeList } from '@/api/admin/discount'
@@ -187,6 +198,8 @@ const props = defineProps({
}
})
const router = useRouter()
//
const queryParams = reactive({
user_id: undefined,
@@ -397,7 +410,7 @@ const fetchUserList = async () => {
try {
const res = await getUserList({
page: 1,
count: 10000,
count: 10,
key: ''
})
UserOptions.value = res.data.data?.data || []
@@ -412,7 +425,7 @@ const fetchDiscountList = async () => {
const res = await getDiscountCodeList({
discount_type: 'coupon',
page: 1,
count: 1000
count: 10
})
if (res.data.code === 200) {
discountOptions.value = res.data.data?.data || []
+10 -2
View File
@@ -40,7 +40,12 @@
style="width: 100%"
>
<el-table-column prop="Id" label="ID" width="80" />
<el-table-column prop="UserId" label="用户ID" min-width="100" />
<el-table-column label="用户ID" min-width="100">
<template #default="{ row }">
<el-link v-if="row.UserId" type="primary" :underline="false" @click="router.push({ path: '/user/detail', query: { user_id: row.UserId } })">{{ row.UserId }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="代金券ID" min-width="110" v-if="!codeId">
<template #default="{ row }">
{{ row.discountId || '-' }}
@@ -212,6 +217,7 @@
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
import {
@@ -230,6 +236,8 @@ const props = defineProps({
}
})
const router = useRouter()
//
const queryParams = reactive({
user_id: undefined,
@@ -459,7 +467,7 @@ const fetchDiscountList = async () => {
const res = await getDiscountCodeList({
discount_type: 'coupon',
page: 1,
count: 1000
count: 10
})
if (res.data.code === 200) {
discountOptions.value = res.data.data?.data || []
+1 -1
View File
@@ -50,7 +50,7 @@ const activeTab = ref('user-distribution')
const voucherId = computed(() => route.params.id)
const goBack = () => {
router.push('/marketing/voucher')
router.push('/product/discount')
}
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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,
Phone, OfficeBuilding, UploadFilled
} from '@element-plus/icons-vue'
import { useUserStore } from '@/store/userStore.js'
//
const isEditing = ref(false)
const loading = ref(false)
const userStore = useUserStore()
// localStoragestore
const getSavedUserInfo = () => {
const savedInfo = userStore.userInfo
if (savedInfo && Object.keys(savedInfo).length > 0) {
return {
username: savedInfo.user_name || '',
realName: savedInfo.real_name?.Name || savedInfo.user_name || '',
email: savedInfo.email || '',
phone: savedInfo.phone || '',
department: savedInfo.admin_group?.name || '',
position: savedInfo.is_admin ? '管理员' : '普通用户',
role: savedInfo.admin_group?.name || '普通用户',
createTime: savedInfo.created_at || '',
lastLogin: savedInfo.created_at || '',
bio: savedInfo.admin_group?.note || '',
avatar: savedInfo.cover || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
sex: savedInfo.sex || '',
age: savedInfo.age || '',
userId: savedInfo.user_id || '',
userGroup: savedInfo.user_group?.Name || ''
}
}
return {
username: '',
realName: '',
email: '',
phone: '',
department: '',
position: '',
role: '',
createTime: '',
lastLogin: '',
bio: '',
avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
sex: '',
age: '',
userId: '',
userGroup: ''
}
}
//
const userInfo = reactive({
username: 'admin',
realName: '管理员',
email: 'admin@example.com',
phone: '13800138000',
department: '技术部',
position: '系统管理员',
role: '超级管理员',
createTime: '2023-01-01 00:00:00',
lastLogin: '2023-06-15 10:30:45',
bio: '系统管理员,负责系统的日常维护和管理工作。拥有丰富的系统管理经验,精通Linux服务器配置和维护,熟悉网络安全,对系统性能优化有独到见解。',
avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
})
const userInfo = reactive(getSavedUserInfo())
//
const userForm = reactive({...userInfo})
@@ -296,9 +328,9 @@ const handleAvatarSuccess = (res) => {
//
const fetchUserInfo = async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
// userInfo
// store
const savedInfo = getSavedUserInfo()
Object.assign(userInfo, savedInfo)
} catch (error) {
ElMessage.error('获取用户信息失败')
console.error(error)
+892
View File
@@ -0,0 +1,892 @@
<template>
<div class="sms-goods-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#e6a23c" stroke-width="1.8">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div>
<h2 class="header-title">短信额度商品管理</h2>
<p class="header-desc">
管理短信平台的额度商品配置
<template v-if="filterServiceName">
当前筛选<el-tag size="small" type="warning">{{ filterServiceName }}</el-tag>
</template>
</p>
</div>
</div>
<div class="header-actions">
<el-button @click="router.push('/sms/service')">
<el-icon><Back /></el-icon> 返回服务列表
</el-button>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增商品
</el-button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select
v-model="queryParams.service_id"
placeholder="筛选主控服务"
clearable
style="width: 220px"
@change="handleSearch"
>
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-input
v-model="queryParams.key"
placeholder="搜索商品名称"
clearable
style="width: 240px"
@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 @click="handleReset">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="关联服务" min-width="140">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="goToService(row.serviceId)">
{{ resolveServiceName(row.serviceId) }}
</el-button>
</template>
</el-table-column>
<el-table-column label="关联商品" min-width="150">
<template #default="{ row }">
<span class="goods-name">{{ row.good?.name || `商品#${row.goodId}` }}</span>
</template>
</el-table-column>
<el-table-column label="额度类型" width="120" align="center">
<template #default="{ row }">
<el-tag :type="quotaTypeTag(row.quotaType).type" effect="dark" size="small">
{{ quotaTypeTag(row.quotaType).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="有效期/周期配置" min-width="200">
<template #default="{ row }">
<template v-if="row.quotaType === 2">
<span class="config-label">有效期模式</span>
<el-tag size="small" type="info">{{ row.expireMode === 'fixed' ? '固定' : '用户选择' }}</el-tag>
</template>
<template v-else-if="row.quotaType === 3">
<span class="config-label">周期模式</span>
<el-tag size="small" type="info">{{ row.cycleMode === 'fixed' ? '固定' : '用户选择' }}</el-tag>
<template v-if="row.cycleMode === 'fixed'">
<span class="config-detail">{{ row.cycleValue || 1 }}{{ cycleUnitLabel(row.cycleUnit) }}</span>
</template>
</template>
<span v-else class="config-detail">永久有效</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-text">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="创建时间" width="170" align="center">
<template #default="{ row }">{{ formatTime(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-popconfirm title="确认删除该商品绑定?" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
<!-- 新增弹窗 -->
<el-dialog
v-model="addDialogVisible"
title="新增短信额度商品"
width="680px"
destroy-on-close
>
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="130px">
<el-divider content-position="left">基本信息</el-divider>
<el-form-item label="关联主控服务" prop="service_id">
<el-select v-model="addForm.service_id" placeholder="选择短信主控服务" style="width: 100%">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="商品分组" prop="good_group_id">
<el-tree-select
v-model="addForm.good_group_id"
:data="groupTreeData"
:props="groupTreeProps"
lazy
:load="loadGroupChildren"
node-key="id"
placeholder="选择商品分组"
clearable
check-strictly
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="商品名称" prop="name">
<el-input v-model="addForm.name" placeholder="如:短信1000条包" />
</el-form-item>
<el-form-item label="商品介绍">
<el-input v-model="addForm.content" type="textarea" :rows="2" placeholder="商品介绍(可选,系统自动追加配额说明)" />
</el-form-item>
<el-form-item label="管理备注">
<el-input v-model="addForm.note" placeholder="管理备注(可选)" />
</el-form-item>
<el-divider content-position="left">额度配置</el-divider>
<el-form-item label="额度类型" prop="quota_type">
<div class="quota-type-cards">
<div
v-for="qt in quotaTypes"
:key="qt.value"
class="qt-card"
:class="{ active: addForm.quota_type === qt.value }"
@click="addForm.quota_type = qt.value"
>
<div class="qt-icon" v-html="qt.icon"></div>
<div class="qt-label">{{ qt.label }}</div>
<div class="qt-desc">{{ qt.desc }}</div>
</div>
</div>
</el-form-item>
<el-form-item label="额度数量模式" prop="quota_value_type">
<el-radio-group v-model="addForm.quota_value_type">
<el-radio value="number">数值范围用户输入数值</el-radio>
<el-radio value="select">固定选项用户选择档位</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="addForm.quota_value_type === 'number'">
<el-form-item label="最小值">
<el-input-number v-model="addForm.quota_min" :min="1" :step="100" />
</el-form-item>
<el-form-item label="最大值">
<el-input-number v-model="addForm.quota_max" :min="1" :step="100" />
</el-form-item>
<el-form-item label="步长">
<el-input-number v-model="addForm.quota_step" :min="1" :step="10" />
</el-form-item>
<el-form-item label="单条价格" prop="quota_unit_price">
<el-input-number v-model="addForm.quota_unit_price" :min="0" :step="0.01" :precision="2" controls-position="right" />
<span class="opt-unit">/</span>
</el-form-item>
</template>
<template v-else>
<el-form-item label="额度选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.quota_options_list" :key="idx" class="option-row">
<el-input-number v-model="item.value" :min="1" placeholder="数量" controls-position="right" class="opt-value" />
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-input-number v-model="item.price" :min="0" :step="0.01" :precision="2" placeholder="价格" controls-position="right" class="opt-price" />
<span class="opt-unit"></span>
<el-button link type="danger" @click="addForm.quota_options_list.splice(idx, 1)" :disabled="addForm.quota_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.quota_options_list.push({ value: null, label: '', price: null })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
</template>
<!-- 短期额度配置 -->
<template v-if="addForm.quota_type === 2">
<el-divider content-position="left">有效期配置</el-divider>
<el-form-item label="有效期模式" prop="expire_mode">
<el-radio-group v-model="addForm.expire_mode">
<el-radio value="fixed">固定天数管理员指定</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="addForm.expire_mode === 'fixed'" label="有效天数">
<el-input-number v-model="addForm.expire_fixed" :min="1" />
<span style="margin-left: 8px; color: #909399"></span>
</el-form-item>
<el-form-item v-if="addForm.expire_mode === 'select'" label="有效期选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.expire_options_list" :key="idx" class="option-row">
<el-input-number v-model="item.value" :min="1" placeholder="天数" controls-position="right" class="opt-value" />
<span class="opt-unit"></span>
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-button link type="danger" @click="addForm.expire_options_list.splice(idx, 1)" :disabled="addForm.expire_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.expire_options_list.push({ value: null, label: '' })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
</template>
<!-- 周期额度配置 -->
<template v-if="addForm.quota_type === 3">
<el-divider content-position="left">周期配置</el-divider>
<el-form-item label="周期单位模式" prop="cycle_mode">
<el-radio-group v-model="addForm.cycle_mode">
<el-radio value="fixed">固定单位管理员指定</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="addForm.cycle_mode === 'fixed'" label="周期单位">
<el-select v-model="addForm.cycle_unit" style="width: 160px">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
</el-form-item>
<el-form-item v-if="addForm.cycle_mode === 'select'" label="周期选项">
<div class="dynamic-options">
<div v-for="(item, idx) in addForm.cycle_options_list" :key="idx" class="option-row">
<el-select v-model="item.value" placeholder="周期单位" class="opt-cycle-unit">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
<el-button link type="danger" @click="addForm.cycle_options_list.splice(idx, 1)" :disabled="addForm.cycle_options_list.length <= 1">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" @click="addForm.cycle_options_list.push({ value: '', label: '' })">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</el-form-item>
<el-form-item label="周期数值" prop="cycle_value">
<el-input-number v-model="addForm.cycle_value" :min="1" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmitAdd">确定</el-button>
</template>
</el-dialog>
<!-- 编辑弹窗仅可修改 SmsGoods 自身字段 -->
<el-dialog
v-model="editDialogVisible"
title="编辑短信额度商品"
width="580px"
destroy-on-close
>
<el-form ref="editFormRef" :model="editForm" label-width="130px">
<el-form-item label="管理备注">
<el-input v-model="editForm.note" placeholder="管理备注" />
</el-form-item>
<el-form-item label="额度类型">
<div class="quota-type-cards small">
<div
v-for="qt in quotaTypes"
:key="qt.value"
class="qt-card"
:class="{ active: editForm.quota_type === qt.value }"
@click="editForm.quota_type = qt.value"
>
<div class="qt-icon" v-html="qt.icon"></div>
<div class="qt-label">{{ qt.label }}</div>
</div>
</div>
</el-form-item>
<template v-if="editForm.quota_type === 2">
<el-form-item label="有效期模式">
<el-radio-group v-model="editForm.expire_mode">
<el-radio value="fixed">固定天数</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
</template>
<template v-if="editForm.quota_type === 3">
<el-form-item label="周期单位模式">
<el-radio-group v-model="editForm.cycle_mode">
<el-radio value="fixed">固定单位</el-radio>
<el-radio value="select">用户选择</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="editForm.cycle_mode === 'fixed'" label="周期单位">
<el-select v-model="editForm.cycle_unit" style="width: 160px">
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
</el-form-item>
<el-form-item label="周期数值">
<el-input-number v-model="editForm.cycle_value" :min="1" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmitEdit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Search, Back, Delete } from '@element-plus/icons-vue'
import {
getSmsServiceList,
getSmsGoodsList,
createSmsGoods,
updateSmsGoods,
deleteSmsGoods
} from '@/api/admin/smsService.js'
import { getProductGroupList } from '@/api/admin/product'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const serviceOptions = ref([])
const serviceMap = ref({})
const addDialogVisible = ref(false)
const editDialogVisible = ref(false)
const addFormRef = ref(null)
const editFormRef = ref(null)
const groupTreeData = ref([])
const groupTreeProps = { label: 'name', children: 'children', isLeaf: (data) => !data.existSub }
const filterServiceName = computed(() => route.query.service_name || '')
const queryParams = reactive({
page: 1,
count: 10,
service_id: route.query.service_id ? parseInt(route.query.service_id) : undefined,
key: ''
})
const quotaTypes = [
{
value: 1, label: '长期',
desc: '永久有效,一次购买持续可用',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'
},
{
value: 2, label: '短期',
desc: '限时有效,到期后额度失效',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>'
},
{
value: 3, label: '周期',
desc: '按周期自动重置额度',
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'
}
]
const defaultAddForm = () => ({
service_id: queryParams.service_id || null,
good_group_id: null,
name: '',
content: '',
note: '',
quota_type: 1,
quota_value_type: 'number',
quota_options_list: [{ value: null, label: '', price: null }],
quota_min: 100,
quota_max: 100000,
quota_step: 100,
quota_unit_price: 0.05,
expire_mode: 'fixed',
expire_fixed: 30,
expire_options_list: [{ value: null, label: '' }],
cycle_mode: 'fixed',
cycle_unit: 'month',
cycle_value: 1,
cycle_options_list: [{ value: '', label: '' }]
})
const serializeOptions = (list, withPrice = false) => {
return list
.filter(item => item.value !== null && item.value !== '' && item.label)
.map(item => withPrice ? `${item.value}:${item.label}:${item.price ?? 0}` : `${item.value}:${item.label}`)
.join(',')
}
const addForm = ref(defaultAddForm())
const editForm = ref({
id: null,
note: '',
quota_type: 1,
expire_mode: '',
cycle_mode: '',
cycle_unit: '',
cycle_value: 1
})
const addRules = {
service_id: [{ required: true, message: '请选择主控服务', trigger: 'change' }],
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
quota_type: [{ required: true, message: '请选择额度类型', trigger: 'change' }]
}
const formatTime = (t) => {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const quotaTypeTag = (type) => {
const map = { 1: { label: '长期', type: 'success' }, 2: { label: '短期', type: 'warning' }, 3: { label: '周期', type: '' } }
return map[type] || { label: '未知', type: 'info' }
}
const cycleUnitLabel = (unit) => {
const map = { day: '天', week: '周', month: '月', year: '年' }
return map[unit] || unit
}
const resolveServiceName = (id) => {
return serviceMap.value[id] || `服务#${id}`
}
const goToService = (serviceId) => {
router.push({ path: '/sms/service' })
}
const loadGroupOptions = async () => {
try {
const res = await getProductGroupList({ level: 1, count: 199 })
if (res.data.code === 200) {
groupTreeData.value = (res.data.data?.data || res.data.data || []).map(item => ({
...item,
existSub: !!item.existSub
}))
}
} catch (e) {
console.error(e)
}
}
const loadGroupChildren = async (node, resolve) => {
if (node.level === 0) return resolve(groupTreeData.value)
try {
const res = await getProductGroupList({ parent_id: node.data.id, level: node.data.level + 1, count: 199 })
if (res.data.code === 200) {
const children = (res.data.data?.data || res.data.data || []).map(item => ({
...item,
existSub: !!item.existSub
}))
resolve(children)
} else {
resolve([])
}
} catch (e) {
resolve([])
}
}
const loadServiceOptions = async () => {
try {
const res = await getSmsServiceList({ page: 1, count: 199 })
const body = res.data
if (body.code === 200) {
const list = body.data?.data || body.data || []
serviceOptions.value = Array.isArray(list) ? list : []
const map = {}
serviceOptions.value.forEach(s => { map[s.id] = s.name })
serviceMap.value = map
}
} catch (e) {
console.error(e)
}
}
const fetchList = async () => {
loading.value = true
try {
const params = { ...queryParams }
if (!params.service_id) delete params.service_id
const res = await getSmsGoodsList(params)
const body = res.data
if (body.code === 200) {
const d = body.data?.data || body.data || []
tableData.value = Array.isArray(d) ? d : []
total.value = body.data?.all_count || 0
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page = 1
fetchList()
}
const handleReset = () => {
queryParams.key = ''
queryParams.service_id = undefined
queryParams.page = 1
fetchList()
}
const handleAdd = () => {
addForm.value = defaultAddForm()
addDialogVisible.value = true
}
const handleSubmitAdd = async () => {
await addFormRef.value?.validate()
submitting.value = true
try {
const params = new URLSearchParams()
params.append('service_id', addForm.value.service_id)
if (addForm.value.good_group_id) params.append('good_group_id', addForm.value.good_group_id)
params.append('name', addForm.value.name)
if (addForm.value.content) params.append('content', addForm.value.content)
if (addForm.value.note) params.append('note', addForm.value.note)
params.append('quota_type', addForm.value.quota_type)
params.append('quota_value_type', addForm.value.quota_value_type)
if (addForm.value.quota_value_type === 'number') {
params.append('quota_min', addForm.value.quota_min)
params.append('quota_max', addForm.value.quota_max)
params.append('quota_step', addForm.value.quota_step)
params.append('quota_unit_price', addForm.value.quota_unit_price)
} else {
params.append('quota_options', serializeOptions(addForm.value.quota_options_list, true))
}
if (addForm.value.quota_type === 2) {
params.append('expire_mode', addForm.value.expire_mode)
if (addForm.value.expire_mode === 'fixed') {
params.append('expire_fixed', addForm.value.expire_fixed)
} else {
params.append('expire_options', serializeOptions(addForm.value.expire_options_list))
}
}
if (addForm.value.quota_type === 3) {
params.append('cycle_mode', addForm.value.cycle_mode)
params.append('cycle_value', addForm.value.cycle_value)
if (addForm.value.cycle_mode === 'fixed') {
params.append('cycle_unit', addForm.value.cycle_unit)
} else {
params.append('cycle_options', serializeOptions(addForm.value.cycle_options_list))
}
}
const res = await createSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('创建成功')
addDialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '创建失败')
}
} catch (e) {
ElMessage.error('创建失败')
} finally {
submitting.value = false
}
}
const handleEdit = (row) => {
editForm.value = {
id: row.id,
note: row.note || '',
quota_type: row.quotaType,
expire_mode: row.expireMode || 'fixed',
cycle_mode: row.cycleMode || 'fixed',
cycle_unit: row.cycleUnit || 'month',
cycle_value: row.cycleValue || 1
}
editDialogVisible.value = true
}
const handleSubmitEdit = async () => {
submitting.value = true
try {
const params = new URLSearchParams()
params.append('id', editForm.value.id)
if (editForm.value.note) params.append('note', editForm.value.note)
params.append('quota_type', editForm.value.quota_type)
if (editForm.value.quota_type === 2) {
params.append('expire_mode', editForm.value.expire_mode)
}
if (editForm.value.quota_type === 3) {
params.append('cycle_mode', editForm.value.cycle_mode)
params.append('cycle_value', editForm.value.cycle_value)
if (editForm.value.cycle_mode === 'fixed') {
params.append('cycle_unit', editForm.value.cycle_unit)
}
}
const res = await updateSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('更新成功')
editDialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '更新失败')
}
} catch (e) {
ElMessage.error('更新失败')
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await deleteSmsGoods(params)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
onMounted(async () => {
await Promise.all([loadServiceOptions(), loadGroupOptions()])
fetchList()
})
</script>
<style scoped>
.sms-goods-page {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, #fef9f0 0%, #fdf2e4 100%);
border-radius: 12px;
border: 1px solid #f0dfc8;
}
.header-info {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(230, 162, 60, 0.15);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0 0 4px;
}
.header-desc {
font-size: 13px;
color: #909399;
margin: 0;
}
.header-actions {
display: flex;
gap: 10px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
background: #fafbfc;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.goods-name {
font-weight: 500;
color: #303133;
}
.config-label {
color: #909399;
font-size: 12px;
margin-right: 4px;
}
.config-detail {
color: #606266;
font-size: 13px;
margin-left: 6px;
}
.note-text {
color: #909399;
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* 额度类型卡片 */
.quota-type-cards {
display: flex;
gap: 12px;
}
.quota-type-cards.small .qt-card {
padding: 10px 16px;
}
.qt-card {
flex: 1;
padding: 16px;
border: 2px solid #ebeef5;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
background: #fafbfc;
}
.qt-card:hover {
border-color: #c0c4cc;
background: #fff;
}
.qt-card.active {
border-color: #409eff;
background: #f0f7ff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.qt-icon {
display: flex;
justify-content: center;
margin-bottom: 8px;
color: #909399;
}
.qt-card.active .qt-icon {
color: #409eff;
}
.qt-label {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.qt-desc {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.form-hint {
font-size: 12px;
color: #c0c4cc;
margin-top: 4px;
}
/* 动态选项列表 */
.dynamic-options {
width: 100%;
}
.option-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.option-row:last-of-type {
margin-bottom: 10px;
}
.opt-value {
width: 140px;
flex-shrink: 0;
}
.opt-unit {
font-size: 13px;
color: #909399;
flex-shrink: 0;
}
.opt-label {
flex: 1;
min-width: 0;
}
.opt-price {
width: 120px;
flex-shrink: 0;
}
.opt-cycle-unit {
width: 120px;
flex-shrink: 0;
}
</style>
+410
View File
@@ -0,0 +1,410 @@
<template>
<div class="sms-service-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path d="M8 9h8M8 13h4" stroke-linecap="round"/>
</svg>
</div>
<div>
<h2 class="header-title">短信主控服务管理</h2>
<p class="header-desc">管理短信平台的主控服务实例每个服务对应一个 sms-server 节点</p>
</div>
</div>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增服务
</el-button>
</div>
<!-- 搜索栏 -->
<div class="filter-bar">
<el-input
v-model="queryParams.key"
placeholder="搜索名称 / 说明 / 地址"
clearable
style="width: 300px"
@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 @click="handleReset">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="服务名称" min-width="150">
<template #default="{ row }">
<div class="service-name-cell">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#67c23a" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8M12 17v4"/>
</svg>
<span class="name-text">{{ row.name }}</span>
<el-tag v-if="row.default" type="success" size="small" effect="dark" style="margin-left: 6px">默认</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="note-text">{{ row.note || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="host" label="服务地址" min-width="250">
<template #default="{ row }">
<el-tag type="info" effect="plain" class="host-tag">{{ row.host }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="serviceToken" label="Service Token" min-width="200">
<template #default="{ row }">
<div class="token-cell">
<span v-if="!row._showToken" class="token-mask">{{ maskToken(row.serviceToken) }}</span>
<span v-else class="token-full">{{ row.serviceToken }}</span>
<el-button link size="small" @click="row._showToken = !row._showToken">
{{ row._showToken ? '隐藏' : '显示' }}
</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="创建时间" width="170" align="center">
<template #default="{ row }">{{ formatTime(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="320" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="success" size="small" @click="openConsole(row)">控制台</el-button>
<el-button v-if="!row.default" link type="warning" size="small" @click="handleSetDefault(row)">设为默认</el-button>
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="goToGoods(row)">额度商品</el-button>
<el-popconfirm :title="row.default ? '该服务为默认服务,删除前请先设置其他服务为默认' : '确认删除该服务?'" :disabled="row.default" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" size="small" :disabled="row.default">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑短信主控服务' : '新增短信主控服务'"
width="560px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="服务名称" prop="name">
<el-input v-model="form.name" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="说明" prop="note">
<el-input v-model="form.note" type="textarea" :rows="2" placeholder="服务说明(可选)" />
</el-form-item>
<el-form-item label="服务地址" prop="host">
<el-input v-model="form.host" placeholder="https://sms.example.com" />
</el-form-item>
<el-form-item label="Service Token" prop="service_token">
<el-input v-model="form.service_token" placeholder="sms-server 的 SERVICE_TOKEN" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import {
getSmsServiceList,
createSmsService,
updateSmsService,
deleteSmsService,
setDefaultSmsService
} from '@/api/admin/smsService.js'
const router = useRouter()
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const queryParams = reactive({
page: 1,
count: 10,
key: ''
})
const form = ref({
id: null,
name: '',
note: '',
host: '',
service_token: ''
})
const rules = {
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
service_token: [{ required: true, message: '请输入 Service Token', trigger: 'blur' }]
}
const formatTime = (t) => {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const maskToken = (token) => {
if (!token) return '-'
if (token.length <= 8) return '****'
return token.slice(0, 4) + '****' + token.slice(-4)
}
const fetchList = async () => {
loading.value = true
try {
const res = await getSmsServiceList(queryParams)
const body = res.data
if (body.code === 200) {
const d = body.data?.data || body.data || []
tableData.value = (Array.isArray(d) ? d : []).map(item => ({ ...item, _showToken: false }))
total.value = body.data?.all_count || 0
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page = 1
fetchList()
}
const handleReset = () => {
queryParams.key = ''
queryParams.page = 1
fetchList()
}
const handleAdd = () => {
isEdit.value = false
form.value = { id: null, name: '', note: '', host: '', service_token: '' }
dialogVisible.value = true
}
const handleEdit = (row) => {
isEdit.value = true
form.value = {
id: row.id,
name: row.name,
note: row.note || '',
host: row.host,
service_token: row.serviceToken || ''
}
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value?.validate()
submitting.value = true
try {
const params = new URLSearchParams()
if (isEdit.value) params.append('id', form.value.id)
params.append('name', form.value.name)
params.append('note', form.value.note)
params.append('host', form.value.host)
params.append('service_token', form.value.service_token)
const fn = isEdit.value ? updateSmsService : createSmsService
const res = await fn(params)
if (res.data.code === 200) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
fetchList()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
} finally {
submitting.value = false
}
}
const handleDelete = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await deleteSmsService(params)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}
const handleSetDefault = async (row) => {
try {
const params = new URLSearchParams()
params.append('id', row.id)
const res = await setDefaultSmsService(params)
if (res.data.code === 200) {
ElMessage.success(`已将「${row.name}」设为默认短信服务`)
fetchList()
} else {
ElMessage.error(res.data.message || '设置失败')
}
} catch (e) {
ElMessage.error('设置默认服务失败')
}
}
const openConsole = (row) => {
const base = (row.host || '').replace(/\/+$/, '')
if (!base) return ElMessage.warning('该服务未配置地址')
window.open(`${base}/login?serverToken=${encodeURIComponent(row.serviceToken || '')}`, '_blank')
}
const goToGoods = (row) => {
router.push({ path: '/sms/goods', query: { service_id: row.id, service_name: row.name } })
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.sms-service-page {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4f8 100%);
border-radius: 12px;
border: 1px solid #e0ecf5;
}
.header-info {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0 0 4px;
}
.header-desc {
font-size: 13px;
color: #909399;
margin: 0;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
background: #fafbfc;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.service-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.name-text {
font-weight: 500;
color: #303133;
}
.note-text {
color: #909399;
font-size: 13px;
}
.host-tag {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.token-cell {
display: flex;
align-items: center;
gap: 8px;
}
.token-mask {
font-family: 'Consolas', 'Monaco', monospace;
color: #c0c4cc;
letter-spacing: 1px;
}
.token-full {
font-family: 'Consolas', 'Monaco', monospace;
color: #606266;
font-size: 12px;
word-break: break-all;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+549
View File
@@ -0,0 +1,549 @@
<template>
<div class="sms-signature-page">
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#67c23a" stroke-width="1.8">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15l2 2 4-4"/>
</svg>
</div>
<div>
<h2 class="header-title">短信签名管理</h2>
<p class="header-desc">管理用户提交的短信签名审核通过后方可使用</p>
</div>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增签名
</el-button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="queryParams.service_id" placeholder="选择主控服务" style="width: 200px" @change="handleSearch">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-select v-model="queryParams.status" placeholder="全部状态" clearable style="width: 140px" @change="handleSearch">
<el-option label="草稿" :value="0" />
<el-option label="审核中" :value="1" />
<el-option label="已通过" :value="2" />
<el-option label="已驳回" :value="3" />
</el-select>
<el-input
v-if="filterUserInfo"
:model-value="`${filterUserInfo.user_name} (ID: ${queryParams.user_id})`"
readonly
style="width: 200px"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearFilterUser"><Close /></el-icon>
</template>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-input v-else placeholder="筛选用户" readonly style="width: 140px" @click="filterUserSelectorVisible = true">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 卡片列表 -->
<div v-loading="loading" class="signature-cards">
<div v-if="!tableData.length && !loading" class="empty-state">
<el-empty description="暂无签名数据" :image-size="80" />
</div>
<div v-for="item in tableData" :key="item.ID" class="sig-card" :class="`status-${item.status}`">
<div class="sig-card-header">
<div class="sig-card-title">
<span class="sig-title-text">{{ item.title }}</span>
<el-tag :type="statusTagType(item.status)" size="small" effect="dark">{{ statusText(item.status) }}</el-tag>
</div>
<span class="sig-card-id">#{{ item.ID }}</span>
</div>
<div class="sig-card-body">
<div class="sig-card-info">
<div class="sig-info-row">
<span class="sig-label">申请人</span>
<span class="sig-value">{{ item.applicant_name || '-' }}</span>
</div>
<div class="sig-info-row">
<span class="sig-label">用户</span>
<el-link type="primary" :underline="false" @click="$router.push({ path: '/user/detail', query: { user_id: item.user_id } })">ID: {{ item.user_id }}</el-link>
</div>
<div class="sig-info-row">
<span class="sig-label">公司</span>
<span class="sig-value">{{ item.applicant_company || '-' }}</span>
</div>
<div class="sig-info-row">
<span class="sig-label">身份证</span>
<span class="sig-value sig-id-card">{{ item.applicant_id_card || '-' }}</span>
</div>
<div class="sig-info-row">
<span class="sig-label">创建时间</span>
<span class="sig-value">{{ formatDate(item.CreatedAt) }}</span>
</div>
<div v-if="item.reject_reason" class="sig-info-row reject-row">
<span class="sig-label">驳回原因</span>
<span class="sig-value reject-text">{{ item.reject_reason }}</span>
</div>
</div>
<div class="sig-card-license">
<el-image
v-if="item.license_url"
:src="item.license_url"
:preview-src-list="[item.license_url]"
fit="cover"
class="license-img"
preview-teleported
/>
<div v-else class="license-placeholder">
<el-icon :size="24"><Picture /></el-icon>
<span>无资质证明</span>
</div>
</div>
</div>
<div class="sig-card-footer">
<el-button type="primary" size="small" @click="handleEdit(item)">编辑</el-button>
<el-button v-if="item.status === 0" type="warning" size="small" @click="handleSubmit(item)">提交审核</el-button>
<el-button v-if="item.status === 1" type="success" size="small" @click="handleApprove(item)">通过</el-button>
<el-button v-if="item.status === 1" type="danger" size="small" plain @click="handleReject(item)">驳回</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(item)">删除</el-button>
</div>
</div>
</div>
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="total"
@size-change="fetchList"
@current-change="fetchList"
background
class="pagination"
/>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增签名' : '编辑签名'" width="560px" append-to-body destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="110px">
<el-form-item label="主控服务" prop="service_id">
<el-select v-model="form.service_id" placeholder="选择服务" style="width: 100%" :disabled="dialogType === 'edit'">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<el-input
v-if="selectedUserInfo"
:model-value="`${selectedUserInfo.user_name} (ID: ${form.user_id})`"
readonly
>
<template #suffix>
<el-icon v-if="dialogType === 'add'" class="clear-icon" @click="clearUser"><Close /></el-icon>
</template>
<template #append>
<el-button @click="userSelectorVisible = true" :disabled="dialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
<el-input v-else placeholder="请选择用户" readonly @click="openUserSelector">
<template #append>
<el-button @click="openUserSelector" :disabled="dialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="签名标题" prop="title">
<el-input v-model="form.title" placeholder="如:某某公司" maxlength="30" show-word-limit />
</el-form-item>
<el-form-item label="申请人姓名">
<el-input v-model="form.applicant_name" placeholder="申请人真实姓名" />
</el-form-item>
<el-form-item label="身份证号">
<el-input v-model="form.applicant_id_card" placeholder="申请人身份证号" />
</el-form-item>
<el-form-item label="公司名称">
<el-input v-model="form.applicant_company" placeholder="申请人公司(可选)" />
</el-form-item>
<el-form-item label="营业执照URL">
<el-input v-model="form.license_url" placeholder="营业执照图片地址(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleFormSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 驳回原因对话框 -->
<el-dialog v-model="rejectDialogVisible" title="驳回签名" width="420px" append-to-body>
<el-form label-width="80px">
<el-form-item label="驳回原因">
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitting" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
<!-- 表单用户选择器 -->
<UserListSelector
v-model="userSelectorVisible"
:current-user-id="form.user_id"
@confirm="handleUserSelect"
/>
<!-- 筛选用户选择器 -->
<UserListSelector
v-model="filterUserSelectorVisible"
:current-user-id="queryParams.user_id ? Number(queryParams.user_id) : undefined"
@confirm="handleFilterUserSelect"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, User, Close, Picture } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/tool'
import {
getSmsServiceList,
getSmsSignatureList, createSmsSignature, updateSmsSignature, deleteSmsSignature,
submitSmsSignature, approveSmsSignature, rejectSmsSignature
} from '@/api/admin/smsService'
import UserListSelector from '@/components/admin/UserListSelector.vue'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const total = ref(0)
const serviceOptions = ref([])
const queryParams = reactive({ service_id: null, page: 1, count: 10, user_id: '', status: null })
const dialogVisible = ref(false)
const dialogType = ref('add')
const formRef = ref(null)
const form = reactive({ service_id: null, user_id: null, title: '', applicant_name: '', applicant_id_card: '', applicant_company: '', license_url: '' })
const formRules = {
service_id: [{ required: true, message: '请选择服务', trigger: 'change' }],
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
title: [{ required: true, message: '请输入签名标题', trigger: 'blur' }]
}
const rejectDialogVisible = ref(false)
const rejectReason = ref('')
const currentRow = ref(null)
const userSelectorVisible = ref(false)
const selectedUserInfo = ref(null)
const filterUserSelectorVisible = ref(false)
const filterUserInfo = ref(null)
const statusText = (s) => ({ 0: '草稿', 1: '审核中', 2: '已通过', 3: '已驳回' }[s] || '未知')
const statusTagType = (s) => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }[s] || 'info')
const loadServices = async () => {
try {
const res = await getSmsServiceList({ page: 1, count: 199 })
const body = res.data
if (body.code === 200) {
const list = body.data?.data || body.data || []
serviceOptions.value = Array.isArray(list) ? list : []
if (serviceOptions.value.length && !queryParams.service_id) {
queryParams.service_id = serviceOptions.value[0].id
}
}
} catch (e) { console.error(e) }
}
const fetchList = async () => {
if (!queryParams.service_id) return
loading.value = true
try {
const params = { service_id: queryParams.service_id, page: queryParams.page, count: queryParams.count }
if (queryParams.user_id) params.user_id = Number(queryParams.user_id)
if (queryParams.status !== null && queryParams.status !== '') params.status = queryParams.status
const res = await getSmsSignatureList(params)
if (res.data.code === 200) {
const d = res.data.data
tableData.value = d?.data || d || []
total.value = d?.all_count || 0
}
} catch (e) { ElMessage.error('获取签名列表失败') }
finally { loading.value = false }
}
const handleFilterUserSelect = (user) => {
queryParams.user_id = String(user.user_id)
filterUserInfo.value = user
handleSearch()
}
const clearFilterUser = () => {
queryParams.user_id = ''
filterUserInfo.value = null
handleSearch()
}
const handleSearch = () => { queryParams.page = 1; fetchList() }
const handleReset = () => { queryParams.user_id = ''; queryParams.status = null; filterUserInfo.value = null; queryParams.page = 1; fetchList() }
const openUserSelector = () => {
if (dialogType.value === 'edit') return
userSelectorVisible.value = true
}
const handleUserSelect = (user) => {
form.user_id = user.user_id
selectedUserInfo.value = user
}
const clearUser = () => {
form.user_id = null
selectedUserInfo.value = null
}
const handleAdd = () => {
dialogType.value = 'add'
selectedUserInfo.value = null
Object.assign(form, { service_id: queryParams.service_id, user_id: null, title: '', applicant_name: '', applicant_id_card: '', applicant_company: '', license_url: '' })
dialogVisible.value = true
}
const handleEdit = (row) => {
dialogType.value = 'edit'
selectedUserInfo.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
Object.assign(form, {
service_id: queryParams.service_id,
signature_id: row.ID,
user_id: row.user_id,
title: row.title,
applicant_name: row.applicant_name || '',
applicant_id_card: row.applicant_id_card || '',
applicant_company: row.applicant_company || '',
license_url: row.license_url || ''
})
dialogVisible.value = true
}
const handleFormSubmit = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const data = { ...form }
let res
if (dialogType.value === 'add') {
res = await createSmsSignature(data)
} else {
res = await updateSmsSignature(data)
}
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功')
dialogVisible.value = false
fetchList()
} else { ElMessage.error(res.data.message || '操作失败') }
} catch (e) { ElMessage.error('操作失败') }
finally { submitting.value = false }
})
}
const handleSubmit = (row) => {
ElMessageBox.confirm(`确认提交签名「${row.title}」进行审核?`, '提交审核', { type: 'info' }).then(async () => {
try {
const res = await submitSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已提交审核'); fetchList() }
else { ElMessage.error(res.data.message || '提交失败') }
} catch { ElMessage.error('提交失败') }
}).catch(() => {})
}
const handleApprove = (row) => {
ElMessageBox.confirm(`确认通过签名「${row.title}」?`, '审核通过', { type: 'success' }).then(async () => {
try {
const res = await approveSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已通过'); fetchList() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const handleReject = (row) => {
currentRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请输入驳回原因'); return }
submitting.value = true
try {
const res = await rejectSmsSignature({ service_id: queryParams.service_id, signature_id: currentRow.value.ID, reject_reason: rejectReason.value })
if (res.data.code === 200) { ElMessage.success('已驳回'); rejectDialogVisible.value = false; fetchList() }
else { ElMessage.error(res.data.message || '驳回失败') }
} catch { ElMessage.error('驳回失败') }
finally { submitting.value = false }
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除签名「${row.title}」?`, '删除', { type: 'warning' }).then(async () => {
try {
const res = await deleteSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchList() }
else { ElMessage.error(res.data.message || '删除失败') }
} catch { ElMessage.error('删除失败') }
}).catch(() => {})
}
onMounted(async () => {
await loadServices()
fetchList()
})
</script>
<style scoped>
.sms-signature-page { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-info { display: flex; align-items: center; gap: 14px; }
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: #f0f9eb; display: flex; align-items: center; justify-content: center; }
.header-title { margin: 0; font-size: 20px; color: #303133; }
.header-desc { margin: 4px 0 0; font-size: 13px; color: #909399; }
.header-actions { display: flex; gap: 10px; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.pagination { margin-top: 16px; justify-content: flex-end; }
.clear-icon { cursor: pointer; color: #909399; transition: color 0.2s; }
.clear-icon:hover { color: #f56c6c; }
/* 卡片列表 */
.signature-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 16px;
min-height: 200px;
}
.empty-state {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: 40px 0;
}
.sig-card {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 10px;
padding: 18px;
transition: all 0.25s;
display: flex;
flex-direction: column;
}
.sig-card:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
border-color: #d9d9d9;
transform: translateY(-2px);
}
.sig-card.status-1 { border-left: 3px solid #e6a23c; }
.sig-card.status-2 { border-left: 3px solid #67c23a; }
.sig-card.status-3 { border-left: 3px solid #f56c6c; }
.sig-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.sig-card-title {
display: flex;
align-items: center;
gap: 10px;
}
.sig-title-text {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.sig-card-id {
font-size: 12px;
color: #c0c4cc;
}
.sig-card-body {
display: flex;
gap: 16px;
flex: 1;
margin-bottom: 14px;
}
.sig-card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.sig-info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.sig-label {
color: #909399;
min-width: 56px;
flex-shrink: 0;
}
.sig-value {
color: #303133;
word-break: break-all;
}
.sig-id-card {
font-family: monospace;
font-size: 12px;
color: #606266;
}
.reject-row .sig-value {
color: #f56c6c;
}
.reject-text { color: #f56c6c; }
.sig-card-license {
flex-shrink: 0;
width: 90px;
display: flex;
align-items: center;
justify-content: center;
}
.license-img {
width: 80px;
height: 80px;
border-radius: 6px;
border: 1px solid #ebeef5;
cursor: pointer;
}
.license-placeholder {
width: 80px;
height: 80px;
border-radius: 6px;
background: #f5f7fa;
border: 1px dashed #dcdfe6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #c0c4cc;
font-size: 11px;
}
.sig-card-footer {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f0f2f5;
flex-wrap: wrap;
}
</style>

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