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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>机器列表</span>
|
||||
</router-link>
|
||||
<router-link to="/pve-hosts" class="nav-item" active-class="active">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span>PVE 主机</span>
|
||||
</router-link>
|
||||
<router-link to="/topology" class="nav-item" active-class="active">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>拓扑图</span>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
@@ -73,6 +73,29 @@
|
||||
<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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -886,6 +947,8 @@ function formatTime(t) {
|
||||
}
|
||||
.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;
|
||||
|
||||
347
web/src/views/PVEHosts.vue
Normal file
347
web/src/views/PVEHosts.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
PVE 主机管理
|
||||
</h2>
|
||||
<el-button type="primary" :icon="Plus" @click="openEdit()">添加 PVE 主机</el-button>
|
||||
</div>
|
||||
|
||||
<!-- PVE 主机列表 -->
|
||||
<div class="pve-list" v-if="hosts.length">
|
||||
<div v-for="h in hosts" :key="h.id" class="pve-card">
|
||||
<div class="pve-card-header">
|
||||
<div class="pve-name">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span>{{ h.name }}</span>
|
||||
</div>
|
||||
<div class="pve-status" :class="h.status">
|
||||
<span class="status-dot" :class="h.status"></span>
|
||||
<span>{{ statusText(h.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pve-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">地址</span>
|
||||
<span class="info-value">{{ h.hostname }}:{{ h.port }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">节点</span>
|
||||
<span class="info-value">{{ h.node_name }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">用户</span>
|
||||
<span class="info-value">{{ h.username }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">SSL 验证</span>
|
||||
<span class="info-value">{{ h.verify_ssl ? '启用' : '跳过' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pve-actions">
|
||||
<el-button text :icon="View" @click="checkStatus(h)">检测</el-button>
|
||||
<el-button text :icon="Edit" @click="openEdit(h)">编辑</el-button>
|
||||
<el-button text type="danger" :icon="Delete" @click="remove(h)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-else description="暂无 PVE 主机,点击上方按钮添加">
|
||||
<el-button type="primary" :icon="Plus" @click="openEdit()">添加 PVE 主机</el-button>
|
||||
</el-empty>
|
||||
|
||||
<!-- 编辑/添加对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editing.id ? '编辑 PVE 主机' : '添加 PVE 主机'"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
class="modern-dialog"
|
||||
>
|
||||
<el-form ref="formRef" :model="editing" :rules="rules" label-width="100px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="editing.name" placeholder="如:家用 PVE">
|
||||
<template #prefix><el-icon><Cpu /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="主机地址" prop="hostname">
|
||||
<el-input v-model="editing.hostname" placeholder="IP 或域名,如 192.168.1.10">
|
||||
<template #prefix><el-icon><Link /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input-number v-model="editing.port" :min="1" :max="65535" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点名称" prop="node_name">
|
||||
<el-input v-model="editing.node_name" placeholder="默认 pve">
|
||||
<template #prefix><el-icon><SetUp /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="editing.username" placeholder="如:root@pam">
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="editing.password" type="password" show-password placeholder="Proxmox 登录密码">
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="SSL 验证">
|
||||
<el-switch
|
||||
v-model="editing.verify_ssl"
|
||||
active-text="启用"
|
||||
inactive-text="跳过"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :icon="Check" @click="save" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus, Cpu, Link, SetUp, User, Lock, Check,
|
||||
View, Edit, Delete,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchPVEHosts, createPVEHost, updatePVEHost, deletePVEHost, fetchPVEHostStatus,
|
||||
} from '@/api'
|
||||
|
||||
const hosts = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const editing = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
hostname: '',
|
||||
port: 8006,
|
||||
node_name: 'pve',
|
||||
username: '',
|
||||
password: '',
|
||||
verify_ssl: false,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
hostname: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
|
||||
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
|
||||
node_name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!editing.value.id && !value) {
|
||||
callback(new Error('请输入密码'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}],
|
||||
}
|
||||
|
||||
function resetEditing() {
|
||||
editing.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
hostname: '',
|
||||
port: 8006,
|
||||
node_name: 'pve',
|
||||
username: '',
|
||||
password: '',
|
||||
verify_ssl: false,
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(host = null) {
|
||||
if (host) {
|
||||
editing.value = { ...host, password: '' }
|
||||
} else {
|
||||
resetEditing()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { ...editing.value }
|
||||
if (editing.value.id) {
|
||||
await updatePVEHost(editing.value.id, data)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createPVEHost(data)
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '操作失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(host) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除 PVE 主机 "${host.name}" 吗?关联的虚拟机配置将被清空。`,
|
||||
'确认删除',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
await deletePVEHost(host.id)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.response?.data?.error || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStatus(host) {
|
||||
try {
|
||||
ElMessage.info('正在检测连接...')
|
||||
const { data } = await fetchPVEHostStatus(host.id)
|
||||
host.status = data.status === 'online' ? 'online' : 'offline'
|
||||
if (data.status === 'online') {
|
||||
ElMessage.success('连接正常')
|
||||
} else {
|
||||
ElMessage.error('连接失败:' + (data.error || '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
host.status = 'offline'
|
||||
ElMessage.error('检测失败')
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status) {
|
||||
const map = { online: '在线', offline: '离线', checking: '检测中', unknown: '未知' }
|
||||
return map[status] || '未知'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await fetchPVEHosts()
|
||||
hosts.value = data.map(h => ({ ...h, status: 'checking' }))
|
||||
// 自动检测所有 PVE 主机状态
|
||||
await Promise.all(hosts.value.map(async (h) => {
|
||||
try {
|
||||
const { data } = await fetchPVEHostStatus(h.id)
|
||||
h.status = data.status === 'online' ? 'online' : 'offline'
|
||||
} catch (e) {
|
||||
h.status = 'offline'
|
||||
}
|
||||
}))
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pve-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pve-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
transition: all .2s ease;
|
||||
}
|
||||
.pve-card:hover {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.pve-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pve-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.pve-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pve-status.online { color: var(--success); }
|
||||
.pve-status.offline { color: var(--danger); }
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.status-dot.online { background: var(--success); }
|
||||
.status-dot.offline { background: var(--danger); }
|
||||
|
||||
.pve-info {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.info-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.info-value {
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.pve-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user