- Add PVE host management page (/pve-hosts) - Add PVE host CRUD operations with connection test - Add VM start/stop buttons on machine list cards - Auto-detect PVE host status on page load - Add VM ID validation (frontend + backend) - Fix PVE status detection logic (online vs ok) - Update Dream2.0 theme CSS variables - Fix visitor mode grid layout
1016 lines
31 KiB
Vue
1016 lines
31 KiB
Vue
.material-card.admin-card {
|
||
cursor: pointer;
|
||
}
|
||
.material-card.admin-card:hover {
|
||
transform: translateY(-3px);
|
||
box-shadow: var(--shadow-lg);
|
||
border-color: var(--border-strong);
|
||
}
|
||
|
||
.material-extra {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}<template>
|
||
<div class="page">
|
||
<!-- Toolbar -->
|
||
<div class="toolbar" v-if="isAdmin">
|
||
<div class="search-wrap">
|
||
<el-icon class="search-icon"><Search /></el-icon>
|
||
<el-input v-model="search" placeholder="搜索主机名" clearable @change="load" />
|
||
</div>
|
||
<el-select v-model="osFilter" placeholder="系统类型" clearable @change="load" class="os-select">
|
||
<el-option label="Linux" value="Linux">
|
||
<el-icon><Platform /></el-icon> Linux
|
||
</el-option>
|
||
<el-option label="Windows" value="Windows">
|
||
<el-icon><Monitor /></el-icon> Windows
|
||
</el-option>
|
||
<el-option label="macOS" value="macOS">
|
||
<el-icon><Apple /></el-icon> macOS
|
||
</el-option>
|
||
<el-option label="Other" value="Other">
|
||
<el-icon><QuestionFilled /></el-icon> Other
|
||
</el-option>
|
||
</el-select>
|
||
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openEdit()">添加机器</el-button>
|
||
<el-button v-if="isAdmin" type="success" :icon="Download" @click="handleExport">导出</el-button>
|
||
<el-button v-if="isAdmin" type="warning" :icon="Upload" @click="handleImportClick">导入</el-button>
|
||
<input ref="importFileRef" type="file" accept=".json" style="display:none" @change="handleImportFile">
|
||
</div>
|
||
|
||
<!-- Cards Grid -->
|
||
<div class="cards-grid" v-if="machines.length">
|
||
<!-- 管理员卡片:Material 风格 + 可点击 + 完整信息 -->
|
||
<div v-if="isAdmin" v-for="(m, idx) in machines" :key="m.id" class="material-card admin-card"
|
||
:class="[{ 'offline-card': !m.is_online }]"
|
||
:style="getMaterialStyle(idx, m.is_online)"
|
||
@click="goDetail(m.id)">
|
||
<div class="material-content">
|
||
<!-- 顶部:状态 + IP -->
|
||
<div class="material-header">
|
||
<div class="status-indicator">
|
||
<span class="status-dot" :class="{ pulse: m.is_online }"></span>
|
||
<span class="status-text" :class="m.is_online ? 'online' : 'offline'">
|
||
{{ m.is_online ? '在线' : '离线' }}
|
||
</span>
|
||
</div>
|
||
<span class="material-ip">{{ m.ip }}</span>
|
||
</div>
|
||
|
||
<!-- 主机名 -->
|
||
<h3 class="material-hostname">{{ m.hostname }}</h3>
|
||
|
||
<!-- OS 信息 + 额外标签 -->
|
||
<div class="material-os">
|
||
<span class="os-dot" :style="{ background: getMaterialColor(idx).accent }"></span>
|
||
<span>{{ m.os_type }} {{ m.os_version || '' }}</span>
|
||
<el-tag v-if="m.service_count" size="small" effect="plain" class="svc-tag" round>
|
||
<el-icon :size="10"><Service /></el-icon> {{ m.service_count }}
|
||
</el-tag>
|
||
<el-tag v-if="m.pve_host_id && m.pve_vmid" size="small" :type="m.pve_vm_status === 'running' ? 'success' : 'danger'" effect="light" round class="vm-tag">
|
||
<el-icon :size="10"><component :is="m.pve_vm_status === 'running' ? VideoPlay : VideoPause" /></el-icon>
|
||
{{ m.pve_vm_status === 'running' ? 'VM运行中' : m.pve_vm_status === 'stopped' ? 'VM已停止' : 'VM检测中' }}
|
||
</el-tag>
|
||
<!-- VM 快捷控制按钮 -->
|
||
<template v-if="m.pve_host_id && m.pve_vmid && m.pve_vm_status !== 'unknown'">
|
||
<el-button
|
||
v-if="m.pve_vm_status !== 'running'"
|
||
type="success"
|
||
:icon="VideoPlay"
|
||
:loading="vmLoadingIds.has(m.id)"
|
||
size="small"
|
||
text
|
||
class="vm-btn"
|
||
@click.stop="handleStartVM(m, $event)"
|
||
>启动</el-button>
|
||
<el-button
|
||
v-if="m.pve_vm_status === 'running'"
|
||
type="warning"
|
||
:icon="VideoPause"
|
||
:loading="vmLoadingIds.has(m.id)"
|
||
size="small"
|
||
text
|
||
class="vm-btn"
|
||
@click.stop="handleStopVM(m, $event)"
|
||
>关闭</el-button>
|
||
</template>
|
||
<span v-if="m.uptime" class="meta-uptime">
|
||
<el-icon :size="10"><Timer /></el-icon> {{ m.uptime }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 资源进度条 -->
|
||
<div class="material-progress-list" v-if="m.cpu_info || m.memory_info || m.disk_info">
|
||
<div v-if="m.cpu_info" class="progress-item">
|
||
<div class="progress-label-row">
|
||
<span class="progress-label">CPU</span>
|
||
<span class="progress-value" :class="{ warning: extractPercent(m.cpu_info) >= 80 }">
|
||
{{ extractPercent(m.cpu_info) }}%
|
||
</span>
|
||
</div>
|
||
<div class="progress-track">
|
||
<div class="progress-fill" :style="{
|
||
width: extractPercent(m.cpu_info) + '%',
|
||
background: getProgressColor(extractPercent(m.cpu_info), getMaterialColor(idx).accent)
|
||
}"></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="m.memory_info" class="progress-item">
|
||
<div class="progress-label-row">
|
||
<span class="progress-label">内存</span>
|
||
<span class="progress-value" :class="{ warning: extractPercent(m.memory_info) >= 80 }">
|
||
{{ extractDetail(m.memory_info) }}
|
||
</span>
|
||
</div>
|
||
<div class="progress-track">
|
||
<div class="progress-fill" :style="{
|
||
width: extractPercent(m.memory_info) + '%',
|
||
background: getProgressColor(extractPercent(m.memory_info), getMaterialColor(idx).accent)
|
||
}"></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="getMainDisk(m.disk_info)" class="progress-item">
|
||
<div class="progress-label-row">
|
||
<span class="progress-label">磁盘</span>
|
||
<span class="progress-value" :class="{ warning: getMainDisk(m.disk_info).percent >= 80 }">
|
||
{{ getMainDisk(m.disk_info).detail }}
|
||
</span>
|
||
</div>
|
||
<div class="progress-track">
|
||
<div class="progress-fill" :style="{
|
||
width: getMainDisk(m.disk_info).percent + '%',
|
||
background: getProgressColor(getMainDisk(m.disk_info).percent, getMaterialColor(idx).accent)
|
||
}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 端口 + 同步时间 -->
|
||
<div v-if="m.listen_ports || m.ssh_synced_at" class="material-extra">
|
||
<div v-if="m.listen_ports" class="ports-row">
|
||
<el-tag v-for="p in m.listen_ports.split(',').slice(0,4)" :key="p" size="small" effect="plain" round class="port-tag">
|
||
<el-icon :size="9"><Connection /></el-icon> {{ p.trim() }}
|
||
</el-tag>
|
||
<span v-if="m.listen_ports.split(',').length > 4" class="more-ports">+{{ m.listen_ports.split(',').length - 4 }}</span>
|
||
</div>
|
||
<div v-if="m.ssh_synced_at" class="sync-time">
|
||
<el-icon :size="10"><Clock /></el-icon>
|
||
同步于 {{ formatTime(m.ssh_synced_at) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未登录用户卡片:Material Design 风格 -->
|
||
<div v-if="!isAdmin" v-for="(m, idx) in machines" :key="m.id" class="material-card"
|
||
:class="[{ 'offline-card': !m.is_online }]"
|
||
:style="getMaterialStyle(idx, m.is_online)">
|
||
<div class="material-content">
|
||
<!-- 顶部:状态 + IP -->
|
||
<div class="material-header">
|
||
<div class="status-indicator">
|
||
<span class="status-dot" :class="{ pulse: m.is_online }"></span>
|
||
<span class="status-text" :class="m.is_online ? 'online' : 'offline'">
|
||
{{ m.is_online ? '在线' : '离线' }}
|
||
</span>
|
||
</div>
|
||
<span class="material-ip">{{ m.ip }}</span>
|
||
</div>
|
||
|
||
<!-- 主机名 -->
|
||
<h3 class="material-hostname">{{ m.hostname }}</h3>
|
||
|
||
<!-- OS 信息 -->
|
||
<div class="material-os">
|
||
<span class="os-dot" :style="{ background: getMaterialColor(idx).accent }"></span>
|
||
<span>{{ m.os_type }} {{ m.os_version || '' }}</span>
|
||
</div>
|
||
|
||
<!-- 资源进度条 -->
|
||
<div class="material-progress-list" v-if="m.cpu_info || m.memory_info || m.disk_info">
|
||
<div v-if="m.cpu_info" class="progress-item">
|
||
<div class="progress-label-row">
|
||
<span class="progress-label">CPU</span>
|
||
<span class="progress-value" :class="{ warning: extractPercent(m.cpu_info) >= 80 }">
|
||
{{ extractPercent(m.cpu_info) }}%
|
||
</span>
|
||
</div>
|
||
<div class="progress-track">
|
||
<div class="progress-fill" :style="{
|
||
width: extractPercent(m.cpu_info) + '%',
|
||
background: getProgressColor(extractPercent(m.cpu_info), getMaterialColor(idx).accent)
|
||
}"></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="m.memory_info" class="progress-item">
|
||
<div class="progress-label-row">
|
||
<span class="progress-label">内存</span>
|
||
<span class="progress-value" :class="{ warning: extractPercent(m.memory_info) >= 80 }">
|
||
{{ extractDetail(m.memory_info) }}
|
||
</span>
|
||
</div>
|
||
<div class="progress-track">
|
||
<div class="progress-fill" :style="{
|
||
width: extractPercent(m.memory_info) + '%',
|
||
background: getProgressColor(extractPercent(m.memory_info), getMaterialColor(idx).accent)
|
||
}"></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="getMainDisk(m.disk_info)" class="progress-item">
|
||
<div class="progress-label-row">
|
||
<span class="progress-label">磁盘</span>
|
||
<span class="progress-value" :class="{ warning: getMainDisk(m.disk_info).percent >= 80 }">
|
||
{{ getMainDisk(m.disk_info).detail }}
|
||
</span>
|
||
</div>
|
||
<div class="progress-track">
|
||
<div class="progress-fill" :style="{
|
||
width: getMainDisk(m.disk_info).percent + '%',
|
||
background: getProgressColor(getMainDisk(m.disk_info).percent, getMaterialColor(idx).accent)
|
||
}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-empty v-if="!machines.length" description="暂无机器" :image-size="80">
|
||
<template #description>
|
||
<div style="color: var(--text-muted); margin-top: 8px;">暂无机器数据</div>
|
||
</template>
|
||
</el-empty>
|
||
|
||
<!-- Edit Dialog -->
|
||
<el-dialog v-model="dialogVisible" :title="editing.id ? '编辑机器' : '添加机器'" width="500px" class="modern-dialog" destroy-on-close>
|
||
<el-form :model="editing" label-width="100px">
|
||
<el-form-item label="主机名" required>
|
||
<el-input v-model="editing.hostname" placeholder="如 web-server-01">
|
||
<template #prefix><el-icon><Monitor /></el-icon></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="IP" required>
|
||
<el-input v-model="editing.ip" placeholder="192.168.1.100">
|
||
<template #prefix><el-icon><Link /></el-icon></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="MAC">
|
||
<el-input v-model="editing.mac" placeholder="AA:BB:CC:DD:EE:FF">
|
||
<template #prefix><el-icon><Connection /></el-icon></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="系统" required>
|
||
<el-select v-model="editing.os_type" style="width:100%">
|
||
<el-option label="Linux" value="Linux">
|
||
<el-icon><Platform /></el-icon> Linux
|
||
</el-option>
|
||
<el-option label="Windows" value="Windows">
|
||
<el-icon><Monitor /></el-icon> Windows
|
||
</el-option>
|
||
<el-option label="macOS" value="macOS">
|
||
<el-icon><Apple /></el-icon> macOS
|
||
</el-option>
|
||
<el-option label="Other" value="Other">
|
||
<el-icon><QuestionFilled /></el-icon> Other
|
||
</el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="系统版本">
|
||
<el-input v-model="editing.os_version" placeholder="Ubuntu 22.04">
|
||
<template #prefix><el-icon><InfoFilled /></el-icon></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-divider content-position="left">
|
||
<el-icon><Lock /></el-icon> SSH 配置
|
||
</el-divider>
|
||
<el-form-item label="SSH 端口">
|
||
<el-input-number v-model="editing.ssh_port" :min="1" :max="65535" style="width:100%" />
|
||
</el-form-item>
|
||
<el-form-item label="SSH 用户">
|
||
<el-input v-model="editing.ssh_username" placeholder="root">
|
||
<template #prefix><el-icon><User /></el-icon></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="SSH 密码">
|
||
<el-input v-model="editing.ssh_password" type="password" show-password placeholder="输入则更新密码,留空保持不变">
|
||
<template #prefix><el-icon><Key /></el-icon></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="备注">
|
||
<el-input v-model="editing.notes" type="textarea" :rows="3" placeholder="记录用途、负责人等信息">
|
||
<template #prefix><el-icon><EditPen /></el-icon></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-divider content-position="left">
|
||
<el-icon><Grid /></el-icon> PVE 配置(可选)
|
||
</el-divider>
|
||
<el-form-item label="PVE 主机">
|
||
<el-select v-model="editing.pve_host_id" placeholder="选择 PVE 主机" clearable style="width:100%">
|
||
<el-option v-for="h in pveHosts" :key="h.id" :label="h.name" :value="h.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="虚拟机 ID">
|
||
<el-input v-model="editing.pve_vmid" placeholder="如 101">
|
||
<template #prefix><el-icon><Grid /></el-icon></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :icon="Check" @click="saveMachine">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, onUnmounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import {
|
||
Plus, Search, Download, Upload, Check, Platform, Monitor, Apple,
|
||
QuestionFilled, CircleCheck, CircleClose, Service, Link, Cpu, Timer,
|
||
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, startVM as apiStartVM, stopVM as apiStopVM } from '@/api'
|
||
import { getAuth } from '@/router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
|
||
const router = useRouter()
|
||
const isAdmin = getAuth().is_admin
|
||
const machines = ref([])
|
||
const pveHosts = ref([])
|
||
const search = ref('')
|
||
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 () => {
|
||
await load()
|
||
if (isAdmin) {
|
||
try {
|
||
const res = await fetchPVEHosts()
|
||
pveHosts.value = res.data
|
||
} catch (e) {}
|
||
}
|
||
const interval = uiRefreshInterval || 10000
|
||
if (interval > 0) {
|
||
timer = setInterval(load, interval)
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (timer) clearInterval(timer)
|
||
})
|
||
|
||
async function load() {
|
||
const res = await fetchMachines({ search: search.value, os_type: osFilter.value })
|
||
machines.value = res.data
|
||
if (isAdmin) {
|
||
const pveMachines = machines.value.filter(m => m.pve_host_id && m.pve_vmid)
|
||
await Promise.all(pveMachines.map(async (m) => {
|
||
try {
|
||
const statusRes = await fetchVMStatus(m.id)
|
||
m.pve_vm_status = statusRes.data.status
|
||
} catch (e) {
|
||
m.pve_vm_status = 'unknown'
|
||
}
|
||
}))
|
||
}
|
||
}
|
||
|
||
function goDetail(id) {
|
||
router.push(`/machines/${id}`)
|
||
}
|
||
|
||
function openEdit(item) {
|
||
editing.value = item
|
||
? { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '', pve_host_id: item.pve_host_id || null, pve_vmid: item.pve_vmid || '' }
|
||
: { hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '', pve_host_id: null, pve_vmid: '' }
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function saveMachine() {
|
||
const data = { ...editing.value }
|
||
if (!data.hostname || !data.ip || !data.os_type) {
|
||
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 {
|
||
await createMachine(data)
|
||
}
|
||
ElMessage.success('保存成功')
|
||
dialogVisible.value = false
|
||
load()
|
||
}
|
||
|
||
async function handleExport() {
|
||
try {
|
||
const res = await exportData()
|
||
const blob = new Blob([res.data])
|
||
const url = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `lan-manager-backup-${new Date().toISOString().slice(0,10)}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
window.URL.revokeObjectURL(url)
|
||
ElMessage.success('导出成功')
|
||
} catch (e) {
|
||
ElMessage.error('导出失败')
|
||
}
|
||
}
|
||
|
||
function handleImportClick() {
|
||
importFileRef.value?.click()
|
||
}
|
||
|
||
async function handleImportFile(e) {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('mode', 'overwrite')
|
||
try {
|
||
await importData(formData)
|
||
ElMessage.success('导入成功,页面即将刷新')
|
||
setTimeout(() => location.reload(), 800)
|
||
} catch (err) {
|
||
ElMessage.error('导入失败')
|
||
}
|
||
e.target.value = ''
|
||
}
|
||
|
||
function extractPercent(str) {
|
||
if (!str) return 0
|
||
const m = str.match(/\((\d+)%\)/) || str.match(/(\d+(?:\.\d+)?)%/)
|
||
if (m) {
|
||
const v = parseFloat(m[1])
|
||
return isNaN(v) ? 0 : Math.min(100, Math.max(0, Math.round(v)))
|
||
}
|
||
return 0
|
||
}
|
||
|
||
function extractDetail(str) {
|
||
if (!str) return ''
|
||
let s = str.replace(/\s*\(\d+(?:\.\d+)?%\)\s*/g, '').trim()
|
||
if (s.includes(',')) s = s.split(',')[0].trim()
|
||
s = s.replace(/^\/\s+/, '').trim()
|
||
if (/^\d+\.?\d*\/\d+\.?\d*$/.test(s)) s += 'G'
|
||
return s
|
||
}
|
||
|
||
function getMaterialColor(index) {
|
||
const colors = [
|
||
{ accent: '#58a6ff' },
|
||
{ accent: '#3fb950' },
|
||
{ accent: '#a371f7' },
|
||
{ accent: '#d29922' },
|
||
{ accent: '#f0883e' },
|
||
{ accent: '#f778ba' },
|
||
{ accent: '#39c5cf' },
|
||
{ accent: '#7ee787' },
|
||
]
|
||
return colors[index % colors.length]
|
||
}
|
||
|
||
function getMaterialStyle(index, isOnline) {
|
||
if (!isOnline) {
|
||
return { opacity: '0.55' }
|
||
}
|
||
return {}
|
||
}
|
||
|
||
function getProgressColor(value, accent) {
|
||
if (value >= 80) return '#F44336'
|
||
if (value >= 60) return '#FFC107'
|
||
return accent
|
||
}
|
||
|
||
function getMemBarColor(v) {
|
||
if (v >= 90) return 'var(--danger)'
|
||
if (v >= 70) return 'var(--warning)'
|
||
return 'var(--success)'
|
||
}
|
||
function getDiskBarColor(v) {
|
||
if (v >= 90) return 'var(--danger)'
|
||
if (v >= 80) return 'var(--warning)'
|
||
return 'var(--accent)'
|
||
}
|
||
|
||
function parseDisks(str) {
|
||
if (!str) return []
|
||
const disks = []
|
||
const regex = /(\S+)\s+([\d\.]+[KMGT]?\/[\d\.]+[KMGT]?)\s+\((\d+)%\)/g
|
||
let m
|
||
while ((m = regex.exec(str)) !== null) {
|
||
disks.push({ mount: m[1], detail: m[2], percent: parseInt(m[3], 10) })
|
||
}
|
||
if (disks.length > 0) return disks
|
||
const pct = extractPercent(str)
|
||
const det = extractDetail(str)
|
||
if (det) return [{ mount: '', detail: det, percent: pct }]
|
||
return []
|
||
}
|
||
|
||
function getMainDisk(str) {
|
||
const disks = parseDisks(str)
|
||
const root = disks.find(d => d.mount === '/')
|
||
return root || disks[0] || null
|
||
}
|
||
|
||
function osClass(os) {
|
||
const map = { Linux: 'os-linux', Windows: 'os-windows', macOS: 'os-macos' }
|
||
return map[os] || 'os-other'
|
||
}
|
||
function osIcon(os) {
|
||
const map = { Linux: 'Platform', Windows: 'Monitor', macOS: 'Apple' }
|
||
return map[os] || 'QuestionFilled'
|
||
}
|
||
function osShort(os) {
|
||
const map = { Linux: 'L', Windows: 'W', macOS: 'M' }
|
||
return map[os] || 'O'
|
||
}
|
||
|
||
function formatTime(t) {
|
||
if (!t) return '-'
|
||
const d = new Date(t)
|
||
if (isNaN(d.getTime())) return 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)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 24px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.search-wrap {
|
||
position: relative;
|
||
flex: 1;
|
||
max-width: 360px;
|
||
}
|
||
.search-wrap :deep(.el-input__wrapper) {
|
||
padding-left: 34px;
|
||
}
|
||
.search-icon {
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--text-muted);
|
||
font-size: 16px;
|
||
z-index: 1;
|
||
}
|
||
|
||
.cards-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
}
|
||
@media (max-width: 1200px) {
|
||
.cards-grid { grid-template-columns: repeat(2, 1fr); }
|
||
}
|
||
@media (max-width: 768px) {
|
||
.cards-grid { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
/* ─── Material Design 访客卡片 ─── */
|
||
.material-card {
|
||
position: relative;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border);
|
||
padding: 12px 14px;
|
||
min-height: 140px;
|
||
cursor: default;
|
||
transition: all 0.2s ease;
|
||
overflow: hidden;
|
||
background: var(--card-bg);
|
||
}
|
||
.material-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: var(--shadow-lg);
|
||
border-color: var(--border-strong);
|
||
}
|
||
.material-card.offline-card {
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.material-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
|
||
.material-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
position: relative;
|
||
}
|
||
.status-dot.pulse {
|
||
background: var(--success);
|
||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.25);
|
||
animation: pulse-ring 2s ease-in-out infinite;
|
||
}
|
||
.status-dot:not(.pulse) {
|
||
background: var(--text-muted);
|
||
}
|
||
|
||
@keyframes pulse-ring {
|
||
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4); }
|
||
70% { box-shadow: 0 0 0 5px rgba(76, 175, 80, 0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
|
||
}
|
||
|
||
.status-text {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
.status-text.online { color: var(--success); }
|
||
.status-text.offline { color: var(--text-muted); }
|
||
|
||
.material-ip {
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.material-hostname {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
margin-bottom: 2px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.material-os {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.os-dot {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.material-progress-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.progress-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.progress-label-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.progress-label {
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.progress-value {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text-primary);
|
||
}
|
||
.progress-value.warning {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.progress-track {
|
||
width: 100%;
|
||
height: 3px;
|
||
border-radius: 2px;
|
||
background: var(--border);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
border-radius: 2px;
|
||
transition: width 0.6s ease;
|
||
}
|
||
|
||
.material-card.admin-card {
|
||
cursor: pointer;
|
||
}
|
||
.material-card.admin-card:hover {
|
||
transform: translateY(-3px);
|
||
box-shadow: var(--shadow-lg);
|
||
border-color: var(--border-strong);
|
||
}
|
||
|
||
.material-extra {
|
||
margin-top: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
/* ─── 管理员卡片(保持原有) ─── */
|
||
.server-card {
|
||
position: relative;
|
||
background: var(--card-bg);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-card);
|
||
border: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: all .3s ease;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* OS 顶部色条 — 使用 border-top */
|
||
.server-card.os-linux {
|
||
border-top: 3px solid #58a6ff;
|
||
}
|
||
.server-card.os-windows {
|
||
border-top: 3px solid #3fb950;
|
||
}
|
||
.server-card.os-macos {
|
||
border-top: 3px solid #a371f7;
|
||
}
|
||
.server-card.os-other {
|
||
border-top: 3px solid #8b949e;
|
||
}
|
||
.server-card.offline-card {
|
||
border-top: 3px solid #f85149;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.server-card:hover.os-linux {
|
||
border-top: 4px solid #58a6ff;
|
||
box-shadow: 0 0 20px rgba(88, 166, 255, 0.15), 0 0 60px rgba(88, 166, 255, 0.08), var(--shadow-lg);
|
||
}
|
||
.server-card:hover.os-windows {
|
||
border-top: 4px solid #3fb950;
|
||
box-shadow: 0 0 20px rgba(63, 185, 80, 0.15), 0 0 60px rgba(63, 185, 80, 0.08), var(--shadow-lg);
|
||
}
|
||
.server-card:hover.os-macos {
|
||
border-top: 4px solid #a371f7;
|
||
box-shadow: 0 0 20px rgba(163, 113, 247, 0.15), 0 0 60px rgba(163, 113, 247, 0.08), var(--shadow-lg);
|
||
}
|
||
.server-card:hover.os-other {
|
||
border-top: 4px solid #8b949e;
|
||
}
|
||
.server-card.offline-card:hover {
|
||
border-top: 4px solid #f85149;
|
||
box-shadow: 0 0 20px rgba(248, 81, 73, 0.15), 0 0 60px rgba(248, 81, 73, 0.08), var(--shadow-lg);
|
||
}
|
||
|
||
.server-card:hover {
|
||
transform: translateY(-3px);
|
||
border-color: var(--border-strong);
|
||
}
|
||
|
||
.card-content {
|
||
position: relative;
|
||
z-index: 1;
|
||
background: var(--card-bg);
|
||
border-radius: 0 0 calc(var(--radius) - 1px) calc(var(--radius) - 1px);
|
||
padding: 16px 18px 18px;
|
||
}
|
||
|
||
.server-card.public-card {
|
||
cursor: default;
|
||
}
|
||
.server-card.public-card:hover {
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.server-card.offline-card .hostname {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.os-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 8px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
flex-shrink: 0;
|
||
}
|
||
.os-badge.os-linux { background: #58a6ff; }
|
||
.os-badge.os-windows { background: #3fb950; }
|
||
.os-badge.os-macos { background: #a371f7; }
|
||
.os-badge.os-other { background: #8b949e; }
|
||
|
||
.hostname {
|
||
font-weight: 700;
|
||
font-size: 15px;
|
||
color: var(--text-primary);
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.status-tag {
|
||
font-size: 11px;
|
||
height: 20px;
|
||
padding: 0 8px;
|
||
font-weight: 600;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.svc-tag {
|
||
font-size: 11px;
|
||
height: 20px;
|
||
padding: 0 8px;
|
||
color: var(--text-secondary);
|
||
border-color: var(--border);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.meta-row {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 6px 0;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.meta-ip {
|
||
color: var(--text-primary);
|
||
font-weight: 500;
|
||
background: var(--card-hover);
|
||
border: 1px solid var(--border);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.meta-item + .meta-item::before,
|
||
.meta-uptime::before {
|
||
content: '·';
|
||
margin: 0 6px;
|
||
color: var(--text-muted);
|
||
}
|
||
.meta-uptime { color: var(--success); display: inline-flex; align-items: center; gap: 4px; }
|
||
.vm-tag { font-size: 11px; height: 20px; padding: 0 8px; display: inline-flex; align-items: center; gap: 4px; }
|
||
.vm-btn { font-size: 11px; height: 20px; padding: 0 6px; margin-left: 4px; }
|
||
.vm-btn :deep(.el-icon) { font-size: 12px; }
|
||
|
||
.stats-row {
|
||
margin-top: 16px;
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 10px;
|
||
}
|
||
.stat-pill {
|
||
background: var(--pill-bg);
|
||
border: 1px solid var(--pill-border);
|
||
border-radius: 10px;
|
||
padding: 9px 10px;
|
||
color: var(--pill-text);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
transition: all .2s ease;
|
||
}
|
||
.pill-icon {
|
||
width: 24px; height: 24px; border-radius: 5px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 10px; font-weight: 800;
|
||
background: rgba(255,255,255,0.10);
|
||
flex-shrink: 0;
|
||
color: #fff;
|
||
}
|
||
.pill-icon.cpu { background: rgba(88, 166, 255, 0.3); color: #58a6ff; }
|
||
.pill-icon.mem { background: rgba(63, 185, 80, 0.3); color: #3fb950; }
|
||
.pill-icon.disk { background: rgba(163, 113, 247, 0.3); color: #a371f7; }
|
||
.pill-body {
|
||
flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||
}
|
||
.pill-label { font-size: 10px; color: var(--text-muted); font-weight: 600; }
|
||
.pill-bar { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||
.pill-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width .3s ease; }
|
||
.pill-value { font-size: 11px; font-weight: 700; color: var(--text-primary); white-space: nowrap; }
|
||
|
||
.ports-row {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.port-tag {
|
||
font-size: 11px;
|
||
height: 20px;
|
||
padding: 0 7px;
|
||
color: var(--text-secondary);
|
||
border-color: var(--border);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.more-ports { font-size: 11px; color: var(--text-muted); line-height: 20px; }
|
||
|
||
.sync-time {
|
||
margin-top: 10px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
</style>
|