diff --git a/src/utils/tool.js b/src/utils/tool.js
index a0a8131..40f0fb7 100644
--- a/src/utils/tool.js
+++ b/src/utils/tool.js
@@ -182,10 +182,16 @@ export function vmStatusType(status) {
// ========== 磁盘状态映射 ==========
const VOLUME_STATUS_MAP = {
- pending: { label: '等待中', type: 'info' },
- ready: { label: '就绪', type: 'success' },
- error: { label: '错误', type: 'danger' },
- unknown: { label: '未知', type: 'info' }
+ pending: { label: '等待中', type: 'info' },
+ creating: { label: '创建中', type: 'warning' },
+ ready: { label: '就绪', type: 'success' },
+ 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' }
}
/**
diff --git a/src/views/product/ProductGroup.vue b/src/views/product/ProductGroup.vue
index 0097e70..934f5dd 100644
--- a/src/views/product/ProductGroup.vue
+++ b/src/views/product/ProductGroup.vue
@@ -595,6 +595,9 @@
all: 所有参数 / plan: 套餐 / customize: 自定义
+
+
+
用户名{{ isWindows ? 'Administrator' : 'root' }}
-
远程端口{{ isWindows ? (vm.ssh_port || 3389) : (vm.ssh_port || 22) }}
+
远程端口{{ isWindows ? (vm.ssh_port && vm.ssh_port !== 22 ? vm.ssh_port : 3389) : (vm.ssh_port || 22) }}
外网IP
-
- {{ vmPublicIpList[0] }}
-
+
+ {{ vmPublicIpList[0] }}
+
- +{{ vmPublicIpList.length - 1 }}
+ +{{ vmPublicIpList.length - 1 }}
+
-
{{ ip }}
+
+ {{ ip }}
+ 复制
+
@@ -88,14 +95,21 @@
内网IP
-
- {{ vmPrivateIpList[0] }}
-
+
+ {{ vmPrivateIpList[0] }}
+
- +{{ vmPrivateIpList.length - 1 }}
+ +{{ vmPrivateIpList.length - 1 }}
+
-
{{ ip }}
+
+ {{ ip }}
+ 复制
+
@@ -172,16 +186,26 @@
- {{ row.size }} GB
- {{ row.is_system ? '系统盘' : '数据盘' }}
- {{ volumeStatusLabel(row.status) }}
- {{ row.is_mount ? '已挂载' : '未挂载' }}
+
+ {{ row.size ? (row.size + ' GB') : '-' }}
+
+
+ {{ row.is_system ? '系统盘' : '数据盘' }}
+
+
+ {{ volumeStatusLabel(row.status) }}
+
+
+
+ {{ isVolumeMounted(row) ? '已挂载' : '未挂载' }}
+
+
{{ row.path || '-' }}
扩容
- 挂载
- 卸载
+ 挂载
+ 卸载
删除
@@ -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 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 volumes = ref([])
const volumeLoading = ref(false)
@@ -2183,7 +2235,14 @@ onBeforeUnmount(() => { disposeCharts() })
.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-popover-list { max-height: 200px; overflow-y: auto; }
-.ip-popover-item { padding: 4px 0; font-size: 13px; color: #303133; border-bottom: 1px dashed #ebeef5; word-break: break-all; }
+.ip-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; }
diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue
index 9a58e7c..e197d7a 100644
--- a/src/views/virtualization/VmDetail.vue
+++ b/src/views/virtualization/VmDetail.vue
@@ -56,17 +56,33 @@
IP地址
- {{ (publicIpList[0] || privateIpList[0]) }}
-
+ {{ (publicIpList[0] || privateIpList[0]) }}
+
- +{{ publicIpList.length + privateIpList.length - 1 }}
+ +{{ publicIpList.length + privateIpList.length - 1 }}
-
-
公网IP
-
{{ ip }}
-
内网IP
-
{{ ip }}
+
+
+
+
+
+
{{ detail.ips || '-' }}
@@ -127,13 +143,20 @@
公网IP
- {{ publicIpList[0] }}
-
+ {{ publicIpList[0] }}
+
- +{{ publicIpList.length - 1 }}
+ +{{ publicIpList.length - 1 }}
+
-
{{ ip }}
+
+ {{ ip }}
+ 复制
+
@@ -142,13 +165,20 @@
内网IP
- {{ privateIpList[0] }}
-
+ {{ privateIpList[0] }}
+
- +{{ privateIpList.length - 1 }}
+ +{{ privateIpList.length - 1 }}
+
-
{{ ip }}
+
+ {{ ip }}
+ 复制
+
@@ -174,11 +204,11 @@
用户名
- root
+ {{ isWindows ? 'Administrator' : 'root' }}
远程端口
- {{ detail.ssh_port || 22 }}
+ {{ isWindows ? (detail.ssh_port && detail.ssh_port !== 22 ? detail.ssh_port : 3389) : (detail.ssh_port || 22) }}
密码
@@ -192,7 +222,7 @@
流量上限
- {{ detail.traffic_max != null ? `${(detail.traffic_max / 1024).toFixed(2)} GB` : '-' }}
+ {{ formatTrafficMax(detail.traffic_max) }}
快照配额
@@ -280,16 +310,16 @@
{{ row.is_system ? '系统盘' : '数据盘' }}
-
-
+
{{ volumeStatusLabel(row.status) }}
+
+
+ {{ isVolumeMounted(row) ? '已挂载' : '未挂载' }}
+
+
{{ row.path || '-' }}
@@ -297,8 +327,8 @@
详情
调整大小
-
- 卸载
+ 挂载
+ 卸载
迁移
删除
@@ -1517,12 +1547,36 @@ const showImageSelector = ref(false)
const activeTab = ref('info')
const showPassword = ref(false)
+const isWindows = computed(() => vmImage.value?.os_type === 'windows')
+
const extractIp = (addr) => addr ? addr.split('/')[0] : ''
const publicIpList = computed(() => vmNetworks.value.filter(n => n.type === 'bridge').map(n => extractIp(n.address)).filter(Boolean))
const privateIpList = computed(() => vmNetworks.value.filter(n => n.type === 'nat').map(n => extractIp(n.address)).filter(Boolean))
const publicIps = computed(() => publicIpList.value.join(', '))
const privateIps = computed(() => privateIpList.value.join(', '))
+const 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 networkPageSize = ref(10)
const pagedNetworks = computed(() => {
@@ -1606,7 +1660,18 @@ const loadDetail = async () => {
detail.value = d.data ?? d.vm ?? d
vmNetworks.value = d.networks || []
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
vmOutPortGroup.value = d.out_port_group || null
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-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.rules-header h4 { margin: 0; font-size: 15px; font-weight: 600; color: #303133; }
-.ip-popover-list { max-height: 200px; overflow-y: auto; }
-.ip-popover-item { padding: 4px 0; font-size: 13px; color: #303133; border-bottom: 1px dashed #ebeef5; word-break: break-all; }
+.ip-main { font-weight: 500; font-family: 'SF Mono', Consolas, monospace; font-size: 13px; }
+.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 .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; }
diff --git a/src/views/virtualization/VmManage.vue b/src/views/virtualization/VmManage.vue
index 5caea91..7b9e879 100644
--- a/src/views/virtualization/VmManage.vue
+++ b/src/views/virtualization/VmManage.vue
@@ -184,6 +184,9 @@
0 表示不创建
+
+ 系统盘建议不低于 30 GB,否则可能无法重装 Windows 系统
+
@@ -775,6 +778,11 @@ const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
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
try {
const fd = new FormData()