diff --git a/server/handlers/pve.go b/server/handlers/pve.go
index fef92f3..e92f133 100644
--- a/server/handlers/pve.go
+++ b/server/handlers/pve.go
@@ -248,6 +248,12 @@ func (h *PVEHandler) VMStatus(c *gin.Context) {
return
}
+ // 验证 VM ID 格式(必须是正整数)
+ if _, err := strconv.Atoi(pveVMID.String); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid VM ID format"})
+ return
+ }
+
status, err := h.service.GetVMStatus(*pveHostID, pveVMID.String)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -283,6 +289,12 @@ func (h *PVEHandler) VMStart(c *gin.Context) {
return
}
+ // 验证 VM ID 格式(必须是正整数)
+ if _, err := strconv.Atoi(pveVMID.String); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid VM ID format"})
+ return
+ }
+
if err := h.service.StartVM(*pveHostID, pveVMID.String); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -323,6 +335,12 @@ func (h *PVEHandler) VMStop(c *gin.Context) {
return
}
+ // 验证 VM ID 格式(必须是正整数)
+ if _, err := strconv.Atoi(pveVMID.String); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid VM ID format"})
+ return
+ }
+
if err := h.service.StopVM(*pveHostID, pveVMID.String); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
diff --git a/web/src/App.vue b/web/src/App.vue
index 9bbe83d..b555332 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -19,23 +19,23 @@ onMounted(() => {
/* ─── Light Mode (Dream2.0 Plus style) ─── */
:root {
--page-bg: #f5f7fa;
- --card-bg: #ffffff;
- --card-hover: #f2f6fc;
- --sidebar-bg: #ffffff;
+ --card-bg: rgba(255, 255, 255, 0.94);
+ --card-hover: rgba(255, 255, 255, 0.98);
+ --sidebar-bg: rgba(255, 255, 255, 0.94);
--text-primary: #424242;
--text-secondary: #57606a;
--text-muted: #8c959f;
--border: #ebeef5;
--border-strong: #dcdcdc;
--accent: #50bfff;
- --accent-weak: #e8f3ff;
+ --accent-weak: rgba(80, 191, 255, 0.12);
--accent-hover: #409eff;
--success: #1a7f37;
- --success-weak: #dafbe1;
+ --success-weak: rgba(26, 127, 55, 0.12);
--warning: #9a6700;
- --warning-weak: #fff8c5;
+ --warning-weak: rgba(154, 103, 0, 0.12);
--danger: #cf222e;
- --danger-weak: #ffebe9;
+ --danger-weak: rgba(207, 34, 46, 0.12);
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 0px 10px -5px #949494;
@@ -49,17 +49,17 @@ onMounted(() => {
--header-gradient: linear-gradient(135deg, #ffffff 0%, #f6f8fa 100%);
}
-/* ─── Dark Mode (Dream2.0 Plus night style) ─── */
+/* ─── Dark Mode (Dream2.0 Plus night style - 分层配色) ─── */
html.dark {
- --page-bg: #282c34;
- --card-bg: #3a3f4b;
- --card-hover: #4a5060;
- --sidebar-bg: #010409;
- --text-primary: #ccc;
- --text-secondary: #8b949e;
- --text-muted: #6e7681;
- --border: #30363d;
- --border-strong: #484f58;
+ --page-bg: #080c28;
+ --card-bg: rgba(40, 44, 52, 0.8);
+ --card-hover: #373d48;
+ --sidebar-bg: #080c28;
+ --text-primary: #d0d0d0;
+ --text-secondary: #aaa;
+ --text-muted: #888;
+ --border: #414243;
+ --border-strong: #666;
--accent: #5d93db;
--accent-weak: rgba(93, 147, 219, 0.15);
--accent-hover: #79b8ff;
@@ -69,14 +69,14 @@ html.dark {
--warning-weak: rgba(210, 153, 34, 0.15);
--danger: #f85149;
--danger-weak: rgba(248, 81, 73, 0.15);
- --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3);
- --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.4);
+ --shadow: 1px 1px 3px 1px #1b1b1b;
+ --shadow-card: 1px 1px 3px 1px #1b1b1b;
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
- --pill-bg: #21262d;
- --pill-text: #c9d1d9;
- --pill-border: #30363d;
- --topo-bg: #0d1117;
- --header-gradient: linear-gradient(135deg, #161b22 0%, #0d1117 100%);
+ --pill-bg: #303030;
+ --pill-text: #ccc;
+ --pill-border: #414243;
+ --topo-bg: #080c28;
+ --header-gradient: linear-gradient(135deg, #161b22 0%, #080c28 100%);
}
* { box-sizing: border-box; }
diff --git a/web/src/components/MainLayout.vue b/web/src/components/MainLayout.vue
index be4b003..21a51d7 100644
--- a/web/src/components/MainLayout.vue
+++ b/web/src/components/MainLayout.vue
@@ -21,6 +21,10 @@
机器列表
+
+
+ PVE 主机
+
拓扑图
diff --git a/web/src/router/index.js b/web/src/router/index.js
index 5db560b..6740988 100644
--- a/web/src/router/index.js
+++ b/web/src/router/index.js
@@ -43,6 +43,12 @@ const routes = [
component: () => import('@/views/Logs.vue'),
meta: { admin: true },
},
+ {
+ path: 'pve-hosts',
+ name: 'PVEHosts',
+ component: () => import('@/views/PVEHosts.vue'),
+ meta: { admin: true },
+ },
]
},
]
diff --git a/web/src/views/MachineList.vue b/web/src/views/MachineList.vue
index ffd5de8..e9926a6 100644
--- a/web/src/views/MachineList.vue
+++ b/web/src/views/MachineList.vue
@@ -73,6 +73,29 @@
{{ m.pve_vm_status === 'running' ? 'VM运行中' : m.pve_vm_status === 'stopped' ? 'VM已停止' : 'VM检测中' }}
+
+
+ 启动
+ 关闭
+
{{ m.uptime }}
@@ -311,9 +334,9 @@ import {
Collection, Histogram, Connection, Clock, VideoPlay, VideoPause, Lock, User, Key,
EditPen, Grid, InfoFilled, Warning
} from '@element-plus/icons-vue'
-import { fetchMachines, createMachine, updateMachine, uiRefreshInterval, exportData, importData, fetchPVEHosts, fetchVMStatus } from '@/api'
+import { fetchMachines, createMachine, updateMachine, uiRefreshInterval, exportData, importData, fetchPVEHosts, fetchVMStatus, startVM as apiStartVM, stopVM as apiStopVM } from '@/api'
import { getAuth } from '@/router'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const isAdmin = getAuth().is_admin
@@ -324,6 +347,7 @@ const osFilter = ref('')
const dialogVisible = ref(false)
const editing = ref({ hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '', pve_host_id: null, pve_vmid: '' })
const importFileRef = ref(null)
+const vmLoadingIds = ref(new Set())
let timer = null
onMounted(async () => {
@@ -377,6 +401,13 @@ async function saveMachine() {
ElMessage.warning('请填写必填项')
return
}
+ // 验证 VM ID 格式
+ if (data.pve_host_id && data.pve_vmid) {
+ if (!/^\d+$/.test(data.pve_vmid)) {
+ ElMessage.warning('虚拟机 ID 必须是正整数(如 101)')
+ return
+ }
+ }
if (data.id) {
await updateMachine(data.id, data)
} else {
@@ -523,6 +554,36 @@ function formatTime(t) {
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
+
+async function handleStartVM(m, event) {
+ event.stopPropagation()
+ try {
+ await ElMessageBox.confirm(`确定要启动虚拟机 ${m.hostname} 吗?`, '确认启动', { type: 'info' })
+ vmLoadingIds.value.add(m.id)
+ await apiStartVM(m.id)
+ ElMessage.success('虚拟机已启动')
+ m.pve_vm_status = 'running'
+ } catch (e) {
+ if (e !== 'cancel') ElMessage.error('启动失败')
+ } finally {
+ vmLoadingIds.value.delete(m.id)
+ }
+}
+
+async function handleStopVM(m, event) {
+ event.stopPropagation()
+ try {
+ await ElMessageBox.confirm(`确定要关闭虚拟机 ${m.hostname} 吗?`, '确认关闭', { type: 'warning' })
+ vmLoadingIds.value.add(m.id)
+ await apiStopVM(m.id)
+ ElMessage.success('虚拟机已关闭')
+ m.pve_vm_status = 'stopped'
+ } catch (e) {
+ if (e !== 'cancel') ElMessage.error('关闭失败')
+ } finally {
+ vmLoadingIds.value.delete(m.id)
+ }
+}