diff --git a/src/api/admin/kvmService.js b/src/api/admin/kvmService.js
index f9b6dcf..b0fd376 100644
--- a/src/api/admin/kvmService.js
+++ b/src/api/admin/kvmService.js
@@ -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 })
+}
+
/**
* ================================
* 主控服务接口 - 安全组管理
diff --git a/src/api/admin/product.js b/src/api/admin/product.js
index 082c7e9..310edb9 100644
--- a/src/api/admin/product.js
+++ b/src/api/admin/product.js
@@ -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})
-}
\ No newline at end of file
+}
diff --git a/src/router/index.js b/src/router/index.js
index 656d932..241e24b 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -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',
diff --git a/src/views/product/components/ProductParameterManager.vue b/src/views/product/components/ProductParameterManager.vue
index 9b8d38d..af65ce0 100644
--- a/src/views/product/components/ProductParameterManager.vue
+++ b/src/views/product/components/ProductParameterManager.vue
@@ -37,6 +37,10 @@
{{ param.name }}
必选
+
+
+ {{ param.hidden ? '用户端隐藏' : '用户端显示' }}
+
#{{ param.id }}
@@ -119,7 +123,10 @@
>
#{{ idx }}
-
{{ attr.name }}
+
+ {{ attr.name }}
+ 用户端隐藏
+
{{ getRangeTypeText(attr.rangeType) }}
@@ -173,6 +180,10 @@
+
+
+ 隐藏后仅管理员可见,用户端商品、套餐及已购商品接口不会返回该参数
+
权限控制
@@ -315,6 +326,10 @@
+
+
+ 隐藏后该参数值不会出现在用户端商品及套餐配置中
+
@@ -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;
diff --git a/src/views/user-vm/UserVmDetail.vue b/src/views/user-vm/UserVmDetail.vue
index 086f220..86d986f 100644
--- a/src/views/user-vm/UserVmDetail.vue
+++ b/src/views/user-vm/UserVmDetail.vue
@@ -6,7 +6,10 @@
用户虚拟机详情
- 刷新
+
+ 迁移记录
+ 刷新
+
@@ -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 }
}
diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue
index 8af83d4..aabae1d 100644
--- a/src/views/virtualization/VmDetail.vue
+++ b/src/views/virtualization/VmDetail.vue
@@ -6,6 +6,7 @@
返回虚拟机列表
@@ -123,6 +124,13 @@
+
配置信息
@@ -143,6 +151,18 @@
-
+
+ 资源阈值
+
+
+
+ {{ item.label }} {{ formatThreshold(resourceThresholdPolicy, item.key) }}
+
+
+ 全部关闭
+ 配置
+
+
@@ -759,6 +779,34 @@
+
+
+
+
+ 某项百分比和持续时间均为 0 时关闭;启用时百分比范围为 (0, 100],持续时间为 1~86400 秒。
+
+
+
+
+ {{ item.label }}
+
+
+ %
+
+
+
+ 秒
+
+
+
+
+
+
+ 取消
+ 保存策略
+
+
+
@@ -1153,7 +1201,7 @@
-
+
@@ -1170,6 +1218,13 @@
个
+
+
+ {{ n.name || n.id }} (ID:{{ n.id }})
+ 选择网络
+
+ 可选;指定目标宿主机上要分配的网络。
+
@@ -1179,6 +1234,7 @@
+
@@ -1251,6 +1307,12 @@
{{ migrateStageLabel(dataMigrateProgressData.stage) }}
+
+ #{{ dataMigrateProgressData.migration_record_id }}
+
+
+ {{ dataMigrateProgressData.chain_id }}
+
{{ dataMigrateProgressData.export_task_id }}
@@ -1261,6 +1323,12 @@
{{ dataMigrateProgressData.target_service_id ?? '-' }}
{{ dataMigrateProgressData.source_host_id ?? '-' }}
{{ dataMigrateProgressData.target_host_id ?? '-' }}
+
+ VM #{{ dataMigrateProgressData.source_vm_id }}
+
+
+ VM #{{ dataMigrateProgressData.imported_vm_id }}
+
@@ -1270,6 +1338,9 @@
{{ dataMigrateProgressData.error }}
+
+ {{ warning }}
+
@@ -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; }
diff --git a/src/views/virtualization/VmManage.vue b/src/views/virtualization/VmManage.vue
index bad8ec8..653c8bd 100644
--- a/src/views/virtualization/VmManage.vue
+++ b/src/views/virtualization/VmManage.vue
@@ -244,6 +244,26 @@
+
+
+
资源阈值策略
+
+ 某项百分比和持续时间均为 0 时关闭该项;启用后持续超限将自动暂停虚拟机。
+
+
+
+ {{ item.label }}
+
+
+ %
+
+
+
+ 秒
+
+
+
+