Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/VmManage.vue
T

1011 lines
50 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="180">
<template #default="{ row }">
<template v-if="row.migrating">
<div class="migrate-inline-status">
<span class="migrate-inline-label">迁移中</span>
<el-progress
v-if="migrateProgressMap[row.id] != null"
:percentage="Math.min(migrateProgressMap[row.id], 100)"
:stroke-width="6" :show-text="false" status="warning"
style="flex:1;min-width:50px"
/>
<span class="migrate-inline-pct" v-if="migrateProgressMap[row.id] != null">{{ migrateProgressMap[row.id] }}%</span>
</div>
</template>
<el-tag v-else :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: 10px;margin-top: 4.5px">
<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 class="tk-dialog">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<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>
</div>
<div class="tk-section">
<div class="tk-section-title">宿主机配置</div>
<template v-if="isEmbeddedHost">
<el-form-item label="宿主机">
<el-input :model-value="embeddedHostLabel" disabled />
</el-form-item>
</template>
<template v-else>
<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="onHostChange">
<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>
</template>
</div>
<div class="tk-section">
<div class="tk-section-title">资源配置</div>
<div class="tk-resource-grid">
<el-form-item label="CPU" required>
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span>
</el-form-item>
<el-form-item label="内存" required>
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
<el-select v-model="memoryUnit" class="tk-unit-select">
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</el-form-item>
<el-form-item label="系统盘" required>
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
<el-select v-model="diskUnit" class="tk-unit-select">
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</el-form-item>
<el-form-item label="下行带宽">
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
<el-form-item label="上行带宽">
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
<el-form-item label="流量上限">
<el-input-number v-model="createForm.traffic_max" :min="0" controls-position="right" /><span class="tk-res-unit">MB</span>
<div style="font-size:12px;color:#909399;margin-top:2px">0 表示不限流量</div>
</el-form-item>
<el-form-item label="耗尽下行限速">
<el-input-number v-model="createForm.traffic_exhausted_rx_mbps" :min="0" :precision="2" controls-position="right" /><span class="tk-res-unit">Mbps0 不限)</span>
</el-form-item>
<el-form-item label="耗尽上行限速">
<el-input-number v-model="createForm.traffic_exhausted_tx_mbps" :min="0" :precision="2" controls-position="right" /><span class="tk-res-unit">Mbps0 不限)</span>
</el-form-item>
</div>
<el-form-item label="额外数据卷">
<div style="display:flex;align-items:center;gap:6px">
<el-input-number v-model="createForm.data_volume_size" :min="0" controls-position="right" style="width:200px" />
<span class="tk-res-unit">GB</span>
<span style="font-size:12px;color:#909399;margin-left:8px">0 表示不创建</span>
</div>
</el-form-item>
<el-alert type="warning" :closable="false" show-icon style="margin-top:4px">
<template #title>系统盘建议不低于 30 GB,否则可能无法重装 Windows 系统</template>
</el-alert>
</div>
<div class="tk-section">
<div class="tk-section-title">网络配置</div>
<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>
<div class="tk-resource-grid" v-if="ipMode === 'num'">
<el-form-item label="IPv4数量">
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
</el-form-item>
<el-form-item label="IPv6数量">
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
</el-form-item>
</div>
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
<div style="display:flex;align-items:center;gap:8px;width:100%">
<el-button :disabled="!createForm.host_id" @click="showCreateNetSelector = true">选择网络</el-button>
<span v-if="createNetSelected.length" style="font-size:13px;color:#606266">已选 {{ createNetSelected.length }} 个</span>
<span v-else style="font-size:13px;color:#909399">{{ createForm.host_id ? '未选择' : '请先选择宿主机' }}</span>
</div>
<div v-if="createNetSelected.length" style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px">
<el-tag v-for="n in createNetSelected" :key="n.id" closable size="small" @close="removeCreateNet(n.id)">
{{ n.name || n.bridge_name || n.id }} - {{ n.address || '' }} (ID:{{ n.id }})
</el-tag>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
</div>
</template>
</el-dialog>
<NetworkSelectorPopup ref="createNetSelectorRef" v-model="showCreateNetSelector"
:service-id="serviceId" :host-id="createForm.host_id || 0"
filter-type="bridge" filter-used="false" :multiple="true"
@confirm="handleCreateNetConfirm" @create="handleCreateNetCreate" />
<!-- 创建网络弹窗 -->
<el-dialog v-model="netCreateVisible" title="创建网络" width="600px" destroy-on-close append-to-body class="tk-dialog">
<el-form ref="netCreateFormRef" :model="netCreateForm" :rules="netCreateRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name"><el-input v-model="netCreateForm.name" placeholder="网络名称" /></el-form-item>
<el-form-item label="网络类型" prop="type">
<el-select v-model="netCreateForm.type" style="width: 100%">
<el-option label="网桥(Bridge/外网)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
</el-form-item>
<el-form-item label="IP" prop="address">
<div style="display:flex;align-items:center;gap:8px;width:100%">
<el-input v-model="netCreateForm.address" placeholder="例如 192.168.1.0/24" />
<span style="color:#909399;white-space:nowrap">CIDR</span>
</div>
</el-form-item>
<el-form-item label="网关地址" prop="gateway"><el-input v-model="netCreateForm.gateway" placeholder="例如 192.168.1.1" /></el-form-item>
<el-form-item label="DNS 服务器"><el-input v-model="netCreateForm.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" /></el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">高级配置</div>
<el-form-item label="MAC 地址"><el-input v-model="netCreateForm.mac_address" placeholder="不填则随机" /></el-form-item>
<el-form-item label="虚拟网桥名"><el-input v-model="netCreateForm.bridge_name" placeholder="不填使用默认" /></el-form-item>
<el-form-item label="逻辑网桥名"><el-input v-model="netCreateForm.ls_bridge_name" placeholder="不填使用默认" /></el-form-item>
<el-form-item label="逻辑端口名"><el-input v-model="netCreateForm.ls_name" placeholder="不填使用默认" /></el-form-item>
</div>
</el-form>
<template #footer>
<div style="display:flex;justify-content:flex-end;gap:8px">
<el-button @click="netCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="netCreateLoading" @click="submitNetCreate">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 重装弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<div class="tk-section">
<div class="tk-section-title">重装信息</div>
<el-alert title="重装会使用新镜像重置虚拟机原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<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>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重装</el-button>
</div>
</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="状态">
<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="SSH端口">{{ currentDetail.ssh_port || '-' }}</el-descriptions-item>
<el-descriptions-item label="IP" :span="2">{{ currentDetail.ips || '-' }}</el-descriptions-item>
<el-descriptions-item label="UUID" :span="2">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ currentDetail.uuid || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Mate Data ID" :span="2">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ currentDetail.mate_data_id || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Root密码" v-if="currentDetail.root_password">
<span style="font-family: Consolas, monospace">{{ currentDetail.root_password }}</span>
</el-descriptions-item>
<el-descriptions-item label="流量上限" v-if="currentDetail.traffic_max">{{ currentDetail.traffic_max }} GB</el-descriptions-item>
<el-descriptions-item label="快照上限" v-if="currentDetail.snapshot_num">{{ currentDetail.snapshot_num }}</el-descriptions-item>
<el-descriptions-item label="备份上限" v-if="currentDetail.backup_num">{{ currentDetail.backup_num }}</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">最新指标 <span style="font-size:12px; font-weight:400; color:#86909c">{{ formatBucket(vmMetricsData.bucket) }}</span></h4>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="CPU使用率">
<span :style="{ color: (vmMetricsData.cpu_usage ?? 0) > 90 ? '#F56C6C' : (vmMetricsData.cpu_usage ?? 0) > 60 ? '#E6A23C' : '#67C23A' }">
{{ (vmMetricsData.cpu_usage ?? 0).toFixed(1) }}%
</span>
</el-descriptions-item>
<el-descriptions-item label="内存">{{ formatMemKB(vmMetricsData.mem_used) }} / {{ formatMemKB(vmMetricsData.mem_total) }}</el-descriptions-item>
<el-descriptions-item label="磁盘读取">{{ formatBytesRaw(vmMetricsData.disk_read) }}</el-descriptions-item>
<el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item>
<el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item>
<el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item>
<el-descriptions-item label="累计流量">{{ vmMetricsData.traffic_used_mb != null ? vmMetricsData.traffic_used_mb + ' MB' : '-' }}</el-descriptions-item>
</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" />
<!-- 电源操作确认弹窗 -->
<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">
<el-icon :size="22" :style="{ color: powerAction === 'stop' ? '#F56C6C' : powerAction === 'reboot' ? '#E6A23C' : '#409EFF', flexShrink: 0, marginTop: '2px' }">
<WarningFilled />
</el-icon>
<div>
<div style="font-size: 15px; font-weight: 500; color: #303133; margin-bottom: 12px">
确定要{{ powerLabels[powerAction] }}虚拟机「{{ powerRow?.name }}」吗?
</div>
<el-checkbox v-model="powerForce" style="margin-bottom: 4px">强制执行</el-checkbox>
<div style="font-size: 12px; color: #909399; padding-left: 24px">勾选后将强制{{ powerLabels[powerAction] }},可能导致数据丢失</div>
</div>
</div>
<template #footer>
<el-button @click="powerDialogVisible = false">取消</el-button>
<el-button :type="powerAction === 'stop' ? 'danger' : 'primary'" @click="submitPower">确定{{ powerLabels[powerAction] }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
import {
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList, createNetwork, getMetricsHistory,
getDataMigrateProgress
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
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 injectedHostDetail = inject('hostDetail', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0)
const isEmbeddedHost = computed(() => !!(embedded && injectedHostId?.value))
const embeddedHostLabel = computed(() => {
const d = injectedHostDetail?.value
if (!d) return `宿主机 (ID: ${injectedHostId?.value || ''})`
return `${d.name || ''} (${d.ip || d.id || ''})`
})
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([])
const showCreateNetSelector = ref(false)
const createNetSelectorRef = ref(null)
const createNetSelected = 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 (hid) => {
if (!hid) return
try {
const params = { service_id: serviceId.value, host_id: hid, used: false, page: 1, count: 10 }
if (injectedHostId?.value) params.type = 'bridge'
const res = await getNetworkList(params)
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 onHostChange = (v) => {
createNetSelected.value = []
createForm.network_ids = []
loadNetworkOptions(v)
}
const handleCreateNetConfirm = (items) => {
const arr = Array.isArray(items) ? items : [items]
const existIds = new Set(createNetSelected.value.map(n => n.id))
arr.forEach(n => { if (!existIds.has(n.id)) createNetSelected.value.push(n) })
createForm.network_ids = createNetSelected.value.map(n => n.id)
}
const removeCreateNet = (id) => {
createNetSelected.value = createNetSelected.value.filter(n => n.id !== id)
createForm.network_ids = createNetSelected.value.map(n => n.id)
}
const netCreateVisible = ref(false)
const netCreateLoading = ref(false)
const netCreateFormRef = ref(null)
const netCreateForm = reactive({ name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '' })
const netCreateRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
address: [{ required: true, message: '请输入 CIDR 格式网段', trigger: 'blur' }],
gateway: [{ required: true, message: '请输入网关', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }]
}
const handleCreateNetCreate = () => {
Object.assign(netCreateForm, { name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '' })
netCreateVisible.value = true
}
const submitNetCreate = () => {
netCreateFormRef.value?.validate(async (valid) => {
if (!valid) return
netCreateLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', netCreateForm.name)
fd.append('address', netCreateForm.address)
fd.append('gateway', netCreateForm.gateway)
fd.append('type', netCreateForm.type)
fd.append('host_id', createForm.host_id)
if (netCreateForm.nameservers) fd.append('nameservers', netCreateForm.nameservers)
if (netCreateForm.mac_address) fd.append('mac_address', netCreateForm.mac_address)
if (netCreateForm.bridge_name) fd.append('bridge_name', netCreateForm.bridge_name)
if (netCreateForm.ls_bridge_name) fd.append('ls_bridge_name', netCreateForm.ls_bridge_name)
if (netCreateForm.ls_name) fd.append('ls_name', netCreateForm.ls_name)
const res = await createNetwork(fd)
if (res?.data?.code === 200) {
ElMessage.success('创建成功')
netCreateVisible.value = false
nextTick(() => createNetSelectorRef.value?.loadList())
} else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { netCreateLoading.value = false }
})
}
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: 10 })
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: 'shutoff' },
{ 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, 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
})
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', shutoff: 'danger', error: 'danger', paused: 'warning',
reboot: 'warning', poweroff: 'info', unknown: 'info'
}[s] || 'info')
const vmStatusLabel = (s) => ({
running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中',
stopped: '已停止', stop: '已停止', shutoff: '已关闭', error: '错误', paused: '已暂停',
reboot: '重启中', poweroff: '已关机', unknown: '未知'
}[s] || s || '-')
const formatMemKB = (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 formatMemory = formatMemKB
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 formatNetSpeed = (v) => {
if (!v && v !== 0) return '0 B/s'
v = Number(v)
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
return v.toFixed(0) + ' B/s'
}
const formatBucket = (bucket) => {
if (!bucket) return '-'
const d = new Date(bucket)
return isNaN(d.getTime()) ? String(bucket) : d.toLocaleString('zh-CN')
}
// 选择器回调
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, count: queryParams.page_size }
if (hostId.value) params.host_id = hostId.value
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 }
handleMigrateAutoRefresh()
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取虚拟机列表失败')) } finally { loading.value = false }
}
const migrateProgressMap = reactive({})
let migrateAutoRefreshTimer = null
const fetchMigrateProgress = async () => {
const migratingVms = vmList.value.filter(v => v.migrating && v.migrate_task_id)
for (const vm of migratingVms) {
try {
const res = await getDataMigrateProgress({
service_id: serviceId.value,
migration_id: vm.migrate_task_id
})
if (res?.data?.code === 200 && res.data.data) {
migrateProgressMap[vm.id] = res.data.data.progress ?? null
}
} catch { /* ignore */ }
}
}
const handleMigrateAutoRefresh = () => {
const hasMigrating = vmList.value.some(v => v.migrating)
if (hasMigrating) {
fetchMigrateProgress()
if (!migrateAutoRefreshTimer) {
migrateAutoRefreshTimer = setInterval(() => { loadList() }, 3000)
}
} else {
stopMigrateAutoRefresh()
}
}
const stopMigrateAutoRefresh = () => {
if (migrateAutoRefreshTimer) { clearInterval(migrateAutoRefreshTimer); migrateAutoRefreshTimer = null }
}
onBeforeUnmount(() => stopMigrateAutoRefresh())
const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
Object.assign(createForm, {
name: '', host_id: injectedHostId?.value || null, image_id: 0,
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: ''
})
memoryUnit.value = 'GB'
diskUnit.value = 'GB'
hostMode.value = 'host'
ipMode.value = 'num'
networkOptions.value = []
createNetSelected.value = []
loadHostOptions()
createDialogVisible.value = true
if (injectedHostId?.value) {
loadNetworkOptions(injectedHostId.value)
}
}
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.ipv4_num && !createForm.ipv6_num) { ElMessage.warning('请输入IPv4或IPv6数量'); return }
createFormRef.value?.validate(async (valid) => {
if (!valid) return
if (createForm.system_size > 0 && createForm.system_size < 30) {
try {
await ElMessageBox.confirm('系统盘小于 30 GB,可能无法正常重装 Windows 系统,是否继续?', '系统盘容量提示', { type: 'warning', confirmButtonText: '继续创建', cancelButtonText: '返回修改' })
} catch { return }
}
submitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('image_id', createForm.image_id)
fd.append('vcpu', createForm.vcpu)
fd.append('memory', createForm.memory)
fd.append('system_size', createForm.system_size)
fd.append('user_id', createForm.user_id)
if (createForm.name) fd.append('name', createForm.name)
if (createForm.rx_bandwidth) fd.append('rx_bandwidth', createForm.rx_bandwidth)
if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_bandwidth)
if (createForm.data_volume_size > 0) fd.append('data_volume_size', createForm.data_volume_size)
if (hostMode.value === 'host') fd.append('host_id', createForm.host_id)
else fd.append('host_group_id', createForm.host_group_id)
if (ipMode.value === 'num') {
if (createForm.ipv4_num) fd.append('ipv4_num', createForm.ipv4_num)
if (createForm.ipv6_num) fd.append('ipv6_num', createForm.ipv6_num)
} else createForm.network_ids.forEach(id => fd.append('network_ids', id))
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)
const res = await createVm(fd)
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 powerDialogVisible = ref(false)
const powerAction = ref('')
const powerRow = ref(null)
const powerForce = ref(false)
const powerLabels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
const handlePower = (row, action) => {
powerRow.value = row
powerAction.value = action
powerForce.value = false
powerDialogVisible.value = true
}
const submitPower = async () => {
const action = powerAction.value
const row = powerRow.value
const label = powerLabels[action]
powerDialogVisible.value = false
try {
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', row.id)
if (powerForce.value) fd.append('force', true)
const res = await apis[action](fd)
if (res?.data?.code === 200) { ElMessage.success(`${label}成功`); loadList() }
else ElMessage.error(extractApiError(res?.data, `${label}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${label}失败`)) }
}
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 fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', rebuildTarget.value.id)
fd.append('image_id', rebuildImageId.value)
const res = await rebuildVm(fd)
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
const vm = d.data ?? d.vm ?? d
vm.networks = d.networks || []
vm.volumes = d.volumes || []
vm.image = d.image || null
vm.in_port_group = d.in_port_group || null
if (!vm.ips && vm.networks?.length) vm.ips = vm.networks.map(n => n.address?.split('/')[0]).filter(Boolean).join(', ')
currentDetail.value = vm
}
} 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 outer = res.data.data
const inner = outer.data ?? outer
const state = inner.state ?? inner.status ?? inner
const desc = inner.desc || ''
currentDetail.value = { ...currentDetail.value, status: state }
ElMessage.success(`状态已刷新: ${desc || vmStatusLabel(state)}`)
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) }
}
const fetchVmMetrics = async (vm) => {
try {
const now = new Date()
const start = new Date(now.getTime() - 60 * 60 * 1000)
const res = await getMetricsHistory({
service_id: serviceId.value,
host_id: vm.host_id || hostId.value,
vm_name: vm.name,
start: start.toISOString(),
end_time: now.toISOString(),
interval: '1m'
})
const body = res?.data
if (body?.code === 200 && body?.data) {
const arr = Array.isArray(body.data) ? body.data : (body.data.data || [])
vmMetricsData.value = arr.length ? arr[arr.length - 1] : null
if (!vmMetricsData.value) ElMessage.warning('暂无指标数据')
} 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()
}
})
defineExpose({ loadList })
</script>
<style scoped>
.vm-manage-container { padding: 20px; }
.vm-config { display: flex; gap: 4px; flex-wrap: wrap; }
.migrate-inline-status { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.migrate-inline-label { color: #e6a23c; font-size: 13px; font-weight: 600; white-space: nowrap; }
.migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; }
</style>