Files
lan-manager/web/src/views/MachineList.vue
shirainbown 21a2d2dff3 feat: PVE host management and VM control
- 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
2026-06-19 17:49:04 +08:00

1016 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
.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>