diff --git a/src/views/virtualization/VmManage.vue b/src/views/virtualization/VmManage.vue index 93ea5b0..e995c3c 100644 --- a/src/views/virtualization/VmManage.vue +++ b/src/views/virtualization/VmManage.vue @@ -37,6 +37,9 @@ 批量关机 + + 批量数据迁移 + 批量删除 @@ -443,6 +446,47 @@ + + + + 将 {{ batchMigrateTargets.length }} 台虚拟机的数据迁移到同一目标宿主机。迁移任务将逐台提交,已在迁移中的实例会被跳过。 + + +
+
目标位置
+ +
+ + 选择 +
+
+ +
+ + 选择 +
+
+
+
+
网络配置
+
+ 个/实例 + 个/实例 +
+ +
+
{{ network.name || network.id }} (#{{ network.id }})
+ 选择网络 +
+
+
+
+ +
+ + + +
@@ -469,18 +513,20 @@ import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled, VideoPlay, SwitchButton, Delete } from '@element-plus/icons-vue' +import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled, VideoPlay, SwitchButton, Delete, Promotion } from '@element-plus/icons-vue' import { getRemoteHostList, getVmList, getVmDetail, getVmStatus, createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm, resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList, createNetwork, getMetricsHistory, - getDataMigrateProgress + getDataMigrateProgress, dataMigrateVm } from '@/api/admin/kvmService' import { extractApiError } from '@/utils/kvmErrorUtil' import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue' import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue' import UserListSelector from '@/components/admin/UserListSelector.vue' import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue' +import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue' +import HostSelectorPopup from '@/components/admin/HostSelectorPopup.vue' const route = useRoute() const router = useRouter() @@ -516,6 +562,135 @@ const selectedVms = ref([]) const handleSelectionChange = (selection) => { selectedVms.value = selection } const clearSelection = () => { vmTableRef.value?.clearSelection() } +// Batch data migration +const batchMigrateVisible = ref(false) +const batchMigrateSubmitting = ref(false) +const showBatchServiceSelector = ref(false) +const showBatchHostSelector = ref(false) +const showBatchNetworkSelector = ref(false) +const batchMigrateTargets = ref([]) +const batchMigrateNetworks = ref([]) +const batchMigrateForm = reactive({ + target_service_id: 0, + target_host_id: 0, + ipv4_num: 0, + ipv6_num: 0, + network_ids: [], + _serviceName: '', + _hostName: '' +}) + +const resetBatchMigrateForm = () => { + Object.assign(batchMigrateForm, { + target_service_id: 0, + target_host_id: 0, + ipv4_num: 0, + ipv6_num: 0, + network_ids: [], + _serviceName: '', + _hostName: '' + }) + batchMigrateNetworks.value = [] +} + +const openBatchDataMigrate = () => { + const targets = selectedVms.value.filter(vm => !vm.migrating) + if (!targets.length) { + ElMessage.warning('所选虚拟机均正在迁移中,请重新选择') + return + } + const skipped = selectedVms.value.length - targets.length + if (skipped) ElMessage.warning(`已跳过 ${skipped} 台正在迁移的虚拟机`) + batchMigrateTargets.value = [...targets] + resetBatchMigrateForm() + batchMigrateVisible.value = true +} + +const handleBatchServiceSelected = (service) => { + if (!service?.id) return + batchMigrateForm.target_service_id = Number(service.id) + batchMigrateForm._serviceName = service.name || '' + batchMigrateForm.target_host_id = 0 + batchMigrateForm._hostName = '' + batchMigrateForm.network_ids = [] + batchMigrateNetworks.value = [] +} + +const handleBatchHostSelected = (host) => { + if (!host?.id) return + batchMigrateForm.target_host_id = Number(host.id) + batchMigrateForm._hostName = host.name || host.ip || '' + batchMigrateForm.network_ids = [] + batchMigrateNetworks.value = [] +} + +const handleBatchNetworkSelected = (networks) => { + const list = Array.isArray(networks) ? networks : (networks ? [networks] : []) + const merged = [...batchMigrateNetworks.value, ...list] + batchMigrateNetworks.value = [...new Map(merged.filter(item => item?.id).map(item => [Number(item.id), item])).values()] + batchMigrateForm.network_ids = batchMigrateNetworks.value.map(item => Number(item.id)) +} + +const removeBatchMigrateNetwork = (networkId) => { + batchMigrateNetworks.value = batchMigrateNetworks.value.filter(item => Number(item.id) !== Number(networkId)) + batchMigrateForm.network_ids = batchMigrateNetworks.value.map(item => Number(item.id)) +} + +const submitBatchDataMigrate = async () => { + if (!batchMigrateForm.target_service_id) { + ElMessage.warning('请选择目标主控服务') + return + } + if (!batchMigrateForm.target_host_id) { + ElMessage.warning('请选择目标宿主机') + return + } + if (!batchMigrateTargets.value.length) { + ElMessage.warning('没有可迁移的虚拟机') + return + } + + try { + await ElMessageBox.confirm( + `确定将 ${batchMigrateTargets.value.length} 台虚拟机迁移到宿主机「${batchMigrateForm._hostName || batchMigrateForm.target_host_id}」吗?`, + '批量数据迁移', + { confirmButtonText: '确定迁移', cancelButtonText: '取消', type: 'warning' } + ) + } catch { return } + + batchMigrateSubmitting.value = true + batchLoading.value = true + let success = 0 + const failed = [] + for (const vm of batchMigrateTargets.value) { + try { + const formData = new FormData() + formData.append('source_service_id', serviceId.value) + formData.append('source_vm_id', vm.id) + formData.append('target_service_id', batchMigrateForm.target_service_id) + formData.append('target_host_id', batchMigrateForm.target_host_id) + if (batchMigrateForm.ipv4_num > 0) formData.append('ipv4_num', batchMigrateForm.ipv4_num) + if (batchMigrateForm.ipv6_num > 0) formData.append('ipv6_num', batchMigrateForm.ipv6_num) + batchMigrateForm.network_ids.forEach(id => formData.append('network_ids', id)) + const res = await dataMigrateVm(formData) + if (res?.data?.code === 200) success++ + else failed.push(vm.name || `ID ${vm.id}`) + } catch { + failed.push(vm.name || `ID ${vm.id}`) + } + } + batchMigrateSubmitting.value = false + batchLoading.value = false + batchMigrateVisible.value = false + clearSelection() + if (failed.length) { + ElMessage.warning(`批量迁移提交完成:成功 ${success},失败 ${failed.length}(${failed.slice(0, 3).join('、')}${failed.length > 3 ? ' 等' : ''})`) + } else { + ElMessage.success(`已提交 ${success} 台虚拟机的数据迁移任务`) + } + loadList() +} + // 选择器 const showCreateImageSelector = ref(false) const showRebuildImageSelector = ref(false) @@ -1177,6 +1352,8 @@ defineExpose({ loadList }) color: #409eff; margin-right: 4px; } +.batch-network-field { display: flex; align-items: flex-start; gap: 8px; width: 100%; } +.batch-network-tags { display: flex; flex: 1; flex-wrap: wrap; gap: 6px; min-width: 0; } .threshold-grid { display: grid; grid-template-columns: 76px minmax(220px, 1fr) minmax(220px, 1fr);