fix: 虚拟机模块
Build and Deploy Vue3 / build (push) Successful in 1m26s
Build and Deploy Vue3 / deploy (push) Successful in 3m5s

This commit is contained in:
2026-04-16 13:22:39 +08:00
parent f53f63e679
commit 985412c3bc
5 changed files with 206 additions and 57 deletions
+10 -4
View File
@@ -182,10 +182,16 @@ export function vmStatusType(status) {
// ========== 磁盘状态映射 ========== // ========== 磁盘状态映射 ==========
const VOLUME_STATUS_MAP = { const VOLUME_STATUS_MAP = {
pending: { label: '等待中', type: 'info' }, pending: { label: '等待中', type: 'info' },
ready: { label: '就绪', type: 'success' }, creating: { label: '创建中', type: 'warning' },
error: { label: '错误', type: 'danger' }, ready: { label: '就绪', type: 'success' },
unknown: { label: '未知', type: 'info' } in_use: { label: '使用中', type: 'success' },
attaching: { label: '挂载中', type: 'warning' },
detaching: { label: '卸载中', type: 'warning' },
resizing: { label: '扩容中', type: 'warning' },
deleting: { label: '删除中', type: 'danger' },
error: { label: '错误', type: 'danger' },
unknown: { label: '未知', type: 'info' }
} }
/** /**
+9 -4
View File
@@ -595,6 +595,9 @@
</el-select> </el-select>
<div class="form-tip">all: 所有参数 / plan: 套餐 / customize: 自定义</div> <div class="form-tip">all: 所有参数 / plan: 套餐 / customize: 自定义</div>
</el-form-item> </el-form-item>
<el-form-item label="允许续费" prop="can_renew">
<el-switch v-model="productForm.can_renew" active-text="允许" inactive-text="禁止" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
@@ -757,7 +760,8 @@ const productForm = reactive({
expire_time: 0, expire_time: 0,
recommend: false, recommend: false,
recommend_rebate: 0, recommend_rebate: 0,
arg_type: 'all' arg_type: 'all',
can_renew: true
}) })
const productRules = { const productRules = {
@@ -1428,7 +1432,8 @@ const handleEditProduct = (product, parentGroupId) => {
expire_time: product.expireTime, expire_time: product.expireTime,
recommend: product.recommend, recommend: product.recommend,
recommend_rebate: product.recommendRebate, recommend_rebate: product.recommendRebate,
arg_type: product.argType || 'all' arg_type: product.argType || 'all',
can_renew: product.canRenew !== undefined ? product.canRenew : (product.can_renew !== undefined ? product.can_renew : true)
}) })
productDialogVisible.value = true productDialogVisible.value = true
@@ -1452,7 +1457,8 @@ const submitProductForm = () => {
expire_time: Number(productForm.expire_time) || 0, expire_time: Number(productForm.expire_time) || 0,
recommend: productForm.recommend, recommend: productForm.recommend,
recommend_rebate: Number(productForm.recommend_rebate) || 0, recommend_rebate: Number(productForm.recommend_rebate) || 0,
arg_type: productForm.arg_type arg_type: productForm.arg_type,
can_renew: productForm.can_renew
} }
let res let res
@@ -1472,7 +1478,6 @@ const submitProductForm = () => {
const newMap = new Map(groupProductsMap.value) const newMap = new Map(groupProductsMap.value)
newMap.delete(productForm.good_group_id) newMap.delete(productForm.good_group_id)
groupProductsMap.value = newMap groupProductsMap.value = newMap
loadProductsForGroup(productForm.good_group_id)
} }
} }
} catch (error) { } catch (error) {
+78 -19
View File
@@ -70,17 +70,24 @@
</div> </div>
<div class="config-row"> <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" 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 || 3389) : (vm.ssh_port || 22) }}</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"> <div class="config-cell">
<span class="config-label">外网IP</span> <span class="config-label">外网IP</span>
<span class="config-value" style="color:#165dff;font-weight:500" v-if="vmPublicIpList.length"> <span class="config-value ip-value" v-if="vmPublicIpList.length">
{{ vmPublicIpList[0] }} <span class="ip-main" style="color:#165dff">{{ vmPublicIpList[0] }}</span>
<el-popover v-if="vmPublicIpList.length > 1" trigger="hover" placement="bottom-start" :width="280"> <el-popover v-if="vmPublicIpList.length > 1" trigger="hover" placement="bottom" :width="360" popper-class="ip-popover-panel">
<template #reference> <template #reference>
<el-tag size="small" type="primary" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ vmPublicIpList.length - 1 }}</el-tag> <el-tag size="small" type="primary" class="ip-more-tag">+{{ vmPublicIpList.length - 1 }}</el-tag>
</template> </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 class="ip-popover-list">
<div v-for="(ip, idx) in vmPublicIpList" :key="idx" class="ip-popover-item">{{ ip }}</div> <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> </div>
</el-popover> </el-popover>
</span> </span>
@@ -88,14 +95,21 @@
</div> </div>
<div class="config-cell"> <div class="config-cell">
<span class="config-label">内网IP</span> <span class="config-label">内网IP</span>
<span class="config-value" style="color:#67c23a;font-weight:500" v-if="vmPrivateIpList.length"> <span class="config-value ip-value" v-if="vmPrivateIpList.length">
{{ vmPrivateIpList[0] }} <span class="ip-main" style="color:#67c23a">{{ vmPrivateIpList[0] }}</span>
<el-popover v-if="vmPrivateIpList.length > 1" trigger="hover" placement="bottom-start" :width="280"> <el-popover v-if="vmPrivateIpList.length > 1" trigger="hover" placement="bottom" :width="360" popper-class="ip-popover-panel">
<template #reference> <template #reference>
<el-tag size="small" type="success" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ vmPrivateIpList.length - 1 }}</el-tag> <el-tag size="small" type="success" class="ip-more-tag">+{{ vmPrivateIpList.length - 1 }}</el-tag>
</template> </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 class="ip-popover-list">
<div v-for="(ip, idx) in vmPrivateIpList" :key="idx" class="ip-popover-item">{{ ip }}</div> <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> </div>
</el-popover> </el-popover>
</span> </span>
@@ -172,16 +186,26 @@
<el-table :data="vmVolumes" stripe size="small"> <el-table :data="vmVolumes" stripe size="small">
<el-table-column prop="id" label="ID" width="70" /> <el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip /> <el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="大小" width="80"><template #default="{ row }">{{ row.size }} GB</template></el-table-column> <el-table-column label="大小" width="100">
<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> <template #default="{ row }">{{ row.size ? (row.size + ' GB') : '-' }}</template>
<el-table-column label="状态" width="80"><template #default="{ row }"><el-tag :type="volumeStatusType(row.status)" size="small">{{ volumeStatusLabel(row.status) }}</el-tag></template></el-table-column> </el-table-column>
<el-table-column label="挂载" width="80"><template #default="{ row }"><el-tag :type="row.is_mount ? 'success' : 'info'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag></template></el-table-column> <el-table-column label="类型" width="80">
<template #default="{ row }"><el-tag :type="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 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"> <el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="handleResizeVolume(row)">扩容</el-button> <el-button link type="primary" size="small" @click="handleResizeVolume(row)">扩容</el-button>
<el-button link type="success" size="small" @click="handleMountVolume(row)" v-if="!row.is_mount">挂载</el-button> <el-button link type="success" size="small" @click="handleMountVolume(row)" v-if="!isVolumeMounted(row)">挂载</el-button>
<el-button link type="warning" size="small" @click="handleUnmountVolume(row)" v-if="row.is_mount">卸载</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> <el-button link type="danger" size="small" @click="handleDeleteVolume(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -1034,6 +1058,30 @@ const copyPassword = async () => {
} }
} }
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 isWindows = computed(() => vmImage.value?.os_type === 'windows')
const vmPublicIpList = computed(() => { const vmPublicIpList = computed(() => {
@@ -1202,6 +1250,10 @@ const handleMoreCmd = (cmd) => {
} }
// ---- 数据卷 ---- // ---- 数据卷 ----
const isVolumeMounted = (row) => {
if (row.is_mount !== undefined) return !!row.is_mount
return row.status === 'ready'
}
const showVolumeSelector = ref(false) const showVolumeSelector = ref(false)
const volumes = ref([]) const volumes = ref([])
const volumeLoading = ref(false) const volumeLoading = ref(false)
@@ -2183,7 +2235,14 @@ onBeforeUnmount(() => { disposeCharts() })
.metric-summary-label { font-size: 12px; color: #86909c; margin-bottom: 8px; } .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-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; } .metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ip-popover-list { max-height: 200px; overflow-y: auto; } .ip-value { display: inline-flex; align-items: center; gap: 4px; }
.ip-popover-item { padding: 4px 0; font-size: 13px; color: #303133; border-bottom: 1px dashed #ebeef5; word-break: break-all; } .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: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; }
</style> </style>
+101 -30
View File
@@ -56,17 +56,33 @@
<div class="status-item"> <div class="status-item">
<span class="status-label">IP地址</span> <span class="status-label">IP地址</span>
<span class="status-value" v-if="publicIpList.length || privateIpList.length"> <span class="status-value" v-if="publicIpList.length || privateIpList.length">
{{ (publicIpList[0] || privateIpList[0]) }} <span class="ip-main">{{ (publicIpList[0] || privateIpList[0]) }}</span>
<el-popover v-if="publicIpList.length + privateIpList.length > 1" trigger="hover" placement="bottom-start" :width="300"> <el-popover v-if="publicIpList.length + privateIpList.length > 1" trigger="hover" placement="bottom" :width="360" popper-class="ip-popover-panel">
<template #reference> <template #reference>
<el-tag size="small" type="info" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ publicIpList.length + privateIpList.length - 1 }}</el-tag> <el-tag size="small" type="info" class="ip-more-tag">+{{ publicIpList.length + privateIpList.length - 1 }}</el-tag>
</template> </template>
<div class="ip-popover-list"> <div v-if="publicIpList.length" class="ip-popover-header">
<div v-if="publicIpList.length" style="font-size:12px;color:#909399;margin-bottom:4px">公网IP</div> <span>公网IP{{ publicIpList.length }}</span>
<div v-for="(ip, idx) in publicIpList" :key="'pub'+idx" class="ip-popover-item">{{ ip }}</div> <el-button link type="primary" size="small" @click="copyAllIps(publicIpList)">复制全部</el-button>
<div v-if="privateIpList.length" style="font-size:12px;color:#909399;margin:8px 0 4px">内网IP</div>
<div v-for="(ip, idx) in privateIpList" :key="'pri'+idx" class="ip-popover-item">{{ ip }}</div>
</div> </div>
<div class="ip-popover-list">
<div v-for="(ip, idx) in publicIpList" :key="'pub'+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>
<template v-if="privateIpList.length">
<div class="ip-popover-header" style="margin-top:8px">
<span>内网IP{{ privateIpList.length }}</span>
<el-button link type="primary" size="small" @click="copyAllIps(privateIpList)">复制全部</el-button>
</div>
<div class="ip-popover-list">
<div v-for="(ip, idx) in privateIpList" :key="'pri'+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>
</template>
</el-popover> </el-popover>
</span> </span>
<span class="status-value" v-else>{{ detail.ips || '-' }}</span> <span class="status-value" v-else>{{ detail.ips || '-' }}</span>
@@ -127,13 +143,20 @@
<div class="config-cell"> <div class="config-cell">
<span class="config-label">公网IP</span> <span class="config-label">公网IP</span>
<span class="config-value ip-value" v-if="publicIpList.length"> <span class="config-value ip-value" v-if="publicIpList.length">
{{ publicIpList[0] }} <span class="ip-main" style="color:#165dff">{{ publicIpList[0] }}</span>
<el-popover v-if="publicIpList.length > 1" trigger="hover" placement="bottom-start" :width="300"> <el-popover v-if="publicIpList.length > 1" trigger="hover" placement="bottom" :width="360" popper-class="ip-popover-panel">
<template #reference> <template #reference>
<el-tag size="small" type="primary" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ publicIpList.length - 1 }}</el-tag> <el-tag size="small" type="primary" class="ip-more-tag">+{{ publicIpList.length - 1 }}</el-tag>
</template> </template>
<div class="ip-popover-header">
<span>全部公网IP{{ publicIpList.length }}</span>
<el-button link type="primary" size="small" @click="copyAllIps(publicIpList)">复制全部</el-button>
</div>
<div class="ip-popover-list"> <div class="ip-popover-list">
<div v-for="(ip, idx) in publicIpList" :key="idx" class="ip-popover-item">{{ ip }}</div> <div v-for="(ip, idx) in publicIpList" :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> </div>
</el-popover> </el-popover>
</span> </span>
@@ -142,13 +165,20 @@
<div class="config-cell"> <div class="config-cell">
<span class="config-label">内网IP</span> <span class="config-label">内网IP</span>
<span class="config-value ip-value" v-if="privateIpList.length"> <span class="config-value ip-value" v-if="privateIpList.length">
{{ privateIpList[0] }} <span class="ip-main" style="color:#67c23a">{{ privateIpList[0] }}</span>
<el-popover v-if="privateIpList.length > 1" trigger="hover" placement="bottom-start" :width="300"> <el-popover v-if="privateIpList.length > 1" trigger="hover" placement="bottom" :width="360" popper-class="ip-popover-panel">
<template #reference> <template #reference>
<el-tag size="small" type="success" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ privateIpList.length - 1 }}</el-tag> <el-tag size="small" type="success" class="ip-more-tag">+{{ privateIpList.length - 1 }}</el-tag>
</template> </template>
<div class="ip-popover-header">
<span>全部内网IP{{ privateIpList.length }}</span>
<el-button link type="primary" size="small" @click="copyAllIps(privateIpList)">复制全部</el-button>
</div>
<div class="ip-popover-list"> <div class="ip-popover-list">
<div v-for="(ip, idx) in privateIpList" :key="idx" class="ip-popover-item">{{ ip }}</div> <div v-for="(ip, idx) in privateIpList" :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> </div>
</el-popover> </el-popover>
</span> </span>
@@ -174,11 +204,11 @@
<div class="config-row"> <div class="config-row">
<div class="config-cell"> <div class="config-cell">
<span class="config-label">用户名</span> <span class="config-label">用户名</span>
<span class="config-value">root</span> <span class="config-value" style="font-weight:500">{{ isWindows ? 'Administrator' : 'root' }}</span>
</div> </div>
<div class="config-cell"> <div class="config-cell">
<span class="config-label">远程端口</span> <span class="config-label">远程端口</span>
<span class="config-value">{{ detail.ssh_port || 22 }}</span> <span class="config-value">{{ isWindows ? (detail.ssh_port && detail.ssh_port !== 22 ? detail.ssh_port : 3389) : (detail.ssh_port || 22) }}</span>
</div> </div>
<div class="config-cell"> <div class="config-cell">
<span class="config-label">密码</span> <span class="config-label">密码</span>
@@ -192,7 +222,7 @@
<div class="config-row"> <div class="config-row">
<div class="config-cell"> <div class="config-cell">
<span class="config-label">流量上限</span> <span class="config-label">流量上限</span>
<span class="config-value">{{ detail.traffic_max != null ? `${(detail.traffic_max / 1024).toFixed(2)} GB` : '-' }}</span> <span class="config-value">{{ formatTrafficMax(detail.traffic_max) }}</span>
</div> </div>
<div class="config-cell"> <div class="config-cell">
<span class="config-label">快照配额</span> <span class="config-label">快照配额</span>
@@ -280,16 +310,16 @@
<el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag> <el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<!-- <el-table-column label="挂载" width="80"> <el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.is_mount ? 'success' : 'info'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag>
</template>
</el-table-column> -->
<el-table-column label="状态" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="volumeStatusType(row.status)" size="small">{{ volumeStatusLabel(row.status) }}</el-tag> <el-tag :type="volumeStatusType(row.status)" size="small">{{ volumeStatusLabel(row.status) }}</el-tag>
</template> </template>
</el-table-column> </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="160" show-overflow-tooltip> <el-table-column prop="path" label="路径" min-width="160" show-overflow-tooltip>
<template #default="{ row }"><span class="mono-text">{{ row.path || '-' }}</span></template> <template #default="{ row }"><span class="mono-text">{{ row.path || '-' }}</span></template>
</el-table-column> </el-table-column>
@@ -297,8 +327,8 @@
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="handleVolDetail(row)">详情</el-button> <el-button link type="primary" size="small" @click="handleVolDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleVolResize(row)">调整大小</el-button> <el-button link type="primary" size="small" @click="handleVolResize(row)">调整大小</el-button>
<!-- <el-button link type="success" size="small" @click="handleVolMount(row)" v-if="!row.is_mount">挂载</el-button> --> <el-button link type="success" size="small" @click="handleVolMount(row)" v-if="!isVolumeMounted(row)">挂载</el-button>
<el-button link type="warning" size="small" @click="handleVolUnmount(row)" v-if="row.is_mount">卸载</el-button> <el-button link type="warning" size="small" @click="handleVolUnmount(row)" v-if="isVolumeMounted(row)">卸载</el-button>
<el-button link type="info" size="small" @click="handleVolTransfer(row)">迁移</el-button> <el-button link type="info" size="small" @click="handleVolTransfer(row)">迁移</el-button>
<el-button link type="danger" size="small" @click="handleVolDelete(row)">删除</el-button> <el-button link type="danger" size="small" @click="handleVolDelete(row)">删除</el-button>
</template> </template>
@@ -1517,12 +1547,36 @@ const showImageSelector = ref(false)
const activeTab = ref('info') const activeTab = ref('info')
const showPassword = ref(false) const showPassword = ref(false)
const isWindows = computed(() => vmImage.value?.os_type === 'windows')
const extractIp = (addr) => addr ? addr.split('/')[0] : '' const extractIp = (addr) => addr ? addr.split('/')[0] : ''
const publicIpList = computed(() => vmNetworks.value.filter(n => n.type === 'bridge').map(n => extractIp(n.address)).filter(Boolean)) const publicIpList = computed(() => vmNetworks.value.filter(n => n.type === 'bridge').map(n => extractIp(n.address)).filter(Boolean))
const privateIpList = computed(() => vmNetworks.value.filter(n => n.type === 'nat').map(n => extractIp(n.address)).filter(Boolean)) const privateIpList = computed(() => vmNetworks.value.filter(n => n.type === 'nat').map(n => extractIp(n.address)).filter(Boolean))
const publicIps = computed(() => publicIpList.value.join(', ')) const publicIps = computed(() => publicIpList.value.join(', '))
const privateIps = computed(() => privateIpList.value.join(', ')) const privateIps = computed(() => privateIpList.value.join(', '))
const isVolumeMounted = (row) => {
if (row.is_mount !== undefined) return !!row.is_mount
return row.status === 'ready'
}
const formatTrafficMax = (val) => {
if (val == null) return '-'
const gb = val / 1024
if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB`
return `${gb.toFixed(2)} GB`
}
const copyAllIps = (ipList) => {
if (!ipList?.length) return
const text = ipList.join('\n')
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => ElMessage.success(`已复制 ${ipList.length} 个IP`)).catch(() => fallbackCopy(text))
} else {
fallbackCopy(text)
}
}
const networkPage = ref(1) const networkPage = ref(1)
const networkPageSize = ref(10) const networkPageSize = ref(10)
const pagedNetworks = computed(() => { const pagedNetworks = computed(() => {
@@ -1606,7 +1660,18 @@ const loadDetail = async () => {
detail.value = d.data ?? d.vm ?? d detail.value = d.data ?? d.vm ?? d
vmNetworks.value = d.networks || [] vmNetworks.value = d.networks || []
vmVolumes.value = d.volumes || [] vmVolumes.value = d.volumes || []
vmImage.value = d.image || null if (d.image) {
vmImage.value = d.image
} else {
const sysVol = (d.volumes || []).find(v => v.is_system)
if (sysVol) {
const name = sysVol.name || ''
const osType = /windows/i.test(name) ? 'windows' : 'linux'
vmImage.value = { id: 0, name, os_type: osType, status: sysVol.status }
} else {
vmImage.value = null
}
}
vmPortGroup.value = d.in_port_group || null vmPortGroup.value = d.in_port_group || null
vmOutPortGroup.value = d.out_port_group || null vmOutPortGroup.value = d.out_port_group || null
vmHostId.value = detail.value?.host_id || vmVolumes.value[0]?.host_id || vmNetworks.value[0]?.host_id || 0 vmHostId.value = detail.value?.host_id || vmVolumes.value[0]?.host_id || vmNetworks.value[0]?.host_id || 0
@@ -3517,7 +3582,13 @@ onMounted(() => { isPageActive = true; initPage() })
.rules-section { margin-top: 8px; } .rules-section { margin-top: 8px; }
.rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.rules-header h4 { margin: 0; font-size: 15px; font-weight: 600; color: #303133; } .rules-header h4 { margin: 0; font-size: 15px; font-weight: 600; color: #303133; }
.ip-popover-list { max-height: 200px; overflow-y: auto; } .ip-main { font-weight: 500; font-family: 'SF Mono', Consolas, monospace; font-size: 13px; }
.ip-popover-item { padding: 4px 0; font-size: 13px; color: #303133; border-bottom: 1px dashed #ebeef5; word-break: break-all; } .ip-more-tag { cursor: pointer; vertical-align: middle; flex-shrink: 0; margin-left: 4px; }
.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: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; }
</style> </style>
+8
View File
@@ -184,6 +184,9 @@
<span style="font-size:12px;color:#909399;margin-left:8px">0 表示不创建</span> <span style="font-size:12px;color:#909399;margin-left:8px">0 表示不创建</span>
</div> </div>
</el-form-item> </el-form-item>
<el-alert type="warning" :closable="false" show-icon style="margin-top:4px">
<template #title>系统盘建议不低于 30 GB,否则可能无法重装 Windows 系统</template>
</el-alert>
</div> </div>
<div class="tk-section"> <div class="tk-section">
@@ -775,6 +778,11 @@ const submitCreate = () => {
createFormRef.value?.validate(async (valid) => { createFormRef.value?.validate(async (valid) => {
if (!valid) return if (!valid) return
if (createForm.system_size > 0 && createForm.system_size < 30) {
try {
await ElMessageBox.confirm('系统盘小于 30 GB,可能无法正常重装 Windows 系统,是否继续?', '系统盘容量提示', { type: 'warning', confirmButtonText: '继续创建', cancelButtonText: '返回修改' })
} catch { return }
}
submitLoading.value = true submitLoading.value = true
try { try {
const fd = new FormData() const fd = new FormData()