@@ -469,18 +536,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, CopyDocument } 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 +585,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)
@@ -725,6 +923,35 @@ const formatMemKB = (kb) => {
}
const formatMemory = formatMemKB
+const getVmIps = (vm) => {
+ if (!vm) return []
+ if (Array.isArray(vm.ips)) return vm.ips.map(ip => String(ip).trim()).filter(Boolean).reverse()
+ if (typeof vm.ips === 'string' && vm.ips.trim()) return vm.ips.split(',').map(ip => ip.trim()).filter(Boolean).reverse()
+ const networks = Array.isArray(vm.networks) ? vm.networks : []
+ return networks.flatMap(network => {
+ if (Array.isArray(network.ips)) return network.ips
+ return [network.ip, network.address].filter(Boolean)
+ }).map(ip => String(ip).trim()).filter(Boolean).reverse()
+}
+
+const copyIp = async (ip) => {
+ if (!ip) return
+ try {
+ await navigator.clipboard.writeText(ip)
+ ElMessage.success('IP 已复制')
+ } catch {
+ const textarea = document.createElement('textarea')
+ textarea.value = ip
+ textarea.style.position = 'fixed'
+ textarea.style.opacity = '0'
+ document.body.appendChild(textarea)
+ textarea.select()
+ const copied = document.execCommand('copy')
+ document.body.removeChild(textarea)
+ copied ? ElMessage.success('IP 已复制') : ElMessage.error('复制失败')
+ }
+}
+
const formatTimestamp = (ts) => {
if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) {
@@ -1159,6 +1386,13 @@ defineExpose({ loadList })