Merge pull request 'fix: 修复镜像选择器分页总数' (#31) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m47s
Build and Deploy Vue3 / deploy (push) Successful in 56s

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2026-08-04 16:28:49 +08:00
7 changed files with 691 additions and 20 deletions
+12
View File
@@ -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 })
+1 -1
View File
@@ -98,7 +98,7 @@ const loadList = async () => {
items = items.map(item => item.image || item).filter(Boolean)
}
list.value = items
total.value = inner.total ?? inner.all_count ?? list.value.length
total.value = inner.meta?.count ?? inner.total ?? inner.all_count ?? list.value.length
}
} catch { /* ignore */ }
finally { loading.value = false }
+95 -2
View File
@@ -75,6 +75,45 @@
<span style="color:#909399;font-size:12px"> (ID:{{ row.userId || row.user_id || '-' }})</span>
</template>
</el-table-column>
<el-table-column label="主机名" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ getRowVm(row)?.hostname || getRowVm(row)?.name || row.itemName || '-' }}</span>
<span v-if="row.itemId" class="vm-secondary-text"> #{{ row.itemId }}</span>
</template>
</el-table-column>
<el-table-column label="配置" min-width="210">
<template #default="{ row }">
<div v-if="getRowVm(row)" class="vm-spec-list">
<el-tag size="small" type="info">{{ getRowVm(row).vcpu || 0 }} </el-tag>
<el-tag size="small" type="info">{{ formatMemory(getRowVm(row).memory) }}</el-tag>
<el-tag v-if="getRowVm(row).system_size" size="small" type="info">{{ getRowVm(row).system_size }} GB </el-tag>
</div>
<span v-else class="vm-secondary-text">-</span>
</template>
</el-table-column>
<el-table-column label="IP 地址" min-width="230">
<template #default="{ row }">
<div v-if="getVmIps(getRowVm(row)).length" class="vm-ip-cell">
<span class="vm-ip-text">{{ getVmIps(getRowVm(row))[0] }}</span>
<el-button link class="vm-ip-copy" title="复制 IP" @click.stop="copyIp(getVmIps(getRowVm(row))[0])">
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-popover v-if="getVmIps(getRowVm(row)).length > 1" placement="top" trigger="hover" :width="360">
<template #reference>
<el-tag size="small" type="info" class="vm-ip-more">+{{ getVmIps(getRowVm(row)).length - 1 }}</el-tag>
</template>
<div class="vm-ip-popover-title">全部 IP</div>
<div v-for="(ip, index) in getVmIps(getRowVm(row))" :key="`${ip}-${index}`" class="vm-ip-popover-row">
<code>{{ ip }}</code>
<el-button link title="复制 IP" @click.stop="copyIp(ip)">
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
</el-popover>
</div>
<span v-else class="vm-secondary-text">-</span>
</template>
</el-table-column>
<el-table-column label="绑定状态" width="90">
<template #default="{ row }">
<el-tag :type="row.itemId ? 'success' : 'info'" size="small">
@@ -934,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'
@@ -972,6 +1011,50 @@ const formatMemory = (kb) => {
return kb + ' KB'
}
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 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)
}).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 formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
@@ -1005,7 +1088,8 @@ 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
} else {
list.value = []
@@ -1857,6 +1941,15 @@ onMounted(() => {
.action-bar { display: flex; gap: 12px; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
.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; }
@@ -107,6 +107,9 @@
<el-tab-pane label="用户组网" name="networking">
<UserNetworkingManage v-if="tabLoaded['networking']" />
</el-tab-pane>
<el-tab-pane label="迁移队列" name="migration_queue">
<VmMigrationQueue v-if="tabLoaded['migration_queue']" />
</el-tab-pane>
<el-tab-pane label="迁移记录" name="migration">
<VmMigrationRecord v-if="tabLoaded['migration']" />
</el-tab-pane>
@@ -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;
}
</style>
+29 -15
View File
@@ -106,7 +106,8 @@
<span class="migrate-banner-title">数据迁移进行中</span>
<template v-if="dataMigrateProgressData">
<span class="migrate-divider"></span>
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small" effect="dark">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</el-tag>
<el-tag :type="migrateStageType(currentMigrateStage)" size="small" effect="dark">{{ migrateStageLabel(currentMigrateStage) }}</el-tag>
<span v-if="dataMigrateProgressData.queue_position > 0" class="migrate-msg">队列 #{{ dataMigrateProgressData.queue_position }}</span>
<span v-if="dataMigrateProgressData.progress != null" class="migrate-progress-text">{{ dataMigrateProgressData.progress }}%</span>
<span v-if="dataMigrateProgressData.speed" class="migrate-speed">{{ dataMigrateProgressData.speed }}</span>
<span v-if="dataMigrateProgressData.message" class="migrate-msg">{{ dataMigrateProgressData.message }}</span>
@@ -1305,7 +1306,7 @@
<div v-loading="dataMigrateProgressLoading">
<el-descriptions :column="2" border size="small" v-if="dataMigrateProgressData">
<el-descriptions-item label="阶段" :span="2">
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</el-tag>
<el-tag :type="migrateStageType(currentMigrateStage)" size="small">{{ migrateStageLabel(currentMigrateStage) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="迁移记录" v-if="dataMigrateProgressData.migration_record_id">
<el-link type="primary" @click="goMigrationRecords({ id: dataMigrateProgressData.migration_record_id })">#{{ dataMigrateProgressData.migration_record_id }}</el-link>
@@ -1313,6 +1314,7 @@
<el-descriptions-item label="迁移链" v-if="dataMigrateProgressData.chain_id">
<el-link type="primary" class="mono-text" @click="goMigrationRecords({ chain_id: dataMigrateProgressData.chain_id })">{{ dataMigrateProgressData.chain_id }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="队列位置" v-if="dataMigrateProgressData.queue_position > 0"> {{ dataMigrateProgressData.queue_position }} </el-descriptions-item>
<el-descriptions-item label="导出任务ID" :span="2" v-if="dataMigrateProgressData.export_task_id">
<span style="font-family:monospace;font-size:12px">{{ dataMigrateProgressData.export_task_id }}</span>
</el-descriptions-item>
@@ -1326,11 +1328,11 @@
<el-descriptions-item label="源虚拟机" v-if="dataMigrateProgressData.source_vm_id">
<el-link type="primary" @click="goVmById(dataMigrateProgressData.source_service_id, dataMigrateProgressData.source_vm_id)">VM #{{ dataMigrateProgressData.source_vm_id }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="导入虚拟机" v-if="dataMigrateProgressData.imported_vm_id">
<el-link type="primary" @click="goVmById(dataMigrateProgressData.target_service_id, dataMigrateProgressData.imported_vm_id)">VM #{{ dataMigrateProgressData.imported_vm_id }}</el-link>
<el-descriptions-item label="导入虚拟机" v-if="dataMigrateProgressData.target_vm_id || dataMigrateProgressData.imported_vm_id">
<el-link type="primary" @click="goVmById(dataMigrateProgressData.target_service_id, dataMigrateProgressData.target_vm_id || dataMigrateProgressData.imported_vm_id)">VM #{{ dataMigrateProgressData.target_vm_id || dataMigrateProgressData.imported_vm_id }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="进度" :span="2" v-if="dataMigrateProgressData.progress != null">
<el-progress :percentage="dataMigrateProgressData.progress" :status="migrateProgressBarStatus(dataMigrateProgressData.stage)" style="width:100%" />
<el-progress :percentage="dataMigrateProgressData.progress" :status="migrateProgressBarStatus(currentMigrateStage)" style="width:100%" />
</el-descriptions-item>
<el-descriptions-item label="速度" v-if="dataMigrateProgressData.speed">{{ dataMigrateProgressData.speed }}</el-descriptions-item>
<el-descriptions-item label="信息" :span="dataMigrateProgressData.speed ? 1 : 2" v-if="dataMigrateProgressData.message">{{ dataMigrateProgressData.message }}</el-descriptions-item>
@@ -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) => {
+238 -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>
@@ -65,6 +68,29 @@
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="IP 地址" min-width="230">
<template #default="{ row }">
<div v-if="getVmIps(row).length" class="vm-ip-cell">
<span class="vm-ip-text">{{ getVmIps(row)[0] }}</span>
<el-button link class="vm-ip-copy" title="复制 IP" @click.stop="copyIp(getVmIps(row)[0])">
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-popover v-if="getVmIps(row).length > 1" placement="top" trigger="hover" :width="360">
<template #reference>
<el-tag size="small" type="info" class="vm-ip-more">+{{ getVmIps(row).length - 1 }}</el-tag>
</template>
<div class="vm-ip-popover-title">全部 IP</div>
<div v-for="ip in getVmIps(row)" :key="ip" class="vm-ip-popover-row">
<code>{{ ip }}</code>
<el-button link title="复制 IP" @click.stop="copyIp(ip)">
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
</el-popover>
</div>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="180">
<template #default="{ row }">
<template v-if="row.migrating">
@@ -443,6 +469,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 +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 })
<style scoped>
.vm-manage-container { padding: 20px; }
.vm-config { display: flex; gap: 4px; flex-wrap: wrap; }
.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; }
.migrate-inline-status { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.migrate-inline-label { color: #e6a23c; font-size: 13px; font-weight: 600; white-space: nowrap; }
.migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; }
@@ -1177,6 +1411,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);
@@ -0,0 +1,307 @@
<template>
<div class="migration-queue-page">
<div class="queue-header">
<div>
<h2>迁移队列</h2>
<p>查看当前主控服务相关的跨服务数据迁移并管理全局迁移并发与队列任务</p>
</div>
<div class="header-actions">
<el-tag type="success" effect="plain"> 5 秒自动刷新</el-tag>
<el-button :icon="Setting" @click="openConcurrencyDialog">并发设置</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadData()">刷新</el-button>
</div>
</div>
<div class="summary-grid">
<el-card v-for="item in summaryItems" :key="item.key" shadow="never" class="summary-card">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value" :class="item.className">{{ item.value }}</div>
<div class="summary-note">{{ item.note }}</div>
</el-card>
</div>
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" class="queue-alert" />
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>活动与排队任务</span>
<span class="updated-at">状态更新时间{{ formatTime(concurrency.updated_at) }}</span>
</div>
</template>
<el-table :data="queueRecords" v-loading="loading" stripe>
<el-table-column label="队列" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.queue_position > 0" type="info">#{{ row.queue_position }}</el-tag>
<el-tag v-else type="success">执行中</el-tag>
</template>
</el-table-column>
<el-table-column label="记录 / 状态" min-width="145">
<template #default="{ row }">
<div class="stack-cell">
<span>记录 #{{ row.id }}</span>
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="源端" min-width="175">
<template #default="{ row }">
<div class="stack-cell">
<el-link v-if="row.source_service_id" type="primary" @click="goService(row.source_service_id)">服务 #{{ row.source_service_id }}</el-link>
<el-link v-if="row.source_host_id" type="primary" @click="goHost(row.source_service_id, row.source_host_id)">宿主机 #{{ row.source_host_id }}</el-link>
<el-link v-if="row.source_vm_id" type="primary" @click="goVm(row.source_service_id, row.source_vm_id)">VM #{{ row.source_vm_id }}</el-link>
</div>
</template>
</el-table-column>
<el-table-column label="目标端" min-width="175">
<template #default="{ row }">
<div class="stack-cell">
<el-link v-if="row.target_service_id" type="primary" @click="goService(row.target_service_id)">服务 #{{ row.target_service_id }}</el-link>
<el-link v-if="row.target_host_id" type="primary" @click="goHost(row.target_service_id, row.target_host_id)">宿主机 #{{ row.target_host_id }}</el-link>
<el-link v-if="row.target_vm_id" type="primary" @click="goVm(row.target_service_id, row.target_vm_id)">VM #{{ row.target_vm_id }}</el-link>
<span v-if="!row.target_vm_id" class="muted">目标 VM 尚未创建</span>
</div>
</template>
</el-table-column>
<el-table-column label="关联对象" min-width="150">
<template #default="{ row }">
<div class="stack-cell">
<el-link v-if="row.user_id" type="primary" @click="goUser(row.user_id)">用户 #{{ row.user_id }}</el-link>
<el-link v-if="row.user_goods_id" type="primary" @click="goUserGoods(row.user_goods_id)">用户商品 #{{ row.user_goods_id }}</el-link>
<span v-if="!row.user_id && !row.user_goods_id" class="muted">-</span>
</div>
</template>
</el-table-column>
<el-table-column label="迁移进度" min-width="210">
<template #default="{ row }">
<el-progress :percentage="progressValue(row.progress)" :status="progressStatus(row.status)" :stroke-width="8" />
<div class="progress-meta">
<span>{{ row.speed || row.message || statusLabel(row.status) }}</span>
<span v-if="row.queue_position > 0">前方 {{ row.queue_position - 1 }} </span>
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTime(row.started_at || row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" :loading="abortingId === row.id" :disabled="row.status === 'aborting'" @click="abortMigration(row)">中断</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !queueRecords.length" description="当前服务暂无迁移中或排队中的虚拟机" />
</el-card>
<el-dialog v-model="concurrencyVisible" title="迁移队列并发设置" width="480px" destroy-on-close>
<el-alert type="info" :closable="false" show-icon class="dialog-alert">
调高并发后空余名额会立即按 FIFO 顺序放行调低并发不会中断正在执行的任务
</el-alert>
<el-form label-width="110px">
<el-form-item label="当前并发数"><strong>{{ concurrency.concurrency }}</strong></el-form-item>
<el-form-item label="新的并发数" required>
<el-input-number v-model="concurrencyForm" :min="1" :max="100" controls-position="right" />
<span class="form-hint">允许范围 1100</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="concurrencyVisible = false">取消</el-button>
<el-button type="primary" :loading="concurrencySubmitting" @click="submitConcurrency">保存并立即生效</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, inject, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Setting } from '@element-plus/icons-vue'
import {
abortDataMigrate,
getDataMigrateConcurrency,
getDataMigrateProgress,
getVmMigrationRecordList,
updateDataMigrateConcurrency
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
const router = useRouter()
const injectedServiceId = inject('serviceId', null)
const serviceId = computed(() => Number(injectedServiceId?.value) || 0)
const loading = ref(false)
const loadError = ref('')
const queueRecords = ref([])
const abortingId = ref(null)
const concurrency = reactive({ concurrency: 1, active_count: 0, queued_count: 0, available_slots: 0, updated_at: '' })
const activeStatuses = new Set(['pending', 'starting', 'exporting', 'importing', 'running', 'aborting', 'recovering'])
const listQueryStatuses = ['pending', 'exporting', 'importing', 'running']
let refreshTimer = null
let requestInFlight = false
const summaryItems = computed(() => [
{ key: 'concurrency', label: '并发上限', value: concurrency.concurrency, note: '全局同时执行数量', className: 'primary' },
{ key: 'active', label: '执行中', value: concurrency.active_count, note: '正在占用迁移名额', className: 'success' },
{ key: 'queued', label: '排队中', value: concurrency.queued_count, note: '按创建顺序等待', className: 'warning' },
{ key: 'available', label: '空余名额', value: concurrency.available_slots, note: '可立即放行任务数', className: 'info' }
])
const statusLabel = status => ({
pending: '排队中', starting: '启动中', exporting: '导出中', importing: '导入中', running: '执行中',
aborting: '中断中', recovering: '恢复中', success: '成功', warning: '警告', failed: '失败', aborted: '已中断'
})[status] || status || '-'
const statusType = status => ({ pending: 'info', starting: 'warning', exporting: 'warning', importing: 'primary', running: 'primary', aborting: 'danger', recovering: 'warning' })[status] || 'info'
const progressValue = value => Math.max(0, Math.min(100, Number(value) || 0))
const progressStatus = status => status === 'failed' ? 'exception' : status === 'success' ? 'success' : undefined
const formatTime = value => {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false })
}
const fetchDirectionRecords = async (direction, status) => {
const result = []
let page = 1
let total = 0
do {
const params = { page, count: 100, migration_type: 'data_migrate', status, [`${direction}_service_id`]: serviceId.value }
const res = await getVmMigrationRecordList(params)
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '加载迁移队列失败'))
const data = res.data.data || {}
const rows = Array.isArray(data.data) ? data.data : []
result.push(...rows)
total = Number(data.all_count) || rows.length
page += 1
} while (result.length < total && page <= 20)
return result
}
const loadData = async ({ silent = false } = {}) => {
if (!serviceId.value || requestInFlight) return
requestInFlight = true
if (!silent) loading.value = true
loadError.value = ''
try {
const concurrencyRes = await getDataMigrateConcurrency()
if (concurrencyRes?.data?.code !== 200) throw new Error(extractApiError(concurrencyRes?.data, '加载并发状态失败'))
Object.assign(concurrency, concurrencyRes.data.data || {})
const recordResults = await Promise.allSettled(
['source', 'target'].flatMap(direction => listQueryStatuses.map(status => fetchDirectionRecords(direction, status)))
)
const successfulGroups = recordResults.filter(result => result.status === 'fulfilled').map(result => result.value)
if (!successfulGroups.length) throw new Error('加载迁移任务失败')
const allRows = successfulGroups.flat()
const uniqueRows = allRows.filter((row, index, rows) => rows.findIndex(item => item.id === row.id) === index)
const candidates = uniqueRows.filter(row => activeStatuses.has(row.status))
const progressResults = await Promise.allSettled(candidates.map(row => getDataMigrateProgress({ migration_record_id: row.id })))
queueRecords.value = candidates.map((row, index) => {
const result = progressResults[index]
const progress = result.status === 'fulfilled' && result.value?.data?.code === 200 ? result.value.data.data : null
return progress ? { ...row, ...progress } : row
}).filter(row => activeStatuses.has(row.status)).sort((a, b) => {
const aQueued = Number(a.queue_position) > 0
const bQueued = Number(b.queue_position) > 0
if (aQueued !== bQueued) return aQueued ? 1 : -1
if (aQueued) return Number(a.queue_position) - Number(b.queue_position)
return new Date(a.started_at || a.CreatedAt || 0) - new Date(b.started_at || b.CreatedAt || 0)
})
} catch (error) {
loadError.value = error.message || extractApiError(error?.response?.data, '加载迁移队列失败')
if (!silent) ElMessage.error(loadError.value)
} finally {
if (!silent) loading.value = false
requestInFlight = false
}
}
const concurrencyVisible = ref(false)
const concurrencyForm = ref(1)
const concurrencySubmitting = ref(false)
const openConcurrencyDialog = () => {
concurrencyForm.value = Number(concurrency.concurrency) || 1
concurrencyVisible.value = true
}
const submitConcurrency = async () => {
concurrencySubmitting.value = true
try {
const formData = new FormData()
formData.append('concurrency', concurrencyForm.value)
const res = await updateDataMigrateConcurrency(formData)
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '更新迁移并发失败'))
const result = res.data.data || {}
ElMessage.success(`并发数已从 ${result.old_concurrency ?? concurrency.concurrency} 调整为 ${result.new_concurrency ?? concurrencyForm.value},放行 ${result.released_count || 0} 项任务`)
concurrencyVisible.value = false
await loadData()
} catch (error) {
ElMessage.error(error.message || extractApiError(error?.response?.data, '更新迁移并发失败'))
} finally {
concurrencySubmitting.value = false
}
}
const abortMigration = async row => {
try {
await ElMessageBox.confirm(`确定中断 VM #${row.source_vm_id} 的迁移任务吗?排队中和执行中的任务均会被终止。`, '中断迁移', {
confirmButtonText: '确定中断', cancelButtonText: '取消', type: 'warning'
})
} catch { return }
abortingId.value = row.id
try {
const formData = new FormData()
formData.append('service_id', row.source_service_id)
formData.append('vm_id', row.source_vm_id)
const res = await abortDataMigrate(formData)
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '中断迁移失败'))
ElMessage.success('迁移任务已中断')
await loadData()
} catch (error) {
ElMessage.error(error.message || extractApiError(error?.response?.data, '中断迁移失败'))
} finally {
abortingId.value = null
}
}
const goService = id => router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: id } })
const goHost = (targetServiceId, hostId) => router.push({ path: '/virtualization/host-detail', query: { service_id: targetServiceId, host_id: hostId } })
const goVm = (targetServiceId, vmId) => router.push({ path: '/virtualization/vm-detail', query: { service_id: targetServiceId, vm_id: vmId } })
const goUser = userId => router.push({ path: '/user/detail', query: { user_id: userId } })
const goUserGoods = id => router.push(`/user-goods/detail/${id}`)
onMounted(async () => {
await loadData()
refreshTimer = window.setInterval(() => loadData({ silent: true }), 5000)
})
onBeforeUnmount(() => {
if (refreshTimer) window.clearInterval(refreshTimer)
})
</script>
<style scoped>
.migration-queue-page { padding: 20px; }
.queue-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.queue-header h2 { margin: 0 0 6px; color: #1d2129; font-size: 20px; }
.queue-header p { margin: 0; color: #86909c; font-size: 13px; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card :deep(.el-card__body) { padding: 16px 18px; }
.summary-label { color: #86909c; font-size: 13px; }
.summary-value { margin: 5px 0; color: #303133; font-size: 28px; font-weight: 700; line-height: 1.2; }
.summary-value.primary { color: #409eff; }
.summary-value.success { color: #67c23a; }
.summary-value.warning { color: #e6a23c; }
.summary-value.info { color: #909399; }
.summary-note, .updated-at, .muted, .progress-meta { color: #86909c; font-size: 12px; }
.queue-alert { margin-bottom: 16px; }
.card-header { display: flex; align-items: center; justify-content: space-between; font-weight: 600; }
.updated-at { font-weight: 400; }
.stack-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.progress-meta { display: flex; justify-content: space-between; gap: 8px; margin-top: 3px; }
.dialog-alert { margin-bottom: 18px; }
.form-hint { margin-left: 10px; color: #909399; font-size: 12px; }
@media (max-width: 900px) {
.queue-header { flex-direction: column; }
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>