feat: 支持商品参数隐藏配置 #29

Merged
shiran merged 2 commits from master into deploy 2026-08-04 08:09:37 +08:00
8 changed files with 472 additions and 29 deletions
+25
View File
@@ -411,6 +411,16 @@ export const getVmDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/detail', { params })
}
export const getVmResourceThresholdPolicy = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/resource_threshold_policy', { params })
}
export const updateVmResourceThresholdPolicy = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/resource_threshold_policy/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 获取虚拟机状态 */
export const getVmStatus = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/status', { params })
@@ -547,6 +557,21 @@ export const abortDataMigrate = (data) => {
})
}
/** 获取虚拟机迁移记录列表 */
export const getVmMigrationRecordList = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/migration_record/list', { params })
}
/** 获取虚拟机迁移记录详情 */
export const getVmMigrationRecordDetail = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/migration_record/detail', { params })
}
/** 获取虚拟机迁移链 */
export const getVmMigrationRecordChain = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/migration_record/chain', { params })
}
/**
* ================================
* 主控服务接口 - 安全组管理
+13 -6
View File
@@ -102,7 +102,9 @@ export const getProductParameterList = (params) => {
}
/**创建商品参数 */
export const createProductParameter = (data) => {
return http2.post('/api/v1/admin/good/spec/create', data,{
const {good_id, ...payload} = data
return http2.post('/api/v1/admin/good/spec/create', payload,{
params: {good_id},
headers:{
'Content-Type':'multipart/form-data'
}
@@ -114,8 +116,9 @@ export const getProductParameterDetail = (params) => {
}
/**更新商品参数 */
export const updateProductParameter = (data) => {
return http2.post('/api/v1/admin/good/spec/update', null, {
params: data,
const {good_id, ...payload} = data
return http2.post('/api/v1/admin/good/spec/update', payload, {
params: {good_id},
headers:{
'Content-Type':'multipart/form-data'
}
@@ -129,7 +132,9 @@ export const deleteProductParameter = (data) => {
}
/**增加商品参数值 */
export const addProductParameterValue = (data) => {
return http2.post('/api/v1/admin/good/spec/add_value', data,{
const {good_id, ...payload} = data
return http2.post('/api/v1/admin/good/spec/add_value', payload,{
params: {good_id},
headers:{
'Content-Type':'multipart/form-data'
}
@@ -143,7 +148,9 @@ export const deleteProductParameterValue = (data) => {
}
/**更新商品参数值 */
export const updateProductParameterValue = (data) => {
return http2.post('/api/v1/admin/good/spec/update_value', data,{
const {good_id, ...payload} = data
return http2.post('/api/v1/admin/good/spec/update_value', payload,{
params: {good_id},
headers:{
'Content-Type':'multipart/form-data'
}
@@ -257,4 +264,4 @@ export const deleteProductGroupTag = (params) => {
/**获取用户已购商品列表 */
export const getUserGoodsList = (params) => {
return http2.get('/api/v1/admin/good/user_goods/list', {params: params})
}
}
+8
View File
@@ -556,6 +556,14 @@ const routes = [
title: 'VNC指令管理'
}
},
{
path: 'migration-record',
name: 'VmMigrationRecord',
component: () => import('../views/virtualization/VmMigrationRecord.vue'),
meta: {
title: '虚拟机迁移记录'
}
},
{
path: 'host-detail',
name: 'VirtHostDetail',
@@ -37,6 +37,10 @@
</el-tag>
<span class="param-name" :title="param.name">{{ param.name }}</span>
<el-tag v-if="param.must" type="danger" size="small" effect="plain" class="title-must-tag">必选</el-tag>
<el-tag :type="param.hidden ? 'warning' : 'success'" size="small" effect="plain" class="title-visibility-tag">
<el-icon><component :is="param.hidden ? Hide : View" /></el-icon>
{{ param.hidden ? '用户端隐藏' : '用户端显示' }}
</el-tag>
<span class="param-id">#{{ param.id }}</span>
</div>
<div class="param-card-actions">
@@ -119,7 +123,10 @@
>
<el-icon class="value-drag-handle"><Rank /></el-icon>
<span class="value-order">#{{ idx }}</span>
<span class="value-name" :title="attr.name">{{ attr.name }}</span>
<div class="value-name-cell">
<span class="value-name" :title="attr.name">{{ attr.name }}</span>
<el-tag v-if="attr.hidden" size="small" type="warning" effect="plain" class="value-hidden-tag">用户端隐藏</el-tag>
</div>
<span class="value-range">
<el-tag v-if="param.type === 'number'" size="small" type="info">
{{ getRangeTypeText(attr.rangeType) }}
@@ -173,6 +180,10 @@
<el-form-item label="是否必选" prop="must">
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
</el-form-item>
<el-form-item label="对用户隐藏">
<el-switch v-model="paramForm.hidden" active-text="隐藏" inactive-text="显示" />
<div class="form-tip">隐藏后仅管理员可见用户端商品套餐及已购商品接口不会返回该参数</div>
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">权限控制</div>
@@ -315,6 +326,10 @@
<el-form-item label="价格" prop="attr_price">
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
</el-form-item>
<el-form-item label="对用户隐藏">
<el-switch v-model="paramValueForm.hidden" active-text="隐藏" inactive-text="显示" />
<div class="form-tip">隐藏后该参数值不会出现在用户端商品及套餐配置中</div>
</el-form-item>
</div>
</el-form>
<template #footer>
@@ -332,7 +347,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Refresh, Rank, MagicStick,
Edit, Delete, Collection, List, Money,
ShoppingCart, User, Ticket
ShoppingCart, User, Ticket, Hide, View
} from '@element-plus/icons-vue'
import {
getProductParameterList,
@@ -369,6 +384,7 @@ const paramForm = reactive({
arg_name: '',
arg_type: 'string',
must: false,
hidden: false,
arg_step: 1,
arg_min: 0,
arg_max: 100,
@@ -450,7 +466,8 @@ const paramValueForm = reactive({
attr_range: 0,
attr_range_display: 0,
display_unit: '',
range_type: 'equal'
range_type: 'equal',
hidden: false
})
const paramValueRules = {
attr_name: [{ required: true, message: '请输入值名称', trigger: 'blur' }]
@@ -609,7 +626,8 @@ const reorderAttrs = async (param, src, dst) => {
attr_name: item.name,
attr_value: item.value || '',
attr_price: Number(item.price) / 100 || 0,
index: newIndex
index: newIndex,
hidden: item.hidden === true
}
if (param?.type === 'number') {
payload.attr_range = item.phase ?? 0
@@ -685,7 +703,7 @@ const handleAddParameter = () => {
paramFormType.value = 'add'
paramFormDialogVisible.value = true
Object.assign(paramForm, {
arg_id: undefined, arg_name: '', arg_type: 'string', must: false,
arg_id: undefined, arg_name: '', arg_type: 'string', must: false, hidden: false,
arg_step: 1, arg_min: 0, arg_max: 100,
step_display: 1, min_display: 0, max_display: 100,
step_unit: '', min_unit: '', max_unit: '',
@@ -714,7 +732,7 @@ const handleEditParameter = (row) => {
maxDisplay = fromBaseUnit(baseMax, defaultUnit, argKey)
}
Object.assign(paramForm, {
arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false,
arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false, hidden: row.hidden === true,
arg_step: baseStep, arg_min: baseMin, arg_max: baseMax,
step_display: stepDisplay, min_display: minDisplay, max_display: maxDisplay,
step_unit: defaultUnit, min_unit: defaultUnit, max_unit: defaultUnit,
@@ -745,6 +763,7 @@ const submitParamForm = () => {
arg_name: paramForm.arg_name,
arg_type: paramForm.arg_type,
must: paramForm.must === true,
hidden: paramForm.hidden === true,
user_add: paramForm.user_add === true,
use_user_group_discount: paramForm.use_user_group_discount === true,
use_user_discount: paramForm.use_user_discount === true
@@ -781,7 +800,7 @@ const handleAddParamValue = (param) => {
: 0
nextTick(() => {
paramValueFormRef.value?.resetFields()
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: nextIndex, attr_range: 0, attr_range_display: 0, display_unit: defaultUnit, range_type: 'equal' })
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: nextIndex, attr_range: 0, attr_range_display: 0, display_unit: defaultUnit, range_type: 'equal', hidden: false })
})
}
@@ -801,7 +820,7 @@ const handleEditParamValue = (row, param) => {
attr_id: row.id, attr_name: row.name, attr_value: row.value || '',
attr_price: row.price / 100 || 0, index: row.index || 0,
attr_range: baseValue, attr_range_display: displayValue,
display_unit: displayUnit, range_type: row.rangeType || 'equal'
display_unit: displayUnit, range_type: row.rangeType || 'equal', hidden: row.hidden === true
})
}
@@ -820,7 +839,7 @@ const submitParamValueForm = () => {
paramValueFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = { good_id: Number(props.goodId), arg_id: Number(currentParam.value.id), attr_name: paramValueForm.attr_name, index: Number(paramValueForm.index), attr_price: Number(paramValueForm.attr_price) }
const submitData = { good_id: Number(props.goodId), arg_id: Number(currentParam.value.id), attr_name: paramValueForm.attr_name, index: Number(paramValueForm.index), attr_price: Number(paramValueForm.attr_price), hidden: paramValueForm.hidden === true }
if (currentParam.value.type === 'select') submitData.attr_value = paramValueForm.attr_value
if (currentParam.value.type === 'number') {
let rangeValue
@@ -933,6 +952,8 @@ watch(() => props.visible, (val) => {
}
.title-type-tag { flex-shrink: 0; }
.param-name {
flex: 1;
min-width: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
@@ -941,6 +962,8 @@ watch(() => props.visible, (val) => {
text-overflow: ellipsis;
}
.title-must-tag { flex-shrink: 0; }
.title-visibility-tag { flex-shrink: 0; display: inline-flex !important; align-items: center; gap: 3px; }
.title-visibility-tag .el-icon { font-size: 12px; }
.param-id {
color: #909399;
font-size: 12px;
@@ -1081,14 +1104,17 @@ watch(() => props.visible, (val) => {
font-family: 'Monaco', 'Menlo', monospace;
text-align: center;
}
.value-name-cell { min-width: 0; display: flex; align-items: center; gap: 6px; }
.value-name {
color: #303133;
font-size: 13px;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value-hidden-tag { flex-shrink: 0; }
.value-range { min-width: 0; }
.value-raw {
color: #606266;
+10 -2
View File
@@ -6,7 +6,10 @@
<el-divider direction="vertical" />
<span class="page-title">用户虚拟机详情</span>
</div>
<el-button :icon="Refresh" plain @click="loadDetail" :loading="loading">刷新</el-button>
<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>
</div>
<div class="main-content" v-loading="loading">
@@ -2096,7 +2099,12 @@ const submitMigrateVm = async () => {
if (migrateForm.ipv4_num) payload.ipv4_num = migrateForm.ipv4_num
if (migrateForm.ipv6_num) payload.ipv6_num = migrateForm.ipv6_num
const res = await migrateUserVm(payload)
if (res?.data?.code === 200) { ElMessage.success('迁移成功'); migrateVisible.value = false; loadDetail() }
if (res?.data?.code === 200) {
const migrationData = res.data.data || {}
ElMessage.success(`迁移已提交${migrationData.migration_record_id ? `,记录 #${migrationData.migration_record_id}` : ''}`)
migrateVisible.value = false
loadDetail()
}
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
}
+186 -10
View File
@@ -6,6 +6,7 @@
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回虚拟机列表</el-button>
</div>
<div class="header-right">
<el-button plain @click="goMigrationRecords">迁移记录</el-button>
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
</div>
</div>
@@ -123,6 +124,13 @@
<!-- 标签页 -->
<el-tabs v-model="activeTab" class="detail-tabs" v-if="detail">
<el-tab-pane label="实例详情" name="info">
<el-alert
v-if="detail.pause_info"
:title="detail.pause_info.reason_text || pauseReasonLabel(detail.pause_info.reason_code)"
:type="detail.pause_info.reason_code === 'resource_threshold' ? 'warning' : 'info'"
:description="formatPauseViolations(detail.pause_info)"
show-icon :closable="false" style="margin-bottom: 16px"
/>
<!-- 配置信息 -->
<div class="section-block">
<h3 class="section-title">配置信息</h3>
@@ -143,6 +151,18 @@
<span v-else>-</span>
</span>
</div>
<div class="config-cell">
<span class="config-label">资源阈值</span>
<span class="config-value threshold-summary">
<template v-for="item in thresholdResources" :key="item.key">
<el-tag v-if="isThresholdEnabled(resourceThresholdPolicy, item.key)" size="small" type="warning">
{{ item.label }} {{ formatThreshold(resourceThresholdPolicy, item.key) }}
</el-tag>
</template>
<span v-if="!hasEnabledThreshold" style="color:#909399">全部关闭</span>
<el-button link type="primary" size="small" @click="openThresholdDialog" :disabled="isMigrating">配置</el-button>
</span>
</div>
</div>
<div class="config-row">
<div class="config-cell">
@@ -759,6 +779,34 @@
</el-tabs>
</div>
<!-- 资源阈值策略弹窗 -->
<el-dialog v-model="thresholdDialogVisible" title="资源阈值策略" width="680px" destroy-on-close class="tk-dialog">
<div v-loading="thresholdLoading">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
<template #title>某项百分比和持续时间均为 0 时关闭启用时百分比范围为 (0, 100]持续时间为 186400 </template>
</el-alert>
<el-form ref="thresholdFormRef" :model="thresholdForm" :rules="thresholdRules" label-width="100px">
<div class="threshold-policy-grid">
<template v-for="item in thresholdResources" :key="item.key">
<div class="threshold-policy-title">{{ item.label }}</div>
<el-form-item label="使用率阈值" :prop="`${item.key}_threshold_percent`">
<el-input-number v-model="thresholdForm[`${item.key}_threshold_percent`]" :min="0" :max="100" :precision="2" controls-position="right" />
<span class="form-unit">%</span>
</el-form-item>
<el-form-item label="持续时间" :prop="`${item.key}_threshold_duration_seconds`">
<el-input-number v-model="thresholdForm[`${item.key}_threshold_duration_seconds`]" :min="0" :max="86400" :precision="0" controls-position="right" />
<span class="form-unit"></span>
</el-form-item>
</template>
</div>
</el-form>
</div>
<template #footer>
<el-button @click="thresholdDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="thresholdSubmitLoading" @click="submitThresholdPolicy">保存策略</el-button>
</template>
</el-dialog>
<!-- 电源操作确认弹窗 -->
<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">
@@ -1153,7 +1201,7 @@
</el-radio-group>
</el-form-item>
<el-form-item label="目标宿主机" v-if="migrateMode === 'host'">
<el-select v-model="migrateForm.target_host_id" placeholder="选择目标宿主机" filterable style="width: 100%">
<el-select v-model="migrateForm.target_host_id" placeholder="选择目标宿主机" filterable style="width: 100%" @change="clearMigrateNetworks">
<el-option v-for="h in migrateHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === detail?.host_id" />
</el-select>
</el-form-item>
@@ -1170,6 +1218,13 @@
<el-input-number v-model="migrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit"></span>
</el-form-item>
</div>
<el-form-item label="目标网络" v-if="migrateMode === 'host'">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%">
<el-tag v-for="n in migrateSelectedNetworks" :key="n.id" closable @close="removeMigrateNetwork(n.id)">{{ n.name || n.id }} (ID:{{ n.id }})</el-tag>
<el-button size="small" :disabled="!migrateForm.target_host_id" @click="showMigrateNetworkSelector = true">选择网络</el-button>
</div>
<span class="tk-form-tip">可选指定目标宿主机上要分配的网络</span>
</el-form-item>
</div>
</el-form>
<template #footer>
@@ -1179,6 +1234,7 @@
</div>
</template>
</el-dialog>
<NetworkSelectorPopup v-model="showMigrateNetworkSelector" :service-id="serviceId" :host-id="migrateForm.target_host_id || 0" filter-type="bridge" filter-used="false" :multiple="true" @confirm="handleMigrateNetworkConfirm" />
<!-- 数据迁移弹窗 -->
<el-dialog v-model="dataMigrateVisible" title="发起虚拟机数据迁移" width="600px" destroy-on-close class="tk-dialog">
@@ -1251,6 +1307,12 @@
<el-descriptions-item label="阶段" :span="2">
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</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>
</el-descriptions-item>
<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="导出任务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>
@@ -1261,6 +1323,12 @@
<el-descriptions-item label="目标主控服务">{{ dataMigrateProgressData.target_service_id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="源宿主机">{{ dataMigrateProgressData.source_host_id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="目标宿主机">{{ dataMigrateProgressData.target_host_id ?? '-' }}</el-descriptions-item>
<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>
<el-descriptions-item label="进度" :span="2" v-if="dataMigrateProgressData.progress != null">
<el-progress :percentage="dataMigrateProgressData.progress" :status="migrateProgressBarStatus(dataMigrateProgressData.stage)" style="width:100%" />
</el-descriptions-item>
@@ -1270,6 +1338,9 @@
<el-descriptions-item label="错误" :span="2" v-if="dataMigrateProgressData.error">
<span style="color:#f56c6c">{{ dataMigrateProgressData.error }}</span>
</el-descriptions-item>
<el-descriptions-item label="警告" :span="2" v-if="dataMigrateProgressData.warnings?.length">
<div v-for="(warning, index) in dataMigrateProgressData.warnings" :key="index" style="color:#e6a23c">{{ warning }}</div>
</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!dataMigrateProgressLoading" description="暂无进度信息" :image-size="60" />
</div>
@@ -1659,7 +1730,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown, Plus, Search, WarningFilled, Loading, Edit } from '@element-plus/icons-vue'
import {
getVmDetail, getVmStatus,
getVmDetail, getVmStatus, getVmResourceThresholdPolicy, updateVmResourceThresholdPolicy,
startVm, stopVm, rebootVm, suspendVm, resumeVm,
rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm,
getVmVnc, getVncNodeList,
@@ -1698,6 +1769,8 @@ 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 goVmById = (targetServiceId, targetVmId) => router.push({ path: '/virtualization/vm-detail', query: { service_id: targetServiceId, vm_id: targetVmId } })
const loading = ref(false)
const actionLoading = ref(false)
@@ -1719,6 +1792,80 @@ const showImageSelector = ref(false)
const activeTab = ref('info')
const showPassword = ref(false)
const thresholdResources = [
{ key: 'cpu', label: 'CPU' },
{ key: 'memory', label: '内存' },
{ key: 'bandwidth', label: '带宽' }
]
const emptyThresholdPolicy = () => Object.fromEntries(thresholdResources.flatMap(({ key }) => [
[`${key}_threshold_percent`, 0], [`${key}_threshold_duration_seconds`, 0]
]))
const resourceThresholdPolicy = computed(() => detail.value?.resource_threshold_policy || emptyThresholdPolicy())
const isThresholdEnabled = (policy, key) => Number(policy?.[`${key}_threshold_percent`] || 0) > 0 && Number(policy?.[`${key}_threshold_duration_seconds`] || 0) > 0
const hasEnabledThreshold = computed(() => thresholdResources.some(({ key }) => isThresholdEnabled(resourceThresholdPolicy.value, key)))
const formatThreshold = (policy, key) => `${Number(policy?.[`${key}_threshold_percent`] || 0)}% / ${Number(policy?.[`${key}_threshold_duration_seconds`] || 0)}`
const pauseReasonLabel = (code) => ({ manual: '管理员手动暂停', resource_threshold: '资源阈值触发暂停', external: '外部原因暂停' }[code] || '虚拟机已暂停')
const formatPauseViolations = (pauseInfo) => {
const labels = { cpu: 'CPU', memory: '内存', bandwidth: '带宽' }
const directions = { rx: '下行', tx: '上行' }
const violations = pauseInfo?.violations || []
if (!violations.length) return pauseInfo?.paused_at ? `暂停时间:${formatTimestamp(pauseInfo.paused_at)}` : ''
return violations.map(v => `${labels[v.resource] || v.resource}${directions[v.direction] ? `${directions[v.direction]}` : ''}:实际 ${v.observed_percent}% / 阈值 ${v.threshold_percent}%,持续 ${v.duration_seconds}`).join('')
}
const thresholdDialogVisible = ref(false)
const thresholdLoading = ref(false)
const thresholdSubmitLoading = ref(false)
const thresholdFormRef = ref(null)
const thresholdForm = reactive(emptyThresholdPolicy())
const validateThresholdPair = (rule, value, callback) => {
const key = rule.field.replace(/_threshold_(percent|duration_seconds)$/, '')
const percent = Number(thresholdForm[`${key}_threshold_percent`] || 0)
const duration = Number(thresholdForm[`${key}_threshold_duration_seconds`] || 0)
if ((percent === 0) !== (duration === 0)) callback(new Error('阈值与持续时间必须同时填写或同时为 0'))
else callback()
}
const thresholdRules = {}
thresholdResources.forEach(({ key }) => {
thresholdRules[`${key}_threshold_percent`] = [{ validator: validateThresholdPair, trigger: 'change' }]
thresholdRules[`${key}_threshold_duration_seconds`] = [{ validator: validateThresholdPair, trigger: 'change' }]
})
const openThresholdDialog = async () => {
thresholdDialogVisible.value = true
thresholdLoading.value = true
try {
const res = await getVmResourceThresholdPolicy({ service_id: serviceId.value, vm_id: vmId.value })
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '加载阈值策略失败'))
Object.assign(thresholdForm, emptyThresholdPolicy(), res.data.data || {})
} catch (e) {
Object.assign(thresholdForm, emptyThresholdPolicy(), resourceThresholdPolicy.value)
ElMessage.error(e?.message || extractApiError(e?.response?.data, '加载阈值策略失败'))
} finally { thresholdLoading.value = false }
}
const submitThresholdPolicy = () => {
thresholdFormRef.value?.validate(async valid => {
if (!valid) return
thresholdSubmitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
thresholdResources.forEach(({ key }) => {
fd.append(`${key}_threshold_percent`, thresholdForm[`${key}_threshold_percent`])
fd.append(`${key}_threshold_duration_seconds`, thresholdForm[`${key}_threshold_duration_seconds`])
})
const res = await updateVmResourceThresholdPolicy(fd)
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '保存阈值策略失败'))
if (detail.value) detail.value.resource_threshold_policy = res.data.data || { ...thresholdForm }
ElMessage.success('资源阈值策略已更新')
thresholdDialogVisible.value = false
} catch (e) { ElMessage.error(e?.message || extractApiError(e?.response?.data, '保存阈值策略失败')) }
finally { thresholdSubmitLoading.value = false }
})
}
const isWindows = computed(() => vmImage.value?.os_type === 'windows')
const extractIp = (addr) => addr ? addr.split('/')[0] : ''
@@ -2520,10 +2667,21 @@ const migrateOptionsLoading = ref(false)
const migrateMode = ref('host')
const migrateHostOptions = ref([])
const migrateGroupOptions = ref([])
const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
const showMigrateNetworkSelector = ref(false)
const migrateSelectedNetworks = ref([])
const clearMigrateNetworks = () => { migrateSelectedNetworks.value = []; migrateForm.network_ids = [] }
const handleMigrateNetworkConfirm = items => {
const list = Array.isArray(items) ? items : [items]
const ids = new Set(migrateSelectedNetworks.value.map(item => item.id))
list.forEach(item => { if (!ids.has(item.id)) migrateSelectedNetworks.value.push(item) })
migrateForm.network_ids = migrateSelectedNetworks.value.map(item => item.id)
}
const removeMigrateNetwork = id => { migrateSelectedNetworks.value = migrateSelectedNetworks.value.filter(item => item.id !== id); migrateForm.network_ids = migrateSelectedNetworks.value.map(item => item.id) }
const handleMigrateVm = async () => {
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
migrateSelectedNetworks.value = []
migrateMode.value = 'host'
migrateDialogVisible.value = true
migrateOptionsLoading.value = true
@@ -2555,8 +2713,14 @@ const submitMigrateVm = async () => {
else fd.append('target_host_group_id', migrateForm.target_host_group_id)
if (migrateForm.ipv4_num) fd.append('ipv4_num', migrateForm.ipv4_num)
if (migrateForm.ipv6_num) fd.append('ipv6_num', migrateForm.ipv6_num)
migrateForm.network_ids.forEach(id => fd.append('network_ids', id))
const res = await migrateVm(fd)
if (res?.data?.code === 200) { ElMessage.success('迁移成功'); migrateDialogVisible.value = false; loadDetail() }
if (res?.data?.code === 200) {
const migrationData = res.data.data || {}
ElMessage.success(`迁移已提交${migrationData.migration_record_id ? `,记录 #${migrationData.migration_record_id}` : ''}`)
migrateDialogVisible.value = false
loadDetail()
}
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
}
@@ -2572,6 +2736,8 @@ const dataMigrateProgressLoading = ref(false)
const dataMigrateProgressData = ref(null)
const dataMigrationId = ref('')
const dataMigrateTaskId = ref('')
const dataMigrationRecordId = ref(null)
const dataMigrationChainId = ref('')
const dataMigrateForm = reactive({ target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
const dataMigrateNetworkOptions = ref([])
const showDataMigrateNetworkSelector = ref(false)
@@ -2658,6 +2824,8 @@ const submitDataMigrate = async () => {
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 || ''
dataMigrateVisible.value = false
dataMigrateProgressVisible.value = true
loadDataMigrateProgress()
@@ -2666,7 +2834,7 @@ const submitDataMigrate = async () => {
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '发起迁移失败')) } finally { actionLoading.value = false }
}
const MIGRATE_DONE_STAGES = ['completed', 'done', 'failed', 'aborted', 'cancelled', 'error']
const MIGRATE_DONE_STAGES = ['completed', 'done', 'success', 'warning', 'failed', 'aborted', 'cancelled', 'error']
const loadDataMigrateProgress = async () => {
if (!dataMigrationId.value && !dataMigrateTaskId.value) return
@@ -2679,6 +2847,8 @@ const loadDataMigrateProgress = async () => {
if (res?.data?.code === 200 && res.data.data) {
dataMigrateProgressData.value = res.data.data
const d = res.data.data
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 || ''
@@ -2686,7 +2856,8 @@ const loadDataMigrateProgress = async () => {
const stage = d.stage
if (MIGRATE_DONE_STAGES.includes(stage)) {
stopMigratePolling()
if (stage === 'completed' || stage === 'done') ElMessage.success('数据迁移已完成')
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 || '数据迁移失败')
loadDetail()
}
@@ -2708,20 +2879,20 @@ const isMigrating = computed(() => {
const migrateStageLabel = (stage) => ({
exporting: '导出中', importing: '导入中', transferring: '传输中',
verifying: '校验中', completed: '已完成', done: '已完成',
verifying: '校验中', completed: '已完成', done: '已完成', success: '成功', warning: '已完成(有警告)', running: '执行中',
failed: '失败', error: '错误', aborted: '已中断', cancelled: '已取消',
pending: '等待中', preparing: '准备中'
}[stage] || stage || '-')
const migrateStageType = (stage) => ({
exporting: 'warning', importing: 'warning', transferring: '',
verifying: '', completed: 'success', done: 'success',
verifying: '', completed: 'success', done: 'success', success: 'success', warning: 'warning', running: 'primary',
failed: 'danger', error: 'danger', aborted: 'info', cancelled: 'info',
pending: 'info', preparing: 'info'
}[stage] || 'info')
const migrateProgressBarStatus = (stage) => {
if (stage === 'completed' || stage === 'done') return 'success'
if (stage === 'completed' || stage === 'done' || stage === 'success') return 'success'
if (stage === 'failed' || stage === 'error') return 'exception'
return ''
}
@@ -4036,6 +4207,11 @@ onMounted(() => { isPageActive = true; initPage() })
.traffic-cell .traffic-cycle { color: #c0c4cc; margin-left: 2px; }
.traffic-cell .traffic-actions { display: inline-flex; gap: 6px; margin-top: 2px; }
.traffic-cell .traffic-actions .cfg-edit-btn { margin-left: 0; }
.threshold-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.threshold-policy-grid { display: grid; grid-template-columns: 70px 1fr 1fr; gap: 8px 12px; align-items: start; }
.threshold-policy-title { padding-top: 9px; font-size: 14px; font-weight: 600; color: #303133; }
.threshold-policy-grid :deep(.el-form-item) { margin-bottom: 14px; }
.form-unit { margin-left: 8px; color: #86909c; }
.spec-value { font-size: 13px; color: #4e5969; }
.ip-value { color: #165dff; font-weight: 500; }
.password-cell { display: flex; align-items: center; gap: 8px; }
+62 -2
View File
@@ -244,6 +244,26 @@
</div>
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">资源阈值策略</div>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 14px">
<template #title>某项百分比和持续时间均为 0 时关闭该项启用后持续超限将自动暂停虚拟机</template>
</el-alert>
<div class="threshold-grid">
<template v-for="item in thresholdResources" :key="item.key">
<div class="threshold-name">{{ item.label }}</div>
<el-form-item :label="`${item.label}阈值`" :prop="`${item.key}_threshold_percent`">
<el-input-number v-model="createForm[`${item.key}_threshold_percent`]" :min="0" :max="100" :precision="2" controls-position="right" />
<span class="tk-res-unit">%</span>
</el-form-item>
<el-form-item label="持续时间" :prop="`${item.key}_threshold_duration_seconds`">
<el-input-number v-model="createForm[`${item.key}_threshold_duration_seconds`]" :min="0" :max="86400" :precision="0" controls-position="right" />
<span class="tk-res-unit"></span>
</el-form-item>
</template>
</div>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
@@ -651,9 +671,26 @@ const createForm = reactive({
system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, data_volume_size: 0,
host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: '',
traffic_max: 0, traffic_exhausted_rx_mbps: 0, traffic_exhausted_tx_mbps: 0
traffic_max: 0, traffic_exhausted_rx_mbps: 0, traffic_exhausted_tx_mbps: 0,
cpu_threshold_percent: 0, cpu_threshold_duration_seconds: 0,
memory_threshold_percent: 0, memory_threshold_duration_seconds: 0,
bandwidth_threshold_percent: 0, bandwidth_threshold_duration_seconds: 0
})
const thresholdResources = [
{ key: 'cpu', label: 'CPU' },
{ key: 'memory', label: '内存' },
{ key: 'bandwidth', label: '带宽' }
]
const validateThresholdPair = (rule, value, callback) => {
const key = rule.field.replace(/_threshold_(percent|duration_seconds)$/, '')
const percent = Number(createForm[`${key}_threshold_percent`] || 0)
const duration = Number(createForm[`${key}_threshold_duration_seconds`] || 0)
if ((percent === 0) !== (duration === 0)) callback(new Error('阈值与持续时间必须同时填写或同时为 0'))
else callback()
}
const createRules = {
image_id: [{ required: true, message: '请选择镜像', trigger: 'blur', type: 'number', min: 1 }],
vcpu: [{ required: true, message: '请输入CPU核数', trigger: 'blur' }],
@@ -661,6 +698,10 @@ const createRules = {
system_size: [{ required: true, message: '请输入系统盘大小', trigger: 'blur' }],
user_id: [{ required: true, message: '请选择用户', trigger: 'change', type: 'number', min: 1 }]
}
thresholdResources.forEach(({ key }) => {
createRules[`${key}_threshold_percent`] = [{ validator: validateThresholdPair, trigger: 'change' }]
createRules[`${key}_threshold_duration_seconds`] = [{ validator: validateThresholdPair, trigger: 'change' }]
})
const vmStatusType = (s) => ({
running: 'success', ready: 'success', creating: 'warning', pending: 'info',
@@ -790,7 +831,10 @@ const handleAdd = () => {
vcpu: 0, memory: 0, system_size: 0,
rx_bandwidth: 0, tx_bandwidth: 0, data_volume_size: 0,
host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: ''
_imageName: '', _groupName: '', _userName: '',
cpu_threshold_percent: 0, cpu_threshold_duration_seconds: 0,
memory_threshold_percent: 0, memory_threshold_duration_seconds: 0,
bandwidth_threshold_percent: 0, bandwidth_threshold_duration_seconds: 0
})
memoryUnit.value = 'GB'
diskUnit.value = 'GB'
@@ -840,6 +884,10 @@ const submitCreate = () => {
if (createForm.traffic_max > 0) fd.append('traffic_max', createForm.traffic_max)
if (createForm.traffic_exhausted_rx_mbps > 0) fd.append('traffic_exhausted_rx_mbps', createForm.traffic_exhausted_rx_mbps)
if (createForm.traffic_exhausted_tx_mbps > 0) fd.append('traffic_exhausted_tx_mbps', createForm.traffic_exhausted_tx_mbps)
thresholdResources.forEach(({ key }) => {
fd.append(`${key}_threshold_percent`, createForm[`${key}_threshold_percent`])
fd.append(`${key}_threshold_duration_seconds`, createForm[`${key}_threshold_duration_seconds`])
})
const res = await createVm(fd)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
@@ -1111,4 +1159,16 @@ defineExpose({ loadList })
color: #409eff;
margin-right: 4px;
}
.threshold-grid {
display: grid;
grid-template-columns: 76px minmax(220px, 1fr) minmax(220px, 1fr);
gap: 8px 12px;
align-items: start;
}
.threshold-name { padding-top: 9px; font-size: 14px; font-weight: 600; color: #303133; }
.threshold-grid :deep(.el-form-item) { margin-bottom: 14px; }
@media (max-width: 760px) {
.threshold-grid { grid-template-columns: 1fr; }
.threshold-name { padding-top: 4px; }
}
</style>
@@ -0,0 +1,133 @@
<template>
<div class="migration-record-page">
<div class="page-header">
<div>
<h2>虚拟机迁移记录</h2>
<p>查看宿主机迁移跨服务数据迁移及完整迁移链路</p>
</div>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item label="迁移类型">
<el-select v-model="filters.migration_type" clearable placeholder="全部" style="width:150px">
<el-option label="宿主机迁移" value="host_migrate" />
<el-option label="跨服务数据迁移" value="data_migrate" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width:140px">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</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="开始时间">
<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>
<el-form-item>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="records" v-loading="loading" stripe>
<el-table-column prop="id" label="记录 ID" width="90" />
<el-table-column label="类型" width="130"><template #default="{ row }"><el-tag :type="row.migration_type === 'data_migrate' ? 'primary' : 'info'">{{ typeLabel(row.migration_type) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="105"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="源端" min-width="180">
<template #default="{ row }"><div class="endpoint"><span>服务 #{{ row.source_service_id || '-' }}</span><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="180">
<template #default="{ row }"><div class="endpoint"><span>服务 #{{ row.target_service_id || '-' }}</span><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></div></template>
</el-table-column>
<el-table-column label="关联对象" min-width="145">
<template #default="{ row }"><div class="endpoint"><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.operator_admin_id">管理员 #{{ row.operator_admin_id }}</span></div></template>
</el-table-column>
<el-table-column label="链路" min-width="155"><template #default="{ row }"><el-link type="primary" class="mono" @click="openChain(row)">{{ shortId(row.chain_id) }}</el-link><div class="muted">序号 {{ row.chain_seq || 1 }}</div></template></el-table-column>
<el-table-column label="耗时" width="110"><template #default="{ row }">{{ durationText(row) }}</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="125" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" @click="openChain(row)">迁移链</el-button></template></el-table-column>
</el-table>
<div class="pagination"><el-pagination v-model:current-page="page" v-model:page-size="count" :page-sizes="[10,20,50,100]" :total="total" layout="total, sizes, prev, pager, next" @current-change="loadList" @size-change="sizeChange" /></div>
</el-card>
<el-drawer v-model="detailVisible" title="迁移记录详情" size="760px" destroy-on-close>
<div v-loading="detailLoading" v-if="currentRecord">
<el-descriptions :column="2" border>
<el-descriptions-item label="记录 ID">{{ currentRecord.id }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusType(currentRecord.status)">{{ statusLabel(currentRecord.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="迁移类型">{{ typeLabel(currentRecord.migration_type) }}</el-descriptions-item><el-descriptions-item label="迁移链序号">{{ currentRecord.chain_seq || 1 }}</el-descriptions-item>
<el-descriptions-item label="链路 ID" :span="2"><span class="mono">{{ currentRecord.chain_id || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="任务 ID" :span="2"><span class="mono">{{ currentRecord.task_id || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="导出任务"><span class="mono">{{ currentRecord.export_task_id || '-' }}</span></el-descriptions-item><el-descriptions-item label="导入任务"><span class="mono">{{ currentRecord.import_task_id || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="开始时间">{{ formatTime(currentRecord.started_at) }}</el-descriptions-item><el-descriptions-item label="完成时间">{{ formatTime(currentRecord.completed_at) }}</el-descriptions-item>
<el-descriptions-item v-if="currentRecord.error_message" label="错误信息" :span="2"><span class="error-text">{{ currentRecord.error_message }}</span></el-descriptions-item>
</el-descriptions>
<el-tabs class="snapshot-tabs">
<el-tab-pane v-for="item in snapshotFields" :key="item.key" :label="item.label"><pre class="json-view">{{ prettySnapshot(currentRecord[item.key]) }}</pre></el-tab-pane>
</el-tabs>
</div>
</el-drawer>
<el-dialog v-model="chainVisible" title="迁移链路" width="820px" destroy-on-close>
<div v-loading="chainLoading">
<div class="chain-id">链路 ID<span class="mono">{{ chainData.chain_id || '-' }}</span></div>
<el-timeline v-if="chainData.records?.length">
<el-timeline-item v-for="item in chainData.records" :key="item.id" :type="statusType(item.status)" :timestamp="formatTime(item.started_at || item.CreatedAt)" placement="top">
<el-card shadow="never"><div class="chain-card-title"><span>#{{ item.id }} · {{ item.chain_seq || 1 }} · {{ typeLabel(item.migration_type) }}</span><el-tag :type="statusType(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag></div><div class="chain-route">服务 {{ item.source_service_id || '-' }} / VM {{ item.source_vm_id || '-' }} 服务 {{ item.target_service_id || '-' }} / VM {{ item.target_vm_id || '-' }}</div><el-button link type="primary" @click="chainVisible=false; openDetail(item)">查看详情</el-button></el-card>
</el-timeline-item>
</el-timeline><el-empty v-else description="暂无迁移链记录" />
</div>
</el-dialog>
</div>
</template>
<script setup>
import { 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 { getVmMigrationRecordList, getVmMigrationRecordDetail, getVmMigrationRecordChain } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
const router = useRouter()
const route = useRoute()
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 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'
const typeLabel = t => ({ host_migrate:'宿主机迁移', data_migrate:'跨服务数据迁移' })[t] || t || '-'
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 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 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 goUser=userId=>router.push({path:'/user/detail',query:{user_id:userId}})
const goUserGoods=id=>router.push(`/user-goods/detail/${id}`)
onMounted(async () => {
if (route.query.chain_id) filters.chain_id = String(route.query.chain_id)
if (route.query.user_goods_id) filters.user_goods_id = Number(route.query.user_goods_id)
await loadList()
if (route.query.id) openDetail({ id: Number(route.query.id) })
})
</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}
</style>