Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/VmDetail.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

2001 lines
96 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-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>
</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">
<!-- 实例概览栏 -->
<div class="instance-overview" v-if="detail">
<div class="overview-left">
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
</div>
<div class="overview-actions">
<el-button type="primary" @click="handleGetVnc">远程连接</el-button>
<el-button type="danger" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop'">关机</el-button>
<el-dropdown trigger="click" @command="handleMoreCommand">
<el-button>更多 <el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="start" :disabled="detail.status === 'running'">启动</el-dropdown-item>
<el-dropdown-item command="reboot">重启</el-dropdown-item>
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
<el-dropdown-item command="resume" v-if="detail.status === 'paused'">恢复</el-dropdown-item>
<el-dropdown-item divided command="editVm">编辑虚拟机</el-dropdown-item>
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
<el-dropdown-item command="updateTraffic">修改带宽</el-dropdown-item>
<el-dropdown-item divided command="bindSg">绑定安全组</el-dropdown-item>
<el-dropdown-item command="unbindSg">解绑安全组</el-dropdown-item>
<el-dropdown-item divided command="rebuild">重建虚拟机</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 状态概览条 -->
<div class="status-bar" v-if="detail">
<div class="status-item">
<span class="status-label">实例状态</span>
<span class="status-value">
<span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span>
{{ vmStatusLabel(detail.status) }}
</span>
</div>
<div class="status-item">
<span class="status-label">IP地址</span>
<span class="status-value">{{ detail.ips || '-' }}</span>
</div>
<div class="status-item">
<span class="status-label">创建时间</span>
<span class="status-value">{{ formatTimestamp(detail.created_at) }}</span>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" class="detail-tabs" v-if="detail">
<el-tab-pane label="实例详情" name="info">
<!-- 配置信息 -->
<div class="section-block">
<h3 class="section-title">配置信息</h3>
<div class="config-grid">
<div class="config-row">
<div class="config-cell">
<span class="config-label">实例规格</span>
<span class="config-value spec-value">{{ detail.vcpu || '-' }}核vCPU | {{ formatMemory(detail.memory) }} | {{ detail.rx_bandwidth || 0 }} Mbps</span>
</div>
<div class="config-cell">
<span class="config-label">操作系统</span>
<span class="config-value">{{ vmImage?.name || '-' }}</span>
</div>
<div class="config-cell">
<span class="config-label">镜像类型</span>
<span class="config-value">
<el-tag v-if="vmImage" :type="vmImage.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ vmImage.os_type || '-' }}</el-tag>
<span v-else>-</span>
</span>
</div>
</div>
<div class="config-row">
<div class="config-cell">
<span class="config-label">公网IP</span>
<span class="config-value ip-value">{{ detail.ips || '暂无' }}</span>
</div>
<div class="config-cell">
<span class="config-label">下行/上行带宽</span>
<span class="config-value">{{ detail.rx_bandwidth || 0 }} / {{ detail.tx_bandwidth || 0 }} Mbps</span>
</div>
<div class="config-cell">
<span class="config-label">安全组</span>
<span class="config-value">
<template v-if="vmPortGroup">
<el-tag size="small" type="success">{{ vmPortGroup.name }}</el-tag>
</template>
<span v-else style="color: #909399">未绑定</span>
</span>
</div>
</div>
<div class="config-row">
<div class="config-cell">
<span class="config-label">用户名</span>
<span class="config-value">root</span>
</div>
<div class="config-cell">
<span class="config-label">远程端口</span>
<span class="config-value">{{ detail.ssh_port || 22 }}</span>
</div>
<div class="config-cell">
<span class="config-label">密码</span>
<span class="config-value password-cell">
<code>{{ showPassword ? (detail.root_password || '-') : '••••••••' }}</code>
<el-button link type="primary" size="small" @click="showPassword = !showPassword">{{ showPassword ? '隐藏' : '显示' }}</el-button>
<el-button link type="primary" size="small" @click="copyText(detail.root_password)">复制</el-button>
</span>
</div>
</div>
<div class="config-row">
<div class="config-cell">
<span class="config-label">流量上限(GB)</span>
<span class="config-value">{{ detail.traffic_max ?? '-' }}</span>
</div>
<div class="config-cell">
<span class="config-label">快照配额</span>
<span class="config-value">{{ detail.snapshot_num ?? '-' }}</span>
</div>
<div class="config-cell">
<span class="config-label">备份配额</span>
<span class="config-value">{{ detail.backup_num ?? '-' }}</span>
</div>
</div>
<div class="config-row">
<div class="config-cell" style="flex: 2">
<span class="config-label">UUID</span>
<span class="config-value mono-text">{{ detail.uuid || '-' }}</span>
</div>
<div class="config-cell">
<span class="config-label">更新时间</span>
<span class="config-value">{{ formatTimestamp(detail.updated_at) }}</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="网络信息" name="network">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">网络信息</h3>
<el-button size="small" type="primary" @click="handleAddNetwork">添加网络</el-button>
</div>
<el-table v-if="vmNetworks.length" :data="pagedNetworks" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="100" />
<el-table-column prop="address" label="IP地址" min-width="140" show-overflow-tooltip />
<el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column prop="mac_address" label="MAC地址" min-width="150" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="80">
<template #default="{ row }"><el-tag size="small">{{ row.type || '-' }}</el-tag></template>
</el-table-column>
<el-table-column prop="bridge_name" label="网桥" width="80" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEditNetwork(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteNetwork(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无网络" :image-size="60" />
<div class="pagination-wrapper" v-if="vmNetworks.length > 0">
<el-pagination v-model:current-page="networkPage" v-model:page-size="networkPageSize"
:page-sizes="[10, 20, 50]" :total="vmNetworks.length" layout="total, sizes, prev, pager, next" small
@size-change="s => { networkPageSize = s; networkPage = 1 }"
@current-change="p => { networkPage = p }" />
</div>
</div>
</el-tab-pane>
<el-tab-pane label="磁盘卷信息" name="volume">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">磁盘卷信息</h3>
<el-button size="small" type="primary" @click="handleAddVolume">添加数据卷</el-button>
</div>
<el-table v-if="vmVolumes.length" :data="pagedVolumes" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="100" />
<el-table-column label="大小" width="80">
<template #default="{ row }">{{ row.size ? row.size + ' GB' : '-' }}</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'ready' ? 'success' : 'info'" size="small">{{ row.status === 'ready' ? '就绪' : (row.status || '-') }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="160" show-overflow-tooltip>
<template #default="{ row }"><span class="mono-text">{{ row.path || '-' }}</span></template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleResizeVolume(row)">调整大小</el-button>
<el-button link type="success" size="small" @click="handleMountVolume(row)" v-if="!row.is_mount">挂载</el-button>
<el-button link type="warning" size="small" @click="handleUnmountVolume(row)" v-if="row.is_mount">卸载</el-button>
<el-button link type="info" size="small" @click="handleTransferVolume(row)">迁移</el-button>
<el-button link type="danger" size="small" @click="handleDeleteVolume(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无磁盘卷" :image-size="60" />
<div class="pagination-wrapper" v-if="vmVolumes.length > 0">
<el-pagination v-model:current-page="volumePage" v-model:page-size="volumePageSize"
:page-sizes="[10, 20, 50]" :total="vmVolumes.length" layout="total, sizes, prev, pager, next" small
@size-change="s => { volumePageSize = s; volumePage = 1 }"
@current-change="p => { volumePage = p }" />
</div>
</div>
</el-tab-pane>
<el-tab-pane label="安全组" name="security">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">安全组管理</h3>
<el-button size="small" type="primary" @click="handleBindSgFromTab">绑定安全组</el-button>
</div>
<el-table v-if="vmSecurityGroups.length" :data="pagedSecurityGroups" size="small" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="note" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column label="锁定" width="80">
<template #default="{ row }">
<el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '已锁定' : '未锁定' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="白名单" width="90">
<template #default="{ row }">
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '已开启' : '未开启' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="共享" width="80">
<template #default="{ row }">
<el-tag :type="row.shared ? 'success' : 'info'" size="small">{{ row.shared ? '共享' : '私有' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleUnbindSgFromTab(row)">解绑</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无绑定的安全组" :image-size="60" />
<div class="pagination-wrapper" v-if="vmSecurityGroups.length > 0">
<el-pagination v-model:current-page="securityPage" v-model:page-size="securityPageSize"
:page-sizes="[10, 20, 50]" :total="vmSecurityGroups.length" layout="total, sizes, prev, pager, next" small
@size-change="s => { securityPageSize = s; securityPage = 1 }"
@current-change="p => { securityPage = p }" />
</div>
</div>
</el-tab-pane>
<el-tab-pane label="快照" name="snapshot">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">快照管理</h3>
<div style="display: flex; align-items: center; gap: 8px">
<el-tag v-if="snapshotQuota" size="small" effect="plain">{{ snapshotQuota.count }} / {{ snapshotQuota.limit }}</el-tag>
<el-button size="small" @click="handleSetSnapshotLimit">设置上限</el-button>
<el-button size="small" type="primary" @click="handleCreateSnapshot">创建快照</el-button>
<el-button size="small" @click="loadSnapshots">刷新</el-button>
</div>
</div>
<el-table :data="snapshotList" v-loading="snapshotLoading" stripe size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">{{ snapshotStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务ID" min-width="160">
<template #default="{ row }">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ row.task_id || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestoreSnapshot(row)">恢复</el-button>
<el-button link type="info" size="small" @click="handleSnapshotProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
<el-button link type="danger" size="small" @click="handleDeleteSnapshot(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!snapshotList.length && !snapshotLoading" description="暂无快照" :image-size="60" />
<div class="pagination-wrapper" v-if="snapshotTotal > 0">
<el-pagination v-model:current-page="snapshotPage" v-model:page-size="snapshotPageSize"
:page-sizes="[10, 20, 50]" :total="snapshotTotal" layout="total, sizes, prev, pager, next" small
@size-change="s => { snapshotPageSize = s; snapshotPage = 1; loadSnapshots() }"
@current-change="p => { snapshotPage = p; loadSnapshots() }" />
</div>
</div>
</el-tab-pane>
<el-tab-pane label="备份" name="backup">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">备份管理</h3>
<div style="display: flex; align-items: center; gap: 8px">
<el-tag v-if="backupQuota" size="small" effect="plain">{{ backupQuota.count }} / {{ backupQuota.limit }}</el-tag>
<el-button size="small" @click="handleSetBackupLimit">设置上限</el-button>
<el-button size="small" type="primary" @click="handleCreateBackup">创建备份</el-button>
<el-button size="small" @click="loadBackups">刷新</el-button>
</div>
</div>
<el-table :data="backupList" v-loading="backupLoading" stripe size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">{{ snapshotStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务ID" min-width="160">
<template #default="{ row }">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ row.task_id || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestoreBackup(row)">恢复</el-button>
<el-button link type="info" size="small" @click="handleBackupProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
<el-button link type="danger" size="small" @click="handleDeleteBackup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!backupList.length && !backupLoading" description="暂无备份" :image-size="60" />
<div class="pagination-wrapper" v-if="backupTotal > 0">
<el-pagination v-model:current-page="backupPage" v-model:page-size="backupPageSize"
:page-sizes="[10, 20, 50]" :total="backupTotal" layout="total, sizes, prev, pager, next" small
@size-change="s => { backupPageSize = s; backupPage = 1; loadBackups() }"
@current-change="p => { backupPage = p; loadBackups() }" />
</div>
</div>
</el-tab-pane>
<el-tab-pane label="监控" name="monitor">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">实时指标</h3>
<div style="display: flex; align-items: center; gap: 8px">
<el-tag v-if="pollingActive" type="success" size="small" effect="light">每3秒刷新</el-tag>
<el-button size="small" :icon="Refresh" @click="pollMetrics" :loading="metricsLoading">刷新指标</el-button>
</div>
</div>
<div v-if="metricsData" class="metrics-summary">
<div class="metric-card">
<div class="metric-title">CPU 使用率</div>
<div class="metric-num">{{ (metricsData.cpu_usage_percent ?? 0).toFixed(1) }}%</div>
</div>
<div class="metric-card" v-if="metricsData.internet_speed && Object.keys(metricsData.internet_speed).length">
<div class="metric-title">网络速率</div>
<div class="net-speed-items">
<div v-for="(val, key) in metricsData.internet_speed" :key="key" class="net-speed-item">
<span class="net-speed-label">{{ key === 'rx_bytes' ? '↓ 接收' : key === 'tx_bytes' ? '↑ 发送' : key }}</span>
<span class="net-speed-value">{{ formatNetSpeed(val) }}</span>
</div>
</div>
</div>
</div>
<div class="charts-area">
<div class="chart-wrapper">
<h4 class="chart-label">CPU 使用率</h4>
<div ref="cpuChartRef" class="chart-container"></div>
</div>
<div class="chart-wrapper" v-if="metricsHistory.netKeys.length">
<h4 class="chart-label">网络速率</h4>
<div ref="netChartRef" class="chart-container"></div>
</div>
</div>
<el-empty v-if="!metricsData && !metricsLoading" description="暂无指标数据切换到此标签页后自动开始采集" />
</div>
</el-tab-pane>
</el-tabs>
</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 }" />
<!-- 创建快照弹窗 -->
<el-dialog v-model="snapshotCreateVisible" title="创建快照" width="480px" destroy-on-close>
<el-form :model="snapshotForm" label-width="100px">
<el-form-item label="虚拟机">{{ detail?.name || '-' }} (ID: {{ vmId }})</el-form-item>
<el-form-item label="快照名称" required>
<el-input v-model="snapshotForm.name" placeholder="请输入快照名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="snapshotCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateSnapshot">创建</el-button>
</template>
</el-dialog>
<!-- 创建备份弹窗 -->
<el-dialog v-model="backupCreateVisible" title="创建备份" width="480px" destroy-on-close>
<el-form :model="backupForm" label-width="100px">
<el-form-item label="虚拟机">{{ detail?.name || '-' }} (ID: {{ vmId }})</el-form-item>
<el-form-item label="备份名称" required>
<el-input v-model="backupForm.name" placeholder="请输入备份名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="backupCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateBackup">创建</el-button>
</template>
</el-dialog>
<!-- 快照/备份进度弹窗 -->
<el-dialog v-model="taskProgressVisible" :title="taskProgressTitle" width="520px" destroy-on-close>
<div v-loading="taskProgressLoading">
<el-descriptions :column="1" border size="small" v-if="taskProgressData">
<el-descriptions-item label="任务ID">
<span class="mono-text">{{ taskProgressData.task_id || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="taskStatusType(taskProgressData.status)" size="small">{{ snapshotStatusLabel(taskProgressData.status) }}</el-tag>
</el-descriptions-item>
<template v-if="taskProgressMeta">
<el-descriptions-item v-for="(val, key) in taskProgressMeta" :key="key" :label="taskMetaLabel(key)">
<span :style="key.includes('path') ? 'font-family: Consolas, monospace; font-size: 13px; word-break: break-all' : ''">{{ val }}</span>
</el-descriptions-item>
</template>
</el-descriptions>
<el-empty v-else description="暂无进度信息" />
</div>
<template #footer>
<el-button @click="taskProgressVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 编辑虚拟机弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑虚拟机" width="640px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" label-width="120px">
<el-form-item label="名称">
<el-input v-model="editForm.name" placeholder="虚拟机名称" />
</el-form-item>
<el-form-item label="内存">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-input-number v-model="editMemoryDisplay" :min="1" :precision="editMemoryUnit === 'GB' ? 2 : 0" controls-position="right" style="width: 200px" />
<el-select v-model="editMemoryUnit" style="width: 80px">
<el-option label="MB" value="MB" />
<el-option label="GB" value="GB" />
</el-select>
<span style="color: #909399; font-size: 12px">{{ editForm.memory }} KB</span>
</div>
</el-form-item>
<el-form-item label="CPU()">
<el-input-number v-model="editForm.vcpu" :min="1" controls-position="right" style="width: 200px" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="editForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="editForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Root密码">
<el-input v-model="editForm.root_password" placeholder="留空则不修改" show-password />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="SSH端口">
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="流量上限(GB)">
<el-input-number v-model="editForm.traffic_max" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="快照配额">
<el-input-number v-model="editForm.snapshot_num" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备份配额">
<el-input-number v-model="editForm.backup_num" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="安全组">
<el-select v-model="editForm.port_group_id" placeholder="选择安全组可选" filterable clearable style="width: 100%">
<el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitEditVm">确定</el-button>
</template>
</el-dialog>
<!-- 重构虚拟机弹窗 -->
<el-dialog v-model="refactorDialogVisible" title="重构虚拟机" width="600px" destroy-on-close>
<el-alert title="重构会修改虚拟机的底层配置参数请谨慎操作" type="warning" :closable="false" style="margin-bottom: 16px" />
<el-form ref="refactorFormRef" :model="refactorForm" label-width="120px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="内存(KB)">
<el-input-number v-model="refactorForm.memory" :min="0" :step="1024" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="CPU()">
<el-input-number v-model="refactorForm.vcpu" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽">
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上行带宽">
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Root密码">
<el-input v-model="refactorForm.root_password" placeholder="不修改留空" show-password />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="SSH端口">
<el-input-number v-model="refactorForm.ssh_port" :min="0" :max="65535" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="VNC端口">
<el-input-number v-model="refactorForm.vnc_port" :min="0" :max="65535" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="VNC密码">
<el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password />
</el-form-item>
<el-form-item label="安全组">
<el-select v-model="refactorForm.port_group_id" placeholder="选择安全组可选" filterable clearable style="width: 100%">
<el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refactorDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="actionLoading" @click="submitRefactorVm">确定重构</el-button>
</template>
</el-dialog>
<!-- VNC 连接弹窗 -->
<el-dialog v-model="vncDialogVisible" title="获取 VNC 连接" width="560px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="虚拟机">{{ detail?.name || '-' }} (ID: {{ vmId }})</el-form-item>
<el-form-item label="VNC节点">
<el-select v-model="vncNodeId" placeholder="选择VNC节点" filterable style="width: 100%">
<el-option v-for="n in vncNodeOptions" :key="n.id" :label="`${n.name} (${n.ip}:${n.port})`" :value="n.id" />
</el-select>
</el-form-item>
</el-form>
<div v-if="vncResult" class="vnc-result">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="VNC地址">
<el-link type="primary" :href="vncResult.url" target="_blank">{{ vncResult.url }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ formatTimestamp(vncResult.expire_at) }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="vncDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="vncLoading" @click="submitGetVnc">获取</el-button>
</template>
</el-dialog>
<!-- 绑定/解绑安全组弹窗 -->
<el-dialog v-model="sgDialogVisible" :title="sgDialogType === 'bind' ? '绑定安全组' : '解绑安全组'" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="虚拟机">{{ detail?.name || '-' }}</el-form-item>
<el-form-item label="安全组">
<el-select v-model="sgSelectedId" placeholder="选择安全组" filterable style="width: 100%">
<el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sgDialogVisible = false">取消</el-button>
<el-button :type="sgDialogType === 'bind' ? 'success' : 'warning'" :loading="actionLoading" @click="submitSgAction">
{{ sgDialogType === 'bind' ? '绑定' : '解绑' }}
</el-button>
</template>
</el-dialog>
<!-- 编辑网络弹窗 -->
<el-dialog v-model="netEditVisible" title="编辑网络" width="560px" destroy-on-close>
<el-form :model="netEditForm" label-width="120px">
<el-form-item label="名称">
<el-input v-model="netEditForm.name" />
</el-form-item>
<el-form-item label="IP地址(CIDR)">
<el-input v-model="netEditForm.address" placeholder=" 192.168.1.100/24" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="netEditForm.gateway" placeholder=" 192.168.1.1" />
</el-form-item>
<el-form-item label="DNS">
<el-input v-model="netEditForm.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
</el-form-item>
<el-form-item label="网络类型">
<el-select v-model="netEditForm.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="MAC地址">
<el-input v-model="netEditForm.mac_address" placeholder="不填随机" />
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="netEditForm.bridge_name" placeholder="不填默认" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="netEditVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitEditNetwork">确定</el-button>
</template>
</el-dialog>
<!-- 调整卷大小弹窗 -->
<el-dialog v-model="volResizeVisible" title="调整数据卷大小" width="440px" destroy-on-close>
<el-form :model="volResizeForm" label-width="100px">
<el-form-item label="卷名称">{{ volResizeForm._name || '-' }}</el-form-item>
<el-form-item label="当前大小">{{ volResizeForm._currentSize }} GB</el-form-item>
<el-form-item label="新大小(GB)">
<el-input-number v-model="volResizeForm.size" :min="volResizeForm._currentSize || 1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="volResizeVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitResizeVolume">确定</el-button>
</template>
</el-dialog>
<!-- 添加网络弹窗(从已有列表选择) -->
<el-dialog v-model="netAddVisible" title="添加网络到虚拟机" width="600px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="选择宿主机">
<el-select v-model="netAddHostId" placeholder="选择宿主机以加载网络列表" filterable style="width: 100%" @change="loadAvailableNetworks">
<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="选择网络">
<el-select v-model="netAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="netOptionsLoading" :disabled="!netAddHostId">
<el-option v-for="n in availableNetworks" :key="n.id" :label="`${n.name} - ${n.address || ''}`" :value="n.id" />
</el-select>
</el-form-item>
</el-form>
<div v-if="netAddSelectedId" style="margin-top: 8px">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="名称">{{ selectedNetworkInfo?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ selectedNetworkInfo?.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ selectedNetworkInfo?.gateway || '-' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ selectedNetworkInfo?.type || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="netAddVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitAddNetwork" :disabled="!netAddSelectedId">添加</el-button>
</template>
</el-dialog>
<!-- 挂载数据卷弹窗(从已有列表选择) -->
<el-dialog v-model="volAddVisible" title="挂载数据卷到虚拟机" width="600px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="选择宿主机">
<el-select v-model="volAddHostId" placeholder="选择宿主机以加载数据卷列表" filterable style="width: 100%" @change="loadAvailableVolumes">
<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="选择数据卷">
<el-select v-model="volAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="volOptionsLoading" :disabled="!volAddHostId">
<el-option v-for="v in availableVolumes" :key="v.id" :label="`${v.name} (${v.size || 0} GB, ID: ${v.id})`" :value="v.id" />
</el-select>
</el-form-item>
</el-form>
<div v-if="volAddSelectedId" style="margin-top: 8px">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="名称">{{ selectedVolumeInfo?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ selectedVolumeInfo?.size ? selectedVolumeInfo.size + ' GB' : '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ selectedVolumeInfo?.status || '-' }}</el-descriptions-item>
<el-descriptions-item label="路径">{{ selectedVolumeInfo?.path || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="volAddVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitAddVolume" :disabled="!volAddSelectedId">挂载</el-button>
</template>
</el-dialog>
<!-- 迁移卷弹窗 -->
<el-dialog v-model="volTransferVisible" title="迁移数据卷" width="480px" destroy-on-close>
<el-form :model="volTransferForm" label-width="120px">
<el-form-item label="卷名称">{{ volTransferForm._name || '-' }}</el-form-item>
<el-form-item label="目标虚拟机" required>
<el-select v-model="volTransferForm.vm_id" placeholder="选择目标虚拟机" filterable style="width: 100%">
<el-option v-for="v in vmListOptions" :key="v.id" :label="`${v.name} (ID: ${v.id})`" :value="v.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="volTransferVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitTransferVolume">确定</el-button>
</template>
</el-dialog>
<!-- 修改带宽弹窗 -->
<el-dialog v-model="trafficDialogVisible" title="修改虚拟机带宽" width="520px" destroy-on-close>
<el-form :model="trafficForm" label-width="140px">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="每月最大流量(KB)">
<el-input-number v-model="trafficForm.traffic_max" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="trafficDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitUpdateTraffic">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown } from '@element-plus/icons-vue'
import {
getVmDetail, getVmStatus, getVmMetrics,
startVm, stopVm, rebootVm, suspendVm, resumeVm,
rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm, deleteVm,
getRemoteHostList, getVmVnc, getVncNodeList,
bindSecurityGroup, unbindSecurityGroup, getSecurityGroupList,
createNetwork, updateNetwork, deleteNetwork, getNetworkList,
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, getVolumeList,
getVmList,
getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getSnapshotCount, setSnapshotLimit,
getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getBackupCount, setBackupLimit
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import * as echarts from 'echarts'
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.vm_id) || 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 vmNetworks = ref([])
const vmVolumes = ref([])
const vmImage = ref(null)
const networkPage = ref(1)
const networkPageSize = ref(10)
const pagedNetworks = computed(() => {
const start = (networkPage.value - 1) * networkPageSize.value
return vmNetworks.value.slice(start, start + networkPageSize.value)
})
const volumePage = ref(1)
const volumePageSize = ref(10)
const pagedVolumes = computed(() => {
const start = (volumePage.value - 1) * volumePageSize.value
return vmVolumes.value.slice(start, start + volumePageSize.value)
})
const vmPortGroup = 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 activeTab = ref('info')
const showPassword = ref(false)
const copyText = (text) => {
if (!text) { ElMessage.warning('无内容可复制'); return }
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制到剪贴板')).catch(() => fallbackCopy(text))
} else {
fallbackCopy(text)
}
}
const fallbackCopy = (text) => {
const ta = document.createElement('textarea')
ta.value = text
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
ElMessage.success('已复制到剪贴板')
} catch { ElMessage.error('复制失败,请手动复制') }
document.body.removeChild(ta)
}
const handleMoreCommand = (cmd) => {
const powerActions = ['start', 'stop', 'reboot', 'suspend', 'resume']
if (powerActions.includes(cmd)) { handlePower(cmd); return }
const actionMap = {
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic,
bindSg: handleBindSg, unbindSg: handleUnbindSg,
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue
}
if (actionMap[cmd]) actionMap[cmd]()
}
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 imgStatusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info')
const imgStatusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[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 formatNetSpeed = (bytes) => { if (bytes == null) return '-'; const n = Number(bytes); if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB/s'; if (n >= 1048576) return (n / 1048576).toFixed(2) + ' MB/s'; if (n >= 1024) return (n / 1024).toFixed(2) + ' KB/s'; return n + ' B/s' }
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 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 = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
}
} 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.data ?? d.vm ?? d
vmNetworks.value = d.networks || []
vmVolumes.value = d.volumes || []
vmImage.value = d.image || null
vmPortGroup.value = d.in_port_group || null
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
}
const loadVmVolumes = async () => {
if (!detail.value) return
const hid = detail.value.host_id
if (!hid) return
try {
const res = await getVolumeList({ service_id: serviceId.value, host_id: hid, vm_id: vmId.value, page: 1, count: 200 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
vmVolumes.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ }
}
const loadVmNetworks = async () => {
if (!detail.value) return
const hid = detail.value.host_id
if (!hid) return
try {
const res = await getNetworkList({ service_id: serviceId.value, host_id: hid, page: 1, page_size: 200 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
vmNetworks.value = inner.data || inner.networks || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ }
}
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 (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) } finally { statusLoading.value = false }
}
// ---- ECharts 监控 ----
const cpuChartRef = ref(null)
const netChartRef = ref(null)
let cpuChart = null
let netChart = null
const MAX_HISTORY = 60
const metricsHistory = reactive({ times: [], cpu: [], netKeys: [], netSeries: {} })
const pollingActive = ref(false)
let pollTimer = null
let isPageActive = false
const fetchVmMetrics = async () => {
if (!detail.value || !isPageActive) 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
pushHistory(metricsData.value)
await nextTick()
renderCharts()
}
} catch { /* silent for polling */ } finally { metricsLoading.value = false }
}
const pushHistory = (d) => {
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
metricsHistory.times.push(now)
metricsHistory.cpu.push(d.cpu_usage_percent ?? 0)
if (d.internet_speed && typeof d.internet_speed === 'object') {
for (const key of Object.keys(d.internet_speed)) {
if (!metricsHistory.netSeries[key]) {
metricsHistory.netKeys.push(key)
metricsHistory.netSeries[key] = []
const fillCount = metricsHistory.times.length - 1
for (let i = 0; i < fillCount; i++) metricsHistory.netSeries[key].push(0)
}
metricsHistory.netSeries[key].push(Number(d.internet_speed[key]) || 0)
}
for (const key of metricsHistory.netKeys) {
if (metricsHistory.netSeries[key].length < metricsHistory.times.length) {
metricsHistory.netSeries[key].push(0)
}
}
}
if (metricsHistory.times.length > MAX_HISTORY) {
metricsHistory.times.shift()
metricsHistory.cpu.shift()
for (const key of metricsHistory.netKeys) {
metricsHistory.netSeries[key]?.shift()
}
}
}
const makeLineOption = (seriesArr) => ({
tooltip: { trigger: 'axis' },
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: metricsHistory.times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10 } },
series: seriesArr.map(s => ({
name: s.name, type: 'line', smooth: true, symbol: 'none',
areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: s.color },
itemStyle: { color: s.color }, data: s.data
}))
})
const COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
const renderCharts = () => {
const times = [...metricsHistory.times]
const cpuData = [...metricsHistory.cpu]
if (cpuChartRef.value) {
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
cpuChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => `${params[0].axisValue}<br/>${params[0].marker} CPU: ${Number(params[0].value).toFixed(2)}%` },
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v.toFixed(1) + '%' } },
series: [{ name: 'CPU', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: cpuData }]
}, true)
}
if (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value)
const series = metricsHistory.netKeys.map((key, i) => ({
name: key, type: 'line', smooth: true, symbol: 'none',
areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: COLORS[i % COLORS.length] },
itemStyle: { color: COLORS[i % COLORS.length] }, data: [...(metricsHistory.netSeries[key] || [])]
}))
if (series.length === 0) series.push({ name: '网络', type: 'line', smooth: true, symbol: 'none', data: [], itemStyle: { color: '#409eff' } })
netChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0]?.axisValue || ''
params.forEach(p => {
const label = p.seriesName === 'rx_bytes' ? '↓ 接收' : p.seriesName === 'tx_bytes' ? '↑ 发送' : p.seriesName
s += `<br/>${p.marker} ${label}: ${formatNetSpeed(p.value)}`
})
return s
}},
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10 } },
series
}, true)
}
}
const pollMetrics = () => { fetchVmMetrics() }
const startPolling = () => {
if (!isPageActive || !serviceId.value || !vmId.value) return
stopPolling()
pollingActive.value = true
fetchVmMetrics()
pollTimer = setInterval(() => { fetchVmMetrics() }, 3000)
}
const stopPolling = () => {
pollingActive.value = false
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
const disposeCharts = () => {
cpuChart?.dispose(); cpuChart = null
netChart?.dispose(); netChart = null
}
const clearHistory = () => {
metricsHistory.times.length = 0
metricsHistory.cpu.length = 0
metricsHistory.netKeys.length = 0
for (const k in metricsHistory.netSeries) delete metricsHistory.netSeries[k]
}
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(extractApiError(res?.data, `${labels[action]}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${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(extractApiError(res?.data, '重建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重建失败')) } 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(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
}).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(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
}).catch(() => {})
}
// ---- 编辑虚拟机 ----
const editDialogVisible = ref(false)
const editFormRef = ref(null)
const editForm = reactive({
name: '', memory: 0, vcpu: 1,
rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '', ssh_port: 22,
traffic_max: 0, snapshot_num: 0, backup_num: 0,
port_group_id: 0
})
const editMemoryUnit = ref('MB')
const editMemUnitFactor = () => editMemoryUnit.value === 'GB' ? 1048576 : 1024
const editMemoryDisplay = computed({
get: () => {
const f = editMemUnitFactor()
const v = editForm.memory / f
return f === 1048576 ? parseFloat(v.toFixed(2)) : Math.round(v)
},
set: (v) => { editForm.memory = Math.round(v * editMemUnitFactor()) }
})
const handleEditVm = async () => {
if (!detail.value) return
const d = detail.value
const mem = d.memory || 0
editMemoryUnit.value = mem >= 1048576 ? 'GB' : 'MB'
Object.assign(editForm, {
name: d.name || '',
memory: mem, vcpu: d.vcpu || 1,
rx_bandwidth: d.rx_bandwidth || 0,
tx_bandwidth: d.tx_bandwidth || 0,
root_password: d.root_password || '',
ssh_port: d.ssh_port || 22,
traffic_max: d.traffic_max || 0,
snapshot_num: d.snapshot_num || 0,
backup_num: d.backup_num || 0,
port_group_id: vmPortGroup.value?.id || null
})
if (!sgOptions.value.length) await loadSgOptions()
editDialogVisible.value = true
}
const submitEditVm = async () => {
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
if (editForm.name) fd.append('name', editForm.name)
fd.append('memory', editForm.memory)
fd.append('vcpu', editForm.vcpu)
fd.append('rx_bandwidth', editForm.rx_bandwidth)
fd.append('tx_bandwidth', editForm.tx_bandwidth)
fd.append('ssh_port', editForm.ssh_port)
fd.append('traffic_max', editForm.traffic_max)
fd.append('snapshot_num', editForm.snapshot_num)
fd.append('backup_num', editForm.backup_num)
if (editForm.root_password) fd.append('root_password', editForm.root_password)
if (editForm.port_group_id) fd.append('port_group_id', editForm.port_group_id)
const res = await updateVm(fd)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '修改失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
}
// ---- 重构虚拟机 ----
const refactorDialogVisible = ref(false)
const refactorFormRef = ref(null)
const refactorForm = reactive({ memory: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidth: 0, root_password: '', ssh_port: 0, vnc_port: 0, vnc_password: '', port_group_id: 0 })
const handleRefactorVm = async () => {
if (!detail.value) return
const d = detail.value
Object.assign(refactorForm, {
memory: d.memory || 0, vcpu: d.vcpu || 0,
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
root_password: '', ssh_port: d.ssh_port || 0,
vnc_port: 0, vnc_password: '',
port_group_id: vmPortGroup.value?.id || null
})
if (!sgOptions.value.length) await loadSgOptions()
refactorDialogVisible.value = true
}
const submitRefactorVm = async () => {
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
if (refactorForm.memory) fd.append('memory', refactorForm.memory)
if (refactorForm.vcpu) fd.append('vcpu', refactorForm.vcpu)
fd.append('rx_bandwidth', refactorForm.rx_bandwidth)
fd.append('tx_bandwidth', refactorForm.tx_bandwidth)
if (refactorForm.root_password) fd.append('root_password', refactorForm.root_password)
if (refactorForm.ssh_port) fd.append('ssh_port', refactorForm.ssh_port)
if (refactorForm.vnc_port) fd.append('vnc_port', refactorForm.vnc_port)
if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password)
if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id)
const res = await refactorVm(fd)
if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '重构失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重构失败')) } finally { actionLoading.value = false }
}
// ---- 修改带宽 ----
const trafficDialogVisible = ref(false)
const trafficForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, traffic_max: 0 })
const handleUpdateTraffic = () => {
if (!detail.value) return
Object.assign(trafficForm, {
rx_bandwidth: detail.value.rx_bandwidth || 0,
tx_bandwidth: detail.value.tx_bandwidth || 0,
traffic_max: detail.value.traffic_max || 0
})
trafficDialogVisible.value = true
}
const submitUpdateTraffic = async () => {
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('rx_bandwidth', trafficForm.rx_bandwidth)
fd.append('tx_bandwidth', trafficForm.tx_bandwidth)
if (trafficForm.traffic_max) fd.append('traffic_max', trafficForm.traffic_max)
const res = await updateVmTraffic(fd)
if (res?.data?.code === 200) { ElMessage.success('带宽修改成功'); trafficDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '修改失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
}
// ---- VNC 连接 ----
const vncDialogVisible = ref(false)
const vncNodeId = ref(null)
const vncNodeOptions = ref([])
const vncLoading = ref(false)
const vncResult = ref(null)
const loadVncNodes = async () => {
try {
const res = await getVncNodeList({ service_id: serviceId.value, page: 1, page_size: 100 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
vncNodeOptions.value = inner.items || inner.vnc_nodes || inner.nodes || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ }
}
const handleGetVnc = async () => {
vncNodeId.value = null
vncResult.value = null
if (!vncNodeOptions.value.length) await loadVncNodes()
vncDialogVisible.value = true
}
const submitGetVnc = async () => {
if (!vncNodeId.value) { ElMessage.warning('请选择VNC节点'); return }
vncLoading.value = true
vncResult.value = null
try {
const res = await getVmVnc({ service_id: serviceId.value, id: vncNodeId.value, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
vncResult.value = res.data.data
} else ElMessage.error(extractApiError(res?.data, '获取VNC连接失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取VNC连接失败')) } finally { vncLoading.value = false }
}
// ---- 安全组管理(标签页列表) ----
const vmSecurityGroups = ref([])
const sgListLoading = ref(false)
const securityPage = ref(1)
const securityPageSize = ref(10)
const pagedSecurityGroups = computed(() => {
const start = (securityPage.value - 1) * securityPageSize.value
return vmSecurityGroups.value.slice(start, start + securityPageSize.value)
})
const loadVmSecurityGroups = async () => {
sgListLoading.value = true
try {
const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 200, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
vmSecurityGroups.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ } finally { sgListLoading.value = false }
}
// ---- 绑定/解绑安全组 ----
const sgDialogVisible = ref(false)
const sgDialogType = ref('bind')
const sgSelectedId = ref(null)
const sgOptions = ref([])
const loadSgOptions = async () => {
try {
const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 200 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
sgOptions.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ }
}
const handleBindSg = async () => {
sgDialogType.value = 'bind'; sgSelectedId.value = null
if (!sgOptions.value.length) await loadSgOptions()
sgDialogVisible.value = true
}
const handleUnbindSg = async () => {
sgDialogType.value = 'unbind'; sgSelectedId.value = null
if (!sgOptions.value.length) await loadSgOptions()
sgDialogVisible.value = true
}
const handleBindSgFromTab = async () => {
sgDialogType.value = 'bind'; sgSelectedId.value = null
if (!sgOptions.value.length) await loadSgOptions()
sgDialogVisible.value = true
}
const handleUnbindSgFromTab = async (row) => {
try {
await ElMessageBox.confirm(`确认解绑安全组「${row.name}」?`, '提示', { type: 'warning' })
} catch { return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('id', row.id)
fd.append('vm_id', vmId.value)
const res = await unbindSecurityGroup(fd)
if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadVmSecurityGroups() }
else ElMessage.error(extractApiError(res?.data, '解绑失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '解绑失败')) } finally { actionLoading.value = false }
}
const submitSgAction = async () => {
if (!sgSelectedId.value) { ElMessage.warning('请选择安全组'); return }
actionLoading.value = true
try {
const api = sgDialogType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('id', sgSelectedId.value)
fd.append('vm_id', vmId.value)
const res = await api(fd)
if (res?.data?.code === 200) {
ElMessage.success(sgDialogType.value === 'bind' ? '绑定成功' : '解绑成功')
sgDialogVisible.value = false
loadVmSecurityGroups()
}
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
}
// ---- 添加网络(从已有列表选择) ----
const netAddVisible = ref(false)
const netAddHostId = ref(null)
const netAddSelectedId = ref(null)
const availableNetworks = ref([])
const netOptionsLoading = ref(false)
const selectedNetworkInfo = computed(() => availableNetworks.value.find(n => n.id === netAddSelectedId.value) || null)
const loadAvailableNetworks = async (hostId) => {
netAddSelectedId.value = null
availableNetworks.value = []
if (!hostId) return
netOptionsLoading.value = true
try {
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, used: false, page: 1, page_size: 200 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
availableNetworks.value = inner.networks || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ } finally { netOptionsLoading.value = false }
}
const handleAddNetwork = () => {
netAddHostId.value = null
netAddSelectedId.value = null
availableNetworks.value = []
netAddVisible.value = true
}
const submitAddNetwork = async () => {
if (!netAddSelectedId.value) { ElMessage.warning('请选择网络'); return }
actionLoading.value = true
try {
const net = selectedNetworkInfo.value
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('network_id', netAddSelectedId.value)
if (net?.host_id) fd.append('host_id', net.host_id)
const res = await createNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('网络添加成功'); netAddVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '添加失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '添加失败')) } finally { actionLoading.value = false }
}
// ---- 网络编辑/删除 ----
const netEditVisible = ref(false)
const netEditForm = reactive({ id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', host_id: 0 })
const handleEditNetwork = (row) => {
Object.assign(netEditForm, {
id: row.id, name: row.name || '', address: row.address || '', gateway: row.gateway || '',
nameservers: row.nameservers || '', type: row.type || 'bridge',
mac_address: row.mac_address || '', bridge_name: row.bridge_name || '', host_id: row.host_id || 0
})
netEditVisible.value = true
}
const submitEditNetwork = async () => {
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('id', netEditForm.id)
fd.append('host_id', netEditForm.host_id)
if (netEditForm.name) fd.append('name', netEditForm.name)
if (netEditForm.address) fd.append('address', netEditForm.address)
if (netEditForm.gateway) fd.append('gateway', netEditForm.gateway)
if (netEditForm.nameservers) fd.append('nameservers', netEditForm.nameservers)
if (netEditForm.type) fd.append('type', netEditForm.type)
if (netEditForm.mac_address) fd.append('mac_address', netEditForm.mac_address)
if (netEditForm.bridge_name) fd.append('bridge_name', netEditForm.bridge_name)
const res = await updateNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('网络修改成功'); netEditVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '修改失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
}
const handleDeleteNetwork = (row) => {
ElMessageBox.confirm(`确定要删除网络「${row.name}」(ID: ${row.id}) 吗?`, '删除网络', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteNetwork({ service_id: serviceId.value, network_id: row.id, host_id: row.host_id })
if (res?.data?.code === 200) { ElMessage.success('网络已删除'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
// ---- 挂载数据卷(从已有列表选择) ----
const volAddVisible = ref(false)
const volAddHostId = ref(null)
const volAddSelectedId = ref(null)
const availableVolumes = ref([])
const volOptionsLoading = ref(false)
const selectedVolumeInfo = computed(() => availableVolumes.value.find(v => v.id === volAddSelectedId.value) || null)
const loadAvailableVolumes = async (hostId) => {
volAddSelectedId.value = null
availableVolumes.value = []
if (!hostId) return
volOptionsLoading.value = true
try {
const res = await getVolumeList({ service_id: serviceId.value, host_id: hostId, page: 1, page_size: 200 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const list = inner.volumes || inner.data || (Array.isArray(inner) ? inner : [])
availableVolumes.value = list.filter(v => !v.is_mount)
}
} catch { /* */ } finally { volOptionsLoading.value = false }
}
const handleAddVolume = () => {
volAddHostId.value = null
volAddSelectedId.value = null
availableVolumes.value = []
volAddVisible.value = true
}
const submitAddVolume = async () => {
if (!volAddSelectedId.value) { ElMessage.warning('请选择数据卷'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('volume_id', volAddSelectedId.value)
const res = await mountVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('数据卷挂载成功'); volAddVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '挂载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } finally { actionLoading.value = false }
}
// ---- 磁盘卷操作 ----
const volResizeVisible = ref(false)
const volResizeForm = reactive({ volume_id: 0, size: 0, _name: '', _currentSize: 0 })
const handleResizeVolume = (row) => {
Object.assign(volResizeForm, { volume_id: row.id, size: row.size || 0, _name: row.name, _currentSize: row.size || 0 })
volResizeVisible.value = true
}
const submitResizeVolume = async () => {
if (volResizeForm.size <= 0) { ElMessage.warning('请输入有效大小'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('volume_id', volResizeForm.volume_id)
fd.append('size', volResizeForm.size)
const res = await resizeVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('调整大小成功'); volResizeVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '调整大小失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整大小失败')) } finally { actionLoading.value = false }
}
const handleUnmountVolume = (row) => {
ElMessageBox.confirm(`确定要卸载磁盘卷「${row.name}」(ID: ${row.id}) 吗?`, '卸载磁盘卷', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('volume_id', row.id)
const res = await unmountVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '卸载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '卸载失败')) }
}).catch(() => {})
}
const handleDeleteVolume = (row) => {
ElMessageBox.confirm(`确定要删除磁盘卷「${row.name}」(ID: ${row.id}) 吗?此操作不可恢复!`, '删除磁盘卷', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'error'
}).then(async () => {
try {
const res = await deleteVolume({ service_id: serviceId.value, volume_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('磁盘卷已删除'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const handleMountVolume = (row) => {
ElMessageBox.confirm(`确定要将磁盘卷「${row.name}」(ID: ${row.id}) 挂载到当前虚拟机吗?`, '挂载磁盘卷', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('volume_id', row.id)
fd.append('vm_id', vmId.value)
const res = await mountVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('挂载成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '挂载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) }
}).catch(() => {})
}
// ---- 迁移卷 ----
const volTransferVisible = ref(false)
const volTransferForm = reactive({ volume_id: 0, vm_id: null, _name: '' })
const vmListOptions = ref([])
const loadVmListOptions = async () => {
try {
const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 200 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
vmListOptions.value = (inner.vms || inner.data || (Array.isArray(inner) ? inner : [])).filter(v => v.id !== vmId.value)
}
} catch { /* */ }
}
const handleTransferVolume = async (row) => {
Object.assign(volTransferForm, { volume_id: row.id, vm_id: null, _name: row.name })
if (!vmListOptions.value.length) await loadVmListOptions()
volTransferVisible.value = true
}
const submitTransferVolume = async () => {
if (!volTransferForm.vm_id) { ElMessage.warning('请选择目标虚拟机'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('volume_id', volTransferForm.volume_id)
fd.append('vm_id', volTransferForm.vm_id)
const res = await transferVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('迁移成功'); volTransferVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
}
// ---- 快照/备份管理 ----
const snapshotList = ref([])
const snapshotLoading = ref(false)
const snapshotQuota = ref(null)
const snapshotPage = ref(1)
const snapshotPageSize = ref(10)
const snapshotTotal = ref(0)
const backupList = ref([])
const backupLoading = ref(false)
const backupQuota = ref(null)
const backupPage = ref(1)
const backupPageSize = ref(10)
const backupTotal = ref(0)
const snapshotCreateVisible = ref(false)
const backupCreateVisible = ref(false)
const snapshotForm = reactive({ name: '' })
const backupForm = reactive({ name: '' })
const taskProgressVisible = ref(false)
const taskProgressLoading = ref(false)
const taskProgressData = ref(null)
const taskProgressTitle = ref('')
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', success: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
const snapshotStatusLabel = (s) => ({ completed: '完成', ready: '完成', success: '成功', pending: '等待', running: '运行中', failed: '失败', error: '错误' }[s] || s || '-')
const taskMetaLabel = (key) => ({ vm_name: '虚拟机名称', backup_path: '备份路径', snapshot_path: '快照路径', path: '路径', progress: '进度', message: '信息', error: '错误信息' }[key] || key)
const taskProgressMeta = computed(() => {
if (!taskProgressData.value?.meta) return null
const raw = taskProgressData.value.meta
if (typeof raw === 'object') return raw
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed || trimmed === '""' || trimmed === '{}') return null
try { return JSON.parse(trimmed) } catch { return { 信息: raw } }
}
return null
})
const loadSnapshots = async () => {
snapshotLoading.value = true
try {
const res = await getSnapshotList({ service_id: serviceId.value, vm_id: vmId.value, page: snapshotPage.value, page_size: snapshotPageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
snapshotList.value = d.data || d.list || (Array.isArray(d) ? d : [])
snapshotTotal.value = d.meta?.count ?? d.total ?? snapshotList.value.length
} else { snapshotList.value = []; snapshotTotal.value = 0 }
} catch { snapshotList.value = []; snapshotTotal.value = 0 } finally { snapshotLoading.value = false }
}
const loadSnapshotQuota = async () => {
try {
const res = await getSnapshotCount({ service_id: serviceId.value, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
snapshotQuota.value = { count: d.count ?? 0, limit: d.limit ?? 0 }
}
} catch { /* ignore */ }
}
const handleSetSnapshotLimit = () => {
ElMessageBox.prompt('请输入快照数量上限', '设置快照上限', {
confirmButtonText: '确定', cancelButtonText: '取消',
inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数',
inputValue: String(snapshotQuota.value?.limit || 10)
}).then(async ({ value }) => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('limit', value)
const res = await setSnapshotLimit(fd)
if (res?.data?.code === 200) { ElMessage.success('快照上限设置成功'); loadSnapshotQuota() }
else ElMessage.error(extractApiError(res?.data, '设置失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置失败')) }
}).catch(() => {})
}
const loadBackups = async () => {
backupLoading.value = true
try {
const res = await getBackupList({ service_id: serviceId.value, vm_id: vmId.value, page: backupPage.value, page_size: backupPageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
backupList.value = d.data || d.list || (Array.isArray(d) ? d : [])
backupTotal.value = d.meta?.count ?? d.total ?? backupList.value.length
} else { backupList.value = []; backupTotal.value = 0 }
} catch { backupList.value = []; backupTotal.value = 0 } finally { backupLoading.value = false }
}
const loadBackupQuota = async () => {
try {
const res = await getBackupCount({ service_id: serviceId.value, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
backupQuota.value = { count: d.count ?? 0, limit: d.limit ?? 0 }
}
} catch { /* ignore */ }
}
const handleSetBackupLimit = () => {
ElMessageBox.prompt('请输入备份数量上限', '设置备份上限', {
confirmButtonText: '确定', cancelButtonText: '取消',
inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数',
inputValue: String(backupQuota.value?.limit || 10)
}).then(async ({ value }) => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('limit', value)
const res = await setBackupLimit(fd)
if (res?.data?.code === 200) { ElMessage.success('备份上限设置成功'); loadBackupQuota() }
else ElMessage.error(extractApiError(res?.data, '设置失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置失败')) }
}).catch(() => {})
}
const handleCreateSnapshot = () => {
Object.assign(snapshotForm, { name: '' })
snapshotCreateVisible.value = true
}
const submitCreateSnapshot = async () => {
if (!snapshotForm.name) { ElMessage.warning('请输入快照名称'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('name', snapshotForm.name)
const res = await createSnapshot(fd)
if (res?.data?.code === 200) { ElMessage.success('快照创建成功'); snapshotCreateVisible.value = false; loadSnapshots(); loadSnapshotQuota() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
const handleRestoreSnapshot = (row) => {
ElMessageBox.confirm(`确定要恢复快照「${row.name}」吗?`, '恢复确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('snapshot_id', row.id)
fd.append('vm_id', vmId.value)
const res = await restoreSnapshot(fd)
if (res?.data?.code === 200) ElMessage.success('恢复操作已提交')
else ElMessage.error(extractApiError(res?.data, '恢复失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复失败')) }
}).catch(() => {})
}
const handleDeleteSnapshot = (row) => {
ElMessageBox.confirm(`确定要删除快照「${row.name}」吗?`, '删除确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('snapshot_id', row.id)
fd.append('vm_id', row.vm_id)
const res = await deleteSnapshot(fd)
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSnapshots(); loadSnapshotQuota() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const handleSnapshotProgress = async (row) => {
taskProgressTitle.value = '快照任务进度'
taskProgressData.value = null
taskProgressVisible.value = true
taskProgressLoading.value = true
try {
const res = await getSnapshotProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) })
if (res?.data?.code === 200) taskProgressData.value = res.data.data?.data ?? res.data.data
else ElMessage.warning('暂无进度信息')
} catch { ElMessage.warning('获取进度失败') } finally { taskProgressLoading.value = false }
}
const handleCreateBackup = () => {
Object.assign(backupForm, { name: '' })
backupCreateVisible.value = true
}
const submitCreateBackup = async () => {
if (!backupForm.name) { ElMessage.warning('请输入备份名称'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('name', backupForm.name)
const res = await createBackup(fd)
if (res?.data?.code === 200) { ElMessage.success('备份创建成功'); backupCreateVisible.value = false; loadBackups(); loadBackupQuota() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
const handleRestoreBackup = (row) => {
ElMessageBox.confirm(`确定要恢复备份「${row.name}」吗?`, '恢复确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('backup_id', row.id)
fd.append('vm_id', vmId.value)
const res = await restoreBackup(fd)
if (res?.data?.code === 200) ElMessage.success('恢复操作已提交')
else ElMessage.error(extractApiError(res?.data, '恢复失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复失败')) }
}).catch(() => {})
}
const handleDeleteBackup = (row) => {
ElMessageBox.confirm(`确定要删除备份「${row.name}」吗?`, '删除确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('backup_id', row.id)
fd.append('vm_id', row.vm_id)
const res = await deleteBackup(fd)
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadBackups(); loadBackupQuota() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const handleBackupProgress = async (row) => {
taskProgressTitle.value = '备份任务进度'
taskProgressData.value = null
taskProgressVisible.value = true
taskProgressLoading.value = true
try {
const res = await getBackupProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) })
if (res?.data?.code === 200) taskProgressData.value = res.data.data?.data ?? res.data.data
else ElMessage.warning('暂无进度信息')
} catch { ElMessage.warning('获取进度失败') } finally { taskProgressLoading.value = false }
}
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
}
let loadedVmId = null
const initPage = () => {
if (!vmId.value || loadedVmId === vmId.value) return
loadedVmId = vmId.value
metricsData.value = null
stopPolling()
disposeCharts()
clearHistory()
loadHostOptions()
loadDetail()
if (activeTab.value === 'monitor') startPolling()
}
watch(vmId, () => { if (isPageActive) initPage() })
watch(activeTab, (tab) => {
if (tab === 'monitor' && detail.value) startPolling()
else stopPolling()
if (tab === 'network') loadVmNetworks()
if (tab === 'volume') loadVmVolumes()
if (tab === 'security') loadVmSecurityGroups()
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
})
onActivated(() => {
isPageActive = true
if (loadedVmId !== vmId.value) initPage()
else if (activeTab.value === 'monitor') startPolling()
})
onDeactivated(() => { isPageActive = false; stopPolling() })
onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
onMounted(() => { isPageActive = true; initPage() })
</script>
<style scoped>
.vm-detail-page { padding: 0; background: #f5f7fa; min-height: 100vh; }
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #fff; border-bottom: 1px solid #e8e8e8; }
.header-left { display: flex; align-items: center; gap: 0; }
.back-btn { font-size: 14px; color: #606266; }
.back-btn:hover { color: #409eff; }
.header-right { display: flex; gap: 8px; }
.main-content { padding: 20px 24px; }
/* 实例概览 */
.instance-overview { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 24px; border-radius: 4px; margin-bottom: 0; border: 1px solid #e8e8e8; border-bottom: none; }
.instance-name { margin: 0; font-size: 18px; font-weight: 600; color: #1d2129; }
.instance-id { font-size: 14px; font-weight: 400; color: #86909c; margin-left: 8px; }
.overview-actions { display: flex; gap: 8px; }
/* 状态概览条 */
.status-bar { display: flex; gap: 0; background: #fff; padding: 16px 24px; border: 1px solid #e8e8e8; border-top: 1px solid #f0f0f0; border-radius: 0 0 4px 4px; margin-bottom: 16px; }
.status-item { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.status-item + .status-item { border-left: 1px solid #e8e8e8; padding-left: 24px; }
.status-label { font-size: 12px; color: #86909c; }
.status-value { font-size: 14px; color: #1d2129; display: flex; align-items: center; gap: 6px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.dot-running { background: #00b42a; }
.dot-other { background: #86909c; }
/* 标签页 */
.detail-tabs { background: #fff; border-radius: 4px; border: 1px solid #e8e8e8; padding: 0 24px; }
:deep(.detail-tabs > .el-tabs__header) { margin-bottom: 0; }
:deep(.detail-tabs > .el-tabs__content) { padding: 0 0 24px; }
/* 区块 */
.section-block { margin-top: 24px; }
.section-title { font-size: 15px; font-weight: 600; color: #1d2129; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.section-header .section-title { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
/* 配置信息网格 */
.config-grid { border: 1px solid #e8e8e8; border-radius: 4px; overflow: hidden; }
.config-row { display: flex; border-bottom: 1px solid #e8e8e8; }
.config-row:last-child { border-bottom: none; }
.config-cell { flex: 1; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; border-right: 1px solid #e8e8e8; }
.config-cell:last-child { border-right: none; }
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
.config-value { font-size: 14px; color: #1d2129; line-height: 1.4; word-break: break-all; }
.spec-value { font-size: 13px; color: #4e5969; }
.ip-value { color: #165dff; font-weight: 500; }
.password-cell { display: flex; align-items: center; gap: 8px; }
.password-cell code { font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; color: #1d2129; }
.mono-text { font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; }
/* 监控指标 */
.metrics-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
.metric-card { flex: 1; min-width: 180px; background: #f7f8fa; border-radius: 8px; padding: 16px 20px; }
.metric-title { font-size: 13px; color: #86909c; margin-bottom: 6px; }
.metric-num { font-size: 28px; font-weight: 600; }
.charts-area { display: flex; flex-direction: column; gap: 20px; }
.chart-wrapper { background: #fafbfc; border: 1px solid #f0f0f0; border-radius: 8px; padding: 16px; }
.chart-label { margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #4e5969; }
.chart-container { width: 100%; height: 220px; }
.net-speed-items { display: flex; gap: 20px; flex-wrap: wrap; }
.net-speed-item { display: flex; flex-direction: column; gap: 2px; }
.net-speed-label { font-size: 12px; color: #86909c; }
.net-speed-value { font-size: 20px; font-weight: 600; color: #1d2129; }
.vnc-result { margin-top: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>