Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/VmManage.vue
T
lin 25d782b050
Build and Deploy Vue3 / build (push) Successful in 1m35s
Build and Deploy Vue3 / deploy (push) Successful in 1m5s
feat: 将页面添加分页
2026-03-21 17:37:06 +08:00

716 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="vm-manage-container">
<div class="page-header" v-if="!embedded">
<div class="header-left">
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
<div class="header-info">
<h3>虚拟机管理</h3>
<span class="sub-info" v-if="serviceName">主控服务{{ serviceName }}</span>
</div>
</div>
<div class="header-right">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建虚拟机</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建虚拟机</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<!-- 筛选 -->
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索虚拟机" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 130px" @change="handleSearch">
<el-option v-for="s in vmStatuses" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</div>
<!-- 虚拟机列表 -->
<el-table :data="vmList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="配置" min-width="200">
<template #default="{ row }">
<div class="vm-config">
<el-tag size="small" type="info" v-if="row.vcpu">{{ row.vcpu }}</el-tag>
<el-tag size="small" type="info" v-if="row.memory">{{ formatMemory(row.memory) }}</el-tag>
<el-tag size="small" type="info" v-if="row.system_size">{{ row.system_size }}GB盘</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="带宽" width="180">
<template #default="{ row }">
<span v-if="row.rx_bandwidth || row.tx_bandwidth">
{{ row.rx_bandwidth || 0 }} Mbps / {{ row.tx_bandwidth || 0 }} Mbps
</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="vmStatusType(row.status)" size="small">{{ vmStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column>
<el-table-column prop="user_id" label="用户" width="80" />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleGoDetail(row)">详情</el-button>
<el-button link type="success" size="small" @click="handlePower(row, 'start')" :disabled="row.status === 'running'">启动</el-button>
<el-button link type="warning" size="small" @click="handlePower(row, 'stop')" :disabled="row.status === 'stopped' || row.status === 'stop'">关机</el-button>
<el-dropdown trigger="click" @command="cmd => handleMoreAction(row, cmd)" style="margin-left: 4px">
<el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="reboot">重启</el-dropdown-item>
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
<el-dropdown-item command="resume" v-if="row.status === 'paused'">恢复</el-dropdown-item>
<el-dropdown-item command="rebuild" divided>重建</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exit_rescue">退出救援</el-dropdown-item>
<el-dropdown-item command="detail" divided>查看详情</el-dropdown-item>
<el-dropdown-item command="delete" divided style="color: #f56c6c">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
<!-- 创建弹窗 -->
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="800px" destroy-on-close>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
<el-form-item label="镜像" prop="image_id">
<div class="bind-selector-row">
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showCreateImageSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<div class="bind-selector-row">
<el-input :model-value="createForm.user_id ? `${createForm._userName || ''} (ID: ${createForm.user_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showUserSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.user_id" @click="createForm.user_id = 0; createForm._userName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-divider content-position="left">宿主机配置(二选一)</el-divider>
<el-form-item label="分配方式">
<el-radio-group v-model="hostMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="宿主机" v-if="hostMode === 'host'">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="宿主机组" v-if="hostMode === 'group'">
<div class="bind-selector-row">
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-divider content-position="left">资源配置</el-divider>
<div class="resource-row">
<div class="resource-item">
<span class="resource-label">* 内存</span>
<el-select v-model="memoryUnit" class="resource-unit-select">
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="resource-input" />
</div>
<div class="resource-item">
<span class="resource-label">* 系统盘</span>
<el-select v-model="diskUnit" class="resource-unit-select">
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" class="resource-input" />
</div>
</div>
<div class="resource-row">
<div class="resource-item">
<span class="resource-label">* CPU(核)</span>
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" class="resource-input" />
</div>
<div class="resource-item">
<span class="resource-label">下行带宽(Mbps)</span>
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" class="resource-input" />
</div>
<div class="resource-item">
<span class="resource-label">上行带宽(Mbps)</span>
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" class="resource-input" />
</div>
</div>
<el-divider content-position="left">网络配置(二选一)</el-divider>
<el-form-item label="IP分配方式">
<el-radio-group v-model="ipMode">
<el-radio value="num">按IP数量分配</el-radio>
<el-radio value="ids">选择网络IP</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="IP数量" v-if="ipMode === 'num'">
<el-input-number v-model="createForm.ip_num" :min="1" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%">
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" />
</el-select>
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络(仅显示未使用的网络)</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<!-- 重建弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="重建虚拟机" width="440px" destroy-on-close>
<el-alert title="重建会使用新镜像重置虚拟机原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-form label-width="100px">
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
<el-form-item label="新镜像" required>
<div class="bind-selector-row">
<el-input :model-value="rebuildImageId ? `镜像 #${rebuildImageId}${rebuildImageName ? ' - ' + rebuildImageName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showRebuildImageSelector = true" style="margin-left: 8px">选择</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重建</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="虚拟机详情" width="720px" destroy-on-close>
<div v-loading="detailLoading">
<el-descriptions :column="2" border v-if="currentDetail">
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="CPU">{{ currentDetail.vcpu }} 核</el-descriptions-item>
<el-descriptions-item label="内存">{{ formatMemory(currentDetail.memory) }}</el-descriptions-item>
<el-descriptions-item label="系统盘">{{ currentDetail.system_size }} GB</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="vmStatusType(currentDetail.status)" size="small">{{ vmStatusLabel(currentDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="下行带宽">{{ currentDetail.rx_bandwidth || 0 }} Mbps</el-descriptions-item>
<el-descriptions-item label="上行带宽">{{ currentDetail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
<el-descriptions-item label="镜像ID">{{ currentDetail.image_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentDetail.user_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="宿主机组ID">{{ currentDetail.host_group_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="IP" :span="2">{{ currentDetail.ip || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
</el-descriptions>
<!-- 网络信息 -->
<template v-if="currentDetail?.networks && currentDetail.networks.length">
<h4 style="margin: 16px 0 8px">🌐 网络</h4>
<el-table :data="currentDetail.networks" size="small" stripe border>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="address" label="地址" min-width="140" />
<el-table-column prop="gateway" label="网关" width="120" />
</el-table>
</template>
<!-- 数据卷信息 -->
<template v-if="currentDetail?.volumes && currentDetail.volumes.length">
<h4 style="margin: 16px 0 8px">💿 数据卷</h4>
<el-table :data="currentDetail.volumes" size="small" stripe border>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="大小" width="80">
<template #default="{ row }">{{ row.size }} GB</template>
</el-table-column>
<el-table-column label="系统卷" width="80">
<template #default="{ row }">{{ row.is_system ? '是' : '否' }}</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="180" show-overflow-tooltip />
</el-table>
</template>
<!-- 镜像信息 -->
<template v-if="currentDetail?.image">
<h4 style="margin: 16px 0 8px">🖼️ 镜像</h4>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="ID">{{ currentDetail.image.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.image.name }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ currentDetail.image.os_type }} / {{ currentDetail.image.type }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ currentDetail.image.status }}</el-descriptions-item>
</el-descriptions>
</template>
<div class="detail-actions" v-if="currentDetail">
<el-button size="small" type="primary" @click="fetchVmStatus(currentDetail)">刷新状态</el-button>
<el-button size="small" @click="fetchVmMetrics(currentDetail)">查看指标</el-button>
</div>
<!-- 指标 -->
<template v-if="vmMetricsData">
<h4 style="margin: 16px 0 8px">实时指标</h4>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="虚拟机">{{ vmMetricsData.vm_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="CPU使用率">
<span :style="{ color: vmMetricsData.cpu_usage_percent > 90 ? '#F56C6C' : vmMetricsData.cpu_usage_percent > 60 ? '#E6A23C' : '#67C23A' }">
{{ (vmMetricsData.cpu_usage_percent ?? 0).toFixed(1) }}%
</span>
</el-descriptions-item>
<template v-if="vmMetricsData.internet_speed && Object.keys(vmMetricsData.internet_speed).length">
<el-descriptions-item label="网络速率" :span="2">
<div v-for="(val, key) in vmMetricsData.internet_speed" :key="key">{{ key }}: {{ val }}</div>
</el-descriptions-item>
</template>
</el-descriptions>
</template>
</div>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 镜像选择器 (创建) -->
<ImageSelectorPopup v-model="showCreateImageSelector" :service-id="serviceId" :current-id="createForm.image_id" @confirm="handleCreateImageSelected" />
<!-- 镜像选择器 (重建) -->
<ImageSelectorPopup v-model="showRebuildImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="handleRebuildImageSelected" />
<!-- 宿主机组选择器 -->
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="serviceId" :current-id="createForm.host_group_id" @confirm="handleHostGroupSelected" />
<!-- 用户选择器 -->
<UserListSelector v-model="showUserSelector" :current-user-id="createForm.user_id" @confirm="handleUserSelected" />
</div>
</template>
<script setup>
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import {
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics,
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
import UserListSelector from '@/components/admin/UserListSelector.vue'
const route = useRoute()
const router = useRouter()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const injectedHostId = inject('hostId', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
const submitLoading = ref(false)
const detailLoading = ref(false)
const vmList = ref([])
const total = ref(0)
const keyword = ref('')
const filterStatus = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 })
// 选择器
const showCreateImageSelector = ref(false)
const showRebuildImageSelector = ref(false)
const showHostGroupSelector = ref(false)
const showUserSelector = ref(false)
// 创建表单模式切换
const hostMode = ref('host')
const ipMode = ref('num')
const networkOptions = ref([])
// 内存单位: API传输单位为 bytes
const memoryUnitOptions = [
{ label: 'MB', factor: 1024 },
{ label: 'GB', factor: 1048576 }
]
const memoryUnit = ref('GB')
const getMemFactor = () => memoryUnitOptions.find(u => u.label === memoryUnit.value)?.factor || 1
const memoryDisplay = computed({
get: () => Math.round(createForm.memory / getMemFactor()),
set: (v) => { createForm.memory = Math.round(v * getMemFactor()) }
})
// 系统盘: API传输单位为 GB
const diskUnitOptions = [
{ label: 'GB', factor: 1 },
{ label: 'TB', factor: 1024 }
]
const diskUnit = ref('GB')
const getDiskFactor = () => diskUnitOptions.find(u => u.label === diskUnit.value)?.factor || 1
const diskDisplay = computed({
get: () => {
const f = getDiskFactor()
return f === 1 ? createForm.system_size : +(createForm.system_size / f).toFixed(2)
},
set: (v) => { createForm.system_size = Math.round(v * getDiskFactor()) }
})
const loadNetworkOptions = async (hostId) => {
if (!hostId) return
try {
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, used: false, page: 1, page_size: 200 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
networkOptions.value = inner.networks || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { networkOptions.value = [] }
}
const getHostLabel = (hid) => {
const h = hostOptions.value.find(x => x.id === hid)
return h ? `${h.name}` : (hid || '-')
}
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
}
} catch (e) { console.error('加载宿主机列表失败:', e) }
}
const vmStatuses = [
{ label: '等待中', value: 'pending' }, { label: '创建中', value: 'creating' },
{ label: '就绪', value: 'ready' }, { label: '运行中', value: 'running' },
{ label: '已停止', value: 'stopped' }, { label: '已停止', value: 'stop' },
{ label: '错误', value: 'error' }, { label: '已暂停', value: 'paused' },
{ label: '重启中', value: 'reboot' }, { label: '已关机', value: 'poweroff' },
{ label: '未知', value: 'unknown' }
]
const createDialogVisible = ref(false)
const createFormRef = ref(null)
const rebuildDialogVisible = ref(false)
const detailVisible = ref(false)
const currentDetail = ref(null)
const rebuildTarget = ref(null)
const rebuildImageId = ref(0)
const rebuildImageName = ref('')
const vmMetricsData = ref(null)
const createForm = reactive({
name: '', host_id: null, image_id: 0, vcpu: 0, memory: 0,
system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0,
host_group_id: null, user_id: 0, ip_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: ''
})
const createRules = {
image_id: [{ required: true, message: '请选择镜像', trigger: 'blur', type: 'number', min: 1 }],
vcpu: [{ required: true, message: '请输入CPU核数', trigger: 'blur' }],
memory: [{ required: true, message: '请输入内存', trigger: 'blur' }],
system_size: [{ required: true, message: '请输入系统盘大小', trigger: 'blur' }],
user_id: [{ required: true, message: '请选择用户', trigger: 'change', type: 'number', min: 1 }]
}
const vmStatusType = (s) => ({
running: 'success', ready: 'success', creating: 'warning', pending: 'info',
stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning',
reboot: 'warning', poweroff: 'info', unknown: 'info'
}[s] || 'info')
const vmStatusLabel = (s) => ({
running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中',
stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停',
reboot: '重启中', poweroff: '已关机', unknown: '未知'
}[s] || s || '-')
const formatMemory = (kb) => {
if (!kb) return '-'
kb = Number(kb)
if (kb >= 1073741824) return (kb / 1073741824).toFixed(1) + ' TB'
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
return kb + ' KB'
}
const formatTimestamp = (ts) => {
if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) {
return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
}
if (typeof ts === 'string' || typeof ts === 'number') {
const d = new Date(ts)
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
}
return '-'
}
const formatBytesRaw = (val) => {
if (!val && val !== 0) return '-'
val = Number(val)
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
return val + ' B'
}
// 选择器回调
const handleCreateImageSelected = (img) => { createForm.image_id = img.id; createForm._imageName = img.name }
const handleRebuildImageSelected = (img) => { rebuildImageId.value = img.id; rebuildImageName.value = img.name }
const handleHostGroupSelected = (group) => { createForm.host_group_id = group.id; createForm._groupName = group.name || '' }
const handleUserSelected = (user) => { createForm.user_id = user.user_id || user.id; createForm._userName = user.user_name || user.name || '' }
const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
if (keyword.value) params.key = keyword.value
if (filterStatus.value) params.status = filterStatus.value
const res = await getVmList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
vmList.value = inner.data || inner.vms || (Array.isArray(inner) ? inner : [])
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? vmList.value.length
} else { vmList.value = []; total.value = 0 }
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取虚拟机列表失败')) } finally { loading.value = false }
}
const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
Object.assign(createForm, {
name: '', host_id: null, image_id: 0,
vcpu: 0, memory: 0, system_size: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: null, user_id: 0, ip_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: ''
})
memoryUnit.value = 'GB'
diskUnit.value = 'GB'
hostMode.value = 'host'
ipMode.value = 'num'
networkOptions.value = []
createDialogVisible.value = true
}
const submitCreate = () => {
if (hostMode.value === 'host' && !createForm.host_id) { ElMessage.warning('请选择宿主机'); return }
if (hostMode.value === 'group' && !createForm.host_group_id) { ElMessage.warning('请选择宿主机组'); return }
if (ipMode.value === 'ids' && !createForm.network_ids.length) { ElMessage.warning('请选择网络IP'); return }
if (ipMode.value === 'num' && !createForm.ip_num) { ElMessage.warning('请输入IP数量'); return }
createFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const payload = {
service_id: serviceId.value,
image_id: createForm.image_id,
vcpu: createForm.vcpu, memory: createForm.memory, system_size: createForm.system_size,
user_id: createForm.user_id
}
if (createForm.name) payload.name = createForm.name
if (createForm.rx_bandwidth) payload.rx_bandwidth = createForm.rx_bandwidth
if (createForm.tx_bandwidth) payload.tx_bandwidth = createForm.tx_bandwidth
if (hostMode.value === 'host') payload.host_id = createForm.host_id
else payload.host_group_id = createForm.host_group_id
if (ipMode.value === 'num') payload.ip_num = createForm.ip_num
else payload.network_ids = createForm.network_ids
const res = await createVm(payload)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) }
finally { submitLoading.value = false }
})
}
const handlePower = (row, action) => {
const labels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
ElMessageBox.confirm(`确定要${labels[action]}虚拟机「${row.name}」吗?`, `${labels[action]}确认`, {
confirmButtonText: '确定', cancelButtonText: '取消',
type: action === 'stop' ? 'warning' : 'info'
}).then(async () => {
try {
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
const payload = { service_id: serviceId.value, vm_id: row.id }
// resume uses FormData
let res
if (action === 'resume') {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', row.id)
res = await resumeVm(fd)
} else {
res = await apis[action](payload)
}
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadList() }
else ElMessage.error(extractApiError(res?.data, `${labels[action]}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${labels[action]}失败`)) }
}).catch(() => {})
}
const handleMoreAction = (row, command) => {
const powerActions = ['reboot', 'suspend', 'resume']
if (powerActions.includes(command)) { handlePower(row, command); return }
if (command === 'rebuild') handleRebuild(row)
else if (command === 'rescue') handleRescue(row)
else if (command === 'exit_rescue') handleExitRescue(row)
else if (command === 'detail') handleViewDetail(row)
else if (command === 'delete') handleDelete(row)
}
const handleRebuild = (row) => {
rebuildTarget.value = row
rebuildImageId.value = row.image_id || 0
rebuildImageName.value = ''
rebuildDialogVisible.value = true
}
const submitRebuild = async () => {
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
submitLoading.value = true
try {
const res = await rebuildVm({ service_id: serviceId.value, vm_id: rebuildTarget.value.id, image_id: rebuildImageId.value })
if (res?.data?.code === 200) { ElMessage.success('重建成功'); rebuildDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '重建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重建失败')) } finally { submitLoading.value = false }
}
const handleRescue = (row) => {
ElMessageBox.confirm(`确定让虚拟机「${row.name}」进入救援模式吗?`, '救援模式', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', row.id)
const res = await rescueVm(fd)
if (res?.data?.code === 200) { ElMessage.success('已进入救援模式'); loadList() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
}).catch(() => {})
}
const handleExitRescue = (row) => {
ElMessageBox.confirm(`确定让虚拟机「${row.name}」退出救援模式吗?`, '退出救援', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', row.id)
const res = await exitRescueVm(fd)
if (res?.data?.code === 200) { ElMessage.success('已退出救援模式'); loadList() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
}).catch(() => {})
}
const handleViewDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
currentDetail.value = row
vmMetricsData.value = null
try {
const res = await getVmDetail({ service_id: serviceId.value, vm_id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
// API may return data.vm nested, or data.data, or flat
currentDetail.value = d.vm ?? d.data ?? d
}
} catch { /* fallback */ } finally { detailLoading.value = false }
}
const fetchVmStatus = async (vm) => {
try {
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vm.id })
if (res?.data?.code === 200 && res?.data?.data) {
const statusData = res.data.data
currentDetail.value = { ...currentDetail.value, status: statusData.status ?? statusData }
ElMessage.success('状态已刷新: ' + vmStatusLabel(currentDetail.value.status))
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) }
}
const fetchVmMetrics = async (vm) => {
try {
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: vm.name })
if (res?.data?.code === 200) vmMetricsData.value = res.data.data?.data ?? res.data.data
else ElMessage.warning('暂无指标数据')
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取指标失败')) }
}
const handleGoDetail = (row) => {
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, vm_id: row.id } })
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除虚拟机「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteVm({ service_id: serviceId.value, vm_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(async () => {
if (serviceId.value) {
await loadHostOptions()
loadList()
}
})
</script>
<style scoped>
.vm-manage-container { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 16px; }
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
.sub-info { font-size: 13px; color: #909399; }
.header-right { display: flex; gap: 8px; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.vm-config { display: flex; gap: 4px; flex-wrap: wrap; }
.text-muted { color: #c0c4cc; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.detail-actions { margin-top: 16px; display: flex; gap: 8px; }
.bind-selector-row { display: flex; align-items: center; width: 100%; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
.resource-row { display: flex; gap: 20px; margin-bottom: 18px; }
.resource-item { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; }
.resource-label { white-space: nowrap; font-size: 14px; color: #606266; flex-shrink: 0; }
.resource-unit-select { width: 72px; flex-shrink: 0; }
.resource-input { flex: 1; min-width: 0; }
</style>