diff --git a/src/api/admin/kvmService.js b/src/api/admin/kvmService.js
index b0fd376..f1a3128 100644
--- a/src/api/admin/kvmService.js
+++ b/src/api/admin/kvmService.js
@@ -557,6 +557,18 @@ export const abortDataMigrate = (data) => {
})
}
+/** 获取跨服务数据迁移并发及队列状态 */
+export const getDataMigrateConcurrency = () => {
+ return http2.get('/api/v1/admin/server/host_service/point/vm/data_migrate/concurrency')
+}
+
+/** 设置跨服务数据迁移并发数 */
+export const updateDataMigrateConcurrency = (data) => {
+ return http2.post('/api/v1/admin/server/host_service/point/vm/data_migrate/concurrency', data, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ })
+}
+
/** 获取虚拟机迁移记录列表 */
export const getVmMigrationRecordList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/migration_record/list', { params })
diff --git a/src/views/user-vm/UserVmList.vue b/src/views/user-vm/UserVmList.vue
index 0bdc491..f048ec3 100644
--- a/src/views/user-vm/UserVmList.vue
+++ b/src/views/user-vm/UserVmList.vue
@@ -91,8 +91,28 @@
-
-
- {{ formatVmIps(getRowVm(row)) || '-' }}
+
+
+
+
{{ getVmIps(getRowVm(row))[0] }}
+
+
+
+
+
+ +{{ getVmIps(getRowVm(row)).length - 1 }}
+
+ 全部 IP
+
+ {{ ip }}
+
+
+
+
+
+
+ -
+
@@ -953,7 +973,7 @@
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
-import { Plus, Refresh, Search, ArrowDown } from '@element-plus/icons-vue'
+import { Plus, Refresh, Search, ArrowDown, CopyDocument } from '@element-plus/icons-vue'
import { getUserVmList, getUserVmDetail, createUserVm, updateUserVm, deleteUserVm, getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, bindUserVm, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
@@ -991,27 +1011,48 @@ const formatMemory = (kb) => {
return kb + ' KB'
}
-const getRowVm = (row) => row?._vm || row?.vm?.data || row?.vm || row?.user_vm || null
+const parseItemArg = (row) => {
+ const raw = row?.itemArg ?? row?.ItemArg ?? row?.item_arg
+ if (!raw) return null
+ if (typeof raw === 'object' && !Array.isArray(raw)) return raw
+ if (typeof raw !== 'string') return null
+ try {
+ const parsed = JSON.parse(raw)
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
+ } catch {
+ return null
+ }
+}
-const formatVmIps = (vm) => {
- if (!vm) return ''
- if (Array.isArray(vm.ips)) return vm.ips.filter(Boolean).join(', ')
- if (typeof vm.ips === 'string' && vm.ips.trim()) return vm.ips.split(',').map(ip => ip.trim()).filter(Boolean).join(', ')
+const getRowVm = (row) => row?._itemArgVm || null
+
+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)
- }).filter(Boolean).join(', ')
+ }).map(ip => String(ip).trim()).filter(Boolean).reverse()
}
-const loadVmSummaries = async (rows) => {
- const targets = rows.filter(row => row?.id && (row.itemId || row.item_id) && !getRowVm(row))
- await Promise.allSettled(targets.map(async row => {
- const res = await getUserVmDetail({ user_goods_id: row.id })
- if (res?.data?.code !== 200 || !res?.data?.data) return
- const detail = res.data.data
- row._vm = detail.vm?.data ?? detail.vm ?? null
- }))
+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 formatExpireTime = (t) => {
@@ -1047,9 +1088,9 @@ const loadList = async () => {
const res = await getUserGoodsList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
- list.value = d.data || (Array.isArray(d) ? d : [])
+ const rows = d.data || (Array.isArray(d) ? d : [])
+ list.value = rows.map(row => ({ ...row, _itemArgVm: parseItemArg(row) }))
total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length
- await loadVmSummaries(list.value)
} else {
list.value = []
total.value = 0
@@ -1902,6 +1943,13 @@ onMounted(() => {
.table-section { padding: 0; }
.vm-spec-list { display: flex; flex-wrap: wrap; gap: 4px; }
.vm-secondary-text { color: #909399; font-size: 12px; }
+.vm-ip-cell { display: flex; align-items: center; min-width: 0; }
+.vm-ip-text { overflow: hidden; color: #303133; font-family: Consolas, Monaco, monospace; text-overflow: ellipsis; white-space: nowrap; }
+.vm-ip-copy { flex-shrink: 0; margin-left: 2px; padding: 4px; }
+.vm-ip-more { flex-shrink: 0; margin-left: 4px; cursor: default; }
+:global(.vm-ip-popover-title) { margin-bottom: 6px; color: #909399; font-size: 12px; }
+:global(.vm-ip-popover-row) { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 30px; }
+:global(.vm-ip-popover-row code) { overflow-wrap: anywhere; color: #303133; font-family: Consolas, Monaco, monospace; }
.pagination { padding: 16px 20px; border-top: 1px solid #e1e8ed; background: #fafbfc; justify-content: flex-end; }
diff --git a/src/views/virtualization/KvmServiceDetail.vue b/src/views/virtualization/KvmServiceDetail.vue
index 2ba1f2e..f60b770 100644
--- a/src/views/virtualization/KvmServiceDetail.vue
+++ b/src/views/virtualization/KvmServiceDetail.vue
@@ -107,6 +107,9 @@
+
+
+
@@ -164,6 +167,7 @@ const VncNodeManage = defineAsyncComponent(() => import('./VncNodeManage.vue'))
const SnapshotManage = defineAsyncComponent(() => import('./SnapshotManage.vue'))
const BackupManage = defineAsyncComponent(() => import('./BackupManage.vue'))
const UserNetworkingManage = defineAsyncComponent(() => import('./UserNetworkingManage.vue'))
+const VmMigrationQueue = defineAsyncComponent(() => import('./VmMigrationQueue.vue'))
const VmMigrationRecord = defineAsyncComponent(() => import('./VmMigrationRecord.vue'))
// 引入tagsViewStore
@@ -198,6 +202,7 @@ const tabLoaded = reactive({
'snapshot': false,
'backup': false,
'networking': false,
+ 'migration_queue': false,
'migration': false
})
@@ -595,4 +600,8 @@ onMounted(() => {
padding: 0;
}
+.custom-tabs :deep(.migration-queue-page) {
+ padding: 0;
+}
+
diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue
index 5656580..908d009 100644
--- a/src/views/virtualization/VmDetail.vue
+++ b/src/views/virtualization/VmDetail.vue
@@ -106,7 +106,8 @@
数据迁移进行中
- {{ migrateStageLabel(dataMigrateProgressData.stage) }}
+ {{ migrateStageLabel(currentMigrateStage) }}
+ 队列 #{{ dataMigrateProgressData.queue_position }}
{{ dataMigrateProgressData.progress }}%
{{ dataMigrateProgressData.speed }}
{{ dataMigrateProgressData.message }}
@@ -1305,7 +1306,7 @@
- {{ migrateStageLabel(dataMigrateProgressData.stage) }}
+ {{ migrateStageLabel(currentMigrateStage) }}
#{{ dataMigrateProgressData.migration_record_id }}
@@ -1313,6 +1314,7 @@
{{ dataMigrateProgressData.chain_id }}
+ 第 {{ dataMigrateProgressData.queue_position }} 位
{{ dataMigrateProgressData.export_task_id }}
@@ -1326,11 +1328,11 @@
VM #{{ dataMigrateProgressData.source_vm_id }}
-
- VM #{{ dataMigrateProgressData.imported_vm_id }}
+
+ VM #{{ dataMigrateProgressData.target_vm_id || dataMigrateProgressData.imported_vm_id }}
-
+
{{ dataMigrateProgressData.speed }}
{{ dataMigrateProgressData.message }}
@@ -2840,12 +2842,21 @@ const submitDataMigrate = async () => {
}
const res = await dataMigrateVm(fd)
if (res?.data?.code === 200) {
- ElMessage.success('数据迁移已发起')
const d = res.data.data
dataMigrationId.value = d?.migration_id || ''
dataMigrateTaskId.value = d?.export_task?.task_id || ''
dataMigrationRecordId.value = d?.migration_record_id || null
dataMigrationChainId.value = d?.chain_id || ''
+ dataMigrateProgressData.value = d ? {
+ source_service_id: serviceId.value,
+ source_host_id: vmHostId.value,
+ source_vm_id: vmId.value,
+ target_service_id: dataMigrateForm.target_service_id,
+ target_host_id: dataMigrateForm.target_host_id,
+ ...d
+ } : null
+ if (d?.queue_position > 0) ElMessage.success(`迁移已加入队列,当前位置 #${d.queue_position}`)
+ else ElMessage.success('数据迁移已发起')
dataMigrateVisible.value = false
dataMigrateProgressVisible.value = true
loadDataMigrateProgress()
@@ -2857,28 +2868,29 @@ const submitDataMigrate = async () => {
const MIGRATE_DONE_STAGES = ['completed', 'done', 'success', 'warning', 'failed', 'aborted', 'cancelled', 'error']
const loadDataMigrateProgress = async () => {
- if (!dataMigrationId.value && !dataMigrateTaskId.value) return
+ if (!dataMigrationRecordId.value && !dataMigrationId.value && !dataMigrateTaskId.value) return
const params = { service_id: serviceId.value, host_id: vmHostId.value }
- if (dataMigrationId.value) params.migration_id = dataMigrationId.value
- if (dataMigrateTaskId.value) params.task_id = dataMigrateTaskId.value
+ if (dataMigrationRecordId.value) params.migration_record_id = dataMigrationRecordId.value
+ else if (dataMigrationId.value) params.migration_id = dataMigrationId.value
+ else if (dataMigrateTaskId.value) params.task_id = dataMigrateTaskId.value
dataMigrateProgressLoading.value = true
try {
const res = await getDataMigrateProgress(params)
if (res?.data?.code === 200 && res.data.data) {
- dataMigrateProgressData.value = res.data.data
const d = res.data.data
+ dataMigrateProgressData.value = { ...(dataMigrateProgressData.value || {}), ...d }
if (d.migration_record_id) dataMigrationRecordId.value = d.migration_record_id
if (d.chain_id) dataMigrationChainId.value = d.chain_id
if (!dataMigrationId.value && d.migration_id) dataMigrationId.value = d.migration_id
if (!dataMigrateTaskId.value) {
dataMigrateTaskId.value = d.export_task_id || d.import_task_id || ''
}
- const stage = d.stage
+ const stage = d.status || d.stage
if (MIGRATE_DONE_STAGES.includes(stage)) {
stopMigratePolling()
if (stage === 'completed' || stage === 'done' || stage === 'success') ElMessage.success('数据迁移已完成')
else if (stage === 'warning') ElMessage.warning('数据迁移已完成,但存在警告')
- else if (stage === 'failed' || stage === 'error') ElMessage.error(d.error || '数据迁移失败')
+ else if (stage === 'failed' || stage === 'error') ElMessage.error(d.error_message || d.error || '数据迁移失败')
loadDetail()
}
} else {
@@ -2891,9 +2903,11 @@ const loadDataMigrateProgress = async () => {
} finally { dataMigrateProgressLoading.value = false }
}
+const currentMigrateStage = computed(() => dataMigrateProgressData.value?.status || dataMigrateProgressData.value?.stage || '')
+
const isMigrating = computed(() => {
if (detail.value?.migrating) return true
- const stage = dataMigrateProgressData.value?.stage
+ const stage = currentMigrateStage.value
return stage && !MIGRATE_DONE_STAGES.includes(stage)
})
@@ -2901,14 +2915,14 @@ const migrateStageLabel = (stage) => ({
exporting: '导出中', importing: '导入中', transferring: '传输中',
verifying: '校验中', completed: '已完成', done: '已完成', success: '成功', warning: '已完成(有警告)', running: '执行中',
failed: '失败', error: '错误', aborted: '已中断', cancelled: '已取消',
- pending: '等待中', preparing: '准备中'
+ pending: '排队中', starting: '启动中', preparing: '准备中', aborting: '中断中', recovering: '恢复中'
}[stage] || stage || '-')
const migrateStageType = (stage) => ({
exporting: 'warning', importing: 'warning', transferring: '',
verifying: '', completed: 'success', done: 'success', success: 'success', warning: 'warning', running: 'primary',
failed: 'danger', error: 'danger', aborted: 'info', cancelled: 'info',
- pending: 'info', preparing: 'info'
+ pending: 'info', starting: 'warning', preparing: 'info', aborting: 'danger', recovering: 'warning'
}[stage] || 'info')
const migrateProgressBarStatus = (stage) => {
diff --git a/src/views/virtualization/VmManage.vue b/src/views/virtualization/VmManage.vue
index cbf2f2e..f491d0d 100644
--- a/src/views/virtualization/VmManage.vue
+++ b/src/views/virtualization/VmManage.vue
@@ -68,9 +68,26 @@
-
-
+
- {{ formatVmIps(row) }}
+
+
{{ getVmIps(row)[0] }}
+
+
+
+
+
+ +{{ getVmIps(row).length - 1 }}
+
+ 全部 IP
+
+ {{ ip }}
+
+
+
+
+
+
-
@@ -519,7 +536,7 @@
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, Promotion } 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,
@@ -906,15 +923,33 @@ const formatMemKB = (kb) => {
}
const formatMemory = formatMemKB
-const formatVmIps = (vm) => {
- if (!vm) return ''
- if (Array.isArray(vm.ips)) return vm.ips.filter(Boolean).join(', ')
- if (typeof vm.ips === 'string' && vm.ips.trim()) return vm.ips.split(',').map(ip => ip.trim()).filter(Boolean).join(', ')
+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)
- }).filter(Boolean).join(', ')
+ }).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) => {
@@ -1351,6 +1386,13 @@ defineExpose({ loadList })