fix: 将迁移记录移入主控服务详情 #30

Merged
shiran merged 4 commits from master into deploy 2026-08-04 09:34:56 +08:00
10 changed files with 189 additions and 39 deletions
@@ -0,0 +1,64 @@
<template>
<el-dialog v-model="visible" title="选择用户商品" width="780px" append-to-body destroy-on-close @close="handleClose">
<div class="selector-toolbar">
<el-input v-model.trim="keyword" placeholder="搜索商品名称、备注或 ID" clearable @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
<el-alert v-if="userId" :title="`仅显示用户 #${userId} 的商品`" type="info" :closable="false" show-icon class="user-alert" />
<el-table :data="list" v-loading="loading" highlight-current-row stripe height="340" @current-change="selected = $event">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="商品" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ goodsName(row) }}</template>
</el-table-column>
<el-table-column label="用户" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ userName(row) }} <span class="muted">#{{ row.user_id || row.userId || '-' }}</span></template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100"><template #default="{ row }">{{ row.status || '-' }}</template></el-table-column>
<el-table-column prop="note" label="备注" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ row.note || '-' }}</template></el-table-column>
</el-table>
<el-empty v-if="!loading && !list.length" description="暂无用户商品" :image-size="60" />
<div class="pagination"><span v-if="selected">已选:{{ goodsName(selected) }} (#{{ selected.id }})</span><el-pagination v-model:current-page="page" v-model:page-size="count" :page-sizes="[10,20,50]" :total="total" layout="total,sizes,prev,pager,next" small @current-change="loadList" @size-change="handleSizeChange" /></div>
<template #footer><el-button @click="handleClose">取消</el-button><el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button></template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getUserGoodsList } from '@/api/admin/userVm'
const props = defineProps({ modelValue: { type: Boolean, default: false }, userId: { type: Number, default: 0 }, currentId: { type: Number, default: 0 } })
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false), loading = ref(false), list = ref([]), selected = ref(null), keyword = ref(''), page = ref(1), count = ref(10), total = ref(0)
const goodsName = row => row?.good?.name || row?.good_name || row?.name || `用户商品 #${row?.id || '-'}`
const userName = row => row?.user?.user_name || row?.user_name || '-'
watch(() => props.modelValue, value => { visible.value = value; if (value) { selected.value = null; page.value = 1; loadList() } })
watch(visible, value => emit('update:modelValue', value))
const loadList = async () => {
loading.value = true
try {
const params = { page: page.value, count: count.value }
if (keyword.value) params.key = keyword.value
if (props.userId) params.user_id = props.userId
const res = await getUserGoodsList(params)
if (res?.data?.code === 200) {
const data = res.data.data || {}
list.value = data.data || (Array.isArray(data) ? data : [])
total.value = data.meta?.count ?? data.all_count ?? data.total ?? list.value.length
selected.value = list.value.find(row => Number(row.id) === Number(props.currentId)) || null
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; loadList() }
const handleReset = () => { keyword.value = ''; page.value = 1; loadList() }
const handleSizeChange = () => { page.value = 1; loadList() }
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar{display:flex;gap:8px;margin-bottom:12px}.selector-toolbar .el-input{max-width:360px}.user-alert{margin-bottom:12px}.pagination{display:flex;align-items:center;justify-content:space-between;min-height:32px;margin-top:12px;color:#606266;font-size:13px}.muted{color:#909399;font-size:12px}
</style>
+30 -8
View File
@@ -2,9 +2,11 @@
<el-dialog v-model="visible" title="选择虚拟机" width="700px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-select v-model="hostIdFilter" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="loadList">
<el-input v-model.trim="keyword" placeholder="搜索虚拟机名称或 ID" clearable style="width:220px" @keyup.enter="handleSearch" @clear="handleSearch" />
<el-select v-model="hostIdFilter" placeholder="全部宿主机" clearable filterable style="width: 220px" @change="handleSearch">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
<el-button type="primary" @click="handleSearch">查询</el-button>
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<el-table-column prop="id" label="ID" width="70" />
@@ -20,6 +22,9 @@
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20,50]" :total="total" layout="total,sizes,prev,pager,next" small @current-change="loadList" @size-change="handleSizeChange" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
@@ -47,10 +52,21 @@ const list = ref([])
const selectedItem = ref(null)
const hostIdFilter = ref('')
const hostOptions = ref([])
const keyword = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) { loadHostOptions(); if (props.hostId) { hostIdFilter.value = props.hostId; loadList() } }
if (val) {
selectedItem.value = null
hostIdFilter.value = props.hostId || ''
keyword.value = ''
page.value = 1
loadHostOptions()
loadList()
}
})
watch(visible, (val) => emit('update:modelValue', val))
@@ -61,26 +77,31 @@ const loadHostOptions = async () => {
if (body?.code === 200 && body?.data) {
const inner = body.data
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
if (!hostIdFilter.value && hostOptions.value.length) hostIdFilter.value = hostOptions.value[0].id
if (hostIdFilter.value) loadList()
}
} catch { /* ignore */ }
}
const loadList = async () => {
if (!hostIdFilter.value) return
if (!props.serviceId) return
loading.value = true
try {
const res = await getVmList({ service_id: props.serviceId, host_id: hostIdFilter.value, page: 1, count: 10 })
const params = { service_id: props.serviceId, page: page.value, count: pageSize.value }
if (hostIdFilter.value) params.host_id = hostIdFilter.value
if (keyword.value) params.key = keyword.value
const res = await getVmList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
list.value = inner.data || (Array.isArray(inner) ? inner : [])
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? list.value.length
}
} catch { /* ignore */ }
} catch { list.value = []; total.value = 0 }
finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; loadList() }
const handleSizeChange = () => { page.value = 1; loadList() }
const formatMem = (kb) => {
if (!kb) return '-'
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
@@ -99,12 +120,13 @@ const handleConfirm = () => {
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
const handleClose = () => { selectedItem.value = null; list.value = []; total.value = 0 }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
-8
View File
@@ -556,14 +556,6 @@ const routes = [
title: 'VNC指令管理'
}
},
{
path: 'migration-record',
name: 'VmMigrationRecord',
component: () => import('../views/virtualization/VmMigrationRecord.vue'),
meta: {
title: '虚拟机迁移记录'
}
},
{
path: 'host-detail',
name: 'VirtHostDetail',
+2 -2
View File
@@ -612,7 +612,7 @@ const loadList = async () => {
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || (Array.isArray(d) ? d : [])
total.value = d.all_count ?? d.total ?? list.value.length
total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
@@ -825,7 +825,7 @@ const loadVmListForItem = async () => {
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
vmListForItem.value = d.data || (Array.isArray(d) ? d : [])
vmListTotal.value = d.all_count ?? d.total ?? vmListForItem.value.length
vmListTotal.value = d.meta?.count ?? d.all_count ?? d.total ?? vmListForItem.value.length
}
} catch { vmListForItem.value = [] } finally { vmListLoading.value = false }
}
+1 -4
View File
@@ -6,10 +6,7 @@
<el-divider direction="vertical" />
<span class="page-title">用户虚拟机详情</span>
</div>
<div>
<el-button plain @click="router.push({ path: '/virtualization/migration-record', query: { user_goods_id: userGoodsId } })">迁移记录</el-button>
<el-button :icon="Refresh" plain @click="loadDetail" :loading="loading">刷新</el-button>
</div>
<el-button :icon="Refresh" plain @click="loadDetail" :loading="loading">刷新</el-button>
</div>
<div class="main-content" v-loading="loading">
+2 -2
View File
@@ -1519,7 +1519,7 @@ const loadVmListForItem = async () => {
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
vmListForItem.value = d.data || (Array.isArray(d) ? d : [])
vmListTotal.value = d.all_count ?? d.total ?? vmListForItem.value.length
vmListTotal.value = d.meta?.count ?? d.all_count ?? d.total ?? vmListForItem.value.length
}
} catch { vmListForItem.value = [] } finally { vmListLoading.value = false }
}
@@ -1710,7 +1710,7 @@ const loadBindVmList = async () => {
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
bindVmList.value = d.data || (Array.isArray(d) ? d : [])
bindVmTotal.value = d.all_count ?? d.total ?? bindVmList.value.length
bindVmTotal.value = d.meta?.count ?? d.all_count ?? d.total ?? bindVmList.value.length
} else { bindVmList.value = []; bindVmTotal.value = 0 }
} catch { bindVmList.value = [] } finally { bindVmLoading.value = false }
}
+14 -2
View File
@@ -107,6 +107,9 @@
<el-tab-pane label="用户组网" name="networking">
<UserNetworkingManage v-if="tabLoaded['networking']" />
</el-tab-pane>
<el-tab-pane label="迁移记录" name="migration">
<VmMigrationRecord v-if="tabLoaded['migration']" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
@@ -161,6 +164,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 VmMigrationRecord = defineAsyncComponent(() => import('./VmMigrationRecord.vue'))
// tagsViewStore
import { useTagsViewStore } from '@/store/tagsViewStore'
@@ -193,7 +197,8 @@ const tabLoaded = reactive({
'vnc': false,
'snapshot': false,
'backup': false,
'networking': false
'networking': false,
'migration': false
})
@@ -347,8 +352,11 @@ const initPage = () => {
// tab 使
Object.keys(tabLoaded).forEach(k => { tabLoaded[k] = false })
const queryTab = route.query.tab
const savedTab = localStorage.getItem('kvmDetailActiveTab')
if (savedTab && tabLoaded.hasOwnProperty(savedTab)) {
if (queryTab && tabLoaded.hasOwnProperty(queryTab)) {
activeTab.value = queryTab
} else if (savedTab && tabLoaded.hasOwnProperty(savedTab)) {
activeTab.value = savedTab
} else {
activeTab.value = 'host'
@@ -583,4 +591,8 @@ onMounted(() => {
padding: 0;
}
.custom-tabs :deep(.migration-record-page) {
padding: 0;
}
</style>
+22 -2
View File
@@ -1769,7 +1769,10 @@ const tagsViewStore = useTagsViewStore()
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
const serviceName = computed(() => route.query.service_name || '')
const vmId = computed(() => parseInt(route.query.vm_id) || 0)
const goMigrationRecords = (query = {}) => router.push({ path: '/virtualization/migration-record', query })
const goMigrationRecords = (query = {}) => router.push({
path: '/virtualization/kvm-service-detail',
query: { service_id: serviceId.value, service_name: serviceName.value, tab: 'migration', ...query }
})
const goVmById = (targetServiceId, targetVmId) => router.push({ path: '/virtualization/vm-detail', query: { service_id: targetServiceId, vm_id: targetVmId } })
const loading = ref(false)
@@ -1997,6 +2000,19 @@ const loadHostOptions = async () => {
} catch { /* */ }
}
const readHostId = value => Number(value?.host_id ?? value?.hostId ?? value?.HostId ?? 0) || 0
const resolveVmHostId = responseData => {
const vm = responseData?.data ?? responseData?.vm ?? responseData
const volumes = responseData?.volumes || []
const networks = responseData?.networks || []
const systemVolume = volumes.find(volume => (volume.is_system ?? volume.isSystem) && readHostId(volume))
const anyVolume = volumes.find(volume => readHostId(volume))
const anyNetwork = networks.find(network => readHostId(network))
return readHostId(responseData) || readHostId(responseData?.host) || readHostId(vm) ||
readHostId(systemVolume) || readHostId(anyVolume) || readHostId(anyNetwork) ||
Number(route.query.host_id) || 0
}
const loadDetail = async () => {
if (!vmId.value) return
loading.value = true
@@ -2021,7 +2037,7 @@ const loadDetail = async () => {
}
vmPortGroup.value = d.in_port_group || null
vmOutPortGroup.value = d.out_port_group || null
vmHostId.value = detail.value?.host_id || vmVolumes.value[0]?.host_id || vmNetworks.value[0]?.host_id || 0
vmHostId.value = resolveVmHostId(d)
handleDetailMigrateState()
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
@@ -2159,6 +2175,10 @@ const vmMemPercent = (m) => {
const loadHistoricalMetrics = async () => {
if (!serviceId.value || !detail.value?.name) return
if (!vmHostId.value) {
ElMessage.warning('未获取到虚拟机所属宿主机,无法加载监控指标')
return
}
const range = getMonitorTimeRange()
if (!range) return
historicalMetricsLoading.value = true
+19 -1
View File
@@ -987,6 +987,18 @@ const handleExitRescue = (row) => {
}).catch(() => {})
}
const readHostId = value => Number(value?.host_id ?? value?.hostId ?? value?.HostId ?? 0) || 0
const resolveVmHostId = responseData => {
const vm = responseData?.data ?? responseData?.vm ?? responseData
const volumes = responseData?.volumes || []
const networks = responseData?.networks || []
const systemVolume = volumes.find(volume => (volume.is_system ?? volume.isSystem) && readHostId(volume))
const anyVolume = volumes.find(volume => readHostId(volume))
const anyNetwork = networks.find(network => readHostId(network))
return readHostId(responseData) || readHostId(responseData?.host) || readHostId(vm) ||
readHostId(systemVolume) || readHostId(anyVolume) || readHostId(anyNetwork) || 0
}
const handleViewDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
@@ -1001,6 +1013,7 @@ const handleViewDetail = async (row) => {
vm.volumes = d.volumes || []
vm.image = d.image || null
vm.in_port_group = d.in_port_group || null
vm.host_id = resolveVmHostId(d) || Number(hostId.value) || 0
if (!vm.ips && vm.networks?.length) vm.ips = vm.networks.map(n => n.address?.split('/')[0]).filter(Boolean).join(', ')
currentDetail.value = vm
}
@@ -1023,11 +1036,16 @@ const fetchVmStatus = async (vm) => {
const fetchVmMetrics = async (vm) => {
try {
const metricsHostId = readHostId(vm) || Number(hostId.value) || 0
if (!metricsHostId) {
ElMessage.warning('未获取到虚拟机所属宿主机,无法查看指标')
return
}
const now = new Date()
const start = new Date(now.getTime() - 60 * 60 * 1000)
const res = await getMetricsHistory({
service_id: serviceId.value,
host_id: vm.host_id || hostId.value,
host_id: metricsHostId,
vm_name: vm.name,
start: start.toISOString(),
end_time: now.toISOString(),
+35 -10
View File
@@ -10,6 +10,14 @@
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item label="关键词">
<el-input v-model.trim="filters.keyword" clearable placeholder="虚拟机、IP、宿主机、备注、任务或错误" style="width:300px" @keyup.enter="search" @clear="search">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="当前服务方向" v-if="embedded && serviceId">
<el-segmented v-model="serviceDirection" :options="[{ label: '作为源端', value: 'source' }, { label: '作为目标端', value: 'target' }]" @change="search" />
</el-form-item>
<el-form-item label="迁移类型">
<el-select v-model="filters.migration_type" clearable placeholder="全部" style="width:150px">
<el-option label="宿主机迁移" value="host_migrate" />
@@ -23,8 +31,12 @@
</el-form-item>
<el-form-item label="链路 ID"><el-input v-model.trim="filters.chain_id" clearable placeholder="chain_id" style="width:210px" /></el-form-item>
<el-form-item label="任务 ID"><el-input v-model.trim="filters.task_id" clearable placeholder="task_id" style="width:210px" /></el-form-item>
<el-form-item label="用户 ID"><el-input-number v-model="filters.user_id" :min="1" :controls="false" style="width:120px" /></el-form-item>
<el-form-item label="用户商品 ID"><el-input-number v-model="filters.user_goods_id" :min="1" :controls="false" style="width:130px" /></el-form-item>
<el-form-item label="用户">
<div class="selector-field"><el-input :model-value="filters.user_id ? `${filters._userName || '用户'} (#${filters.user_id})` : ''" readonly placeholder="请选择用户" /><el-button type="primary" @click="showUserSelector=true">选择</el-button><el-button v-if="filters.user_id" :icon="Close" circle title="清除用户" @click="clearUser" /></div>
</el-form-item>
<el-form-item label="用户商品">
<div class="selector-field"><el-input :model-value="filters.user_goods_id ? `${filters._userGoodsName || '用户商品'} (#${filters.user_goods_id})` : ''" readonly placeholder="请选择用户商品" /><el-button type="primary" @click="showUserGoodsSelector=true">选择</el-button><el-button v-if="filters.user_goods_id" :icon="Close" circle title="清除用户商品" @click="clearUserGoods" /></div>
</el-form-item>
<el-form-item label="开始时间">
<el-date-picker v-model="filters.started_range" type="datetimerange" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD HH:mm:ss" style="width:350px" />
</el-form-item>
@@ -84,21 +96,34 @@
</el-timeline><el-empty v-else description="暂无迁移链记录" />
</div>
</el-dialog>
<UserListSelector v-model="showUserSelector" :current-user-id="filters.user_id || 0" @confirm="handleUserSelected" />
<UserGoodsSelectorPopup v-model="showUserGoodsSelector" :user-id="filters.user_id || 0" :current-id="filters.user_goods_id || 0" @confirm="handleUserGoodsSelected" />
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { computed, inject, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import { Close, Refresh, Search } from '@element-plus/icons-vue'
import { getVmMigrationRecordList, getVmMigrationRecordDetail, getVmMigrationRecordChain } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import UserGoodsSelectorPopup from '@/components/admin/UserGoodsSelectorPopup.vue'
const router = useRouter()
const route = useRoute()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const serviceId = computed(() => injectedServiceId?.value || Number(route.query.service_id) || 0)
const serviceDirection = ref('source')
const loading = ref(false), records = ref([]), total = ref(0), page = ref(1), count = ref(20)
const filters = reactive({ migration_type: '', status: '', chain_id: '', task_id: '', user_id: null, user_goods_id: null, started_range: [] })
const filters = reactive({ keyword: '', migration_type: '', status: '', chain_id: '', task_id: '', user_id: null, user_goods_id: null, _userName: '', _userGoodsName: '', started_range: [] })
const showUserSelector = ref(false), showUserGoodsSelector = ref(false)
const clearUserGoods = () => { filters.user_goods_id = null; filters._userGoodsName = '' }
const clearUser = () => { filters.user_id = null; filters._userName = ''; clearUserGoods() }
const handleUserSelected = user => { const id = user.user_id || user.id; if (Number(filters.user_id) !== Number(id)) clearUserGoods(); filters.user_id = id; filters._userName = user.user_name || user.name || '' }
const handleUserGoodsSelected = goods => { filters.user_goods_id = goods.id; filters._userGoodsName = goods.good?.name || goods.good_name || goods.name || '' ; if (!filters.user_id && (goods.user_id || goods.userId)) filters.user_id = goods.user_id || goods.userId }
const statusOptions = ['pending','exporting','importing','running','success','failed','aborted','warning'].map(value => ({ value, label: ({ pending:'等待中',exporting:'导出中',importing:'导入中',running:'执行中',success:'成功',failed:'失败',aborted:'已中断',warning:'警告' })[value] }))
const statusLabel = s => statusOptions.find(x => x.value === s)?.label || s || '-'
const statusType = s => ({ success:'success', failed:'danger', aborted:'info', warning:'warning', exporting:'warning', importing:'primary', running:'primary', pending:'info' })[s] || 'info'
@@ -106,18 +131,18 @@ const typeLabel = t => ({ host_migrate:'宿主机迁移', data_migrate:'跨服
const formatTime = value => { if (!value) return '-'; const d = new Date(value); return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString('zh-CN', { hour12:false }) }
const shortId = value => !value ? '-' : (value.length > 18 ? `${value.slice(0,8)}${value.slice(-6)}` : value)
const durationText = row => { if (!row.started_at) return '-'; const end = row.completed_at ? new Date(row.completed_at) : new Date(); const sec = Math.max(0, Math.floor((end-new Date(row.started_at))/1000)); return sec >= 3600 ? `${Math.floor(sec/3600)}${Math.floor(sec%3600/60)}` : sec >= 60 ? `${Math.floor(sec/60)}${sec%60}` : `${sec}` }
const buildParams = () => { const p = { page:page.value, count:count.value }; ['migration_type','status','chain_id','task_id','user_id','user_goods_id'].forEach(k => { if (filters[k] !== '' && filters[k] != null) p[k]=filters[k] }); if (filters.started_range?.length===2) [p.started_at_start,p.started_at_end]=filters.started_range; return p }
const buildParams = () => { const p = { page:page.value, count:count.value }; ['keyword','migration_type','status','chain_id','task_id','user_id','user_goods_id'].forEach(k => { if (filters[k] !== '' && filters[k] != null) p[k]=filters[k] }); if (embedded && serviceId.value) p[`${serviceDirection.value}_service_id`] = serviceId.value; if (filters.started_range?.length===2) [p.started_at_start,p.started_at_end]=filters.started_range; return p }
const loadList = async () => { loading.value=true; try { const res=await getVmMigrationRecordList(buildParams()); if(res?.data?.code!==200) throw new Error(extractApiError(res?.data,'加载迁移记录失败')); const d=res.data.data||{}; records.value=d.data||[]; total.value=d.all_count||0 } catch(e){ ElMessage.error(e.message||extractApiError(e?.response?.data,'加载迁移记录失败')) } finally { loading.value=false } }
const search = () => { page.value=1; loadList() }, sizeChange = () => { page.value=1; loadList() }
const resetFilters = () => { Object.assign(filters,{ migration_type:'',status:'',chain_id:'',task_id:'',user_id:null,user_goods_id:null,started_range:[] }); search() }
const resetFilters = () => { Object.assign(filters,{ keyword:'',migration_type:'',status:'',chain_id:'',task_id:'',user_id:null,user_goods_id:null,_userName:'',_userGoodsName:'',started_range:[] }); search() }
const detailVisible=ref(false), detailLoading=ref(false), currentRecord=ref(null)
const snapshotFields=[{key:'request_snapshot',label:'请求参数'},{key:'before_snapshot',label:'迁移前快照'},{key:'after_snapshot',label:'迁移后快照'},{key:'manifest_snapshot',label:'迁移清单'},{key:'warning_snapshot',label:'警告'}]
const prettySnapshot = value => { if(!value) return '暂无数据'; try { return JSON.stringify(typeof value==='string'?JSON.parse(value):value,null,2) } catch { return String(value) } }
const openDetail = async row => { detailVisible.value=true; currentRecord.value=row; detailLoading.value=true; try { const res=await getVmMigrationRecordDetail({id:row.id}); if(res?.data?.code!==200) throw new Error(extractApiError(res?.data,'加载详情失败')); currentRecord.value=res.data.data?.record||res.data.data||row } catch(e){ ElMessage.error(e.message||extractApiError(e?.response?.data,'加载详情失败')) } finally { detailLoading.value=false } }
const chainVisible=ref(false), chainLoading=ref(false), chainData=reactive({chain_id:'',records:[]})
const openChain = async row => { chainVisible.value=true; chainLoading.value=true; Object.assign(chainData,{chain_id:row.chain_id||'',records:[]}); try { const params=row.chain_id?{chain_id:row.chain_id}:{id:row.id}; const res=await getVmMigrationRecordChain(params); if(res?.data?.code!==200) throw new Error(extractApiError(res?.data,'加载迁移链失败')); Object.assign(chainData,res.data.data||{}) } catch(e){ ElMessage.error(e.message||extractApiError(e?.response?.data,'加载迁移链失败')) } finally { chainLoading.value=false } }
const goVm=(serviceId,vmId)=>router.push({path:'/virtualization/vm-detail',query:{service_id:serviceId,vm_id:vmId}})
const goHost=(serviceId,hostId)=>router.push({path:'/virtualization/host-detail',query:{service_id:serviceId,host_id:hostId}})
const goVm=(targetServiceId,vmId)=>router.push({path:'/virtualization/vm-detail',query:{service_id:targetServiceId,vm_id:vmId}})
const goHost=(targetServiceId,hostId)=>router.push({path:'/virtualization/host-detail',query:{service_id:targetServiceId,host_id:hostId}})
const goUser=userId=>router.push({path:'/user/detail',query:{user_id:userId}})
const goUserGoods=id=>router.push(`/user-goods/detail/${id}`)
onMounted(async () => {
@@ -129,5 +154,5 @@ onMounted(async () => {
</script>
<style scoped>
.migration-record-page{padding:20px}.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-header h2{font-size:20px;margin:0 0 6px}.page-header p{margin:0;color:#86909c;font-size:13px}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:8px}.endpoint{display:flex;flex-direction:column;align-items:flex-start;gap:3px}.mono{font-family:Consolas,monospace;font-size:12px}.muted{font-size:12px;color:#86909c;margin-top:3px}.pagination{display:flex;justify-content:flex-end;margin-top:16px}.snapshot-tabs{margin-top:18px}.json-view{margin:0;padding:14px;max-height:460px;overflow:auto;background:#f7f8fa;border:1px solid #ebeef5;border-radius:6px;white-space:pre-wrap;word-break:break-all;font:12px/1.6 Consolas,monospace}.error-text{color:#f56c6c}.chain-id{margin-bottom:20px;color:#606266}.chain-card-title{display:flex;justify-content:space-between;align-items:center;font-weight:600}.chain-route{margin:10px 0;color:#606266;font-size:13px}
.migration-record-page{padding:20px}.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-header h2{font-size:20px;margin:0 0 6px}.page-header p{margin:0;color:#86909c;font-size:13px}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:8px}.selector-field{display:flex;gap:6px;width:300px}.selector-field .el-input{flex:1}.endpoint{display:flex;flex-direction:column;align-items:flex-start;gap:3px}.mono{font-family:Consolas,monospace;font-size:12px}.muted{font-size:12px;color:#86909c;margin-top:3px}.pagination{display:flex;justify-content:flex-end;margin-top:16px}.snapshot-tabs{margin-top:18px}.json-view{margin:0;padding:14px;max-height:460px;overflow:auto;background:#f7f8fa;border:1px solid #ebeef5;border-radius:6px;white-space:pre-wrap;word-break:break-all;font:12px/1.6 Consolas,monospace}.error-text{color:#f56c6c}.chain-id{margin-bottom:20px;color:#606266}.chain-card-title{display:flex;justify-content:space-between;align-items:center;font-weight:600}.chain-route{margin:10px 0;color:#606266;font-size:13px}
</style>