Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/VmDetail.vue
T
lin cae1f847e4
Build and Deploy Vue3 / build (push) Successful in 1m59s
Build and Deploy Vue3 / deploy (push) Successful in 1m21s
fix: 网络模块
2026-04-15 16:46:28 +08:00

3489 lines
178 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' || isMigrating">关机</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" :disabled="isMigrating">修改虚拟机</el-dropdown-item>
<el-dropdown-item command="refactorVm" :disabled="isMigrating">重构虚拟机</el-dropdown-item>
<el-dropdown-item command="updateTraffic" :disabled="isMigrating">修改带宽</el-dropdown-item>
<el-dropdown-item divided command="rebuild" :disabled="isMigrating">重装虚拟机</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
<el-dropdown-item divided command="migrateVm" :disabled="isMigrating">迁移虚拟机</el-dropdown-item>
<el-dropdown-item command="dataMigrateVm" :disabled="isMigrating">数据迁移</el-dropdown-item>
<el-dropdown-item command="dataMigrateProgress" v-if="isMigrating">查看迁移进度</el-dropdown-item>
<el-dropdown-item command="abortDataMigrate" v-if="isMigrating" style="color:#f56c6c">中断迁移</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) }}
<el-tag v-if="isMigrating" type="warning" size="small" effect="dark" style="margin-left:8px">迁移中</el-tag>
</span>
</div>
<div class="status-item">
<span class="status-label">IP地址</span>
<span class="status-value" v-if="publicIpList.length || privateIpList.length">
{{ (publicIpList[0] || privateIpList[0]) }}
<el-popover v-if="publicIpList.length + privateIpList.length > 1" trigger="hover" placement="bottom-start" :width="300">
<template #reference>
<el-tag size="small" type="info" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ publicIpList.length + privateIpList.length - 1 }}</el-tag>
</template>
<div class="ip-popover-list">
<div v-if="publicIpList.length" style="font-size:12px;color:#909399;margin-bottom:4px">公网IP</div>
<div v-for="(ip, idx) in publicIpList" :key="'pub'+idx" class="ip-popover-item">{{ ip }}</div>
<div v-if="privateIpList.length" style="font-size:12px;color:#909399;margin:8px 0 4px">内网IP</div>
<div v-for="(ip, idx) in privateIpList" :key="'pri'+idx" class="ip-popover-item">{{ ip }}</div>
</div>
</el-popover>
</span>
<span class="status-value" v-else>{{ detail.ips || '-' }}</span>
</div>
<div class="status-item">
<span class="status-label">创建时间</span>
<span class="status-value">{{ formatTimestamp(detail.created_at) }}</span>
</div>
</div>
<!-- 数据迁移进度横幅 -->
<div class="migrate-banner" v-if="isMigrating">
<div class="migrate-banner-info">
<el-icon class="migrate-spin"><Loading /></el-icon>
<span class="migrate-banner-title">数据迁移进行中</span>
<template v-if="dataMigrateProgressData">
<span class="migrate-divider"></span>
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small" effect="dark">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</el-tag>
<span v-if="dataMigrateProgressData.progress != null" class="migrate-progress-text">{{ dataMigrateProgressData.progress }}%</span>
<span v-if="dataMigrateProgressData.speed" class="migrate-speed">{{ dataMigrateProgressData.speed }}</span>
<span v-if="dataMigrateProgressData.message" class="migrate-msg">{{ dataMigrateProgressData.message }}</span>
</template>
<span v-else class="migrate-msg">正在获取进度信息...</span>
</div>
<div class="migrate-banner-actions">
<el-button size="small" @click="loadDataMigrateProgress" :loading="dataMigrateProgressLoading">
<el-icon v-if="!dataMigrateProgressLoading"><Refresh /></el-icon>刷新进度
</el-button>
<el-button size="small" type="danger" plain @click="handleAbortMigrate" :loading="abortLoading">中断迁移</el-button>
</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" v-if="publicIpList.length">
{{ publicIpList[0] }}
<el-popover v-if="publicIpList.length > 1" trigger="hover" placement="bottom-start" :width="300">
<template #reference>
<el-tag size="small" type="primary" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ publicIpList.length - 1 }}</el-tag>
</template>
<div class="ip-popover-list">
<div v-for="(ip, idx) in publicIpList" :key="idx" class="ip-popover-item">{{ ip }}</div>
</div>
</el-popover>
</span>
<span class="config-value ip-value" v-else>暂无</span>
</div>
<div class="config-cell">
<span class="config-label">内网IP</span>
<span class="config-value ip-value" v-if="privateIpList.length">
{{ privateIpList[0] }}
<el-popover v-if="privateIpList.length > 1" trigger="hover" placement="bottom-start" :width="300">
<template #reference>
<el-tag size="small" type="success" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ privateIpList.length - 1 }}</el-tag>
</template>
<div class="ip-popover-list">
<div v-for="(ip, idx) in privateIpList" :key="idx" class="ip-popover-item">{{ ip }}</div>
</div>
</el-popover>
</span>
<span class="config-value ip-value" v-else>暂无</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>
<div class="config-row">
<div class="config-cell">
<span class="config-label">安全组</span>
<span class="config-value">
<template v-if="vmPortGroup || vmOutPortGroup">
<el-tag v-if="vmPortGroup" size="small" type="success" style="margin-right: 4px">入站: {{ vmPortGroup.name }}</el-tag>
<el-tag v-if="vmOutPortGroup" size="small" type="warning">出站: {{ vmOutPortGroup.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">流量上限</span>
<span class="config-value">{{ detail.traffic_max != null ? `${(detail.traffic_max / 1024).toFixed(2)} GB` : '-' }}</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>
<div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="showNetBindBridgeSelector = true">绑定外网</el-button>
<el-button size="small" type="success" @click="showNetBindNatSelector = true">绑定内网</el-button>
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
</div>
</div>
<el-table :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="地址(CIDR)" min-width="140" show-overflow-tooltip />
<el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="mac_address" label="MAC地址" min-width="150" show-overflow-tooltip />
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="bridge_name" label="网桥" width="100" />
<el-table-column prop="target_device" label="目标设备" width="100" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleNetDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleNetEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleNetDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!vmNetworks.length" 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>
<div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="showVolSelector = true">绑定数据卷</el-button>
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
</div>
</div>
<el-table :data="pagedVolumes" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="大小" width="80">
<template #default="{ row }">{{ row.size ? 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.is_mount ? 'success' : 'info'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag>
</template>
</el-table-column> -->
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="volumeStatusType(row.status)" size="small">{{ volumeStatusLabel(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="300" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleVolDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleVolResize(row)">调整大小</el-button>
<!-- <el-button link type="success" size="small" @click="handleVolMount(row)" v-if="!row.is_mount">挂载</el-button> -->
<el-button link type="warning" size="small" @click="handleVolUnmount(row)" v-if="row.is_mount">卸载</el-button>
<el-button link type="info" size="small" @click="handleVolTransfer(row)">迁移</el-button>
<el-button link type="danger" size="small" @click="handleVolDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!vmVolumes.length" 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>
<div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="handleSgCreate"><el-icon><Plus /></el-icon>创建安全组</el-button>
<el-button size="small" @click="handleSgBind">绑定安全组</el-button>
<el-button size="small" :icon="Refresh" @click="async () => { await loadDetail(); loadSgLockInfo() }">刷新</el-button>
</div>
</div>
<el-table :data="pagedSecurityGroups" size="small" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="锁定" width="80">
<template #default="{ row }">
<el-tag :type="row.lock ? 'danger' : 'success'" size="small">{{ row.lock ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="白名单" width="80">
<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.direction === 'in' ? 'success' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : row.direction === 'out' ? '出站' : (row.direction || '-') }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleSgGoDetail(row)">编辑</el-button>
<el-button link type="success" size="small" @click="handleSgSync(row)">同步</el-button>
<el-button link type="warning" size="small" @click="handleSgApply(row)">应用</el-button>
<el-dropdown trigger="click" @command="cmd => handleSgRowMore(row, cmd)" style="margin-left: 4px; margin-top: 4.5px">
<el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="unbindCurrent" style="color: #e6a23c">解绑当前虚拟机</el-dropdown-item>
<el-dropdown-item command="bindVm" divided>绑定虚拟机</el-dropdown-item>
<el-dropdown-item command="unbindVm">解绑虚拟机</el-dropdown-item>
<el-dropdown-item command="whitelist" divided>{{ row.drop_all ? '关闭白名单' : '开启白名单' }}</el-dropdown-item>
<el-dropdown-item command="viewDetail">查看详情</el-dropdown-item>
<el-dropdown-item command="delete" divided style="color: #f56c6c">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!sgTableData.length" description="暂无绑定的安全组" :image-size="60" />
<div class="pagination-wrapper" v-if="sgTableData.length > 0">
<el-pagination
v-model:current-page="sgPage"
v-model:page-size="sgPageSize"
:page-size="[10,20,50]"
:total="sgTableData.length"
layout="total,sizes,prev,pager,next"
small
@size-change="s => {sgPageSize = s; sgPage = 1}"
@current-change="p => {sgPage = p}"
>
</el-pagination>
</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="userNetworking">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">用户组网</h3>
<div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="handleVnCreate"><el-icon><Plus /></el-icon>创建组网</el-button>
<el-button size="small" :icon="Refresh" @click="loadVmNetworkingList" :loading="vnLoading">刷新</el-button>
</div>
</div>
<el-table :data="vnList" v-loading="vnLoading" stripe size="small">
<el-table-column label="组网" min-width="130">
<template #default="{ row }">{{ row.networking?.name || '-' }} <span style="color:#909399">(ID: {{ row.networking?.id }})</span></template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.network" type="success" size="small">已加入</el-tag>
<el-tag v-else type="info" size="small">未加入</el-tag>
</template>
</el-table-column>
<el-table-column label="网桥" width="130">
<template #default="{ row }"><span class="mono-text">{{ row.network?.bridge_name || row.networking?.bridge_name || '-' }}</span></template>
</el-table-column>
<el-table-column label="IP地址" min-width="150">
<template #default="{ row }">
<span v-if="row.network" class="mono-text">{{ row.network.address || '-' }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="网关" width="140">
<template #default="{ row }">
<span v-if="row.network" class="mono-text">{{ row.network.gateway || '-' }}</span>
<span v-else class="mono-text" style="color:#c0c4cc">{{ row.networking?.gateway || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="MAC地址" min-width="160">
<template #default="{ row }">
<span v-if="row.network" class="mono-text">{{ row.network.mac_address || '-' }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag v-if="row.network" :type="row.network.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.network.type || '-' }}</el-tag>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.network" link type="primary" size="small" @click="handleJoinNetworking(row)">加入</el-button>
<el-button v-else link type="danger" size="small" @click="handleLeaveNetworking(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!vnList.length && !vnLoading" description="没有匹配的可用组网" :image-size="60" />
</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-select v-model="historyTimeRange" size="small" style="width: 120px" @change="loadHistoricalMetrics">
<el-option v-for="option in historyTimeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-button size="small" :icon="Refresh" @click="loadHistoricalMetrics" :loading="historicalMetricsLoading">刷新</el-button>
</div>
</div>
<template v-if="latestMetrics">
<div class="metric-summary-row">
<div class="metric-summary-card">
<div class="metric-summary-label">CPU 使用率</div>
<div class="metric-summary-value">{{ latestMetrics.cpu_usage?.toFixed(1) }}%</div>
<div class="metric-summary-sub">&nbsp;</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">内存</div>
<div class="metric-summary-value">{{ vmMemPercent(latestMetrics) }}%</div>
<div class="metric-summary-sub">{{ formatMemKB(latestMetrics.mem_used) }} / {{ formatMemKB(latestMetrics.mem_total) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">磁盘 I/O</div>
<div class="metric-summary-value">读 {{ formatBytesRaw(latestMetrics.disk_read) }}</div>
<div class="metric-summary-sub">写 {{ formatBytesRaw(latestMetrics.disk_write) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">网络流量</div>
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
</div>
</div>
</template>
<template v-if="historicalMetricsData">
<el-row :gutter="16">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> CPU 使用率</span></template>
<div ref="cpuChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 内存使用率</span></template>
<div ref="memChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 I/O</span></template>
<div ref="diskChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 网络流量</span></template>
<div ref="netChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</template>
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 电源操作确认弹窗 -->
<el-dialog v-model="powerDialogVisible" :title="`${powerLabels[powerAction] || ''}虚拟机`" width="400px" destroy-on-close>
<div style="display: flex; align-items: flex-start; gap: 12px; padding: 8px 0">
<el-icon :size="22" :style="{ color: powerAction === 'stop' ? '#F56C6C' : powerAction === 'reboot' ? '#E6A23C' : '#409EFF', flexShrink: 0, marginTop: '-1px' }">
<WarningFilled />
</el-icon>
<div>
<div style="font-size: 15px; font-weight: 500; color: #303133; margin-bottom: 12px">
确定要{{ powerLabels[powerAction] }}虚拟机「{{ detail?.name }}」吗?
</div>
<el-checkbox v-model="powerForce" style="margin-bottom: 4px">强制执行</el-checkbox>
<div style="font-size: 12px; color: #909399; padding-left: 24px">勾选后将强制{{ powerLabels[powerAction] }},可能导致数据丢失</div>
</div>
</div>
<template #footer>
<el-button @click="powerDialogVisible = false">取消</el-button>
<el-button :type="powerAction === 'stop' ? 'danger' : 'primary'" @click="submitPower">确定{{ powerLabels[powerAction] }}</el-button>
</template>
</el-dialog>
<!-- 重装弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<div class="tk-section">
<div class="tk-section-title">重装信息</div>
<el-alert title="重装会清除当前虚拟机数据并使用新镜像重新创建请谨慎操作" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-form-item label="虚拟机">{{ 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>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="actionLoading" @click="submitRebuild">确定重装</el-button>
</div>
</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 class="tk-dialog">
<el-form :model="snapshotForm" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">快照信息</div>
<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>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="snapshotCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateSnapshot">创建</el-button>
</div>
</template>
</el-dialog>
<!-- 创建备份弹窗 -->
<el-dialog v-model="backupCreateVisible" title="创建备份" width="480px" destroy-on-close class="tk-dialog">
<el-form :model="backupForm" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">备份信息</div>
<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>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="backupCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateBackup">创建</el-button>
</div>
</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>
<!-- 编辑虚拟机弹窗(仅 update API 支持的参数) -->
<el-dialog v-model="editDialogVisible" title="修改虚拟机" width="640px" destroy-on-close class="tk-dialog">
<el-form ref="editFormRef" :model="editForm" label-width="100px" v-loading="dialogOptionsLoading">
<div class="tk-section">
<div class="tk-section-title">带宽与端口</div>
<div class="tk-resource-grid">
<el-form-item label="下行带宽">
<el-input-number v-model="editForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
<el-form-item label="上行带宽">
<el-input-number v-model="editForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
</div>
<el-form-item label="额外数据卷">
<div style="display:flex;align-items:center;gap:6px">
<el-input-number v-model="editForm.data_volume_size" :min="0" controls-position="right" style="width:200px" />
<span class="tk-res-unit">GB</span>
<span style="font-size:12px;color:#909399;margin-left:8px">0 表示不创建</span>
</div>
</el-form-item>
<el-form-item label="Root密码">
<el-input v-model="editForm.root_password" placeholder="留空则不修改" show-password />
</el-form-item>
<el-form-item label="SSH端口">
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width: 200px" />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">网络配置</div>
<el-form-item label="网络">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: 100%">
<el-tag v-for="net in editSelectedNetworks" :key="net.id" closable @close="removeEditNetwork(net.id)">
{{ net.name }} (ID:{{ net.id }})
</el-tag>
<el-button size="small" @click="showEditNetworkSelector = true">添加网络</el-button>
</div>
</el-form-item>
<el-form-item label="内网">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-tag v-if="editSelectedInternalNetworks.length" closable @close="removeEditInternalNetwork(editSelectedInternalNetworks[0].id)">
{{ editSelectedInternalNetworks[0].name }} (ID:{{ editSelectedInternalNetworks[0].id }})
</el-tag>
<el-button size="small" @click="showEditInternalNetworkSelector = true">{{ editSelectedInternalNetworks.length ? '更换内网' : '选择内网' }}</el-button>
</div>
</el-form-item>
<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>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitEditVm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 编辑用网络选择器(外网 bridge -->
<NetworkSelectorPopup ref="editNetSelectorRef" v-model="showEditNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleEditNetworkConfirm" @create="() => handleNetCreate('edit')" />
<!-- 编辑用内网选择器(内网 nat -->
<NetworkSelectorPopup ref="editInternalNetSelectorRef" v-model="showEditInternalNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleEditInternalNetworkConfirm" @create="() => handleNetCreate('editInternal')" />
<!-- 重构虚拟机弹窗 -->
<el-dialog v-model="refactorDialogVisible" title="重构虚拟机" width="700px" destroy-on-close class="tk-dialog">
<el-form ref="refactorFormRef" :model="refactorForm" label-width="100px" v-loading="dialogOptionsLoading">
<el-alert title="重构会修改虚拟机的底层配置参数请谨慎操作" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<div class="tk-section">
<div class="tk-section-title">资源配置</div>
<div class="tk-resource-grid">
<el-form-item label="CPU">
<el-input-number v-model="refactorForm.vcpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span>
</el-form-item>
<el-form-item label="内存">
<el-input-number v-model="refactorMemDisplay" :min="0" :step="refactorMemUnit === 1048576 ? 1 : (refactorMemUnit === 1024 ? 512 : 1024)" :precision="refactorMemUnit === 1048576 ? 2 : 0" controls-position="right" />
<el-select v-model="refactorMemUnit" class="tk-unit-select" @change="onRefactorMemUnitChange">
<el-option label="KB" :value="1" />
<el-option label="MB" :value="1024" />
<el-option label="GB" :value="1048576" />
</el-select>
</el-form-item>
<el-form-item label="下行带宽">
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
<el-form-item label="上行带宽">
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title">基本参数</div>
<el-form-item label="Root密码">
<el-input v-model="refactorForm.root_password" placeholder="不修改留空" show-password />
</el-form-item>
<el-form-item label="UUID">
<el-input v-model="refactorForm.uuid" placeholder="虚拟机UUID" />
</el-form-item>
<el-form-item label="Mate Data ID">
<el-input v-model="refactorForm.mate_data_id" placeholder="元数据ID" />
</el-form-item>
<el-form-item label="物理机名称">
<el-input v-model="refactorForm.physical_name" placeholder="不修改留空" />
</el-form-item>
<el-form-item label="配置路径">
<el-input v-model="refactorForm.config_path" placeholder="不修改留空" />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">端口与VNC</div>
<div class="tk-resource-grid">
<el-form-item label="SSH端口">
<el-input-number v-model="refactorForm.ssh_port" :min="0" :max="65535" controls-position="right" />
</el-form-item>
<el-form-item label="VNC端口">
<el-input-number v-model="refactorForm.vnc_port" :min="0" :max="65535" controls-position="right" />
</el-form-item>
</div>
<el-form-item label="VNC密码">
<el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">网络配置</div>
<el-form-item label="网络">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: 100%">
<el-tag v-for="net in refactorSelectedNetworks" :key="net.id" closable @close="removeRefactorNetwork(net.id)">
{{ net.name }} (ID:{{ net.id }})
</el-tag>
<el-button size="small" @click="showRefactorNetworkSelector = true">添加网络</el-button>
</div>
</el-form-item>
<el-form-item label="内网">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-tag v-if="refactorSelectedInternalNetworks.length" closable @close="removeRefactorInternalNetwork(refactorSelectedInternalNetworks[0].id)">
{{ refactorSelectedInternalNetworks[0].name }} (ID:{{ refactorSelectedInternalNetworks[0].id }})
</el-tag>
<el-button size="small" @click="showRefactorInternalNetworkSelector = true">{{ refactorSelectedInternalNetworks.length ? '更换内网' : '选择内网' }}</el-button>
</div>
</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>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="refactorDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="actionLoading" @click="submitRefactorVm">确定重构</el-button>
</div>
</template>
</el-dialog>
<!-- 重构用网络选择器(外网 bridge -->
<NetworkSelectorPopup ref="refactorNetSelectorRef" v-model="showRefactorNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleRefactorNetworkConfirm" @create="() => handleNetCreate('refactor')" />
<!-- 重构用内网选择器(内网 nat -->
<NetworkSelectorPopup ref="refactorInternalNetSelectorRef" v-model="showRefactorInternalNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleRefactorInternalNetworkConfirm" @create="() => handleNetCreate('refactorInternal')" />
<!-- VNC 连接弹窗 -->
<el-dialog v-model="vncDialogVisible" title="获取 VNC 连接" width="560px" destroy-on-close class="tk-dialog">
<el-form label-width="100px" v-loading="dialogOptionsLoading">
<div class="tk-section">
<div class="tk-section-title">VNC 配置</div>
<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>
</div>
</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>
<div class="tk-dialog-footer">
<el-button @click="vncDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="vncLoading" @click="submitGetVnc">获取</el-button>
</div>
</template>
</el-dialog>
<!-- 修改带宽弹窗 -->
<el-dialog v-model="trafficDialogVisible" title="修改虚拟机带宽" width="640px" destroy-on-close class="tk-dialog">
<el-form :model="trafficForm" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">带宽与流量</div>
<div class="tk-resource-grid">
<el-form-item label="下行带宽">
<el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
<el-form-item label="上行带宽">
<el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
</div>
<el-form-item label="流量上限">
<div class="tk-inline-unit">
<el-input-number v-model="trafficForm._trafficGB" :min="0" :precision="2" controls-position="right" /><span class="tk-res-unit">GB</span>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="trafficDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitUpdateTraffic">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 迁移虚拟机弹窗 -->
<el-dialog v-model="migrateDialogVisible" title="迁移虚拟机更换宿主机" width="580px" destroy-on-close class="tk-dialog">
<el-form label-width="100px" v-loading="migrateOptionsLoading">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">将虚拟机迁移到其他宿主机或宿主机组,请谨慎操作!</el-alert>
<div class="tk-section">
<div class="tk-section-title">迁移配置</div>
<!-- <el-form-item label="当前宿主机">
<el-tag>{{ detail?.host_name || detail?.host_id || '-' }}</el-tag>
</el-form-item> -->
<el-form-item label="迁移方式">
<el-radio-group v-model="migrateMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="目标宿主机" v-if="migrateMode === 'host'">
<el-select v-model="migrateForm.target_host_id" placeholder="选择目标宿主机" filterable style="width: 100%">
<el-option v-for="h in migrateHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === detail?.host_id" />
</el-select>
</el-form-item>
<el-form-item label="目标宿主机组" v-if="migrateMode === 'group'">
<el-select v-model="migrateForm.target_host_group_id" placeholder="选择目标宿主机组" filterable style="width: 100%">
<el-option v-for="g in migrateGroupOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
</el-select>
</el-form-item>
<div class="tk-resource-grid">
<el-form-item label="IPv4数量">
<el-input-number v-model="migrateForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
</el-form-item>
<el-form-item label="IPv6数量">
<el-input-number v-model="migrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
</el-form-item>
</div>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="migrateDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="actionLoading" @click="submitMigrateVm">确定迁移</el-button>
</div>
</template>
</el-dialog>
<!-- 数据迁移弹窗 -->
<el-dialog v-model="dataMigrateVisible" title="发起虚拟机数据迁移" width="600px" destroy-on-close class="tk-dialog">
<el-form :model="dataMigrateForm" label-width="100px" v-loading="dataMigrateLoading">
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
源服务的虚拟机数据将通过 rsync 传输到目标服务的宿主机上,完成后在目标宿主机上校验并恢复虚拟机。
</el-alert>
<div class="tk-section">
<div class="tk-section-title">源信息</div>
<el-form-item label="主控服务">
<el-input :model-value="`${serviceName} (ID: ${serviceId})`" disabled />
</el-form-item>
<el-form-item label="虚拟机">
<el-input :model-value="`${detail?.name || ''} (ID: ${vmId})`" disabled />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">目标信息</div>
<el-form-item label="主控服务" required>
<el-select v-model="dataMigrateForm.target_service_id" placeholder="选择目标主控服务" filterable style="width:100%"
@change="dataMigrateForm.target_host_id = null; loadDataMigrateHosts()">
<el-option v-for="s in dataMigrateServiceOptions" :key="s.id" :label="`${s.name} (ID:${s.id})`" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="宿主机" required>
<el-select v-model="dataMigrateForm.target_host_id" placeholder="选择目标宿主机" filterable style="width:100%"
:disabled="!dataMigrateForm.target_service_id" :loading="dataMigrateHostsLoading"
@change="dataMigrateForm.network_ids = []; dataMigrateSelectedNetworks = []; loadDataMigrateNetworks()">
<el-option v-for="h in dataMigrateHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<div class="tk-resource-grid">
<el-form-item label="IPv4数量">
<el-input-number v-model="dataMigrateForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
</el-form-item>
<el-form-item label="IPv6数量">
<el-input-number v-model="dataMigrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
</el-form-item>
</div>
<el-form-item label="网络ID">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%">
<el-tag v-for="n in dataMigrateSelectedNetworks" :key="n.id" closable @close="removeDataMigrateNetwork(n.id)">
{{ n.name || n.id }} (ID:{{ n.id }})
</el-tag>
<el-button size="small" :disabled="!dataMigrateForm.target_host_id" @click="showDataMigrateNetworkSelector = true">选择网络</el-button>
</div>
<span class="tk-form-tip">在目标宿主机监听的网络ID列表(可选)</span>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="dataMigrateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitDataMigrate">发起迁移</el-button>
</div>
</template>
</el-dialog>
<!-- 数据迁移用网络选择器 -->
<NetworkSelectorPopup ref="dataMigrateNetSelectorRef" v-model="showDataMigrateNetworkSelector"
:service-id="dataMigrateForm.target_service_id || 0"
:host-id="dataMigrateForm.target_host_id || 0"
filter-type="bridge" filter-used="false" :multiple="true"
@confirm="handleDataMigrateNetworkConfirm"
@create="() => handleNetCreate('dataMigrate')" />
<!-- 数据迁移进度弹窗 -->
<el-dialog v-model="dataMigrateProgressVisible" title="数据迁移进度" width="560px" destroy-on-close>
<div v-loading="dataMigrateProgressLoading">
<el-descriptions :column="2" border size="small" v-if="dataMigrateProgressData">
<el-descriptions-item label="阶段" :span="2">
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="导出任务ID" :span="2" v-if="dataMigrateProgressData.export_task_id">
<span style="font-family:monospace;font-size:12px">{{ dataMigrateProgressData.export_task_id }}</span>
</el-descriptions-item>
<el-descriptions-item label="导入任务ID" :span="2" v-if="dataMigrateProgressData.import_task_id">
<span style="font-family:monospace;font-size:12px">{{ dataMigrateProgressData.import_task_id }}</span>
</el-descriptions-item>
<el-descriptions-item label="源主控服务">{{ dataMigrateProgressData.source_service_id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="目标主控服务">{{ dataMigrateProgressData.target_service_id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="源宿主机">{{ dataMigrateProgressData.source_host_id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="目标宿主机">{{ dataMigrateProgressData.target_host_id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="进度" :span="2" v-if="dataMigrateProgressData.progress != null">
<el-progress :percentage="dataMigrateProgressData.progress" :status="migrateProgressBarStatus(dataMigrateProgressData.stage)" style="width:100%" />
</el-descriptions-item>
<el-descriptions-item label="速度" v-if="dataMigrateProgressData.speed">{{ dataMigrateProgressData.speed }}</el-descriptions-item>
<el-descriptions-item label="信息" :span="dataMigrateProgressData.speed ? 1 : 2" v-if="dataMigrateProgressData.message">{{ dataMigrateProgressData.message }}</el-descriptions-item>
<el-descriptions-item label="详情" :span="2" v-if="dataMigrateProgressData.detail">{{ dataMigrateProgressData.detail }}</el-descriptions-item>
<el-descriptions-item label="错误" :span="2" v-if="dataMigrateProgressData.error">
<span style="color:#f56c6c">{{ dataMigrateProgressData.error }}</span>
</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!dataMigrateProgressLoading" description="暂无进度信息" :image-size="60" />
</div>
<template #footer>
<el-button @click="dataMigrateProgressVisible = false">关闭</el-button>
<el-button :icon="Refresh" @click="loadDataMigrateProgress" :loading="dataMigrateProgressLoading">刷新进度</el-button>
<el-button type="danger" @click="handleAbortMigrate" :loading="abortLoading" v-if="isMigrating">中断迁移</el-button>
</template>
</el-dialog>
<!-- 绑定外网选择器(bridge -->
<NetworkSelectorPopup ref="bindBridgeNetSelectorRef" v-model="showNetBindBridgeSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleNetBindBridgeConfirm" @create="() => handleNetCreate('bindBridge')" />
<!-- 绑定内网选择器(nat -->
<NetworkSelectorPopup ref="bindNatNetSelectorRef" v-model="showNetBindNatSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleNetBindNatConfirm" @create="() => handleNetCreate('bindNat')" />
<!-- 创建/编辑网络弹窗 -->
<el-dialog v-model="netDialogVisible" :title="netDialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close class="tk-dialog">
<el-form ref="netFormRef" :model="netForm" :rules="netFormRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name"><el-input v-model="netForm.name" placeholder="网络名称" /></el-form-item>
<el-form-item label="网络类型" prop="type">
<el-select v-model="netForm.type" style="width: 100%">
<el-option label="网桥(Bridge/外网)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
</el-form-item>
<el-form-item label="IP" prop="address">
<div class="tk-inline-unit">
<el-input v-model="netForm.address" placeholder="例如 192.168.1.0/24" />
<span class="tk-res-unit">CIDR</span>
</div>
</el-form-item>
<el-form-item label="网关地址" prop="gateway"><el-input v-model="netForm.gateway" placeholder="例如 192.168.1.1" /></el-form-item>
<el-form-item label="DNS 服务器"><el-input v-model="netForm.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" /></el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">高级配置</div>
<el-form-item label="MAC 地址"><el-input v-model="netForm.mac_address" placeholder="不填则随机" /></el-form-item>
<el-form-item label="虚拟网桥名"><el-input v-model="netForm.bridge_name" placeholder="不填使用默认" /></el-form-item>
<el-form-item label="逻辑网桥名"><el-input v-model="netForm.ls_bridge_name" placeholder="不填使用默认" /></el-form-item>
<el-form-item label="逻辑端口名"><el-input v-model="netForm.ls_name" placeholder="不填使用默认" /></el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="handleNetDialogCancel">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitNetForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 网络详情弹窗 -->
<el-dialog v-model="netDetailVisible" title="网络详情" width="600px" destroy-on-close>
<el-descriptions :column="2" border v-if="netDetailData">
<el-descriptions-item label="ID">{{ netDetailData.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ netDetailData.name }}</el-descriptions-item>
<el-descriptions-item label="类型"><el-tag :type="netDetailData.type === 'bridge' ? 'success' : 'warning'" size="small">{{ netDetailData.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag></el-descriptions-item>
<el-descriptions-item label="地址">{{ netDetailData.address }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ netDetailData.gateway }}</el-descriptions-item>
<el-descriptions-item label="DNS">{{ netDetailData.nameservers || '-' }}</el-descriptions-item>
<el-descriptions-item label="MAC 地址">{{ netDetailData.mac_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="虚拟网桥">{{ netDetailData.bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="逻辑网桥">{{ netDetailData.ls_bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="逻辑端口">{{ netDetailData.ls_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标设备">{{ netDetailData.target_device || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer><el-button @click="netDetailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 数据卷选择器 -->
<VolumeSelectorPopup v-model="showVolSelector" :service-id="serviceId" :host-id="vmHostId" @confirm="handleVolBindConfirm" @create="handleVolCreateFromSelector" />
<!-- 创建数据卷弹窗 -->
<el-dialog v-model="volCreateVisible" title="创建数据卷" width="560px" :close-on-click-modal="false" class="tk-dialog">
<el-form ref="volCreateFormRef" :model="volCreateForm" :rules="volCreateRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">卷信息</div>
<el-form-item label="名称" prop="name"><el-input v-model="volCreateForm.name" placeholder="数据卷名称" /></el-form-item>
<el-form-item label="大小" prop="size">
<div class="tk-inline-unit">
<el-input-number v-model="volCreateForm.size" :min="1" controls-position="right" /><span class="tk-res-unit">GB</span>
</div>
</el-form-item>
<el-form-item label="宿主机">
<el-input :model-value="vmHostId ? `宿主机 #${vmHostId}` : '-'" disabled style="width: 100%" />
</el-form-item>
<el-form-item label="系统卷"><el-switch v-model="volCreateForm.is_system" /></el-form-item>
<el-form-item v-if="volCreateForm.is_system" label="镜像">
<div class="bind-selector-row">
<el-input :model-value="volCreateForm.image_id ? `镜像 #${volCreateForm.image_id}${volCreateForm._imageName ? ' - ' + volCreateForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="volImageSelectorFromCreate = true; volCreateVisible = false; showVolImageSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="volCreateForm.image_id" @click="volCreateForm.image_id = 0; volCreateForm._imageName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="虚拟机">
<el-input :model-value="detail ? `${detail.name || ''} (ID: ${vmId})` : '未选择'" disabled style="width: 100%" />
</el-form-item>
<el-form-item label="目标设备名"><el-input v-model="volCreateForm.target_device" placeholder="不填自动生成" /></el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="volCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitVolCreate">确定</el-button>
</div>
</template>
</el-dialog>
<ImageSelectorPopup v-model="showVolImageSelector" :service-id="serviceId" :current-id="volCreateForm.image_id" @confirm="handleVolImageSelected" />
<!-- 调整卷大小弹窗 -->
<el-dialog v-model="volResizeVisible" title="调整数据卷大小" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<div class="tk-section">
<div class="tk-section-title">调整信息</div>
<el-form-item label="卷名称">{{ volResizeTarget?._name || '-' }}</el-form-item>
<el-form-item label="当前大小">{{ volResizeTarget?._currentSize || 0 }} GB</el-form-item>
<el-form-item label="新大小">
<div class="tk-inline-unit">
<el-input-number v-model="volNewSize" :min="volResizeTarget?._currentSize || 1" controls-position="right" /><span class="tk-res-unit">GB</span>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="volResizeVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitVolResize">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 迁移卷弹窗 -->
<el-dialog v-model="volTransferVisible" title="迁移数据卷" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px" v-loading="dialogOptionsLoading">
<div class="tk-section">
<div class="tk-section-title">迁移信息</div>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px">将数据卷迁移到另一台虚拟机上</el-alert>
<el-form-item label="卷名称">{{ volTransferTarget?._name || '-' }}</el-form-item>
<el-form-item label="目标虚拟机" required>
<el-select v-model="volTransferVmId" 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>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="volTransferVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitVolTransfer">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 数据卷详情弹窗 -->
<el-dialog v-model="volDetailVisible" title="数据卷详情" width="600px" destroy-on-close>
<el-descriptions :column="2" border v-if="volDetailData">
<el-descriptions-item label="ID">{{ volDetailData.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ volDetailData.name }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ volDetailData.size }} GB</el-descriptions-item>
<el-descriptions-item label="类型"><el-tag :type="volDetailData.is_system ? 'danger' : 'info'" size="small">{{ volDetailData.is_system ? '系统盘' : '数据盘' }}</el-tag></el-descriptions-item>
<el-descriptions-item label="挂载状态"><el-tag :type="volDetailData.is_mount ? 'success' : 'info'" size="small">{{ volDetailData.is_mount ? '已挂载' : '未挂载' }}</el-tag></el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="volDetailData.status === 'ready' ? 'success' : 'info'" size="small">{{ volDetailData.status === 'ready' ? '就绪' : (volDetailData.status || '-') }}</el-tag></el-descriptions-item>
<el-descriptions-item label="宿主机ID">{{ volDetailData.host_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="宿主机卷ID">{{ volDetailData.host_volume_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ volDetailData.path || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="目标设备" v-if="volDetailData.target_device">{{ volDetailData.target_device }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ volDetailData.created_at ? new Date(Number(volDetailData.created_at.seconds) * 1000).toLocaleString('zh-CN') : '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ volDetailData.updated_at ? new Date(Number(volDetailData.updated_at.seconds) * 1000).toLocaleString('zh-CN') : '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer><el-button @click="volDetailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 绑定安全组到当前虚拟机 -->
<SecurityGroupSelectorPopup v-model="sgBindVisible" :service-id="serviceId" @confirm="handleSgBindConfirm" @create="handleSgCreate" />
<!-- 创建安全组弹窗 -->
<el-dialog v-model="sgCreateDialogVisible" title="创建安全组" width="520px" destroy-on-close class="tk-dialog">
<el-form ref="sgCreateFormRef" :model="sgCreateForm" :rules="sgCreateRules" label-width="100px" v-loading="dialogOptionsLoading">
<div class="tk-section">
<div class="tk-section-title">安全组信息</div>
<el-form-item label="名称" prop="name">
<el-input v-model="sgCreateForm.name" placeholder="安全组名称" />
</el-form-item>
<el-form-item label="方向" prop="direction">
<el-select v-model="sgCreateForm.direction" placeholder="选择规则方向" style="width: 100%">
<el-option label="入站 (in)" value="in" />
<el-option label="出站 (out)" value="out" />
</el-select>
</el-form-item>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="sgCreateForm.host_id" placeholder="选择宿主机" filterable clearable style="width: 100%">
<el-option v-for="h in sgHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="锁定">
<el-switch v-model="sgCreateForm.lock" active-text="用户不可修改" />
</el-form-item>
<el-form-item label="白名单模式">
<el-switch v-model="sgCreateForm.drop_all" active-text="开启白名单" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="sgCreateDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="sgSubmitLoading" @click="submitSgCreate">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 同步安全组弹窗 -->
<el-dialog v-model="sgSyncDialogVisible" title="同步安全组到宿主机" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px" v-loading="dialogOptionsLoading">
<div class="tk-section">
<div class="tk-section-title">同步配置</div>
<el-form-item label="安全组">{{ sgSyncTarget?.name || '-' }}</el-form-item>
<el-form-item label="目标宿主机">
<el-select v-model="sgSyncHostId" placeholder="选择宿主机" filterable style="width: 100%">
<el-option v-for="h in sgHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="sgSyncDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="sgSubmitLoading" @click="submitSgSync">同步</el-button>
</div>
</template>
</el-dialog>
<!-- 安全组绑定/解绑虚拟机弹窗 -->
<el-dialog v-model="sgVmBindDialogVisible" :title="sgVmBindType === 'bind' ? '绑定安全组到虚拟机' : '解绑安全组'" width="420px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="安全组">{{ sgVmBindTarget?.name || '-' }}</el-form-item>
<el-form-item label="虚拟机">
<el-input :model-value="detail ? `${detail.name || ''} (ID: ${vmId})` : '-'" disabled style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sgVmBindDialogVisible = false">取消</el-button>
<el-button :type="sgVmBindType === 'bind' ? 'primary' : 'warning'" :loading="sgSubmitLoading" @click="submitSgVmBind">
{{ sgVmBindType === 'bind' ? '绑定' : '解绑' }}
</el-button>
</template>
</el-dialog>
<!-- 安全组详情+规则弹窗 -->
<el-dialog v-model="sgDetailVisible" title="安全组详情 & 规则" width="800px" destroy-on-close>
<el-descriptions :column="2" border v-if="sgCurrentDetail" v-loading="sgDetailLoading" style="margin-bottom: 20px">
<el-descriptions-item label="ID" width="100px">{{ sgCurrentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="名称" width="100px">{{ sgCurrentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="锁定" width="100px">
<el-tag :type="sgCurrentDetail.lock ? 'danger' : 'success'" size="small">{{ sgCurrentDetail.lock ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="白名单" width="100px">
<el-tag :type="sgCurrentDetail.drop_all ? 'warning' : 'info'" size="small">{{ sgCurrentDetail.drop_all ? '开启' : '关闭' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="方向">
<el-tag :type="sgCurrentDetail.direction === 'in' ? 'success' : 'warning'" size="small">{{ sgCurrentDetail.direction === 'in' ? '入站' : sgCurrentDetail.direction === 'out' ? '出站' : (sgCurrentDetail.direction || '-') }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="rules-section">
<div class="rules-header">
<h4>安全组规则</h4>
<el-button type="primary" size="small" @click="handleSgAddRule">新增规则</el-button>
</div>
<el-table :data="sgCurrentDetail?.rules || []" stripe size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="protocol" label="协议" width="80">
<template #default="{ row }">
<el-tag size="small">{{ (row.protocol || '-').toUpperCase() }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="action" label="动作" width="80">
<template #default="{ row }">
<el-tag :type="row.action === 'allow' ? 'success' : 'danger'" size="small">{{ row.action === 'allow' ? '允许' : '拒绝' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="port_range" label="端口范围" min-width="120" />
<el-table-column prop="ip_range" label="IP 范围" min-width="140" />
<el-table-column prop="priority" label="优先级" width="80" />
<el-table-column label="操作" width="130">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleSgEditRule(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleSgDeleteRule(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer><el-button @click="sgDetailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 新增/编辑安全组规则弹窗 -->
<el-dialog v-model="sgRuleDialogVisible" :title="sgRuleDialogType === 'add' ? '新增安全组规则' : '编辑安全组规则'" width="520px" destroy-on-close class="tk-dialog">
<el-form ref="sgRuleFormRef" :model="sgRuleForm" :rules="sgRuleRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">规则配置</div>
<el-form-item label="协议" prop="protocol">
<el-select v-model="sgRuleForm.protocol" style="width: 100%">
<el-option label="TCP" value="tcp" />
<el-option label="UDP" value="udp" />
</el-select>
</el-form-item>
<el-form-item label="动作" prop="action">
<el-select v-model="sgRuleForm.action" style="width: 100%">
<el-option label="允许 (Allow)" value="allow" />
<el-option label="拒绝 (Deny)" value="deny" />
</el-select>
</el-form-item>
<el-form-item label="端口范围">
<el-input v-model="sgRuleForm.port_range" placeholder=" 80 80-90" />
</el-form-item>
<el-form-item label="IP 范围">
<el-input v-model="sgRuleForm.ip_range" placeholder=" 0.0.0.0/0 192.168.1.0/24" />
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="sgRuleForm.priority" :min="0" :max="9999" style="width: 100%" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="sgRuleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="sgSubmitLoading" @click="submitSgRule">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 加入组网弹窗 -->
<el-dialog v-model="vnJoinVisible" title="加入组网" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<div class="tk-section">
<div class="tk-section-title">组网信息</div>
<el-form-item label="组网">{{ vnJoinTarget?.name || '-' }} (ID: {{ vnJoinTarget?.id }})</el-form-item>
<el-form-item label="网桥"><span class="mono-text">{{ vnJoinTarget?.bridge_name || '-' }}</span></el-form-item>
<el-form-item label="网关"><span class="mono-text">{{ vnJoinTarget?.gateway || '-' }}</span></el-form-item>
<el-form-item label="指定IP">
<el-input v-model="vnJoinIp" placeholder="留空自动分配" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="vnJoinVisible = false">取消</el-button>
<el-button type="primary" :loading="vnSubmitLoading" @click="submitJoinNetworking">加入</el-button>
</div>
</template>
</el-dialog>
<!-- 创建组网弹窗 -->
<el-dialog v-model="vnCreateVisible" title="创建组网" width="500px" destroy-on-close class="tk-dialog">
<el-form ref="vnCreateFormRef" :model="vnCreateForm" :rules="vnCreateRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">组网配置</div>
<el-form-item label="名称" prop="name">
<el-input v-model="vnCreateForm.name" placeholder="组网名称" />
</el-form-item>
<el-form-item label="网桥名称" prop="bridge_name">
<el-input v-model="vnCreateForm.bridge_name" placeholder="网桥名称" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="vnCreateForm.gateway" placeholder="可选 10.0.0.1/24" />
</el-form-item>
<el-form-item label="宿主机">
<span style="color: #606266">{{ vnCreateHostName || '加载中...' }} <span style="color: #909399">(ID: {{ vmHostId }})</span></span>
</el-form-item>
<el-form-item label="用户">
<span style="color: #606266">{{ vnCreateUserName || '加载中...' }} <span style="color: #909399">(ID: {{ detail?.user_id || '-' }})</span></span>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="vnCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="vnSubmitLoading" @click="submitVnCreate">创建</el-button>
</div>
</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, Plus, Search, WarningFilled, Loading } from '@element-plus/icons-vue'
import {
getVmDetail, getVmStatus,
startVm, stopVm, rebootVm, suspendVm, resumeVm,
rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm,
getVmVnc, getVncNodeList,
getSecurityGroupList, getUserNetworkingList, createUserNetworking, assignUserNetworking, removeUserNetworkingNetwork,
getRemoteHostList, getSecurityGroupDetail, createSecurityGroup,
syncSecurityGroup, deleteSecurityGroup,
enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule,
createNetwork, updateNetwork, deleteNetwork,
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume,
getVmList, bindSecurityGroup, unbindSecurityGroup,
getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getSnapshotCount, setSnapshotLimit,
getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getBackupCount, setBackupLimit,
migrateVm, getRemoteHostGroupList, getRemoteHostDetail,
dataMigrateVm, getDataMigrateProgress, abortDataMigrate,
getKvmServiceList, getMetricsHistory, getNetworkList
} from '@/api/admin/kvmService'
import { getUserInfo } from '@/api/admin/user'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
import * as echarts from 'echarts'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
import SecurityGroupSelectorPopup from '@/components/admin/SecurityGroupSelectorPopup.vue'
import VolumeSelectorPopup from '@/components/admin/VolumeSelectorPopup.vue'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.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) || 0)
const loading = ref(false)
const actionLoading = ref(false)
const statusLoading = ref(false)
const dialogOptionsLoading = ref(false)
const detail = ref(null)
const vmHostId = ref(0)
const vmNetworks = ref([])
const vmVolumes = ref([])
const vmImage = ref(null)
const vmPortGroup = ref(null)
const vmOutPortGroup = 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 extractIp = (addr) => addr ? addr.split('/')[0] : ''
const publicIpList = computed(() => vmNetworks.value.filter(n => n.type === 'bridge').map(n => extractIp(n.address)).filter(Boolean))
const privateIpList = computed(() => vmNetworks.value.filter(n => n.type === 'nat').map(n => extractIp(n.address)).filter(Boolean))
const publicIps = computed(() => publicIpList.value.join(', '))
const privateIps = computed(() => privateIpList.value.join(', '))
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 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,
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue,
migrateVm: handleMigrateVm
}
if (actionMap[cmd]) actionMap[cmd]()
if (cmd === 'dataMigrateVm') handleDataMigrateVm()
if (cmd === 'dataMigrateProgress') { dataMigrateProgressVisible.value = true; loadDataMigrateProgress() }
if (cmd === 'abortDataMigrate') handleAbortMigrate()
}
const vmStatusType = (s) => vmStatusTypeUtil(s)
const vmStatusLabel = (s) => vmStatusLabelUtil(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: 10 })
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
vmOutPortGroup.value = d.out_port_group || null
vmHostId.value = detail.value?.host_id || vmVolumes.value[0]?.host_id || vmNetworks.value[0]?.host_id || 0
handleDetailMigrateState()
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
}
const handleDetailMigrateState = () => {
const vm = detail.value
if (!vm) return
if (vm.migrating && vm.migrate_task_id) {
dataMigrateTaskId.value = vm.migrate_task_id
if (!dataMigrationId.value) dataMigrationId.value = vm.migrate_task_id
if (!migratePollingTimer) {
loadDataMigrateProgress()
startMigratePolling()
}
startDetailAutoRefresh()
} else if (!vm.migrating) {
stopDetailAutoRefresh()
if (migratePollingTimer) {
stopMigratePolling()
dataMigrateProgressData.value = null
dataMigrationId.value = ''
dataMigrateTaskId.value = ''
}
}
}
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 outer = res.data.data
const inner = outer.data ?? outer
const state = inner.state ?? inner.status ?? inner
const desc = inner.desc || ''
detail.value = { ...detail.value, status: state }
ElMessage.success(`状态已刷新: ${desc || vmStatusLabel(state)}`)
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) } finally { statusLoading.value = false }
}
// ---- ECharts 监控 ----
const cpuChartRef = ref(null)
const memChartRef = ref(null)
const netChartRef = ref(null)
const diskChartRef = ref(null)
let cpuChart = null
let memChart = null
let netChart = null
let diskChart = null
let isPageActive = false
const historicalMetricsData = ref(null)
const historicalMetricsLoading = ref(false)
const historyTimeRange = ref('1h')
const historyTimeOptions = [
{ label: '最近1分钟', value: '1m' },
{ label: '最近5分钟', value: '5m' },
{ label: '最近1小时', value: '1h' },
{ label: '最近1天', value: '1d' }
]
const latestMetrics = computed(() => {
const arr = historicalMetricsData.value
if (!Array.isArray(arr) || !arr.length) return null
return arr[arr.length - 1]
})
const formatBytesRaw = (val) => {
if (!val && val !== 0) return '-'; val = Number(val)
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
return val + ' B'
}
const formatNetLabel = (v) => {
if (!v) return '0 B/s'
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
return v + ' B/s'
}
const formatMemKB = (kb) => {
if (!kb && kb !== 0) 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 vmMemPercent = (m) => {
if (!m || !m.mem_total) return '0.0'
return ((m.mem_used / m.mem_total) * 100).toFixed(1)
}
const loadHistoricalMetrics = async () => {
if (!serviceId.value || !detail.value?.name) return
historicalMetricsLoading.value = true
try {
const now = new Date()
let startTime = new Date()
switch (historyTimeRange.value) {
case '1m': startTime.setMinutes(now.getMinutes() - 1); break
case '5m': startTime.setMinutes(now.getMinutes() - 5); break
case '1h': startTime.setHours(now.getHours() - 1); break
case '1d': startTime.setDate(now.getDate() - 1); break
}
const params = {
service_id: serviceId.value,
host_id: vmHostId.value,
vm_name: detail.value.name,
start: startTime.toISOString(),
end_time: now.toISOString(),
interval: { '1m': '1m', '5m': '5m', '1h': '1h', '1d': '1d' }[historyTimeRange.value] || '5m'
}
const res = await getMetricsHistory(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
historicalMetricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
await nextTick()
renderHistoricalCharts()
} else {
ElMessage.error(extractApiError(body, '加载监控数据失败'))
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '加载监控数据失败'))
} finally {
historicalMetricsLoading.value = false
}
}
const renderHistoricalCharts = () => {
const metrics = historicalMetricsData.value
if (!Array.isArray(metrics) || !metrics.length) return
const range = historyTimeRange.value
const showDate = range === '1d'
const symbolType = showDate ? 'circle' : 'none'
const labelRotate = showDate ? 45 : 0
const times = metrics.map(m => {
const date = new Date(m.bucket)
if (showDate) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
})
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0)
const diskReadData = metrics.map(m => m.disk_read ?? 0)
const diskWriteData = metrics.map(m => m.disk_write ?? 0)
const netRxData = metrics.map(m => m.net_rx ?? 0)
const netTxData = metrics.map(m => m.net_tx ?? 0)
const baseGrid = { top: 10, right: 16, bottom: 24, left: 50 }
const makeXAxis = () => ({ type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10, rotate: labelRotate } })
const makeSeries = (name, data, color) => ({ name, type: 'line', smooth: true, symbol: symbolType, areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data })
if (cpuChartRef.value) {
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
cpuChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} CPU: ${p[0].value.toFixed(1)}%` },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
series: [makeSeries('CPU', cpuData, '#409eff')]
}, true)
}
if (memChartRef.value) {
if (!memChart) memChart = echarts.init(memChartRef.value)
memChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
series: [makeSeries('内存', memData, '#67c23a')]
}, true)
}
if (diskChartRef.value) {
if (!diskChart) diskChart = echarts.init(diskChartRef.value)
diskChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0].axisValue
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}` })
return s
}},
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) } },
series: [makeSeries('读取', diskReadData, '#409eff'), makeSeries('写入', diskWriteData, '#e6a23c')]
}, true)
}
if (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value)
netChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0].axisValue
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
return s
}},
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatNetLabel(v) } },
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
}, true)
}
}
const disposeCharts = () => {
cpuChart?.dispose(); cpuChart = null
memChart?.dispose(); memChart = null
netChart?.dispose(); netChart = null
diskChart?.dispose(); diskChart = null
}
const powerDialogVisible = ref(false)
const powerAction = ref('')
const powerForce = ref(false)
const powerLabels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
const handlePower = (action) => {
powerAction.value = action
powerForce.value = false
powerDialogVisible.value = true
}
const submitPower = async () => {
const action = powerAction.value
const label = powerLabels[action]
powerDialogVisible.value = false
try {
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
if (powerForce.value) fd.append('force', true)
const res = await apis[action](fd)
if (res?.data?.code === 200) { ElMessage.success(`${label}成功`); loadDetail() }
else ElMessage.error(extractApiError(res?.data, `${label}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${label}失败`)) }
}
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 fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('image_id', rebuildImageId.value)
const res = await rebuildVm(fd)
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(() => {})
}
// ---- 编辑虚拟机(仅 update API 参数) ----
const editDialogVisible = ref(false)
const editFormRef = ref(null)
const editForm = reactive({
rx_bandwidth: 0, tx_bandwidth: 0, data_volume_size: 0,
root_password: '', ssh_port: 22,
port_group_id: ''
})
const editSelectedNetworks = ref([])
const showEditNetworkSelector = ref(false)
const editSelectedInternalNetworks = ref([])
const showEditInternalNetworkSelector = ref(false)
const handleEditNetworkConfirm = (network) => {
if (!editSelectedNetworks.value.some(n => n.id === network.id)) {
editSelectedNetworks.value = [...editSelectedNetworks.value, { id: network.id, name: network.name }]
}
}
const removeEditNetwork = (id) => {
editSelectedNetworks.value = editSelectedNetworks.value.filter(n => n.id !== id)
}
const handleEditInternalNetworkConfirm = (network) => {
editSelectedInternalNetworks.value = [{ id: network.id, name: network.name }]
}
const removeEditInternalNetwork = (id) => {
editSelectedInternalNetworks.value = editSelectedInternalNetworks.value.filter(n => n.id !== id)
}
const handleEditVm = async () => {
if (!detail.value) return
const d = detail.value
Object.assign(editForm, {
rx_bandwidth: d.rx_bandwidth || 0,
tx_bandwidth: d.tx_bandwidth || 0,
data_volume_size: d.data_volume_size || 0,
root_password: d.root_password || '',
ssh_port: d.ssh_port || 22,
port_group_id: vmPortGroup.value?.id || ''
})
const bridgeNets = vmNetworks.value.filter(n => n.type === 'bridge')
const natNets = vmNetworks.value.filter(n => n.type === 'nat')
editSelectedNetworks.value = bridgeNets.map(n => ({ id: n.id, name: n.name }))
editSelectedInternalNetworks.value = natNets.length ? [{ id: natNets[0].id, name: natNets[0].name }] : []
editDialogVisible.value = true
dialogOptionsLoading.value = true
try {
await Promise.all([
!sgOptions.value.length ? loadSgOptions() : Promise.resolve()
])
} finally { dialogOptionsLoading.value = false }
}
const submitEditVm = 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', editForm.rx_bandwidth)
fd.append('tx_bandwidth', editForm.tx_bandwidth)
if (editForm.data_volume_size > 0) fd.append('data_volume_size', editForm.data_volume_size)
if (editForm.root_password) fd.append('root_password', editForm.root_password)
fd.append('ssh_port', editForm.ssh_port)
editSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id))
if (editSelectedInternalNetworks.value.length) fd.append('internet_network_id', editSelectedInternalNetworks.value[0].id)
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: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '',
ssh_port: 0, vnc_port: 0, vnc_password: '',
port_group_id: ''
})
const refactorMemUnit = ref(1048576)
const refactorMemDisplay = ref(0)
const refactorSelectedNetworks = ref([])
const showRefactorNetworkSelector = ref(false)
const refactorSelectedInternalNetworks = ref([])
const showRefactorInternalNetworkSelector = ref(false)
const onRefactorMemUnitChange = () => {
refactorMemDisplay.value = refactorForm.memory ? Math.round(refactorForm.memory / refactorMemUnit.value * 100) / 100 : 0
}
watch(refactorMemDisplay, (v) => { refactorForm.memory = Math.round(v * refactorMemUnit.value) })
const handleRefactorNetworkConfirm = (network) => {
if (!refactorSelectedNetworks.value.some(n => n.id === network.id)) {
refactorSelectedNetworks.value = [...refactorSelectedNetworks.value, { id: network.id, name: network.name }]
}
}
const removeRefactorNetwork = (id) => {
refactorSelectedNetworks.value = refactorSelectedNetworks.value.filter(n => n.id !== id)
}
const handleRefactorInternalNetworkConfirm = (network) => {
refactorSelectedInternalNetworks.value = [{ id: network.id, name: network.name }]
}
const removeRefactorInternalNetwork = (id) => {
refactorSelectedInternalNetworks.value = refactorSelectedInternalNetworks.value.filter(n => n.id !== id)
}
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: d.root_password || '',
uuid: d.uuid || '', mate_data_id: d.mate_data_id || '',
physical_name: d.physical_name || '', config_path: d.config_path || '',
ssh_port: d.ssh_port || 0, vnc_port: 0, vnc_password: '',
port_group_id: vmPortGroup.value?.id || ''
})
const bridgeNets = vmNetworks.value.filter(n => n.type === 'bridge')
const natNets = vmNetworks.value.filter(n => n.type === 'nat')
refactorSelectedNetworks.value = bridgeNets.map(n => ({ id: n.id, name: n.name }))
refactorSelectedInternalNetworks.value = natNets.length ? [{ id: natNets[0].id, name: natNets[0].name }] : []
const mem = d.memory || 0
if (mem >= 1048576 && mem % 1048576 === 0) { refactorMemUnit.value = 1048576; refactorMemDisplay.value = mem / 1048576 }
else if (mem >= 1024 && mem % 1024 === 0) { refactorMemUnit.value = 1024; refactorMemDisplay.value = mem / 1024 }
else { refactorMemUnit.value = 1; refactorMemDisplay.value = mem }
refactorDialogVisible.value = true
dialogOptionsLoading.value = true
try {
await Promise.all([
!sgOptions.value.length ? loadSgOptions() : Promise.resolve()
])
} finally { dialogOptionsLoading.value = false }
}
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.uuid) fd.append('uuid', refactorForm.uuid)
if (refactorForm.mate_data_id) fd.append('mate_data_id', refactorForm.mate_data_id)
if (refactorForm.physical_name) fd.append('physical_name', refactorForm.physical_name)
if (refactorForm.config_path) fd.append('config_path', refactorForm.config_path)
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)
refactorSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id))
if (refactorSelectedInternalNetworks.value.length) fd.append('internet_network_id', refactorSelectedInternalNetworks.value[0].id)
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, _trafficGB: 0 })
const handleUpdateTraffic = () => {
if (!detail.value) return
Object.assign(trafficForm, {
rx_bandwidth: detail.value.rx_bandwidth || 0,
tx_bandwidth: detail.value.tx_bandwidth || 0,
_trafficGB: ((detail.value.traffic_max || 0) / 1024).toFixed(2) * 1
})
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._trafficGB) fd.append('traffic_max', Math.round(trafficForm._trafficGB * 1024)) // GB → Mb
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 }
}
// ---- 迁移虚拟机 ----
const migrateDialogVisible = ref(false)
const migrateOptionsLoading = ref(false)
const migrateMode = ref('host')
const migrateHostOptions = ref([])
const migrateGroupOptions = ref([])
const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
const handleMigrateVm = async () => {
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
migrateMode.value = 'host'
migrateDialogVisible.value = true
migrateOptionsLoading.value = true
try {
const [hostRes, groupRes] = await Promise.all([
getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 }),
getRemoteHostGroupList({ service_id: serviceId.value, page: 1, page_size: 10 })
])
if (hostRes?.data?.code === 200 && hostRes?.data?.data) {
const inner = hostRes.data.data
migrateHostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || [])
}
if (groupRes?.data?.code === 200 && groupRes?.data?.data) {
const inner = groupRes.data.data
migrateGroupOptions.value = Array.isArray(inner) ? inner : (inner.host_groups || inner.data || [])
}
} catch { /* */ } finally { migrateOptionsLoading.value = false }
}
const submitMigrateVm = async () => {
if (migrateMode.value === 'host' && !migrateForm.target_host_id) { ElMessage.warning('请选择目标宿主机'); return }
if (migrateMode.value === 'group' && !migrateForm.target_host_group_id) { ElMessage.warning('请选择目标宿主机组'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
if (migrateMode.value === 'host') fd.append('target_host_id', migrateForm.target_host_id)
else fd.append('target_host_group_id', migrateForm.target_host_group_id)
if (migrateForm.ipv4_num) fd.append('ipv4_num', migrateForm.ipv4_num)
if (migrateForm.ipv6_num) fd.append('ipv6_num', migrateForm.ipv6_num)
const res = await migrateVm(fd)
if (res?.data?.code === 200) { ElMessage.success('迁移成功'); migrateDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
}
// ---- 数据迁移 ----
const dataMigrateVisible = ref(false)
const dataMigrateLoading = ref(false)
const dataMigrateHostsLoading = ref(false)
const dataMigrateServiceOptions = ref([])
const dataMigrateHostOptions = ref([])
const dataMigrateProgressVisible = ref(false)
const dataMigrateProgressLoading = ref(false)
const dataMigrateProgressData = ref(null)
const dataMigrationId = ref('')
const dataMigrateTaskId = ref('')
const dataMigrateForm = reactive({ target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
const dataMigrateNetworkOptions = ref([])
const showDataMigrateNetworkSelector = ref(false)
const dataMigrateNetSelectorRef = ref(null)
const editNetSelectorRef = ref(null)
const editInternalNetSelectorRef = ref(null)
const refactorNetSelectorRef = ref(null)
const refactorInternalNetSelectorRef = ref(null)
const bindBridgeNetSelectorRef = ref(null)
const bindNatNetSelectorRef = ref(null)
const dataMigrateSelectedNetworks = ref([])
const handleDataMigrateNetworkConfirm = (items) => {
const arr = Array.isArray(items) ? items : [items]
const existIds = new Set(dataMigrateSelectedNetworks.value.map(n => n.id))
arr.forEach(n => { if (!existIds.has(n.id)) dataMigrateSelectedNetworks.value.push(n) })
dataMigrateForm.network_ids = dataMigrateSelectedNetworks.value.map(n => n.id)
}
const removeDataMigrateNetwork = (id) => {
dataMigrateSelectedNetworks.value = dataMigrateSelectedNetworks.value.filter(n => n.id !== id)
dataMigrateForm.network_ids = dataMigrateSelectedNetworks.value.map(n => n.id)
}
const handleDataMigrateVm = async () => {
Object.assign(dataMigrateForm, { target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
dataMigrateSelectedNetworks.value = []
dataMigrateNetworkOptions.value = []
dataMigrateVisible.value = true
dataMigrateLoading.value = true
try {
const res = await getKvmServiceList({ page: 1, count: 10 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
dataMigrateServiceOptions.value = raw.map(s => ({ id: s.id ?? s.Id, name: s.name ?? s.Name }))
}
} catch { /* */ } finally { dataMigrateLoading.value = false }
}
const loadDataMigrateHosts = async () => {
if (!dataMigrateForm.target_service_id) return
dataMigrateHostsLoading.value = true
dataMigrateNetworkOptions.value = []
dataMigrateForm.network_ids = []
dataMigrateSelectedNetworks.value = []
try {
const res = await getRemoteHostList({ service_id: dataMigrateForm.target_service_id, page: 1, page_size: 10 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
dataMigrateHostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || [])
}
} catch { /* */ } finally { dataMigrateHostsLoading.value = false }
}
const loadDataMigrateNetworks = async () => {
if (!dataMigrateForm.target_service_id || !dataMigrateForm.target_host_id) return
try {
const res = await getNetworkList({ service_id: dataMigrateForm.target_service_id, host_id: dataMigrateForm.target_host_id })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
dataMigrateNetworkOptions.value = Array.isArray(inner) ? inner : (inner.networks || inner.data || [])
}
} catch { dataMigrateNetworkOptions.value = [] }
}
const submitDataMigrate = async () => {
if (!dataMigrateForm.target_service_id) { ElMessage.warning('请选择目标主控服务'); return }
if (!dataMigrateForm.target_host_id) { ElMessage.warning('请选择目标宿主机'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('source_service_id', serviceId.value)
fd.append('source_vm_id', vmId.value)
fd.append('target_service_id', dataMigrateForm.target_service_id)
fd.append('target_host_id', dataMigrateForm.target_host_id)
if (dataMigrateForm.ipv4_num) fd.append('ipv4_num', dataMigrateForm.ipv4_num)
if (dataMigrateForm.ipv6_num) fd.append('ipv6_num', dataMigrateForm.ipv6_num)
if (dataMigrateForm.network_ids?.length) {
dataMigrateForm.network_ids.forEach(id => fd.append('network_ids', id))
}
const res = await dataMigrateVm(fd)
if (res?.data?.code === 200) {
ElMessage.success('数据迁移已发起')
const d = res.data.data
dataMigrationId.value = d?.migration_id || ''
dataMigrateTaskId.value = d?.export_task?.task_id || ''
dataMigrateVisible.value = false
dataMigrateProgressVisible.value = true
loadDataMigrateProgress()
startMigratePolling()
} else ElMessage.error(extractApiError(res?.data, '发起迁移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '发起迁移失败')) } finally { actionLoading.value = false }
}
const MIGRATE_DONE_STAGES = ['completed', 'done', 'failed', 'aborted', 'cancelled', 'error']
const loadDataMigrateProgress = async () => {
if (!dataMigrationId.value && !dataMigrateTaskId.value) return
const params = { service_id: serviceId.value, host_id: vmHostId.value }
if (dataMigrationId.value) params.migration_id = dataMigrationId.value
if (dataMigrateTaskId.value) params.task_id = dataMigrateTaskId.value
dataMigrateProgressLoading.value = true
try {
const res = await getDataMigrateProgress(params)
if (res?.data?.code === 200 && res.data.data) {
dataMigrateProgressData.value = res.data.data
const d = res.data.data
if (!dataMigrationId.value && d.migration_id) dataMigrationId.value = d.migration_id
if (!dataMigrateTaskId.value) {
dataMigrateTaskId.value = d.export_task_id || d.import_task_id || ''
}
const stage = d.stage
if (MIGRATE_DONE_STAGES.includes(stage)) {
stopMigratePolling()
if (stage === 'completed' || stage === 'done') ElMessage.success('数据迁移已完成')
else if (stage === 'failed' || stage === 'error') ElMessage.error(d.error || '数据迁移失败')
loadDetail()
}
} else {
dataMigrateProgressData.value = null
stopMigratePolling()
}
} catch {
dataMigrateProgressData.value = null
stopMigratePolling()
} finally { dataMigrateProgressLoading.value = false }
}
const isMigrating = computed(() => {
if (detail.value?.migrating) return true
const stage = dataMigrateProgressData.value?.stage
return stage && !MIGRATE_DONE_STAGES.includes(stage)
})
const migrateStageLabel = (stage) => ({
exporting: '导出中', importing: '导入中', transferring: '传输中',
verifying: '校验中', completed: '已完成', done: '已完成',
failed: '失败', error: '错误', aborted: '已中断', cancelled: '已取消',
pending: '等待中', preparing: '准备中'
}[stage] || stage || '-')
const migrateStageType = (stage) => ({
exporting: 'warning', importing: 'warning', transferring: '',
verifying: '', completed: 'success', done: 'success',
failed: 'danger', error: 'danger', aborted: 'info', cancelled: 'info',
pending: 'info', preparing: 'info'
}[stage] || 'info')
const migrateProgressBarStatus = (stage) => {
if (stage === 'completed' || stage === 'done') return 'success'
if (stage === 'failed' || stage === 'error') return 'exception'
return ''
}
let migratePollingTimer = null
const startMigratePolling = () => {
stopMigratePolling()
migratePollingTimer = setInterval(loadDataMigrateProgress, 3000)
}
const stopMigratePolling = () => {
if (migratePollingTimer) { clearInterval(migratePollingTimer); migratePollingTimer = null }
}
let detailAutoRefreshTimer = null
const startDetailAutoRefresh = () => {
if (detailAutoRefreshTimer) return
detailAutoRefreshTimer = setInterval(() => { loadDetail() }, 3000)
}
const stopDetailAutoRefresh = () => {
if (detailAutoRefreshTimer) { clearInterval(detailAutoRefreshTimer); detailAutoRefreshTimer = null }
}
const abortLoading = ref(false)
const handleAbortMigrate = () => {
ElMessageBox.confirm('确定要中断当前数据迁移吗?此操作不可恢复!', '中断迁移', {
confirmButtonText: '确定中断', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'el-button--danger'
}).then(async () => {
abortLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
const res = await abortDataMigrate(fd)
if (res?.data?.code === 200) {
ElMessage.success('迁移已中断')
stopMigratePolling()
dataMigrateProgressData.value = null
dataMigrationId.value = ''
dataMigrateTaskId.value = ''
loadDetail()
} else ElMessage.error(extractApiError(res?.data, '中断迁移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '中断迁移失败')) } finally { abortLoading.value = false }
}).catch(() => {})
}
// ---- 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: 10 })
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
vncDialogVisible.value = true
if (!vncNodeOptions.value.length) {
dialogOptionsLoading.value = true
try { await loadVncNodes() } finally { dialogOptionsLoading.value = false }
}
}
const submitGetVnc = async () => {
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 sgOptions = ref([])
const loadSgOptions = async () => {
try {
const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 10 })
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 { /* */ }
}
// ---- 绑定网络(通过 updateVm 接口) ----
const showNetBindBridgeSelector = ref(false)
const showNetBindNatSelector = ref(false)
const submitNetBind = async (paramName, newId, existingIds) => {
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
const bridgeIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
const natNet = vmNetworks.value.find(n => n.type === 'nat')
if (paramName === 'network_ids') {
const allBridge = [...bridgeIds, newId]
allBridge.forEach(id => fd.append('network_ids', id))
if (natNet) fd.append('internet_network_id', natNet.id)
} else {
bridgeIds.forEach(id => fd.append('network_ids', id))
fd.append('internet_network_id', newId)
}
if (detail.value?.rx_bandwidth) fd.append('rx_bandwidth', detail.value.rx_bandwidth)
if (detail.value?.tx_bandwidth) fd.append('tx_bandwidth', detail.value.tx_bandwidth)
if (detail.value?.ssh_port) fd.append('ssh_port', detail.value.ssh_port)
if (vmPortGroup.value?.id) fd.append('port_group_id', vmPortGroup.value.id)
const res = await updateVm(fd)
if (res?.data?.code === 200) {
ElMessage.success('绑定网络成功')
loadDetail()
} else {
ElMessage.error(extractApiError(res?.data, '绑定网络失败'))
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '绑定网络失败'))
} finally {
actionLoading.value = false
}
}
const handleNetBindBridgeConfirm = (selectedNetwork) => {
const existingIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
if (existingIds.includes(selectedNetwork.id)) {
ElMessage.warning('该网络已绑定')
return
}
submitNetBind('network_ids', selectedNetwork.id)
}
const handleNetBindNatConfirm = (selectedNetwork) => {
const existingNat = vmNetworks.value.find(n => n.type === 'nat')
if (existingNat?.id === selectedNetwork.id) {
ElMessage.warning('该内网已绑定')
return
}
submitNetBind('internet_network_id', selectedNetwork.id)
}
// ---- 网络操作(创建/编辑/删除/详情) ----
const netDialogVisible = ref(false)
const netDialogType = ref('add')
const netDialogSource = ref('')
const netFormRef = ref(null)
const netDetailVisible = ref(false)
const netDetailData = ref(null)
const netForm = reactive({ id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0 })
const netFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
address: [{ required: true, message: '请输入 CIDR 格式网段', trigger: 'blur' }],
gateway: [{ required: true, message: '请输入网关', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }]
}
const handleNetCreate = (source = '') => {
netDialogSource.value = source
netDialogType.value = 'add'
const hostId = source === 'dataMigrate' ? (dataMigrateForm.target_host_id || vmHostId.value) : vmHostId.value
Object.assign(netForm, { id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostId })
netDialogVisible.value = true
}
const handleNetDialogCancel = () => {
netDialogVisible.value = false
netDialogSource.value = ''
}
const handleNetEdit = (row) => {
netDialogType.value = 'edit'
Object.assign(netForm, { 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 || '', ls_bridge_name: row.ls_bridge_name || '', ls_name: row.ls_name || '', host_id: row.host_id || vmHostId.value })
netDialogVisible.value = true
}
const submitNetForm = () => {
netFormRef.value?.validate(async (valid) => {
if (!valid) return
actionLoading.value = true
try {
const fd = new FormData()
const sid = netDialogSource.value === 'dataMigrate' && dataMigrateForm.target_service_id ? dataMigrateForm.target_service_id : serviceId.value
fd.append('service_id', sid)
fd.append('name', netForm.name)
fd.append('address', netForm.address)
fd.append('gateway', netForm.gateway)
fd.append('type', netForm.type)
fd.append('host_id', netForm.host_id)
if (netForm.nameservers) fd.append('nameservers', netForm.nameservers)
if (netForm.mac_address) fd.append('mac_address', netForm.mac_address)
if (netForm.bridge_name) fd.append('bridge_name', netForm.bridge_name)
if (netForm.ls_bridge_name) fd.append('ls_bridge_name', netForm.ls_bridge_name)
if (netForm.ls_name) fd.append('ls_name', netForm.ls_name)
let res
if (netDialogType.value === 'add') { res = await createNetwork(fd) }
else { fd.append('id', netForm.id); res = await updateNetwork(fd) }
if (res?.data?.code === 200) {
ElMessage.success(netDialogType.value === 'add' ? '创建成功' : '修改成功')
netDialogVisible.value = false
const src = netDialogSource.value
netDialogSource.value = ''
const refMap = {
dataMigrate: dataMigrateNetSelectorRef,
edit: editNetSelectorRef,
editInternal: editInternalNetSelectorRef,
refactor: refactorNetSelectorRef,
refactorInternal: refactorInternalNetSelectorRef,
bindBridge: bindBridgeNetSelectorRef,
bindNat: bindNatNetSelectorRef,
}
const selectorRef = refMap[src]
if (selectorRef) {
nextTick(() => selectorRef.value?.loadList())
}
if (src !== 'dataMigrate') loadDetail()
}
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
})
}
const handleNetDetail = (row) => { netDetailData.value = row; netDetailVisible.value = true }
const handleNetDelete = (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 showVolSelector = ref(false)
const handleVolBindConfirm = async (vol) => {
if (!vol?.id) return
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('volume_id', vol.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, '绑定失败')) } finally { actionLoading.value = false }
}
const handleVolCreateFromSelector = () => {
Object.assign(volCreateForm, { name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' })
volCreateVisible.value = true
}
const volCreateVisible = ref(false)
const volCreateFormRef = ref(null)
const volCreateForm = reactive({ name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' })
const volCreateRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }], size: [{ required: true, message: '请输入大小', trigger: 'blur' }] }
const showVolImageSelector = ref(false)
const volImageSelectorFromCreate = ref(false)
const handleVolImageSelected = (img) => { volCreateForm.image_id = img.id; volCreateForm._imageName = img.name; volCreateVisible.value = true; volImageSelectorFromCreate.value = false }
watch(showVolImageSelector, (val) => { if (!val && volImageSelectorFromCreate.value) { volCreateVisible.value = true; volImageSelectorFromCreate.value = false } })
const volResizeVisible = ref(false)
const volResizeTarget = ref(null)
const volNewSize = ref(1)
const volTransferVisible = ref(false)
const volTransferTarget = ref(null)
const volTransferVmId = ref(null)
const vmListOptions = ref([])
const volDetailVisible = ref(false)
const volDetailData = ref(null)
const handleVolCreate = () => {
Object.assign(volCreateForm, { name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' })
volCreateVisible.value = true
}
const submitVolCreate = () => {
volCreateFormRef.value?.validate(async (valid) => {
if (!valid) return
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', volCreateForm.name)
fd.append('size', volCreateForm.size)
fd.append('is_system', volCreateForm.is_system)
fd.append('vm_id', vmId.value)
fd.append('host_id', vmHostId.value)
if (volCreateForm.target_device) fd.append('target_device', volCreateForm.target_device)
if (volCreateForm.image_id) fd.append('image_id', volCreateForm.image_id)
const res = await createVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); volCreateVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
})
}
const handleVolResize = (row) => { volResizeTarget.value = { id: row.id, _name: row.name, _currentSize: row.size || 0 }; volNewSize.value = row.size || 1; volResizeVisible.value = true }
const submitVolResize = async () => {
actionLoading.value = true
try {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', volResizeTarget.value.id); fd.append('size', volNewSize.value)
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 handleVolMount = (row) => {
ElMessageBox.confirm(`确定要将「${row.name}」挂载到当前虚拟机吗?`, '挂载', { 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 handleVolUnmount = (row) => {
ElMessageBox.confirm(`确定要卸载「${row.name}」吗?`, '卸载', { 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 loadVmListOptions = async () => {
try {
const res = await getVmList({ service_id: serviceId.value, page: 1, count: 10 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
vmListOptions.value = inner.vms || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ }
}
const handleVolTransfer = async (row) => {
volTransferTarget.value = { id: row.id, _name: row.name }; volTransferVmId.value = vmId.value
volTransferVisible.value = true
if (!vmListOptions.value.length) {
dialogOptionsLoading.value = true
try { await loadVmListOptions() } finally { dialogOptionsLoading.value = false }
}
}
const submitVolTransfer = async () => {
if (!volTransferVmId.value) { ElMessage.warning('请选择目标虚拟机'); return }
actionLoading.value = true
try {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', volTransferTarget.value.id); fd.append('vm_id', volTransferVmId.value)
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 handleVolDelete = (row) => {
ElMessageBox.confirm(`确定要删除「${row.name}」吗?此操作不可恢复!`, '删除', { 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 handleVolDetail = (row) => {
router.push({ path: '/virtualization/volume-detail', query: { service_id: serviceId.value, volume_id: row.id } })
}
// ---- 安全组完整管理 ----
const sgTableData = computed(() => {
const list = []
if (vmPortGroup.value) list.push(vmPortGroup.value)
if (vmOutPortGroup.value) list.push(vmOutPortGroup.value)
return list
})
//安全组分页
const sgPage = ref(1)
const sgPageSize = ref(10)
const pagedSecurityGroups = computed(() =>{
const start = (sgPage.value -1) * sgPageSize.value
return sgTableData.value.slice(start,start + sgPageSize.value)
})
const sgSubmitLoading = ref(false)
const sgDetailLoading = ref(false)
const sgHostOptions = ref([])
const loadSgHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
sgHostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
}
} catch (e) { console.error('加载宿主机列表失败:', e) }
}
// 绑定安全组到当前虚拟机(通过安全组选择器)
const sgBindVisible = ref(false)
const handleSgBind = () => { sgBindVisible.value = true }
const handleSgBindConfirm = async (sg) => {
if (!sg?.id) return
actionLoading.value = true
try {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sg.id); fd.append('vm_id', vmId.value)
const res = await bindSecurityGroup(fd)
if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '绑定失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '绑定失败')) } finally { actionLoading.value = false }
}
// 解绑指定安全组与当前虚拟机
const handleSgUnbindFromVm = async (sg) => {
if (!sg) return
try { await ElMessageBox.confirm(`确定要解绑安全组「${sg.name}」(${sg.direction === 'in' ? '入站' : '出站'})吗?`, '解绑安全组', { type: 'warning' }) } catch { return }
actionLoading.value = true
try {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sg.id); fd.append('vm_id', vmId.value)
const res = await unbindSecurityGroup(fd)
if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '解绑失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '解绑失败')) } finally { actionLoading.value = false }
}
// 创建安全组
const sgCreateDialogVisible = ref(false)
const sgCreateFormRef = ref(null)
const sgCreateForm = reactive({ name: '', host_id: null, direction: '', lock: false, drop_all: false })
const sgCreateRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
direction: [{ required: true, message: '请选择方向', trigger: 'change' }],
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
}
const handleSgCreate = async () => {
Object.assign(sgCreateForm, { name: '', host_id: vmHostId.value || null, direction: '', lock: false, drop_all: false })
sgCreateDialogVisible.value = true
if (!sgHostOptions.value.length) {
dialogOptionsLoading.value = true
try { await loadSgHostOptions() } finally { dialogOptionsLoading.value = false }
}
}
const submitSgCreate = () => {
sgCreateFormRef.value?.validate(async (valid) => {
if (!valid) return
sgSubmitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', sgCreateForm.name)
fd.append('host_id', sgCreateForm.host_id)
fd.append('direction', sgCreateForm.direction)
fd.append('lock', sgCreateForm.lock ? 'true' : 'false')
fd.append('drop_all', sgCreateForm.drop_all ? 'true' : 'false')
const res = await createSecurityGroup(fd)
if (res?.data?.code === 200) {
ElMessage.success('创建成功')
sgCreateDialogVisible.value = false
loadDetail()
} else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { sgSubmitLoading.value = false }
})
}
// 同步安全组
const sgSyncDialogVisible = ref(false)
const sgSyncTarget = ref(null)
const sgSyncHostId = ref(null)
const handleSgSync = async (row) => {
sgSyncTarget.value = row
sgSyncHostId.value = row.host_id || vmHostId.value || null
sgSyncDialogVisible.value = true
if (!sgHostOptions.value.length) {
dialogOptionsLoading.value = true
try { await loadSgHostOptions() } finally { dialogOptionsLoading.value = false }
}
}
const submitSgSync = async () => {
if (!sgSyncHostId.value) { ElMessage.warning('请选择宿主机'); return }
sgSubmitLoading.value = true
try {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sgSyncTarget.value.id); fd.append('host_id', sgSyncHostId.value)
const res = await syncSecurityGroup(fd)
if (res?.data?.code === 200) {
ElMessage.success('同步成功')
sgSyncDialogVisible.value = false
loadDetail()
} else ElMessage.error(extractApiError(res?.data, '同步失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '同步失败')) } finally { sgSubmitLoading.value = false }
}
// 应用安全组
const handleSgApply = (row) => {
ElMessageBox.confirm(`确定要应用安全组「${row.name}」的规则到所有已绑定虚拟机吗?`, '应用安全组', {
confirmButtonText: '确定应用', cancelButtonText: '取消', type: 'info'
}).then(async () => {
try {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', row.id)
const res = await applySecurityGroup(fd)
if (res?.data?.code === 200) ElMessage.success('应用成功')
else ElMessage.error(extractApiError(res?.data, '应用失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '应用失败')) }
}).catch(() => {})
}
// 编辑(跳转安全组详情页)
const handleSgGoDetail = (row) => {
router.push({ path: '/virtualization/security-group-detail', query: { service_id: serviceId.value, service_name: serviceName.value, sg_id: row.id } })
}
// 白名单切换
const handleSgToggleWhitelist = (row) => {
const action = row.drop_all ? '关闭' : '开启'
ElMessageBox.confirm(`确定要${action}安全组「${row.name}」的白名单模式吗?`, `${action}白名单`, {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const api = row.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', row.id)
const res = await api(fd)
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() }
else ElMessage.error(extractApiError(res?.data, `${action}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) }
}).catch(() => {})
}
// 删除安全组
const handleSgDelete = (row) => {
ElMessageBox.confirm(`确定要删除安全组「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteSecurityGroup({ service_id: serviceId.value, 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 sgVmBindDialogVisible = ref(false)
const sgVmBindType = ref('bind')
const sgVmBindTarget = ref(null)
const handleSgBindVm = (row) => {
sgVmBindType.value = 'bind'; sgVmBindTarget.value = row
sgVmBindDialogVisible.value = true
}
const handleSgUnbindVm = (row) => {
sgVmBindType.value = 'unbind'; sgVmBindTarget.value = row
sgVmBindDialogVisible.value = true
}
const submitSgVmBind = async () => {
sgSubmitLoading.value = true
try {
const api = sgVmBindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sgVmBindTarget.value.id); fd.append('vm_id', vmId.value)
const res = await api(fd)
if (res?.data?.code === 200) {
ElMessage.success(sgVmBindType.value === 'bind' ? '绑定成功' : '解绑成功')
sgVmBindDialogVisible.value = false
loadDetail()
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { sgSubmitLoading.value = false }
}
// 查看安全组详情(含规则)
const sgDetailVisible = ref(false)
const sgCurrentDetail = ref(null)
const handleSgViewDetail = async (row) => {
sgDetailVisible.value = true
sgDetailLoading.value = true
sgCurrentDetail.value = row
try {
const res = await getSecurityGroupDetail({ service_id: serviceId.value, id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
sgCurrentDetail.value = inner.group || inner.data || inner
}
} catch { /* fallback */ } finally { sgDetailLoading.value = false }
}
// 安全组规则操作
const sgRuleDialogVisible = ref(false)
const sgRuleDialogType = ref('add')
const sgRuleFormRef = ref(null)
const sgRuleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: '' })
const sgRuleRules = {
protocol: [{ required: true, message: '请选择协议', trigger: 'change' }],
action: [{ required: true, message: '请选择动作', trigger: 'change' }]
}
const handleSgAddRule = () => {
sgRuleDialogType.value = 'add'
Object.assign(sgRuleForm, { id: undefined, group_id: sgCurrentDetail.value?.id || 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: '' })
sgRuleDialogVisible.value = true
}
const handleSgEditRule = (rule) => {
sgRuleDialogType.value = 'edit'
Object.assign(sgRuleForm, {
id: rule.id, group_id: sgCurrentDetail.value?.id || 0, port_group_id: sgCurrentDetail.value?.id || '',
protocol: rule.protocol || 'tcp', action: rule.action || 'allow',
port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0
})
sgRuleDialogVisible.value = true
}
const submitSgRule = () => {
sgRuleFormRef.value?.validate(async (valid) => {
if (!valid) return
sgSubmitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('group_id', sgRuleForm.group_id)
if (sgRuleForm.id) fd.append('id', sgRuleForm.id)
if (sgRuleForm.port_group_id) fd.append('port_group_id', sgRuleForm.port_group_id)
fd.append('protocol', sgRuleForm.protocol)
fd.append('action', sgRuleForm.action)
if (sgRuleForm.port_range) fd.append('port_range', sgRuleForm.port_range)
if (sgRuleForm.ip_range) fd.append('ip_range', sgRuleForm.ip_range)
fd.append('priority', sgRuleForm.priority || 0)
const res = sgRuleDialogType.value === 'add'
? await createSecurityGroupRule(fd)
: await updateSecurityGroupRule(fd)
if (res?.data?.code === 200) {
ElMessage.success(sgRuleDialogType.value === 'add' ? '规则创建成功' : '规则修改成功')
sgRuleDialogVisible.value = false
handleSgViewDetail(sgCurrentDetail.value)
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { sgSubmitLoading.value = false }
})
}
const handleSgDeleteRule = (rule) => {
ElMessageBox.confirm('确定要删除该规则吗?', '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteSecurityGroupRule({ service_id: serviceId.value, id: rule.id })
if (res?.data?.code === 200) {
ElMessage.success('删除成功')
handleSgViewDetail(sgCurrentDetail.value)
} else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
// 更多菜单
const handleSgRowMore = (row, command) => {
if (command === 'unbindCurrent') handleSgUnbindFromVm(row)
else if (command === 'bindVm') handleSgBindVm(row)
else if (command === 'unbindVm') handleSgUnbindVm(row)
else if (command === 'whitelist') handleSgToggleWhitelist(row)
else if (command === 'viewDetail') handleSgViewDetail(row)
else if (command === 'delete') handleSgDelete(row)
}
// ---- 快照/备份管理 ----
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 } })
router.back()
}
// ---- 组网管理 ----
const vnLoading = ref(false)
const vnSubmitLoading = ref(false)
const vnList = ref([])
const vnJoinVisible = ref(false)
const vnJoinTarget = ref(null)
const vnJoinIp = ref('')
const vmBridgeNameMap = computed(() => {
const map = {}
for (const n of vmNetworks.value) {
if (n.bridge_name) map[n.bridge_name] = n
}
return map
})
const loadVmNetworkingList = async () => {
if (!detail.value) return
vnLoading.value = true
vnList.value = []
try {
const userId = detail.value.user_id
const hostId = vmHostId.value
if (!userId || !hostId) { vnLoading.value = false; return }
const res = await getUserNetworkingList({
service_id: serviceId.value, page: 1, count: 10,
host_id: hostId, user_id: userId
})
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const networkings = Array.isArray(inner) ? inner : (inner.data || [])
const results = []
for (const nw of networkings) {
const matchedVmNet = nw.bridge_name ? vmBridgeNameMap.value[nw.bridge_name] : null
results.push({ networking: nw, network: matchedVmNet || null })
}
vnList.value = results
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取组网信息失败')) }
finally { vnLoading.value = false }
}
// 创建组网
const vnCreateVisible = ref(false)
const vnCreateFormRef = ref(null)
const vnCreateForm = reactive({ name: '', bridge_name: '', gateway: '' })
const vnCreateHostName = ref('')
const vnCreateUserName = ref('')
const vnCreateRules = {
bridge_name: [{ required: true, message: '请输入网桥名称', trigger: 'blur' }]
}
const handleVnCreate = async () => {
Object.assign(vnCreateForm, { name: '', bridge_name: '', gateway: '' })
vnCreateHostName.value = ''
vnCreateUserName.value = ''
vnCreateVisible.value = true
const userId = detail.value?.user_id
const hostId = vmHostId.value
const requests = []
if (userId) {
requests.push(
getUserInfo({ user_id: userId }).then(res => {
if (res?.data?.code === 200 && res?.data?.data) {
vnCreateUserName.value = res.data.data.UserName || ''
}
}).catch(() => {})
)
}
if (hostId) {
requests.push(
getRemoteHostDetail({ service_id: serviceId.value, id: hostId }).then(res => {
if (res?.data?.code === 200 && res?.data?.data) {
const h = res.data.data.host ?? res.data.data
vnCreateHostName.value = h.name || ''
}
}).catch(() => {})
)
}
await Promise.all(requests)
}
const submitVnCreate = () => {
vnCreateFormRef.value?.validate(async (valid) => {
if (!valid) return
vnSubmitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', vnCreateForm.name)
fd.append('bridge_name', vnCreateForm.bridge_name)
fd.append('host_id', vmHostId.value)
fd.append('user_id', detail.value?.user_id || 0)
if (vnCreateForm.gateway) fd.append('gateway', vnCreateForm.gateway)
const res = await createUserNetworking(fd)
if (res?.data?.code === 200) {
ElMessage.success('创建组网成功')
vnCreateVisible.value = false
loadVmNetworkingList()
} else ElMessage.error(extractApiError(res?.data, '创建组网失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建组网失败')) }
finally { vnSubmitLoading.value = false }
})
}
const handleJoinNetworking = (row) => {
vnJoinTarget.value = row.networking
vnJoinIp.value = ''
vnJoinVisible.value = true
}
const submitJoinNetworking = async () => {
if (!vnJoinTarget.value) return
vnSubmitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('networking_id', vnJoinTarget.value.id)
fd.append('vm_id', vmId.value)
if (vnJoinIp.value.trim()) fd.append('ip', vnJoinIp.value.trim())
const res = await assignUserNetworking(fd)
if (res?.data?.code === 200) {
ElMessage.success('加入组网成功')
vnJoinVisible.value = false
loadVmNetworkingList()
loadDetail()
} else ElMessage.error(extractApiError(res?.data, '加入组网失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加入组网失败')) }
finally { vnSubmitLoading.value = false }
}
const handleLeaveNetworking = (row) => {
const netId = row.network?.id
const networkingId = row.networking?.id
if (!netId || !networkingId) { ElMessage.warning('缺少网络信息'); return }
ElMessageBox.confirm(
`确定要将该虚拟机从组网「${row.networking?.name || networkingId}」中移除?`,
'移除确认', { type: 'warning' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('networking_id', networkingId)
fd.append('network_id', netId)
fd.append('vm_id', vmId.value)
const res = await removeUserNetworkingNetwork(fd)
if (res?.data?.code === 200) {
ElMessage.success('已移除')
loadVmNetworkingList()
loadDetail()
} else ElMessage.error(extractApiError(res?.data, '移除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '移除失败')) }
}).catch(() => {})
}
let loadedVmId = null
const initPage = async () => {
if (!vmId.value || loadedVmId === vmId.value) return
loadedVmId = vmId.value
historicalMetricsData.value = null
dataMigrateProgressData.value = null
dataMigrationId.value = ''
dataMigrateTaskId.value = ''
stopMigratePolling()
disposeCharts()
loadHostOptions()
await loadDetail()
triggerTabLoad(activeTab.value)
}
const triggerTabLoad = (tab) => {
if (tab === 'monitor' && !historicalMetricsData.value) loadHistoricalMetrics()
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
if (tab === 'userNetworking') loadVmNetworkingList()
if (tab === 'security') loadSgLockInfo()
}
// 请求安全组详情补充 lock 字段
const loadSgLockInfo = async () => {
const groups = [vmPortGroup.value, vmOutPortGroup.value].filter(Boolean)
for (const sg of groups) {
try {
const res = await getSecurityGroupDetail({ service_id: serviceId.value, id: sg.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data.group || res.data.data.data || res.data.data
if (vmPortGroup.value?.id === sg.id) vmPortGroup.value = { ...vmPortGroup.value, lock: d.lock ?? sg.lock }
if (vmOutPortGroup.value?.id === sg.id) vmOutPortGroup.value = { ...vmOutPortGroup.value, lock: d.lock ?? sg.lock }
}
} catch { /* */ }
}
}
watch(vmId, () => { if (isPageActive) initPage() })
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
onActivated(() => {
isPageActive = true
if (loadedVmId !== vmId.value) initPage()
})
onDeactivated(() => { isPageActive = false; stopMigratePolling() })
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
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; }
.tk-form-tip { display: block; font-size: 12px; color: #909399; line-height: 1.5; margin-top: 4px; }
/* 迁移横幅 */
.migrate-banner { display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, #fffbf0 0%, #fff7e6 100%); border: 1px solid #faecd8; border-radius: 8px; padding: 14px 20px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(230,162,60,.1); }
.migrate-banner-info { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-width: 0; }
.migrate-banner-title { font-weight: 600; font-size: 14px; color: #e6a23c; white-space: nowrap; }
.migrate-progress-text { font-size: 16px; font-weight: 700; color: #e6a23c; letter-spacing: .5px; }
.migrate-divider { width: 1px; height: 16px; background: #e6a23c; opacity: .35; flex-shrink: 0; }
.migrate-speed { font-size: 14px; font-weight: 700; color: #409eff; letter-spacing: .3px; }
.migrate-msg { font-size: 13px; color: #8c8c8c; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.migrate-banner-actions { display: flex; gap: 8px; flex-shrink: 0; margin-left: 16px; }
.migrate-spin { animation: migrate-rotate 1s linear infinite; color: #e6a23c; font-size: 18px; }
@keyframes migrate-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* 监控指标 */
.metrics-card { margin-bottom: 0; }
.metrics-title { font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
.chart-container { width: 100%; height: 220px; }
.metric-summary-row { display: flex; gap: 16px; margin-bottom: 16px; }
.metric-summary-card { flex: 1; min-width: 0; background: #f7f8fa; border-radius: 6px; padding: 14px 16px; border: 1px solid #e8e8e8; display: flex; flex-direction: column; }
.metric-summary-label { font-size: 12px; color: #86909c; margin-bottom: 8px; }
.metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vnc-result { margin-top: 12px; }
.rules-section { margin-top: 8px; }
.rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.rules-header h4 { margin: 0; font-size: 15px; font-weight: 600; color: #303133; }
.ip-popover-list { max-height: 200px; overflow-y: auto; }
.ip-popover-item { padding: 4px 0; font-size: 13px; color: #303133; border-bottom: 1px dashed #ebeef5; word-break: break-all; }
.ip-popover-item:last-child { border-bottom: none; }
</style>