0dcce0822d
缘由:上一次提交(802eaa3)在 UserVmDetail.vue 流量上限单元格内追加的"修改"按钮误用了不存在的 handleCommand,点击时报 _ctx.handleCommand is not a function。该文件中触发 updateTraffic 的实际函数名为 handleMoreCmd(行 1329),dropdown 的 @command 也是绑到该函数。
修改:仅将 132 行附近的 @click="handleCommand('updateTraffic')" 改为 @click="handleMoreCmd('updateTraffic')"。
预期:流量上限单元格的"修改"按钮可正常触发 trafficVisible 弹窗,与 dropdown "修改带宽"行为一致。
测试:admin_dashboard_pc 本地 dev 已 HMR 更新,未见编译/控制台报错。VmDetail.vue 同名样式与按钮独立、不受影响。
Co-authored-by: Cursor <cursoragent@cursor.com>
2456 lines
135 KiB
Vue
2456 lines
135 KiB
Vue
<template>
|
||
<div class="uvm-detail">
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button>
|
||
<el-divider direction="vertical" />
|
||
<span class="page-title">用户虚拟机详情</span>
|
||
</div>
|
||
<el-button :icon="Refresh" plain @click="loadDetail" :loading="loading">刷新</el-button>
|
||
</div>
|
||
|
||
<div class="main-content" v-loading="loading">
|
||
<!-- 概览卡片 -->
|
||
<el-card shadow="never" class="overview-card" v-if="userGoods">
|
||
<div class="overview-header">
|
||
<div class="overview-left">
|
||
<el-icon :size="44" color="#409eff"><Monitor /></el-icon>
|
||
<div class="overview-info">
|
||
<div class="name-row">
|
||
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
|
||
<el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag>
|
||
<el-tag v-if="vm?.rescue" size="small" type="danger" effect="dark" style="margin-left:4px">救援模式</el-tag>
|
||
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
|
||
</div>
|
||
<div class="meta-row">
|
||
<span>用户商品ID: <b>{{ userGoods.id }}</b></span>
|
||
<el-divider direction="vertical" />
|
||
<span>虚拟机ID: <b>{{ userGoods.itemId || '-' }}</b></span>
|
||
<el-divider direction="vertical" />
|
||
<span>用户ID: <b>{{ userGoods.userId }}</b></span>
|
||
<el-divider direction="vertical" />
|
||
<span>到期: <b>{{ formatExpireTime(userGoods.expireTime) }}</b></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="overview-actions">
|
||
<el-button v-if="isVmGoods" size="small" type="primary" @click="handleVnc">VNC</el-button>
|
||
<el-button v-if="isVmGoods" size="small" type="success" @click="handlePower('start')" :disabled="vm?.status === 'running'">启动</el-button>
|
||
<el-button v-if="isVmGoods" size="small" type="warning" @click="handlePower('reboot')">重启</el-button>
|
||
<el-button v-if="isVmGoods" size="small" type="danger" @click="handlePower('stop')" :disabled="vm?.status === 'stopped' || vm?.status === 'stop'">关机</el-button>
|
||
<el-dropdown v-if="isVmGoods" trigger="click" @command="handleMoreCmd">
|
||
<el-button size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
|
||
<el-dropdown-item command="resume">恢复</el-dropdown-item>
|
||
<el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</el-dropdown-item>
|
||
<el-dropdown-item command="exitRescue" :disabled="!vm?.rescue">退出救援</el-dropdown-item>
|
||
<el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item>
|
||
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="updateTraffic">修改带宽</el-dropdown-item>
|
||
<el-dropdown-item divided command="migrate">迁移</el-dropdown-item>
|
||
<el-dropdown-item command="transfer">转移用户</el-dropdown-item>
|
||
<el-dropdown-item divided command="editGoods">编辑商品信息</el-dropdown-item>
|
||
<el-dropdown-item divided command="delete" style="color:#f56c6c">删除</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</div>
|
||
<el-divider style="margin:12px 0 8px" />
|
||
<!-- VM 配置信息 -->
|
||
<div class="vm-config-grid" v-if="vm">
|
||
<div class="config-row">
|
||
<div class="config-cell"><span class="config-label">vCPU</span><span class="config-value">{{ vm.vcpu || '-' }} 核</span></div>
|
||
<div class="config-cell"><span class="config-label">内存</span><span class="config-value">{{ formatMemory(vm.memory) }}</span></div>
|
||
<div class="config-cell"><span class="config-label">下行带宽</span><span class="config-value">{{ vm.rx_bandwidth || 0 }} Mbps</span></div>
|
||
<div class="config-cell"><span class="config-label">上行带宽</span><span class="config-value">{{ vm.tx_bandwidth || 0 }} Mbps</span></div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell"><span class="config-label">用户名</span><span class="config-value" style="font-weight:500">{{ isWindows ? 'Administrator' : 'root' }}</span></div>
|
||
<div class="config-cell"><span class="config-label">远程端口</span><span class="config-value">{{ isWindows ? (vm.ssh_port && vm.ssh_port !== 22 ? vm.ssh_port : 3389) : (vm.ssh_port || 22) }}</span></div>
|
||
<div class="config-cell">
|
||
<span class="config-label">外网IP</span>
|
||
<span class="config-value ip-value" v-if="vmPublicIpList.length">
|
||
<span class="ip-main" style="color:#165dff">{{ vmPublicIpList[0] }}</span>
|
||
<el-popover v-if="vmPublicIpList.length > 1" trigger="hover" placement="bottom" :width="360" popper-class="ip-popover-panel">
|
||
<template #reference>
|
||
<el-tag size="small" type="primary" class="ip-more-tag">+{{ vmPublicIpList.length - 1 }}</el-tag>
|
||
</template>
|
||
<div class="ip-popover-header">
|
||
<span>全部外网IP({{ vmPublicIpList.length }})</span>
|
||
<el-button link type="primary" size="small" @click="copyAllIps(vmPublicIpList)">复制全部</el-button>
|
||
</div>
|
||
<div class="ip-popover-list">
|
||
<div v-for="(ip, idx) in vmPublicIpList" :key="idx" class="ip-popover-item">
|
||
<span class="ip-text">{{ ip }}</span>
|
||
<el-button link type="primary" size="small" class="ip-copy-btn" @click="copyText(ip)">复制</el-button>
|
||
</div>
|
||
</div>
|
||
</el-popover>
|
||
</span>
|
||
<span class="config-value" v-else>-</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">内网IP</span>
|
||
<span class="config-value ip-value" v-if="vmPrivateIpList.length">
|
||
<span class="ip-main" style="color:#67c23a">{{ vmPrivateIpList[0] }}</span>
|
||
<el-popover v-if="vmPrivateIpList.length > 1" trigger="hover" placement="bottom" :width="360" popper-class="ip-popover-panel">
|
||
<template #reference>
|
||
<el-tag size="small" type="success" class="ip-more-tag">+{{ vmPrivateIpList.length - 1 }}</el-tag>
|
||
</template>
|
||
<div class="ip-popover-header">
|
||
<span>全部内网IP({{ vmPrivateIpList.length }})</span>
|
||
<el-button link type="primary" size="small" @click="copyAllIps(vmPrivateIpList)">复制全部</el-button>
|
||
</div>
|
||
<div class="ip-popover-list">
|
||
<div v-for="(ip, idx) in vmPrivateIpList" :key="idx" class="ip-popover-item">
|
||
<span class="ip-text">{{ ip }}</span>
|
||
<el-button link type="primary" size="small" class="ip-copy-btn" @click="copyText(ip)">复制</el-button>
|
||
</div>
|
||
</div>
|
||
</el-popover>
|
||
</span>
|
||
<span class="config-value" v-else>-</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">Root密码</span>
|
||
<span class="config-value pwd-value">
|
||
<code class="pwd-text">{{ showPassword ? (vm.root_password || '-') : '••••••••' }}</code>
|
||
<el-button link size="small" @click="showPassword = !showPassword" class="pwd-btn">
|
||
<el-icon :size="14"><View v-if="!showPassword" /><Hide v-else /></el-icon>
|
||
</el-button>
|
||
<el-button link size="small" type="primary" @click="copyPassword" class="pwd-btn">
|
||
<el-icon :size="14"><CopyDocument /></el-icon>
|
||
</el-button>
|
||
</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">流量上限</span>
|
||
<span class="config-value">
|
||
{{ formatTraffic(vm.traffic_max) }}
|
||
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="handleMoreCmd('updateTraffic')">
|
||
<el-icon :size="14"><Edit /></el-icon>修改
|
||
</el-button>
|
||
</span>
|
||
</div>
|
||
<div class="config-cell"><span class="config-label">续费价格</span><span class="config-value">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</span></div>
|
||
<div class="config-cell"><span class="config-label">基础价格</span><span class="config-value">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</span></div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell"><span class="config-label">商品</span><span class="config-value">{{ userGoods.good?.name || '-' }}</span></div>
|
||
<div class="config-cell"><span class="config-label">备注</span><span class="config-value">{{ userGoods.note || '-' }}</span></div>
|
||
<div class="config-cell">
|
||
<span class="config-label">入站安全组</span>
|
||
<span class="config-value">
|
||
<el-tag v-if="inPortGroup" size="small" type="success">{{ inPortGroup.name }} (ID:{{ inPortGroup.id }})</el-tag>
|
||
<span v-else style="color:#c0c4cc">未绑定</span>
|
||
</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">出站安全组</span>
|
||
<span class="config-value">
|
||
<el-tag v-if="outPortGroup" size="small" type="warning">{{ outPortGroup.name }} (ID:{{ outPortGroup.id }})</el-tag>
|
||
<span v-else style="color:#c0c4cc">未绑定</span>
|
||
</span>
|
||
</div>
|
||
<div class="config-cell" v-if="vmImage">
|
||
<span class="config-label">镜像</span>
|
||
<span class="config-value">{{ vmImage.name }} <el-tag size="small" :type="vmImage.os_type === 'linux' ? 'success' : 'primary'" style="margin-left:4px">{{ vmImage.os_type }}</el-tag></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="3" border size="small" v-else>
|
||
<el-descriptions-item label="商品">{{ userGoods.good?.name || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="商品标签">{{ userGoods.tag || userGoods.good?.tag || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="套餐ID">{{ userGoods.goodPlanId || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="续费价格">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</el-descriptions-item>
|
||
<el-descriptions-item label="基础价格">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</el-descriptions-item>
|
||
<el-descriptions-item label="订单">{{ userGoods.order?.name || (userGoods.orderId ? `订单 #${userGoods.orderId}` : '-') }}</el-descriptions-item>
|
||
<el-descriptions-item label="创建时间">{{ formatTime(userGoods.CreatedAt) }}</el-descriptions-item>
|
||
<el-descriptions-item label="更新时间">{{ formatTime(userGoods.UpdatedAt) }}</el-descriptions-item>
|
||
<el-descriptions-item label="备注">{{ userGoods.note || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="操作" :span="3">
|
||
<el-button size="small" type="primary" plain @click="openEditGoods">编辑商品信息</el-button>
|
||
<el-button size="small" type="danger" plain @click="handleMoreCmd('delete')" style="margin-left:8px">删除</el-button>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-card>
|
||
|
||
<!-- 标签页 -->
|
||
<el-card shadow="never" class="tabs-card" v-if="userGoods && isVmGoods">
|
||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||
|
||
<!-- 数据卷 -->
|
||
<el-tab-pane v-if="isVmGoods" label="数据卷" name="volume">
|
||
<div class="tab-toolbar">
|
||
<el-button size="small" type="primary" @click="showVolumeSelector = true">挂载数据卷</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
|
||
</div>
|
||
<el-table :data="vmVolumes" stripe size="small">
|
||
<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="100">
|
||
<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="90">
|
||
<template #default="{ row }"><el-tag :type="volumeStatusType(row.status)" size="small">{{ volumeStatusLabel(row.status) }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column label="挂载" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="isVolumeMounted(row) ? 'success' : 'info'" size="small">{{ isVolumeMounted(row) ? '已挂载' : '未挂载' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip><template #default="{ row }"><span style="font-family:monospace;font-size:12px">{{ row.path || '-' }}</span></template></el-table-column>
|
||
<el-table-column label="操作" width="220" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleResizeVolume(row)">扩容</el-button>
|
||
<el-button link type="success" size="small" @click="handleMountVolume(row)" v-if="!isVolumeMounted(row)">挂载</el-button>
|
||
<el-button link type="warning" size="small" @click="handleUnmountVolume(row)" v-if="isVolumeMounted(row)">卸载</el-button>
|
||
<el-button link type="danger" size="small" @click="handleDeleteVolume(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!vmVolumes.length" :image-size="60" description="暂无数据卷" />
|
||
</el-tab-pane>
|
||
|
||
<!-- 快照 -->
|
||
<el-tab-pane v-if="isVmGoods" label="快照" name="snapshot">
|
||
<div class="tab-toolbar">
|
||
<el-tag v-if="snapshotQuota" size="small" effect="plain">{{ snapshotQuota.count || 0}} / {{ 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" :icon="Refresh" @click="loadSnapshots">刷新</el-button>
|
||
</div>
|
||
<el-table :data="snapshots" v-loading="snapshotLoading" stripe size="small">
|
||
<el-table-column prop="id" label="ID" width="70" />
|
||
<el-table-column prop="name" label="名称" min-width="140" />
|
||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="taskStatusType(row.status)" size="small">{{ row.status || '-' }}</el-tag></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="!snapshots.length && !snapshotLoading" :image-size="60" description="暂无快照" />
|
||
</el-tab-pane>
|
||
|
||
<!-- 备份 -->
|
||
<el-tab-pane v-if="isVmGoods" label="备份" name="backup">
|
||
<div class="tab-toolbar">
|
||
<el-tag v-if="backupQuota" size="small" effect="plain">{{ backupQuota.count || 0 }} / {{ 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" :icon="Refresh" @click="loadBackups">刷新</el-button>
|
||
</div>
|
||
<el-table :data="backups" v-loading="backupLoading" stripe size="small">
|
||
<el-table-column prop="id" label="ID" width="70" />
|
||
<el-table-column prop="name" label="名称" min-width="140" />
|
||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="taskStatusType(row.status)" size="small">{{ row.status || '-' }}</el-tag></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="!backups.length && !backupLoading" :image-size="60" description="暂无备份" />
|
||
</el-tab-pane>
|
||
|
||
<!-- 安全组 -->
|
||
<el-tab-pane v-if="isVmGoods" label="安全组" name="security">
|
||
<div class="tab-toolbar">
|
||
<el-button size="small" type="primary" @click="showSgBindSelector = true">绑定安全组</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="async () => { await loadDetail(); loadSgLockInfo() }">刷新</el-button>
|
||
</div>
|
||
<el-table :data="inPortGroupList" stripe size="small">
|
||
<el-table-column prop="id" label="ID" width="70" />
|
||
<el-table-column prop="name" label="名称" min-width="140" />
|
||
<el-table-column label="方向" width="80">
|
||
<template #default="{ row }"><el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : '出站' }}</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="70">
|
||
<template #default="{ row }"><el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '是' : '否' }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column label="应用时间" min-width="160">
|
||
<template #default="{ row }">{{ row.apply_time ? formatTime(row.apply_time) : '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="320" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleSgDetail(row)">规则</el-button>
|
||
<el-button link type="info" size="small" @click="handleEditSg(row)">编辑</el-button>
|
||
<el-button link type="warning" size="small" @click="handleApplySg(row)">应用</el-button>
|
||
<el-button link type="primary" size="small" @click="handleSgWhitelist(row)">{{ row.drop_all ? '关闭白名单' : '开启白名单' }}</el-button>
|
||
<el-button link type="danger" size="small" @click="handleUnbindSg(row)">解绑</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!inPortGroupList.length" :image-size="60" description="暂无绑定的安全组" />
|
||
</el-tab-pane>
|
||
|
||
<!-- 网络 -->
|
||
<el-tab-pane v-if="isVmGoods" label="网络" name="network">
|
||
<div class="tab-toolbar">
|
||
<el-button size="small" type="primary" @click="showBindNetworkSelector = true">绑定网络</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
|
||
</div>
|
||
<el-table :data="vmNetworks" stripe size="small">
|
||
<el-table-column prop="id" label="ID" width="70" />
|
||
<el-table-column prop="name" label="名称" min-width="120" />
|
||
<el-table-column prop="address" label="地址(CIDR)" min-width="150" />
|
||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip />
|
||
<el-table-column 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 label="操作" width="90" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="danger" size="small" :loading="deletingNetworkId === row.id" @click="handleDeleteVmNetwork(row)">
|
||
<el-icon :size="14"><Delete /></el-icon>删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!vmNetworks.length" :image-size="60" description="暂无网络" />
|
||
</el-tab-pane>
|
||
|
||
<!-- 组网 -->
|
||
<el-tab-pane v-if="isVmGoods" label="组网" name="networking">
|
||
<div class="tab-toolbar">
|
||
<el-button size="small" type="primary" @click="handleCreateNetworking">创建组网</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadNetworkings">刷新</el-button>
|
||
</div>
|
||
<el-table :data="networkingRows" v-loading="networkingLoading" stripe size="small">
|
||
<el-table-column label="组网" min-width="160">
|
||
<template #default="{ row }">
|
||
<span>{{ row.name }} <span style="color:#909399">(ID: {{ row.id }})</span></span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row._joined ? 'success' : 'info'" size="small">{{ row._joined ? '已加入' : '未加入' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="网桥" min-width="120">
|
||
<template #default="{ row }">{{ row.bridge_name || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="IP地址" min-width="160">
|
||
<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="网关" min-width="130">
|
||
<template #default="{ row }">
|
||
<span class="mono-text">{{ row._network?.gateway || row.gateway || '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="120" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button v-if="!row._joined" 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="!networkings.length && !networkingLoading" :image-size="60" description="暂无组网" />
|
||
<div class="pagination-wrapper" v-if="networkingTotal > 0">
|
||
<el-pagination v-model:current-page="networkingPage" v-model:page-size="networkingPageSize" :page-sizes="[10,20]" :total="networkingTotal" layout="total,sizes,prev,pager,next" small
|
||
@size-change="s => { networkingPageSize = s; networkingPage = 1; loadNetworkings() }" @current-change="p => { networkingPage = p; loadNetworkings() }" />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- 监控 -->
|
||
<el-tab-pane v-if="isVmGoods" 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; flex-wrap: wrap">
|
||
<el-date-picker
|
||
v-model="monitorDateRange"
|
||
type="datetimerange"
|
||
range-separator="至"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
size="small"
|
||
style="width: 360px"
|
||
:shortcuts="monitorShortcuts"
|
||
@change="loadMetricsHistory"
|
||
/>
|
||
<span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span>
|
||
<el-button size="small" :icon="Refresh" @click="loadMetricsHistory" :loading="metricsLoading">刷新</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"> </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="metricsData">
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Monitor /></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><Monitor /></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><Monitor /></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><Monitor /></el-icon> 网络流量</span></template>
|
||
<div ref="netChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</template>
|
||
<el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- 流量策略 -->
|
||
<el-tab-pane v-if="isVmGoods" label="流量策略" name="trafficPolicy">
|
||
<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="openTrafficPolicyDialog">修改流量策略</el-button>
|
||
<el-button size="small" type="success" @click="openAddTrafficDialog('fixed')">增加固定流量</el-button>
|
||
<el-button size="small" type="warning" @click="openAddTrafficDialog('temporary')">增加临时流量</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadTrafficPolicy" :loading="trafficPolicyLoading">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<el-descriptions v-if="trafficPolicy" :column="3" border size="small" style="margin-top:12px">
|
||
<el-descriptions-item label="流量上限">{{ trafficPolicy.traffic_max_mb != null ? (trafficPolicy.traffic_max_mb === 0 ? '不限' : trafficPolicy.traffic_max_mb + ' MB') : '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="耗尽下行限速">{{ trafficPolicy.exhausted_rx_mbps != null ? (trafficPolicy.exhausted_rx_mbps === 0 ? '不限' : trafficPolicy.exhausted_rx_mbps + ' Mbps') : '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="耗尽上行限速">{{ trafficPolicy.exhausted_tx_mbps != null ? (trafficPolicy.exhausted_tx_mbps === 0 ? '不限' : trafficPolicy.exhausted_tx_mbps + ' Mbps') : '-' }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-empty v-else-if="!trafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
</el-tabs>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- 弹窗 -->
|
||
<!-- VNC -->
|
||
<el-dialog v-model="vncVisible" title="VNC连接" width="480px" destroy-on-close class="vnc-dialog">
|
||
<div v-loading="vncLoading">
|
||
<el-descriptions :column="1" border v-if="vncResult">
|
||
<el-descriptions-item label="VNC地址">
|
||
<el-link type="primary" :href="vncResult.url" target="_blank" class="vnc-url-link">{{ vncResult.url }}</el-link>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="过期时间">{{ formatVncExpire(vncResult.expire_at) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-empty v-else-if="!vncLoading" description="获取失败" :image-size="60" />
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="vncVisible = false">关闭</el-button>
|
||
<el-button v-if="vncResult?.url" type="primary" @click="openVncUrl">打开连接</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 电源操作 -->
|
||
<el-dialog v-model="powerVisible" :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: '0px' }">
|
||
<WarningFilled />
|
||
</el-icon>
|
||
<div>
|
||
<div style="font-size:15px;font-weight:500;color:#303133">
|
||
确定要{{ powerLabels[powerAction] }}虚拟机「{{ vm?.name || userGoods?.good?.name }}」吗?
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="powerVisible = false">取消</el-button>
|
||
<el-button :type="powerAction === 'stop' ? 'danger' : 'primary'" :loading="actionLoading" @click="submitPower">确定{{ powerLabels[powerAction] }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建数据卷 -->
|
||
<el-dialog v-model="volCreateVisible" title="创建数据卷" width="440px" destroy-on-close>
|
||
<el-form :model="volCreateForm" label-width="100px">
|
||
<el-form-item label="名称" required><el-input v-model="volCreateForm.name" /></el-form-item>
|
||
<el-form-item label="大小">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="volCreateForm.size" :min="1" controls-position="right" style="flex:1" />
|
||
<span class="unit-text">GB</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="目标设备名"><el-input v-model="volCreateForm.target_device" placeholder="不填自动生成" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="volCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateVolume">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 扩容数据卷 -->
|
||
<el-dialog v-model="volResizeVisible" title="扩容数据卷" width="400px" destroy-on-close>
|
||
<el-form label-width="100px">
|
||
<el-form-item label="当前大小">{{ volResizeTarget?.size || 0 }} GB</el-form-item>
|
||
<el-form-item label="新大小">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="volNewSize" :min="volResizeTarget?.size || 1" controls-position="right" style="flex:1" />
|
||
<span class="unit-text">GB</span>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="volResizeVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitResizeVolume">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建快照 -->
|
||
<el-dialog v-model="snapshotCreateVisible" title="创建快照" width="400px" destroy-on-close>
|
||
<el-form label-width="90px">
|
||
<el-form-item label="快照名称" required><el-input v-model="snapshotForm.name" /></el-form-item>
|
||
<el-form-item label="描述"><el-input v-model="snapshotForm.description" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="snapshotCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateSnapshot">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建备份 -->
|
||
<el-dialog v-model="backupCreateVisible" title="创建备份" width="400px" destroy-on-close>
|
||
<el-form label-width="90px">
|
||
<el-form-item label="备份名称" required><el-input v-model="backupForm.name" /></el-form-item>
|
||
<el-form-item label="描述"><el-input v-model="backupForm.description" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="backupCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateBackup">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建安全组 -->
|
||
<el-dialog v-model="sgCreateVisible" title="创建安全组" width="440px" destroy-on-close>
|
||
<el-form :model="sgCreateForm" label-width="90px">
|
||
<el-form-item label="名称" required><el-input v-model="sgCreateForm.name" /></el-form-item>
|
||
<el-form-item label="方向">
|
||
<el-select v-model="sgCreateForm.direction" style="width:100%">
|
||
<el-option label="入站 (in)" value="in" /><el-option label="出站 (out)" value="out" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="锁定"><el-switch v-model="sgCreateForm.lock" /></el-form-item>
|
||
<el-form-item label="白名单"><el-switch v-model="sgCreateForm.drop_all" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="sgCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateSg">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建组网 -->
|
||
<el-dialog v-model="networkingCreateVisible" title="创建组网" width="440px" destroy-on-close>
|
||
<el-form :model="networkingCreateForm" label-width="90px">
|
||
<el-form-item label="名称"><el-input v-model="networkingCreateForm.name" placeholder="不填则随机生成" /></el-form-item>
|
||
<el-form-item label="网桥名称"><el-input v-model="networkingCreateForm.bridge_name" placeholder="不填则随机生成" /></el-form-item>
|
||
<el-form-item label="网关"><el-input v-model="networkingCreateForm.gateway" placeholder="可选" /></el-form-item>
|
||
<el-form-item label="描述"><el-input v-model="networkingCreateForm.description" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="networkingCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateNetworking">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 分配IP -->
|
||
<el-dialog v-model="assignVisible" title="加入组网" width="400px" destroy-on-close>
|
||
<el-form label-width="90px">
|
||
<el-form-item label="组网">{{ assignTarget?.name }}</el-form-item>
|
||
<el-form-item label="指定IP"><el-input v-model="assignIp" placeholder="留空自动分配" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="assignVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitAssign">分配</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 重装系统 -->
|
||
<el-dialog v-model="rebuildVisible" title="重装系统" width="480px" destroy-on-close>
|
||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">重装会清除当前系统数据,请谨慎操作!</el-alert>
|
||
<el-form label-width="80px">
|
||
<el-form-item label="镜像" required>
|
||
<div class="selector-row">
|
||
<el-input :model-value="rebuildImageName || (rebuildImageId ? `镜像 #${rebuildImageId}` : '')"
|
||
readonly placeholder="请选择镜像" style="flex:1" />
|
||
<el-button type="primary" @click="showRebuildImageSelector = true" style="margin-left:8px">选择</el-button>
|
||
<el-button v-if="rebuildImageId" @click="rebuildImageId = 0; rebuildImageName = ''" style="margin-left:4px">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="rebuildVisible = false">取消</el-button>
|
||
<el-button type="danger" :loading="actionLoading" :disabled="!rebuildImageId" @click="submitRebuild">确定重装</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 重装镜像选择器 -->
|
||
<el-dialog v-model="showRebuildImageSelector" title="选择镜像" width="640px" append-to-body destroy-on-close>
|
||
<div class="selector-toolbar">
|
||
<el-button :icon="Refresh" @click="loadRebuildImages" :loading="rebuildImagesLoading">刷新</el-button>
|
||
<span style="color:#909399;font-size:13px">仅显示宿主机上已同步完成的镜像</span>
|
||
</div>
|
||
<el-table :data="rebuildImages" v-loading="rebuildImagesLoading" highlight-current-row
|
||
@current-change="row => rebuildSelectedImage = row" :height="320" stripe size="small">
|
||
<el-table-column prop="id" label="ID" width="70" />
|
||
<el-table-column prop="name" label="名称" min-width="200" show-overflow-tooltip />
|
||
<el-table-column label="系统类型" width="100">
|
||
<template #default="{ row }"><el-tag :type="row.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ row.os_type }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column label="类型" width="80">
|
||
<template #default="{ row }"><el-tag size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag></template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!rebuildImages.length && !rebuildImagesLoading" :image-size="60" description="暂无可用镜像" />
|
||
<template #footer>
|
||
<el-button @click="showRebuildImageSelector = false">取消</el-button>
|
||
<el-button type="primary" :disabled="!rebuildSelectedImage" @click="confirmRebuildImage">确定选择</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 修改带宽 -->
|
||
<el-dialog v-model="trafficVisible" title="修改带宽" width="440px" destroy-on-close>
|
||
<el-form :model="trafficForm" label-width="100px">
|
||
<el-form-item label="下行带宽">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||
<el-select v-model="trafficForm._rxUnit" class="unit-select" style="width:100px">
|
||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||
</el-select>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="上行带宽">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||
<el-select v-model="trafficForm._txUnit" class="unit-select" style="width:100px">
|
||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||
</el-select>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="流量上限">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="trafficForm._trafficValue" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||
<el-select v-model="trafficForm._trafficUnit" class="unit-select" style="width:100px">
|
||
<el-option label="MB" value="MB" /><el-option label="GB" value="GB" /><el-option label="TB" value="TB" />
|
||
</el-select>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="耗尽下行限速">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="trafficForm.traffic_exhausted_rx_mbps" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||
<span class="unit-text" style="margin-left:8px">Mbps(0 不限)</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="耗尽上行限速">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="trafficForm.traffic_exhausted_tx_mbps" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||
<span class="unit-text" style="margin-left:8px">Mbps(0 不限)</span>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="trafficVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitUpdateTraffic">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 流量策略管理 -->
|
||
<el-dialog v-model="trafficPolicyVisible" title="修改流量策略" width="440px" destroy-on-close>
|
||
<el-form :model="trafficPolicyForm" label-width="120px">
|
||
<el-form-item label="流量上限">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="trafficPolicyForm.traffic_max_mb" :min="0" controls-position="right" style="flex:1" />
|
||
<span class="unit-text" style="margin-left:8px">MB(0 不限)</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="耗尽下行限速">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="trafficPolicyForm.exhausted_rx_mbps" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||
<span class="unit-text" style="margin-left:8px">Mbps(0 不限)</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="耗尽上行限速">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="trafficPolicyForm.exhausted_tx_mbps" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||
<span class="unit-text" style="margin-left:8px">Mbps(0 不限)</span>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="trafficPolicyVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="trafficPolicyLoading" @click="submitUpdateTrafficPolicy">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 增加固定/临时流量 -->
|
||
<el-dialog v-model="addTrafficVisible" :title="addTrafficType === 'fixed' ? '增加固定流量上限' : '增加一次性临时流量'" width="400px" destroy-on-close>
|
||
<el-form :model="addTrafficForm" label-width="110px">
|
||
<el-form-item label="流量数量">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="addTrafficForm.traffic_mb" :min="1" controls-position="right" style="flex:1" />
|
||
<span class="unit-text" style="margin-left:8px">MB</span>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="addTrafficVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="trafficPolicyLoading" @click="submitAddTraffic">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 转移用户 -->
|
||
<el-dialog v-model="transferVisible" title="转移虚拟机" width="440px" destroy-on-close>
|
||
<el-form label-width="100px">
|
||
<el-form-item label="目标用户">
|
||
<div class="selector-row">
|
||
<el-input :model-value="transferForm._userName || (transferForm.target_user_id ? `用户 #${transferForm.target_user_id}` : '')" readonly placeholder="请选择目标用户" style="flex:1" />
|
||
<el-button type="primary" @click="showTransferUserSelector = true" style="margin-left:8px">选择</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="transferVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitTransfer">确定转移</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
<UserSelector v-model:visible="showTransferUserSelector" @select="u => { transferForm.target_user_id = u.user_id; transferForm._userName = u.user_name }" />
|
||
|
||
<!-- 迁移虚拟机弹窗 -->
|
||
<el-dialog v-model="migrateVisible" title="迁移虚拟机(更换宿主机)" width="560px" destroy-on-close>
|
||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">将虚拟机迁移到其他宿主机或宿主机组,请谨慎操作!</el-alert>
|
||
<el-form label-width="120px" v-loading="migrateOptionsLoading">
|
||
<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'" required>
|
||
<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" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="目标宿主机组" v-if="migrateMode === 'group'" required>
|
||
<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>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="IPv4数量">
|
||
<el-input-number v-model="migrateForm.ipv4_num" :min="0" controls-position="right" style="width:100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="IPv6数量">
|
||
<el-input-number v-model="migrateForm.ipv6_num" :min="0" controls-position="right" style="width:100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="migrateVisible = false">取消</el-button>
|
||
<el-button type="warning" :loading="actionLoading" @click="submitMigrateVm">确定迁移</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
<!-- 编辑安全组弹窗 -->
|
||
<el-dialog v-model="sgEditVisible" title="编辑安全组" width="440px" destroy-on-close>
|
||
<el-form :model="sgEditForm" label-width="90px">
|
||
<el-form-item label="名称"><el-input v-model="sgEditForm.name" /></el-form-item>
|
||
<el-form-item label="方向">
|
||
<el-select v-model="sgEditForm.direction" style="width:100%">
|
||
<el-option label="入站 (in)" value="in" /><el-option label="出站 (out)" value="out" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="锁定"><el-switch v-model="sgEditForm.lock" /></el-form-item>
|
||
<el-form-item label="白名单"><el-switch v-model="sgEditForm.drop_all" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="sgEditVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitEditSg">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 安全组规则管理弹窗 -->
|
||
<el-dialog v-model="sgRuleVisible" :title="`安全组「${sgRuleTarget?.name}」规则管理`" width="760px" destroy-on-close>
|
||
<div class="tab-toolbar" style="margin-bottom:12px">
|
||
<el-button size="small" type="primary" @click="handleAddSgRule">新增规则</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadSgDetail(sgRuleTarget?.id)" :loading="sgDetailLoading">刷新</el-button>
|
||
<el-button size="small" @click="handleSgWhitelist(sgRuleTarget)">{{ sgRuleTarget?.drop_all ? '关闭白名单' : '开启白名单' }}</el-button>
|
||
</div>
|
||
<el-table :data="sgRules" v-loading="sgDetailLoading" stripe size="small">
|
||
<el-table-column prop="id" label="ID" width="70" />
|
||
<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 label="动作" width="70"><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="100" />
|
||
<el-table-column prop="ip_range" label="IP范围" min-width="130" />
|
||
<el-table-column prop="priority" label="优先级" width="80" />
|
||
<el-table-column label="操作" width="120" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleEditSgRule(row)">编辑</el-button>
|
||
<el-button link type="danger" size="small" @click="handleDeleteSgRule(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!sgRules.length && !sgDetailLoading" :image-size="60" description="暂无规则" />
|
||
<template #footer><el-button @click="sgRuleVisible = false">关闭</el-button></template>
|
||
</el-dialog>
|
||
|
||
<!-- 新增/编辑规则弹窗 -->
|
||
<el-dialog v-model="sgRuleFormVisible" :title="sgRuleFormType === 'add' ? '新增规则' : '编辑规则'" width="480px" destroy-on-close>
|
||
<el-form :model="sgRuleForm" label-width="90px">
|
||
<el-form-item label="协议">
|
||
<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="动作">
|
||
<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" /></el-form-item>
|
||
<el-form-item label="优先级"><el-input-number v-model="sgRuleForm.priority" :min="0" :max="9999" controls-position="right" style="width:100%" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="sgRuleFormVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitSgRule">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 绑定安全组选择器 -->
|
||
<UserVmSecurityGroupSelector v-model="showSgBindSelector" :user-goods-id="userGoodsId"
|
||
@confirm="sg => handleBindSg(sg)" />
|
||
<!-- 挂载数据卷选择器 -->
|
||
<UserVmVolumeSelector v-model="showVolumeSelector" :user-goods-id="userGoodsId"
|
||
@confirm="vol => handleMountVolume(vol)" />
|
||
|
||
<!-- 编辑虚拟机弹窗(对接 /user_vm/update) -->
|
||
<el-dialog v-model="editVmVisible" title="编辑虚拟机配置" width="680px" destroy-on-close class="scrollable-dialog">
|
||
<el-form :model="editVmForm" label-width="90px" v-loading="editVmLoading">
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="下行带宽">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="editVmForm.rx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||
<el-select v-model="editVmForm._rxUnit" class="unit-select" style="width:100px">
|
||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||
</el-select>
|
||
</div>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="上行带宽">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="editVmForm.tx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||
<el-select v-model="editVmForm._txUnit" class="unit-select" style="width:100px">
|
||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||
</el-select>
|
||
</div>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="Root密码">
|
||
<el-input v-model="editVmForm.root_password" placeholder="留空则不修改" show-password />
|
||
</el-form-item>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="SSH端口">
|
||
<el-input-number v-model="editVmForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width:100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="快照上限">
|
||
<el-input-number v-model="editVmForm.snapshot_num" :min="0" controls-position="right" style="width:100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="备份上限">
|
||
<el-input-number v-model="editVmForm.backup_num" :min="0" controls-position="right" style="width:100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="安全组">
|
||
<div class="selector-row">
|
||
<el-input :model-value="editVmForm._sgName || (editVmForm.port_group_id ? `安全组 #${editVmForm.port_group_id}` : '')"
|
||
readonly placeholder="可选" style="flex:1" />
|
||
<el-button type="primary" @click="showEditVmSgSelector = true" style="margin-left:8px">选择</el-button>
|
||
<el-button v-if="editVmForm.port_group_id" @click="editVmForm.port_group_id = 0; editVmForm._sgName = ''" style="margin-left:4px">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="公网网络">
|
||
<div class="selector-row">
|
||
<el-input :model-value="editVmForm._networkName || (editVmForm.internet_network_id ? `网络 #${editVmForm.internet_network_id}` : '')"
|
||
readonly placeholder="可选,仅网桥类型" style="flex:1" />
|
||
<el-button type="primary" @click="showEditVmNetSelector = true" style="margin-left:8px">选择</el-button>
|
||
<el-button v-if="editVmForm.internet_network_id" @click="editVmForm.internet_network_id = 0; editVmForm._networkName = ''" style="margin-left:4px">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="editVmVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitEditVm">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
<UserVmSecurityGroupSelector v-model="showEditVmSgSelector" :user-goods-id="userGoodsId"
|
||
@confirm="sg => { editVmForm.port_group_id = sg.id; editVmForm._sgName = sg.name }" />
|
||
<UserVmNetworkSelector v-model="showEditVmNetSelector" :user-goods-id="userGoodsId"
|
||
@confirm="net => { editVmForm.internet_network_id = net.id; editVmForm._networkName = net.name }" />
|
||
|
||
<!-- 重构虚拟机弹窗 -->
|
||
<el-dialog v-model="refactorVisible" title="重构虚拟机" width="720px" destroy-on-close class="scrollable-dialog">
|
||
<el-alert type="warning" :closable="false" style="margin-bottom:12px">重构会修改虚拟机底层配置,请谨慎操作!</el-alert>
|
||
<el-form :model="refactorForm" label-width="90px">
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="内存">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="refactorForm._memoryValue" :min="0" controls-position="right" style="flex:1" />
|
||
<el-select v-model="refactorForm._memoryUnit" class="unit-select" style="width:85px">
|
||
<el-option label="MB" value="MB" /><el-option label="GB" value="GB" />
|
||
</el-select>
|
||
</div>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="vCPU">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="refactorForm.vcpu" :min="0" controls-position="right" style="flex:1" />
|
||
<span class="unit-text">核</span>
|
||
</div>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="下行带宽">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||
<el-select v-model="refactorForm._rxUnit" class="unit-select" style="width:100px">
|
||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||
</el-select>
|
||
</div>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="上行带宽">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||
<el-select v-model="refactorForm._txUnit" class="unit-select" style="width:100px">
|
||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||
</el-select>
|
||
</div>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="Root密码"><el-input v-model="refactorForm.root_password" placeholder="不修改留空" show-password /></el-form-item>
|
||
<el-form-item label="UUID"><el-input v-model="refactorForm.uuid" placeholder="虚拟机UUID" /></el-form-item>
|
||
<el-form-item label="元数据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>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="SSH端口"><el-input-number v-model="refactorForm.ssh_port" :min="0" :max="65535" controls-position="right" style="width:100%" /></el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="VNC端口"><el-input-number v-model="refactorForm.vnc_port" :min="0" :max="65535" controls-position="right" style="width:100%" /></el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="VNC密码"><el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password /></el-form-item>
|
||
<el-form-item label="安全组">
|
||
<div class="selector-row">
|
||
<el-input :model-value="refactorForm._sgName || (refactorForm.port_group_id ? `安全组 #${refactorForm.port_group_id}` : '')" readonly placeholder="可选" style="flex:1" />
|
||
<el-button type="primary" @click="showRefactorSgSelector = true" style="margin-left:8px">选择</el-button>
|
||
<el-button v-if="refactorForm.port_group_id" @click="refactorForm.port_group_id = 0; refactorForm._sgName = ''" style="margin-left:4px">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="公网网络">
|
||
<div class="selector-row">
|
||
<el-input :model-value="refactorForm._networkName || (refactorForm.internet_network_id ? `网络 #${refactorForm.internet_network_id}` : '')" readonly placeholder="可选" style="flex:1" />
|
||
<el-button type="primary" @click="showRefactorNetSelector = true" style="margin-left:8px">选择</el-button>
|
||
<el-button v-if="refactorForm.internet_network_id" @click="refactorForm.internet_network_id = 0; refactorForm._networkName = ''" style="margin-left:4px">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="refactorVisible = false">取消</el-button>
|
||
<el-button type="warning" :loading="actionLoading" @click="submitRefactorVm">确定重构</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
<UserVmSecurityGroupSelector v-model="showRefactorSgSelector" :user-goods-id="userGoodsId"
|
||
@confirm="sg => { refactorForm.port_group_id = sg.id; refactorForm._sgName = sg.name }" />
|
||
<UserVmNetworkSelector v-model="showRefactorNetSelector" :user-goods-id="userGoodsId"
|
||
@confirm="net => { refactorForm.internet_network_id = net.id; refactorForm._networkName = net.name }" />
|
||
|
||
<!-- 绑定网络选择器 -->
|
||
<UserVmNetworkSelector v-model="showBindNetworkSelector" :user-goods-id="userGoodsId"
|
||
:show-create-button="false" @confirm="handleBindNetworkConfirm" />
|
||
|
||
<!-- 编辑商品信息弹窗 --> <el-dialog v-model="editGoodsVisible" title="编辑商品信息" width="480px" destroy-on-close>
|
||
<el-form :model="editGoodsForm" label-width="110px">
|
||
<el-form-item label="备注"><el-input v-model="editGoodsForm.note" /></el-form-item>
|
||
<el-form-item label="续费价格">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="editGoodsForm.renew_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||
<span class="unit-text">元</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="基础价格">
|
||
<div class="unit-input-row">
|
||
<el-input-number v-model="editGoodsForm.base_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||
<span class="unit-text">元</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="到期时间">
|
||
<el-date-picker v-model="editGoodsForm.expire_time" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="editGoodsVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitEditGoods">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 任务进度弹窗 -->
|
||
<el-dialog v-model="progressVisible" :title="progressTitle" width="480px" destroy-on-close>
|
||
<div v-loading="progressLoading">
|
||
<el-descriptions :column="1" border size="small" v-if="progressData">
|
||
<el-descriptions-item label="任务ID">
|
||
<span style="font-family:monospace;font-size:12px">{{ progressData.task_id || '-' }}</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="状态">
|
||
<el-tag :type="taskStatusType(progressData.status)" size="small">{{ progressData.status || '-' }}</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="元数据" v-if="progressData.meta && progressData.meta.length > 2">
|
||
<span style="word-break:break-all;font-size:12px">{{ progressData.meta }}</span>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-empty v-else-if="!progressLoading" description="暂无进度信息" :image-size="60" />
|
||
</div>
|
||
<template #footer><el-button @click="progressVisible = false">关闭</el-button></template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
|
||
import {
|
||
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
|
||
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
||
transferUserVm, migrateUserVm, updateUserVmTraffic, updateUserVm, refactorUserVm,
|
||
getUserVmVolumeList, createUserVmVolume, resizeUserVmVolume, mountUserVmVolume, unmountUserVmVolume, deleteUserVmVolume,
|
||
getUserVmSnapshotList, getUserVmSnapshotCount, createUserVmSnapshot, restoreUserVmSnapshot, deleteUserVmSnapshot, getUserVmSnapshotProgress, setUserVmSnapshotLimit,
|
||
getUserVmBackupList, getUserVmBackupCount, createUserVmBackup, restoreUserVmBackup, deleteUserVmBackup, getUserVmBackupProgress, setUserVmBackupLimit,
|
||
getUserVmPostGroupList, createUserVmPostGroup, updateUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist,
|
||
createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule,
|
||
getUserVmPostGroupDetail,
|
||
getUserVmNetworkList, getUserVmNetworkDetail, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
|
||
getUserGoodsDetail,
|
||
getUserVmMetricsHistory,
|
||
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic
|
||
} from '@/api/admin/userVm'
|
||
import { deleteNetwork as deletePointNetwork } from '@/api/admin/kvmService'
|
||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
|
||
import UserSelector from '@/components/UserSelector/index.vue'
|
||
import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue'
|
||
import UserVmVolumeSelector from '@/components/admin/UserVmVolumeSelector.vue'
|
||
import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue'
|
||
import dayjs from 'dayjs'
|
||
import * as echarts from 'echarts'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const userGoodsId = ref(parseInt(route.query.id) || 0)
|
||
|
||
const loading = ref(false)
|
||
const actionLoading = ref(false)
|
||
const activeTab = ref('volume')
|
||
|
||
// 详情数据
|
||
const userGoods = ref(null)
|
||
const vm = ref(null)
|
||
const vmNetworks = ref([])
|
||
const vmVolumes = ref([])
|
||
const vmImage = ref(null)
|
||
const inPortGroup = ref(null)
|
||
const outPortGroup = ref(null)
|
||
const isVmGoods = ref(false)
|
||
const showPassword = ref(false)
|
||
|
||
const copyPassword = async () => {
|
||
const pwd = vm.value?.root_password
|
||
if (!pwd) { ElMessage.warning('暂无密码'); return }
|
||
try {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(pwd)
|
||
} else {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = pwd
|
||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'
|
||
document.body.appendChild(ta)
|
||
ta.focus()
|
||
ta.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
}
|
||
ElMessage.success('密码已复制到剪贴板')
|
||
} catch {
|
||
ElMessage.error('复制失败,请手动复制')
|
||
}
|
||
}
|
||
|
||
const clipCopy = async (text) => {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(text)
|
||
} else {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'
|
||
document.body.appendChild(ta)
|
||
ta.focus(); ta.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
}
|
||
}
|
||
|
||
const copyText = async (text) => {
|
||
if (!text) return
|
||
try { await clipCopy(text); ElMessage.success('已复制') } catch { ElMessage.error('复制失败') }
|
||
}
|
||
|
||
const copyAllIps = async (ipList) => {
|
||
if (!ipList?.length) return
|
||
try { await clipCopy(ipList.join('\n')); ElMessage.success(`已复制 ${ipList.length} 个IP`) } catch { ElMessage.error('复制失败') }
|
||
}
|
||
|
||
const isWindows = computed(() => vmImage.value?.os_type === 'windows')
|
||
|
||
const vmPublicIpList = computed(() => {
|
||
return vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean)
|
||
})
|
||
const vmPrivateIpList = computed(() => {
|
||
return vmNetworks.value.filter(n => n.type === 'nat').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean)
|
||
})
|
||
|
||
// 安全组列表(来自详情接口,入站+出站)
|
||
const inPortGroupList = computed(() => {
|
||
const list = []
|
||
if (inPortGroup.value) list.push(inPortGroup.value)
|
||
if (outPortGroup.value) list.push(outPortGroup.value)
|
||
return list
|
||
})
|
||
const showSgBindSelector = ref(false)
|
||
|
||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||
const formatVncExpire = (t) => {
|
||
if (!t) return '-'
|
||
// 处理 {seconds: xxx} 格式
|
||
const sec = typeof t === 'object' ? t.seconds : null
|
||
if (sec) return dayjs(sec * 1000).format('YYYY-MM-DD HH:mm:ss')
|
||
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
|
||
}
|
||
const formatExpireTime = (t) => { if (!t) return '-'; const d = dayjs(t); return d.year() < 2000 ? '永久' : d.format('YYYY-MM-DD HH:mm') }
|
||
const formatMemory = (kb) => { if (!kb) return '-'; const n = Number(kb); if (n >= 1048576) return (n / 1048576).toFixed(1) + ' GB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' MB'; return n + ' KB' }
|
||
const formatTraffic = (mb) => { if (!mb) return '-'; const n = Number(mb); if (n >= 1024) return (n / 1024).toFixed(2) + ' GB'; return n + ' Mb' }
|
||
const vmStatusType = (s) => vmStatusTypeUtil(s)
|
||
const vmStatusLabel = (s) => vmStatusLabelUtil(s)
|
||
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
|
||
|
||
const goBack = () => router.back()
|
||
|
||
const loadDetail = async () => {
|
||
if (!userGoodsId.value) return
|
||
loading.value = true
|
||
try {
|
||
// 并行请求虚拟机详情和用户商品详情
|
||
const [vmRes, goodsRes] = await Promise.allSettled([
|
||
getUserVmDetail({ user_goods_id: userGoodsId.value }),
|
||
getUserGoodsDetail({ id: userGoodsId.value })
|
||
])
|
||
|
||
// 优先用虚拟机详情里的 user_goods
|
||
if (vmRes.status === 'fulfilled' && vmRes.value?.data?.code === 200 && vmRes.value?.data?.data) {
|
||
const d = vmRes.value.data.data
|
||
userGoods.value = d.user_goods
|
||
isVmGoods.value = true
|
||
const vmData = d.vm
|
||
if (vmData) {
|
||
vm.value = vmData.data
|
||
vmNetworks.value = vmData.networks || []
|
||
vmVolumes.value = vmData.volumes || []
|
||
vmImage.value = vmData.image || null
|
||
inPortGroup.value = vmData.in_port_group || null
|
||
outPortGroup.value = vmData.out_port_group || null
|
||
if (activeTab.value === 'security') loadSgLockInfo()
|
||
} else {
|
||
// vm 为空,降级:用用户商品详情补充数据
|
||
vm.value = null
|
||
vmNetworks.value = []
|
||
vmVolumes.value = []
|
||
vmImage.value = null
|
||
inPortGroup.value = null
|
||
outPortGroup.value = null
|
||
}
|
||
} else if (goodsRes.status === 'fulfilled' && goodsRes.value?.data?.code === 200 && goodsRes.value?.data?.data) {
|
||
// 虚拟机详情失败(非虚拟机商品),降级用用户商品详情
|
||
const d = goodsRes.value.data.data?.data ?? goodsRes.value.data.data
|
||
userGoods.value = d
|
||
isVmGoods.value = false
|
||
vm.value = null
|
||
vmNetworks.value = []
|
||
vmVolumes.value = []
|
||
vmImage.value = null
|
||
inPortGroup.value = null
|
||
outPortGroup.value = null
|
||
} else {
|
||
ElMessage.error('加载失败')
|
||
}
|
||
|
||
// 如果 userGoods 还没有,尝试从用户商品详情补充
|
||
if (userGoods.value && goodsRes.status === 'fulfilled' && goodsRes.value?.data?.code === 200) {
|
||
const goodsData = goodsRes.value.data.data?.data ?? goodsRes.value.data.data
|
||
// 补充 user 信息(虚拟机详情里 user 字段可能为空)
|
||
if (goodsData?.user?.UserName && !userGoods.value.user?.UserName) {
|
||
userGoods.value = { ...userGoods.value, user: goodsData.user }
|
||
}
|
||
}
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||
}
|
||
|
||
const handleTabChange = (tab) => {
|
||
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
|
||
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
||
if (tab === 'security') loadSgLockInfo()
|
||
if (tab === 'networking') loadNetworkings()
|
||
if (tab === 'monitor' && !metricsData.value) loadMetricsHistory()
|
||
if (tab === 'trafficPolicy') loadTrafficPolicy()
|
||
}
|
||
|
||
// 请求安全组详情补充 lock 字段(使用用户虚拟机安全组详情接口)
|
||
const loadSgLockInfo = async () => {
|
||
const groups = [inPortGroup.value, outPortGroup.value].filter(Boolean)
|
||
for (const sg of groups) {
|
||
try {
|
||
const res = await getUserVmPostGroupDetail({ user_goods_id: userGoodsId.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 (inPortGroup.value?.id === sg.id) inPortGroup.value = { ...inPortGroup.value, lock: d.lock ?? sg.lock }
|
||
if (outPortGroup.value?.id === sg.id) outPortGroup.value = { ...outPortGroup.value, lock: d.lock ?? sg.lock }
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
}
|
||
|
||
// ---- VNC ----
|
||
const vncVisible = ref(false)
|
||
const vncLoading = ref(false)
|
||
const vncResult = ref(null)
|
||
const handleVnc = async () => {
|
||
vncVisible.value = true; vncLoading.value = true; vncResult.value = null
|
||
try {
|
||
const res = await getUserVmVnc({ user_goods_id: userGoodsId.value })
|
||
if (res?.data?.code === 200) vncResult.value = res.data.data
|
||
else ElMessage.error(extractApiError(res?.data, '获取VNC失败'))
|
||
} catch { /* */ } finally { vncLoading.value = false }
|
||
}
|
||
const openVncUrl = () => { if (vncResult.value?.url) window.open(vncResult.value.url, '_blank') }
|
||
|
||
// ---- 电源 ----
|
||
const powerVisible = ref(false)
|
||
const powerAction = ref('')
|
||
const powerLabels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复', rescue: '救援', exitRescue: '退出救援' }
|
||
const powerApis = { start: startUserVm, stop: stopUserVm, reboot: rebootUserVm, suspend: suspendUserVm, resume: resumeUserVm, rescue: rescueUserVm, exitRescue: exitRescueUserVm }
|
||
|
||
const handlePower = (action) => { powerAction.value = action; powerVisible.value = true }
|
||
const submitPower = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await powerApis[powerAction.value]({ user_goods_id: userGoodsId.value })
|
||
if (res?.data?.code === 200) { ElMessage.success(`${powerLabels[powerAction.value]}成功`); powerVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
const handleMoreCmd = (cmd) => {
|
||
if (powerLabels[cmd]) { handlePower(cmd); return }
|
||
if (cmd === 'rebuild') { rebuildImageId.value = 0; rebuildImageName.value = ''; rebuildImages.value = []; rebuildVisible.value = true; loadRebuildImages() }
|
||
if (cmd === 'updateTraffic') { Object.assign(trafficForm, { rx_bandwidth: vm.value?.rx_bandwidth || 0, tx_bandwidth: vm.value?.tx_bandwidth || 0, _trafficValue: +((vm.value?.traffic_max || 0) / 1024).toFixed(2), _rxUnit: 'Mbps', _txUnit: 'Mbps', _trafficUnit: 'GB', traffic_exhausted_rx_mbps: 0, traffic_exhausted_tx_mbps: 0 }); trafficVisible.value = true }
|
||
if (cmd === 'transfer') { Object.assign(transferForm, { target_user_id: 0, _userName: '' }); transferVisible.value = true }
|
||
if (cmd === 'updateVm') openEditVm()
|
||
if (cmd === 'refactorVm') openRefactorVm()
|
||
if (cmd === 'migrate') openMigrateVm()
|
||
if (cmd === 'editGoods') openEditGoods()
|
||
if (cmd === 'delete') {
|
||
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
|
||
try {
|
||
const res = await deleteUserVm({ user_goods_id: userGoodsId.value })
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
}
|
||
|
||
// ---- 数据卷 ----
|
||
const isVolumeMounted = (row) => {
|
||
if (row.is_mount !== undefined) return !!row.is_mount
|
||
return row.status === 'ready'
|
||
}
|
||
const showVolumeSelector = ref(false)
|
||
const volumes = ref([])
|
||
const volumeLoading = ref(false)
|
||
const volumePage = ref(1)
|
||
const volumePageSize = ref(10)
|
||
const volumeTotal = ref(0)
|
||
const volCreateVisible = ref(false)
|
||
const volResizeVisible = ref(false)
|
||
const volResizeTarget = ref(null)
|
||
const volNewSize = ref(1)
|
||
const volCreateForm = reactive({ name: '', size: 10, target_device: '' })
|
||
|
||
const loadVolumes = async () => {
|
||
volumeLoading.value = true
|
||
try {
|
||
const res = await getUserVmVolumeList({ user_goods_id: userGoodsId.value, page: volumePage.value, count: volumePageSize.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data; volumes.value = d.data || (Array.isArray(d) ? d : [])
|
||
volumeTotal.value = d.all_count ?? d.total ?? volumes.value.length
|
||
}
|
||
} catch { /* */ } finally { volumeLoading.value = false }
|
||
}
|
||
const handleCreateVolume = () => { Object.assign(volCreateForm, { name: '', size: 10, target_device: '' }); volCreateVisible.value = true }
|
||
const submitCreateVolume = async () => {
|
||
if (!volCreateForm.name) { ElMessage.warning('请输入名称'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await createUserVmVolume({ user_goods_id: userGoodsId.value, ...volCreateForm })
|
||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); volCreateVisible.value = false; loadVolumes() }
|
||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||
}
|
||
const handleResizeVolume = (row) => { volResizeTarget.value = row; volNewSize.value = row.size || 1; volResizeVisible.value = true }
|
||
const submitResizeVolume = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await resizeUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: volResizeTarget.value.id, size: volNewSize.value })
|
||
if (res?.data?.code === 200) { ElMessage.success('扩容成功'); volResizeVisible.value = false; loadVolumes() }
|
||
else ElMessage.error(extractApiError(res?.data, '扩容失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '扩容失败')) } finally { actionLoading.value = false }
|
||
}
|
||
const handleMountVolume = (row) => {
|
||
ElMessageBox.confirm('确定挂载该数据卷吗?', '挂载', { type: 'info' }).then(async () => {
|
||
try { const res = await mountUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('挂载成功'); loadVolumes() } else ElMessage.error(extractApiError(res?.data, '挂载失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
const handleUnmountVolume = (row) => {
|
||
ElMessageBox.confirm('确定卸载该数据卷吗?', '卸载', { type: 'warning' }).then(async () => {
|
||
try { const res = await unmountUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadDetail() } else ElMessage.error(extractApiError(res?.data, '卸载失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
const handleDeleteVolume = (row) => {
|
||
ElMessageBox.confirm('确定删除该数据卷吗?', '删除', { type: 'error' }).then(async () => {
|
||
try { const res = await deleteUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadVolumes() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// ---- 任务进度 ----
|
||
const progressVisible = ref(false)
|
||
const progressLoading = ref(false)
|
||
const progressTitle = ref('')
|
||
const progressData = ref(null)
|
||
|
||
// ---- 快照 ----
|
||
const snapshots = ref([])
|
||
const snapshotLoading = ref(false)
|
||
const snapshotQuota = ref(null)
|
||
const snapshotCreateVisible = ref(false)
|
||
const snapshotForm = reactive({ name: '', description: '' })
|
||
|
||
const loadSnapshots = async () => {
|
||
snapshotLoading.value = true
|
||
try {
|
||
const res = await getUserVmSnapshotList({ user_goods_id: userGoodsId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data; snapshots.value = d.data || (Array.isArray(d) ? d : []) }
|
||
} catch { /* */ } finally { snapshotLoading.value = false }
|
||
}
|
||
const loadSnapshotQuota = async () => {
|
||
try { const res = await getUserVmSnapshotCount({ user_goods_id: userGoodsId.value }); if (res?.data?.code === 200) snapshotQuota.value = res.data.data } catch { /* */ }
|
||
}
|
||
const handleCreateSnapshot = () => { Object.assign(snapshotForm, { name: '', description: '' }); snapshotCreateVisible.value = true }
|
||
const submitCreateSnapshot = async () => {
|
||
if (!snapshotForm.name) { ElMessage.warning('请输入名称'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await createUserVmSnapshot({ user_goods_id: userGoodsId.value, ...snapshotForm })
|
||
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}」吗?`, '恢复', { type: 'warning' }).then(async () => {
|
||
try { const res = await restoreUserVmSnapshot({ user_goods_id: userGoodsId.value, snapshot_id: row.id }); if (res?.data?.code === 200) ElMessage.success('恢复操作已提交'); else ElMessage.error(extractApiError(res?.data, '恢复失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
const handleDeleteSnapshot = (row) => {
|
||
ElMessageBox.confirm(`确定删除快照「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
|
||
try { const res = await deleteUserVmSnapshot({ user_goods_id: userGoodsId.value, snapshot_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSnapshots(); loadSnapshotQuota() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
const handleSnapshotProgress = async (row) => {
|
||
progressTitle.value = '快照任务进度'
|
||
progressData.value = null
|
||
progressVisible.value = true
|
||
progressLoading.value = true
|
||
try {
|
||
const res = await getUserVmSnapshotProgress({ user_goods_id: userGoodsId.value, task_id: String(row.task_id || row.id) })
|
||
if (res?.data?.code === 200) progressData.value = res.data.data?.data ?? res.data.data
|
||
else ElMessage.warning('暂无进度信息')
|
||
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
|
||
}
|
||
const handleSetSnapshotLimit = () => { ElMessageBox.prompt('请输入快照上限', '设置快照上限', { inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数', inputValue: String(snapshotQuota.value?.limit || 10) })
|
||
.then(async ({ value }) => { try { const res = await setUserVmSnapshotLimit({ user_goods_id: userGoodsId.value, limit: value }); if (res?.data?.code === 200) { ElMessage.success('设置成功'); loadSnapshotQuota() } else ElMessage.error(extractApiError(res?.data, '设置失败')) } catch { /* */ } }).catch(() => {})
|
||
}
|
||
|
||
// ---- 备份 ----
|
||
const backups = ref([])
|
||
const backupLoading = ref(false)
|
||
const backupQuota = ref(null)
|
||
const backupCreateVisible = ref(false)
|
||
const backupForm = reactive({ name: '', description: '' })
|
||
|
||
const loadBackups = async () => {
|
||
backupLoading.value = true
|
||
try {
|
||
const res = await getUserVmBackupList({ user_goods_id: userGoodsId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data; backups.value = d.data || (Array.isArray(d) ? d : []) }
|
||
} catch { /* */ } finally { backupLoading.value = false }
|
||
}
|
||
const loadBackupQuota = async () => {
|
||
try { const res = await getUserVmBackupCount({ user_goods_id: userGoodsId.value }); if (res?.data?.code === 200) backupQuota.value = res.data.data } catch { /* */ }
|
||
}
|
||
const handleCreateBackup = () => { Object.assign(backupForm, { name: '', description: '' }); backupCreateVisible.value = true }
|
||
const submitCreateBackup = async () => {
|
||
if (!backupForm.name) { ElMessage.warning('请输入名称'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await createUserVmBackup({ user_goods_id: userGoodsId.value, ...backupForm })
|
||
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}」吗?`, '恢复', { type: 'warning' }).then(async () => {
|
||
try { const res = await restoreUserVmBackup({ user_goods_id: userGoodsId.value, backup_id: row.id }); if (res?.data?.code === 200) ElMessage.success('恢复操作已提交'); else ElMessage.error(extractApiError(res?.data, '恢复失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
const handleDeleteBackup = (row) => {
|
||
ElMessageBox.confirm(`确定删除备份「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
|
||
try { const res = await deleteUserVmBackup({ user_goods_id: userGoodsId.value, backup_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadBackups(); loadBackupQuota() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
const handleBackupProgress = async (row) => {
|
||
progressTitle.value = '备份任务进度'
|
||
progressData.value = null
|
||
progressVisible.value = true
|
||
progressLoading.value = true
|
||
try {
|
||
const res = await getUserVmBackupProgress({ user_goods_id: userGoodsId.value, task_id: String(row.task_id || row.id) })
|
||
if (res?.data?.code === 200) progressData.value = res.data.data?.data ?? res.data.data
|
||
else ElMessage.warning('暂无进度信息')
|
||
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
|
||
}
|
||
const handleSetBackupLimit = () => {
|
||
ElMessageBox.prompt('请输入备份上限', '设置备份上限', { inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数', inputValue: String(backupQuota.value?.limit || 10) })
|
||
.then(async ({ value }) => { try { const res = await setUserVmBackupLimit({ user_goods_id: userGoodsId.value, limit: value }); if (res?.data?.code === 200) { ElMessage.success('设置成功'); loadBackupQuota() } else ElMessage.error(extractApiError(res?.data, '设置失败')) } catch { /* */ } }).catch(() => {})
|
||
}
|
||
|
||
// ---- 安全组 ----
|
||
const sgs = ref([])
|
||
const sgLoading = ref(false)
|
||
const sgPage = ref(1)
|
||
const sgPageSize = ref(10)
|
||
const sgTotal = ref(0)
|
||
const sgCreateVisible = ref(false)
|
||
const sgCreateForm = reactive({ name: '', direction: 'in', lock: false, drop_all: false })
|
||
|
||
const loadSecurityGroups = async () => {
|
||
sgLoading.value = true
|
||
try {
|
||
const res = await getUserVmPostGroupList({ user_goods_id: userGoodsId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data; sgs.value = d.groups || d.data || (Array.isArray(d) ? d : [])
|
||
sgTotal.value = d.total ?? sgs.value.length
|
||
}
|
||
} catch { /* */ } finally { sgLoading.value = false }
|
||
}
|
||
const handleCreateSg = () => { Object.assign(sgCreateForm, { name: '', direction: 'in', lock: false, drop_all: false }); sgCreateVisible.value = true }
|
||
const submitCreateSg = async () => {
|
||
if (!sgCreateForm.name) { ElMessage.warning('请输入名称'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await createUserVmPostGroup({ user_goods_id: userGoodsId.value, ...sgCreateForm })
|
||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); sgCreateVisible.value = false; loadSecurityGroups() }
|
||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||
}
|
||
const handleBindSg = async (row) => { try { const res = await bindUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadDetail() } else ElMessage.error(extractApiError(res?.data, '绑定失败')) } catch { /* */ } }
|
||
const handleUnbindSg = async (row) => { try { const res = await unbindUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadDetail() } else ElMessage.error(extractApiError(res?.data, '解绑失败')) } catch { /* */ } }
|
||
const handleApplySg = async (row) => { try { const res = await applyUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) ElMessage.success('应用成功'); else ElMessage.error(extractApiError(res?.data, '应用失败')) } catch { /* */ } }
|
||
|
||
// ---- 编辑安全组 ----
|
||
const sgEditVisible = ref(false)
|
||
const sgEditTarget = ref(null)
|
||
const sgEditForm = reactive({ name: '', direction: 'in', lock: false, drop_all: false })
|
||
|
||
const handleEditSg = (row) => {
|
||
sgEditTarget.value = row
|
||
Object.assign(sgEditForm, { name: row.name || '', direction: row.direction || 'in', lock: !!row.lock, drop_all: !!row.drop_all })
|
||
sgEditVisible.value = true
|
||
}
|
||
const submitEditSg = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await updateUserVmPostGroup({ user_goods_id: userGoodsId.value, id: sgEditTarget.value.id, ...sgEditForm })
|
||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); sgEditVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 安全组规则管理 ----
|
||
const sgRuleVisible = ref(false)
|
||
const sgRuleTarget = ref(null)
|
||
const sgRules = ref([])
|
||
const sgDetailLoading = ref(false)
|
||
const sgRuleFormVisible = ref(false)
|
||
const sgRuleFormType = ref('add')
|
||
const sgRuleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0 })
|
||
|
||
const loadSgDetail = async (id) => {
|
||
if (!id) return
|
||
sgDetailLoading.value = true
|
||
try {
|
||
const res = await getUserVmPostGroupDetail({ user_goods_id: userGoodsId.value, id })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data
|
||
const sg = d.group || d.data || d
|
||
sgRuleTarget.value = { ...sgRuleTarget.value, ...sg }
|
||
sgRules.value = sg.rules || []
|
||
}
|
||
} catch { /* */ } finally { sgDetailLoading.value = false }
|
||
}
|
||
|
||
const handleSgDetail = (row) => {
|
||
sgRuleTarget.value = row
|
||
sgRules.value = []
|
||
sgRuleVisible.value = true
|
||
loadSgDetail(row.id)
|
||
}
|
||
|
||
const handleAddSgRule = () => {
|
||
sgRuleFormType.value = 'add'
|
||
Object.assign(sgRuleForm, { id: undefined, group_id: sgRuleTarget.value?.id || 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0 })
|
||
sgRuleFormVisible.value = true
|
||
}
|
||
const handleEditSgRule = (rule) => {
|
||
sgRuleFormType.value = 'edit'
|
||
Object.assign(sgRuleForm, { id: rule.id, group_id: sgRuleTarget.value?.id || 0, protocol: rule.protocol || 'tcp', action: rule.action || 'allow', port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0 })
|
||
sgRuleFormVisible.value = true
|
||
}
|
||
const submitSgRule = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const payload = { user_goods_id: userGoodsId.value, group_id: sgRuleForm.group_id, protocol: sgRuleForm.protocol, action: sgRuleForm.action, priority: sgRuleForm.priority }
|
||
if (sgRuleForm.port_range) payload.port_range = sgRuleForm.port_range
|
||
if (sgRuleForm.ip_range) payload.ip_range = sgRuleForm.ip_range
|
||
if (sgRuleFormType.value === 'edit') { payload.id = sgRuleForm.id; payload.port_group_id = sgRuleForm.group_id }
|
||
const res = sgRuleFormType.value === 'add' ? await createUserVmPostGroupRule(payload) : await updateUserVmPostGroupRule(payload)
|
||
if (res?.data?.code === 200) { ElMessage.success(sgRuleFormType.value === 'add' ? '创建成功' : '修改成功'); sgRuleFormVisible.value = false; loadSgDetail(sgRuleTarget.value?.id) }
|
||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||
}
|
||
const handleDeleteSgRule = (rule) => {
|
||
ElMessageBox.confirm('确定删除该规则吗?', '删除', { type: 'warning' }).then(async () => {
|
||
try {
|
||
const res = await deleteUserVmPostGroupRule({ user_goods_id: userGoodsId.value, id: rule.id })
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSgDetail(sgRuleTarget.value?.id) }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
const handleSgWhitelist = async (row) => {
|
||
const api = row.drop_all ? disableUserVmPostGroupWhitelist : enableUserVmPostGroupWhitelist
|
||
try { const res = await api({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('操作成功'); loadDetail() } else ElMessage.error(extractApiError(res?.data, '操作失败')) } catch { /* */ }
|
||
}
|
||
const handleDeleteSg = (row) => {
|
||
ElMessageBox.confirm(`确定删除安全组「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
|
||
try { const res = await deleteUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSecurityGroups() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// ---- 网络 ----
|
||
const networks = ref([])
|
||
const networkLoading = ref(false)
|
||
const networkPage = ref(1)
|
||
const networkPageSize = ref(10)
|
||
const networkTotal = ref(0)
|
||
|
||
const loadNetworks = async () => {
|
||
networkLoading.value = true
|
||
try {
|
||
const res = await getUserVmNetworkList({ user_goods_id: userGoodsId.value, page: networkPage.value, count: networkPageSize.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data; networks.value = d.data || (Array.isArray(d) ? d : [])
|
||
networkTotal.value = d.all_count ?? d.total ?? networks.value.length
|
||
}
|
||
} catch { /* */ } finally { networkLoading.value = false }
|
||
}
|
||
|
||
// ---- 删除网络 ----
|
||
// 走 host_service/point/network/delete:删除底层物理网络(破坏性,会影响其他绑定该网络的 VM)
|
||
// row 字段可能不完整,service_id / host_id 通过 getUserVmNetworkDetail 兜底反查
|
||
const deletingNetworkId = ref(0)
|
||
const resolveNetworkServiceHost = async (row) => {
|
||
let serviceId = row.service_id ?? row.host_service_id ?? row.kvm_service_id ?? 0
|
||
let hostId = row.host_id ?? 0
|
||
if (!serviceId || !hostId) {
|
||
try {
|
||
const res = await getUserVmNetworkDetail({ user_goods_id: userGoodsId.value, id: row.id })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data
|
||
const n = d.data || d.network || d
|
||
if (!serviceId) serviceId = n?.service_id ?? n?.host_service_id ?? n?.kvm_service_id ?? 0
|
||
if (!hostId) hostId = n?.host_id ?? 0
|
||
}
|
||
} catch { /* 兜底失败时返回原值,由调用方判断 */ }
|
||
}
|
||
return { serviceId, hostId }
|
||
}
|
||
const handleDeleteVmNetwork = (row) => {
|
||
ElMessageBox.confirm(
|
||
`将删除底层网络「${row.name}」(ID:${row.id}),该操作会影响所有绑定该网络的虚拟机,是否继续?`,
|
||
'删除网络',
|
||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
|
||
).then(async () => {
|
||
deletingNetworkId.value = row.id
|
||
try {
|
||
const { serviceId, hostId } = await resolveNetworkServiceHost(row)
|
||
if (!serviceId) { ElMessage.error('无法获取该网络所属服务ID,删除失败'); return }
|
||
const params = { service_id: serviceId, network_id: row.id }
|
||
if (hostId) params.host_id = hostId
|
||
const res = await deletePointNetwork(params)
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) {
|
||
ElMessage.error(extractApiError(e?.response?.data, '删除失败'))
|
||
} finally {
|
||
deletingNetworkId.value = 0
|
||
}
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// ---- 绑定网络 ----
|
||
const showBindNetworkSelector = ref(false)
|
||
|
||
const handleBindNetworkConfirm = async (selectedNetwork) => {
|
||
const existingIds = vmNetworks.value.map(n => n.id)
|
||
if (existingIds.includes(selectedNetwork.id)) {
|
||
ElMessage.warning('该网络已绑定')
|
||
return
|
||
}
|
||
actionLoading.value = true
|
||
try {
|
||
const payload = { user_goods_id: userGoodsId.value }
|
||
const bridgeIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
|
||
const natNet = vmNetworks.value.find(n => n.type === 'nat')
|
||
if (selectedNetwork.type === 'nat') {
|
||
payload.internet_network_id = selectedNetwork.id
|
||
if (bridgeIds.length) payload.network_ids = bridgeIds
|
||
} else {
|
||
payload.network_ids = [...bridgeIds, selectedNetwork.id]
|
||
if (natNet) payload.internet_network_id = natNet.id
|
||
}
|
||
if (vm.value?.rx_bandwidth) payload.rx_bandwidth = vm.value.rx_bandwidth
|
||
if (vm.value?.tx_bandwidth) payload.tx_bandwidth = vm.value.tx_bandwidth
|
||
if (vm.value?.ssh_port) payload.ssh_port = vm.value.ssh_port
|
||
if (inPortGroup.value?.id) payload.port_group_id = inPortGroup.value.id
|
||
const res = await updateUserVm(payload)
|
||
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 networkings = ref([])
|
||
const networkingLoading = ref(false)
|
||
const networkingPage = ref(1)
|
||
const networkingPageSize = ref(10)
|
||
const networkingTotal = ref(0)
|
||
const networkingCreateVisible = ref(false)
|
||
|
||
// 组网行数据:匹配 vmNetworks 判断是否已加入
|
||
const networkingRows = computed(() => {
|
||
const bridgeMap = {}
|
||
for (const n of vmNetworks.value) {
|
||
if (n.bridge_name) bridgeMap[n.bridge_name] = n
|
||
}
|
||
return networkings.value.map(nw => {
|
||
const matched = nw.bridge_name ? bridgeMap[nw.bridge_name] : null
|
||
return { ...nw, _joined: !!matched, _network: matched || null }
|
||
})
|
||
})
|
||
const networkingCreateForm = reactive({ name: '', bridge_name: '', gateway: '', description: '' })
|
||
const assignVisible = ref(false)
|
||
const assignTarget = ref(null)
|
||
const assignIp = ref('')
|
||
|
||
const loadNetworkings = async () => {
|
||
networkingLoading.value = true
|
||
try {
|
||
const res = await getUserVmNetworkingList({ user_goods_id: userGoodsId.value, page: networkingPage.value, count: networkingPageSize.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data; networkings.value = d.data || (Array.isArray(d) ? d : [])
|
||
networkingTotal.value = d.all_count ?? d.total ?? networkings.value.length
|
||
}
|
||
} catch { /* */ } finally { networkingLoading.value = false }
|
||
}
|
||
const handleCreateNetworking = () => { Object.assign(networkingCreateForm, { name: '', bridge_name: '', gateway: '', description: '' }); networkingCreateVisible.value = true }
|
||
const submitCreateNetworking = async () => {
|
||
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await createUserVmNetworking({ user_goods_id: userGoodsId.value, ...networkingCreateForm })
|
||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); networkingCreateVisible.value = false; loadNetworkings() }
|
||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||
}
|
||
const handleAssignNetworking = (row) => { assignTarget.value = row; assignIp.value = ''; assignVisible.value = true }
|
||
|
||
// 加入组网(弹窗指定IP)
|
||
const handleJoinNetworking = (row) => { assignTarget.value = row; assignIp.value = ''; assignVisible.value = true }
|
||
|
||
// 移除组网(通过 remove_network 接口)
|
||
const handleLeaveNetworking = (row) => {
|
||
const networkId = row._network?.id
|
||
if (!networkId) { ElMessage.warning('未找到对应网络ID'); return }
|
||
ElMessageBox.confirm(`确定将虚拟机从组网「${row.name}」中移除吗?`, '移除确认', { type: 'warning' }).then(async () => {
|
||
try {
|
||
const res = await removeUserVmNetworkingNetwork({ user_goods_id: userGoodsId.value, networking_id: row.id, network_id: networkId })
|
||
if (res?.data?.code === 200) { ElMessage.success('移除成功'); loadDetail(); loadNetworkings() }
|
||
else ElMessage.error(extractApiError(res?.data, '移除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '移除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
const submitAssign = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const payload = { user_goods_id: userGoodsId.value, networking_id: assignTarget.value.id }
|
||
if (assignIp.value.trim()) payload.ip = assignIp.value.trim()
|
||
const res = await assignUserVmNetworking(payload)
|
||
if (res?.data?.code === 200) { ElMessage.success('加入成功'); assignVisible.value = false; loadDetail(); loadNetworkings() }
|
||
else ElMessage.error(extractApiError(res?.data, '分配失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '分配失败')) } finally { actionLoading.value = false }
|
||
}
|
||
const handleDeleteNetworking = (row) => {
|
||
ElMessageBox.confirm(`确定删除组网「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
|
||
try { const res = await deleteUserVmNetworking({ user_goods_id: userGoodsId.value, networking_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadNetworkings() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// ---- 重装 ----
|
||
const rebuildVisible = ref(false)
|
||
const rebuildImageId = ref(0)
|
||
const rebuildImageName = ref('')
|
||
const showRebuildImageSelector = ref(false)
|
||
const rebuildImages = ref([])
|
||
const rebuildImagesLoading = ref(false)
|
||
const rebuildSelectedImage = ref(null)
|
||
|
||
const loadRebuildImages = async () => {
|
||
rebuildImagesLoading.value = true
|
||
try {
|
||
const res = await getUserVmHostImages({ user_goods_id: userGoodsId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data
|
||
rebuildImages.value = (d.data || []).map(item => item.image || item).filter(Boolean)
|
||
}
|
||
} catch { /* */ } finally { rebuildImagesLoading.value = false }
|
||
}
|
||
|
||
const confirmRebuildImage = () => {
|
||
if (rebuildSelectedImage.value) {
|
||
rebuildImageId.value = rebuildSelectedImage.value.id
|
||
rebuildImageName.value = rebuildSelectedImage.value.name
|
||
showRebuildImageSelector.value = false
|
||
rebuildSelectedImage.value = null
|
||
}
|
||
}
|
||
|
||
const submitRebuild = async () => {
|
||
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await rebuildUserVm({ user_goods_id: userGoodsId.value, image_id: rebuildImageId.value })
|
||
if (res?.data?.code === 200) { ElMessage.success('重装成功'); rebuildVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '重装失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重装失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 迁移虚拟机 ----
|
||
const migrateVisible = 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 openMigrateVm = async () => {
|
||
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
|
||
migrateMode.value = 'host'
|
||
migrateVisible.value = true
|
||
migrateOptionsLoading.value = true
|
||
try {
|
||
// 需要通过 service_id 获取宿主机列表,从 userGoods 的 good.table 判断服务
|
||
// 这里通过 KvmServiceSelector 选择,或直接加载所有服务下的宿主机
|
||
// 简化处理:加载用户商品关联的主控服务的宿主机
|
||
const { getRemoteHostList, getRemoteHostGroupList } = await import('@/api/admin/kvmService')
|
||
// 获取所有主控服务列表,让用户先选服务再选宿主机
|
||
const { getKvmServiceList } = await import('@/api/admin/kvmService')
|
||
const svcRes = await getKvmServiceList({ page: 1, count: 10 })
|
||
if (svcRes?.data?.code === 200 && svcRes?.data?.data) {
|
||
const inner = svcRes.data.data
|
||
const svcs = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||
// 取第一个服务加载宿主机(实际应让用户选服务)
|
||
if (svcs.length > 0) {
|
||
const sid = svcs[0].id ?? svcs[0].Id
|
||
const [hostRes, groupRes] = await Promise.all([
|
||
getRemoteHostList({ service_id: sid, page: 1, page_size: 10 }),
|
||
getRemoteHostGroupList({ service_id: sid, page: 1, page_size: 10 })
|
||
])
|
||
if (hostRes?.data?.code === 200 && hostRes?.data?.data) {
|
||
const d = hostRes.data.data
|
||
migrateHostOptions.value = Array.isArray(d) ? d : (d.hosts || d.data || [])
|
||
}
|
||
if (groupRes?.data?.code === 200 && groupRes?.data?.data) {
|
||
const d = groupRes.data.data
|
||
migrateGroupOptions.value = Array.isArray(d) ? d : (d.host_groups || d.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 payload = { user_goods_id: userGoodsId.value }
|
||
if (migrateMode.value === 'host') payload.target_host_id = migrateForm.target_host_id
|
||
else payload.target_host_group_id = migrateForm.target_host_group_id
|
||
if (migrateForm.ipv4_num) payload.ipv4_num = migrateForm.ipv4_num
|
||
if (migrateForm.ipv6_num) payload.ipv6_num = migrateForm.ipv6_num
|
||
const res = await migrateUserVm(payload)
|
||
if (res?.data?.code === 200) { ElMessage.success('迁移成功'); migrateVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 重构虚拟机 ----
|
||
const refactorVisible = ref(false)
|
||
const showRefactorSgSelector = ref(false)
|
||
const showRefactorNetSelector = ref(false)
|
||
const refactorForm = reactive({
|
||
_memoryValue: 0, _memoryUnit: 'MB', vcpu: 0,
|
||
rx_bandwidth: 0, tx_bandwidth: 0, _rxUnit: 'Mbps', _txUnit: 'Mbps',
|
||
root_password: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '',
|
||
ssh_port: 0, vnc_port: 0, vnc_password: '',
|
||
port_group_id: 0, _sgName: '', internet_network_id: 0, _networkName: ''
|
||
})
|
||
|
||
const openRefactorVm = () => {
|
||
Object.assign(refactorForm, {
|
||
_memoryValue: vm.value ? Math.round((vm.value.memory || 0) / 1024) : 0,
|
||
_memoryUnit: 'MB',
|
||
vcpu: vm.value?.vcpu || 0,
|
||
rx_bandwidth: vm.value?.rx_bandwidth || 0,
|
||
tx_bandwidth: vm.value?.tx_bandwidth || 0,
|
||
_rxUnit: 'Mbps', _txUnit: 'Mbps',
|
||
root_password: '', uuid: vm.value?.uuid || '',
|
||
mate_data_id: vm.value?.mate_data_id || '',
|
||
physical_name: '', config_path: '',
|
||
ssh_port: vm.value?.ssh_port || 0, vnc_port: 0, vnc_password: '',
|
||
port_group_id: inPortGroup.value?.id || 0, _sgName: inPortGroup.value?.name || '',
|
||
internet_network_id: 0, _networkName: ''
|
||
})
|
||
const bridgeNet = vmNetworks.value.find(n => n.type === 'bridge')
|
||
if (bridgeNet) { refactorForm.internet_network_id = bridgeNet.id; refactorForm._networkName = bridgeNet.name || bridgeNet.address }
|
||
refactorVisible.value = true
|
||
}
|
||
|
||
const submitRefactorVm = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const payload = { user_goods_id: userGoodsId.value }
|
||
if (refactorForm._memoryValue) {
|
||
const memKB = refactorForm._memoryUnit === 'GB' ? refactorForm._memoryValue * 1024 * 1024 : refactorForm._memoryValue * 1024
|
||
payload.memory = Math.round(memKB)
|
||
}
|
||
if (refactorForm.vcpu) payload.vcpu = refactorForm.vcpu
|
||
const refRx = refactorForm._rxUnit === 'Gbps' ? refactorForm.rx_bandwidth * 1000 : refactorForm.rx_bandwidth
|
||
const refTx = refactorForm._txUnit === 'Gbps' ? refactorForm.tx_bandwidth * 1000 : refactorForm.tx_bandwidth
|
||
if (refRx) payload.rx_bandwidth = Math.round(refRx)
|
||
if (refTx) payload.tx_bandwidth = Math.round(refTx)
|
||
if (refactorForm.root_password) payload.root_password = refactorForm.root_password
|
||
if (refactorForm.uuid) payload.uuid = refactorForm.uuid
|
||
if (refactorForm.mate_data_id) payload.mate_data_id = refactorForm.mate_data_id
|
||
if (refactorForm.physical_name) payload.physical_name = refactorForm.physical_name
|
||
if (refactorForm.config_path) payload.config_path = refactorForm.config_path
|
||
if (refactorForm.ssh_port) payload.ssh_port = refactorForm.ssh_port
|
||
if (refactorForm.vnc_port) payload.vnc_port = refactorForm.vnc_port
|
||
if (refactorForm.vnc_password) payload.vnc_password = refactorForm.vnc_password
|
||
if (refactorForm.port_group_id) payload.port_group_id = refactorForm.port_group_id
|
||
if (refactorForm.internet_network_id) payload.internet_network_id = refactorForm.internet_network_id
|
||
const res = await refactorUserVm(payload)
|
||
if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '重构失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重构失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 编辑虚拟机 ----
|
||
const editVmVisible = ref(false)
|
||
const editVmLoading = ref(false)
|
||
const showEditVmSgSelector = ref(false)
|
||
const showEditVmNetSelector = ref(false)
|
||
const editVmForm = reactive({
|
||
rx_bandwidth: 0, tx_bandwidth: 0,
|
||
_rxUnit: 'Mbps', _txUnit: 'Mbps',
|
||
root_password: '',
|
||
ssh_port: 22,
|
||
port_group_id: 0, _sgName: '',
|
||
snapshot_num: 0, backup_num: 0,
|
||
internet_network_id: 0, _networkName: ''
|
||
})
|
||
|
||
const openEditVm = async () => {
|
||
Object.assign(editVmForm, {
|
||
rx_bandwidth: vm.value?.rx_bandwidth || 0,
|
||
tx_bandwidth: vm.value?.tx_bandwidth || 0,
|
||
_rxUnit: 'Mbps', _txUnit: 'Mbps',
|
||
root_password: '',
|
||
ssh_port: vm.value?.ssh_port || 22,
|
||
port_group_id: inPortGroup.value?.id || 0,
|
||
_sgName: inPortGroup.value?.name || '',
|
||
snapshot_num: vm.value?.snapshot_num || 0,
|
||
backup_num: vm.value?.backup_num || 0,
|
||
internet_network_id: 0, _networkName: ''
|
||
})
|
||
// 回填公网网络(取第一个 bridge 类型)
|
||
const bridgeNet = vmNetworks.value.find(n => n.type === 'bridge')
|
||
if (bridgeNet) { editVmForm.internet_network_id = bridgeNet.id; editVmForm._networkName = bridgeNet.name || bridgeNet.address }
|
||
editVmVisible.value = true
|
||
}
|
||
|
||
const submitEditVm = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const payload = { user_goods_id: userGoodsId.value }
|
||
const editRx = editVmForm._rxUnit === 'Gbps' ? editVmForm.rx_bandwidth * 1000 : editVmForm.rx_bandwidth
|
||
const editTx = editVmForm._txUnit === 'Gbps' ? editVmForm.tx_bandwidth * 1000 : editVmForm.tx_bandwidth
|
||
if (editRx) payload.rx_bandwidth = Math.round(editRx)
|
||
if (editTx) payload.tx_bandwidth = Math.round(editTx)
|
||
if (editVmForm.root_password) payload.root_password = editVmForm.root_password
|
||
if (editVmForm.ssh_port) payload.ssh_port = editVmForm.ssh_port
|
||
if (editVmForm.port_group_id) payload.port_group_id = editVmForm.port_group_id
|
||
if (editVmForm.snapshot_num) payload.snapshot_num = editVmForm.snapshot_num
|
||
if (editVmForm.backup_num) payload.backup_num = editVmForm.backup_num
|
||
if (editVmForm.internet_network_id) payload.internet_network_id = editVmForm.internet_network_id
|
||
const res = await updateUserVm(payload)
|
||
if (res?.data?.code === 200) { ElMessage.success('保存成功'); editVmVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '保存失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 修改带宽 ----
|
||
const trafficVisible = ref(false)
|
||
const trafficForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, _trafficValue: 0, _rxUnit: 'Mbps', _txUnit: 'Mbps', _trafficUnit: 'GB', traffic_exhausted_rx_mbps: 0, traffic_exhausted_tx_mbps: 0 })
|
||
const submitUpdateTraffic = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const rxBw = trafficForm._rxUnit === 'Gbps' ? trafficForm.rx_bandwidth * 1000 : trafficForm.rx_bandwidth
|
||
const txBw = trafficForm._txUnit === 'Gbps' ? trafficForm.tx_bandwidth * 1000 : trafficForm.tx_bandwidth
|
||
const trafficMb = trafficForm._trafficUnit === 'TB' ? (trafficForm._trafficValue || 0) * 1024 * 1024
|
||
: trafficForm._trafficUnit === 'GB' ? (trafficForm._trafficValue || 0) * 1024
|
||
: (trafficForm._trafficValue || 0)
|
||
const payload = {
|
||
user_goods_id: userGoodsId.value,
|
||
rx_bandwidth: Math.round(rxBw),
|
||
tx_bandwidth: Math.round(txBw),
|
||
traffic_max: Math.round(trafficMb)
|
||
}
|
||
if (trafficForm.traffic_exhausted_rx_mbps > 0) payload.traffic_exhausted_rx_mbps = trafficForm.traffic_exhausted_rx_mbps
|
||
if (trafficForm.traffic_exhausted_tx_mbps > 0) payload.traffic_exhausted_tx_mbps = trafficForm.traffic_exhausted_tx_mbps
|
||
const res = await updateUserVmTraffic(payload)
|
||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); trafficVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 流量策略 ----
|
||
// 测试未通过(接口新增,待联调)
|
||
const trafficPolicy = ref(null)
|
||
const trafficPolicyLoading = ref(false)
|
||
const trafficPolicyVisible = ref(false)
|
||
const addTrafficVisible = ref(false)
|
||
const addTrafficType = ref('fixed')
|
||
const trafficPolicyForm = reactive({ traffic_max_mb: 0, exhausted_rx_mbps: 0, exhausted_tx_mbps: 0 })
|
||
const addTrafficForm = reactive({ traffic_mb: 1 })
|
||
|
||
const loadTrafficPolicy = async () => {
|
||
if (!userGoodsId.value) return
|
||
trafficPolicyLoading.value = true
|
||
try {
|
||
const res = await getUserVmTrafficPolicy({ user_goods_id: userGoodsId.value })
|
||
if (res?.data?.code === 200) trafficPolicy.value = res.data.data
|
||
} catch { /* ignore */ } finally { trafficPolicyLoading.value = false }
|
||
}
|
||
|
||
const openTrafficPolicyDialog = () => {
|
||
Object.assign(trafficPolicyForm, {
|
||
traffic_max_mb: trafficPolicy.value?.traffic_max_mb || 0,
|
||
exhausted_rx_mbps: trafficPolicy.value?.exhausted_rx_mbps || 0,
|
||
exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps || 0
|
||
})
|
||
trafficPolicyVisible.value = true
|
||
}
|
||
|
||
const submitUpdateTrafficPolicy = async () => {
|
||
trafficPolicyLoading.value = true
|
||
try {
|
||
const res = await updateUserVmTrafficPolicy({
|
||
user_goods_id: userGoodsId.value,
|
||
traffic_max_mb: trafficPolicyForm.traffic_max_mb,
|
||
exhausted_rx_mbps: trafficPolicyForm.exhausted_rx_mbps,
|
||
exhausted_tx_mbps: trafficPolicyForm.exhausted_tx_mbps
|
||
})
|
||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); trafficPolicyVisible.value = false; loadTrafficPolicy() }
|
||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { trafficPolicyLoading.value = false }
|
||
}
|
||
|
||
const openAddTrafficDialog = (type) => {
|
||
addTrafficType.value = type
|
||
addTrafficForm.traffic_mb = 1
|
||
addTrafficVisible.value = true
|
||
}
|
||
|
||
const submitAddTraffic = async () => {
|
||
if (!addTrafficForm.traffic_mb || addTrafficForm.traffic_mb < 1) { ElMessage.warning('请输入有效的流量数量'); return }
|
||
trafficPolicyLoading.value = true
|
||
try {
|
||
const apiFn = addTrafficType.value === 'fixed' ? addUserVmFixedTraffic : addUserVmTemporaryTraffic
|
||
const res = await apiFn({ user_goods_id: userGoodsId.value, traffic_mb: addTrafficForm.traffic_mb })
|
||
if (res?.data?.code === 200) { ElMessage.success('操作成功'); addTrafficVisible.value = false; loadTrafficPolicy() }
|
||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { trafficPolicyLoading.value = false }
|
||
}
|
||
|
||
// ---- 转移 ----
|
||
const transferVisible = ref(false)
|
||
const showTransferUserSelector = ref(false)
|
||
const transferForm = reactive({ target_user_id: 0, _userName: '' })
|
||
const submitTransfer = async () => {
|
||
if (!transferForm.target_user_id) { ElMessage.warning('请选择目标用户'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const res = await transferUserVm({ user_goods_id: userGoodsId.value, target_user_id: transferForm.target_user_id })
|
||
if (res?.data?.code === 200) { ElMessage.success('转移成功'); transferVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '转移失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '转移失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 编辑商品信息 ----
|
||
const editGoodsVisible = ref(false)
|
||
const editGoodsForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '' })
|
||
|
||
const openEditGoods = () => {
|
||
Object.assign(editGoodsForm, {
|
||
note: userGoods.value?.note || '',
|
||
renew_price: (userGoods.value?.renewPrice || 0) / 100,
|
||
base_price: (userGoods.value?.basePrice || 0) / 100,
|
||
expire_time: (() => {
|
||
const t = userGoods.value?.expireTime
|
||
if (!t) return ''
|
||
const d = dayjs(t)
|
||
return d.year() < 2000 ? '' : d.format('YYYY-MM-DD HH:mm:ss')
|
||
})()
|
||
})
|
||
editGoodsVisible.value = true
|
||
}
|
||
|
||
const submitEditGoods = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const { updateUserGoods } = await import('@/api/admin/userVm')
|
||
const payload = { id: userGoodsId.value }
|
||
if (editGoodsForm.note !== undefined) payload.note = editGoodsForm.note
|
||
if (editGoodsForm.renew_price) payload.renew_price = Math.round(editGoodsForm.renew_price * 100)
|
||
if (editGoodsForm.base_price) payload.base_price = Math.round(editGoodsForm.base_price * 100)
|
||
if (editGoodsForm.expire_time) payload.expire_time = editGoodsForm.expire_time
|
||
const res = await updateUserGoods(payload)
|
||
if (res?.data?.code === 200) { ElMessage.success('保存成功'); editGoodsVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '保存失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 监控指标 ----
|
||
const cpuChartRef = ref(null)
|
||
const memChartRef = ref(null)
|
||
const diskChartRef = ref(null)
|
||
const netChartRef = ref(null)
|
||
let cpuChart = null
|
||
let memChart = null
|
||
let diskChart = null
|
||
let netChart = null
|
||
|
||
const metricsData = ref(null)
|
||
const metricsLoading = ref(false)
|
||
|
||
const makeDefaultRange = () => {
|
||
const now = new Date()
|
||
return [new Date(now.getTime() - 10 * 60 * 1000), now]
|
||
}
|
||
const monitorDateRange = ref(makeDefaultRange())
|
||
|
||
const monitorShortcuts = [
|
||
{ text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } },
|
||
{ text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } },
|
||
{ text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } },
|
||
{ text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } },
|
||
{ text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } },
|
||
{ text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } },
|
||
{ text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } },
|
||
]
|
||
|
||
function calcInterval(startTime, endTime) {
|
||
const spanMin = (endTime.getTime() - startTime.getTime()) / 60000
|
||
if (spanMin < 30) return '1m'
|
||
if (spanMin < 60) return '3m'
|
||
if (spanMin < 360) return '5m'
|
||
if (spanMin < 720) return '15m'
|
||
if (spanMin < 1440) return '30m'
|
||
if (spanMin < 4320) return '1h'
|
||
if (spanMin < 10080) return '2h'
|
||
if (spanMin < 43200) return '6h'
|
||
if (spanMin < 129600) return '12h'
|
||
return '1d'
|
||
}
|
||
|
||
const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' }
|
||
const currentIntervalLabel = computed(() => {
|
||
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-'
|
||
const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1]))
|
||
return intervalLabelMap[iv] || iv
|
||
})
|
||
|
||
const latestMetrics = computed(() => {
|
||
const arr = metricsData.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'
|
||
v = Number(v)
|
||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
|
||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
|
||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
|
||
return v + ' 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 loadMetricsHistory = async () => {
|
||
if (!userGoodsId.value) return
|
||
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return
|
||
metricsLoading.value = true
|
||
try {
|
||
const startTime = new Date(monitorDateRange.value[0])
|
||
const endTime = new Date(monitorDateRange.value[1])
|
||
const interval = calcInterval(startTime, endTime)
|
||
const params = {
|
||
user_goods_id: userGoodsId.value,
|
||
start: startTime.toISOString(),
|
||
end_time: endTime.toISOString(),
|
||
interval
|
||
}
|
||
const res = await getUserVmMetricsHistory(params)
|
||
const body = res?.data
|
||
if (body?.code === 200 && body?.data) {
|
||
metricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||
await nextTick()
|
||
renderMetricsCharts()
|
||
} else {
|
||
ElMessage.error(extractApiError(body, '加载监控数据失败'))
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(extractApiError(e?.response?.data, '加载监控数据失败'))
|
||
} finally {
|
||
metricsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const renderMetricsCharts = () => {
|
||
const metrics = metricsData.value
|
||
if (!Array.isArray(metrics) || !metrics.length) return
|
||
|
||
const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 600000
|
||
const showDate = spanMs >= 86400000
|
||
const symbolType = metrics.length < 30 ? '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: showDate ? 40 : 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
|
||
diskChart?.dispose(); diskChart = null
|
||
netChart?.dispose(); netChart = null
|
||
}
|
||
|
||
onMounted(() => { loadDetail() })
|
||
|
||
onActivated(() => {
|
||
const newId = parseInt(route.query.id) || 0
|
||
if (newId && newId !== userGoodsId.value) {
|
||
userGoodsId.value = newId
|
||
userGoods.value = null
|
||
vm.value = null
|
||
vmNetworks.value = []
|
||
vmVolumes.value = []
|
||
inPortGroup.value = null
|
||
outPortGroup.value = null
|
||
isVmGoods.value = false
|
||
metricsData.value = null
|
||
disposeCharts()
|
||
loadDetail()
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => { disposeCharts() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.uvm-detail { padding: 0; }
|
||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||
.header-left { display: flex; align-items: center; gap: 0; }
|
||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||
.main-content { padding: 16px 20px; }
|
||
.overview-card { margin-bottom: 16px; }
|
||
.overview-header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||
.overview-left { display: flex; align-items: center; gap: 16px; }
|
||
.overview-info { display: flex; flex-direction: column; gap: 8px; }
|
||
.name-row { display: flex; align-items: center; }
|
||
.vm-name { margin: 0; font-size: 20px; font-weight: 600; color: #303133; }
|
||
.meta-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #606266; flex-wrap: wrap; }
|
||
.overview-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.tabs-card { }
|
||
.tab-toolbar { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
|
||
.selector-row { display: flex; align-items: center; width: 100%; }
|
||
|
||
/* VM 配置网格 */
|
||
.vm-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: 10px 14px; display: flex; flex-direction: column; gap: 4px; 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: 13px; color: #1d2129; line-height: 1.4; word-break: break-all; }
|
||
.cfg-edit-btn { margin-left: 8px; padding: 0 4px; height: 18px; vertical-align: middle; }
|
||
.cfg-edit-btn .el-icon { margin-right: 2px; vertical-align: -2px; }
|
||
.pwd-value { display: inline-flex; align-items: center; gap: 4px; }
|
||
.pwd-text { font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; background: #f5f7fa; padding: 2px 8px; border-radius: 3px; letter-spacing: .5px; user-select: all; }
|
||
.pwd-btn { padding: 0 !important; height: auto !important; min-height: auto !important; }
|
||
|
||
/* 单位输入行 */
|
||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||
.unit-select :deep(.el-input__wrapper) { padding: 0 8px; }
|
||
.unit-text { flex-shrink: 0; font-size: 13px; color: #606266; padding: 0 4px; min-width: 36px; text-align: center; line-height: 32px; background: #f5f7fa; border: 1px solid #dcdfe6; border-radius: 4px; }
|
||
|
||
/* 监控指标 */
|
||
.section-block { padding: 0 4px; }
|
||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||
.section-title { margin: 0; font-size: 15px; font-weight: 600; color: #303133; }
|
||
.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; }
|
||
.ip-value { display: inline-flex; align-items: center; gap: 4px; }
|
||
.ip-main { font-weight: 500; font-family: 'SF Mono', Consolas, monospace; font-size: 13px; }
|
||
.ip-more-tag { cursor: pointer; vertical-align: middle; flex-shrink: 0; }
|
||
.ip-popover-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; margin-bottom: 4px; font-size: 13px; color: #606266; }
|
||
.ip-popover-list { max-height: 240px; overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; }
|
||
.ip-popover-list::-webkit-scrollbar { display: none; }
|
||
.ip-popover-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid #f2f3f5; }
|
||
.ip-popover-item:last-child { border-bottom: none; }
|
||
.ip-popover-item .ip-text { font-family: 'SF Mono', Consolas, monospace; font-size: 13px; color: #303133; word-break: break-all; }
|
||
.ip-popover-item .ip-copy-btn { flex-shrink: 0; margin-left: 8px; }
|
||
|
||
/* VNC 弹窗 */
|
||
.vnc-url-link { word-break: break-all; white-space: normal; }
|
||
.vnc-dialog :deep(.el-descriptions__cell) { word-break: break-all; }
|
||
</style>
|