31 Commits

Author SHA1 Message Date
shiran a8954bd85d fix: 修复用户详情页订单列表字段映射错误
Build and Deploy Vue3 / build (push) Successful in 4m30s
Build and Deploy Vue3 / deploy (push) Successful in 50s
- state替代status、CreatedAt替代created_at

- 新增订单类型列(create/renew/upgrade)与到期时间列

- 金额列显示续费价格、详情跳转改用row.id

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 21:37:49 +08:00
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
55 changed files with 15103 additions and 9143 deletions
+12 -8
View File
@@ -18,7 +18,8 @@ jobs:
- name: Install dependencies
run: |
pnpm install
pnpm install --ignore-scripts
pnpm rebuild
- name: 替换域名
run: |
@@ -28,12 +29,15 @@ 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
@@ -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"
+11 -5
View File
@@ -18,18 +18,22 @@ 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
@@ -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"
+50
View File
@@ -284,6 +284,56 @@ 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' }
})
}
/**
* ================================
* 主控服务接口 - 数据卷管理
+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' }
})
}
+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 })
}
+2 -2
View File
@@ -34,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 })
}
/**更新用户信息 */
+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 })
}
+5
View File
@@ -88,6 +88,10 @@ export const deleteUserVmPostGroupRule = (params) => http2.delete(`${BASE}/post_
// ========== 网络 ==========
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 })
@@ -105,6 +109,7 @@ export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(d
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 })
// ========== 流量策略 ==========
// 测试未通过(接口新增,待联调)
+38 -8
View File
@@ -5,14 +5,14 @@ import request from "@/utils/request.js";
* @returns {Promise}
*/
export function getTickerList(count, page, status, orderBy, order, userId, keyword) {
export function getTickerList(count, page, status, orderBy, order, userId, keyword, type) {
const params = { count, page }
if (status !== undefined && status !== '') params.status = status
if (orderBy) params.orderBy = orderBy
if (order) params.order = order
if (userId) params.user_id = userId
if (keyword) params.keyword = keyword
console.log('工单列表请求参数:', params) // 调试日志
if (type) params.type = type
return request.get('/api/v1/admin/work_order/list', params)
}
@@ -43,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) {
@@ -106,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' }
})
}
/**获取工单类型列表 */
@@ -124,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' }
})
}
+28 -11
View File
@@ -26,6 +26,10 @@
</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>
重置
@@ -118,6 +122,7 @@ const selectedUser = ref(null)
const searchParams = reactive({
key: '',
is_admin: undefined,
page: 1,
count: 10
})
@@ -161,6 +166,7 @@ const handleSearch = () => {
const handleReset = () => {
searchParams.key = ''
searchParams.is_admin = undefined
searchParams.page = 1
fetchUserList()
}
@@ -191,22 +197,25 @@ const confirmSelection = () => {
<style scoped>
.user-selector-content {
max-height: 500px;
max-height: 520px;
overflow: hidden;
padding: 4px 0;
}
.selector-search {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 16px;
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;
max-width: 350px;
min-width: 0;
}
.reset-btn {
@@ -231,7 +240,7 @@ const confirmSelection = () => {
}
.selector-pagination {
margin-top: 16px;
margin-top: 14px;
justify-content: flex-end;
}
@@ -244,19 +253,26 @@ const confirmSelection = () => {
.selected-info {
margin-right: auto;
color: #606266;
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) {
:deep(.current-row > td) {
background-color: var(--el-color-primary-light-9) !important;
}
@@ -265,8 +281,9 @@ const confirmSelection = () => {
}
:deep(.el-avatar) {
background-color: var(--el-color-primary-light-5);
background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary));
color: #fff;
font-size: 12px;
font-weight: 600;
}
</style>
+29 -7
View File
@@ -13,9 +13,15 @@
<div class="file-list-container">
<div class="file-list-header">
<h4>文件列表</h4>
<el-button type="primary" @click="switchToUpload" :icon="Upload">
上传新文件
</el-button>
<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
@@ -137,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) => {
@@ -144,6 +151,7 @@ import { closeAllMessage } from '../../utils/message'
if (newVal) {
selectedId.value = props.currentCoverId
currentPage.value = 1
sourceFilter.value = undefined
fetchFileList()
}
})
@@ -161,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)
@@ -219,6 +228,12 @@ import { closeAllMessage } from '../../utils/message'
fetchFileList()
}
// 来源筛选变化
const handleSourceChange = () => {
currentPage.value = 1
fetchFileList()
}
// 切换到上传标签页
const switchToUpload = () => {
activeTab.value = 'upload'
@@ -312,6 +327,7 @@ import { closeAllMessage } from '../../utils/message'
fileList.value = []
currentPage.value = 1
total.value = 0
sourceFilter.value = undefined
}
// 确认选择
@@ -347,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;
+20 -5
View File
@@ -33,6 +33,10 @@
@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">
@@ -178,6 +182,7 @@ 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 竞态条件
@@ -190,6 +195,7 @@ watch(() => props.modelValue, (newVal) => {
selectedIds.value = new Set()
currentPage.value = 1
searchKeyword.value = ''
sourceFilter.value = undefined
fetchFileList()
}
})
@@ -224,10 +230,11 @@ const fetchFileList = async () => {
loading.value = true
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)
// 如果有更新的请求发起,丢弃当前结果
if (currentFetchVersion !== fetchVersion) return
@@ -285,10 +292,15 @@ const handleTabClick = (tab) => {
// 处理搜索
const handleSearch = () => {
// 搜索时重置到第一页
currentPage.value = 1
}
// 来源筛选变化
const handleSourceChange = () => {
currentPage.value = 1
fetchFileList()
}
// 分页处理
const handleSizeChange = (size) => {
pageSize.value = size
@@ -436,6 +448,7 @@ const handleClose = () => {
currentPage.value = 1
total.value = 0
searchKeyword.value = ''
sourceFilter.value = undefined
// 清理待上传文件的预览URL
pendingFiles.value.forEach(f => {
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
@@ -495,6 +508,8 @@ const handleConfirm = () => {
.filter-section {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.file-grid {
+173 -467
View File
@@ -1,166 +1,111 @@
<template>
<el-dialog
v-model="visible"
title="选择用户"
width="900px"
append-to-body
@close="handleClose"
>
<div class="user-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<!-- 选择用户 -->
<el-tab-pane label="选择用户" name="selectUser">
<div class="user-list-container">
<!-- 搜索筛选区域 -->
<div class="filter-section">
<el-form :inline="true" :model="searchParams" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchParams.key"
placeholder="搜索用户名或ID"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="用户状态">
<el-select
v-model="searchParams.status"
placeholder="全部状态"
clearable
style="width: 120px"
>
<el-option label="正常" value="1" />
<el-option label="禁用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search">
搜索
</el-button>
<el-button @click="handleReset" :icon="Refresh">
重置
</el-button>
<el-button type="success" @click="switchToAdd" :icon="Plus">
添加新用户
</el-button>
</el-form-item>
</el-form>
</div>
<el-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>
<!-- 用户列表表格 -->
<el-table
v-loading="loading"
:data="userList"
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
:height="350"
:row-class-name="tableRowClassName"
<!-- 搜索栏 -->
<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)"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="user_id" label="用户ID" width="100" align="center" />
<el-table-column prop="user_name" label="用户名" min-width="120">
<template #default="{ row }">
<div class="user-info-cell">
<el-avatar :size="32" :src="row.cover">
<el-icon><User /></el-icon>
</el-avatar>
<span class="user-name">{{ row.user_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" show-overflow-tooltip />
<!-- <el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column> -->
<el-table-column prop="created_at" label="注册时间" width="160" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.count"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<div 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>
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" />
</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 label="添加用户" name="addUser">
<div class="add-user-section">
<el-form
ref="addFormRef"
:model="addForm"
:rules="addFormRules"
label-width="100px"
class="add-user-form"
>
<!-- ====== 添加用户 ====== -->
<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-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-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-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-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-input v-model="addForm.confirmPassword" placeholder="请再次输入密码" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleAddUser" :loading="addLoading">
<el-icon><Plus /></el-icon>
立即创建
</el-button>
<el-button @click="resetAddForm">
<el-icon><Refresh /></el-icon>
重置表单
</el-button>
<el-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>
@@ -169,16 +114,9 @@
</div>
<template #footer>
<div class="dialog-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>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedUser" v-if="activeTab === 'selectUser'">确定选择</el-button>
</div>
</template>
</el-dialog>
@@ -190,23 +128,12 @@ import { ElMessage } from 'element-plus'
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
import { getUserList, createTask } from '@/api/admin/user'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 当前已选中的用户ID(用于回显)
currentUserId: {
type: [String, Number],
default: ''
}
modelValue: { type: Boolean, default: false },
currentUserId: { type: [String, Number], default: '' }
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm'])
// 响应式数据
const visible = ref(false)
const activeTab = ref('selectUser')
const loading = ref(false)
@@ -216,352 +143,131 @@ const total = ref(0)
const selectedUser = ref(null)
const addFormRef = ref(null)
// 搜索参数
const searchParams = reactive({
key: '',
status: '',
page: 1,
count: 10
})
const searchParams = reactive({ key: '', is_admin: undefined, page: 1, count: 12 })
// 添加用户表单
const addForm = reactive({
user_name: '',
email: '',
phone: '',
password: '',
confirmPassword: ''
})
// 密码确认验证
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()
}
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' }
]
user_name: [{ required: true, message: '请输入用户名', trigger: 'blur' }, { min: 2, max: 50, message: '2-50 个字符', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 20, message: '6-20 个字符', trigger: 'blur' }],
confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }]
}
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
// 重置状态
activeTab.value = 'selectUser'
selectedUser.value = null
searchParams.page = 1
fetchUserList()
}
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))
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 获取用户列表
const fetchUserList = async () => {
loading.value = true
userList.value = []
loading.value = true; userList.value = []
try {
const params = {
page: searchParams.page,
count: searchParams.count,
key: searchParams.key || ''
}
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
// 如果有当前选中的用户ID,自动选中
if (props.currentUserId) {
const currentUser = userList.value.find(
user => user.user_id === props.currentUserId
)
if (currentUser) {
selectedUser.value = currentUser
}
}
} else {
ElMessage.error(res.data.msg || '获取用户列表失败')
if (props.currentUserId) { const u = userList.value.find(u => u.user_id === props.currentUserId); if (u) selectedUser.value = u }
}
} catch (error) {
console.error('获取用户列表失败:', error)
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
} catch { ElMessage.error('获取用户列表失败') }
finally { loading.value = false }
}
// 处理标签页切换
const handleTabClick = (tab) => {
if (tab.paneName === 'selectUser') {
fetchUserList()
}
}
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 handleSearch = () => {
searchParams.page = 1
fetchUserList()
}
// 重置搜索
const handleReset = () => {
searchParams.key = ''
searchParams.status = ''
searchParams.page = 1
fetchUserList()
}
// 分页处理
const handleSizeChange = (size) => {
searchParams.count = size
searchParams.page = 1
fetchUserList()
}
const handlePageChange = (page) => {
searchParams.page = page
fetchUserList()
}
// 切换到添加用户标签页
const switchToAdd = () => {
activeTab.value = 'addUser'
}
// 选择用户
const handleCurrentChange = (row) => {
selectedUser.value = row
}
// 表格行样式
const tableRowClassName = ({ row }) => {
if (selectedUser.value && row.user_id === selectedUser.value.user_id) {
return 'selected-row'
}
return ''
}
// 添加用户
const handleAddUser = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate(async (valid) => {
if (!valid) return
addLoading.value = true
try {
const formData = new FormData()
formData.append('user_name', addForm.user_name)
formData.append('email', addForm.email)
if (addForm.phone) {
formData.append('phone', addForm.phone)
}
formData.append('password', addForm.password)
const res = await createTask(formData)
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()
}
// 重置表单
if (newUser) { selectedUser.value = { user_id: newUser.user_id || newUser.id, user_name: newUser.user_name || addForm.user_name, email: newUser.email || addForm.email, phone: newUser.phone || addForm.phone, ...newUser }; emit('confirm', selectedUser.value); handleClose() }
else { activeTab.value = 'selectUser'; searchParams.page = 1; await fetchUserList() }
resetAddForm()
} else {
ElMessage.error(res.data.msg || '用户创建失败')
}
} catch (error) {
console.error('用户创建失败:', error)
ElMessage.error('用户创建失败')
} finally {
addLoading.value = false
}
} else { ElMessage.error(res.data.msg || '创建失败') }
} catch { ElMessage.error('创建失败') }
finally { addLoading.value = false }
})
}
// 重置添加表单
const resetAddForm = () => {
addForm.user_name = ''
addForm.email = ''
addForm.phone = ''
addForm.password = ''
addForm.confirmPassword = ''
addFormRef.value?.resetFields()
}
// 关闭对话框
const handleClose = () => {
visible.value = false
selectedUser.value = null
userList.value = []
searchParams.key = ''
searchParams.status = ''
searchParams.page = 1
total.value = 0
resetAddForm()
}
// 确认选择
const handleConfirm = () => {
if (selectedUser.value) {
emit('confirm', selectedUser.value)
handleClose()
} else {
ElMessage.warning('请选择一个用户')
}
}
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>
.user-selector {
min-height: 450px;
}
.uls-root { min-height: 460px; }
.user-list-container {
padding: 10px 0;
}
/* 标签页 */
.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; }
.filter-section {
margin-bottom: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
}
/* 搜索栏 */
.uls-search { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.uls-search-input { flex: 1; max-width: 320px; }
.search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* 已选提示条 */
.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; }
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.fade-enter-active, .fade-leave-active { transition: all .25s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-6px); }
.user-info-cell {
display: flex;
align-items: center;
gap: 10px;
}
/* 卡片网格 */
.uls-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; max-height: 340px; overflow-y: auto; padding: 2px; }
.user-name {
font-weight: 500;
color: #303133;
}
.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; }
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.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; }
.add-user-section {
padding: 30px 60px;
}
.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); }
.add-user-form {
max-width: 500px;
margin: 0 auto;
}
.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; }
.add-user-form :deep(.el-input) {
width: 100%;
}
.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; }
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 分页 */
.uls-pagination { margin-top: 14px; display: flex; justify-content: flex-end; }
/* 表格样式 */
:deep(.el-table__row) {
cursor: pointer;
}
/* 添加用户 */
.uls-add-section { padding: 24px 40px; }
.uls-add-form { max-width: 460px; margin: 0 auto; }
:deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
/* 底部 */
.uls-footer { display: flex; justify-content: flex-end; gap: 10px; }
: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;
}
/* 滚动条美化 */
.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>
@@ -124,13 +124,19 @@ const submitCreate = async () => {
lock: createForm.lock,
drop_all: createForm.drop_all
})
if (res?.data?.code === 200) {
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 })
loadList()
} else ElMessage.error(res?.data?.message || '创建失败')
} catch { ElMessage.error('创建失败') } finally { createLoading.value = 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 }
+3 -18
View File
@@ -45,12 +45,8 @@
<breadcrumb />
</div>
<div class="navbar-right">
<div class="navbar-item hidden-mobile">
<el-tooltip content="全屏" placement="bottom">
<el-button type="text" class="header-btn" @click="toggleFullScreen">
<el-icon :size="18"><full-screen /></el-icon>
</el-button>
</el-tooltip>
<div class="navbar-item">
<GlobalSearch />
</div>
<div class="navbar-item">
@@ -103,9 +99,9 @@ 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,
@@ -165,17 +161,6 @@ const closeMobileMenu = () => {
isMobileMenuOpen.value = false
}
// 切换全屏
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
}
// 退出登录
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
+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>
+48 -17
View File
@@ -12,6 +12,14 @@ export const menus = [
{
path: '/ticket/list',
title: '工单列表'
},
{
path: '/ticket/types',
title: '工单类型'
},
{
path: '/ticket/templates',
title: '回复模板'
}
]
},
@@ -39,7 +47,8 @@ export const menus = [
title: '商品管理',
icon: 'Goods',
children: [
{ path: '/product/manage', title: '商品管理' }
{ path: '/product/manage', title: '商品管理' },
{ path: '/product/discount', title: '优惠管理' }
]
},
{
@@ -62,22 +71,6 @@ export const menus = [
}
]
},
{
path: '/marketing',
title: '优惠营销',
icon: 'Present',
children: [
{
path: '/marketing/discount',
title: '优惠码管理'
},
{
path: '/marketing/voucher',
title: '代金券管理'
},
]
},
{
path: '/activity',
title: '活动管理',
@@ -141,6 +134,40 @@ 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',
@@ -186,6 +213,10 @@ export const menus = [
path: '/system/setting-manage',
title: '配置管理'
},
{
path: '/system/notice-channel',
title: '通知管理'
},
{
path: '/system/menu',
title: '菜单管理',
+95 -30
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: '回复模板管理'
}
}
]
},
@@ -237,6 +253,18 @@ const routes = [
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' }
]
@@ -294,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` }
]
},
// 活动管理路由
@@ -408,6 +413,12 @@ const routes = [
component: () => import('../views/system/SettingManage.vue'),
meta: { title: '配置管理' }
},
{
path: 'notice-channel',
name: 'NoticeChannel',
component: () => import('../views/system/NoticeChannel.vue'),
meta: { title: '通知管理' }
},
{
path: 'menu-manage',
name: 'MenuManage',
@@ -587,6 +598,60 @@ const routes = [
}
]
},
// 短信平台管理路由
{
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: '主控服务管理' }
}
]
},
// 站点审计路由
{
path: 'audit',
+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>
-677
View File
@@ -1,677 +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">
<div class="unit-input-row">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
</el-form-item>
<el-form-item label="最低消费" prop="min_amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大抵扣" prop="max_amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</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
}
// 根据优惠类型设置amount或percentage
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; }
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
<style>
/* 时间选择器弹出层样式 - 非 scoped */
.discount-date-picker {
z-index: 9999 !important;
}
.discount-date-picker .el-picker-panel {
max-width: 90vw;
}
</style>
+235 -159
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([])
@@ -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,6 +823,57 @@ onMounted(() => {
padding: 0;
}
/* 折叠层级选择器样式 */
.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;
}
.tree-node-id {
color: #909399;
font-size: 12px;
}
.tree-node-price {
color: #f56c6c;
font-size: 12px;
font-weight: bold;
}
:deep(.el-card__body) {
padding: 0;
}
+1 -1
View File
@@ -359,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' }
-569
View File
@@ -1,569 +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">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最低消费" prop="min_amount">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大抵扣" prop="max_amount">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</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">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="flex:1" />
<span class="unit-text"></span>
</div>
<div class="form-tip">代金券领取后的有效持续时间</div>
</el-form-item>
<el-form-item label="发放时间范围" prop="timeRange">
<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
console.log('编辑代金券原始数据:', row)
// 转换时间为日期字符串(YYYY-MM-DD HH:mm:ss 格式)
let startTime = ''
let endTime = ''
if (row.startTime) {
// 处理字符串格式的时间(如 "2026-01-08T00:00:00+08:00"
const start = new Date(row.startTime)
if (!isNaN(start.getTime())) {
startTime = start.getFullYear() + '-' +
String(start.getMonth() + 1).padStart(2, '0') + '-' +
String(start.getDate()).padStart(2, '0') + ' ' +
String(start.getHours()).padStart(2, '0') + ':' +
String(start.getMinutes()).padStart(2, '0') + ':' +
String(start.getSeconds()).padStart(2, '0')
}
}
if (row.endTime) {
// 处理字符串格式的时间(如 "2026-02-25T00:00:00+08:00"
const end = new Date(row.endTime)
if (!isNaN(end.getTime())) {
endTime = end.getFullYear() + '-' +
String(end.getMonth() + 1).padStart(2, '0') + '-' +
String(end.getDate()).padStart(2, '0') + ' ' +
String(end.getHours()).padStart(2, '0') + ':' +
String(end.getMinutes()).padStart(2, '0') + ':' +
String(end.getSeconds()).padStart(2, '0')
}
}
console.log('转换后的时间:', { startTime, endTime })
Object.assign(voucherForm, {
code_id: row.id,
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 ? Math.round(row.duration / 86400) : 30, // 秒转天
timeRange: startTime && endTime ? [startTime, endTime] : [],
renew: row.renew || false,
can_stacking: row.canStacking || false,
can_combine: row.canCombine || false
})
console.log('表单数据:', voucherForm)
}
// 管理代金券
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;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
<style>
/* 时间选择器弹出层样式 - 非 scoped */
.voucher-date-picker {
z-index: 9999 !important;
}
.voucher-date-picker .el-picker-panel {
max-width: 90vw;
}
</style>
+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
+203 -120
View File
@@ -567,6 +567,9 @@
<el-icon class="group-picker-arrow"><ArrowRight /></el-icon>
</div>
</el-form-item>
<el-form-item label="所属表" prop="table">
<el-input v-model="productForm.table" placeholder="请输入商品所属表(如 kvm_service" />
</el-form-item>
<el-form-item label="商品介绍" prop="content">
<el-input
v-model="productForm.content"
@@ -588,125 +591,177 @@
@confirm="handleProductCoverSelect"
/>
<!-- 价格与销售 -->
<div class="form-section">
<div class="form-section-title">价格与销售</div>
<div class="form-grid">
<el-form-item prop="price">
<template #label>
<span>商品价格<span class="unit-suffix"></span></span>
</template>
<el-input-number
v-model="productForm.price"
:min="0"
:precision="2"
:step="0.01"
placeholder="请输入价格"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="单个商品数量" prop="pay_num">
<el-input-number
v-model="productForm.pay_num"
:min="1"
placeholder="请输入数量"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="expire_time">
<template #label>
<span>有效期<span class="unit-suffix">0 为永久</span></span>
</template>
<el-input-number
v-model="productForm.expire_time"
:min="0"
placeholder="请输入有效期"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="允许续费" prop="can_renew">
<el-switch
v-model="productForm.can_renew"
active-text="允许"
inactive-text="禁止"
/>
</el-form-item>
<el-form-item label="购买类型" prop="arg_type">
<el-select
v-model="productForm.arg_type"
placeholder="请选择购买类型"
style="width: 100%"
>
<el-option label="所有类型" value="all" />
<el-option label="仅套餐" value="plan" />
<el-option label="仅自定义参数" value="customize" />
</el-select>
</el-form-item>
<el-form-item label="需要实名" prop="require_real_name">
<el-switch
v-model="productForm.require_real_name"
active-text="需要"
inactive-text="不需要"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div>
</el-form-item>
</div>
</div>
<!-- Tab 栏管理 -->
<el-tabs v-model="productFormTab" class="product-form-tabs">
<el-tab-pane label="价格与销售" name="sales">
<div class="form-grid">
<el-form-item prop="price">
<template #label>
<span>商品价格<span class="unit-suffix"></span></span>
</template>
<el-input-number
v-model="productForm.price"
:min="0"
:precision="2"
:step="0.01"
placeholder="请输入价格"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="单个商品数量" prop="pay_num">
<el-input-number
v-model="productForm.pay_num"
:min="1"
placeholder="请输入数量"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="expire_time">
<template #label>
<span>有效期<span class="unit-suffix">0 为永久</span></span>
</template>
<el-input-number
v-model="productForm.expire_time"
:min="0"
placeholder="请输入有效期"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="允许续费" prop="can_renew">
<el-switch
v-model="productForm.can_renew"
active-text="允许"
inactive-text="禁止"
/>
</el-form-item>
<el-form-item prop="renew_price" v-if="productForm.can_renew">
<template #label>
<span>续费价格<span class="unit-suffix">0 沿用商品价格</span></span>
</template>
<el-input-number
v-model="productForm.renew_price"
:min="0"
:precision="2"
:step="0.01"
placeholder="续费基础价格"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="购买类型" prop="arg_type">
<el-select
v-model="productForm.arg_type"
placeholder="请选择购买类型"
style="width: 100%"
>
<el-option label="所有类型" value="all" />
<el-option label="仅套餐" value="plan" />
<el-option label="仅自定义参数" value="customize" />
</el-select>
</el-form-item>
<el-form-item label="需要实名" prop="require_real_name">
<el-switch
v-model="productForm.require_real_name"
active-text="需要"
inactive-text="不需要"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div>
</el-form-item>
</div>
</el-tab-pane>
<!-- 库存管理 -->
<div class="form-section">
<div class="form-section-title">库存管理</div>
<div class="form-grid">
<el-form-item label="库存控制" prop="inventory_control">
<el-switch
v-model="productForm.inventory_control"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<el-form-item label="库存数量" prop="inventory">
<el-input-number
v-model="productForm.inventory"
:min="0"
:disabled="!productForm.inventory_control"
placeholder="请输入库存"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</div>
</div>
<el-tab-pane label="库存管理" name="inventory">
<div class="form-grid">
<el-form-item label="库存控制" prop="inventory_control">
<el-switch
v-model="productForm.inventory_control"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<el-form-item label="库存数量" prop="inventory">
<el-input-number
v-model="productForm.inventory"
:min="0"
:disabled="!productForm.inventory_control"
placeholder="请输入库存"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="售罄状态" prop="sold_out">
<el-switch
v-model="productForm.sold_out"
active-text="售罄"
inactive-text="正常"
active-color="#f56c6c"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">开启后用户端将无法选购该商品</div>
</el-form-item>
<el-form-item label="购买限制" prop="max_per_user">
<el-input-number
v-model="productForm.max_per_user"
:min="0"
placeholder="0 表示不限"
controls-position="right"
style="width: 100%"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量0 表示不限制</div>
</el-form-item>
</div>
</el-tab-pane>
<!-- 推荐与参数 -->
<div class="form-section">
<div class="form-section-title">推荐与参数</div>
<div class="form-grid">
<el-form-item label="推荐" prop="recommend">
<el-switch
v-model="productForm.recommend"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<el-form-item prop="recommend_rebate">
<template #label>
<span>推荐返还<span class="unit-suffix">%</span></span>
</template>
<el-input-number
v-model="productForm.recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="返还百分比"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</div>
</div>
<el-tab-pane label="推荐与通知" name="promotion">
<div class="form-grid">
<el-form-item label="推荐" prop="recommend">
<el-switch
v-model="productForm.recommend"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<el-form-item prop="recommend_rebate">
<template #label>
<span>推荐返还<span class="unit-suffix">%</span></span>
</template>
<el-input-number
v-model="productForm.recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="返还百分比"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="renew_recommend_rebate">
<template #label>
<span>续费推介返还<span class="unit-suffix">%0 沿用推荐返还</span></span>
</template>
<el-input-number
v-model="productForm.renew_recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="续费推介返还百分比"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="购买通知" prop="send_notice">
<el-switch
v-model="productForm.send_notice"
active-text="发送"
inactive-text="不发送"
/>
<div style="font-size:12px;color:#909399;margin:4px">用户购买后是否发送通知给管理员</div>
</el-form-item>
</div>
</el-tab-pane>
</el-tabs>
</el-form>
<template #footer>
<div class="dialog-footer">
@@ -873,7 +928,12 @@ const productForm = reactive({
recommend_rebate: 0,
arg_type: 'all',
can_renew: true,
require_real_name: false
require_real_name: false,
sold_out: false,
max_per_user: 0,
send_notice: false,
renew_price: 0,
renew_recommend_rebate: 0
})
const productRules = {
@@ -895,6 +955,7 @@ const productRules = {
// 商品对话框状态
const productDialogVisible = ref(false)
const productDialogType = ref('add')
const productFormTab = ref('sales')
const productFormRef = ref(null)
const coverSelectorVisible = ref(false)
@@ -1557,7 +1618,12 @@ const handleEditProduct = (product, parentGroupId) => {
recommend_rebate: product.recommendRebate,
arg_type: product.argType || 'all',
can_renew: product.canRenew !== undefined ? product.canRenew : (product.can_renew !== undefined ? product.can_renew : true),
require_real_name: product.requireRealName ?? product.require_real_name ?? false
require_real_name: product.requireRealName ?? product.require_real_name ?? false,
sold_out: !!product.soldOut,
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
send_notice: !!product.sendNotice,
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0
})
productDialogVisible.value = true
@@ -1583,7 +1649,12 @@ const submitProductForm = () => {
recommend_rebate: Number(productForm.recommend_rebate) || 0,
arg_type: productForm.arg_type,
can_renew: productForm.can_renew,
require_real_name: productForm.require_real_name
require_real_name: productForm.require_real_name,
sold_out: productForm.sold_out === true,
max_per_user: Number(productForm.max_per_user) || 0,
send_notice: productForm.send_notice === true,
renew_price: Number(productForm.renew_price) || 0,
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0
}
let res
@@ -2185,6 +2256,18 @@ onMounted(() => {
flex-shrink: 0;
}
.product-form-tabs {
margin-top: 16px;
}
.product-form-tabs :deep(.el-tabs__header) {
margin-bottom: 16px;
}
.product-form-tabs :deep(.el-tab-pane) {
padding: 4px 0;
}
.form-section {
margin-bottom: var(--section-gap);
padding: 16px;
File diff suppressed because it is too large Load Diff
@@ -275,13 +275,13 @@
<el-form-item v-if="currentParam?.type === 'number'" label="数值范围" prop="attr_range">
<div class="range-config-row">
<el-select v-model="paramValueForm.range_type" class="range-type-select">
<el-option label="" value="before">
<span class="range-opt-symbol"></span>
<span class="range-opt-desc">小于</span>
<el-option label="" value="before">
<span class="range-opt-symbol"></span>
<span class="range-opt-desc">小于等于</span>
</el-option>
<el-option label="" value="after">
<span class="range-opt-symbol"></span>
<span class="range-opt-desc">大于</span>
<el-option label="" value="after">
<span class="range-opt-symbol"></span>
<span class="range-opt-desc">大于等于</span>
</el-option>
<el-option label="" value="equal">
<span class="range-opt-symbol"></span>
@@ -635,7 +635,7 @@ const getArgTypeTag = (type) => {
return tagMap[type] || 'info'
}
const getRangeTypeText = (type) => {
const typeMap = { 'after': '', 'before': '', 'equal': '' }
const typeMap = { 'after': '', 'before': '', 'equal': '' }
return typeMap[type] || type || '-'
}
@@ -113,6 +113,9 @@
<el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small" effect="plain">
{{ row.canUpdate || row.can_update ? '允许升级' : '禁止升级' }}
</el-tag>
<el-tag v-if="(row.maxPerUser || row.max_per_user) > 0" type="warning" size="small" effect="plain">
限购{{ row.maxPerUser || row.max_per_user }}
</el-tag>
</div>
<!-- 说明 -->
@@ -162,6 +165,17 @@
<el-form-item label="说明" prop="note">
<el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" />
</el-form-item>
<div v-if="missingMustSpecs.length > 0" class="must-params-alert">
<div class="must-params-alert-header">
<el-icon><WarningFilled /></el-icon>
<span>以下必填参数尚未配置请将其加入参数配置额外参数</span>
</div>
<div class="must-params-alert-tags">
<el-tag v-for="spec in missingMustSpecs" :key="spec.id" type="danger" size="small" effect="plain">
<span class="must-star">*</span>{{ spec.name }}
</el-tag>
</div>
</div>
<el-form-item label="参数配置" prop="args">
<div class="args-config-container">
<div class="args-select-row">
@@ -260,6 +274,13 @@
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="续费固定价格" prop="renew_fixed_price" v-if="planForm.enable_fixed_price === true">
<div class="unit-input-row">
<el-input-number v-model="planForm.renew_fixed_price" :min="0" :precision="2" :step="0.01" style="flex:1" placeholder="0 表示沿用固定价格" />
<span class="unit-text"></span>
</div>
<div class="form-tip">续费时使用的固定价格 0 时沿用上方固定价格</div>
</el-form-item>
<el-form-item label="排序索引" prop="index">
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
</el-form-item>
@@ -277,6 +298,10 @@
<el-switch v-model="planForm.can_update" active-text="允许" inactive-text="不允许" />
<div class="form-tip">控制用户是否可以升级到此套餐</div>
</el-form-item>
<el-form-item label="购买限制" prop="max_per_user">
<el-input-number v-model="planForm.max_per_user" :min="0" placeholder="单用户最大购买数量" style="width: 100%" />
<div class="form-tip">限制单个用户对该套餐的最大购买数量0 表示不限制</div>
</el-form-item>
</el-form>
</div>
<template #footer>
@@ -367,7 +392,9 @@ const planForm = reactive({
index: 0,
disable: false,
show_home: false,
can_update: false
can_update: false,
max_per_user: 0,
renew_fixed_price: 0
})
const planFormRules = {
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
@@ -385,6 +412,10 @@ const selectedExtraArgIds = ref([])
const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id)))
const extraSpecList = computed(() => planSpecList.value.filter(spec => !selectedArgIds.value.includes(spec.id)))
const missingMustSpecs = computed(() => {
const allSelectedIds = [...selectedArgIds.value, ...selectedExtraArgIds.value]
return planSpecList.value.filter(s => s.must && !allSelectedIds.includes(s.id))
})
const getSpecDisplayMin = (spec) => {
if (!hasUnit(spec)) return spec.min ?? 0
@@ -531,11 +562,11 @@ const findMatchingNumberAttr = (spec, numValue) => {
if (!spec.attrs || spec.attrs.length === 0) return null
const sortedAttrs = [...spec.attrs].sort((a, b) => (a.index || 0) - (b.index || 0))
for (const attr of sortedAttrs) {
const phase = attr.phase || 0
const rangeVal = attr.range ?? attr.phase ?? 0
const rangeType = attr.rangeType || 'before'
if (rangeType === 'before' && numValue < phase) return attr
else if (rangeType === 'after' && numValue > phase) return attr
else if (rangeType === 'equal' && numValue === phase) return attr
if (rangeType === 'before' && numValue <= rangeVal) return attr
else if (rangeType === 'after' && numValue >= rangeVal) return attr
else if (rangeType === 'equal' && numValue === rangeVal) return attr
}
return sortedAttrs[sortedAttrs.length - 1]
}
@@ -743,7 +774,7 @@ const handleAddPlan = async () => {
displayUnits[spec.id] = getParamDefaultUnit(spec)
}
}
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false, can_update: false })
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false, can_update: false, max_per_user: 0 })
planFormDialogVisible.value = true
nextTick(() => { planFormRef.value?.resetFields() })
}
@@ -770,7 +801,9 @@ const handleEditPlan = async (row) => {
inventory: data.inventory || 0, fixed_price: ((data.fixedPrice || data.fixed_price || 0) / 100).toFixed(2) * 1,
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price),
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home),
can_update: !!(data.canUpdate || data.can_update)
can_update: !!(data.canUpdate || data.can_update),
max_per_user: data.maxPerUser ?? data.max_per_user ?? 0,
renew_fixed_price: ((data.renewFixedPrice ?? data.renew_fixed_price ?? 0) / 100).toFixed(2) * 1
})
initSelectedArgsFromJson(data.args, extraArgIdsArray)
planFormDialogVisible.value = true
@@ -827,7 +860,9 @@ const submitPlanForm = () => {
args: planForm.args || '', extra_arg_ids: extraArgIdsStr || planForm.extra_arg_ids || '',
inventory: Number(planForm.inventory) || 0, fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0,
index: Number(planForm.index) || 0, show_home: planForm.show_home === true,
can_update: planForm.can_update === true
can_update: planForm.can_update === true,
max_per_user: Number(planForm.max_per_user) || 0,
renew_fixed_price: Math.round(Number(planForm.renew_fixed_price) * 100) || 0
}
if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
let res
@@ -1184,6 +1219,26 @@ watch(() => props.visible, (val) => {
.plan-card-actions .el-button + .el-button {
margin-left: 0;
}
.must-params-alert {
margin: 0 0 16px;
padding: 10px 14px;
background: linear-gradient(135deg, #fff5f5 0%, #fff1f0 100%);
border: 1px solid #fcdcdc;
border-radius: 8px;
border-left: 3px solid #f56c6c;
}
.must-params-alert-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #f56c6c;
font-weight: 500;
margin-bottom: 8px;
}
.must-params-alert-header .el-icon { font-size: 16px; flex-shrink: 0; }
.must-params-alert-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.must-params-alert-tags .must-star { color: #f56c6c; font-weight: 700; margin-right: 2px; }
.plan-form-content { max-height: 60vh; overflow-y: auto; padding-right: 8px; margin-right: -8px; }
.plan-form-content::-webkit-scrollbar { width: 6px; }
.plan-form-content::-webkit-scrollbar-track { background: transparent; }
+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>
+632
View File
@@ -0,0 +1,632 @@
<template>
<div class="sms-template-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="M4 4h16v16H4z" rx="2"/>
<path d="M8 8h8M8 12h6M8 16h4" stroke-linecap="round"/>
</svg>
</div>
<div>
<h2 class="header-title">短信模板管理</h2>
<p class="header-desc">管理用户短信模板的审核推荐模板的维护</p>
</div>
</div>
</div>
<!-- 服务选择 -->
<div class="filter-bar">
<el-select v-model="serviceId" placeholder="选择主控服务" style="width: 200px" @change="onServiceChange">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</div>
<!-- 主内容 Tabs -->
<el-tabs v-model="activeTab" type="border-card">
<!-- 用户模板 -->
<el-tab-pane label="用户模板" name="user">
<div class="tab-toolbar">
<div class="tab-filters">
<el-select v-model="tplQuery.status" placeholder="全部状态" clearable style="width: 130px" @change="fetchTemplates">
<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="tplFilterUserInfo"
:model-value="`${tplFilterUserInfo.user_name} (ID: ${tplQuery.user_id})`"
readonly
style="width: 200px"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearTplFilterUser"><Close /></el-icon>
</template>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-input v-else placeholder="筛选用户" readonly style="width: 140px" @click="tplFilterUserSelectorVisible = true">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-button type="primary" @click="fetchTemplates">搜索</el-button>
</div>
<el-button type="primary" @click="handleAddTemplate">
<el-icon><Plus /></el-icon> 新增模板
</el-button>
</div>
<el-table :data="tplList" v-loading="tplLoading" stripe border style="width: 100%">
<el-table-column prop="ID" label="ID" width="70" align="center" />
<el-table-column label="用户ID" width="90">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="$router.push({ path: '/user/detail', query: { user_id: row.user_id } })">{{ row.user_id }}</el-link>
</template>
</el-table-column>
<el-table-column prop="name" label="模板名称" min-width="140" />
<el-table-column label="内容" min-width="200">
<template #default="{ row }">
<el-tooltip :content="row.content" placement="top" :show-after="300">
<span class="content-cell">{{ row.content }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="参数" width="150">
<template #default="{ row }">
<template v-if="row.params && row.params.length">
<el-tag v-for="p in row.params" :key="p.key" size="small" style="margin: 2px">{{ p.key }}</el-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="驳回原因" min-width="130">
<template #default="{ row }">
<span v-if="row.reject_reason" class="reject-text">{{ row.reject_reason }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditTemplate(row)">编辑</el-button>
<el-button v-if="row.status === 0" type="warning" link @click="handleSubmitTpl(row)">提交审核</el-button>
<el-button v-if="row.status === 1" type="success" link @click="handleApproveTpl(row)">通过</el-button>
<el-button v-if="row.status === 1" type="danger" link @click="handleRejectTpl(row)">驳回</el-button>
<el-button type="danger" link @click="handleDeleteTpl(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="tplQuery.page"
v-model:page-size="tplQuery.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="tplTotal"
@size-change="fetchTemplates"
@current-change="fetchTemplates"
background
class="pagination"
/>
</el-tab-pane>
<!-- 推荐模板 -->
<el-tab-pane label="推荐模板" name="recommended">
<div class="tab-toolbar">
<div></div>
<el-button type="primary" @click="handleAddRecommended">
<el-icon><Plus /></el-icon> 新增推荐模板
</el-button>
</div>
<el-table :data="recList" v-loading="recLoading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="分类" width="120">
<template #default="{ row }">
<el-tag v-if="row.category" size="small">{{ row.category }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="内容" min-width="220">
<template #default="{ row }">
<el-tooltip :content="row.content" placement="top" :show-after="300">
<span class="content-cell">{{ row.content }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="参数" width="150">
<template #default="{ row }">
<template v-if="row.params && row.params.length">
<el-tag v-for="p in row.params" :key="p.key" size="small" style="margin: 2px">{{ p.key }}</el-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditRecommended(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteRecommended(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="recQuery.page"
v-model:page-size="recQuery.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="recTotal"
@size-change="fetchRecommended"
@current-change="fetchRecommended"
background
class="pagination"
/>
</el-tab-pane>
</el-tabs>
<!-- 用户模板表单对话框 -->
<el-dialog v-model="tplDialogVisible" :title="tplDialogType === 'add' ? '新增模板' : '编辑模板'" width="640px" append-to-body destroy-on-close>
<el-form ref="tplFormRef" :model="tplForm" :rules="tplFormRules" label-width="100px">
<el-form-item label="用户" prop="user_id">
<el-input
v-if="tplSelectedUser"
:model-value="`${tplSelectedUser.user_name} (ID: ${tplForm.user_id})`"
readonly
>
<template #suffix>
<el-icon v-if="tplDialogType === 'add'" class="clear-icon" @click="clearTplUser"><Close /></el-icon>
</template>
<template #append>
<el-button @click="tplUserSelectorVisible = true" :disabled="tplDialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
<el-input v-else placeholder="请选择用户" readonly @click="openTplUserSelector">
<template #append>
<el-button @click="openTplUserSelector" :disabled="tplDialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="模板名称" prop="name">
<el-input v-model="tplForm.name" placeholder="请输入模板名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="推荐模板">
<el-select v-model="tplForm.recommended_id" placeholder="选择推荐模板(可选)" clearable style="width: 100%" @change="onRecommendedSelect">
<el-option v-for="r in recOptionsCache" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
<el-form-item label="模板参数">
<div class="params-editor">
<div v-for="(p, idx) in tplForm.paramsList" :key="idx" class="param-row">
<el-input v-model="p.key" placeholder="参数名" style="width: 100px" />
<el-select v-model="p.type" placeholder="类型" style="width: 100px">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="时间" value="date" />
</el-select>
<el-input-number v-model="p.max_len" :min="1" :max="999" placeholder="长度" style="width: 100px" />
<el-button type="danger" link @click="tplForm.paramsList.splice(idx, 1)"><el-icon><Delete /></el-icon></el-button>
</div>
<el-button type="primary" link @click="tplForm.paramsList.push({ key: '', type: 'string', max_len: 20 })">
<el-icon><Plus /></el-icon> 添加参数
</el-button>
</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input v-model="tplForm.content" type="textarea" :rows="4" placeholder="如:您的验证码为{code},有效期{time}分钟。" />
<div v-if="tplForm.paramsList.length" class="insert-btns">
<span class="insert-label">插入参数</span>
<el-button v-for="p in tplForm.paramsList.filter(x => x.key)" :key="p.key" size="small" @click="insertParam(p.key)">{{ '{' + p.key + '}' }}</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="tplDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitTplForm">确定</el-button>
</template>
</el-dialog>
<!-- 推荐模板表单对话框 -->
<el-dialog v-model="recDialogVisible" :title="recDialogType === 'add' ? '新增推荐模板' : '编辑推荐模板'" width="600px" append-to-body destroy-on-close>
<el-form ref="recFormRef" :model="recForm" :rules="recFormRules" label-width="100px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="recForm.name" placeholder="请输入名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="分类">
<el-input v-model="recForm.category" placeholder="如:验证码、通知、营销" />
</el-form-item>
<el-form-item label="模板参数">
<div class="params-editor">
<div v-for="(p, idx) in recForm.paramsList" :key="idx" class="param-row">
<el-input v-model="p.key" placeholder="参数名" style="width: 100px" />
<el-select v-model="p.type" placeholder="类型" style="width: 100px">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="时间" value="date" />
</el-select>
<el-input-number v-model="p.max_len" :min="1" :max="999" placeholder="长度" style="width: 100px" />
<el-button type="danger" link @click="recForm.paramsList.splice(idx, 1)"><el-icon><Delete /></el-icon></el-button>
</div>
<el-button type="primary" link @click="recForm.paramsList.push({ key: '', type: 'string', max_len: 20 })">
<el-icon><Plus /></el-icon> 添加参数
</el-button>
</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input v-model="recForm.content" type="textarea" :rows="4" placeholder="模板内容,参数使用 {key} 格式" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="recDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitRecForm">确定</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="confirmRejectTpl">确认驳回</el-button>
</template>
</el-dialog>
<!-- 表单用户选择器 -->
<UserListSelector
v-model="tplUserSelectorVisible"
:current-user-id="tplForm.user_id"
@confirm="handleTplUserSelect"
/>
<!-- 筛选用户选择器 -->
<UserListSelector
v-model="tplFilterUserSelectorVisible"
:current-user-id="tplQuery.user_id ? Number(tplQuery.user_id) : undefined"
@confirm="handleTplFilterUserSelect"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, User, Close } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/tool'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import {
getSmsServiceList,
getSmsTemplateList, createSmsTemplate, updateSmsTemplate, deleteSmsTemplate,
submitSmsTemplate, approveSmsTemplate, rejectSmsTemplate,
getSmsRecommendedTemplateList, createSmsRecommendedTemplate, updateSmsRecommendedTemplate, deleteSmsRecommendedTemplate
} from '@/api/admin/smsService'
const serviceOptions = ref([])
const serviceId = ref(null)
const activeTab = ref('user')
const submitting = ref(false)
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 })
if (res.data.code === 200) {
const list = res.data.data?.data || res.data.data || []
serviceOptions.value = Array.isArray(list) ? list : []
if (serviceOptions.value.length && !serviceId.value) {
serviceId.value = serviceOptions.value[0].id
}
}
} catch (e) { console.error(e) }
}
const onServiceChange = () => {
fetchTemplates()
fetchRecommended()
}
// ========== ==========
const tplLoading = ref(false)
const tplList = ref([])
const tplTotal = ref(0)
const tplQuery = reactive({ page: 1, count: 10, user_id: '', status: null })
const tplFilterUserSelectorVisible = ref(false)
const tplFilterUserInfo = ref(null)
const handleTplFilterUserSelect = (user) => {
tplQuery.user_id = String(user.user_id)
tplFilterUserInfo.value = user
fetchTemplates()
}
const clearTplFilterUser = () => {
tplQuery.user_id = ''
tplFilterUserInfo.value = null
fetchTemplates()
}
const fetchTemplates = async () => {
if (!serviceId.value) return
tplLoading.value = true
try {
const params = { service_id: serviceId.value, page: tplQuery.page, count: tplQuery.count }
if (tplQuery.user_id) params.user_id = Number(tplQuery.user_id)
if (tplQuery.status !== null && tplQuery.status !== '') params.status = tplQuery.status
const res = await getSmsTemplateList(params)
if (res.data.code === 200) {
const d = res.data.data
tplList.value = d?.data || d || []
tplTotal.value = d?.all_count || 0
}
} catch { ElMessage.error('获取模板列表失败') }
finally { tplLoading.value = false }
}
//
const tplDialogVisible = ref(false)
const tplDialogType = ref('add')
const tplFormRef = ref(null)
const tplForm = reactive({ user_id: null, name: '', content: '', paramsList: [], recommended_id: null, template_id: null })
const tplUserSelectorVisible = ref(false)
const tplSelectedUser = ref(null)
const openTplUserSelector = () => {
if (tplDialogType.value === 'edit') return
tplUserSelectorVisible.value = true
}
const handleTplUserSelect = (user) => {
tplForm.user_id = user.user_id
tplSelectedUser.value = user
}
const clearTplUser = () => {
tplForm.user_id = null
tplSelectedUser.value = null
}
const tplFormRules = {
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
const recOptionsCache = ref([])
const handleAddTemplate = () => {
tplDialogType.value = 'add'
tplSelectedUser.value = null
Object.assign(tplForm, { user_id: null, name: '', content: '', paramsList: [], recommended_id: null, template_id: null })
loadRecOptionsCache()
tplDialogVisible.value = true
}
const handleEditTemplate = (row) => {
tplDialogType.value = 'edit'
tplSelectedUser.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
const params = Array.isArray(row.params) ? row.params.map(p => ({ ...p })) : []
Object.assign(tplForm, { user_id: row.user_id, name: row.name, content: row.content, paramsList: params, recommended_id: row.recommended_id || null, template_id: row.ID })
loadRecOptionsCache()
tplDialogVisible.value = true
}
const loadRecOptionsCache = async () => {
if (!serviceId.value) return
try {
const res = await getSmsRecommendedTemplateList({ service_id: serviceId.value, page: 1, count: 100 })
if (res.data.code === 200) {
recOptionsCache.value = res.data.data?.data || res.data.data || []
}
} catch {}
}
const onRecommendedSelect = (id) => {
if (!id) return
const rec = recOptionsCache.value.find(r => r.id === id)
if (rec) {
tplForm.content = rec.content
tplForm.paramsList = Array.isArray(rec.params) ? rec.params.map(p => ({ ...p })) : []
}
}
const insertParam = (key) => {
tplForm.content += `{${key}}`
}
const submitTplForm = () => {
tplFormRef.value?.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const paramsJson = tplForm.paramsList.filter(p => p.key).length > 0
? JSON.stringify(tplForm.paramsList.filter(p => p.key))
: ''
const data = {
service_id: serviceId.value,
user_id: tplForm.user_id,
name: tplForm.name,
content: tplForm.content
}
if (paramsJson) data.params = paramsJson
if (tplForm.recommended_id) data.recommended_id = tplForm.recommended_id
let res
if (tplDialogType.value === 'add') {
res = await createSmsTemplate(data)
} else {
data.template_id = tplForm.template_id
res = await updateSmsTemplate(data)
}
if (res.data.code === 200) { ElMessage.success('操作成功'); tplDialogVisible.value = false; fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
})
}
const handleSubmitTpl = (row) => {
ElMessageBox.confirm(`提交模板「${row.name}」审核?`, '提交审核', { type: 'info' }).then(async () => {
try {
const res = await submitSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已提交'); fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const handleApproveTpl = (row) => {
ElMessageBox.confirm(`通过模板「${row.name}」?`, '审核通过', { type: 'success' }).then(async () => {
try {
const res = await approveSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已通过'); fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const rejectDialogVisible = ref(false)
const rejectReason = ref('')
const currentRejectRow = ref(null)
const handleRejectTpl = (row) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmRejectTpl = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请输入驳回原因'); return }
submitting.value = true
try {
const res = await rejectSmsTemplate({ service_id: serviceId.value, template_id: currentRejectRow.value.ID, reject_reason: rejectReason.value })
if (res.data.code === 200) { ElMessage.success('已驳回'); rejectDialogVisible.value = false; fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
}
const handleDeleteTpl = (row) => {
ElMessageBox.confirm(`确认删除模板「${row.name}」?`, '删除', { type: 'warning' }).then(async () => {
try {
const res = await deleteSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchTemplates() }
else { ElMessage.error(res.data.message || '删除失败') }
} catch { ElMessage.error('删除失败') }
}).catch(() => {})
}
// ========== ==========
const recLoading = ref(false)
const recList = ref([])
const recTotal = ref(0)
const recQuery = reactive({ page: 1, count: 10 })
const fetchRecommended = async () => {
if (!serviceId.value) return
recLoading.value = true
try {
const res = await getSmsRecommendedTemplateList({ service_id: serviceId.value, page: recQuery.page, count: recQuery.count })
if (res.data.code === 200) {
const d = res.data.data
recList.value = d?.data || d || []
recTotal.value = d?.all_count || 0
}
} catch { ElMessage.error('获取推荐模板失败') }
finally { recLoading.value = false }
}
const recDialogVisible = ref(false)
const recDialogType = ref('add')
const recFormRef = ref(null)
const recForm = reactive({ name: '', content: '', paramsList: [], category: '', template_id: null })
const recFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
}
const handleAddRecommended = () => {
recDialogType.value = 'add'
Object.assign(recForm, { name: '', content: '', paramsList: [], category: '', template_id: null })
recDialogVisible.value = true
}
const handleEditRecommended = (row) => {
recDialogType.value = 'edit'
const params = Array.isArray(row.params) ? row.params.map(p => ({ ...p })) : []
Object.assign(recForm, { name: row.name, content: row.content, paramsList: params, category: row.category || '', template_id: row.id })
recDialogVisible.value = true
}
const submitRecForm = () => {
recFormRef.value?.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const paramsJson = recForm.paramsList.filter(p => p.key).length > 0
? JSON.stringify(recForm.paramsList.filter(p => p.key))
: ''
const data = {
service_id: serviceId.value,
name: recForm.name,
content: recForm.content
}
if (paramsJson) data.params = paramsJson
if (recForm.category) data.category = recForm.category
let res
if (recDialogType.value === 'add') {
res = await createSmsRecommendedTemplate(data)
} else {
data.template_id = recForm.template_id
res = await updateSmsRecommendedTemplate(data)
}
if (res.data.code === 200) { ElMessage.success('操作成功'); recDialogVisible.value = false; fetchRecommended() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
})
}
const handleDeleteRecommended = (row) => {
ElMessageBox.confirm(`确认删除推荐模板「${row.name}」?`, '删除', { type: 'warning' }).then(async () => {
try {
const res = await deleteSmsRecommendedTemplate({ service_id: serviceId.value, template_id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchRecommended() }
else { ElMessage.error(res.data.message || '删除失败') }
} catch { ElMessage.error('删除失败') }
}).catch(() => {})
}
// ========== ==========
onMounted(async () => {
await loadServices()
fetchTemplates()
fetchRecommended()
})
</script>
<style scoped>
.sms-template-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: #ecf5ff; 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; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; align-items: center; }
.tab-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.tab-filters { display: flex; gap: 10px; align-items: center; }
.pagination { margin-top: 16px; justify-content: flex-end; }
.content-cell { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; font-size: 13px; cursor: pointer; }
.reject-text { color: #f56c6c; font-size: 12px; }
.text-muted { color: #c0c4cc; }
.params-editor { width: 100%; }
.param-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.insert-btns { margin-top: 8px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.insert-label { font-size: 12px; color: #909399; }
.clear-icon { cursor: pointer; color: #909399; transition: color 0.2s; }
.clear-icon:hover { color: #f56c6c; }
</style>
+1 -6
View File
@@ -118,12 +118,7 @@ const domainForm = reactive({
//
const domainRules = {
domain: [
{ required: true, message: '请输入域名', trigger: 'blur' },
{
pattern: /^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+[A-Za-z]{2,6}$/,
message: '请输入有效的域名格式',
trigger: 'blur'
}
{ required: true, message: '请输入域名', trigger: 'blur' }
]
}
+720
View File
@@ -0,0 +1,720 @@
<template>
<div class="notice-page">
<!-- 页头 -->
<div class="page-header">
<div class="header-left">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 22c1.1 0 2-.9 2-2h-4a2 2 0 0 0 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" fill="#409eff"/></svg>
</div>
<div>
<h2 class="page-title">通知管理</h2>
<p class="page-desc">管理通知渠道开关与消息模板控制各业务事件的通知行为</p>
</div>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" type="border-card" class="main-tabs">
<!-- ==================== 渠道配置 ==================== -->
<el-tab-pane name="channel">
<template #label>
<span class="tab-label">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z" fill="currentColor"/><path d="M8 14h8v2H8zm0-4h8v2H8z" fill="currentColor"/></svg>
渠道配置
</span>
</template>
<div class="channel-section" v-loading="channelLoading">
<!-- 短信渠道 -->
<div class="channel-group">
<div class="group-header sms-header">
<div class="group-icon sms-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/><path d="M7 9h10v2H7z" fill="currentColor"/><path d="M7 6h7v2H7z" fill="currentColor"/></svg>
</div>
<div class="group-title-area">
<h3>短信通知</h3>
<span class="group-count">{{ smsChannels.length }} 个事件</span>
</div>
<div class="group-summary">
<el-tag type="success" size="small" effect="dark" round>{{ smsChannels.filter(c => c.enabled).length }} 已启用</el-tag>
<el-tag type="info" size="small" effect="plain" round>{{ smsChannels.filter(c => !c.enabled).length }} 已关闭</el-tag>
</div>
</div>
<div class="channel-cards">
<div v-for="item in smsChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
<div class="card-top">
<span class="card-event-name">{{ item.eventName || '-' }}</span>
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
</div>
<div class="card-bottom">
<code class="card-event-type">{{ item.eventType }}</code>
<span v-if="item.note" class="card-note" :title="item.note">{{ item.note }}</span>
</div>
</div>
<el-empty v-if="smsChannels.length === 0" description="暂无短信通知配置" :image-size="60" />
</div>
</div>
<!-- 邮件渠道 -->
<div class="channel-group">
<div class="group-header email-header">
<div class="group-icon email-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
</div>
<div class="group-title-area">
<h3>邮件通知</h3>
<span class="group-count">{{ emailChannels.length }} 个事件</span>
</div>
<div class="group-summary">
<el-tag type="success" size="small" effect="dark" round>{{ emailChannels.filter(c => c.enabled).length }} 已启用</el-tag>
<el-tag type="info" size="small" effect="plain" round>{{ emailChannels.filter(c => !c.enabled).length }} 已关闭</el-tag>
</div>
</div>
<div class="channel-cards">
<div v-for="item in emailChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
<div class="card-top">
<span class="card-event-name">{{ item.eventName || '-' }}</span>
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
</div>
<div class="card-bottom">
<code class="card-event-type">{{ item.eventType }}</code>
<span v-if="item.note" class="card-note" :title="item.note">{{ item.note }}</span>
</div>
</div>
<el-empty v-if="emailChannels.length === 0" description="暂无邮件通知配置" :image-size="60" />
</div>
</div>
<!-- 其他渠道 -->
<div class="channel-group" v-if="otherChannels.length > 0">
<div class="group-header other-header">
<div class="group-icon other-icon">
<svg viewBox="0 0 24 24" width="22" height="22"><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="currentColor"/></svg>
</div>
<div class="group-title-area">
<h3>其他渠道</h3>
<span class="group-count">{{ otherChannels.length }} 个事件</span>
</div>
</div>
<div class="channel-cards">
<div v-for="item in otherChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
<div class="card-top">
<span class="card-event-name">{{ item.eventName || '-' }}</span>
<el-tag size="small" type="info">{{ item.channel }}</el-tag>
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
</div>
<div class="card-bottom">
<code class="card-event-type">{{ item.eventType }}</code>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
<!-- ==================== 模板管理 ==================== -->
<el-tab-pane name="template">
<template #label>
<span class="tab-label">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z" fill="currentColor"/><path d="M8 12h8v2H8zm0 4h5v2H8z" fill="currentColor"/></svg>
模板管理
</span>
</template>
<div class="template-section">
<div class="tpl-toolbar">
<div class="tpl-toolbar-left">
<el-radio-group v-model="tplTypeFilter" size="default" @change="filterTemplates">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="email">
<svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-2px;margin-right:3px"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
邮件模板
</el-radio-button>
<el-radio-button value="phone">
<svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-2px;margin-right:3px"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/></svg>
短信模板
</el-radio-button>
</el-radio-group>
</div>
<el-button type="primary" :icon="Plus" @click="openTplDialog()">新增模板</el-button>
</div>
<el-table :data="filteredTemplates" v-loading="tplLoading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="65" align="center" />
<el-table-column prop="name" label="模板名称" min-width="140">
<template #default="{ row }">
<span class="tpl-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="tag" label="模板标识" min-width="160">
<template #default="{ row }">
<code class="tpl-tag">{{ row.tag }}</code>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.type === 'email' ? 'warning' : 'success'" effect="dark" size="small" round>
<span style="display:inline-flex;align-items:center;gap:3px">
<svg v-if="row.type === 'email'" viewBox="0 0 24 24" width="12" height="12"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
<svg v-else viewBox="0 0 24 24" width="12" height="12"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/></svg>
{{ row.type === 'email' ? '邮件' : '短信' }}
</span>
</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="模板内容" min-width="240">
<template #default="{ row }">
<div class="tpl-content-preview" :title="row.content">{{ row.content || '-' }}</div>
</template>
</el-table-column>
<el-table-column prop="args" label="参数" min-width="180">
<template #default="{ row }">
<div v-if="row.args" class="args-tags">
<el-tag v-for="arg in parseArgs(row.args)" :key="arg" size="small" type="info" effect="plain" class="arg-tag">{{ arg }}</el-tag>
</div>
<span v-else class="no-args">-</span>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="160" show-overflow-tooltip>
<template #default="{ row }"><span class="note-text">{{ row.note || '-' }}</span></template>
</el-table-column>
<el-table-column label="更新时间" width="160" align="center">
<template #default="{ row }">{{ formatTime(row.UpdatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="130" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openTplDialog(row)">编辑</el-button>
<el-button size="small" link type="danger" @click="handleDeleteTpl(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<!-- ==================== 模板编辑弹窗 ==================== -->
<el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="1060px" append-to-body destroy-on-close @opened="onTplDialogOpened">
<div class="tpl-dialog-body">
<!-- 左侧表单 -->
<div class="tpl-dialog-left">
<el-form :model="tplForm" :rules="tplRules" ref="tplFormRef" label-width="90px" label-position="right">
<el-form-item label="模板名称" prop="name">
<el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="模板标识" prop="tag">
<el-input v-model="tplForm.tag" placeholder="例:user_register_notify" maxlength="100" :disabled="!!tplForm.id" />
</el-form-item>
<el-form-item label="模板类型" prop="type">
<el-radio-group v-model="tplForm.type">
<el-radio value="email">
<span class="radio-with-icon">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="#e6a23c"/></svg>
邮件
</span>
</el-radio>
<el-radio value="phone">
<span class="radio-with-icon">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="#67c23a"/></svg>
短信
</span>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="可用参数" v-if="tplFormArgs.length > 0">
<div class="args-btn-group">
<el-button v-for="arg in tplFormArgs" :key="arg" size="small" @click="insertArg(arg)" class="arg-insert-btn">
<svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:3px"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/></svg>
{{ arg }}
</el-button>
</div>
<div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="8" placeholder="模板内容,点击上方参数按钮插入变量" />
</el-form-item>
</el-form>
</div>
<!-- 右侧渲染预览 -->
<div class="tpl-dialog-right">
<div class="preview-header">
<span class="preview-title">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="currentColor"/></svg>
渲染预览
</span>
<el-button size="small" link type="primary" @click="fetchPreview" :loading="previewLoading">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</div>
<div class="preview-body" v-loading="previewLoading">
<template v-if="previewData.rendered">
<div class="preview-rendered" :class="{ 'html-mode': tplForm.type === 'email' }" v-html="previewRenderedHtml"></div>
</template>
<div v-else-if="previewError" class="preview-empty">
<el-icon :size="32" color="#f56c6c"><WarningFilled /></el-icon>
<p>{{ previewError }}</p>
</div>
<div v-else class="preview-empty">
<el-icon :size="32" color="#c0c4cc"><View /></el-icon>
<p>填写模板标识和类型后自动加载预览</p>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="tplDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="tplSubmitting" @click="handleSubmitTpl">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, View, WarningFilled } from '@element-plus/icons-vue'
import {
getNoticeChannelList, updateNoticeChannel,
getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate,
previewNoticeTemplate
} from '@/api/admin/noticeChannel'
// ==================== ====================
const activeTab = ref('channel')
const channelLoading = ref(false)
const channelList = ref([])
const smsChannels = computed(() => channelList.value.filter(c => c.channel === 'sms'))
const emailChannels = computed(() => channelList.value.filter(c => c.channel === 'email'))
const otherChannels = computed(() => channelList.value.filter(c => c.channel !== 'sms' && c.channel !== 'email'))
const loadChannels = async () => {
channelLoading.value = true
try {
const res = await getNoticeChannelList()
const body = res?.data
if (body?.code === 200) {
const list = body.data?.data || body.data || []
channelList.value = (Array.isArray(list) ? list : []).map(item => ({ ...item, _switching: false }))
}
} catch {
ElMessage.error('加载渠道配置失败')
} finally {
channelLoading.value = false
}
}
const handleToggle = async (row, val) => {
row._switching = true
try {
const fd = new FormData()
fd.append('id', row.id)
fd.append('enabled', val)
const res = await updateNoticeChannel(fd)
if (res?.data?.code === 200) {
const chName = row.channel === 'sms' ? '短信' : row.channel === 'email' ? '邮件' : row.channel
ElMessage.success(`${val ? '启用' : '关闭'} ${row.eventName} - ${chName}`)
} else {
row.enabled = !val
ElMessage.error(res?.data?.message || '操作失败')
}
} catch {
row.enabled = !val
ElMessage.error('操作失败')
} finally {
row._switching = false
}
}
// ==================== ====================
const tplLoading = ref(false)
const templateList = ref([])
const tplTypeFilter = ref('')
const filteredTemplates = computed(() => {
if (!tplTypeFilter.value) return templateList.value
return templateList.value.filter(t => t.type === tplTypeFilter.value)
})
const filterTemplates = () => {}
const loadTemplates = async () => {
tplLoading.value = true
try {
const res = await getNoticeTemplateList()
const body = res?.data
if (body?.code === 200) {
const list = body.data?.data || body.data || []
templateList.value = Array.isArray(list) ? list : []
}
} catch {
ElMessage.error('加载模板列表失败')
} finally {
tplLoading.value = false
}
}
//
const tplDialogVisible = ref(false)
const tplSubmitting = ref(false)
const tplFormRef = ref(null)
const tplForm = ref({ id: 0, name: '', tag: '', content: '', type: 'email', args: '' })
const tplRules = {
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
tag: [{ required: true, message: '请输入模板标识', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
const contentInputRef = ref(null)
const tplFormArgs = computed(() => parseArgs(tplForm.value.args))
const insertArg = (arg) => {
const snippet = `{{index . "${arg}"}}`
const textarea = contentInputRef.value?.textarea
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const before = tplForm.value.content.slice(0, start)
const after = tplForm.value.content.slice(end)
tplForm.value.content = before + snippet + after
nextTick(() => {
const pos = start + snippet.length
textarea.focus()
textarea.setSelectionRange(pos, pos)
})
} else {
tplForm.value.content += snippet
}
}
const openTplDialog = (row) => {
if (row) {
tplForm.value = { id: row.id, name: row.name, tag: row.tag, content: row.content, type: row.type, args: row.args || '' }
} else {
tplForm.value = { id: 0, name: '', tag: '', content: '', type: 'email', args: '' }
}
tplDialogVisible.value = true
}
const handleSubmitTpl = async () => {
const formEl = tplFormRef.value
if (!formEl) return
await formEl.validate()
tplSubmitting.value = true
try {
const fd = new FormData()
if (tplForm.value.id) fd.append('id', tplForm.value.id)
fd.append('name', tplForm.value.name)
fd.append('tag', tplForm.value.tag)
fd.append('content', tplForm.value.content)
fd.append('type', tplForm.value.type)
if (tplForm.value.args) fd.append('args', tplForm.value.args)
const apiFn = tplForm.value.id ? updateNoticeTemplate : addNoticeTemplate
const res = await apiFn(fd)
if (res?.data?.code === 200) {
ElMessage.success(tplForm.value.id ? '模板已更新' : '模板已添加')
tplDialogVisible.value = false
loadTemplates()
} else {
ElMessage.error(res?.data?.message || '操作失败')
}
} catch {
ElMessage.error('操作失败')
} finally {
tplSubmitting.value = false
}
}
const handleDeleteTpl = async (row) => {
try {
await ElMessageBox.confirm(`确定要删除模板「${row.name}」吗?`, '确认删除', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
const res = await deleteNoticeTemplate({ id: row.id })
if (res?.data?.code === 200) {
ElMessage.success('模板已删除')
loadTemplates()
} else {
ElMessage.error(res?.data?.message || '删除失败')
}
} catch {}
}
// ==================== ====================
const previewLoading = ref(false)
const previewError = ref('')
const previewData = ref({ rendered: '', default_args: null })
const previewRenderedHtml = computed(() => {
const text = previewData.value.rendered || ''
if (tplForm.value.type === 'email') return text
return text.replace(/\n/g, '<br>')
})
let previewTimer = null
const debouncedFetchPreview = () => {
clearTimeout(previewTimer)
previewTimer = setTimeout(() => fetchPreview(), 600)
}
const fetchPreview = async () => {
const tag = tplForm.value.tag?.trim()
const type = tplForm.value.type
const content = tplForm.value.content?.trim()
if (!content && (!tag || !type)) {
previewData.value = { rendered: '', default_args: null }
previewError.value = ''
return
}
previewLoading.value = true
previewError.value = ''
try {
const params = {}
if (content) {
params.content = content
} else {
params.tag = tag
params.type = type
}
const res = await previewNoticeTemplate(params)
const body = res?.data
if (body?.code === 200 && body.data) {
previewData.value = body.data
} else {
previewData.value = { rendered: '', default_args: null }
previewError.value = body?.message || '渲染失败'
}
} catch {
previewData.value = { rendered: '', default_args: null }
previewError.value = '请求失败'
} finally {
previewLoading.value = false
}
}
const onTplDialogOpened = () => {
if (tplForm.value.tag && tplForm.value.type) {
fetchPreview()
}
}
watch(() => tplForm.value.tag, debouncedFetchPreview)
watch(() => tplForm.value.type, debouncedFetchPreview)
watch(() => tplForm.value.content, debouncedFetchPreview)
// ==================== ====================
const formatTime = (t) => {
if (!t) return '-'
const d = new Date(t)
if (isNaN(d.getTime())) return t
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
const parseArgs = (args) => {
if (!args) return []
return args.split('/').map(s => s.trim()).filter(Boolean)
}
// ==================== ====================
onMounted(() => {
loadChannels()
loadTemplates()
})
</script>
<style scoped>
.notice-page { padding: 20px 24px; }
/* ===== 页头 ===== */
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.header-left { display: flex; align-items: center; gap: 14px; }
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.page-title { margin: 0 0 2px; font-size: 20px; font-weight: 700; color: #1d2129; }
.page-desc { margin: 0; font-size: 13px; color: #86909c; }
/* ===== 标签页 ===== */
.main-tabs { border-radius: 8px; }
.tab-label { display: inline-flex; align-items: center; gap: 6px; }
/* ===== 渠道配置 ===== */
.channel-section { padding: 4px 0; }
.channel-group { margin-bottom: 24px; }
.channel-group:last-child { margin-bottom: 0; }
.group-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-radius: 10px; margin-bottom: 14px; }
.sms-header { background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%); }
.email-header { background: linear-gradient(135deg, #fdf6ec 0%, #faecd8 100%); }
.other-header { background: linear-gradient(135deg, #f4f4f5 0%, #e9e9eb 100%); }
.group-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sms-icon { background: #67c23a; color: #fff; }
.email-icon { background: #e6a23c; color: #fff; }
.other-icon { background: #909399; color: #fff; }
.group-title-area { flex: 1; }
.group-title-area h3 { margin: 0; font-size: 15px; font-weight: 600; color: #1d2129; }
.group-count { font-size: 12px; color: #86909c; }
.group-summary { display: flex; gap: 6px; }
.channel-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.channel-card { border: 1px solid #e4e7ed; border-radius: 8px; padding: 14px 16px; transition: all .25s; background: #fff; }
.channel-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); transform: translateY(-1px); }
.channel-card.enabled { border-left: 3px solid #67c23a; }
.channel-card.disabled { border-left: 3px solid #dcdfe6; opacity: .7; }
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.card-event-name { font-size: 14px; font-weight: 500; color: #1d2129; }
.card-bottom { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.card-event-type { font-size: 11px; font-family: 'Consolas','Monaco',monospace; color: #909399; background: #f5f7fa; padding: 1px 6px; border-radius: 3px; }
.card-note { font-size: 12px; color: #a8abb2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
/* ===== 模板管理 ===== */
.template-section { padding: 4px 0; }
.tpl-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 10px; }
.tpl-toolbar-left { display: flex; align-items: center; gap: 12px; }
.tpl-name { font-weight: 500; color: #1d2129; }
.tpl-tag { font-size: 12px; font-family: 'Consolas','Monaco',monospace; color: #606266; background: #f0f2f5; padding: 2px 8px; border-radius: 4px; }
.tpl-content-preview { font-size: 12px; color: #606266; max-height: 42px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.6; }
.note-text { color: #86909c; font-size: 13px; }
.no-args { color: #c0c4cc; }
.args-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.args-btn-group { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
.arg-insert-btn { font-family: 'Consolas','Monaco',monospace; font-size: 12px; }
.arg-tag { font-family: 'Consolas','Monaco',monospace; font-size: 11px; }
.form-hint { margin-top: 6px; font-size: 12px; color: #a8abb2; line-height: 1.4; }
.form-hint code { background: #f5f7fa; padding: 1px 5px; border-radius: 3px; font-size: 11px; color: #606266; }
/* ===== 弹窗双栏布局 ===== */
.radio-with-icon { display: inline-flex; align-items: center; gap: 4px; }
.tpl-dialog-body {
display: flex;
gap: 20px;
min-height: 380px;
}
.tpl-dialog-left {
flex: 1;
min-width: 0;
}
.tpl-dialog-right {
width: 380px;
flex-shrink: 0;
border: 1px solid #e4e7ed;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fafbfc;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
}
.preview-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.preview-body {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.preview-rendered {
font-size: 13px;
line-height: 1.7;
color: #303133;
word-break: break-all;
white-space: pre-wrap;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px;
max-height: 320px;
overflow-y: auto;
}
.preview-rendered.html-mode {
white-space: normal;
transform: scale(0.75);
transform-origin: top left;
width: 133.33%;
max-height: 426px;
}
.preview-args {
margin-top: 14px;
}
.preview-args-title {
font-size: 12px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
}
.preview-args-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-arg-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.preview-arg-key {
background: #ecf5ff;
color: #409eff;
padding: 2px 8px;
border-radius: 3px;
font-family: 'Consolas','Monaco',monospace;
flex-shrink: 0;
}
.preview-arg-val {
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: #909399;
}
.preview-empty p {
margin: 10px 0 0;
font-size: 13px;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+228 -72
View File
@@ -12,6 +12,12 @@
<el-form-item label="筛选用户">
<el-input-number v-model="queryParams.user_id" placeholder="请输入用户ID" :controls="false" clearable style="width: 150px" />
</el-form-item>
<el-form-item label="上传来源">
<el-select v-model="queryParams.is_admin" placeholder="全部" clearable style="width: 130px">
<el-option label="管理员" :value="true" />
<el-option label="用户" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
@@ -69,6 +75,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="上传来源" width="100">
<template #default="{ row }">
<el-tag :type="row.isAdmin ? 'warning' : ''">
{{ row.isAdmin ? '管理员' : '用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
@@ -102,65 +115,87 @@
<!-- 文件详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="文件详情"
width="700px"
title=""
width="720px"
destroy-on-close
class="file-detail-dialog"
>
<div v-if="fileDetail" class="file-detail-container">
<!-- 文件预览区域 -->
<div class="file-preview-section">
<div class="preview-label">文件预览</div>
<div class="preview-content">
<!-- 顶部卡片预览 + 核心信息 -->
<div class="detail-top">
<div class="detail-preview">
<el-image
v-if="isImageFile(fileDetail.type, fileDetail.url, fileDetail.realName) && fileDetail.url"
:src="fileDetail.url"
fit="contain"
style="max-width: 100%; max-height: 400px; border-radius: 8px;"
class="preview-image"
:preview-src-list="[fileDetail.url]"
:initial-index="0"
>
<template #error>
<div class="image-error">
<el-icon size="40"><Picture /></el-icon>
<div>图片加载失败</div>
<el-icon size="36"><Picture /></el-icon>
<span>加载失败</span>
</div>
</template>
</el-image>
<div v-else class="file-icon-large">
<el-icon size="80"><Document /></el-icon>
<div class="file-type-text">{{ fileDetail.type || '未知类型' }}</div>
<el-icon size="56" color="#909399"><Document /></el-icon>
</div>
</div>
<div class="detail-summary">
<div class="detail-filename" :title="fileDetail.realName">{{ fileDetail.realName }}</div>
<div class="detail-meta-row">
<el-tag :type="getFileTypeColor(fileDetail.type, fileDetail.url, fileDetail.realName)" size="small">{{ fileDetail.type || '未知' }}</el-tag>
<span class="detail-size">{{ formatFileSize(fileDetail.size) }}</span>
<el-tag :type="fileDetail.openDow ? 'success' : 'info'" size="small">{{ fileDetail.openDow ? '公开' : '私有' }}</el-tag>
<el-tag :type="fileDetail.isAdmin ? 'warning' : ''" size="small">{{ fileDetail.isAdmin ? '管理员' : '用户' }}</el-tag>
</div>
<div class="detail-meta-row secondary">
<span>ID: {{ fileDetail.id }}</span>
<span>用户: {{ fileDetail.userId }}</span>
<span>{{ formatDate(fileDetail.CreatedAt) }}</span>
</div>
<div class="detail-actions">
<el-button v-if="fileDetail.url" type="primary" size="small" @click="openFileUrl(fileDetail.url)">
<el-icon><View /></el-icon>查看原文件
</el-button>
<el-button v-if="fileDetail.url" type="success" size="small" @click="handleDownload(fileDetail)">
<el-icon><Download /></el-icon>下载
</el-button>
<el-button type="default" size="small" @click="copyPath(fileDetail.savePath)">
<el-icon><DocumentCopy /></el-icon>复制路径
</el-button>
</div>
</div>
</div>
<!-- 文件信息 -->
<el-descriptions :column="2" border class="file-info-descriptions">
<el-descriptions-item label="文件ID" label-align="right">{{ fileDetail.id }}</el-descriptions-item>
<el-descriptions-item label="用户ID" label-align="right">{{ fileDetail.userId }}</el-descriptions-item>
<el-descriptions-item label="真实文件名" label-align="right" :span="2">{{ fileDetail.realName }}</el-descriptions-item>
<el-descriptions-item label="保存名称" label-align="right">{{ fileDetail.saveName }}</el-descriptions-item>
<el-descriptions-item label="文件类型" label-align="right">
<el-tag :type="getFileTypeColor(fileDetail.type, fileDetail.url, fileDetail.realName)">{{ fileDetail.type || '未知' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件大小" label-align="right">{{ formatFileSize(fileDetail.size) }}</el-descriptions-item>
<el-descriptions-item label="是否公开" label-align="right">
<el-tag :type="fileDetail.openDow ? 'success' : 'info'">
{{ fileDetail.openDow ? '公开访问' : '私有' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="保存路径" label-align="right" :span="2">
<span class="file-path">{{ fileDetail.savePath }}</span>
</el-descriptions-item>
<el-descriptions-item label="文件URL" label-align="right" :span="2">
<el-link :href="fileDetail.url" target="_blank" type="primary" v-if="fileDetail.url">
点击查看文件
</el-link>
<span v-else style="color: #909399;">无URL</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间" label-align="right">{{ formatDate(fileDetail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间" label-align="right">{{ formatDate(fileDetail.UpdatedAt) }}</el-descriptions-item>
<el-descriptions-item label="备注" label-align="right" :span="2">{{ fileDetail.content || '无' }}</el-descriptions-item>
</el-descriptions>
<!-- 详细信息网格 -->
<div class="detail-grid">
<div class="detail-grid-item">
<span class="grid-label">保存名称</span>
<span class="grid-value mono">{{ fileDetail.saveName }}</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">保存路径</span>
<span class="grid-value mono">{{ fileDetail.savePath }}</span>
</div>
<div class="detail-grid-item" v-if="fileDetail.url">
<span class="grid-label">访问地址</span>
<div class="grid-value url-row">
<el-link :href="fileDetail.url" target="_blank" type="primary" class="url-text">{{ fileDetail.url }}</el-link>
<el-icon class="url-copy-icon" @click="copyPath(fileDetail.url)" title="复制地址"><DocumentCopy /></el-icon>
</div>
</div>
<div class="detail-grid-item" v-if="fileDetail.UpdatedAt && fileDetail.UpdatedAt !== fileDetail.CreatedAt">
<span class="grid-label">更新时间</span>
<span class="grid-value">{{ formatDate(fileDetail.UpdatedAt) }}</span>
</div>
<div class="detail-grid-item" v-if="fileDetail.content">
<span class="grid-label">备注</span>
<span class="grid-value">{{ fileDetail.content }}</span>
</div>
</div>
</div>
</el-dialog>
@@ -255,13 +290,14 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Delete, Search, Document, VideoPlay, Folder, UploadFilled, Picture, Refresh } from '@element-plus/icons-vue'
import { Upload, Delete, Search, Document, VideoPlay, Folder, UploadFilled, Picture, Refresh, View, Download, DocumentCopy } from '@element-plus/icons-vue'
import { getFileList, getFileDetail, updateFile, deleteFile, uploadFile } from '@/api/admin/file'
//
const queryParams = reactive({
key: '',
user_id: undefined,
is_admin: undefined,
page: 1,
count: 10
})
@@ -391,6 +427,7 @@ const handleQuery = () => {
const resetQuery = () => {
queryParams.key = ''
queryParams.user_id = undefined
queryParams.is_admin = undefined
queryParams.page = 1
fetchFileList()
}
@@ -676,6 +713,20 @@ const submitEditForm = () => {
})
}
//
const openFileUrl = (url) => {
window.open(url, '_blank')
}
//
const copyPath = (text) => {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制到剪贴板')
}).catch(() => {
ElMessage.info(text)
})
}
//
onMounted(() => {
fetchFileList()
@@ -750,28 +801,43 @@ onMounted(() => {
}
.file-detail-container {
padding: 10px 0;
padding: 0;
}
.file-preview-section {
margin-bottom: 24px;
}
.preview-label {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
}
.preview-content {
/* 顶部区域:预览 + 摘要 */
.detail-top {
display: flex;
gap: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f4;
margin-bottom: 16px;
}
.detail-preview {
flex-shrink: 0;
width: 200px;
min-height: 160px;
max-height: 300px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f5f7fa;
border-radius: 8px;
padding: 20px;
min-height: 200px;
justify-content: center;
background: #f8f9fb;
border-radius: 10px;
overflow: hidden;
padding: 8px;
}
.preview-image {
width: 100%;
height: 100%;
max-height: 280px;
border-radius: 6px;
}
.preview-image :deep(img) {
object-fit: contain;
width: 100%;
height: 100%;
}
.file-icon-large {
@@ -780,12 +846,6 @@ onMounted(() => {
align-items: center;
justify-content: center;
color: #909399;
gap: 12px;
}
.file-type-text {
font-size: 14px;
color: #606266;
}
.image-error {
@@ -793,23 +853,119 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
color: #c0c4cc;
gap: 6px;
font-size: 12px;
}
.detail-summary {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}
.detail-filename {
font-size: 16px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-meta-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.file-info-descriptions {
margin-top: 16px;
.detail-meta-row.secondary {
font-size: 12px;
color: #909399;
gap: 14px;
}
.file-path {
font-family: 'Courier New', monospace;
.detail-size {
font-size: 13px;
color: #606266;
font-weight: 500;
}
.detail-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* 详细信息网格 */
.detail-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-grid-item {
display: flex;
align-items: baseline;
gap: 12px;
}
.grid-label {
flex-shrink: 0;
width: 70px;
font-size: 12px;
color: #909399;
text-align: right;
}
.grid-value {
font-size: 13px;
color: #303133;
word-break: break-all;
line-height: 1.5;
}
.grid-value.mono {
font-family: 'SF Mono', 'Menlo', 'Courier New', monospace;
font-size: 12px;
color: #606266;
word-break: break-all;
}
:deep(.el-descriptions__label) {
width: 120px;
.url-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.url-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 420px;
}
.url-copy-icon {
flex-shrink: 0;
cursor: pointer;
color: #909399;
transition: color 0.2s;
}
.url-copy-icon:hover {
color: #409eff;
}
:deep(.file-detail-dialog .el-dialog__header) {
padding: 16px 20px 0;
}
:deep(.file-detail-dialog .el-dialog__body) {
padding: 20px;
}
/* 表格样式优化 */
+617 -101
View File
@@ -52,14 +52,22 @@
</el-button>
</div>
</el-popover>
<div class="ticket-title" v-if="ticketInfo">{{ ticketInfo.title }}</div>
<div class="ticket-title" v-if="ticketInfo">
<span class="weight-indicator" :class="'weight-' + ticketInfo.weight" :title="getWeightLabel(ticketInfo.weight)"></span>
{{ ticketInfo.title }}
</div>
<el-tag v-if="ticketInfo?.workOrderType?.name" size="small" type="info" class="type-tag">{{ ticketInfo.workOrderType.name }}</el-tag>
</div>
<div class="header-right" v-if="ticketInfo">
<span class="ticket-id">工单号: {{ ticketInfo.id }}</span>
<span class="ticket-id-badge" @click="copyTicketId" title="点击复制工单号">
#{{ ticketInfo.id }}
<el-icon :size="12"><DocumentCopy /></el-icon>
</span>
<el-select
v-model="ticketInfo.status"
size="small"
style="width: 120px"
:class="'status-select-' + ticketInfo.status"
@change="handleStatusChange"
>
<el-option label="待处理" value="pending" />
@@ -78,46 +86,86 @@
</div>
</div>
<!-- 关联商品信息 -->
<div class="goods-info-panel" v-if="ticketInfo?.userGoods">
<div class="goods-panel-header">
<el-icon :size="14"><Box /></el-icon>
<span class="goods-panel-title">关联商品</span>
<span class="gp-link gp-jump" @click="goToUserGoods">查看详情 </span>
</div>
<div class="goods-panel-body">
<div class="goods-panel-item" v-if="ticketInfo.userGoods.good?.name || ticketInfo.userGoods.tag">
<span class="gp-label">商品</span>
<span class="gp-value">{{ ticketInfo.userGoods.good?.name || ticketInfo.userGoods.tag }}</span>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.tag">
<span class="gp-label">分类</span>
<el-tag size="small" type="info">{{ ticketInfo.userGoods.tag }}</el-tag>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.itemArg?.name">
<span class="gp-label">实例</span>
<span class="gp-value">{{ ticketInfo.userGoods.itemArg.name }}</span>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.itemArg?.status">
<span class="gp-label">状态</span>
<span class="gp-status-indicator" :class="ticketInfo.userGoods.itemArg.status === 'running' ? 'running' : 'other'"></span>
<el-tag :type="ticketInfo.userGoods.itemArg.status === 'running' ? 'success' : 'warning'" size="small">{{ ticketInfo.userGoods.itemArg.status }}</el-tag>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.itemArg?.ips">
<span class="gp-label">IP</span>
<span class="gp-value gp-ips gp-copyable" @click.stop="copyText(ticketInfo.userGoods.itemArg.ips)" title="点击复制">{{ ticketInfo.userGoods.itemArg.ips }}</span>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.expireTime">
<span class="gp-label">到期</span>
<span class="gp-value">{{ formatGoodsTime(ticketInfo.userGoods.expireTime) }}</span>
</div>
</div>
</div>
<!-- 聊天记录 -->
<div class="chat-container" v-loading="isLoadingMessages">
<div class="chat-messages" ref="messagesContainer">
<div
v-for="(message, index) in messages"
:key="index"
:class="['message-item', message.isAdmin ? 'message-admin' : 'message-user']"
>
<div class="message-avatar" v-if="!message.isAdmin">
<el-avatar :size="36" :src="message.avatar">{{ ticketInfo?.username?.charAt(0) || 'U' }}</el-avatar>
<template v-for="(message, index) in messages" :key="index">
<div class="time-separator" v-if="showTimeSeparator(index)">
<span class="time-separator-text">{{ formatMessageTime(message.time) }}</span>
</div>
<div class="message-content">
<div class="message-text" v-if="message.content">
{{ message.content }}
<span
v-if="message.isAdmin && !message.isTempMessage"
class="edit-btn"
@click="handleEditMessage(message)"
title="编辑消息"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</span>
<div :class="['message-item', message.isAdmin ? 'message-admin' : 'message-user']">
<div class="message-avatar" v-if="!message.isAdmin">
<el-avatar :size="36" :src="message.avatar">{{ ticketInfo?.username?.charAt(0) || 'U' }}</el-avatar>
</div>
<div class="message-images" v-if="message.images && message.images.length">
<img
v-for="(img, imgIndex) in message.images"
:key="imgIndex"
:src="img"
class="message-image"
@click="openImage(img)"
/>
<div class="message-content">
<div class="message-text" v-if="message.content">
{{ message.content }}
<span
v-if="message.isAdmin && !message.isTempMessage"
class="edit-btn"
@click="handleEditMessage(message)"
title="编辑消息"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</span>
</div>
<div class="message-images" v-if="message.images && message.images.length">
<img
v-for="(img, imgIndex) in message.images"
:key="imgIndex"
:src="img"
class="message-image"
@click="openImage(img)"
/>
</div>
<div class="message-time" v-if="!showTimeSeparator(index)">{{ formatMessageTime(message.time) }}</div>
</div>
<div class="message-avatar" v-if="message.isAdmin">
<el-avatar :size="36" :src="message.avatar">A</el-avatar>
</div>
<div class="message-time">{{ formatMessageTime(message.time) }}</div>
</div>
<div class="message-avatar" v-if="message.isAdmin">
<el-avatar :size="36" :src="message.avatar">A</el-avatar>
</div>
</div>
</template>
</div>
<div class="new-message-tip" v-if="hasNewMessages" @click="scrollToBottom">
有新消息
</div>
</div>
@@ -131,16 +179,32 @@
</div>
</div>
<!-- 快捷回复 -->
<div class="quick-replies">
<el-button
v-for="(reply, index) in quickReplies"
:key="index"
<!-- 回复模板 -->
<div class="reply-templates">
<el-input
v-model="templateKeyword"
placeholder="筛选模板"
size="small"
@click="useQuickReply(reply)"
clearable
class="template-filter-input"
@input="() => fetchTemplateList(true)"
/>
<el-tooltip
v-for="tpl in templateList"
:key="tpl.id"
:content="tpl.content?.slice(0, 80) + (tpl.content?.length > 80 ? '...' : '')"
placement="top"
:show-after="400"
>
{{ reply.title }}
</el-button>
<div class="template-tag" @click="useTemplate(tpl)">
<span class="template-tag-name">{{ tpl.name }}</span>
<el-icon class="template-tag-delete" @click.stop="handleTemplateDelete(tpl)"><Close /></el-icon>
</div>
</el-tooltip>
<div class="template-tag template-tag-add" @click="openAddTemplate">
<el-icon><Plus /></el-icon>
<span>新增模板</span>
</div>
</div>
<!-- 输入框 -->
@@ -151,14 +215,17 @@
@dragleave.prevent="handleDragLeave"
:class="{ 'drag-over': isDragOver }"
>
<el-input
ref="textareaRef"
v-model="messageInput"
type="textarea"
:rows="3"
placeholder="请输入回复内容(支持粘贴图片和拖拽图片)..."
@keyup.ctrl.enter="sendMessage"
/>
<div class="textarea-wrap">
<el-input
ref="textareaRef"
v-model="messageInput"
type="textarea"
:rows="3"
placeholder="请输入回复内容(支持粘贴图片和拖拽图片)..."
@keyup.ctrl.enter="sendMessage"
/>
<span class="char-count" v-if="messageInput.length > 0">{{ messageInput.length }}</span>
</div>
<div class="input-actions">
<div class="left-actions">
<el-upload
@@ -171,15 +238,18 @@
>
<el-button type="primary" plain icon="Plus">图片</el-button>
</el-upload>
<el-button plain @click="saveCurrentAsTemplate" :disabled="!messageInput.trim()">存为模板</el-button>
<el-button plain @click="loadMoreTemplates" :loading="templateLoadingMore" v-if="templateHasMore">加载更多</el-button>
</div>
<span class="hint-text">Ctrl + Enter 发送</span>
<el-button
type="primary"
:type="sendSuccess ? 'success' : 'primary'"
:disabled="!messageInput.trim() && selectedImages.length === 0"
:loading="isSending"
@click="sendMessage"
:class="{ 'send-success-anim': sendSuccess }"
>
发送
{{ sendSuccess ? '✓' : '发送' }}
</el-button>
</div>
</div>
@@ -190,6 +260,27 @@
<el-alert title="该工单已结束,无法继续回复" type="info" :closable="false" />
</div>
<!-- 模板编辑对话框 -->
<el-dialog
v-model="showTemplateDialog"
:title="isEditingTemplate ? '编辑模板' : '新增模板'"
width="480px"
destroy-on-close
>
<el-form label-width="80px">
<el-form-item label="模板名称">
<el-input v-model="templateForm.name" placeholder="请输入模板名称" maxlength="50" />
</el-form-item>
<el-form-item label="模板内容">
<el-input v-model="templateForm.content" type="textarea" :rows="5" placeholder="请输入模板内容" maxlength="1000" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTemplateDialog = false">取消</el-button>
<el-button type="primary" @click="handleTemplateSave">保存</el-button>
</template>
</el-dialog>
<!-- 编辑消息对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑消息" width="600px">
<el-form>
@@ -242,8 +333,8 @@
import { ref, onMounted, nextTick, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getTicketDetail, replyTicket, closeTicket, updateTicketInfo, updateTicketReplayInfo } from '@/api/ticket'
import { Plus } from '@element-plus/icons-vue'
import { getTicketDetail, replyTicket, closeTicket, updateTicketInfo, updateTicketReplayInfo, getReplyTemplateList, createReplyTemplate, updateReplyTemplate, deleteReplyTemplate } from '@/api/ticket'
import { Plus, Box, Search, Edit, Delete, Close, DocumentCopy } from '@element-plus/icons-vue'
import { getUserInfo } from '@/api/admin/user'
import { uploadFile } from '@/api/admin/file'
import { useUserStore } from '@/store/userStore'
@@ -259,6 +350,7 @@ const ticketInfo = ref(null)
const messages = ref([])
const isLoadingMessages = ref(false)
const isSending = ref(false)
const sendSuccess = ref(false)
//
const userDetail = ref(null)
@@ -288,13 +380,116 @@ const isEditingSaving = ref(false)
//
const refreshTimer = ref(null)
//
const quickReplies = [
{ title: '您好,有什么可以帮助您的?', content: '您好,有什么可以帮助您的?' },
{ title: '正在处理中', content: '您的问题正在处理中,请稍等片刻,我们会尽快给您答复。' },
{ title: '需要更多信息', content: '为了更好地解决您的问题,请您提供更多相关信息。' },
{ title: '问题已解决', content: '您的问题已解决,感谢您的反馈。如有其他问题,请随时联系我们。' }
]
//
const templateList = ref([])
const templateKeyword = ref('')
const templatePage = ref(1)
const templatePageSize = ref(10)
const templateTotal = ref(0)
const templateHasMore = ref(false)
const templateLoadingMore = ref(false)
const showTemplateDialog = ref(false)
const templateForm = ref({ id: null, name: '', content: '' })
const isEditingTemplate = ref(false)
const fetchTemplateList = async (reset = true) => {
try {
if (reset) templatePage.value = 1
const params = { count: templatePageSize.value, page: templatePage.value }
if (templateKeyword.value) params.keyword = templateKeyword.value
const res = await getReplyTemplateList(params)
if (res.code === 200) {
const list = res.data?.data || []
templateTotal.value = res.data?.all_count || 0
if (reset) {
templateList.value = list
} else {
templateList.value = [...templateList.value, ...list]
}
templateHasMore.value = templateList.value.length < templateTotal.value
}
} catch (e) {
console.error('获取模板列表失败', e)
}
}
const loadMoreTemplates = async () => {
templateLoadingMore.value = true
templatePage.value++
await fetchTemplateList(false)
templateLoadingMore.value = false
}
const useTemplate = (tpl) => {
messageInput.value = tpl.content
}
const openAddTemplate = () => {
isEditingTemplate.value = false
templateForm.value = { id: null, name: '', content: '' }
showTemplateDialog.value = true
}
const openEditTemplate = (tpl) => {
isEditingTemplate.value = true
templateForm.value = { id: tpl.id, name: tpl.name, content: tpl.content }
showTemplateDialog.value = true
}
const handleTemplateSave = async () => {
if (!templateForm.value.name.trim() || !templateForm.value.content.trim()) {
ElMessage.warning('模板名称和内容不能为空')
return
}
try {
if (isEditingTemplate.value) {
const res = await updateReplyTemplate(templateForm.value)
if (res.code === 200) {
ElMessage.success('模板已更新')
} else {
ElMessage.error(res.message || '更新失败')
return
}
} else {
const res = await createReplyTemplate({ name: templateForm.value.name, content: templateForm.value.content })
if (res.code === 200) {
ElMessage.success('模板已创建')
} else {
ElMessage.error(res.message || '创建失败')
return
}
}
showTemplateDialog.value = false
fetchTemplateList()
} catch (e) {
ElMessage.error('操作失败')
}
}
const handleTemplateDelete = async (tpl) => {
try {
await ElMessageBox.confirm(`确定删除模板「${tpl.name}」?`, '删除确认', { type: 'warning' })
const res = await deleteReplyTemplate({ id: tpl.id })
if (res.code === 200) {
ElMessage.success('已删除')
fetchTemplateList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (e) {
//
}
}
const saveCurrentAsTemplate = () => {
if (!messageInput.value.trim()) {
ElMessage.warning('输入框内容为空,无法保存为模板')
return
}
isEditingTemplate.value = false
templateForm.value = { id: null, name: '', content: messageInput.value.trim() }
showTemplateDialog.value = true
}
//
const convertStatusToString = (status) => {
@@ -370,7 +565,10 @@ const fetchTicketDetail = async (showLoading = true) => {
userId: detail.user?.userId,
avatar: detail.user?.coverUrl || '',
createTime: new Date(detail.created_at).toLocaleString(),
status: convertStatusToString(detail.status)
status: convertStatusToString(detail.status),
userGoods: detail.userGoods ? parseUserGoods(detail.userGoods) : null,
workOrderType: detail.workOrderType || null,
weight: detail.weight
}
// ID
@@ -392,11 +590,19 @@ const fetchTicketDetail = async (showLoading = true) => {
const hasChanges = !messagesEqual(messages.value, newMessages)
if (hasChanges) {
const shouldScroll = showLoading || newMessages.length > messages.value.length
const isNewMsg = newMessages.length > messages.value.length
messages.value = newMessages
if (shouldScroll) {
if (showLoading) {
nextTick(() => scrollToBottom())
} else if (isNewMsg) {
const container = messagesContainer.value
const isAtBottom = container && (container.scrollHeight - container.scrollTop - container.clientHeight < 80)
if (isAtBottom) {
nextTick(() => scrollToBottom())
} else {
hasNewMessages.value = true
}
}
}
}
@@ -497,7 +703,8 @@ const sendMessage = async () => {
if (res.code === 200) {
messages.value = messages.value.filter(msg => !msg.isTempMessage)
await fetchTicketDetail()
ElMessage.success('回复成功')
sendSuccess.value = true
setTimeout(() => { sendSuccess.value = false }, 1500)
} else {
messages.value = messages.value.filter(msg => !msg.isTempMessage)
messageInput.value = inputMsg
@@ -790,15 +997,22 @@ const saveEditMessage = async () => {
}
}
//
const useQuickReply = (reply) => {
messageInput.value = reply.content
}
//
const hasNewMessages = ref(false)
const showTimeSeparator = (index) => {
if (index === 0) return true
const curr = new Date(messages.value[index]?.time)
const prev = new Date(messages.value[index - 1]?.time)
if (isNaN(curr.getTime()) || isNaN(prev.getTime())) return false
return (curr.getTime() - prev.getTime()) > 3600000
}
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
hasNewMessages.value = false
}
}
@@ -863,11 +1077,32 @@ const formatMessageTime = (timeStr) => {
//
const goBack = () => {
// tab
tagsViewStore.delVisitedView(route)
router.push('/ticket/list')
}
const getWeightLabel = (weight) => {
const map = { 0: '常规', 1: '一般', 2: '紧急', 3: '非常紧急' }
return map[weight] || '常规'
}
const copyTicketId = () => {
const id = `#${ticketInfo.value.id}`
navigator.clipboard.writeText(id).then(() => {
ElMessage.success(`已复制 ${id}`)
}).catch(() => {
ElMessage.info(id)
})
}
const copyText = (text) => {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制')
}).catch(() => {
ElMessage.info(text)
})
}
//
const fetchUserDetail = async () => {
if (!ticketInfo.value?.userId) return
@@ -887,10 +1122,40 @@ const fetchUserDetail = async () => {
//
const goToUserDetail = () => {
if (ticketInfo.value?.userId) {
router.push({ path: '/user/detail', query: { user_id: ticketInfo.value.userId } })
const href = router.resolve({ path: '/user/detail', query: { user_id: ticketInfo.value.userId } }).href
window.open(href, '_blank')
}
}
const goToUserGoods = () => {
const goods = ticketInfo.value?.userGoods
if (!goods) return
const tag = (goods.tag || goods.good?.tag || '').toLowerCase()
let href
if (tag === '云服务器') {
href = router.resolve({ path: '/user-goods/vm-detail', query: { id: goods.id } }).href
} else {
href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
}
window.open(href, '_blank')
}
const parseUserGoods = (raw) => {
const goods = { ...raw }
// itemArg JSON
if (typeof goods.itemArg === 'string') {
try { goods.itemArg = JSON.parse(goods.itemArg) } catch { goods.itemArg = null }
}
return goods
}
const formatGoodsTime = (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')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
//
const startAutoRefresh = () => {
refreshTimer.value = setInterval(() => {
@@ -919,17 +1184,24 @@ watch(
}
)
// ticketInfo textarea v-if
let pasteBindDone = false
watch(ticketInfo, (val) => {
if (val && !pasteBindDone) {
pasteBindDone = true
nextTick(() => {
const textarea = textareaRef.value?.$el?.querySelector('textarea')
if (textarea) {
textarea.addEventListener('paste', handlePaste)
}
})
}
})
onMounted(() => {
fetchTicketDetail()
fetchTemplateList()
startAutoRefresh()
// textarea
nextTick(() => {
const textarea = textareaRef.value?.$el?.querySelector('textarea')
if (textarea) {
textarea.addEventListener('paste', handlePaste)
}
})
})
onBeforeUnmount(() => {
@@ -948,7 +1220,7 @@ onBeforeUnmount(() => {
padding: 0;
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
height: calc(100vh - 148px);
min-height: 600px;
}
@@ -961,6 +1233,70 @@ onBeforeUnmount(() => {
border-bottom: 1px solid #ebeef5;
}
.goods-info-panel {
background: #f8fbff;
border-bottom: 1px solid #e6f2ff;
padding: 10px 20px;
}
.goods-panel-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
color: #606266;
font-size: 13px;
font-weight: 500;
}
.goods-panel-title {
color: #303133;
flex: 1;
}
.goods-panel-header .gp-jump {
font-size: 12px;
font-weight: 400;
}
.goods-panel-body {
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
}
.goods-panel-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.goods-panel-item .gp-label {
color: #909399;
}
.goods-panel-item .gp-value {
color: #303133;
}
.goods-panel-item .gp-link {
color: #409eff;
cursor: pointer;
font-weight: 500;
}
.goods-panel-item .gp-link:hover,
.goods-panel-header .gp-link:hover {
text-decoration: underline;
}
.goods-panel-item .gp-ips {
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.header-left {
display: flex;
align-items: center;
@@ -973,7 +1309,7 @@ onBeforeUnmount(() => {
gap: 12px;
}
.ticket-id {
.ticket-id, .ticket-id-badge {
font-weight: 500;
color: #303133;
font-size: 14px;
@@ -1050,6 +1386,9 @@ onBeforeUnmount(() => {
}
.ticket-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 15px;
font-weight: 500;
color: #303133;
@@ -1059,10 +1398,145 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.weight-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.weight-indicator.weight-0 { background: #c0c4cc; }
.weight-indicator.weight-1 { background: #409eff; }
.weight-indicator.weight-2 { background: #e6a23c; }
.weight-indicator.weight-3 { background: #f56c6c; animation: pulse-weight 1.5s infinite; }
@keyframes pulse-weight {
0% { box-shadow: 0 0 0 0 rgba(245,108,108,0.5); }
70% { box-shadow: 0 0 0 5px rgba(245,108,108,0); }
100% { box-shadow: 0 0 0 0 rgba(245,108,108,0); }
}
.type-tag {
margin-left: 6px;
flex-shrink: 0;
}
.ticket-id-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: #f4f4f5;
border-radius: 4px;
font-size: 13px;
font-family: 'SF Mono', 'Menlo', monospace;
color: #606266;
cursor: pointer;
transition: all 0.2s;
}
.ticket-id-badge:hover {
background: #ecf5ff;
color: #409eff;
}
.status-select-pending :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #e6a23c inset; }
.status-select-processing :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #409eff inset; }
.status-select-replied :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #909399 inset; }
.status-select-completed :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #67c23a inset; }
/* 商品面板增强 */
.gp-status-indicator {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.gp-status-indicator.running {
background: #67c23a;
animation: pulse-weight 1.5s infinite;
}
.gp-status-indicator.other {
background: #c0c4cc;
}
.gp-copyable {
cursor: pointer;
transition: color 0.2s;
}
.gp-copyable:hover {
color: #409eff;
}
/* 聊天区增强 */
.time-separator {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
}
.time-separator-text {
font-size: 12px;
color: #909399;
background: #ebeef5;
padding: 2px 12px;
border-radius: 10px;
}
.new-message-tip {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
padding: 6px 16px;
background: #409eff;
color: #fff;
border-radius: 16px;
font-size: 12px;
cursor: pointer;
box-shadow: 0 2px 12px rgba(64,158,255,0.3);
transition: all 0.2s;
z-index: 10;
}
.new-message-tip:hover {
background: #337ecc;
}
/* 输入框增强 */
.textarea-wrap {
position: relative;
}
.char-count {
position: absolute;
bottom: 6px;
right: 12px;
font-size: 11px;
color: #c0c4cc;
pointer-events: none;
}
.send-success-anim {
animation: send-pop 0.3s ease;
}
@keyframes send-pop {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.chat-container {
position: relative;
flex: 1;
min-height: 400px;
background: #f5f7fa;
background: #f8f9fb;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -1095,10 +1569,11 @@ onBeforeUnmount(() => {
.message-text {
background: #fff;
padding: 12px 16px;
border-radius: 8px;
border-radius: 12px 12px 12px 4px;
word-break: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
position: relative;
line-height: 1.5;
}
.message-text .edit-btn {
@@ -1124,8 +1599,9 @@ onBeforeUnmount(() => {
}
.message-admin .message-text {
background: #409eff;
background: linear-gradient(135deg, #409eff, #337ecc);
color: #fff;
border-radius: 12px 12px 4px 12px;
}
.message-admin .message-text .edit-btn {
@@ -1204,11 +1680,64 @@ onBeforeUnmount(() => {
font-size: 14px;
}
.quick-replies {
.reply-templates {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
margin-bottom: 10px;
}
.template-filter-input {
width: 130px;
}
.template-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
border-radius: 4px;
border: 1px solid var(--el-border-color);
background: var(--el-fill-color-blank);
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.template-tag:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.template-tag-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-tag-delete {
font-size: 12px;
color: var(--el-text-color-placeholder);
border-radius: 50%;
transition: all 0.2s;
}
.template-tag-delete:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
.template-tag-add {
border-style: dashed;
color: var(--el-text-color-secondary);
}
.template-tag-add:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.input-area {
@@ -1350,10 +1879,6 @@ onBeforeUnmount(() => {
max-width: 70%;
}
.quick-replies {
flex-wrap: wrap;
}
.input-area {
gap: 10px;
}
@@ -1445,15 +1970,6 @@ onBeforeUnmount(() => {
padding: 12px;
}
.quick-replies {
gap: 6px;
}
.quick-replies .el-button {
font-size: 12px;
padding: 6px 10px;
}
.input-actions {
flex-direction: column;
gap: 12px;
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
<template>
<div class="ticket-templates-page">
<div class="page-header">
<h3>回复模板管理</h3>
<div class="header-actions">
<el-input
v-model="keyword"
placeholder="搜索模板名称或内容"
clearable
:prefix-icon="Search"
style="width: 240px"
@clear="loadList"
@keyup.enter="loadList"
/>
<el-button type="primary" :icon="Plus" @click="openAdd">新增模板</el-button>
</div>
</div>
<el-table :data="templateList" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="模板名称" min-width="160" />
<el-table-column prop="content" label="模板内容" min-width="300" show-overflow-tooltip />
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatTime(row.UpdatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="currentPage"
@current-change="handlePageChange"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑模板' : '新增模板'"
width="560px"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入模板名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="6" placeholder="请输入模板内容" maxlength="2000" show-word-limit />
</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, onMounted } from 'vue'
import { Plus, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getReplyTemplateList, createReplyTemplate, updateReplyTemplate, deleteReplyTemplate } from '@/api/ticket.js'
const loading = ref(false)
const templateList = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref(null)
const keyword = ref('')
const currentPage = ref(1)
const pageSize = ref(15)
const total = ref(0)
const form = ref({
id: null,
name: '',
content: ''
})
const rules = {
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
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')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const loadList = async () => {
loading.value = true
try {
const params = { count: pageSize.value, page: currentPage.value }
if (keyword.value.trim()) params.keyword = keyword.value.trim()
const res = await getReplyTemplateList(params)
if (res.code === 200) {
templateList.value = res.data?.data || []
total.value = res.data?.all_count || 0
}
} catch (e) {
console.error('加载模板列表失败', e)
}
loading.value = false
}
const handlePageChange = (page) => {
currentPage.value = page
loadList()
}
const openAdd = () => {
isEdit.value = false
form.value = { id: null, name: '', content: '' }
dialogVisible.value = true
}
const openEdit = (row) => {
isEdit.value = true
form.value = { id: row.id, name: row.name, content: row.content }
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value.validate()
submitting.value = true
try {
let res
if (isEdit.value) {
res = await updateReplyTemplate({ id: form.value.id, name: form.value.name, content: form.value.content })
} else {
res = await createReplyTemplate({ name: form.value.name, content: form.value.content })
}
if (res.code === 200) {
ElMessage.success(isEdit.value ? '修改成功' : '添加成功')
dialogVisible.value = false
loadList()
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
submitting.value = false
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除模板「${row.name}」吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
}).then(async () => {
try {
const res = await deleteReplyTemplate({ id: row.id })
if (res.code === 200) {
ElMessage.success('删除成功')
loadList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
onMounted(() => { loadList() })
</script>
<style scoped>
.ticket-templates-page {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #2c3e50;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+194
View File
@@ -0,0 +1,194 @@
<template>
<div class="ticket-types-page">
<div class="page-header">
<h3>工单类型管理</h3>
<el-button type="primary" :icon="Plus" @click="openAdd">新增类型</el-button>
</div>
<el-table :data="typeList" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="类型名称" min-width="160" />
<el-table-column prop="note" label="说明" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.note || '—' }}
</template>
</el-table-column>
<el-table-column label="图标" width="80" align="center">
<template #default="{ row }">
<el-avatar v-if="row.ico?.savePath" :size="32" :src="row.ico.savePath" shape="square" />
<span v-else class="no-icon"></span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑工单类型' : '新增工单类型'"
width="480px"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<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="3" placeholder="请输入说明(选填)" />
</el-form-item>
<el-form-item label="图标ID" prop="icoId">
<el-input-number v-model="form.icoId" :min="0" placeholder="文件ID" controls-position="right" />
</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, onMounted } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getTicketTypeList, addTicketType, updateTicketType, deleteTicketType } from '@/api/ticket.js'
const loading = ref(false)
const typeList = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref(null)
const form = ref({
id: null,
name: '',
note: '',
icoId: 0
})
const rules = {
name: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
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')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const loadList = async () => {
loading.value = true
try {
const res = await getTicketTypeList()
if (res?.code === 200) {
typeList.value = res.data || []
}
} catch (e) { /* ignore */ }
loading.value = false
}
const openAdd = () => {
isEdit.value = false
form.value = { id: null, name: '', note: '', icoId: 0 }
dialogVisible.value = true
}
const openEdit = (row) => {
isEdit.value = true
form.value = {
id: row.id,
name: row.name,
note: row.note || '',
icoId: row.icoId || 0
}
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value.validate()
submitting.value = true
try {
const data = { name: form.value.name }
if (form.value.note) data.note = form.value.note
if (form.value.icoId) data.icoId = form.value.icoId
let res
if (isEdit.value) {
data.id = form.value.id
res = await updateTicketType(data)
} else {
res = await addTicketType(data)
}
if (res?.code === 200) {
ElMessage.success(isEdit.value ? '修改成功' : '添加成功')
dialogVisible.value = false
loadList()
} else {
ElMessage.error(res?.message || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
submitting.value = false
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除工单类型「${row.name}」吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
}).then(async () => {
try {
const res = await deleteTicketType({ id: row.id })
if (res?.code === 200) {
ElMessage.success('删除成功')
loadList()
} else {
ElMessage.error(res?.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
onMounted(() => { loadList() })
</script>
<style scoped>
.ticket-types-page {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #2c3e50;
}
.no-icon {
color: #909399;
}
</style>
+430 -63
View File
@@ -19,6 +19,7 @@
<div class="name-row">
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
<el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag>
<el-tag v-if="vm?.network_disabled" size="small" type="danger" effect="dark" style="margin-left:4px">已断网</el-tag>
<el-tag v-if="vm?.rescue" size="small" type="danger" effect="dark" style="margin-left:4px">救援模式</el-tag>
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
</div>
@@ -35,6 +36,7 @@
</div>
<div class="overview-actions">
<el-button v-if="isVmGoods" size="small" type="primary" @click="handleVnc">VNC</el-button>
<el-button v-if="isVmGoods" size="small" type="success" @click="handleSshConnect" :disabled="!vmPublicIpList.length || isWindows">SSH连接</el-button>
<el-button v-if="isVmGoods" size="small" type="success" @click="handlePower('start')" :disabled="vm?.status === 'running'">启动</el-button>
<el-button v-if="isVmGoods" size="small" type="warning" @click="handlePower('reboot')">重启</el-button>
<el-button v-if="isVmGoods" size="small" type="danger" @click="handlePower('stop')" :disabled="vm?.status === 'stopped' || vm?.status === 'stop'">关机</el-button>
@@ -46,6 +48,9 @@
<el-dropdown-item command="resume">恢复</el-dropdown-item>
<el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue" :disabled="!vm?.rescue">退出救援</el-dropdown-item>
<el-dropdown-item command="resetMac">重置MAC地址</el-dropdown-item>
<el-dropdown-item command="disconnectNetwork" :disabled="vm?.network_disabled">断网</el-dropdown-item>
<el-dropdown-item command="connectNetwork" :disabled="!vm?.network_disabled">恢复网络</el-dropdown-item>
<el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item>
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
@@ -63,14 +68,20 @@
<!-- VM 配置信息 -->
<div class="vm-config-grid" v-if="vm">
<div class="config-row">
<div class="config-cell"><span class="config-label">vCPU</span><span class="config-value">{{ vm.vcpu || '-' }} </span></div>
<div class="config-cell"><span class="config-label">内存</span><span class="config-value">{{ formatMemory(vm.memory) }}</span></div>
<div class="config-cell"><span class="config-label">下行带宽</span><span class="config-value">{{ vm.rx_bandwidth || 0 }} Mbps</span></div>
<div class="config-cell"><span class="config-label">上行带宽</span><span class="config-value">{{ vm.tx_bandwidth || 0 }} Mbps</span></div>
</div>
<div class="config-row">
<div class="config-cell"><span class="config-label">vCPU / 内存</span><span class="config-value">{{ vm.vcpu || '-' }} / {{ formatMemory(vm.memory) }}</span></div>
<div class="config-cell">
<span class="config-label">带宽 / </span>
<span class="config-value">
{{ vm.rx_bandwidth || 0 }} / {{ vm.tx_bandwidth || 0 }} Mbps
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="handleMoreCmd('updateTraffic')">
<el-icon :size="14"><Edit /></el-icon>修改
</el-button>
</span>
</div>
<div class="config-cell"><span class="config-label">用户名</span><span class="config-value" style="font-weight:500">{{ isWindows ? 'Administrator' : 'root' }}</span></div>
<div class="config-cell"><span class="config-label">远程端口</span><span class="config-value">{{ isWindows ? (vm.ssh_port && vm.ssh_port !== 22 ? vm.ssh_port : 3389) : (vm.ssh_port || 22) }}</span></div>
</div>
<div class="config-row">
<div class="config-cell">
<span class="config-label">外网IP</span>
<span class="config-value ip-value" v-if="vmPublicIpList.length">
@@ -129,7 +140,25 @@
</el-button>
</span>
</div>
<div class="config-cell"><span class="config-label">流量上限</span><span class="config-value">{{ formatTraffic(vm.traffic_max) }}</span></div>
<div class="config-cell">
<span class="config-label">流量</span>
<span class="config-value traffic-cell">
<span class="traffic-main">{{ formatTraffic(trafficUsedMb) }} / {{ formatTraffic(trafficTotalMb) }}</span>
<span class="traffic-sub">
基础 {{ formatTraffic(trafficBaseMb) }}
<template v-if="trafficTempMb > 0">
· 临时 {{ formatTraffic(trafficTempMb) }}
<span v-if="trafficCycleStartText" class="traffic-cycle">周期 {{ trafficCycleStartText }}</span>
</template>
</span>
<span class="traffic-actions">
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="openTrafficPolicyDialog">
<el-icon :size="14"><Edit /></el-icon>修改
</el-button>
<el-button link type="warning" size="small" class="cfg-edit-btn" @click="openAddTrafficDialog('temporary')">加临时</el-button>
</span>
</span>
</div>
<div class="config-cell"><span class="config-label">续费价格</span><span class="config-value">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</span></div>
<div class="config-cell"><span class="config-label">基础价格</span><span class="config-value">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</span></div>
</div>
@@ -305,7 +334,20 @@
<el-table-column prop="address" label="地址(CIDR)" min-width="150" />
<el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip />
<el-table-column label="主IP" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_primary" type="success" size="small" effect="dark"></el-tag>
</template>
</el-table-column>
<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 label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.is_primary" link type="warning" size="small" @click="handleSetVmNetworkPrimary(row)">设为主IP</el-button>
<el-button link type="danger" size="small" :loading="deletingNetworkId === row.id" @click="handleDeleteVmNetwork(row)">
<el-icon :size="14"><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!vmNetworks.length" :image-size="60" description="暂无网络" />
</el-tab-pane>
@@ -361,15 +403,28 @@
<div class="section-header">
<h3 class="section-title">监控指标</h3>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="monitorTimeMode" size="small" @change="loadMetricsHistory">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="monitorTimeMode === 'relative'" v-model="monitorRelativeMinutes" size="small" style="width: 120px" @change="loadMetricsHistory">
<el-option label="10分钟" :value="10" />
<el-option label="30分钟" :value="30" />
<el-option label="1小时" :value="60" />
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker
v-else
v-model="monitorDateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
start-placeholder="开始"
end-placeholder="结束"
size="small"
style="width: 360px"
:shortcuts="monitorShortcuts"
style="width: 340px"
@change="loadMetricsHistory"
/>
<span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span>
@@ -398,6 +453,11 @@
<div class="metric-summary-value">{{ formatNetLabel(latestMetrics.net_rx) }}</div>
<div class="metric-summary-sub">{{ formatNetLabel(latestMetrics.net_tx) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">累计流量</div>
<div class="metric-summary-value">{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}</div>
<div class="metric-summary-sub">&nbsp;</div>
</div>
</div>
</template>
<template v-if="metricsData">
@@ -410,7 +470,7 @@
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 内存使用</span></template>
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 内存使用</span></template>
<div ref="memChartRef" class="chart-container"></div>
</el-card>
</el-col>
@@ -418,24 +478,38 @@
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘 I/O</span></template>
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘读写量</span></template>
<div ref="diskChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 网络流量</span></template>
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 网络速率</span></template>
<div ref="netChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘IO速率</span></template>
<div ref="diskIopsChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 流量使用趋势</span></template>
<div ref="trafficUsedChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</template>
<el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" />
</div>
</el-tab-pane>
<!-- 流量策略 -->
<el-tab-pane v-if="isVmGoods" label="流量策略" name="trafficPolicy">
<!-- 流量管理合并流量策略 + 流量统计 -->
<el-tab-pane v-if="isVmGoods" label="流量管理" name="trafficManage">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">流量策略</h3>
@@ -453,6 +527,41 @@
</el-descriptions>
<el-empty v-else-if="!trafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
</div>
<div class="section-block" style="margin-top:16px">
<div class="section-header">
<h3 class="section-title">每小时流量</h3>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="trafficTimeMode" size="small" @change="loadTrafficHourly">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="trafficTimeMode === 'relative'" v-model="trafficRelativeMinutes" size="small" style="width: 120px" @change="loadTrafficHourly">
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="3天" :value="4320" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker
v-else
v-model="trafficHourlyRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
size="small"
style="width: 340px"
@change="loadTrafficHourly"
/>
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
</div>
</div>
<el-card v-if="trafficHourlyData.length" shadow="hover" class="metrics-card" style="margin-top:12px">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 每小时流量MB</span></template>
<div ref="trafficHourlyChartRef" class="chart-container"></div>
</el-card>
<el-empty v-else-if="!trafficHourlyLoading" description="暂无流量统计数据" :image-size="60" />
</div>
</el-tab-pane>
</el-tabs>
@@ -1069,10 +1178,10 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument } from '@element-plus/icons-vue'
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
import {
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
@@ -1083,11 +1192,14 @@ import {
getUserVmPostGroupList, createUserVmPostGroup, updateUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist,
createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule,
getUserVmPostGroupDetail,
getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
getUserVmNetworkList, getUserVmNetworkDetail, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
getUserGoodsDetail,
getUserVmMetricsHistory,
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic
getUserVmMetricsHistory, getUserVmTrafficHourly,
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic,
setUserVmNetworkPrimary, resetUserVmMac,
disconnectUserVmNetwork, connectUserVmNetwork
} from '@/api/admin/userVm'
import { deleteNetwork as deletePointNetwork } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
import UserSelector from '@/components/UserSelector/index.vue'
@@ -1164,6 +1276,18 @@ const copyAllIps = async (ipList) => {
const isWindows = computed(() => vmImage.value?.os_type === 'windows')
// 使 traffic_max_mbadd.json fallback traffic_max
// traffic_used MB traffic_max_mb
const trafficBaseMb = computed(() => Number(vm.value?.traffic_max_mb ?? vm.value?.traffic_max ?? 0) || 0)
const trafficTempMb = computed(() => Number(vm.value?.temporary_traffic_mb || 0))
const trafficTotalMb = computed(() => trafficBaseMb.value + trafficTempMb.value)
const trafficUsedMb = computed(() => Number(vm.value?.traffic_used || 0))
const trafficCycleStartText = computed(() => {
const t = vm.value?.temporary_cycle_start
const sec = typeof t === 'object' ? (t?.seconds ?? null) : t
return sec ? dayjs(Number(sec) * 1000).format('YYYY-MM-DD') : ''
})
const vmPublicIpList = computed(() => {
return vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean)
})
@@ -1262,7 +1386,7 @@ const handleTabChange = (tab) => {
if (tab === 'security') loadSgLockInfo()
if (tab === 'networking') loadNetworkings()
if (tab === 'monitor' && !metricsData.value) loadMetricsHistory()
if (tab === 'trafficPolicy') loadTrafficPolicy()
if (tab === 'trafficManage') { loadTrafficPolicy(); loadTrafficHourly() }
}
// lock 使
@@ -1284,6 +1408,15 @@ const loadSgLockInfo = async () => {
const vncVisible = ref(false)
const vncLoading = ref(false)
const vncResult = ref(null)
const handleSshConnect = () => {
const host = vmPublicIpList.value[0]
if (!host) return ElMessage.warning('无可用公网 IP')
const username = isWindows.value ? 'Administrator' : 'root'
const password = vm.value?.root_password || ''
const encodedPwd = btoa(password)
window.open(`https://webssh1.007yjs.com/?hostname=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodedPwd}`, '_blank')
}
const handleVnc = async () => {
vncVisible.value = true; vncLoading.value = true; vncResult.value = null
try {
@@ -1319,6 +1452,9 @@ const handleMoreCmd = (cmd) => {
if (cmd === 'refactorVm') openRefactorVm()
if (cmd === 'migrate') openMigrateVm()
if (cmd === 'editGoods') openEditGoods()
if (cmd === 'resetMac') handleResetVmMac()
if (cmd === 'disconnectNetwork') handleDisconnectVmNetwork()
if (cmd === 'connectNetwork') handleConnectVmNetwork()
if (cmd === 'delete') {
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
try {
@@ -1644,6 +1780,101 @@ const loadNetworks = async () => {
} catch { /* */ } finally { networkLoading.value = false }
}
// ---- ----
// host_service/point/network/delete VM
// row service_id / host_id getUserVmNetworkDetail
const deletingNetworkId = ref(0)
const resolveNetworkServiceHost = async (row) => {
let serviceId = row.service_id ?? row.host_service_id ?? row.kvm_service_id ?? 0
let hostId = row.host_id ?? 0
if (!serviceId || !hostId) {
try {
const res = await getUserVmNetworkDetail({ user_goods_id: userGoodsId.value, id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
const n = d.data || d.network || d
if (!serviceId) serviceId = n?.service_id ?? n?.host_service_id ?? n?.kvm_service_id ?? 0
if (!hostId) hostId = n?.host_id ?? 0
}
} catch { /* 兜底失败时返回原值,由调用方判断 */ }
}
return { serviceId, hostId }
}
const handleDeleteVmNetwork = (row) => {
ElMessageBox.confirm(
`将删除底层网络「${row.name}」(ID:${row.id}),该操作会影响所有绑定该网络的虚拟机,是否继续?`,
'删除网络',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
deletingNetworkId.value = row.id
try {
const { serviceId, hostId } = await resolveNetworkServiceHost(row)
if (!serviceId) { ElMessage.error('无法获取该网络所属服务ID,删除失败'); return }
const params = { service_id: serviceId, network_id: row.id }
if (hostId) params.host_id = hostId
const res = await deletePointNetwork(params)
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '删除失败'))
} finally {
deletingNetworkId.value = 0
}
}).catch(() => {})
}
const handleSetVmNetworkPrimary = (row) => {
ElMessageBox.confirm(
`将「${row.address || row.name}」设为主IP?设置后将重启虚拟机。`,
'设置主IP', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await setUserVmNetworkPrimary({ user_goods_id: userGoodsId.value, network_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('设置主IP成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '设置主IP失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置主IP失败')) }
}).catch(() => {})
}
const handleResetVmMac = () => {
ElMessageBox.confirm(
'重置MAC地址将重建 CloudInit 并重启虚拟机,确定继续?',
'重置MAC地址', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await resetUserVmMac({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('MAC地址重置成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '重置MAC失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重置MAC失败')) }
}).catch(() => {})
}
const handleDisconnectVmNetwork = () => {
ElMessageBox.confirm(
'断开虚拟机外部网络连接?断网后虚拟机将无法访问外部网络。',
'断网', { confirmButtonText: '确定断网', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await disconnectUserVmNetwork({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('已断网'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '断网失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '断网失败')) }
}).catch(() => {})
}
const handleConnectVmNetwork = () => {
ElMessageBox.confirm(
'恢复虚拟机外部网络连接?',
'恢复网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
const res = await connectUserVmNetwork({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('网络已恢复'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '恢复网络失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复网络失败')) }
}).catch(() => {})
}
// ---- ----
const showBindNetworkSelector = ref(false)
@@ -2019,12 +2250,15 @@ const loadTrafficPolicy = async () => {
}
const openTrafficPolicyDialog = () => {
// trafficPolicy trafficPolicy tab
// vm.value add.json fallback便 submit
Object.assign(trafficPolicyForm, {
traffic_max_mb: trafficPolicy.value?.traffic_max_mb || 0,
exhausted_rx_mbps: trafficPolicy.value?.exhausted_rx_mbps || 0,
exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps || 0
traffic_max_mb: trafficPolicy.value?.traffic_max_mb ?? vm.value?.traffic_max_mb ?? vm.value?.traffic_max ?? 0,
exhausted_rx_mbps: trafficPolicy.value?.exhausted_rx_mbps ?? vm.value?.traffic_exhausted_rx_mbps ?? 0,
exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps ?? vm.value?.traffic_exhausted_tx_mbps ?? 0
})
trafficPolicyVisible.value = true
if (!trafficPolicy.value) loadTrafficPolicy()
}
const submitUpdateTrafficPolicy = async () => {
@@ -2058,6 +2292,66 @@ const submitAddTraffic = async () => {
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { trafficPolicyLoading.value = false }
}
// ---- ----
const trafficTimeMode = ref('relative')
const trafficRelativeMinutes = ref(1440)
const trafficHourlyRange = ref(null)
const trafficHourlyData = ref([])
const trafficHourlyLoading = ref(false)
const trafficHourlyChartRef = ref(null)
let trafficHourlyChart = null
const getTrafficTimeRange = () => {
if (trafficTimeMode.value === 'relative') {
const endTime = new Date()
const startTime = new Date(endTime - trafficRelativeMinutes.value * 60 * 1000)
return { startTime, endTime }
} else {
if (!trafficHourlyRange.value || trafficHourlyRange.value.length < 2) return null
return { startTime: new Date(trafficHourlyRange.value[0]), endTime: new Date(trafficHourlyRange.value[1]) }
}
}
const loadTrafficHourly = async () => {
if (!userGoodsId.value) return
const range = getTrafficTimeRange()
if (!range) return
trafficHourlyLoading.value = true
try {
const res = await getUserVmTrafficHourly({
user_goods_id: userGoodsId.value,
start: range.startTime.toISOString(),
end_time: range.endTime.toISOString()
})
const raw = res?.data?.data?.data
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
nextTick(renderTrafficHourlyChart)
} catch { trafficHourlyData.value = [] } finally { trafficHourlyLoading.value = false }
}
const renderTrafficHourlyChart = () => {
if (!trafficHourlyChartRef.value || !trafficHourlyData.value.length) return
if (!trafficHourlyChart) trafficHourlyChart = echarts.init(trafficHourlyChartRef.value)
const data = trafficHourlyData.value
const times = data.map(d => {
const date = new Date(d.bucket)
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
})
const toMB = (b) => +(b / 1048576).toFixed(2)
trafficHourlyChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => params.map(p => `${p.marker}${p.seriesName}: ${p.value} MB`).join('<br/>') },
legend: { data: ['下行', '上行', '合计'], bottom: 0 },
grid: { top: 10, right: 16, bottom: 40, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', name: 'MB', axisLabel: { fontSize: 10 } },
series: [
{ name: '下行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.rx_bytes)), itemStyle: { color: '#409EFF' } },
{ name: '上行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.tx_bytes)), itemStyle: { color: '#67C23A' } },
{ name: '合计', type: 'line', smooth: true, data: data.map(d => toMB(d.total_bytes)), itemStyle: { color: '#E6A23C' }, lineStyle: { width: 2 } }
]
}, true)
}
// ---- ----
const transferVisible = ref(false)
const showTransferUserSelector = ref(false)
@@ -2110,30 +2404,33 @@ const submitEditGoods = async () => {
const cpuChartRef = ref(null)
const memChartRef = ref(null)
const diskChartRef = ref(null)
const diskIopsChartRef = ref(null)
const netChartRef = ref(null)
const trafficUsedChartRef = ref(null)
let cpuChart = null
let memChart = null
let diskChart = null
let diskIopsChart = null
let netChart = null
let trafficUsedChart = null
const metricsData = ref(null)
const metricsLoading = ref(false)
const makeDefaultRange = () => {
const now = new Date()
return [new Date(now.getTime() - 10 * 60 * 1000), now]
}
const monitorDateRange = ref(makeDefaultRange())
const monitorTimeMode = ref('relative')
const monitorRelativeMinutes = ref(10)
const monitorDateRange = ref(null)
const monitorShortcuts = [
{ text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } },
{ text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } },
{ text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } },
{ text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } },
{ text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } },
{ text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } },
{ text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } },
]
const getMonitorTimeRange = () => {
if (monitorTimeMode.value === 'relative') {
const endTime = new Date()
const startTime = new Date(endTime - monitorRelativeMinutes.value * 60 * 1000)
return { startTime, endTime }
} else {
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return null
return { startTime: new Date(monitorDateRange.value[0]), endTime: new Date(monitorDateRange.value[1]) }
}
}
function calcInterval(startTime, endTime) {
const spanMin = (endTime.getTime() - startTime.getTime()) / 60000
@@ -2151,8 +2448,9 @@ function calcInterval(startTime, endTime) {
const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' }
const currentIntervalLabel = computed(() => {
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-'
const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1]))
const range = getMonitorTimeRange()
if (!range) return '-'
const iv = calcInterval(range.startTime, range.endTime)
return intervalLabelMap[iv] || iv
})
@@ -2197,11 +2495,11 @@ const vmMemPercent = (m) => {
const loadMetricsHistory = async () => {
if (!userGoodsId.value) return
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return
const range = getMonitorTimeRange()
if (!range) return
metricsLoading.value = true
try {
const startTime = new Date(monitorDateRange.value[0])
const endTime = new Date(monitorDateRange.value[1])
const { startTime, endTime } = range
const interval = calcInterval(startTime, endTime)
const params = {
user_goods_id: userGoodsId.value,
@@ -2229,7 +2527,8 @@ const renderMetricsCharts = () => {
const metrics = metricsData.value
if (!Array.isArray(metrics) || !metrics.length) return
const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 600000
const range = getMonitorTimeRange()
const spanMs = range ? (range.endTime.getTime() - range.startTime.getTime()) : 600000
const showDate = spanMs >= 86400000
const symbolType = metrics.length < 30 ? 'circle' : 'none'
const labelRotate = showDate ? 45 : 0
@@ -2241,9 +2540,28 @@ const renderMetricsCharts = () => {
})
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0)
const memData = metrics.map(m => m.mem_used ?? 0)
const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0))
const memAxisFmt = memTotal >= 1048576
? (v) => (v / 1048576).toFixed(1) + ' GB'
: memTotal >= 1024
? (v) => (v / 1024).toFixed(0) + ' MB'
: (v) => v + ' KB'
const diskReadData = metrics.map(m => m.disk_read ?? 0)
const diskWriteData = metrics.map(m => m.disk_write ?? 0)
const diskReadRate = []
const diskWriteRate = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { diskReadRate.push(0); diskWriteRate.push(0); continue }
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
if (dt > 0) {
diskReadRate.push(+Math.max(0, ((metrics[i].disk_read ?? 0) - (metrics[i - 1].disk_read ?? 0)) / dt / 1024).toFixed(2))
diskWriteRate.push(+Math.max(0, ((metrics[i].disk_write ?? 0) - (metrics[i - 1].disk_write ?? 0)) / dt / 1024).toFixed(2))
} else { diskReadRate.push(0); diskWriteRate.push(0) }
}
const netRxData = metrics.map(m => m.net_rx ?? 0)
const netTxData = metrics.map(m => m.net_tx ?? 0)
@@ -2264,9 +2582,9 @@ const renderMetricsCharts = () => {
if (memChartRef.value) {
if (!memChart) memChart = echarts.init(memChartRef.value)
memChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${formatMemKB(p[0].value)}` },
grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } },
series: [makeSeries('内存', memData, '#67c23a')]
}, true)
}
@@ -2285,6 +2603,20 @@ const renderMetricsCharts = () => {
}, true)
}
if (diskIopsChartRef.value) {
if (!diskIopsChart) diskIopsChart = echarts.init(diskIopsChartRef.value)
diskIopsChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0].axisValue
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}/s` })
return s
}},
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) + '/s' } },
series: [makeSeries('读取', diskReadRate, '#409eff'), makeSeries('写入', diskWriteRate, '#e6a23c')]
}, true)
}
if (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value)
netChart.setOption({
@@ -2298,32 +2630,59 @@ const renderMetricsCharts = () => {
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
}, true)
}
if (trafficUsedChartRef.value) {
if (!trafficUsedChart) trafficUsedChart = echarts.init(trafficUsedChartRef.value)
const trafficDelta = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { trafficDelta.push(0); continue }
const delta = Math.max(0, (metrics[i].traffic_used_mb ?? 0) - (metrics[i - 1].traffic_used_mb ?? 0))
trafficDelta.push(+delta.toFixed(2))
}
trafficUsedChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 流量增量: ${p[0].value} MB` },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v + ' MB' } },
series: [makeSeries('流量增量', trafficDelta, '#722ed1')]
}, true)
}
}
const disposeCharts = () => {
cpuChart?.dispose(); cpuChart = null
memChart?.dispose(); memChart = null
diskChart?.dispose(); diskChart = null
diskIopsChart?.dispose(); diskIopsChart = null
netChart?.dispose(); netChart = null
trafficUsedChart?.dispose(); trafficUsedChart = null
trafficHourlyChart?.dispose(); trafficHourlyChart = null
}
const reloadWithNewId = (newId) => {
if (!newId || newId === userGoodsId.value) return
userGoodsId.value = newId
userGoods.value = null
vm.value = null
vmNetworks.value = []
vmVolumes.value = []
inPortGroup.value = null
outPortGroup.value = null
isVmGoods.value = false
metricsData.value = null
disposeCharts()
loadDetail()
}
watch(() => route.query.id, (newVal) => {
if (route.path === '/user-goods/vm-detail') {
reloadWithNewId(parseInt(newVal) || 0)
}
})
onMounted(() => { loadDetail() })
onActivated(() => {
const newId = parseInt(route.query.id) || 0
if (newId && newId !== userGoodsId.value) {
userGoodsId.value = newId
userGoods.value = null
vm.value = null
vmNetworks.value = []
vmVolumes.value = []
inPortGroup.value = null
outPortGroup.value = null
isVmGoods.value = false
metricsData.value = null
disposeCharts()
loadDetail()
}
reloadWithNewId(parseInt(route.query.id) || 0)
})
onBeforeUnmount(() => { disposeCharts() })
@@ -2355,6 +2714,14 @@ onBeforeUnmount(() => { disposeCharts() })
.config-cell:last-child { border-right: none; }
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
.config-value { font-size: 13px; color: #1d2129; line-height: 1.4; word-break: break-all; }
.cfg-edit-btn { margin-left: 8px; padding: 0 4px; height: 18px; vertical-align: middle; }
.cfg-edit-btn .el-icon { margin-right: 2px; vertical-align: -2px; }
.traffic-cell { display: flex; flex-direction: column; gap: 2px; }
.traffic-cell .traffic-main { font-size: 13px; color: #1d2129; }
.traffic-cell .traffic-sub { font-size: 12px; color: #86909c; line-height: 1.3; }
.traffic-cell .traffic-cycle { color: #c0c4cc; margin-left: 2px; }
.traffic-cell .traffic-actions { display: inline-flex; gap: 6px; margin-top: 2px; }
.traffic-cell .traffic-actions .cfg-edit-btn { margin-left: 0; }
.pwd-value { display: inline-flex; align-items: center; gap: 4px; }
.pwd-text { font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; background: #f5f7fa; padding: 2px 8px; border-radius: 3px; letter-spacing: .5px; user-select: all; }
.pwd-btn { padding: 0 !important; height: auto !important; min-height: auto !important; }
+36 -15
View File
@@ -181,18 +181,29 @@
</el-tab-pane>
<el-tab-pane label="订单列表" name="3">
<el-table :data="userOrderList" v-loading="orderListLoading" stripe style="width: 100%">
<el-table-column prop="id" label="订单ID" width="100" />
<el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="price" label="金额" width="100">
<template #default="{row}">¥{{ (row.price / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="id" label="订单ID" width="80" />
<el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" width="80">
<template #default="{row}">
<el-tag :type="getOrderStatusType(row.status)" size="small">{{ getOrderStatusText(row.status) }}</el-tag>
<el-tag :type="getOrderTypeTag(row.type)" size="small">{{ getOrderTypeText(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{row}">{{ formatDate(row.created_at) }}</template>
<el-table-column label="金额" width="120">
<template #default="{row}">
<span>¥{{ (row.price / 100).toFixed(2) }}</span>
<div v-if="row.renewPrice" style="font-size:12px;color:#909399">续费价: ¥{{ (row.renewPrice / 100).toFixed(2) }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{row}">
<el-tag :type="getOrderStatusType(row.state)" size="small">{{ getOrderStatusText(row.state) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" width="160">
<template #default="{row}">{{ row.expireTime ? formatDate(row.expireTime) : '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{row}">{{ formatDate(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="scope">
@@ -1017,7 +1028,6 @@ const fetchUserOrderList = async () => {
page: orderListPage.value,
count: orderListPageSize.value
})
console.log('111',res)
if (res.data.code === 200) {
userOrderList.value = res.data.data.list || []
orderListTotal.value = res.data.data.all_count || 0
@@ -1119,14 +1129,25 @@ const fetchUserBalance = async () => {
}
//
const getOrderStatusText = (status) => {
const getOrderStatusText = (state) => {
const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款', 4: '已完成' }
return map[status] || '未知'
return map[state] || '未知'
}
const getOrderStatusType = (status) => {
const getOrderStatusType = (state) => {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: 'success' }
return map[status] || 'info'
return map[state] || 'info'
}
//
const getOrderTypeText = (type) => {
const map = { create: '新购', renew: '续费', upgrade: '升级' }
return map[type] || type || '未知'
}
const getOrderTypeTag = (type) => {
const map = { create: 'primary', renew: 'success', upgrade: 'warning' }
return map[type] || 'info'
}
//
@@ -1144,7 +1165,7 @@ const getTicketStatusType = (status) => {
const handleViewOrder = (row) => {
router.push({
path: '/order/list',
query: { order_id: row.order_id }
query: { order_id: row.id }
})
}
+298 -2
View File
@@ -85,7 +85,7 @@
{{ row.create_time || row.CreateTime || row.CreatedAt || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)">
@@ -94,6 +94,9 @@
<el-button type="success" link @click="handleViewMembers(row)">
<el-icon><User /></el-icon>成员
</el-button>
<el-button type="warning" link @click="handleViewDiscount(row)">
<el-icon><Present /></el-icon>优惠
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
@@ -288,13 +291,129 @@
</div>
</template>
</el-dialog>
<!-- 用户组优惠管理对话框 -->
<el-dialog
v-model="discountDialogVisible"
:title="`优惠管理 - ${currentGroupName}`"
width="900px"
append-to-body
class="custom-dialog"
>
<div class="discount-action-bar">
<el-button type="primary" @click="handleAddDiscount">
<el-icon><Plus /></el-icon>新增优惠绑定
</el-button>
<el-button type="success" @click="fetchDiscountList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-table
v-loading="discountLoading"
:data="discountList"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="绑定对象" min-width="220">
<template #default="{ row }">
<el-tag v-if="row.good" type="primary" effect="plain">
商品{{ row.good.name }} (ID:{{ row.good.id }})
</el-tag>
<el-tag v-else-if="row.goodGroup" type="warning" effect="plain">
商品组{{ row.goodGroup.name }} (ID:{{ row.goodGroup.id }})
</el-tag>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="固定抵扣额" width="130">
<template #default="{ row }">
<span v-if="row.amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="百分比抵扣" width="120">
<template #default="{ row }">
<span v-if="row.percentage">{{ (row.percentage / 100).toFixed(0) }}%</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditDiscount(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteDiscount(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无优惠绑定" :image-size="80" />
</template>
</el-table>
</el-dialog>
<!-- 用户组优惠 新增/编辑 对话框 -->
<el-dialog
v-model="discountFormVisible"
:title="discountFormType === 'add' ? '新增优惠绑定' : '编辑优惠绑定'"
width="560px"
append-to-body
>
<el-form
ref="discountFormRef"
:model="discountForm"
:rules="discountFormRules"
label-width="120px"
>
<el-form-item label="绑定类型" prop="bind_type">
<el-radio-group v-model="discountForm.bind_type" @change="handleBindTypeChange">
<el-radio label="good">商品</el-radio>
<el-radio label="good_group">商品组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="discountForm.bind_type === 'good'" label="选择商品" prop="good_id">
<el-select v-model="discountForm.good_id" placeholder="请选择商品" filterable style="width: 100%">
<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 v-else label="选择商品组" prop="good_group_id">
<el-select v-model="discountForm.good_group_id" placeholder="请选择商品组" filterable style="width: 100%">
<el-option
v-for="item in productGroupOptions"
:key="item.id"
:label="`${item.name} (ID:${item.id})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="固定抵扣额" prop="amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="0表示不使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="百分比抵扣" prop="percentage">
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="0表示不使用" style="width: 100%" />
<div style="color:#909399;font-size:12px;margin-top:4px">固定抵扣额与百分比抵扣二选一填写</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="discountFormVisible = false">取消</el-button>
<el-button type="primary" @click="submitDiscountForm">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Edit, User, Delete, Connection, Close } from '@element-plus/icons-vue'
import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue'
import {
getUserGroupList,
getUserGroupMemberList,
@@ -303,6 +422,13 @@ import {
deleteUserGroup,
addUserGroupMember
} from '@/api/admin/user'
import {
getUserGroupDiscountList,
addUserGroupDiscount,
updateUserGroupDiscount,
deleteUserGroupDiscount
} from '@/api/admin/userGroupDiscount'
import { getProductList, getProductGroupList } from '@/api/admin/product'
import { formatTime } from '@/utils/tool'
import UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
@@ -624,6 +750,167 @@ const clearHigherGroup = () => {
selectedHigherGroupInfo.value = null
}
/** -------------------- 用户组优惠管理 -------------------- */
const discountDialogVisible = ref(false)
const discountFormVisible = ref(false)
const discountLoading = ref(false)
const discountList = ref([])
const discountFormType = ref('add')
const discountFormRef = ref(null)
const currentGroupId = ref(undefined)
const currentGroupName = ref('')
const productOptions = ref([])
const productGroupOptions = ref([])
const discountForm = reactive({
id: undefined,
bind_type: 'good',
good_id: undefined,
good_group_id: undefined,
amount: 0,
percentage: 0
})
const discountFormRules = {
bind_type: [{ required: true, message: '请选择绑定类型', trigger: 'change' }],
good_id: [{ required: true, message: '请选择商品', trigger: 'change' }],
good_group_id: [{ required: true, message: '请选择商品组', trigger: 'change' }]
}
// /
const loadProductOptions = async () => {
try {
const [pRes, gRes] = await Promise.all([
getProductList({ page: 1, count: 1000 }),
getProductGroupList({ page: 1, count: 1000 })
])
if (pRes.data.code === 200) {
productOptions.value = pRes.data.data?.data || pRes.data.data || []
}
if (gRes.data.code === 200) {
productGroupOptions.value = gRes.data.data?.data || gRes.data.data || []
}
} catch (error) {
console.error('加载商品选项失败:', error)
}
}
//
const fetchDiscountList = async () => {
if (!currentGroupId.value) return
discountLoading.value = true
try {
const res = await getUserGroupDiscountList({ user_group_id: currentGroupId.value, page: 1, count: 1000 })
if (res.data.code === 200) {
discountList.value = res.data.data || []
}
} catch (error) {
console.error('获取用户组优惠列表失败:', error)
ElMessage.error('获取用户组优惠列表失败')
} finally {
discountLoading.value = false
}
}
//
const handleViewDiscount = (row) => {
currentGroupId.value = row.group_id || row.GroupId || row.id || row.Id
currentGroupName.value = row.group_name || row.name || row.Name || ''
discountDialogVisible.value = true
loadProductOptions()
fetchDiscountList()
}
//
const handleBindTypeChange = () => {
discountForm.good_id = undefined
discountForm.good_group_id = undefined
}
//
const handleAddDiscount = () => {
discountFormType.value = 'add'
discountFormVisible.value = true
Object.assign(discountForm, {
id: undefined,
bind_type: 'good',
good_id: undefined,
good_group_id: undefined,
amount: 0,
percentage: 0
})
discountFormRef.value?.resetFields()
}
//
const handleEditDiscount = (row) => {
discountFormType.value = 'edit'
discountFormVisible.value = true
const isGroup = !!row.goodGroup
Object.assign(discountForm, {
id: row.id,
bind_type: isGroup ? 'good_group' : 'good',
good_id: row.good ? row.good.id : undefined,
good_group_id: row.goodGroup ? row.goodGroup.id : undefined,
amount: row.amount ? row.amount / 100 : 0,
percentage: row.percentage ? row.percentage / 100 : 0
})
}
//
const handleDeleteDiscount = (row) => {
ElMessageBox.confirm('确认删除该优惠绑定吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteUserGroupDiscount({ discount_id: row.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchDiscountList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
//
const submitDiscountForm = () => {
discountFormRef.value?.validate(async (valid) => {
if (!valid) return
try {
const params = new URLSearchParams()
params.append('user_group_id', currentGroupId.value)
if (discountForm.bind_type === 'good') {
params.append('good_id', discountForm.good_id)
} else {
params.append('good_group_id', discountForm.good_group_id)
}
params.append('amount', Math.round((discountForm.amount || 0) * 100))
params.append('percentage', Math.round((discountForm.percentage || 0) * 100))
let res
if (discountFormType.value === 'add') {
res = await addUserGroupDiscount(params)
} else {
params.append('id', discountForm.id)
res = await updateUserGroupDiscount(params)
}
if (res.data.code === 200) {
ElMessage.success(discountFormType.value === 'add' ? '新增成功' : '修改成功')
discountFormVisible.value = false
fetchDiscountList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
//
onMounted(() => {
fetchGroupList()
@@ -696,6 +983,15 @@ onMounted(() => {
padding: 0;
}
.discount-action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.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; }
/* 表格样式优化 */
:deep(.el-table) {
border: none;
+9
View File
@@ -15,6 +15,13 @@
class="search-input"
/>
</div>
<div class="search-group">
<span class="search-label">身份</span>
<el-select v-model="queryParams.is_admin" placeholder="全部" clearable class="search-input-small" style="width: 110px">
<el-option label="管理员" :value="true" />
<el-option label="普通用户" :value="false" />
</el-select>
</div>
<div class="search-group">
<span class="search-label">用户ID</span>
<el-input
@@ -669,6 +676,7 @@ const showJumpUserSelector = ref(false)
//
const queryParams = reactive({
key: '',
is_admin: undefined,
page: 1,
count: 10
})
@@ -872,6 +880,7 @@ const handleQuery = () => {
//
const resetQuery = () => {
queryParams.key = ''
queryParams.is_admin = undefined
queryParams.page = 1
fetchUserList()
}
File diff suppressed because it is too large Load Diff
+237 -110
View File
@@ -8,30 +8,24 @@
</div>
</div>
<div class="header-right">
<el-select v-model="selectedServiceId" placeholder="选择主控服务" filterable style="width: 240px" @change="handleServiceChange">
<el-option v-for="s in serviceOptions" :key="s.id" :label="`${s.name} (ID: ${s.id})`" :value="s.id" />
</el-select>
<el-button type="primary" @click="handleSync" :loading="syncLoading" :disabled="!serviceId">
<el-icon><RefreshRight /></el-icon>从远程同步
</el-button>
<el-button @click="handleRefresh">
<el-button @click="loadAllServices" :loading="servicesLoading">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleSync" :loading="syncLoading"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button>
<el-button type="primary" @click="handleSync(embeddedServiceId)" :loading="syncLoadingMap[embeddedServiceId]"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
<el-button @click="loadHostGroupsForService(embeddedServiceId)"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<!-- 本地主机组列表树形折叠 -->
<div class="main-panel">
<!-- embedded 模式直接展示单个主控的主机组 -->
<div v-if="embedded" class="main-panel">
<div class="panel-header">
<h4>本地主机组列表</h4>
</div>
<div class="panel-body" v-loading="loading">
<el-table :data="treeGroupList" stripe style="width: 100%" row-key="_rowKey"
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
<div class="panel-body" v-loading="hostGroupLoadingMap[embeddedServiceId]">
<el-table :data="getTreeGroupList(embeddedServiceId)" stripe style="width: 100%" row-key="_rowKey"
default-expand-all :tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="远程ID" width="80">
@@ -60,13 +54,82 @@
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row)">删除</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row, embeddedServiceId)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- embedded 模式展示所有主控服务每个可折叠展开 -->
<div v-if="!embedded" v-loading="servicesLoading">
<el-empty v-if="!servicesLoading && serviceList.length === 0" description="暂无主控服务" :image-size="100" />
<div v-for="service in serviceList" :key="service.id" class="service-card">
<div class="service-card-header" @click="toggleService(service)">
<div class="service-card-title">
<el-icon class="expand-icon" :class="{ 'is-expanded': expandedServiceIds.has(service.id) }"><ArrowRight /></el-icon>
<span class="service-name">{{ service.name }}</span>
<el-tag size="small" type="info">ID: {{ service.id }}</el-tag>
<el-tag v-if="service.host" size="small">{{ service.host }}{{ service.port ? ':' + service.port : '' }}</el-tag>
</div>
<div class="service-card-actions" @click.stop>
<el-button type="primary" size="small" @click="handleSync(service.id)" :loading="syncLoadingMap[service.id]">
<el-icon><RefreshRight /></el-icon>同步
</el-button>
<el-button size="small" @click="loadHostGroupsForService(service.id)">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="expandedServiceIds.has(service.id)" class="service-card-body">
<div v-loading="hostGroupLoadingMap[service.id]" class="host-group-table-wrapper">
<el-table
v-if="hostGroupDataMap[service.id]?.length > 0"
:data="getTreeGroupList(service.id)"
stripe style="width: 100%" row-key="_rowKey"
default-expand-all
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }"
>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="远程ID" width="80">
<template #default="{ row }">{{ row.remoteId || '-' }}</template>
</el-table-column>
<el-table-column label="父级远程ID" width="100">
<template #default="{ row }">{{ row.parentRemoteId || '-' }}</template>
</el-table-column>
<el-table-column label="绑定商品组" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.goodGroupId" type="success" size="small">商品组#{{ row.goodGroupId }}</el-tag>
<span v-else class="text-muted">未绑定</span>
</template>
</el-table-column>
<el-table-column label="绑定商品" min-width="100">
<template #default="{ row }">
<el-tag v-if="row.goodId" type="warning" size="small">商品#{{ row.goodId }}</el-tag>
<span v-else class="text-muted">未绑定</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-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row, service.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!hostGroupLoadingMap[service.id]" description="暂无主机组数据,请先从远程同步" :image-size="60" />
</div>
</div>
</el-collapse-transition>
</div>
</div>
<!-- 编辑本地主机组弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑本地主机组" width="480px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="90px">
@@ -215,7 +278,7 @@
import { ref, reactive, computed, inject, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, RefreshRight, Search, ArrowLeft } from '@element-plus/icons-vue'
import { Plus, Refresh, RefreshRight, Search, ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import {
getHostGroupList,
syncHostGroup,
@@ -237,15 +300,12 @@ const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const selectedServiceId = ref(parseInt(route.query.service_id) || null)
const serviceOptions = ref([])
const embeddedServiceId = computed(() => injectedServiceId?.value || 0)
const serviceId = computed(() => injectedServiceId?.value || selectedServiceId.value || 0)
const serviceName = computed(() => {
if (injectedServiceName?.value) return injectedServiceName.value
const s = serviceOptions.value.find(x => x.id === selectedServiceId.value)
return s?.name || route.query.service_name || ''
})
// ==================== ====================
const serviceList = ref([])
const servicesLoading = ref(false)
const expandedServiceIds = reactive(new Set())
const normalizeService = (s) => ({
id: s.Id ?? s.id,
@@ -255,59 +315,29 @@ const normalizeService = (s) => ({
note: s.Note ?? s.note
})
const loadServiceOptions = async () => {
const loadAllServices = async () => {
servicesLoading.value = true
try {
const res = await getKvmServiceList({ page: 1, count: 10, key: '' })
const res = await getKvmServiceList({ page: 1, count: 100, key: '' })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
serviceOptions.value = raw.map(normalizeService)
serviceList.value = raw.map(normalizeService)
serviceList.value.forEach(s => {
expandedServiceIds.add(s.id)
loadHostGroupsForService(s.id)
})
}
} catch { /* */ }
}
const handleServiceChange = () => {
hostGroupList.value = []
if (serviceId.value) {
loadHostGroups()
} catch { /* */ } finally {
servicesLoading.value = false
}
}
const handleRefresh = () => {
if (!serviceId.value) {
ElMessage.warning('请先选择主控服务')
return
}
loadHostGroups()
}
// ==================== ====================
const hostGroupDataMap = reactive({})
const hostGroupLoadingMap = reactive({})
const syncLoadingMap = reactive({})
const loading = ref(false)
const syncLoading = ref(false)
const hostGroupList = ref([])
const selectedGroup = ref(null)
const treeGroupList = computed(() => {
const items = hostGroupList.value
if (!items.length) return []
const map = new Map()
items.forEach(item => {
map.set(item.remoteId, { ...item, _rowKey: `g-${item.id}`, _children: [], _hasChildren: false })
})
const roots = []
map.forEach(item => {
if (item.parentRemoteId && map.has(item.parentRemoteId)) {
const parent = map.get(item.parentRemoteId)
parent._children.push(item)
parent._hasChildren = true
} else {
roots.push(item)
}
})
return roots
})
// PascalCase camelCase
// 便访
const normalizeHostGroup = (item) => {
if (!item) return item
return {
@@ -325,36 +355,65 @@ const normalizeHostGroup = (item) => {
}
}
// ========== ==========
const loadHostGroups = async () => {
if (!serviceId.value) return
loading.value = true
const loadHostGroupsForService = async (svcId) => {
if (!svcId) return
hostGroupLoadingMap[svcId] = true
try {
const res = await getHostGroupList({ service_id: serviceId.value })
const res = await getHostGroupList({ service_id: svcId })
const body = res?.data
console.debug('[HostGroup] list response body:', JSON.stringify(body))
if (body?.code === 200 && body?.data) {
// data { all_count, data: [...] }
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
hostGroupList.value = items.map(normalizeHostGroup)
console.debug('[HostGroup] normalized list:', hostGroupList.value)
hostGroupDataMap[svcId] = items.map(normalizeHostGroup)
} else {
hostGroupList.value = []
if (body?.message) {
ElMessage.warning(body.message)
}
hostGroupDataMap[svcId] = []
if (body?.message) ElMessage.warning(body.message)
}
} catch (error) {
console.error('获取本地主机组列表失败:', error)
ElMessage.error('获取本地主机组列表失败')
hostGroupDataMap[svcId] = []
} finally {
loading.value = false
hostGroupLoadingMap[svcId] = false
}
}
// ========== ==========
const handleSync = async () => {
if (!serviceId.value) {
const buildTree = (items) => {
if (!items || !items.length) return []
const map = new Map()
items.forEach(item => {
map.set(item.remoteId, { ...item, _rowKey: `g-${item.id}`, _children: [], _hasChildren: false })
})
const roots = []
map.forEach(item => {
if (item.parentRemoteId && map.has(item.parentRemoteId)) {
const parent = map.get(item.parentRemoteId)
parent._children.push(item)
parent._hasChildren = true
} else {
roots.push(item)
}
})
return roots
}
const getTreeGroupList = (svcId) => {
return buildTree(hostGroupDataMap[svcId] || [])
}
const toggleService = (service) => {
if (expandedServiceIds.has(service.id)) {
expandedServiceIds.delete(service.id)
} else {
expandedServiceIds.add(service.id)
if (!hostGroupDataMap[service.id]) {
loadHostGroupsForService(service.id)
}
}
}
// ==================== ====================
const handleSync = async (svcId) => {
if (!svcId) {
ElMessage.warning('缺少主控服务ID')
return
}
@@ -363,9 +422,9 @@ const handleSync = async () => {
cancelButtonText: '取消',
type: 'info'
}).then(async () => {
syncLoading.value = true
syncLoadingMap[svcId] = true
try {
const res = await syncHostGroup({ service_id: serviceId.value })
const res = await syncHostGroup({ service_id: svcId })
const body = res?.data
if (body?.code === 200) {
const synced = body.data
@@ -374,19 +433,20 @@ const handleSync = async () => {
} else {
ElMessage.warning(body?.message || '同步返回异常')
}
loadHostGroups()
loadHostGroupsForService(svcId)
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
} finally {
syncLoading.value = false
syncLoadingMap[svcId] = false
}
}).catch(() => {})
}
// ========== ==========
// ==================== ====================
const editDialogVisible = ref(false)
const editSubmitLoading = ref(false)
const editFormRef = ref(null)
const editingServiceId = ref(null)
const editForm = reactive({
id: undefined,
@@ -399,6 +459,7 @@ const editFormRules = {
}
const handleEditGroup = (row) => {
editingServiceId.value = row.serviceId
Object.assign(editForm, {
id: Number(row.Id ?? row.id),
name: row.Name ?? row.name,
@@ -421,7 +482,7 @@ const submitEdit = () => {
if (body?.code === 200) {
ElMessage.success('修改成功')
editDialogVisible.value = false
loadHostGroups()
if (editingServiceId.value) loadHostGroupsForService(editingServiceId.value)
} else {
ElMessage.error(extractApiError(body, '修改失败'))
}
@@ -433,10 +494,11 @@ const submitEdit = () => {
})
}
// ========== ==========
// ==================== ====================
const bindDialogVisible = ref(false)
const bindSubmitLoading = ref(false)
const bindFormRef = ref(null)
const bindingServiceId = ref(null)
const bindForm = reactive({
id: undefined,
@@ -447,23 +509,19 @@ const bindForm = reactive({
good_id: 0
})
//
const showGroupSelector = ref(false)
const showProductSelector = ref(false)
//
const handleGroupSelected = (group) => {
bindForm.good_group_id = group.id
bindForm._groupName = group.name || ''
}
//
const handleProductSelected = (product) => {
bindForm.good_id = product.id
bindForm._goodName = product.name || ''
}
//
const clearBindGroup = () => {
bindForm.good_group_id = 0
bindForm._groupName = ''
@@ -475,6 +533,7 @@ const clearBindProduct = () => {
}
const handleBind = (row) => {
bindingServiceId.value = row.serviceId
Object.assign(bindForm, {
id: Number(row.Id ?? row.id),
_name: row.Name ?? row.name,
@@ -498,7 +557,7 @@ const submitBind = async () => {
if (body?.code === 200) {
ElMessage.success('绑定成功')
bindDialogVisible.value = false
loadHostGroups()
if (bindingServiceId.value) loadHostGroupsForService(bindingServiceId.value)
} else {
ElMessage.error(extractApiError(body, '绑定失败'))
}
@@ -509,10 +568,11 @@ const submitBind = async () => {
}
}
// ========== ==========
// ==================== ====================
const generateDialogVisible = ref(false)
const generateSubmitLoading = ref(false)
const generateFormRef = ref(null)
const generatingServiceId = ref(null)
const generateForm = reactive({
id: undefined,
@@ -527,10 +587,8 @@ const generateFormRules = {
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
}
//
const showGenerateGroupSelector = ref(false)
//
const showGenerateTagSelector = ref(false)
const tagOptions = ref([])
const tagLoading = ref(false)
@@ -567,10 +625,10 @@ const fetchTagOptions = async () => {
} catch { /* */ } finally { tagLoading.value = false }
}
//
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
const handleGenerateGoods = (row) => {
generatingServiceId.value = row.serviceId
Object.assign(generateForm, {
id: Number(row.Id ?? row.id),
parent_group_id: 0,
@@ -603,7 +661,7 @@ const submitGenerate = () => {
if (body?.code === 200) {
ElMessage.success('商品生成成功')
generateDialogVisible.value = false
loadHostGroups()
if (generatingServiceId.value) loadHostGroupsForService(generatingServiceId.value)
} else {
ElMessage.error(extractApiError(body, '商品生成失败'))
}
@@ -616,8 +674,8 @@ const submitGenerate = () => {
})
}
// ========== ==========
const handleDeleteGroup = (row) => {
// ==================== ====================
const handleDeleteGroup = (row, svcId) => {
const rawId = Number(row.Id ?? row.id)
if (!rawId) {
ElMessage.error('无法获取主机组ID')
@@ -633,7 +691,7 @@ const handleDeleteGroup = (row) => {
const body = res?.data
if (body?.code === 200) {
ElMessage.success('删除成功')
loadHostGroups()
if (svcId) loadHostGroupsForService(svcId)
} else {
ElMessage.error(extractApiError(body, '删除失败'))
}
@@ -643,15 +701,16 @@ const handleDeleteGroup = (row) => {
}).catch(() => {})
}
// ========== ==========
// ==================== ====================
const goBack = () => {
router.push('/virtualization/kvm-service')
}
onMounted(() => {
if (!embedded) loadServiceOptions()
if (serviceId.value) {
loadHostGroups()
if (!embedded) {
loadAllServices()
} else if (embeddedServiceId.value) {
loadHostGroupsForService(embeddedServiceId.value)
}
})
</script>
@@ -723,8 +782,76 @@ onMounted(() => {
.panel-body {
padding: 16px;
min-height: 300px;
min-height: 200px;
}
/* 主控服务卡片 */
.service-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
margin-bottom: 12px;
overflow: hidden;
border: 1px solid #ebeef5;
transition: border-color 0.2s;
}
.service-card:hover {
border-color: #c0c4cc;
}
.service-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
cursor: pointer;
user-select: none;
background: #fafbfc;
transition: background 0.2s;
}
.service-card-header:hover {
background: #f0f2f5;
}
.service-card-title {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.expand-icon {
font-size: 14px;
color: #909399;
transition: transform 0.3s;
flex-shrink: 0;
}
.expand-icon.is-expanded {
transform: rotate(90deg);
}
.service-name {
font-size: 15px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-card-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.service-card-body {
border-top: 1px solid #ebeef5;
}
.host-group-table-wrapper {
padding: 16px;
min-height: 100px;
}
</style>
+111 -7
View File
@@ -159,6 +159,32 @@
</el-form-item>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showDiskIoSection = !showDiskIoSection">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showDiskIoSection }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showDiskIoSection">
<div class="io-sub-title">
带宽限制
<el-select v-model="ioBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label">
<el-input-number :model-value="+(formData[f.key] / getIoBwFactor()).toFixed(2)" @update:model-value="v => formData[f.key] = Math.round((v || 0) * getIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label">
<el-input-number v-model="formData[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组">
@@ -271,6 +297,32 @@
</el-form-item>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showTokenDiskIo = !showTokenDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showTokenDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showTokenDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="tokenIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number :model-value="+(tokenForm[f.key] / getTokenIoBwFactor()).toFixed(2)" @update:model-value="v => tokenForm[f.key] = Math.round((v || 0) * getTokenIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number v-model="tokenForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title">令牌有效期</div>
<el-form-item label="有效期" prop="expire_hours">
@@ -378,7 +430,7 @@
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue'
import { Plus, Refresh, Search, ArrowLeft, ArrowRight, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue'
import {
getRemoteHostList, getRemoteHostDetail,
addRemoteHost, updateRemoteHost, deleteRemoteHost,
@@ -418,12 +470,49 @@ const currentDetail = ref(null)
const metricsVisible = ref(false)
const metricsData = ref(null)
const diskIoDefaults = {
read_bytes_sec: 314572800, write_bytes_sec: 314572800,
read_iops_sec: 1000, write_iops_sec: 1000,
read_bytes_sec_max: 314572800, write_bytes_sec_max: 314572800,
read_iops_sec_max: 1000, write_iops_sec_max: 1000
}
const diskIoBwFields = [
{ key: 'read_bytes_sec', label: '读取' },
{ key: 'write_bytes_sec', label: '写入' },
{ key: 'read_bytes_sec_max', label: '突发读取' },
{ key: 'write_bytes_sec_max', label: '突发写入' }
]
const diskIoIopsFields = [
{ key: 'read_iops_sec', label: '读取' },
{ key: 'write_iops_sec', label: '写入' },
{ key: 'read_iops_sec_max', label: '突发读取' },
{ key: 'write_iops_sec_max', label: '突发写入' }
]
const diskIoFields = [
...diskIoBwFields.map(f => ({ ...f, isBandwidth: true })),
...diskIoIopsFields.map(f => ({ ...f, isBandwidth: false }))
]
const ioBwUnitOptions = [
{ label: 'B/s', factor: 1 },
{ label: 'KB/s', factor: 1024 },
{ label: 'MB/s', factor: 1048576 },
{ label: 'GB/s', factor: 1073741824 }
]
const ioBwUnit = ref('MB/s')
const tokenIoBwUnit = ref('MB/s')
const getIoBwFactor = () => ioBwUnitOptions.find(u => u.label === ioBwUnit.value)?.factor || 1048576
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
const showDiskIoSection = ref(false)
const showTokenDiskIo = ref(false)
const formData = reactive({
id: undefined, name: '', base_url: '', ip: '', token: '',
port: 22, user: '', password: '', private_key: '',
max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
_groupName: ''
_groupName: '',
...diskIoDefaults
})
const formRules = {
@@ -522,8 +611,10 @@ const resetForm = () => {
port: 22, user: '', password: '', private_key: '',
max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
_groupName: ''
_groupName: '', ...diskIoDefaults
})
showDiskIoSection.value = false
ioBwUnit.value = 'MB/s'
}
const handleHostGroupSelected = (group) => {
@@ -585,9 +676,10 @@ const handleEdit = (row) => {
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
host_group_id: row.host_group_id || 0, description: row.description || '',
_groupName: getGroupName(row.host_group_id)
_groupName: getGroupName(row.host_group_id),
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
})
// password
showDiskIoSection.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
const body = res?.data
if (body?.code === 200 && body?.data) {
@@ -595,6 +687,7 @@ const handleEdit = (row) => {
if (detail.password) formData.password = detail.password
if (detail.token) formData.token = detail.token
if (detail.private_key) formData.private_key = detail.private_key
diskIoFields.forEach(f => { if (detail[f.key] !== undefined) formData[f.key] = detail[f.key] })
}
}).catch(() => {})
dialogVisible.value = true
@@ -719,7 +812,8 @@ const tokenForm = reactive({
max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24,
_groupName: ''
_groupName: '',
...diskIoDefaults
})
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
@@ -750,10 +844,13 @@ const openTokenDialog = () => {
name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24, _groupName: ''
description: '', expire_hours: 24, _groupName: '',
...diskIoDefaults
})
tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB'
showTokenDiskIo.value = false
tokenIoBwUnit.value = 'MB/s'
tokenDialogVisible.value = true
}
@@ -776,6 +873,7 @@ const handleTokenSubmit = () => {
fd.append('max_disk', tokenForm.max_disk)
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
diskIoFields.forEach(f => { if (tokenForm[f.key] !== undefined) fd.append(f.key, tokenForm[f.key]) })
fd.append('description', tokenForm.description || '')
fd.append('expire_hours', tokenForm.expire_hours)
@@ -832,4 +930,10 @@ onMounted(() => {
.metrics-card { margin-bottom: 12px; }
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
.tk-section-title.clickable:hover { color: #409eff; }
.section-arrow { transition: transform 0.2s; font-size: 14px; }
.section-arrow.expanded { transform: rotate(90deg); }
.section-hint { font-size: 12px; color: #909399; font-weight: 400; }
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
</style>
+135 -8
View File
@@ -22,8 +22,10 @@
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }">
<el-table-column label="名称" min-width="280">
<template #default="{ row }">
<div class="tree-name-cell" :style="{ paddingLeft: (row._depth * 24) + 'px' }">
<span v-if="row._isGroup" class="expand-icon" @click="toggleExpand(row)">
<div class="tree-name-cell" :style="{ paddingLeft: (row._depth * 24) + 'px' }"
:class="{ 'clickable-group': row._isGroup, 'clickable-host': row._isHost }"
@click="row._isGroup ? toggleExpand(row) : (row._isHost ? handleGoHostDetail(row) : null)">
<span v-if="row._isGroup" class="expand-icon">
<el-icon v-if="row._loading"><Loading /></el-icon>
<el-icon v-else :class="{ 'is-expanded': row._expanded }"><ArrowRight /></el-icon>
</span>
@@ -131,6 +133,32 @@
</el-form-item>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showTokenDiskIo = !showTokenDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showTokenDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showTokenDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="tokenIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number :model-value="+(tokenForm[f.key] / getTokenIoBwFactor()).toFixed(2)" @update:model-value="v => tokenForm[f.key] = Math.round((v || 0) * getTokenIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number v-model="tokenForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title">令牌有效期</div>
<el-form-item label="有效期" prop="expire_hours">
@@ -326,6 +354,32 @@
</el-form-item>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showHostDiskIo = !showHostDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showHostDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showHostDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="hostIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label">
<el-input-number :model-value="+(hostForm[f.key] / getHostIoBwFactor()).toFixed(2)" @update:model-value="v => hostForm[f.key] = Math.round((v || 0) * getHostIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label">
<el-input-number v-model="hostForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组">
@@ -625,6 +679,42 @@ const handleOptimalHost = async (row) => {
}
// ---- 宿 CRUD ----
const diskIoDefaults = {
read_bytes_sec: 314572800, write_bytes_sec: 314572800,
read_iops_sec: 1000, write_iops_sec: 1000,
read_bytes_sec_max: 314572800, write_bytes_sec_max: 314572800,
read_iops_sec_max: 1000, write_iops_sec_max: 1000
}
const diskIoBwFields = [
{ key: 'read_bytes_sec', label: '读取' },
{ key: 'write_bytes_sec', label: '写入' },
{ key: 'read_bytes_sec_max', label: '突发读取' },
{ key: 'write_bytes_sec_max', label: '突发写入' }
]
const diskIoIopsFields = [
{ key: 'read_iops_sec', label: '读取' },
{ key: 'write_iops_sec', label: '写入' },
{ key: 'read_iops_sec_max', label: '突发读取' },
{ key: 'write_iops_sec_max', label: '突发写入' }
]
const diskIoFields = [
...diskIoBwFields.map(f => ({ ...f, isBandwidth: true })),
...diskIoIopsFields.map(f => ({ ...f, isBandwidth: false }))
]
const ioBwUnitOptions = [
{ label: 'B/s', factor: 1 },
{ label: 'KB/s', factor: 1024 },
{ label: 'MB/s', factor: 1048576 },
{ label: 'GB/s', factor: 1073741824 }
]
const hostIoBwUnit = ref('MB/s')
const tokenIoBwUnit = ref('MB/s')
const getHostIoBwFactor = () => ioBwUnitOptions.find(u => u.label === hostIoBwUnit.value)?.factor || 1048576
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
const showHostDiskIo = ref(false)
const showTokenDiskIo = ref(false)
const hostDialogVisible = ref(false)
const hostDialogType = ref('add')
const hostFormRef = ref(null)
@@ -632,7 +722,8 @@ const hostForm = reactive({
id: undefined, name: '', base_url: '', ip: '', token: '',
port: 22, user: '', password: '', private_key: '',
max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: ''
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
...diskIoDefaults
})
const hostFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
@@ -642,13 +733,17 @@ const hostFormRules = {
const handleAddHost = () => {
hostDialogType.value = 'add'
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' })
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', ...diskIoDefaults })
showHostDiskIo.value = false
hostIoBwUnit.value = 'MB/s'
hostDialogVisible.value = true
}
const handleAddHostToGroup = (group) => {
hostDialogType.value = 'add'
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '' })
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '', ...diskIoDefaults })
showHostDiskIo.value = false
hostIoBwUnit.value = 'MB/s'
hostDialogVisible.value = true
}
@@ -659,14 +754,17 @@ const handleEditHost = (row) => {
port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '',
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
host_group_id: row.host_group_id || 0, description: row.description || ''
host_group_id: row.host_group_id || 0, description: row.description || '',
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
})
showHostDiskIo.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data.host ?? res.data.data.data ?? res.data.data
if (d.password) hostForm.password = d.password
if (d.token) hostForm.token = d.token
if (d.private_key) hostForm.private_key = d.private_key
diskIoFields.forEach(f => { if (d[f.key] !== undefined) hostForm[f.key] = d[f.key] })
}
}).catch(() => {})
hostDialogVisible.value = true
@@ -723,7 +821,8 @@ const tokenForm = reactive({
name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24
description: '', expire_hours: 24,
...diskIoDefaults
})
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
const tokenRules = {
@@ -751,10 +850,13 @@ const openTokenDialog = () => {
name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24
description: '', expire_hours: 24,
...diskIoDefaults
})
tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB'
showTokenDiskIo.value = false
tokenIoBwUnit.value = 'MB/s'
tokenDialogVisible.value = true
}
@@ -772,6 +874,7 @@ const handleTokenSubmit = () => {
fd.append('max_disk', tokenForm.max_disk)
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
diskIoFields.forEach(f => { if (tokenForm[f.key] !== undefined) fd.append(f.key, tokenForm[f.key]) })
fd.append('description', tokenForm.description || '')
fd.append('expire_hours', tokenForm.expire_hours)
const res = await createHostToken(fd)
@@ -823,6 +926,24 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-radius: 4px;
transition: background 0.15s;
}
.tree-name-cell.clickable-group {
cursor: pointer;
}
.tree-name-cell.clickable-group:hover {
background: #f5f7fa;
}
.tree-name-cell.clickable-host {
cursor: pointer;
}
.tree-name-cell.clickable-host:hover {
background: #ecf5ff;
}
.tree-name-cell.clickable-host:hover .row-name {
color: #409eff;
}
.expand-icon {
cursor: pointer;
@@ -839,4 +960,10 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
.host-addr { color: #409eff; font-size: 13px; }
.host-url { color: #909399; font-size: 12px; }
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
.tk-section-title.clickable:hover { color: #409eff; }
.section-arrow { transition: transform 0.2s; font-size: 14px; }
.section-arrow.expanded { transform: rotate(90deg); }
.section-hint { font-size: 12px; color: #909399; font-weight: 400; }
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
</style>
+370 -43
View File
@@ -18,6 +18,7 @@
</div>
<div class="overview-actions">
<el-button type="primary" @click="handleGetVnc">远程连接</el-button>
<el-button type="success" @click="handleSshConnect" :disabled="!publicIpList.length || isWindows">SSH连接</el-button>
<el-button type="danger" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop' || isMigrating">关机</el-button>
<el-dropdown trigger="click" @command="handleMoreCommand">
<el-button>更多 <el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
@@ -30,6 +31,9 @@
<el-dropdown-item divided command="editVm" :disabled="isMigrating">修改虚拟机</el-dropdown-item>
<el-dropdown-item command="refactorVm" :disabled="isMigrating">重构虚拟机</el-dropdown-item>
<el-dropdown-item command="updateTraffic" :disabled="isMigrating">修改带宽</el-dropdown-item>
<el-dropdown-item command="resetMac" :disabled="isMigrating">重置MAC地址</el-dropdown-item>
<el-dropdown-item command="disconnectNetwork" :disabled="isMigrating || detail.network_disabled">断网</el-dropdown-item>
<el-dropdown-item command="connectNetwork" :disabled="isMigrating || !detail.network_disabled">恢复网络</el-dropdown-item>
<el-dropdown-item divided command="rebuild" :disabled="isMigrating">重装虚拟机</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
@@ -50,6 +54,7 @@
<span class="status-value">
<span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span>
{{ vmStatusLabel(detail.status) }}
<el-tag v-if="detail.network_disabled" type="danger" size="small" effect="dark" style="margin-left:8px">已断网</el-tag>
<el-tag v-if="isMigrating" type="warning" size="small" effect="dark" style="margin-left:8px">迁移中</el-tag>
</span>
</div>
@@ -221,8 +226,23 @@
</div>
<div class="config-row">
<div class="config-cell">
<span class="config-label">流量上限</span>
<span class="config-value">{{ formatTrafficMax(detail.traffic_max) }}</span>
<span class="config-label">流量</span>
<span class="config-value traffic-cell">
<span class="traffic-main">{{ formatTrafficMb(detailTrafficUsedMb) }} / {{ formatTrafficMb(detailTrafficTotalMb) }}</span>
<span class="traffic-sub">
基础 {{ formatTrafficMb(detailTrafficBaseMb) }}
<template v-if="detailTrafficTempMb > 0">
· 临时 {{ formatTrafficMb(detailTrafficTempMb) }}
<span v-if="detailTrafficCycleStartText" class="traffic-cycle">周期 {{ detailTrafficCycleStartText }}</span>
</template>
</span>
<span class="traffic-actions">
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="openVmTrafficPolicyDialog" :disabled="isMigrating">
<el-icon :size="14"><Edit /></el-icon>修改
</el-button>
<el-button link type="warning" size="small" class="cfg-edit-btn" @click="openVmAddTrafficDialog('temporary')" :disabled="isMigrating">加临时</el-button>
</span>
</span>
</div>
<div class="config-cell">
<span class="config-label">快照配额</span>
@@ -265,6 +285,11 @@
<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="mac_address" label="MAC地址" min-width="150" show-overflow-tooltip />
<el-table-column label="主IP" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_primary" type="success" size="small" effect="dark"></el-tag>
</template>
</el-table-column>
<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>
@@ -272,10 +297,11 @@
</el-table-column>
<el-table-column prop="bridge_name" label="网桥" width="100" />
<el-table-column prop="target_device" label="目标设备" width="100" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleNetDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleNetEdit(row)">编辑</el-button>
<el-button v-if="!row.is_primary" link type="warning" size="small" @click="handleSetPrimary(row)">设为主IP</el-button>
<el-button link type="danger" size="small" @click="handleNetDelete(row)">删除</el-button>
</template>
</el-table-column>
@@ -569,15 +595,28 @@
<div class="section-header">
<h3 class="section-title">监控指标</h3>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="monitorTimeMode" size="small" @change="loadHistoricalMetrics">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="monitorTimeMode === 'relative'" v-model="monitorRelativeMinutes" size="small" style="width: 120px" @change="loadHistoricalMetrics">
<el-option label="10分钟" :value="10" />
<el-option label="30分钟" :value="30" />
<el-option label="1小时" :value="60" />
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker
v-else
v-model="monitorDateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
start-placeholder="开始"
end-placeholder="结束"
size="small"
style="width: 360px"
:shortcuts="monitorShortcuts"
style="width: 340px"
@change="loadHistoricalMetrics"
/>
<span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span>
@@ -606,6 +645,11 @@
<div class="metric-summary-value">{{ formatNetLabel(latestMetrics.net_rx) }}</div>
<div class="metric-summary-sub">{{ formatNetLabel(latestMetrics.net_tx) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">累计流量</div>
<div class="metric-summary-value">{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}</div>
<div class="metric-summary-sub">&nbsp;</div>
</div>
</div>
</template>
<template v-if="historicalMetricsData">
@@ -618,7 +662,7 @@
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 内存使用</span></template>
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 内存使用</span></template>
<div ref="memChartRef" class="chart-container"></div>
</el-card>
</el-col>
@@ -626,24 +670,38 @@
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 I/O</span></template>
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘读写量</span></template>
<div ref="diskChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 网络流量</span></template>
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 网络速率</span></template>
<div ref="netChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘IO速率</span></template>
<div ref="diskIopsChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 流量使用趋势</span></template>
<div ref="trafficUsedChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</template>
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
</div>
</el-tab-pane>
<!-- 流量策略 -->
<el-tab-pane label="流量策略" name="vmTrafficPolicy">
<!-- 流量管理合并流量策略 + 流量统计 -->
<el-tab-pane label="流量管理" name="trafficManage">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">流量策略</h3>
@@ -661,6 +719,41 @@
</el-descriptions>
<el-empty v-else-if="!vmTrafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
</div>
<div class="section-block">
<div class="section-header">
<h3 class="section-title">每小时流量</h3>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-radio-group v-model="trafficTimeMode" size="small" @change="loadTrafficHourly">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="trafficTimeMode === 'relative'" v-model="trafficRelativeMinutes" size="small" style="width: 120px" @change="loadTrafficHourly">
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="1天" :value="1440" />
<el-option label="3天" :value="4320" />
<el-option label="7天" :value="10080" />
</el-select>
<el-date-picker
v-else
v-model="trafficHourlyRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
size="small"
style="width: 340px"
@change="loadTrafficHourly"
/>
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
</div>
</div>
<el-card v-if="trafficHourlyData.length" shadow="hover" class="metrics-card" style="margin-top:12px">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 每小时流量MB</span></template>
<div ref="trafficHourlyChartRef" class="chart-container"></div>
</el-card>
<el-empty v-else-if="!trafficHourlyLoading" description="暂无流量统计数据" :image-size="60" />
</div>
</el-tab-pane>
</el-tabs>
@@ -1564,7 +1657,7 @@
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown, Plus, Search, WarningFilled, Loading } from '@element-plus/icons-vue'
import { ArrowLeft, Refresh, ArrowDown, Plus, Search, WarningFilled, Loading, Edit } from '@element-plus/icons-vue'
import {
getVmDetail, getVmStatus,
startVm, stopVm, rebootVm, suspendVm, resumeVm,
@@ -1583,7 +1676,9 @@ import {
migrateVm, getRemoteHostGroupList, getRemoteHostDetail,
dataMigrateVm, getDataMigrateProgress, abortDataMigrate,
getKvmServiceList, getMetricsHistory, getNetworkList,
getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic
getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic,
setNetworkPrimary, resetVmMac,
disconnectVmNetwork, connectVmNetwork, getVmTrafficHourly
} from '@/api/admin/kvmService'
import { getUserInfo } from '@/api/admin/user'
import { extractApiError } from '@/utils/kvmErrorUtil'
@@ -1644,6 +1739,33 @@ const formatTrafficMax = (val) => {
return `${gb.toFixed(2)} GB`
}
// MB formatTrafficMax 0 /
const formatTrafficMb = (mb) => {
const n = Number(mb)
if (!Number.isFinite(n) || n <= 0) return '0 MB'
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} TB`
if (n >= 1024) return `${(n / 1024).toFixed(2)} GB`
return `${n} MB`
}
// 使 add.json traffic_max_mb / temporary_traffic_mbfallback traffic_max
// traffic_used MB traffic_max_mb
const detailTrafficBaseMb = computed(() => Number(detail.value?.traffic_max_mb ?? detail.value?.traffic_max ?? 0) || 0)
const detailTrafficTempMb = computed(() => Number(detail.value?.temporary_traffic_mb || 0))
const detailTrafficTotalMb = computed(() => detailTrafficBaseMb.value + detailTrafficTempMb.value)
const detailTrafficUsedMb = computed(() => Number(detail.value?.traffic_used || 0))
const detailTrafficCycleStartText = computed(() => {
const t = detail.value?.temporary_cycle_start
const sec = typeof t === 'object' ? (t?.seconds ?? null) : t
if (!sec) return ''
const d = new Date(Number(sec) * 1000)
if (isNaN(d.getTime())) return ''
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
})
const copyAllIps = (ipList) => {
if (!ipList?.length) return
const text = ipList.join('\n')
@@ -1694,7 +1816,8 @@ const handleMoreCommand = (cmd) => {
const actionMap = {
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic,
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue,
migrateVm: handleMigrateVm
migrateVm: handleMigrateVm, resetMac: handleResetMac,
disconnectNetwork: handleDisconnectNetwork, connectNetwork: handleConnectNetwork
}
if (actionMap[cmd]) actionMap[cmd]()
if (cmd === 'dataMigrateVm') handleDataMigrateVm()
@@ -1800,30 +1923,33 @@ const cpuChartRef = ref(null)
const memChartRef = ref(null)
const netChartRef = ref(null)
const diskChartRef = ref(null)
const diskIopsChartRef = ref(null)
const trafficUsedChartRef = ref(null)
let cpuChart = null
let memChart = null
let netChart = null
let diskChart = null
let diskIopsChart = null
let trafficUsedChart = null
let isPageActive = false
const historicalMetricsData = ref(null)
const historicalMetricsLoading = ref(false)
const makeDefaultRange = () => {
const now = new Date()
return [new Date(now.getTime() - 10 * 60 * 1000), now]
}
const monitorDateRange = ref(makeDefaultRange())
const monitorTimeMode = ref('relative')
const monitorRelativeMinutes = ref(10)
const monitorDateRange = ref(null)
const monitorShortcuts = [
{ text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } },
{ text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } },
{ text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } },
{ text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } },
{ text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } },
{ text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } },
{ text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } },
]
const getMonitorTimeRange = () => {
if (monitorTimeMode.value === 'relative') {
const endTime = new Date()
const startTime = new Date(endTime - monitorRelativeMinutes.value * 60 * 1000)
return { startTime, endTime }
} else {
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return null
return { startTime: new Date(monitorDateRange.value[0]), endTime: new Date(monitorDateRange.value[1]) }
}
}
function calcInterval(startTime, endTime) {
const spanMin = (endTime.getTime() - startTime.getTime()) / 60000
@@ -1841,8 +1967,9 @@ function calcInterval(startTime, endTime) {
const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' }
const currentIntervalLabel = computed(() => {
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-'
const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1]))
const range = getMonitorTimeRange()
if (!range) return '-'
const iv = calcInterval(range.startTime, range.endTime)
return intervalLabelMap[iv] || iv
})
@@ -1885,11 +2012,11 @@ const vmMemPercent = (m) => {
const loadHistoricalMetrics = async () => {
if (!serviceId.value || !detail.value?.name) return
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return
const range = getMonitorTimeRange()
if (!range) return
historicalMetricsLoading.value = true
try {
const startTime = new Date(monitorDateRange.value[0])
const endTime = new Date(monitorDateRange.value[1])
const { startTime, endTime } = range
const interval = calcInterval(startTime, endTime)
const params = {
service_id: serviceId.value,
@@ -1919,7 +2046,8 @@ const renderHistoricalCharts = () => {
const metrics = historicalMetricsData.value
if (!Array.isArray(metrics) || !metrics.length) return
const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 0
const range = getMonitorTimeRange()
const spanMs = range ? (range.endTime.getTime() - range.startTime.getTime()) : 0
const showDate = spanMs >= 12 * 3600 * 1000
const symbolType = showDate ? 'circle' : 'none'
const labelRotate = showDate ? 45 : 0
@@ -1931,9 +2059,28 @@ const renderHistoricalCharts = () => {
})
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0)
const memData = metrics.map(m => m.mem_used ?? 0)
const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0))
const memAxisFmt = memTotal >= 1048576
? (v) => (v / 1048576).toFixed(1) + ' GB'
: memTotal >= 1024
? (v) => (v / 1024).toFixed(0) + ' MB'
: (v) => v + ' KB'
const diskReadData = metrics.map(m => m.disk_read ?? 0)
const diskWriteData = metrics.map(m => m.disk_write ?? 0)
const diskReadRate = []
const diskWriteRate = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { diskReadRate.push(0); diskWriteRate.push(0); continue }
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
if (dt > 0) {
diskReadRate.push(+Math.max(0, ((metrics[i].disk_read ?? 0) - (metrics[i - 1].disk_read ?? 0)) / dt / 1024).toFixed(2))
diskWriteRate.push(+Math.max(0, ((metrics[i].disk_write ?? 0) - (metrics[i - 1].disk_write ?? 0)) / dt / 1024).toFixed(2))
} else { diskReadRate.push(0); diskWriteRate.push(0) }
}
const netRxData = metrics.map(m => m.net_rx ?? 0)
const netTxData = metrics.map(m => m.net_tx ?? 0)
@@ -1954,9 +2101,9 @@ const renderHistoricalCharts = () => {
if (memChartRef.value) {
if (!memChart) memChart = echarts.init(memChartRef.value)
memChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${formatMemKB(p[0].value)}` },
grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } },
series: [makeSeries('内存', memData, '#67c23a')]
}, true)
}
@@ -1975,6 +2122,20 @@ const renderHistoricalCharts = () => {
}, true)
}
if (diskIopsChartRef.value) {
if (!diskIopsChart) diskIopsChart = echarts.init(diskIopsChartRef.value)
diskIopsChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0].axisValue
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}/s` })
return s
}},
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) + '/s' } },
series: [makeSeries('读取', diskReadRate, '#409eff'), makeSeries('写入', diskWriteRate, '#e6a23c')]
}, true)
}
if (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value)
netChart.setOption({
@@ -1988,6 +2149,22 @@ const renderHistoricalCharts = () => {
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
}, true)
}
if (trafficUsedChartRef.value) {
if (!trafficUsedChart) trafficUsedChart = echarts.init(trafficUsedChartRef.value)
const trafficDelta = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { trafficDelta.push(0); continue }
const delta = Math.max(0, (metrics[i].traffic_used_mb ?? 0) - (metrics[i - 1].traffic_used_mb ?? 0))
trafficDelta.push(+delta.toFixed(2))
}
trafficUsedChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 流量增量: ${p[0].value} MB` },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v + ' MB' } },
series: [makeSeries('流量增量', trafficDelta, '#722ed1')]
}, true)
}
}
const disposeCharts = () => {
@@ -1995,6 +2172,9 @@ const disposeCharts = () => {
memChart?.dispose(); memChart = null
netChart?.dispose(); netChart = null
diskChart?.dispose(); diskChart = null
diskIopsChart?.dispose(); diskIopsChart = null
trafficUsedChart?.dispose(); trafficUsedChart = null
trafficHourlyChart?.dispose(); trafficHourlyChart = null
}
const powerDialogVisible = ref(false)
@@ -2287,12 +2467,15 @@ const loadVmTrafficPolicy = async () => {
}
const openVmTrafficPolicyDialog = () => {
// vmTrafficPolicy tab
// detail.value add.json fallback
Object.assign(vmTrafficPolicyForm, {
traffic_max_mb: vmTrafficPolicy.value?.traffic_max_mb || 0,
exhausted_rx_mbps: vmTrafficPolicy.value?.exhausted_rx_mbps || 0,
exhausted_tx_mbps: vmTrafficPolicy.value?.exhausted_tx_mbps || 0
traffic_max_mb: vmTrafficPolicy.value?.traffic_max_mb ?? detail.value?.traffic_max_mb ?? detail.value?.traffic_max ?? 0,
exhausted_rx_mbps: vmTrafficPolicy.value?.exhausted_rx_mbps ?? detail.value?.traffic_exhausted_rx_mbps ?? 0,
exhausted_tx_mbps: vmTrafficPolicy.value?.exhausted_tx_mbps ?? detail.value?.traffic_exhausted_tx_mbps ?? 0
})
vmTrafficPolicyVisible.value = true
if (!vmTrafficPolicy.value) loadVmTrafficPolicy()
}
const submitUpdateVmTrafficPolicy = async () => {
@@ -2601,6 +2784,15 @@ const loadVncNodes = async () => {
} catch { /* */ }
}
const handleSshConnect = () => {
const host = publicIpList.value[0]
if (!host) return ElMessage.warning('无可用公网 IP')
const username = isWindows.value ? 'Administrator' : 'root'
const password = detail.value?.root_password || ''
const encodedPwd = btoa(password)
window.open(`https://webssh1.007yjs.com/?hostname=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodedPwd}`, '_blank')
}
const handleGetVnc = async () => {
vncNodeId.value = null
vncResult.value = null
@@ -2780,6 +2972,133 @@ const handleNetDelete = (row) => {
}).catch(() => {})
}
const handleSetPrimary = (row) => {
ElMessageBox.confirm(
`将「${row.address || row.name}」设为主IP?设置后将重启虚拟机。`,
'设置主IP', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('network_id', row.id)
fd.append('host_id', row.host_id || detail.value?.host_id)
const res = await setNetworkPrimary(fd)
if (res?.data?.code === 200) { ElMessage.success('设置主IP成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '设置主IP失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置主IP失败')) }
}).catch(() => {})
}
const handleResetMac = () => {
ElMessageBox.confirm(
'重置MAC地址将重建 CloudInit 并重启虚拟机,确定继续?',
'重置MAC地址', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', detail.value?.id)
const res = await resetVmMac(fd)
if (res?.data?.code === 200) { ElMessage.success('MAC地址重置成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '重置MAC失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重置MAC失败')) }
}).catch(() => {})
}
const handleDisconnectNetwork = () => {
ElMessageBox.confirm(
'断开虚拟机外部网络连接?断网后虚拟机将无法访问外部网络。',
'断网', { confirmButtonText: '确定断网', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', detail.value?.id)
const res = await disconnectVmNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('已断网'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '断网失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '断网失败')) }
}).catch(() => {})
}
const handleConnectNetwork = () => {
ElMessageBox.confirm(
'恢复虚拟机外部网络连接?',
'恢复网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', detail.value?.id)
const res = await connectVmNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('网络已恢复'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '恢复网络失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复网络失败')) }
}).catch(() => {})
}
// ---- ----
const trafficTimeMode = ref('relative')
const trafficRelativeMinutes = ref(1440)
const trafficHourlyRange = ref(null)
const trafficHourlyData = ref([])
const trafficHourlyLoading = ref(false)
const trafficHourlyChartRef = ref(null)
let trafficHourlyChart = null
const getTrafficTimeRange = () => {
if (trafficTimeMode.value === 'relative') {
const endTime = new Date()
const startTime = new Date(endTime - trafficRelativeMinutes.value * 60 * 1000)
return { startTime, endTime }
} else {
if (!trafficHourlyRange.value || trafficHourlyRange.value.length < 2) return null
return { startTime: new Date(trafficHourlyRange.value[0]), endTime: new Date(trafficHourlyRange.value[1]) }
}
}
const loadTrafficHourly = async () => {
if (!serviceId.value || !detail.value?.host_id || !detail.value?.name) return
const range = getTrafficTimeRange()
if (!range) return
trafficHourlyLoading.value = true
try {
const res = await getVmTrafficHourly({
service_id: serviceId.value,
host_id: detail.value.host_id,
vm_name: detail.value.name,
start: range.startTime.toISOString(),
end_time: range.endTime.toISOString()
})
const raw = res?.data?.data?.data
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
nextTick(renderTrafficHourlyChart)
} catch { trafficHourlyData.value = [] } finally { trafficHourlyLoading.value = false }
}
const renderTrafficHourlyChart = () => {
if (!trafficHourlyChartRef.value || !trafficHourlyData.value.length) return
if (!trafficHourlyChart) trafficHourlyChart = echarts.init(trafficHourlyChartRef.value)
const data = trafficHourlyData.value
const times = data.map(d => {
const date = new Date(d.bucket)
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
})
const toMB = (b) => +(b / 1048576).toFixed(2)
trafficHourlyChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => params.map(p => `${p.marker}${p.seriesName}: ${p.value} MB`).join('<br/>') },
legend: { data: ['下行', '上行', '合计'], bottom: 0 },
grid: { top: 10, right: 16, bottom: 40, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', name: 'MB', axisLabel: { fontSize: 10 } },
series: [
{ name: '下行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.rx_bytes)), itemStyle: { color: '#409EFF' } },
{ name: '上行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.tx_bytes)), itemStyle: { color: '#67C23A' } },
{ name: '合计', type: 'line', smooth: true, data: data.map(d => toMB(d.total_bytes)), itemStyle: { color: '#E6A23C' }, lineStyle: { width: 2 } }
]
}, true)
}
// ---- /////// ----
const showVolSelector = ref(false)
@@ -3619,7 +3938,7 @@ const triggerTabLoad = (tab) => {
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
if (tab === 'userNetworking') loadVmNetworkingList()
if (tab === 'security') loadSgLockInfo()
if (tab === 'vmTrafficPolicy') loadVmTrafficPolicy()
if (tab === 'trafficManage') { loadVmTrafficPolicy(); loadTrafficHourly() }
}
// lock
@@ -3692,6 +4011,14 @@ onMounted(() => { isPageActive = true; initPage() })
.config-cell:last-child { border-right: none; }
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
.config-value { font-size: 14px; color: #1d2129; line-height: 1.4; word-break: break-all; }
.cfg-edit-btn { margin-left: 8px; padding: 0 4px; height: 18px; vertical-align: middle; }
.cfg-edit-btn .el-icon { margin-right: 2px; vertical-align: -2px; }
.traffic-cell { display: flex; flex-direction: column; gap: 2px; }
.traffic-cell .traffic-main { font-size: 14px; color: #1d2129; }
.traffic-cell .traffic-sub { font-size: 12px; color: #86909c; line-height: 1.3; }
.traffic-cell .traffic-cycle { color: #c0c4cc; margin-left: 2px; }
.traffic-cell .traffic-actions { display: inline-flex; gap: 6px; margin-top: 2px; }
.traffic-cell .traffic-actions .cfg-edit-btn { margin-left: 0; }
.spec-value { font-size: 13px; color: #4e5969; }
.ip-value { color: #165dff; font-weight: 500; }
.password-cell { display: flex; align-items: center; gap: 8px; }
+1
View File
@@ -391,6 +391,7 @@
<el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item>
<el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item>
<el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item>
<el-descriptions-item label="累计流量">{{ vmMetricsData.traffic_used_mb != null ? vmMetricsData.traffic_used_mb + ' MB' : '-' }}</el-descriptions-item>
</el-descriptions>
</template>
</div>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="vm-monitor-container">
<div class="monitor-toolbar">
<div class="toolbar-row">
<div class="toolbar-item">
<span class="toolbar-label">虚拟机</span>
<el-select
v-model="selectedVms"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择要监控的虚拟机"
style="width: 360px"
filterable
:loading="vmListLoading"
>
<el-option v-for="vm in vmOptions" :key="vm.name" :label="`${vm.name} (ID:${vm.id})`" :value="vm.name" />
</el-select>
<el-button link type="primary" @click="selectAllVms" v-if="vmOptions.length > 0">全选</el-button>
<el-button link @click="selectedVms = []" v-if="selectedVms.length > 0">清空</el-button>
</div>
<div class="toolbar-item">
<span class="toolbar-label">指标</span>
<el-checkbox-group v-model="selectedMetrics">
<el-checkbox label="cpu">CPU</el-checkbox>
<el-checkbox label="memory">内存</el-checkbox>
<el-checkbox label="disk">磁盘IO</el-checkbox>
<el-checkbox label="network">网络</el-checkbox>
</el-checkbox-group>
</div>
</div>
<div class="toolbar-row">
<div class="toolbar-item">
<span class="toolbar-label">时间</span>
<el-radio-group v-model="timeMode" size="small" @change="handleRefresh">
<el-radio-button label="relative">最近</el-radio-button>
<el-radio-button label="fixed">自定义</el-radio-button>
</el-radio-group>
<el-select v-if="timeMode === 'relative'" v-model="timeRange" style="width: 130px" @change="handleRefresh">
<el-option label="10分钟" :value="10" />
<el-option label="30分钟" :value="30" />
<el-option label="1小时" :value="60" />
<el-option label="3小时" :value="180" />
<el-option label="6小时" :value="360" />
<el-option label="12小时" :value="720" />
<el-option label="24小时" :value="1440" />
</el-select>
<el-date-picker
v-else
v-model="fixedDateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
size="small"
style="width: 340px"
@change="handleRefresh"
/>
</div>
<div class="toolbar-item">
<span class="toolbar-label">自动刷新</span>
<el-select v-model="autoRefreshInterval" style="width: 110px" @change="resetAutoRefresh">
<el-option label="关闭" :value="0" />
<el-option label="10秒" :value="10" />
<el-option label="30秒" :value="30" />
<el-option label="1分钟" :value="60" />
<el-option label="5分钟" :value="300" />
</el-select>
</div>
<div class="toolbar-item">
<el-button type="primary" @click="handleRefresh" :loading="metricsLoading" :disabled="selectedVms.length === 0">
刷新
</el-button>
</div>
</div>
</div>
<div class="charts-area" v-if="hasData">
<div class="chart-section" v-for="metric in selectedMetrics" :key="metric">
<h3 class="chart-section-title">{{ metricLabels[metric] }}</h3>
<div class="chart-wrapper">
<div class="chart-box" :ref="el => setChartRef(metric, el)"></div>
</div>
</div>
</div>
<el-empty v-else-if="!metricsLoading && selectedVms.length === 0" description="请选择要监控的虚拟机" :image-size="80" />
<el-empty v-else-if="!metricsLoading && loaded && !hasData" description="暂无监控数据" :image-size="80" />
</div>
</template>
<script setup>
import { ref, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { getVmList, getMetricsHistory } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import * as echarts from 'echarts'
const serviceId = inject('serviceId')
const hostId = inject('hostId')
const vmListLoading = ref(false)
const vmOptions = ref([])
const selectedVms = ref([])
const selectedMetrics = ref(['cpu', 'memory'])
const metricsLoading = ref(false)
const loaded = ref(false)
const metricsDataMap = ref({})
const timeMode = ref('relative')
const timeRange = ref(60)
const fixedDateRange = ref(null)
const autoRefreshInterval = ref(0)
let autoRefreshTimer = null
const metricLabels = {
cpu: 'CPU 使用率',
memory: '内存使用',
disk: '磁盘IO速率',
network: '网络速率'
}
const vmColors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#b37feb', '#36cfc9', '#ff85c0', '#ffc53d', '#597ef7']
const hasData = computed(() => Object.keys(metricsDataMap.value).length > 0)
const chartInstances = {}
const chartElements = {}
const setChartRef = (metric, el) => {
if (el) {
chartElements[metric] = el
nextTick(() => {
if (!chartInstances[metric]) {
chartInstances[metric] = echarts.init(el)
}
})
}
}
const selectAllVms = () => { selectedVms.value = vmOptions.value.map(v => v.name) }
const calcInterval = (ms) => {
if (ms <= 10 * 60 * 1000) return '1m'
if (ms <= 30 * 60 * 1000) return '3m'
if (ms <= 60 * 60 * 1000) return '5m'
if (ms <= 3 * 3600 * 1000) return '10m'
if (ms <= 6 * 3600 * 1000) return '20m'
if (ms <= 12 * 3600 * 1000) return '30m'
if (ms <= 24 * 3600 * 1000) return '1h'
return '3h'
}
const formatBytes = (val) => {
if (!val && val !== 0) return '0 B'
val = Math.abs(Number(val))
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
return val.toFixed(0) + ' B'
}
const formatBytesPerSec = (val) => formatBytes(val) + '/s'
const formatMemKiB = (kib) => {
if (!kib && kib !== 0) return '0'
kib = Math.abs(Number(kib))
if (kib >= 1048576) return (kib / 1048576).toFixed(1) + ' GB'
if (kib >= 1024) return (kib / 1024).toFixed(0) + ' MB'
return kib.toFixed(0) + ' KB'
}
const loadVmList = async () => {
if (!serviceId.value || !hostId.value) return
vmListLoading.value = true
try {
const res = await getVmList({ service_id: serviceId.value, host_id: hostId.value, page: 1, count: 500 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
vmOptions.value = inner.data || inner.vms || (Array.isArray(inner) ? inner : [])
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '获取虚拟机列表失败'))
} finally {
vmListLoading.value = false
}
}
const getTimeRange = () => {
if (timeMode.value === 'relative') {
const endTime = new Date()
const startTime = new Date(endTime - timeRange.value * 60 * 1000)
return { startTime, endTime }
} else {
if (!fixedDateRange.value || fixedDateRange.value.length < 2) return null
return { startTime: new Date(fixedDateRange.value[0]), endTime: new Date(fixedDateRange.value[1]) }
}
}
const handleRefresh = async () => {
if (!selectedVms.value.length) return
const range = getTimeRange()
if (!range) { ElMessage.warning('请选择时间范围'); return }
metricsLoading.value = true
const { startTime, endTime } = range
const interval = calcInterval(endTime - startTime)
const dataMap = {}
try {
await Promise.all(selectedVms.value.map(async (vmName) => {
try {
const res = await getMetricsHistory({
service_id: serviceId.value,
host_id: hostId.value,
vm_name: vmName,
start: startTime.toISOString(),
end_time: endTime.toISOString(),
interval
})
const body = res?.data
if (body?.code === 200 && body?.data) {
dataMap[vmName] = Array.isArray(body.data) ? body.data : (body.data.data || [])
}
} catch (e) {
console.warn(`获取 ${vmName} 监控数据失败:`, e)
}
}))
metricsDataMap.value = dataMap
loaded.value = true
await nextTick()
renderCharts()
} catch (e) {
ElMessage.error('加载监控数据失败')
} finally {
metricsLoading.value = false
}
}
const renderCharts = () => {
const range = getTimeRange()
const spanMs = range ? (range.endTime - range.startTime) : 0
const showDate = spanMs >= 12 * 3600 * 1000
const labelRotate = showDate ? 30 : 0
for (const metric of selectedMetrics.value) {
const chart = chartInstances[metric]
if (!chart) continue
const allTimes = getUnifiedTimeline()
if (!allTimes.length) continue
const timeLabels = allTimes.map(t => {
const date = new Date(t)
if (showDate) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
})
const seriesList = []
selectedVms.value.forEach((vmName, idx) => {
const metrics = metricsDataMap.value[vmName]
if (!metrics || !metrics.length) return
const color = vmColors[idx % vmColors.length]
const vmData = buildMetricData(metric, metrics, allTimes)
seriesList.push({ name: vmName, data: vmData, color })
})
if (!seriesList.length) continue
let yAxisFormatter, tooltipUnit
if (metric === 'cpu') {
yAxisFormatter = v => v.toFixed(0) + '%'
tooltipUnit = '%'
} else if (metric === 'memory') {
yAxisFormatter = v => formatMemKiB(v)
tooltipUnit = 'KiB'
} else if (metric === 'disk') {
yAxisFormatter = v => formatBytesPerSec(v)
tooltipUnit = '/s'
} else {
yAxisFormatter = v => formatBytesPerSec(v)
tooltipUnit = '/s'
}
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: (params) => {
let s = params[0]?.axisValue || ''
params.forEach(p => {
let val
if (metric === 'cpu') val = (p.value ?? 0).toFixed(1) + '%'
else if (metric === 'memory') val = formatMemKiB(p.value)
else val = formatBytesPerSec(p.value)
s += `<br/>${p.marker} ${p.seriesName}: ${val}`
})
return s
}
},
legend: { top: 4, right: 8, textStyle: { fontSize: 11 }, type: 'scroll' },
grid: { top: 40, left: 70, right: 16, bottom: labelRotate > 0 ? 55 : 35 },
xAxis: {
type: 'category', data: timeLabels, boundaryGap: false,
axisLabel: { fontSize: 10, rotate: labelRotate, color: '#86909c' },
axisLine: { lineStyle: { color: '#e8e8e8' } }
},
yAxis: {
type: 'value', min: 0,
max: metric === 'cpu' ? 100 : undefined,
axisLabel: { fontSize: 10, formatter: yAxisFormatter, color: '#86909c' },
splitLine: { lineStyle: { color: '#f0f0f0' } }
},
series: seriesList.map(s => ({
name: s.name,
type: 'line',
data: s.data,
smooth: true,
symbol: 'none',
lineStyle: { width: 1.5 },
itemStyle: { color: s.color },
areaStyle: seriesList.length <= 3
? { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: s.color + '20' }, { offset: 1, color: s.color + '02' }]) }
: undefined
}))
}, true)
chart.resize()
}
}
const getUnifiedTimeline = () => {
const timeSet = new Set()
for (const vmName of selectedVms.value) {
const data = metricsDataMap.value[vmName]
if (data) data.forEach(m => timeSet.add(m.bucket))
}
return Array.from(timeSet).sort()
}
const buildMetricData = (metric, metrics, allTimes) => {
const timeMap = new Map()
metrics.forEach((m, i) => timeMap.set(m.bucket, { ...m, _idx: i }))
return allTimes.map((t, tIdx) => {
const m = timeMap.get(t)
if (!m) return null
if (metric === 'cpu') {
return +(m.cpu_usage ?? 0).toFixed(1)
} else if (metric === 'memory') {
return +(m.mem_used ?? 0)
} else if (metric === 'disk') {
// disk_read/disk_write are cumulative Bytes, compute rate via diff
if (m._idx === 0) return 0
const prev = metrics[m._idx - 1]
const dt = (new Date(m.bucket) - new Date(prev.bucket)) / 1000
if (dt <= 0) return 0
const readRate = Math.max(0, ((m.disk_read ?? 0) - (prev.disk_read ?? 0)) / dt)
const writeRate = Math.max(0, ((m.disk_write ?? 0) - (prev.disk_write ?? 0)) / dt)
return +(readRate + writeRate).toFixed(0)
} else if (metric === 'network') {
// net_rx/net_tx are already Bytes/s
return +((m.net_rx ?? 0) + (m.net_tx ?? 0)).toFixed(0)
}
return 0
})
}
const resetAutoRefresh = () => {
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null }
if (autoRefreshInterval.value > 0) {
autoRefreshTimer = setInterval(() => handleRefresh(), autoRefreshInterval.value * 1000)
}
}
const disposeCharts = () => {
Object.values(chartInstances).forEach(c => { try { c.dispose() } catch {} })
Object.keys(chartInstances).forEach(k => delete chartInstances[k])
}
const handleResize = () => {
Object.values(chartInstances).forEach(c => { try { c.resize() } catch {} })
}
onMounted(() => {
loadVmList()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer)
disposeCharts()
window.removeEventListener('resize', handleResize)
})
defineExpose({ loadVmList, loadList: loadVmList })
</script>
<style scoped>
.vm-monitor-container { padding: 0; }
.monitor-toolbar {
background: #f7f8fa;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 20px;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.toolbar-row + .toolbar-row { margin-top: 12px; }
.toolbar-item {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-label {
font-size: 13px;
color: #606266;
font-weight: 500;
white-space: nowrap;
}
.charts-area { margin-top: 8px; }
.chart-section { margin-bottom: 20px; }
.chart-section-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin: 0 0 8px;
padding-left: 8px;
border-left: 3px solid #409eff;
}
.chart-wrapper {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 12px 16px;
}
.chart-box {
width: 100%;
height: 260px;
}
</style>