feat: 对接主控服务接口
Build and Deploy Vue3 / build (push) Successful in 2m29s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s

This commit is contained in:
2026-03-14 15:45:07 +08:00
parent 25975c8b29
commit f4dbf17ce9
21 changed files with 6323 additions and 67 deletions
+319
View File
@@ -0,0 +1,319 @@
<template>
<div class="vm-detail-page">
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回虚拟机列表</el-button>
<el-divider direction="vertical" />
<span class="page-title">虚拟机详情</span>
<el-tag v-if="detail" :type="vmStatusType(detail.status)" size="small" style="margin-left: 8px">{{ vmStatusLabel(detail.status) }}</el-tag>
</div>
<div class="header-right">
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
</div>
</div>
<div class="main-content" v-loading="loading">
<!-- 基本信息 -->
<el-card shadow="never" class="info-card" v-if="detail">
<template #header><span class="card-title">基本信息</span></template>
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="CPU">{{ detail.vcpu ? detail.vcpu + ' 核' : '-' }}</el-descriptions-item>
<el-descriptions-item label="内存">{{ formatMemory(detail.memory) }}</el-descriptions-item>
<el-descriptions-item label="系统盘">{{ detail.system_size ? detail.system_size + ' MB' : '-' }}</el-descriptions-item>
<el-descriptions-item label="镜像ID">{{ detail.image_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(detail.host_id) }}</el-descriptions-item>
<el-descriptions-item label="宿主机组ID">{{ detail.host_group_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="下行带宽">{{ detail.rx_bandwidth || 0 }} Mbps</el-descriptions-item>
<el-descriptions-item label="上行带宽">{{ detail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ detail.user_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="IP数量">{{ detail.ip_num || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 电源控制 -->
<el-card shadow="never" class="info-card" v-if="detail">
<template #header><span class="card-title">电源控制</span></template>
<div class="action-buttons">
<el-button type="success" @click="handlePower('start')" :disabled="detail.status === 'running'">启动</el-button>
<el-button type="warning" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop'">停止</el-button>
<el-button type="primary" @click="handlePower('reboot')">重启</el-button>
<el-button type="info" @click="handlePower('suspend')">暂停</el-button>
<el-button type="success" @click="handlePower('resume')" v-if="detail.status === 'paused'">恢复</el-button>
<el-divider direction="vertical" />
<el-button @click="handleRebuild">重建</el-button>
<el-button type="warning" @click="handleRescue">救援模式</el-button>
<el-button @click="handleExitRescue">退出救援</el-button>
<el-divider direction="vertical" />
<el-button @click="fetchVmStatus" :loading="statusLoading">刷新状态</el-button>
</div>
</el-card>
<!-- 实时指标 -->
<el-card shadow="never" class="info-card">
<template #header>
<div class="card-header-row">
<span class="card-title">实时指标</span>
<el-button size="small" :icon="Refresh" @click="fetchVmMetrics" :loading="metricsLoading">刷新指标</el-button>
</div>
</template>
<div v-loading="metricsLoading">
<template v-if="metricsData">
<el-row :gutter="16">
<el-col :span="12" v-if="metricsData.cpu">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="12" v-if="metricsData.memory">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</template>
<el-empty v-else description="暂无指标数据,点击刷新加载" />
</div>
</el-card>
<!-- 子表格网络/数据卷/镜像 -->
<el-card shadow="never" class="info-card" v-if="detail && (detail.networks?.length || detail.volumes?.length)">
<template #header><span class="card-title">关联资源</span></template>
<template v-if="detail.networks?.length">
<h4 style="margin: 0 0 8px; color: #303133">网络</h4>
<el-table :data="detail.networks" size="small" stripe border style="margin-bottom: 16px">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="mac" label="MAC" min-width="140" />
<el-table-column prop="ip" label="IP" min-width="120" />
</el-table>
</template>
<template v-if="detail.volumes?.length">
<h4 style="margin: 0 0 8px; color: #303133">数据卷</h4>
<el-table :data="detail.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 prop="size" label="大小" width="100" />
<el-table-column prop="path" label="路径" min-width="160" show-overflow-tooltip />
</el-table>
</template>
</el-card>
</div>
<!-- 重建弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="重建虚拟机" width="480px" destroy-on-close>
<el-alert title="重建会清除当前虚拟机数据并使用新镜像重新创建,请谨慎操作!" type="warning" :closable="false" style="margin-bottom: 16px" />
<el-form label-width="100px">
<el-form-item label="虚拟机">{{ detail?.name || '-' }}</el-form-item>
<el-form-item label="新镜像" required>
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="rebuildImageId ? `${rebuildImageName || ''} (ID: ${rebuildImageId})` : ''" readonly placeholder="请选择镜像" style="flex: 1" />
<el-button type="primary" @click="showImageSelector = true">选择</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="actionLoading" @click="submitRebuild">确定重建</el-button>
</template>
</el-dialog>
<ImageSelectorPopup v-model="showImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="img => { rebuildImageId = img.id; rebuildImageName = img.name }" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Monitor, Coin } from '@element-plus/icons-vue'
import {
getVmDetail, getVmStatus, getVmMetrics,
startVm, stopVm, rebootVm, suspendVm, resumeVm,
rebuildVm, rescueVm, exitRescueVm, deleteVm, getRemoteHostList
} from '@/api/admin/kvmService'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import { useTagsViewStore } from '@/store/tagsViewStore'
const route = useRoute()
const router = useRouter()
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.id) || 0)
const loading = ref(false)
const actionLoading = ref(false)
const statusLoading = ref(false)
const metricsLoading = ref(false)
const detail = ref(null)
const metricsData = ref(null)
const hostOptions = ref([])
const rebuildDialogVisible = ref(false)
const rebuildImageId = ref(0)
const rebuildImageName = ref('')
const showImageSelector = ref(false)
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 '-'; 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 >= 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 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 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ }
}
const loadDetail = async () => {
if (!vmId.value) return
loading.value = true
try {
const res = await getVmDetail({ service_id: serviceId.value, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
detail.value = d.vm ?? d.data ?? d
} else ElMessage.error(res?.data?.message || '加载失败')
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
}
const fetchVmStatus = async () => {
if (!detail.value) return
statusLoading.value = true
try {
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const sd = res.data.data
detail.value = { ...detail.value, status: sd.status ?? sd }
ElMessage.success('状态已刷新: ' + vmStatusLabel(detail.value.status))
}
} catch { ElMessage.error('获取状态失败') } finally { statusLoading.value = false }
}
const fetchVmMetrics = async () => {
if (!detail.value) return
metricsLoading.value = true
try {
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: detail.value.name })
if (res?.data?.code === 200) metricsData.value = res.data.data?.data ?? res.data.data
else ElMessage.warning('暂无指标数据')
} catch { ElMessage.error('获取指标失败') } finally { metricsLoading.value = false }
}
const handlePower = (action) => {
const labels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
ElMessageBox.confirm(`确定要${labels[action]}虚拟机「${detail.value?.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 }
let res
if (action === 'resume') {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
res = await resumeVm(fd)
} else {
res = await apis[action]({ service_id: serviceId.value, vm_id: vmId.value })
}
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadDetail() }
else ElMessage.error(res?.data?.message || `${labels[action]}失败`)
} catch { ElMessage.error(`${labels[action]}失败`) }
}).catch(() => {})
}
const handleRebuild = () => {
rebuildImageId.value = detail.value?.image_id || 0
rebuildImageName.value = ''
rebuildDialogVisible.value = true
}
const submitRebuild = async () => {
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
actionLoading.value = true
try {
const res = await rebuildVm({ service_id: serviceId.value, vm_id: vmId.value, image_id: rebuildImageId.value })
if (res?.data?.code === 200) { ElMessage.success('重建成功'); rebuildDialogVisible.value = false; loadDetail() }
else ElMessage.error(res?.data?.message || '重建失败')
} catch { ElMessage.error('重建失败') } finally { actionLoading.value = false }
}
const handleRescue = () => {
ElMessageBox.confirm(`确定让虚拟机「${detail.value?.name}」进入救援模式吗?`, '救援模式', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
try {
const res = await rescueVm(fd)
if (res?.data?.code === 200) { ElMessage.success('已进入救援模式'); loadDetail() }
else ElMessage.error(res?.data?.message || '操作失败')
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const handleExitRescue = () => {
ElMessageBox.confirm(`确定让虚拟机「${detail.value?.name}」退出救援模式吗?`, '退出救援', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
}).then(async () => {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
try {
const res = await exitRescueVm(fd)
if (res?.data?.code === 200) { ElMessage.success('已退出救援模式'); loadDetail() }
else ElMessage.error(res?.data?.message || '操作失败')
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
}
onMounted(() => { loadHostOptions(); loadDetail() })
</script>
<style scoped>
.vm-detail-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 0; }
.back-btn { font-size: 14px; color: #606266; }
.back-btn:hover { color: #409eff; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; gap: 8px; }
.main-content { padding: 20px; }
.info-card { margin-bottom: 20px; }
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.metrics-card { margin-bottom: 0; }
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
</style>