feat: 新增虚拟机批量数据迁移
Build and Deploy Vue3 / build (push) Successful in 1m47s
Build and Deploy Vue3 / deploy (push) Successful in 54s

This commit is contained in:
shiran
2026-08-04 12:45:14 +08:00
parent 5864731795
commit d373e10097
+179 -2
View File
@@ -37,6 +37,9 @@
<el-button type="warning" size="small" @click="handleBatchPower('stop')" :loading="batchLoading">
<el-icon><SwitchButton /></el-icon>批量关机
</el-button>
<el-button type="primary" size="small" @click="openBatchDataMigrate" :loading="batchLoading">
<el-icon><Promotion /></el-icon>批量数据迁移
</el-button>
<el-button type="danger" size="small" @click="handleBatchDelete" :loading="batchLoading">
<el-icon><Delete /></el-icon>批量删除
</el-button>
@@ -443,6 +446,47 @@
<!-- 用户选择器 -->
<UserListSelector v-model="showUserSelector" :current-user-id="createForm.user_id" @confirm="handleUserSelected" />
<!-- 批量数据迁移 -->
<el-dialog v-model="batchMigrateVisible" title="批量数据迁移" width="620px" destroy-on-close class="tk-dialog">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
将 {{ batchMigrateTargets.length }} 台虚拟机的数据迁移到同一目标宿主机。迁移任务将逐台提交,已在迁移中的实例会被跳过。
</el-alert>
<el-form :model="batchMigrateForm" label-width="110px">
<div class="tk-section">
<div class="tk-section-title">目标位置</div>
<el-form-item label="目标主控服务" required>
<div class="bind-selector-row">
<el-input :model-value="batchMigrateForm.target_service_id ? `${batchMigrateForm._serviceName} (ID: ${batchMigrateForm.target_service_id})` : ''" readonly placeholder="请选择目标主控服务" />
<el-button type="primary" @click="showBatchServiceSelector=true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="目标宿主机" required>
<div class="bind-selector-row">
<el-input :model-value="batchMigrateForm.target_host_id ? `${batchMigrateForm._hostName} (ID: ${batchMigrateForm.target_host_id})` : ''" readonly placeholder="请先选择目标主控服务" />
<el-button type="primary" :disabled="!batchMigrateForm.target_service_id" @click="showBatchHostSelector=true">选择</el-button>
</div>
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">网络配置</div>
<div class="tk-resource-grid">
<el-form-item label="IPv4 数量"><el-input-number v-model="batchMigrateForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个/实例</span></el-form-item>
<el-form-item label="IPv6 数量"><el-input-number v-model="batchMigrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个/实例</span></el-form-item>
</div>
<el-form-item label="目标网络">
<div class="batch-network-field">
<div class="batch-network-tags" v-if="batchMigrateNetworks.length"><el-tag v-for="network in batchMigrateNetworks" :key="network.id" closable @close="removeBatchMigrateNetwork(network.id)">{{ network.name || network.id }} (#{{ network.id }})</el-tag></div>
<el-button :disabled="!batchMigrateForm.target_host_id" @click="showBatchNetworkSelector=true">选择网络</el-button>
</div>
</el-form-item>
</div>
</el-form>
<template #footer><el-button @click="batchMigrateVisible=false">取消</el-button><el-button type="primary" :loading="batchMigrateSubmitting" @click="submitBatchDataMigrate">提交迁移</el-button></template>
</el-dialog>
<KvmServiceSelector v-model="showBatchServiceSelector" @confirm="handleBatchServiceSelected" />
<HostSelectorPopup v-model="showBatchHostSelector" :service-id="batchMigrateForm.target_service_id || 0" :current-id="batchMigrateForm.target_host_id || 0" @confirm="handleBatchHostSelected" />
<NetworkSelectorPopup v-model="showBatchNetworkSelector" :service-id="batchMigrateForm.target_service_id || 0" :host-id="batchMigrateForm.target_host_id || 0" filter-type="bridge" filter-used="false" @confirm="handleBatchNetworkSelected" />
<!-- 电源操作确认弹窗 -->
<el-dialog v-model="powerDialogVisible" :title="`${powerLabels[powerAction] || ''}虚拟机`" width="400px" destroy-on-close>
<div style="display: flex; align-items: flex-start; gap: 12px; padding: 8px 0">
@@ -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);