From 802eaa396b48b4cdae6e62ef220bd9bf4878809b Mon Sep 17 00:00:00 2001 From: shiran Date: Tue, 12 May 2026 17:09:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin/user-vm):=20=E6=B5=81=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E5=B1=95=E7=A4=BA=E5=8A=A0=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E3=80=81=E7=BD=91=E7=BB=9Ctab=E5=8A=A0?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=BD=91=E7=BB=9C=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缘由: 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 --- src/views/user-vm/UserVmDetail.vue | 67 +++++++++++++++++++++++++-- src/views/virtualization/VmDetail.vue | 11 ++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/views/user-vm/UserVmDetail.vue b/src/views/user-vm/UserVmDetail.vue index 3c066f4..4676b95 100644 --- a/src/views/user-vm/UserVmDetail.vue +++ b/src/views/user-vm/UserVmDetail.vue @@ -129,7 +129,15 @@ -
流量上限{{ formatTraffic(vm.traffic_max) }}
+
+ 流量上限 + + {{ formatTraffic(vm.traffic_max) }} + + 修改 + + +
续费价格¥{{ (userGoods.renewPrice / 100).toFixed(2) }}
基础价格¥{{ (userGoods.basePrice / 100).toFixed(2) }}
@@ -306,6 +314,13 @@ + + + @@ -1072,7 +1087,7 @@ import { ref, reactive, computed, 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 +1098,12 @@ 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 } 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' @@ -1644,6 +1660,49 @@ 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 showBindNetworkSelector = ref(false) @@ -2355,6 +2414,8 @@ 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; } .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; } diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue index 7ff8cd6..dbbfc1d 100644 --- a/src/views/virtualization/VmDetail.vue +++ b/src/views/virtualization/VmDetail.vue @@ -222,7 +222,12 @@
流量上限 - {{ formatTrafficMax(detail.traffic_max) }} + + {{ formatTrafficMax(detail.traffic_max) }} + + 修改 + +
快照配额 @@ -1564,7 +1569,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, @@ -3692,6 +3697,8 @@ 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; } .spec-value { font-size: 13px; color: #4e5969; } .ip-value { color: #165dff; font-weight: 500; } .password-cell { display: flex; align-items: center; gap: 8px; }