From 40a5e486a67f9e707907d9630d5854a65b96c59e Mon Sep 17 00:00:00 2001 From: 2256907009 <2256907009@qq.com> Date: Tue, 24 Mar 2026 18:57:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E6=8E=A5=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=BB=84=E7=BD=91=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/kvmService.js | 13 +- .../admin/HostGroupSelectorPopup.vue | 1 + src/components/admin/ImageSelectorPopup.vue | 3 +- src/components/admin/NetworkSelectorPopup.vue | 19 +- .../admin/SecurityGroupSelectorPopup.vue | 16 +- src/components/admin/VmSelectorPopup.vue | 5 +- src/components/admin/VolumeSelectorPopup.vue | 18 +- src/router/index.js | 10 + src/style.css | 17 + src/views/acs/nodes/components/VmList.vue | 2 +- src/views/acs/nodes/server.vue | 2 +- src/views/virtualization/BackupManage.vue | 2 + src/views/virtualization/HostDetail.vue | 53 +- src/views/virtualization/HostTreeManage.vue | 2 +- src/views/virtualization/ImageDetail.vue | 11 +- src/views/virtualization/ImageManage.vue | 80 +- src/views/virtualization/KvmServiceDetail.vue | 16 +- src/views/virtualization/NetworkManage.vue | 4 +- .../virtualization/SecurityGroupDetail.vue | 5 +- .../virtualization/SecurityGroupManage.vue | 2 +- src/views/virtualization/SnapshotManage.vue | 2 + .../virtualization/UserNetworkingManage.vue | 139 +- src/views/virtualization/VmDetail.vue | 1476 +-- src/views/virtualization/VmManage.vue | 126 +- src/views/virtualization/VncNodeManage.vue | 2 +- src/views/virtualization/VolumeDetail.vue | 291 + src/views/virtualization/VolumeManage.vue | 112 +- 虚拟化平台管理图谱.md | 271 + 默认模块.openapi.json | 8576 +---------------- 29 files changed, 1895 insertions(+), 9381 deletions(-) create mode 100644 src/views/virtualization/VolumeDetail.vue create mode 100644 虚拟化平台管理图谱.md diff --git a/src/api/admin/kvmService.js b/src/api/admin/kvmService.js index 54d41e6..4705a5e 100644 --- a/src/api/admin/kvmService.js +++ b/src/api/admin/kvmService.js @@ -672,42 +672,41 @@ export const setBackupLimit = (data) => { /** * ================================ * 用户组网管理 (UserNetworking) - * 注意:此模块接口前缀为 /api/v1/admins/service/ * ================================ */ /** 获取组网列表 */ export const getUserNetworkingList = (params) => { - return http2.get('/api/v1/admins/service/host_service/point/networking/list', { params }) + return http2.get('/api/v1/admin/server/host_service/point/networking/list', { params }) } /** 获取组网详情 */ export const getUserNetworkingDetail = (params) => { - return http2.get('/api/v1/admins/service/host_service/point/networking/detail', { params }) + return http2.get('/api/v1/admin/server/host_service/point/networking/detail', { params }) } /** 创建用户组网 */ export const createUserNetworking = (data) => { - return http2.post('/api/v1/admins/service/host_service/point/networking/create', data, { + return http2.post('/api/v1/admin/server/host_service/point/networking/create', data, { headers: { 'Content-Type': 'multipart/form-data' } }) } /** 为虚拟机分配组网 IP */ export const assignUserNetworking = (data) => { - return http2.post('/api/v1/admins/service/host_service/point/networking/assign', data, { + return http2.post('/api/v1/admin/server/host_service/point/networking/assign', data, { headers: { 'Content-Type': 'multipart/form-data' } }) } /** 删除组网 */ export const deleteUserNetworking = (params) => { - return http2.delete('/api/v1/admins/service/host_service/point/networking/delete', { params }) + return http2.delete('/api/v1/admin/server/host_service/point/networking/delete', { params }) } /** 删除组网下的指定网络 */ export const removeUserNetworkingNetwork = (data) => { - return http2.post('/api/v1/admins/service/host_service/point/networking/remove_network', data, { + return http2.post('/api/v1/admin/server/host_service/point/networking/remove_network', data, { headers: { 'Content-Type': 'multipart/form-data' } }) } diff --git a/src/components/admin/HostGroupSelectorPopup.vue b/src/components/admin/HostGroupSelectorPopup.vue index dfa062a..6913ce7 100644 --- a/src/components/admin/HostGroupSelectorPopup.vue +++ b/src/components/admin/HostGroupSelectorPopup.vue @@ -70,4 +70,5 @@ const handleClose = () => { selectedItem.value = null } diff --git a/src/components/admin/ImageSelectorPopup.vue b/src/components/admin/ImageSelectorPopup.vue index 3c33675..9158806 100644 --- a/src/components/admin/ImageSelectorPopup.vue +++ b/src/components/admin/ImageSelectorPopup.vue @@ -68,7 +68,7 @@ watch(visible, (val) => emit('update:modelValue', val)) const loadList = async () => { loading.value = true try { - const params = { service_id: props.serviceId, page: 1, count: 100 } + const params = { service_id: props.serviceId, page: 1, count: 10 } if (keyword.value) params.keyword = keyword.value if (filterOsType.value) params.os_type = filterOsType.value const res = await getImageList(params) @@ -96,4 +96,5 @@ const handleClose = () => { selectedItem.value = null } .selector-container { min-height: 200px; } .filter-bar { display: flex; gap: 8px; margin-bottom: 12px; } :deep(.current-row) { background-color: #ecf5ff !important; } +:deep(.el-table__body tr) { cursor: pointer; } diff --git a/src/components/admin/NetworkSelectorPopup.vue b/src/components/admin/NetworkSelectorPopup.vue index 81b575b..d8e0c5b 100644 --- a/src/components/admin/NetworkSelectorPopup.vue +++ b/src/components/admin/NetworkSelectorPopup.vue @@ -35,8 +35,13 @@ @@ -52,7 +57,7 @@ const props = defineProps({ hostId: { type: Number, default: 0 } }) -const emit = defineEmits(['update:modelValue', 'confirm']) +const emit = defineEmits(['update:modelValue', 'confirm', 'create']) const visible = ref(false) const loading = ref(false) @@ -63,6 +68,7 @@ const pageSize = ref(10) const keyword = ref('') const typeFilter = ref('') const selectedItem = ref(null) +const type = ref('bridge') watch(() => props.modelValue, (val) => { visible.value = val @@ -82,7 +88,7 @@ const loadList = async () => { if (!props.serviceId || !props.hostId) return loading.value = true try { - const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value } + const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value,type: type.value } if (keyword.value) params.keyword = keyword.value if (typeFilter.value) params.type = typeFilter.value const res = await getNetworkList(params) @@ -103,6 +109,10 @@ const handleConfirm = () => { } } const handleClose = () => { selectedItem.value = null } +const handleCreate = () => { + visible.value = false + emit('create') +} diff --git a/src/components/admin/SecurityGroupSelectorPopup.vue b/src/components/admin/SecurityGroupSelectorPopup.vue index 7c9610d..a36ca04 100644 --- a/src/components/admin/SecurityGroupSelectorPopup.vue +++ b/src/components/admin/SecurityGroupSelectorPopup.vue @@ -36,8 +36,13 @@ @@ -52,7 +57,7 @@ const props = defineProps({ serviceId: { type: Number, default: 0 } }) -const emit = defineEmits(['update:modelValue', 'confirm']) +const emit = defineEmits(['update:modelValue', 'confirm', 'create']) const visible = ref(false) const loading = ref(false) @@ -92,6 +97,10 @@ const handleConfirm = () => { if (selectedItem.value) { emit('confirm', selectedItem.value); visible.value = false } } const handleClose = () => { selectedItem.value = null } +const handleCreate = () => { + visible.value = false + emit('create') +} diff --git a/src/components/admin/VmSelectorPopup.vue b/src/components/admin/VmSelectorPopup.vue index a2787b5..f93e95d 100644 --- a/src/components/admin/VmSelectorPopup.vue +++ b/src/components/admin/VmSelectorPopup.vue @@ -45,7 +45,7 @@ const visible = ref(false) const loading = ref(false) const list = ref([]) const selectedItem = ref(null) -const hostIdFilter = ref(0) +const hostIdFilter = ref('') const hostOptions = ref([]) watch(() => props.modelValue, (val) => { @@ -56,7 +56,7 @@ watch(visible, (val) => emit('update:modelValue', val)) const loadHostOptions = async () => { try { - const res = await getRemoteHostList({ service_id: props.serviceId, page: 1, page_size: 100 }) + const res = await getRemoteHostList({ service_id: props.serviceId, page: 1, page_size: 10 }) const body = res?.data if (body?.code === 200 && body?.data) { const inner = body.data @@ -106,4 +106,5 @@ const handleClose = () => { selectedItem.value = null } .selector-container { min-height: 200px; } .filter-bar { display: flex; gap: 8px; margin-bottom: 12px; } :deep(.current-row) { background-color: #ecf5ff !important; } +:deep(.el-table__body tr) { cursor: pointer; } diff --git a/src/components/admin/VolumeSelectorPopup.vue b/src/components/admin/VolumeSelectorPopup.vue index 2309c44..c621218 100644 --- a/src/components/admin/VolumeSelectorPopup.vue +++ b/src/components/admin/VolumeSelectorPopup.vue @@ -43,8 +43,13 @@ @@ -60,7 +65,7 @@ const props = defineProps({ hostId: { type: Number, default: 0 } }) -const emit = defineEmits(['update:modelValue', 'confirm']) +const emit = defineEmits(['update:modelValue', 'confirm', 'create']) const visible = ref(false) const loading = ref(false) @@ -90,7 +95,7 @@ const loadList = async () => { if (!props.serviceId || !props.hostId) return loading.value = true try { - const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value } + const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, count: pageSize.value } if (keyword.value) params.keyword = keyword.value if (statusFilter.value) params.status = statusFilter.value const res = await getVolumeList(params) @@ -114,6 +119,10 @@ const handleConfirm = () => { } } const handleClose = () => { selectedItem.value = null } +const handleCreate = () => { + visible.value = false + emit('create') +} diff --git a/src/router/index.js b/src/router/index.js index 2ceef4b..ce5cb74 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -534,6 +534,16 @@ const routes = [ hidden: true, activeMenu: '/virtualization/kvm-service' } + }, + { + path: 'volume-detail', + name: 'VirtVolumeDetail', + component: () => import('../views/virtualization/VolumeDetail.vue'), + meta: { + title: '数据卷详情', + hidden: true, + activeMenu: '/virtualization/kvm-service' + } } ] }, diff --git a/src/style.css b/src/style.css index 222de0c..3932200 100644 --- a/src/style.css +++ b/src/style.css @@ -114,6 +114,23 @@ body { padding-right: 10px; } +/* 可点击元素统一手型光标 */ +.el-button, +.el-button--link, +.el-tag.is-closable .el-tag__close, +.el-dropdown, +.el-dropdown-menu__item, +.el-switch, +.el-checkbox, +.el-radio, +.el-select .el-input__wrapper, +.el-table__body tr.el-table__row { + cursor: pointer; +} +.back-btn { + cursor: pointer; +} + /* 响应式工具类 */ @media (max-width: 768px) { .hidden-xs { diff --git a/src/views/acs/nodes/components/VmList.vue b/src/views/acs/nodes/components/VmList.vue index 5983064..4845074 100644 --- a/src/views/acs/nodes/components/VmList.vue +++ b/src/views/acs/nodes/components/VmList.vue @@ -618,7 +618,7 @@ const fetchPlanList = async () => { try { const response = await getServerPlan({ server_id: props.ID, - count: 100 + count: 10 }); if (response && response.data && response.data.code === 200) { diff --git a/src/views/acs/nodes/server.vue b/src/views/acs/nodes/server.vue index c08d2a1..f1ae628 100644 --- a/src/views/acs/nodes/server.vue +++ b/src/views/acs/nodes/server.vue @@ -2407,7 +2407,7 @@ const fetchContainerPlanList = async () => { try { const response = await getServerPlan({ server_id: route.query.server_id, - count: 100 + count: 10 }); console.log("获取容器套餐列表1111:",response); diff --git a/src/views/virtualization/BackupManage.vue b/src/views/virtualization/BackupManage.vue index 8464bc0..9308868 100644 --- a/src/views/virtualization/BackupManage.vue +++ b/src/views/virtualization/BackupManage.vue @@ -224,6 +224,8 @@ const handleProgress = async (row) => { } onMounted(() => { loadList() }) + +defineExpose({ loadList }) diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue index c185f98..730dff9 100644 --- a/src/views/virtualization/VmDetail.vue +++ b/src/views/virtualization/VmDetail.vue @@ -27,12 +27,10 @@ 重启 暂停 恢复 - 编辑虚拟机 + 修改虚拟机 重构虚拟机 修改带宽 - 绑定安全组 - 解绑安全组 - 重建虚拟机 + 重装虚拟机 救援模式 退出救援 @@ -52,7 +50,7 @@
IP地址 - {{ detail.ips || '-' }} + {{ publicIps || privateIps || detail.ips || '-' }}
创建时间 @@ -87,17 +85,24 @@
公网IP - {{ detail.ips || '暂无' }} + {{ publicIps || '暂无' }} +
+
+ 内网IP + {{ privateIps || '暂无' }}
下行/上行带宽 {{ detail.rx_bandwidth || 0 }} / {{ detail.tx_bandwidth || 0 }} Mbps
+
+
安全组 - + + + - + - + - -
- -
+
@@ -422,9 +439,9 @@
- - - + + + {{ detail?.name || '-' }} @@ -436,7 +453,7 @@ @@ -493,25 +510,9 @@ - - - - - - - -
- - - - - - ({{ editForm.memory }} KB) -
-
- - - + + + @@ -527,30 +528,24 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + +
+
+ + {{ n.name }} (ID:{{ n.id }}) + +
+ 选择网络 +
+
+ + + + + @@ -562,11 +557,13 @@ 确定
+ + - + @@ -639,9 +636,9 @@ 选择网络
- - - + + + @@ -661,7 +658,7 @@ - + {{ detail?.name || '-' }} (ID: {{ vmId }}) @@ -683,146 +680,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ volResizeForm._name || '-' }} - {{ volResizeForm._currentSize }} GB - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 高级配置(可选) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ volTransferForm._name || '-' }} - - - - - - - - @@ -842,6 +699,275 @@ 确定 + + + + + + + + + + + + + + + + + + 高级配置(可选) + + + + + + + + + + + + {{ netDetailData.id }} + {{ netDetailData.name }} + {{ netDetailData.type === 'bridge' ? '网桥' : 'NAT' }} + {{ netDetailData.address }} + {{ netDetailData.gateway }} + {{ netDetailData.nameservers || '-' }} + {{ netDetailData.mac_address || '-' }} + {{ netDetailData.bridge_name || '-' }} + {{ netDetailData.ls_bridge_name || '-' }} + {{ netDetailData.ls_name || '-' }} + {{ netDetailData.target_device || '-' }} + + + + + + + + + + + + + + + + + +
+ + 选择 + 清除 +
+
+ + + + +
+ +
+ + + + + + {{ volResizeTarget?._name || '-' }} + {{ volResizeTarget?._currentSize || 0 }} GB + + + + + + + + 将数据卷迁移到另一台虚拟机上 + + {{ volTransferTarget?._name || '-' }} + + + + + + + + + + + + + + {{ volDetailData.id }} + {{ volDetailData.name }} + {{ volDetailData.size }} GB + {{ volDetailData.is_system ? '系统盘' : '数据盘' }} + {{ volDetailData.is_mount ? '已挂载' : '未挂载' }} + {{ volDetailData.status === 'ready' ? '就绪' : (volDetailData.status || '-') }} + {{ volDetailData.host_id || '-' }} + {{ volDetailData.host_volume_id || '-' }} + {{ volDetailData.path || '-' }} + {{ volDetailData.target_device }} + {{ volDetailData.created_at ? new Date(Number(volDetailData.created_at.seconds) * 1000).toLocaleString('zh-CN') : '-' }} + {{ volDetailData.updated_at ? new Date(Number(volDetailData.updated_at.seconds) * 1000).toLocaleString('zh-CN') : '-' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ sgSyncTarget?.name || '-' }} + + + + + + + + + + + + + {{ sgVmBindTarget?.name || '-' }} + + + + + + + + + + + {{ sgCurrentDetail.id }} + {{ sgCurrentDetail.name }} + + {{ sgCurrentDetail.lock ? '是' : '否' }} + + + {{ sgCurrentDetail.drop_all ? '开启' : '关闭' }} + + + {{ sgCurrentDetail.direction === 'in' ? '入站' : sgCurrentDetail.direction === 'out' ? '出站' : (sgCurrentDetail.direction || '-') }} + + +
+
+

安全组规则

+ 新增规则 +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -849,25 +975,30 @@ 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 } from '@element-plus/icons-vue' +import { ArrowLeft, Refresh, ArrowDown, Plus, Search } from '@element-plus/icons-vue' import { getVmDetail, getVmStatus, getVmMetrics, startVm, stopVm, rebootVm, suspendVm, resumeVm, - rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm, deleteVm, - getRemoteHostList, getVmVnc, getVncNodeList, - bindSecurityGroup, unbindSecurityGroup, getSecurityGroupList, - createNetwork, updateNetwork, deleteNetwork, getNetworkList, - createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, getVolumeList, - getVmList, + rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm, + getVmVnc, getVncNodeList, + getSecurityGroupList, getUserNetworkingList, + getRemoteHostList, getSecurityGroupDetail, createSecurityGroup, + syncSecurityGroup, deleteSecurityGroup, + enableSecurityGroupWhitelist, disableSecurityGroupWhitelist, + applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule, + createNetwork, updateNetwork, deleteNetwork, + createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, + getVmList, bindSecurityGroup, unbindSecurityGroup, getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getSnapshotCount, setSnapshotLimit, getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getBackupCount, setBackupLimit } from '@/api/admin/kvmService' import { extractApiError } from '@/utils/kvmErrorUtil' import * as echarts from 'echarts' import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue' -import VolumeSelectorPopup from '@/components/admin/VolumeSelectorPopup.vue' import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue' import SecurityGroupSelectorPopup from '@/components/admin/SecurityGroupSelectorPopup.vue' +import VolumeSelectorPopup from '@/components/admin/VolumeSelectorPopup.vue' +import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue' import { useTagsViewStore } from '@/store/tagsViewStore' const route = useRoute() @@ -881,6 +1012,7 @@ const vmId = computed(() => parseInt(route.query.vm_id) || parseInt(route.query. const loading = ref(false) const actionLoading = ref(false) const statusLoading = ref(false) +const dialogOptionsLoading = ref(false) const metricsLoading = ref(false) const detail = ref(null) const vmHostId = ref(0) @@ -888,20 +1020,8 @@ const vmNetworks = ref([]) const vmVolumes = ref([]) const vmImage = ref(null) -const networkPage = ref(1) -const networkPageSize = ref(10) -const pagedNetworks = computed(() => { - const start = (networkPage.value - 1) * networkPageSize.value - return vmNetworks.value.slice(start, start + networkPageSize.value) -}) - -const volumePage = ref(1) -const volumePageSize = ref(10) -const pagedVolumes = computed(() => { - const start = (volumePage.value - 1) * volumePageSize.value - return vmVolumes.value.slice(start, start + volumePageSize.value) -}) const vmPortGroup = ref(null) +const vmOutPortGroup = ref(null) const metricsData = ref(null) const hostOptions = ref([]) const rebuildDialogVisible = ref(false) @@ -911,6 +1031,23 @@ const showImageSelector = ref(false) const activeTab = ref('info') const showPassword = ref(false) +const extractIp = (addr) => addr ? addr.split('/')[0] : '' +const publicIps = computed(() => vmNetworks.value.filter(n => n.type === 'bridge').map(n => extractIp(n.address)).filter(Boolean).join(', ')) +const privateIps = computed(() => vmNetworks.value.filter(n => n.type === 'nat').map(n => extractIp(n.address)).filter(Boolean).join(', ')) + +const networkPage = ref(1) +const networkPageSize = ref(10) +const pagedNetworks = computed(() => { + const start = (networkPage.value - 1) * networkPageSize.value + return vmNetworks.value.slice(start, start + networkPageSize.value) +}) +const volumePage = ref(1) +const volumePageSize = ref(10) +const pagedVolumes = computed(() => { + const start = (volumePage.value - 1) * volumePageSize.value + return vmVolumes.value.slice(start, start + volumePageSize.value) +}) + const copyText = (text) => { if (!text) { ElMessage.warning('无内容可复制'); return } if (navigator.clipboard && window.isSecureContext) { @@ -937,7 +1074,6 @@ const handleMoreCommand = (cmd) => { if (powerActions.includes(cmd)) { handlePower(cmd); return } const actionMap = { editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic, - bindSg: handleBindSg, unbindSg: handleUnbindSg, rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue } if (actionMap[cmd]) actionMap[cmd]() @@ -960,7 +1096,7 @@ const getHostLabel = (hid) => { const h = hostOptions.value.find(x => x.id === h const loadHostOptions = async () => { try { - const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 }) + const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 }) if (res?.data?.code === 200 && res?.data?.data) { const inner = res.data.data hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || []) @@ -980,37 +1116,12 @@ const loadDetail = async () => { vmVolumes.value = d.volumes || [] vmImage.value = d.image || null vmPortGroup.value = d.in_port_group || null + vmOutPortGroup.value = d.out_port_group || null vmHostId.value = detail.value?.host_id || vmVolumes.value[0]?.host_id || vmNetworks.value[0]?.host_id || 0 } else ElMessage.error(extractApiError(res?.data, '加载失败')) } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false } } -const loadVmVolumes = async () => { - if (!detail.value) return - const hid = vmHostId.value - if (!hid) return - try { - const res = await getVolumeList({ service_id: serviceId.value, host_id: hid, vm_id: vmId.value, page: 1, count: 200 }) - if (res?.data?.code === 200 && res?.data?.data) { - const inner = res.data.data - vmVolumes.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : []) - } - } catch { /* */ } -} - -const loadVmNetworks = async () => { - if (!detail.value) return - const hid = vmHostId.value - if (!hid) return - try { - const res = await getNetworkList({ service_id: serviceId.value, host_id: hid, page: 1, page_size: 200 }) - if (res?.data?.code === 200 && res?.data?.data) { - const inner = res.data.data - vmNetworks.value = inner.data || inner.networks || (Array.isArray(inner) ? inner : []) - } - } catch { /* */ } -} - const fetchVmStatus = async () => { if (!detail.value) return statusLoading.value = true @@ -1189,9 +1300,9 @@ const submitRebuild = async () => { actionLoading.value = true try { const res = await rebuildVm({ service_id: serviceId.value, vm_id: vmId.value, image_id: rebuildImageId.value }) - if (res?.data?.code === 200) { ElMessage.success('重建成功'); rebuildDialogVisible.value = false; loadDetail() } - else ElMessage.error(extractApiError(res?.data, '重建失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重建失败')) } finally { actionLoading.value = false } + if (res?.data?.code === 200) { ElMessage.success('重装成功'); rebuildDialogVisible.value = false; loadDetail() } + else ElMessage.error(extractApiError(res?.data, '重装失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重装失败')) } finally { actionLoading.value = false } } const handleRescue = () => { @@ -1220,46 +1331,58 @@ const handleExitRescue = () => { }).catch(() => {}) } -// ---- 编辑虚拟机 ---- +// ---- 编辑虚拟机(仅 update API 参数) ---- const editDialogVisible = ref(false) const editFormRef = ref(null) const editForm = reactive({ - name: '', memory: 0, vcpu: 1, rx_bandwidth: 0, tx_bandwidth: 0, root_password: '', ssh_port: 22, - traffic_max: 0, snapshot_num: 0, backup_num: 0, - port_group_id: 0 -}) -const editMemoryUnit = ref('MB') -const editMemUnitFactor = () => editMemoryUnit.value === 'GB' ? 1048576 : 1024 -const editMemoryDisplay = computed({ - get: () => { - const f = editMemUnitFactor() - const v = editForm.memory / f - return f === 1048576 ? parseFloat(v.toFixed(2)) : Math.round(v) - }, - set: (v) => { editForm.memory = Math.round(v * editMemUnitFactor()) } + internet_network_id: 0, port_group_id: 0 }) +const editSelectedNetworks = ref([]) +const showEditNetworkSelector = ref(false) + +const handleEditNetworkConfirm = (network) => { + if (!editSelectedNetworks.value.find(n => n.id === network.id)) { + editSelectedNetworks.value.push({ id: network.id, name: network.name }) + } +} +const removeEditNetwork = (id) => { + editSelectedNetworks.value = editSelectedNetworks.value.filter(n => n.id !== id) + if (editForm.internet_network_id === id) editForm.internet_network_id = 0 +} + +const networkingOptions = ref([]) +const loadNetworkingOptions = async () => { + try { + const res = await getUserNetworkingList({ service_id: serviceId.value, page: 1, count: 10 }) + if (res?.data?.code === 200 && res?.data?.data) { + const inner = res.data.data + networkingOptions.value = Array.isArray(inner) ? inner : (inner.data || []) + } + } catch { /* */ } +} const handleEditVm = async () => { if (!detail.value) return const d = detail.value - const mem = d.memory || 0 - editMemoryUnit.value = mem >= 1048576 ? 'GB' : 'MB' Object.assign(editForm, { - name: d.name || '', - memory: mem, vcpu: d.vcpu || 1, rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0, - root_password: d.root_password || '', + root_password: '', ssh_port: d.ssh_port || 22, - traffic_max: d.traffic_max || 0, - snapshot_num: d.snapshot_num || 0, - backup_num: d.backup_num || 0, - port_group_id: vmPortGroup.value?.id || null + internet_network_id: '', + port_group_id: vmPortGroup.value?.id || 0 }) - if (!sgOptions.value.length) await loadSgOptions() + editSelectedNetworks.value = vmNetworks.value.map(n => ({ id: n.id, name: n.name })) editDialogVisible.value = true + dialogOptionsLoading.value = true + try { + await Promise.all([ + !sgOptions.value.length ? loadSgOptions() : Promise.resolve(), + loadNetworkingOptions() + ]) + } finally { dialogOptionsLoading.value = false } } const submitEditVm = async () => { @@ -1268,16 +1391,12 @@ const submitEditVm = async () => { const fd = new FormData() fd.append('service_id', serviceId.value) fd.append('vm_id', vmId.value) - if (editForm.name) fd.append('name', editForm.name) - fd.append('memory', editForm.memory) - fd.append('vcpu', editForm.vcpu) fd.append('rx_bandwidth', editForm.rx_bandwidth) fd.append('tx_bandwidth', editForm.tx_bandwidth) - fd.append('ssh_port', editForm.ssh_port) - fd.append('traffic_max', editForm.traffic_max) - fd.append('snapshot_num', editForm.snapshot_num) - fd.append('backup_num', editForm.backup_num) if (editForm.root_password) fd.append('root_password', editForm.root_password) + fd.append('ssh_port', editForm.ssh_port) + editSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id)) + if (editForm.internet_network_id) fd.append('internet_network_id', editForm.internet_network_id) if (editForm.port_group_id) fd.append('port_group_id', editForm.port_group_id) const res = await updateVm(fd) if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() } @@ -1332,8 +1451,14 @@ const handleRefactorVm = async () => { if (mem >= 1048576 && mem % 1048576 === 0) { refactorMemUnit.value = 1048576; refactorMemDisplay.value = mem / 1048576 } else if (mem >= 1024 && mem % 1024 === 0) { refactorMemUnit.value = 1024; refactorMemDisplay.value = mem / 1024 } else { refactorMemUnit.value = 1; refactorMemDisplay.value = mem } - if (!sgOptions.value.length) await loadSgOptions() refactorDialogVisible.value = true + dialogOptionsLoading.value = true + try { + await Promise.all([ + !sgOptions.value.length ? loadSgOptions() : Promise.resolve(), + loadNetworkingOptions() + ]) + } finally { dialogOptionsLoading.value = false } } const submitRefactorVm = async () => { @@ -1354,7 +1479,7 @@ const submitRefactorVm = async () => { if (refactorForm.ssh_port) fd.append('ssh_port', refactorForm.ssh_port) if (refactorForm.vnc_port) fd.append('vnc_port', refactorForm.vnc_port) if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password) - if (refactorSelectedNetworks.value.length) fd.append('network_ids', refactorSelectedNetworks.value.map(n => n.id).join(',')) + refactorSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id)) if (refactorForm.internet_network_id) fd.append('internet_network_id', refactorForm.internet_network_id) if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id) const res = await refactorVm(fd) @@ -1401,7 +1526,7 @@ const vncResult = ref(null) const loadVncNodes = async () => { try { - const res = await getVncNodeList({ service_id: serviceId.value, page: 1, page_size: 100 }) + const res = await getVncNodeList({ service_id: serviceId.value, page: 1, page_size: 10 }) if (res?.data?.code === 200 && res?.data?.data) { const inner = res.data.data vncNodeOptions.value = inner.items || inner.vnc_nodes || inner.nodes || inner.data || (Array.isArray(inner) ? inner : []) @@ -1412,8 +1537,11 @@ const loadVncNodes = async () => { const handleGetVnc = async () => { vncNodeId.value = null vncResult.value = null - if (!vncNodeOptions.value.length) await loadVncNodes() vncDialogVisible.value = true + if (!vncNodeOptions.value.length) { + dialogOptionsLoading.value = true + try { await loadVncNodes() } finally { dialogOptionsLoading.value = false } + } } const submitGetVnc = async () => { @@ -1427,34 +1555,7 @@ const submitGetVnc = async () => { } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取VNC连接失败')) } finally { vncLoading.value = false } } -// ---- 安全组管理(标签页列表) ---- -const vmSecurityGroups = ref([]) -const sgListLoading = ref(false) -const securityPage = ref(1) -const securityPageSize = ref(10) -const sgDirectionFilter = ref('') -const filteredSecurityGroups = computed(() => { - if (!sgDirectionFilter.value) return vmSecurityGroups.value - return vmSecurityGroups.value.filter(g => g.direction === sgDirectionFilter.value) -}) -const pagedSecurityGroups = computed(() => { - const start = (securityPage.value - 1) * securityPageSize.value - return filteredSecurityGroups.value.slice(start, start + securityPageSize.value) -}) - -const loadVmSecurityGroups = async () => { - sgListLoading.value = true - try { - const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 200, vm_id: vmId.value }) - if (res?.data?.code === 200 && res?.data?.data) { - const inner = res.data.data - vmSecurityGroups.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : []) - } - } catch { /* */ } finally { sgListLoading.value = false } -} - -// ---- 绑定/解绑安全组 ---- -const sgBindSelectorVisible = ref(false) +// ---- 安全组选项(用于编辑弹窗) ---- const sgOptions = ref([]) const loadSgOptions = async () => { @@ -1467,160 +1568,145 @@ const loadSgOptions = async () => { } catch { /* */ } } -const handleBindSg = () => { sgBindSelectorVisible.value = true } -const handleUnbindSg = () => { sgBindSelectorVisible.value = true } -const handleBindSgFromTab = () => { sgBindSelectorVisible.value = true } - -const handleSgBindConfirm = async (sg) => { - if (!sg?.id) return +// ---- 绑定网络(通过 updateVm 接口) ---- +const showNetBindSelector = ref(false) +const handleNetBindConfirm = async (selectedNetwork) => { + const existingIds = vmNetworks.value.map(n => n.id) + if (existingIds.includes(selectedNetwork.id)) { + ElMessage.warning('该网络已绑定') + return + } actionLoading.value = true try { + const allNetworkIds = [...existingIds, selectedNetwork.id] const fd = new FormData() fd.append('service_id', serviceId.value) - fd.append('id', sg.id) fd.append('vm_id', vmId.value) - const res = await bindSecurityGroup(fd) - if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadVmSecurityGroups() } - else ElMessage.error(extractApiError(res?.data, '绑定失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '绑定失败')) } finally { actionLoading.value = false } + allNetworkIds.forEach(id => fd.append('network_ids', id)) + if (detail.value?.rx_bandwidth) fd.append('rx_bandwidth', detail.value.rx_bandwidth) + if (detail.value?.tx_bandwidth) fd.append('tx_bandwidth', detail.value.tx_bandwidth) + if (detail.value?.ssh_port) fd.append('ssh_port', detail.value.ssh_port) + if (vmPortGroup.value?.id) fd.append('port_group_id', vmPortGroup.value.id) + const res = await updateVm(fd) + if (res?.data?.code === 200) { + ElMessage.success('绑定网络成功') + loadDetail() + } else { + ElMessage.error(extractApiError(res, '绑定网络失败')) + } + } catch (e) { + ElMessage.error(extractApiError(e, '绑定网络失败')) + } finally { + actionLoading.value = false + } } -const handleUnbindSgFromTab = async (row) => { - try { - await ElMessageBox.confirm(`确认解绑安全组「${row.name}」?`, '提示', { type: 'warning' }) - } catch { return } - actionLoading.value = true - try { - const fd = new FormData() - fd.append('service_id', serviceId.value) - fd.append('id', row.id) - fd.append('vm_id', vmId.value) - const res = await unbindSecurityGroup(fd) - if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadVmSecurityGroups() } - else ElMessage.error(extractApiError(res?.data, '解绑失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '解绑失败')) } finally { actionLoading.value = false } -} - -// ---- 创建网络(表单弹窗) ---- -const netCreateVisible = ref(false) -const netCreateFormRef = ref(null) -const netCreateForm = reactive({ name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '' }) -const netCreateRules = { +// ---- 网络操作(创建/编辑/删除/详情) ---- +const netDialogVisible = ref(false) +const netDialogType = ref('add') +const netFormRef = ref(null) +const netDetailVisible = ref(false) +const netDetailData = ref(null) +const netForm = reactive({ id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0 }) +const netFormRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }], address: [{ required: true, message: '请输入IP地址(CIDR)', trigger: 'blur' }], - gateway: [{ required: true, message: '请输入网关地址', trigger: 'blur' }], - type: [{ required: true, message: '请选择网络类型', trigger: 'change' }] + gateway: [{ required: true, message: '请输入网关', trigger: 'blur' }], + type: [{ required: true, message: '请选择类型', trigger: 'change' }] } -const showCreateNetworkDialog = () => { - Object.assign(netCreateForm, { name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '' }) - netCreateVisible.value = true +const handleNetCreate = () => { + netDialogType.value = 'add' + Object.assign(netForm, { id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: vmHostId.value }) + netDialogVisible.value = true } - -const submitCreateNetwork = () => { - netCreateFormRef.value?.validate(async (valid) => { +const handleNetEdit = (row) => { + netDialogType.value = 'edit' + Object.assign(netForm, { id: row.id, name: row.name || '', address: row.address || '', gateway: row.gateway || '', nameservers: row.nameservers || '', type: row.type || 'bridge', mac_address: row.mac_address || '', bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '', ls_name: row.ls_name || '', host_id: row.host_id || vmHostId.value }) + netDialogVisible.value = true +} +const submitNetForm = () => { + netFormRef.value?.validate(async (valid) => { if (!valid) return actionLoading.value = true try { const fd = new FormData() fd.append('service_id', serviceId.value) - fd.append('name', netCreateForm.name) - fd.append('address', netCreateForm.address) - fd.append('gateway', netCreateForm.gateway) - fd.append('type', netCreateForm.type) - fd.append('host_id', vmHostId.value) - if (netCreateForm.nameservers) fd.append('nameservers', netCreateForm.nameservers) - if (netCreateForm.mac_address) fd.append('mac_address', netCreateForm.mac_address) - if (netCreateForm.bridge_name) fd.append('bridge_name', netCreateForm.bridge_name) - if (netCreateForm.ls_bridge_name) fd.append('ls_bridge_name', netCreateForm.ls_bridge_name) - if (netCreateForm.ls_name) fd.append('ls_name', netCreateForm.ls_name) - const res = await createNetwork(fd) - if (res?.data?.code === 200) { ElMessage.success('网络创建成功'); netCreateVisible.value = false; loadDetail() } - else ElMessage.error(extractApiError(res?.data, '创建失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false } + fd.append('name', netForm.name) + fd.append('address', netForm.address) + fd.append('gateway', netForm.gateway) + fd.append('type', netForm.type) + fd.append('host_id', netForm.host_id) + if (netForm.nameservers) fd.append('nameservers', netForm.nameservers) + if (netForm.mac_address) fd.append('mac_address', netForm.mac_address) + if (netForm.bridge_name) fd.append('bridge_name', netForm.bridge_name) + if (netForm.ls_bridge_name) fd.append('ls_bridge_name', netForm.ls_bridge_name) + if (netForm.ls_name) fd.append('ls_name', netForm.ls_name) + let res + if (netDialogType.value === 'add') { res = await createNetwork(fd) } + else { fd.append('network_id', netForm.id); res = await updateNetwork(fd) } + if (res?.data?.code === 200) { ElMessage.success(netDialogType.value === 'add' ? '创建成功' : '修改成功'); netDialogVisible.value = false; loadDetail() } + else ElMessage.error(extractApiError(res?.data, '操作失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false } }) } +const handleNetDetail = (row) => { netDetailData.value = row; netDetailVisible.value = true } +const handleNetDelete = (row) => { + ElMessageBox.confirm(`确定要删除网络「${row.name}」(ID: ${row.id}) 吗?`, '删除网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }) + .then(async () => { + try { + const res = await deleteNetwork({ service_id: serviceId.value, network_id: row.id, host_id: row.host_id }) + if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() } + else ElMessage.error(extractApiError(res?.data, '删除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } + }).catch(() => {}) +} -// ---- 添加已有网络(弹窗选择组件) ---- -const netAddVisible = ref(false) +// ---- 数据卷操作(绑定/创建/调整/挂载/卸载/迁移/删除/详情) ---- +const showVolSelector = ref(false) -const handleAddNetwork = () => { netAddVisible.value = true } - -const handleNetworkSelectorConfirm = async (network) => { - if (!network?.id) return +const handleVolBindConfirm = async (vol) => { + if (!vol?.id) return actionLoading.value = true try { const fd = new FormData() fd.append('service_id', serviceId.value) + fd.append('volume_id', vol.id) fd.append('vm_id', vmId.value) - fd.append('network_id', network.id) - if (network.host_id) fd.append('host_id', network.host_id) - const res = await createNetwork(fd) - if (res?.data?.code === 200) { ElMessage.success('网络添加成功'); loadDetail() } - else ElMessage.error(extractApiError(res?.data, '添加失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '添加失败')) } finally { actionLoading.value = false } + const res = await mountVolume(fd) + if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadDetail() } + else ElMessage.error(extractApiError(res?.data, '绑定失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '绑定失败')) } finally { actionLoading.value = false } } -// ---- 网络编辑/删除 ---- -const netEditVisible = ref(false) -const netEditForm = reactive({ id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', host_id: 0 }) - -const handleEditNetwork = (row) => { - Object.assign(netEditForm, { - id: row.id, name: row.name || '', address: row.address || '', gateway: row.gateway || '', - nameservers: row.nameservers || '', type: row.type || 'bridge', - mac_address: row.mac_address || '', bridge_name: row.bridge_name || '', host_id: row.host_id || 0 - }) - netEditVisible.value = true -} - -const submitEditNetwork = async () => { - actionLoading.value = true - try { - const fd = new FormData() - fd.append('service_id', serviceId.value) - fd.append('id', netEditForm.id) - fd.append('host_id', netEditForm.host_id) - if (netEditForm.name) fd.append('name', netEditForm.name) - if (netEditForm.address) fd.append('address', netEditForm.address) - if (netEditForm.gateway) fd.append('gateway', netEditForm.gateway) - if (netEditForm.nameservers) fd.append('nameservers', netEditForm.nameservers) - if (netEditForm.type) fd.append('type', netEditForm.type) - if (netEditForm.mac_address) fd.append('mac_address', netEditForm.mac_address) - if (netEditForm.bridge_name) fd.append('bridge_name', netEditForm.bridge_name) - const res = await updateNetwork(fd) - if (res?.data?.code === 200) { ElMessage.success('网络修改成功'); netEditVisible.value = false; loadDetail() } - else ElMessage.error(extractApiError(res?.data, '修改失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false } -} - -const handleDeleteNetwork = (row) => { - ElMessageBox.confirm(`确定要删除网络「${row.name}」(ID: ${row.id}) 吗?`, '删除网络', { - confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' - }).then(async () => { - try { - const res = await deleteNetwork({ service_id: serviceId.value, network_id: row.id, host_id: row.host_id }) - if (res?.data?.code === 200) { ElMessage.success('网络已删除'); loadDetail() } - else ElMessage.error(extractApiError(res?.data, '删除失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } - }).catch(() => {}) -} - -// ---- 创建数据卷(表单弹窗) ---- -const volCreateVisible = ref(false) -const volCreateFormRef = ref(null) -const volCreateForm = reactive({ name: '', size: 10, is_system: false, target_device: '', image_id: 0 }) -const volCreateRules = { - name: [{ required: true, message: '请输入名称', trigger: 'blur' }], - size: [{ required: true, message: '请输入大小', trigger: 'blur' }] -} - -const showCreateVolumeDialog = () => { - Object.assign(volCreateForm, { name: '', size: 10, is_system: false, target_device: '', image_id: 0 }) +const handleVolCreateFromSelector = () => { + Object.assign(volCreateForm, { name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' }) volCreateVisible.value = true } -const submitCreateVolume = () => { +const volCreateVisible = ref(false) +const volCreateFormRef = ref(null) +const volCreateForm = reactive({ name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' }) +const volCreateRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }], size: [{ required: true, message: '请输入大小', trigger: 'blur' }] } +const showVolImageSelector = ref(false) +const volImageSelectorFromCreate = ref(false) +const handleVolImageSelected = (img) => { volCreateForm.image_id = img.id; volCreateForm._imageName = img.name; volCreateVisible.value = true; volImageSelectorFromCreate.value = false } +watch(showVolImageSelector, (val) => { if (!val && volImageSelectorFromCreate.value) { volCreateVisible.value = true; volImageSelectorFromCreate.value = false } }) +const volResizeVisible = ref(false) +const volResizeTarget = ref(null) +const volNewSize = ref(1) +const volTransferVisible = ref(false) +const volTransferTarget = ref(null) +const volTransferVmId = ref(null) +const vmListOptions = ref([]) +const volDetailVisible = ref(false) +const volDetailData = ref(null) + +const handleVolCreate = () => { + Object.assign(volCreateForm, { name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' }) + volCreateVisible.value = true +} +const submitVolCreate = () => { volCreateFormRef.value?.validate(async (valid) => { if (!valid) return actionLoading.value = true @@ -1635,131 +1721,337 @@ const submitCreateVolume = () => { if (volCreateForm.target_device) fd.append('target_device', volCreateForm.target_device) if (volCreateForm.image_id) fd.append('image_id', volCreateForm.image_id) const res = await createVolume(fd) - if (res?.data?.code === 200) { ElMessage.success('数据卷创建成功'); volCreateVisible.value = false; loadDetail() } + if (res?.data?.code === 200) { ElMessage.success('创建成功'); volCreateVisible.value = false; loadDetail() } else ElMessage.error(extractApiError(res?.data, '创建失败')) } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false } }) } - -// ---- 挂载已有数据卷(弹窗选择组件) ---- -const volAddVisible = ref(false) - -const handleAddVolume = () => { volAddVisible.value = true } - -const handleVolumeSelectorConfirm = async (volume) => { - if (!volume?.id) return +const handleVolResize = (row) => { volResizeTarget.value = { id: row.id, _name: row.name, _currentSize: row.size || 0 }; volNewSize.value = row.size || 1; volResizeVisible.value = true } +const submitVolResize = async () => { actionLoading.value = true try { - const fd = new FormData() - fd.append('service_id', serviceId.value) - fd.append('vm_id', vmId.value) - fd.append('volume_id', volume.id) - const res = await mountVolume(fd) - if (res?.data?.code === 200) { ElMessage.success('数据卷挂载成功'); loadDetail() } - else ElMessage.error(extractApiError(res?.data, '挂载失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } finally { actionLoading.value = false } -} - -// ---- 磁盘卷操作 ---- -const volResizeVisible = ref(false) -const volResizeForm = reactive({ volume_id: 0, size: 0, _name: '', _currentSize: 0 }) - -const handleResizeVolume = (row) => { - Object.assign(volResizeForm, { volume_id: row.id, size: row.size || 0, _name: row.name, _currentSize: row.size || 0 }) - volResizeVisible.value = true -} - -const submitResizeVolume = async () => { - if (volResizeForm.size <= 0) { ElMessage.warning('请输入有效大小'); return } - actionLoading.value = true - try { - const fd = new FormData() - fd.append('service_id', serviceId.value) - fd.append('volume_id', volResizeForm.volume_id) - fd.append('size', volResizeForm.size) + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', volResizeTarget.value.id); fd.append('size', volNewSize.value) const res = await resizeVolume(fd) - if (res?.data?.code === 200) { ElMessage.success('调整大小成功'); volResizeVisible.value = false; loadDetail() } - else ElMessage.error(extractApiError(res?.data, '调整大小失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整大小失败')) } finally { actionLoading.value = false } + if (res?.data?.code === 200) { ElMessage.success('调整成功'); volResizeVisible.value = false; loadDetail() } + else ElMessage.error(extractApiError(res?.data, '调整失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { actionLoading.value = false } } - -const handleUnmountVolume = (row) => { - ElMessageBox.confirm(`确定要卸载磁盘卷「${row.name}」(ID: ${row.id}) 吗?`, '卸载磁盘卷', { - confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' - }).then(async () => { +const handleVolMount = (row) => { + ElMessageBox.confirm(`确定要将「${row.name}」挂载到当前虚拟机吗?`, '挂载', { type: 'info' }).then(async () => { try { - const fd = new FormData() - fd.append('service_id', serviceId.value) - fd.append('volume_id', row.id) - const res = await unmountVolume(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 handleDeleteVolume = (row) => { - ElMessageBox.confirm(`确定要删除磁盘卷「${row.name}」(ID: ${row.id}) 吗?此操作不可恢复!`, '删除磁盘卷', { - confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'error' - }).then(async () => { - try { - const res = await deleteVolume({ service_id: serviceId.value, volume_id: row.id }) - if (res?.data?.code === 200) { ElMessage.success('磁盘卷已删除'); loadDetail() } - else ElMessage.error(extractApiError(res?.data, '删除失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } - }).catch(() => {}) -} - -const handleMountVolume = (row) => { - ElMessageBox.confirm(`确定要将磁盘卷「${row.name}」(ID: ${row.id}) 挂载到当前虚拟机吗?`, '挂载磁盘卷', { - confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' - }).then(async () => { - try { - const fd = new FormData() - fd.append('service_id', serviceId.value) - fd.append('volume_id', row.id) - fd.append('vm_id', vmId.value) + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', row.id); fd.append('vm_id', vmId.value) const res = await mountVolume(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 volTransferVisible = ref(false) -const volTransferForm = reactive({ volume_id: 0, vm_id: null, _name: '' }) -const vmListOptions = ref([]) - +const handleVolUnmount = (row) => { + ElMessageBox.confirm(`确定要卸载「${row.name}」吗?`, '卸载', { type: 'warning' }).then(async () => { + try { + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', row.id) + const res = await unmountVolume(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 loadVmListOptions = async () => { try { const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 200 }) if (res?.data?.code === 200 && res?.data?.data) { const inner = res.data.data - vmListOptions.value = (inner.vms || inner.data || (Array.isArray(inner) ? inner : [])).filter(v => v.id !== vmId.value) + vmListOptions.value = inner.vms || inner.data || (Array.isArray(inner) ? inner : []) } } catch { /* */ } } - -const handleTransferVolume = async (row) => { - Object.assign(volTransferForm, { volume_id: row.id, vm_id: null, _name: row.name }) - if (!vmListOptions.value.length) await loadVmListOptions() +const handleVolTransfer = async (row) => { + volTransferTarget.value = { id: row.id, _name: row.name }; volTransferVmId.value = vmId.value volTransferVisible.value = true + if (!vmListOptions.value.length) { + dialogOptionsLoading.value = true + try { await loadVmListOptions() } finally { dialogOptionsLoading.value = false } + } } - -const submitTransferVolume = async () => { - if (!volTransferForm.vm_id) { ElMessage.warning('请选择目标虚拟机'); return } +const submitVolTransfer = async () => { + if (!volTransferVmId.value) { ElMessage.warning('请选择目标虚拟机'); return } actionLoading.value = true try { - const fd = new FormData() - fd.append('service_id', serviceId.value) - fd.append('volume_id', volTransferForm.volume_id) - fd.append('vm_id', volTransferForm.vm_id) + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', volTransferTarget.value.id); fd.append('vm_id', volTransferVmId.value) const res = await transferVolume(fd) if (res?.data?.code === 200) { ElMessage.success('迁移成功'); volTransferVisible.value = false; loadDetail() } else ElMessage.error(extractApiError(res?.data, '迁移失败')) } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false } } +const handleVolDelete = (row) => { + ElMessageBox.confirm(`确定要删除「${row.name}」吗?此操作不可恢复!`, '删除', { type: 'error' }).then(async () => { + try { + const res = await deleteVolume({ service_id: serviceId.value, volume_id: row.id }) + if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() } + else ElMessage.error(extractApiError(res?.data, '删除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } + }).catch(() => {}) +} +const handleVolDetail = (row) => { + router.push({ path: '/virtualization/volume-detail', query: { service_id: serviceId.value, volume_id: row.id } }) +} + +// ---- 安全组完整管理 ---- +const sgTableData = computed(() => { + const list = [] + if (vmPortGroup.value) list.push(vmPortGroup.value) + if (vmOutPortGroup.value) list.push(vmOutPortGroup.value) + return list +}) +const sgSubmitLoading = ref(false) +const sgDetailLoading = ref(false) +const sgHostOptions = ref([]) + +const loadSgHostOptions = async () => { + try { + const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 }) + const body = res?.data + if (body?.code === 200 && body?.data) { + const inner = body.data + sgHostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || []) + } + } catch (e) { console.error('加载宿主机列表失败:', e) } +} + +// 绑定安全组到当前虚拟机(通过安全组选择器) +const sgBindVisible = ref(false) +const handleSgBind = () => { sgBindVisible.value = true } +const handleSgBindConfirm = async (sg) => { + if (!sg?.id) return + actionLoading.value = true + try { + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sg.id); fd.append('vm_id', vmId.value) + const res = await bindSecurityGroup(fd) + if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadDetail() } + else ElMessage.error(extractApiError(res?.data, '绑定失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '绑定失败')) } finally { actionLoading.value = false } +} + +// 解绑指定安全组与当前虚拟机 +const handleSgUnbindFromVm = async (sg) => { + if (!sg) return + try { await ElMessageBox.confirm(`确定要解绑安全组「${sg.name}」(${sg.direction === 'in' ? '入站' : '出站'})吗?`, '解绑安全组', { type: 'warning' }) } catch { return } + actionLoading.value = true + try { + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sg.id); fd.append('vm_id', vmId.value) + const res = await unbindSecurityGroup(fd) + if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadDetail() } + else ElMessage.error(extractApiError(res?.data, '解绑失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '解绑失败')) } finally { actionLoading.value = false } +} + +// 创建安全组 +const sgCreateDialogVisible = ref(false) +const sgCreateFormRef = ref(null) +const sgCreateForm = reactive({ name: '', host_id: null, direction: '', lock: false, drop_all: false }) +const sgCreateRules = { + name: [{ required: true, message: '请输入名称', trigger: 'blur' }], + direction: [{ required: true, message: '请选择方向', trigger: 'change' }], + host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }] +} +const handleSgCreate = async () => { + Object.assign(sgCreateForm, { name: '', host_id: vmHostId.value || null, direction: '', lock: false, drop_all: false }) + sgCreateDialogVisible.value = true + if (!sgHostOptions.value.length) { + dialogOptionsLoading.value = true + try { await loadSgHostOptions() } finally { dialogOptionsLoading.value = false } + } +} +const submitSgCreate = () => { + sgCreateFormRef.value?.validate(async (valid) => { + if (!valid) return + sgSubmitLoading.value = true + try { + const res = await createSecurityGroup({ service_id: serviceId.value, ...sgCreateForm }) + if (res?.data?.code === 200) { + ElMessage.success('创建成功') + sgCreateDialogVisible.value = false + loadDetail() + } else ElMessage.error(extractApiError(res?.data, '创建失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { sgSubmitLoading.value = false } + }) +} + +// 同步安全组 +const sgSyncDialogVisible = ref(false) +const sgSyncTarget = ref(null) +const sgSyncHostId = ref(null) +const handleSgSync = async (row) => { + sgSyncTarget.value = row + sgSyncHostId.value = row.host_id || vmHostId.value || null + sgSyncDialogVisible.value = true + if (!sgHostOptions.value.length) { + dialogOptionsLoading.value = true + try { await loadSgHostOptions() } finally { dialogOptionsLoading.value = false } + } +} +const submitSgSync = async () => { + if (!sgSyncHostId.value) { ElMessage.warning('请选择宿主机'); return } + sgSubmitLoading.value = true + try { + const res = await syncSecurityGroup({ service_id: serviceId.value, id: sgSyncTarget.value.id, host_id: sgSyncHostId.value }) + if (res?.data?.code === 200) { + ElMessage.success('同步成功') + sgSyncDialogVisible.value = false + loadDetail() + } else ElMessage.error(extractApiError(res?.data, '同步失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '同步失败')) } finally { sgSubmitLoading.value = false } +} + +// 应用安全组 +const handleSgApply = (row) => { + ElMessageBox.confirm(`确定要应用安全组「${row.name}」的规则到所有已绑定虚拟机吗?`, '应用安全组', { + confirmButtonText: '确定应用', cancelButtonText: '取消', type: 'info' + }).then(async () => { + try { + const res = await applySecurityGroup({ service_id: serviceId.value, id: row.id }) + if (res?.data?.code === 200) ElMessage.success('应用成功') + else ElMessage.error(extractApiError(res?.data, '应用失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '应用失败')) } + }).catch(() => {}) +} + +// 编辑(跳转安全组详情页) +const handleSgGoDetail = (row) => { + router.push({ path: '/virtualization/security-group-detail', query: { service_id: serviceId.value, service_name: serviceName.value, id: row.id } }) +} + +// 白名单切换 +const handleSgToggleWhitelist = (row) => { + const action = row.drop_all ? '关闭' : '开启' + ElMessageBox.confirm(`确定要${action}安全组「${row.name}」的白名单模式吗?`, `${action}白名单`, { + confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' + }).then(async () => { + try { + const api = row.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist + const res = await api({ service_id: serviceId.value, id: row.id }) + if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() } + else ElMessage.error(extractApiError(res?.data, `${action}失败`)) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) } + }).catch(() => {}) +} + +// 删除安全组 +const handleSgDelete = (row) => { + ElMessageBox.confirm(`确定要删除安全组「${row.name}」吗?`, '删除确认', { + confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' + }).then(async () => { + try { + const res = await deleteSecurityGroup({ service_id: serviceId.value, id: row.id }) + if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() } + else ElMessage.error(extractApiError(res?.data, '删除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } + }).catch(() => {}) +} + +// 绑定/解绑虚拟机(操作栏更多菜单,针对其他虚拟机) +const sgVmBindDialogVisible = ref(false) +const sgVmBindType = ref('bind') +const sgVmBindTarget = ref(null) +const handleSgBindVm = (row) => { + sgVmBindType.value = 'bind'; sgVmBindTarget.value = row + sgVmBindDialogVisible.value = true +} +const handleSgUnbindVm = (row) => { + sgVmBindType.value = 'unbind'; sgVmBindTarget.value = row + sgVmBindDialogVisible.value = true +} +const submitSgVmBind = async () => { + sgSubmitLoading.value = true + try { + const api = sgVmBindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup + const res = await api({ service_id: serviceId.value, id: sgVmBindTarget.value.id, vm_id: vmId.value }) + if (res?.data?.code === 200) { + ElMessage.success(sgVmBindType.value === 'bind' ? '绑定成功' : '解绑成功') + sgVmBindDialogVisible.value = false + loadDetail() + } else ElMessage.error(extractApiError(res?.data, '操作失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { sgSubmitLoading.value = false } +} + +// 查看安全组详情(含规则) +const sgDetailVisible = ref(false) +const sgCurrentDetail = ref(null) +const handleSgViewDetail = async (row) => { + sgDetailVisible.value = true + sgDetailLoading.value = true + sgCurrentDetail.value = row + try { + const res = await getSecurityGroupDetail({ service_id: serviceId.value, id: row.id }) + if (res?.data?.code === 200 && res?.data?.data) { + const inner = res.data.data + sgCurrentDetail.value = inner.group || inner.data || inner + } + } catch { /* fallback */ } finally { sgDetailLoading.value = false } +} + +// 安全组规则操作 +const sgRuleDialogVisible = ref(false) +const sgRuleDialogType = ref('add') +const sgRuleFormRef = ref(null) +const sgRuleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 }) +const sgRuleRules = { + protocol: [{ required: true, message: '请选择协议', trigger: 'change' }], + action: [{ required: true, message: '请选择动作', trigger: 'change' }] +} +const handleSgAddRule = () => { + sgRuleDialogType.value = 'add' + Object.assign(sgRuleForm, { id: undefined, group_id: sgCurrentDetail.value?.id || 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 }) + sgRuleDialogVisible.value = true +} +const handleSgEditRule = (rule) => { + sgRuleDialogType.value = 'edit' + Object.assign(sgRuleForm, { + id: rule.id, group_id: sgCurrentDetail.value?.id || 0, port_group_id: sgCurrentDetail.value?.id || 0, + protocol: rule.protocol || 'tcp', action: rule.action || 'allow', + port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0 + }) + sgRuleDialogVisible.value = true +} +const submitSgRule = () => { + sgRuleFormRef.value?.validate(async (valid) => { + if (!valid) return + sgSubmitLoading.value = true + try { + const res = sgRuleDialogType.value === 'add' + ? await createSecurityGroupRule({ service_id: serviceId.value, ...sgRuleForm }) + : await updateSecurityGroupRule({ service_id: serviceId.value, ...sgRuleForm }) + if (res?.data?.code === 200) { + ElMessage.success(sgRuleDialogType.value === 'add' ? '规则创建成功' : '规则修改成功') + sgRuleDialogVisible.value = false + handleSgViewDetail(sgCurrentDetail.value) + } else ElMessage.error(extractApiError(res?.data, '操作失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { sgSubmitLoading.value = false } + }) +} +const handleSgDeleteRule = (rule) => { + ElMessageBox.confirm('确定要删除该规则吗?', '删除确认', { + confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' + }).then(async () => { + try { + const res = await deleteSecurityGroupRule({ service_id: serviceId.value, id: rule.id }) + if (res?.data?.code === 200) { + ElMessage.success('删除成功') + handleSgViewDetail(sgCurrentDetail.value) + } else ElMessage.error(extractApiError(res?.data, '删除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } + }).catch(() => {}) +} + +// 更多菜单 +const handleSgRowMore = (row, command) => { + if (command === 'unbindCurrent') handleSgUnbindFromVm(row) + else if (command === 'bindVm') handleSgBindVm(row) + else if (command === 'unbindVm') handleSgUnbindVm(row) + else if (command === 'whitelist') handleSgToggleWhitelist(row) + else if (command === 'viewDetail') handleSgViewDetail(row) + else if (command === 'delete') handleSgDelete(row) +} // ---- 快照/备份管理 ---- const snapshotList = ref([]) @@ -2001,7 +2293,8 @@ const handleBackupProgress = async (row) => { const goBack = () => { tagsViewStore.delVisitedView(route) - router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } }) + // router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } }) + router.back() } let loadedVmId = null @@ -2021,9 +2314,6 @@ watch(vmId, () => { if (isPageActive) initPage() }) watch(activeTab, (tab) => { if (tab === 'monitor' && detail.value) startPolling() else stopPolling() - if (tab === 'network') loadVmNetworks() - if (tab === 'volume') loadVmVolumes() - if (tab === 'security') loadVmSecurityGroups() if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() } if (tab === 'backup') { loadBackups(); loadBackupQuota() } }) @@ -2104,4 +2394,8 @@ onMounted(() => { isPageActive = true; initPage() }) .vnc-result { margin-top: 12px; } .pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; } +.bind-selector-row { display: flex; align-items: center; width: 100%; } +.rules-section { margin-top: 8px; } +.rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.rules-header h4 { margin: 0; font-size: 15px; font-weight: 600; color: #303133; } diff --git a/src/views/virtualization/VmManage.vue b/src/views/virtualization/VmManage.vue index 93551cc..79c6327 100644 --- a/src/views/virtualization/VmManage.vue +++ b/src/views/virtualization/VmManage.vue @@ -54,10 +54,10 @@ {{ vmStatusLabel(row.status) }} - + + - - - + + + {{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }}) @@ -197,7 +204,7 @@ @@ -234,7 +241,7 @@