Files
ApiServer-Web-admin_dashboa…/src/views/user-vm/UserVmDetail.vue
T
shiran 802eaa396b feat(admin/user-vm): 流量上限展示加修改入口、网络tab加删除网络操作
缘由:
1) 虚拟机详情页(UserVmDetail.vue / VmDetail.vue)中"流量上限"原仅展示无修改入口,对应 docs/2026.05.08.12.37-update.json 中 update_traffic 接口已支持 traffic_max + traffic_exhausted_rx/tx_mbps 修改,但用户需从"更多 dropdown"绕一道才能到达。
2) /user-goods/vm-detail 网络管理 tab 缺少"删除网络"操作。

修改:
- UserVmDetail.vue:流量上限单元格内追加"修改"小按钮,复用既有 updateTraffic 弹窗(已覆盖 update_traffic 全部新字段,不动接口逻辑);网络表格新增"操作"列+删除按钮,调用 host_service/point/network/delete;row 上若缺 service_id/host_id 用 getUserVmNetworkDetail 反查兜底,仍取不到则提示并阻止;二次确认弹窗明示该操作会影响所有绑定该网络的虚拟机。
- VmDetail.vue:流量上限单元格内追加"修改"小按钮,复用 handleUpdateTraffic(host_service/point/vm/update_traffic)。

预期:
- 详情页用户在"流量上限"位置可一键进入修改弹窗,无需走 dropdown。
- vm-detail 网络tab 表格每行可触发"删除网络"流程,含强提示与兜底取值。
- 不引入新依赖;trafficVisible 弹窗保持向 docs 字段对齐;UI 微调仅限新增样式 .cfg-edit-btn 与一列操作列。

未测试:未在 admin_dashboard_pc 本地 dev 验证(终端仅运行 user_dashboard_pc),需联调 update_traffic 与 point/network/delete 实际返回。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 17:09:23 +08:00

2456 lines
135 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="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="handleCommand('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">&nbsp;</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">内存</div>
<div class="metric-summary-value">{{ vmMemPercent(latestMetrics) }}%</div>
<div class="metric-summary-sub">{{ formatMemKB(latestMetrics.mem_used) }} / {{ formatMemKB(latestMetrics.mem_total) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">磁盘 I/O</div>
<div class="metric-summary-value">读 {{ formatBytesRaw(latestMetrics.disk_read) }}</div>
<div class="metric-summary-sub">写 {{ formatBytesRaw(latestMetrics.disk_write) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">网络流量</div>
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
</div>
</div>
</template>
<template v-if="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">Mbps0 不限)</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">Mbps0 不限)</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">MB0 不限)</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">Mbps0 不限)</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">Mbps0 不限)</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>