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) + } +}