fix: 虚拟机模块
This commit is contained in:
+10
-4
@@ -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' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user