feat: UI 全面升级,新增 Dashboard 仪表盘

- 新增 Dashboard.vue 首页(统计卡片、系统分布、在线率趋势)
- 路由调整:根路径 / 默认重定向到 /dashboard
- 新增品牌图标(logo.svg、favicon)
- 登录页、侧边栏、拓扑图视觉增强
- 全局 CSS 变量重定义(GitHub 风格)
- 拓扑图新增布局切换和过滤控制
- 更新 README.md 部署指导,删除旧二进制部署说明
This commit is contained in:
shirainbown
2026-06-19 00:28:00 +08:00
parent ba1c2c3af8
commit 73ca3ee65a
14 changed files with 1971 additions and 885 deletions

View File

@@ -3,8 +3,13 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/favicon.png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>局域网机器管理后台</title>
<title>LAN Manager · 局域网资产管理</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

13
web/public/logo.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#58a6ff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1f6feb;stop-opacity:1" />
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g)" />
<rect x="20" y="22" width="24" height="3" rx="1.5" fill="white" opacity="0.9" />
<rect x="20" y="39" width="24" height="3" rx="1.5" fill="white" opacity="0.9" />
<circle cx="28" cy="23.5" r="1.5" fill="white" />
<circle cx="28" cy="40.5" r="1.5" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 674 B

View File

@@ -16,156 +16,373 @@ onMounted(() => {
</script>
<style>
/* Light mode (default) */
/* ─── Light Mode ─── */
:root {
--bg: #f8fafc;
--surface: #ffffff;
--surface-hover: #f8fafc;
--text: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--primary: #3b82f6;
--primary-weak: #eff6ff;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--radius: 14px;
--shadow: 0 1px 3px rgba(2, 8, 23, 0.05), 0 1px 2px rgba(2, 8, 23, 0.03);
--shadow-lg: 0 10px 30px rgba(2, 8, 23, 0.08);
--pill-bg: #0f172a;
--pill-text: #f8fafc;
--pill-border: rgba(255,255,255,0.06);
--page-bg: #f6f8fa;
--card-bg: #ffffff;
--card-hover: #f6f8fa;
--sidebar-bg: #ffffff;
--text-primary: #1f2328;
--text-secondary: #57606a;
--text-muted: #8c959f;
--border: #d0d7de;
--border-strong: #c8cdd1;
--accent: #0969da;
--accent-weak: #ddf4ff;
--accent-hover: #0550ae;
--success: #1a7f37;
--success-weak: #dafbe1;
--warning: #9a6700;
--warning-weak: #fff8c5;
--danger: #cf222e;
--danger-weak: #ffebe9;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 1px 2px rgba(31, 35, 40, 0.04), 0 1px 3px rgba(31, 35, 40, 0.02);
--shadow-card: 0 1px 3px rgba(31, 35, 40, 0.06), 0 4px 12px rgba(31, 35, 40, 0.04);
--shadow-lg: 0 8px 24px rgba(31, 35, 40, 0.08);
--font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--pill-bg: #f6f8fa;
--pill-text: #1f2328;
--pill-border: #d0d7de;
--topo-bg: #f6f8fa;
--header-gradient: linear-gradient(135deg, #ffffff 0%, #f6f8fa 100%);
}
/* Dark mode */
/* ─── Dark Mode (songshiyu.cn style) ─── */
html.dark {
--bg: #020617;
--surface: #0f172a;
--surface-hover: #1e293b;
--text: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border: #1e293b;
--border-strong: #334155;
--primary: #60a5fa;
--primary-weak: rgba(59,130,246,0.15);
--success: #34d399;
--warning: #fbbf24;
--danger: #f87171;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
--pill-bg: #1e293b;
--pill-text: #f8fafc;
--pill-border: #334155;
--page-bg: #0d1117;
--card-bg: #161b22;
--card-hover: #1c2128;
--sidebar-bg: #010409;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--border: #30363d;
--border-strong: #484f58;
--accent: #58a6ff;
--accent-weak: rgba(88, 166, 255, 0.15);
--accent-hover: #79b8ff;
--success: #3fb950;
--success-weak: rgba(63, 185, 80, 0.15);
--warning: #d29922;
--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-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%);
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
background: var(--bg);
color: var(--text);
margin: 0; padding: 0; height: 100%;
font-family: var(--font-family);
background: var(--page-bg);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
transition: background .2s ease, color .2s ease;
transition: background .25s ease, color .25s ease;
}
.page {
padding: 22px;
max-width: 1400px;
padding: 24px;
max-width: 1440px;
margin: 0 auto;
}
.card {
background: var(--surface);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
margin-bottom: 16px;
transition: background .2s ease, border-color .2s ease;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--text);
margin-bottom: 14px;
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.3px;
display: flex;
align-items: center;
gap: 10px;
}
.page-title .el-icon {
font-size: 22px;
color: var(--accent);
}
.page-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
/* Element Plus overrides */
.card {
background: var(--card-bg);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow-card);
border: 1px solid var(--border);
margin-bottom: 16px;
transition: background .25s ease, border-color .25s ease, box-shadow .25s ease;
}
.card:hover {
border-color: var(--border-strong);
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-title .el-icon {
font-size: 18px;
color: var(--accent);
margin-right: 4px;
}
/* ─── Element Plus Overrides ─── */
.el-button {
border-radius: 8px;
border-radius: var(--radius-sm);
font-weight: 500;
transition: all .15s ease;
}
.el-button--primary {
--el-button-bg-color: #3b82f6;
--el-button-border-color: #3b82f6;
--el-button-hover-bg-color: #2563eb;
--el-button-hover-border-color: #2563eb;
--el-button-bg-color: var(--accent);
--el-button-border-color: var(--accent);
--el-button-hover-bg-color: var(--accent-hover);
--el-button-hover-border-color: var(--accent-hover);
--el-button-text-color: #fff;
}
.el-button--success {
--el-button-bg-color: var(--success);
--el-button-border-color: var(--success);
--el-button-text-color: #fff;
}
.el-button--danger {
--el-button-bg-color: var(--danger);
--el-button-border-color: var(--danger);
--el-button-text-color: #fff;
}
.el-button--warning {
--el-button-bg-color: var(--warning);
--el-button-border-color: var(--warning);
--el-button-text-color: #fff;
}
html.dark .el-button--primary {
--el-button-bg-color: #3b82f6;
--el-button-border-color: #3b82f6;
--el-button-hover-bg-color: #2563eb;
--el-button-hover-border-color: #2563eb;
--el-button-bg-color: #1f6feb;
--el-button-border-color: #1f6feb;
--el-button-hover-bg-color: #388bfd;
--el-button-hover-border-color: #388bfd;
--el-button-text-color: #fff;
}
.el-input__wrapper {
border-radius: 10px;
border-radius: var(--radius-sm) !important;
background: var(--card-bg) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
transition: box-shadow .15s ease;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--accent) inset !important;
}
.el-input__inner {
color: var(--text-primary) !important;
}
.el-input__inner::placeholder {
color: var(--text-muted) !important;
}
.el-select .el-input__wrapper {
border-radius: var(--radius-sm) !important;
}
.el-tag {
border-radius: 6px;
font-weight: 500;
}
.el-dialog {
border-radius: 16px;
border-radius: var(--radius);
background: var(--card-bg);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
}
.el-dialog__header {
padding: 18px 20px 10px;
padding: 20px 24px 12px;
font-weight: 700;
font-size: 16px;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
margin-right: 0;
}
.el-dialog__body {
padding: 10px 20px 18px;
padding: 16px 24px;
color: var(--text-secondary);
}
.el-dialog__footer {
padding: 12px 24px 20px;
border-top: 1px solid var(--border);
}
.el-table {
--el-table-header-bg-color: var(--surface-hover);
--el-table-row-hover-bg-color: var(--surface-hover);
--el-table-header-bg-color: var(--card-hover);
--el-table-row-hover-bg-color: var(--card-hover);
--el-table-border-color: var(--border);
border-radius: 10px;
--el-table-text-color: var(--text-primary);
--el-table-header-text-color: var(--text-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--card-bg);
}
.el-table th.el-table__cell {
font-weight: 600;
font-size: 13px;
color: var(--text-secondary);
background: var(--card-hover);
}
.el-table .cell {
font-size: 13px;
}
.el-pagination .btn-prev,
.el-pagination .btn-next,
.el-pager li {
border-radius: 8px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
background: var(--card-bg);
border: 1px solid var(--border);
}
.el-pager li.is-active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
.el-form-item__label {
color: var(--text-secondary);
font-weight: 500;
}
.el-divider__text {
color: var(--text-muted);
font-weight: 500;
font-size: 12px;
}
.el-empty__description {
color: var(--text-muted);
}
.el-message {
border-radius: var(--radius-sm);
font-weight: 500;
}
/* ─── Scrollbar ─── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.25);
background: rgba(128, 128, 128, 0.25);
border-radius: 4px;
}
html.dark ::-webkit-scrollbar-thumb {
background: rgba(148,163,184,0.25);
html.dark ::-webkit-scrollbar-thumb { background: rgba(110, 118, 129, 0.4); }
::-webkit-scrollbar-track { background: transparent; }
/* ─── Animations ─── */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
::-webkit-scrollbar-track {
background: transparent;
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out both;
}
.animate-slide-in {
animation: slideInRight 0.4s ease-out both;
}
/* ─── Stat Card Grid ─── */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
@media (max-width: 900px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
.stat-grid { grid-template-columns: 1fr; }
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
display: flex;
align-items: flex-start;
gap: 14px;
transition: transform .12s ease, box-shadow .2s ease, border-color .2s ease;
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
border-color: var(--border-strong);
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
background: var(--accent-weak);
color: var(--accent);
}
.stat-icon.success { background: var(--success-weak); color: var(--success); }
.stat-icon.warning { background: var(--warning-weak); color: var(--warning); }
.stat-icon.danger { background: var(--danger-weak); color: var(--danger); }
.stat-body { flex: 1; }
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
letter-spacing: -0.5px;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
font-weight: 500;
}
.stat-trend {
font-size: 12px;
font-weight: 600;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.stat-trend.up { color: var(--success); }
.stat-trend.down { color: var(--danger); }
</style>

View File

@@ -2,10 +2,20 @@
<div class="layout">
<aside v-if="isAdmin" class="sidebar">
<div class="brand">
<div class="brand-logo">LM</div>
<div class="brand-text">LAN Manager</div>
<div class="brand-logo">
<el-icon><Connection /></el-icon>
</div>
<div class="brand-text">
<div class="brand-name">LAN Manager</div>
<div class="brand-desc">局域网资产管理</div>
</div>
</div>
<nav class="nav">
<router-link to="/dashboard" class="nav-item" active-class="active">
<el-icon><Odometer /></el-icon>
<span>仪表盘</span>
</router-link>
<router-link to="/machines" class="nav-item" active-class="active">
<el-icon><Monitor /></el-icon>
<span>机器列表</span>
@@ -19,23 +29,29 @@
<span>操作日志</span>
</router-link>
</nav>
<div class="theme-toggle" @click="toggleTheme">
<el-icon class="theme-icon"><component :is="isDark ? Sunny : Moon" /></el-icon>
<span>{{ isDark ? '浅色模式' : '深色模式' }}</span>
</div>
<div class="sidebar-footer">
<div class="user-info">
<el-icon class="user-icon"><User /></el-icon>
<span class="user-name">{{ isAdmin ? '管理员' : '访客' }}</span>
<div class="theme-toggle" @click="toggleTheme">
<el-icon class="theme-icon"><component :is="isDark ? Sunny : Moon" /></el-icon>
<span>{{ isDark ? '浅色模式' : '深色模式' }}</span>
</div>
<div class="user-info">
<el-icon class="user-icon"><UserFilled /></el-icon>
<div class="user-meta">
<span class="user-name">{{ isAdmin ? '管理员' : '访客' }}</span>
<span class="user-role">{{ isAdmin ? 'Admin' : 'Guest' }}</span>
</div>
</div>
<div class="footer-actions">
<el-button text class="footer-btn" @click="openChangePassword">
<el-icon><Lock /></el-icon>
<span>修改密码</span>
</el-button>
<el-button text class="footer-btn" @click="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出</span>
</el-button>
</div>
<el-button text class="logout-btn" @click="openChangePassword">
<el-icon><Lock /></el-icon>
<span>修改密码</span>
</el-button>
<el-button text class="logout-btn" @click="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出</span>
</el-button>
</div>
</aside>
@@ -48,8 +64,9 @@
<el-dialog
v-model="pwdDialogVisible"
title="修改密码"
width="360px"
width="400px"
:close-on-click-modal="false"
class="modern-dialog"
>
<el-form
ref="pwdFormRef"
@@ -63,7 +80,11 @@
type="password"
show-password
placeholder="请输入旧密码"
/>
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="新密码" prop="new_password">
<el-input
@@ -71,7 +92,11 @@
type="password"
show-password
placeholder="至少6位字符"
/>
>
<template #prefix>
<el-icon><Key /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="确认密码" prop="confirm_password">
<el-input
@@ -79,7 +104,11 @@
type="password"
show-password
placeholder="再次输入新密码"
/>
>
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
@@ -93,7 +122,7 @@
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Monitor, Share, Document, User, SwitchButton, Sunny, Moon, Lock } from '@element-plus/icons-vue'
import { Odometer, Monitor, Share, Document, UserFilled, SwitchButton, Sunny, Moon, Lock, Key, CircleCheck, Connection } from '@element-plus/icons-vue'
import { getAuth, refreshAuth } from '@/router'
import { logout as apiLogout, changePassword } from '@/api'
import { ElMessage } from 'element-plus'
@@ -178,9 +207,9 @@ async function submitChangePassword() {
}
.sidebar {
width: 220px;
background: #0f172a;
color: #fff;
width: 240px;
background: var(--sidebar-bg);
color: var(--text-primary);
display: flex;
flex-direction: column;
position: fixed;
@@ -188,140 +217,177 @@ async function submitChangePassword() {
top: 0;
bottom: 0;
z-index: 100;
border-right: 1px solid var(--border);
transition: background .25s ease, border-color .25s ease;
}
.brand {
padding: 22px 18px 14px;
padding: 22px 18px 18px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(255,255,255,0.06);
gap: 12px;
border-bottom: 1px solid var(--border);
}
.brand-logo {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #3b82f6, #60a5fa);
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 13px;
color: #fff;
font-size: 18px;
box-shadow: 0 4px 12px rgba(9, 105, 218, 0.3);
}
.brand-text {
html.dark .brand-logo {
box-shadow: 0 4px 12px rgba(88, 166, 255, 0.25);
}
.brand-name {
font-weight: 700;
font-size: 14px;
letter-spacing: 0.3px;
color: var(--text-primary);
}
.brand-desc {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.nav {
flex: 1;
padding: 12px 10px;
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: rgba(255,255,255,0.65);
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
transition: background .15s ease, color .15s ease;
font-weight: 500;
transition: all .15s ease;
}
.nav-item:hover {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.9);
background: var(--card-hover);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(59, 130, 246, 0.18);
color: #60a5fa;
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
}
.nav-item .el-icon {
font-size: 16px;
font-size: 18px;
}
.sidebar-footer {
padding: 12px 10px 16px;
border-top: 1px solid rgba(255,255,255,0.06);
padding: 12px 12px 16px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all .15s ease;
border: 1px solid transparent;
}
.theme-toggle:hover {
background: var(--card-hover);
border-color: var(--border);
color: var(--text-secondary);
}
.theme-icon {
font-size: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
color: rgba(255,255,255,0.55);
gap: 10px;
padding: 8px 12px;
border-radius: var(--radius-sm);
background: var(--card-hover);
border: 1px solid var(--border);
}
.user-icon {
font-size: 14px;
font-size: 18px;
color: var(--accent);
}
.user-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-name {
font-weight: 500;
color: rgba(255,255,255,0.85);
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
}
.logout-btn {
.user-role {
font-size: 11px;
color: var(--text-muted);
}
.footer-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.footer-btn {
justify-content: flex-start;
width: 100%;
color: rgba(255,255,255,0.55);
padding: 8px 10px;
border-radius: 8px;
}
.logout-btn:hover {
color: #fff;
background: rgba(255,255,255,0.06);
}
.logout-btn span {
margin-left: 8px;
color: var(--text-muted);
padding: 6px 10px;
border-radius: var(--radius-sm);
font-size: 12px;
}
.footer-btn:hover {
color: var(--text-secondary);
background: var(--card-hover);
}
.footer-btn .el-icon {
font-size: 14px;
margin-right: 6px;
}
.main {
flex: 1;
margin-left: 220px;
margin-left: 240px;
min-height: 100vh;
background: var(--bg);
background: var(--page-bg);
transition: background .25s ease;
}
.main-inner {
padding: 10px;
}
.theme-toggle {
margin: 0 10px 10px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: rgba(255,255,255,0.55);
font-size: 13px;
cursor: pointer;
transition: background .15s ease, color .15s ease;
}
.theme-toggle:hover {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.9);
}
.theme-icon {
font-size: 16px;
}
.main.no-sidebar {
margin-left: 0;
}
@media (max-width: 768px) {
.sidebar { width: 64px; }
.brand-text, .nav-item span, .user-name, .logout-btn span { display: none; }
.brand-text, .nav-item span, .user-meta, .footer-btn span, .theme-toggle span { display: none; }
.brand { justify-content: center; padding: 18px 10px; }
.nav-item { justify-content: center; }
.nav-item { justify-content: center; padding: 12px; }
.user-info { justify-content: center; padding: 10px; }
.theme-toggle { justify-content: center; padding: 10px; }
.main { margin-left: 64px; }
.main.no-sidebar { margin-left: 0; }
}

View File

@@ -8,6 +8,7 @@ import router from './router'
const app = createApp(App)
// 全局注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}

View File

@@ -12,7 +12,13 @@ const routes = [
path: '/',
component: () => import('@/components/MainLayout.vue'),
children: [
{ path: '', redirect: '/machines' },
{ path: '', redirect: '/dashboard' },
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { public: true },
},
{
path: 'machines',
name: 'MachineList',
@@ -60,7 +66,6 @@ router.beforeEach(async (to, from, next) => {
authChecked = true
}
// 访客禁止进入机器详情页
if (to.name === 'MachineDetail' && !authState.is_admin) {
return next('/machines')
}

362
web/src/views/Dashboard.vue Normal file
View File

@@ -0,0 +1,362 @@
<template>
<div class="page">
<div class="page-header">
<div>
<div class="page-title">
<el-icon><Odometer /></el-icon>
仪表盘
</div>
<div class="page-subtitle">局域网资产全局概览</div>
</div>
<div class="header-actions">
<el-button text :icon="Refresh" @click="load" :loading="loading">刷新</el-button>
</div>
</div>
<!-- Stat Cards -->
<div class="stat-grid">
<div class="stat-card">
<div class="stat-icon"><el-icon><Monitor /></el-icon></div>
<div class="stat-body">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总机器数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success"><el-icon><CircleCheck /></el-icon></div>
<div class="stat-body">
<div class="stat-value">{{ stats.online }}</div>
<div class="stat-label">在线机器</div>
<div class="stat-trend up" v-if="stats.total">
<el-icon><Top /></el-icon>
{{ ((stats.online / stats.total) * 100).toFixed(1) }}%
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon danger"><el-icon><CircleClose /></el-icon></div>
<div class="stat-body">
<div class="stat-value">{{ stats.offline }}</div>
<div class="stat-label">离线机器</div>
<div class="stat-trend down" v-if="stats.total">
<el-icon><Bottom /></el-icon>
{{ ((stats.offline / stats.total) * 100).toFixed(1) }}%
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon warning"><el-icon><Service /></el-icon></div>
<div class="stat-body">
<div class="stat-value">{{ stats.services }}</div>
<div class="stat-label">服务总数</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="detail-grid">
<!-- OS Distribution -->
<div class="card">
<div class="card-title">
<el-icon><PieChart /></el-icon>
系统分布
</div>
<div class="os-chart">
<div v-for="(item, idx) in osDistribution" :key="idx" class="os-bar-item">
<div class="os-bar-header">
<span class="os-name">
<el-icon :size="14">
<component :is="osIcon(item.os)" />
</el-icon>
{{ item.os }}
</span>
<span class="os-count">{{ item.count }} </span>
</div>
<div class="os-bar-track">
<div class="os-bar-fill" :style="{ width: item.percent + '%', background: osColor(item.os) }"></div>
</div>
</div>
</div>
</div>
<!-- Quick Status -->
<div class="card">
<div class="card-title">
<el-icon><Lightning /></el-icon>
快速状态
</div>
<div class="quick-status">
<div class="status-row">
<div class="status-item">
<el-icon class="status-icon success"><Cpu /></el-icon>
<div class="status-info">
<div class="status-num">{{ stats.pveHosts }}</div>
<div class="status-desc">PVE 主机</div>
</div>
</div>
<div class="status-item">
<el-icon class="status-icon accent"><VideoPlay /></el-icon>
<div class="status-info">
<div class="status-num">{{ stats.pveRunning }}</div>
<div class="status-desc">运行中 VM</div>
</div>
</div>
</div>
<div class="status-divider"></div>
<div class="status-row">
<div class="status-item">
<el-icon class="status-icon warning"><Link /></el-icon>
<div class="status-info">
<div class="status-num">{{ stats.relations }}</div>
<div class="status-desc">关联关系</div>
</div>
</div>
<div class="status-item">
<el-icon class="status-icon danger"><Timer /></el-icon>
<div class="status-info">
<div class="status-num">{{ stats.offlineEvents }}</div>
<div class="status-desc">离线事件</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Machines -->
<div class="card">
<div class="card-title">
<el-icon><List /></el-icon>
最近状态变化
<el-button text size="small" @click="$router.push('/machines')">
查看全部 <el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<div class="recent-list">
<div v-for="m in recentMachines" :key="m.id" class="recent-item" @click="isAdmin && $router.push(`/machines/${m.id}`)">
<div class="recent-left">
<el-icon class="recent-os" :size="16" :color="osColor(m.os_type)">
<component :is="osIcon(m.os_type)" />
</el-icon>
<div class="recent-info">
<div class="recent-name">{{ m.hostname }}</div>
<div class="recent-meta">{{ m.ip }} · {{ m.os_type }} {{ m.os_version || '' }}</div>
</div>
</div>
<div class="recent-right">
<el-tag size="small" :type="m.is_online ? 'success' : 'danger'" effect="light" round>
<el-icon :size="12"><component :is="m.is_online ? CircleCheck : CircleClose" /></el-icon>
{{ m.is_online ? '在线' : '离线' }}
</el-tag>
<span class="recent-time" v-if="m.ssh_synced_at">{{ formatTime(m.ssh_synced_at) }}</span>
</div>
</div>
</div>
<el-empty v-if="!recentMachines.length" description="暂无数据" :image-size="80" />
</div>
<!-- PVE Overview -->
<div class="card" v-if="pveMachines.length">
<div class="card-title">
<el-icon><Grid /></el-icon>
PVE 虚拟机概览
</div>
<div class="pve-grid">
<div v-for="m in pveMachines" :key="m.id" class="pve-card" :class="m.pve_vm_status">
<div class="pve-header">
<el-icon class="pve-icon" :size="18"><Platform /></el-icon>
<span class="pve-name">{{ m.hostname }}</span>
<el-tag size="small" :type="m.pve_vm_status === 'running' ? 'success' : 'danger'" effect="light" round>
{{ m.pve_vm_status === 'running' ? '运行中' : '已停止' }}
</el-tag>
</div>
<div class="pve-meta">VM ID: {{ m.pve_vmid }} · PVE Host: {{ m.pve_host_name || m.pve_host_id }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import {
Odometer, Monitor, CircleCheck, CircleClose, Top, Bottom,
PieChart, Lightning, Cpu, VideoPlay, Link, Timer, List,
ArrowRight, Refresh, Grid, Platform, Service
} from '@element-plus/icons-vue'
import { fetchMachines, fetchAllServices, fetchRelationships, fetchPVEHosts, checkAuth } from '@/api'
import { getAuth } from '@/router'
const isAdmin = getAuth().is_admin
const loading = ref(false)
const machines = ref([])
const services = ref([])
const relationships = ref([])
const pveHosts = ref([])
const stats = computed(() => {
const total = machines.value.length
const online = machines.value.filter(m => m.is_online).length
const offline = total - online
const pveHostsCount = pveHosts.value.length
const pveRunning = machines.value.filter(m => m.pve_vm_status === 'running').length
const offlineEvents = machines.value.reduce((sum, m) => sum + (m.offline_count || 0), 0)
return {
total,
online,
offline,
services: services.value.length,
pveHosts: pveHostsCount,
pveRunning,
relations: relationships.value.length,
offlineEvents,
}
})
const osDistribution = computed(() => {
const map = {}
machines.value.forEach(m => {
map[m.os_type] = (map[m.os_type] || 0) + 1
})
const total = machines.value.length
return Object.entries(map)
.map(([os, count]) => ({ os, count, percent: total ? (count / total) * 100 : 0 }))
.sort((a, b) => b.count - a.count)
})
const recentMachines = computed(() => {
return [...machines.value]
.sort((a, b) => {
const tA = a.ssh_synced_at ? new Date(a.ssh_synced_at).getTime() : 0
const tB = b.ssh_synced_at ? new Date(b.ssh_synced_at).getTime() : 0
return tB - tA
})
.slice(0, 8)
})
const pveMachines = computed(() => {
return machines.value.filter(m => m.pve_host_id && m.pve_vmid)
})
onMounted(async () => {
await load()
await checkAuth()
})
async function load() {
loading.value = true
try {
const [mRes, sRes, rRes, pRes] = await Promise.all([
fetchMachines(),
fetchAllServices(),
fetchRelationships(),
fetchPVEHosts().catch(() => ({ data: [] })),
])
machines.value = mRes.data
services.value = sRes.data
relationships.value = rRes.data
pveHosts.value = pRes.data
} catch (e) {}
loading.value = false
}
function osColor(os) {
const map = { Linux: '#58a6ff', Windows: '#3fb950', macOS: '#a371f7', Other: '#8b949e' }
return map[os] || '#8b949e'
}
function osIcon(os) {
const map = { Linux: 'Platform', Windows: 'Monitor', macOS: 'Apple', Other: 'QuestionFilled' }
return map[os] || 'Monitor'
}
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())}`
}
</script>
<style scoped>
.header-actions { display: flex; gap: 10px; }
.os-chart {
display: flex;
flex-direction: column;
gap: 14px;
}
.os-bar-item { display: flex; flex-direction: column; gap: 6px; }
.os-bar-header {
display: flex; justify-content: space-between; align-items: center; font-size: 13px;
}
.os-name {
display: flex; align-items: center; gap: 6px;
font-weight: 600; color: var(--text-primary);
}
.os-count { color: var(--text-muted); font-weight: 500; }
.os-bar-track {
height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
}
.os-bar-fill {
height: 100%; border-radius: 3px; transition: width 0.6s ease;
}
.quick-status { display: flex; flex-direction: column; gap: 16px; }
.status-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.status-item {
display: flex; align-items: center; gap: 12px;
padding: 14px; background: var(--card-hover);
border-radius: var(--radius-sm); border: 1px solid var(--border);
transition: background .2s ease;
}
.status-item:hover { background: var(--border); }
.status-icon {
width: 36px; height: 36px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex-shrink: 0;
}
.status-icon.success { background: var(--success-weak); color: var(--success); }
.status-icon.accent { background: var(--accent-weak); color: var(--accent); }
.status-icon.warning { background: var(--warning-weak); color: var(--warning); }
.status-icon.danger { background: var(--danger-weak); color: var(--danger); }
.status-info { display: flex; flex-direction: column; gap: 2px; }
.status-num { font-size: 20px; font-weight: 700; color: var(--text-primary); }
.status-desc { font-size: 12px; color: var(--text-muted); }
.status-divider { height: 1px; background: var(--border); margin: 0 4px; }
.recent-list { display: flex; flex-direction: column; gap: 8px; }
.recent-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; background: var(--card-hover);
border-radius: var(--radius-sm); border: 1px solid var(--border);
cursor: pointer; transition: all .15s ease;
}
.recent-item:hover {
background: var(--border); border-color: var(--border-strong);
transform: translateX(2px);
}
.recent-left { display: flex; align-items: center; gap: 12px; }
.recent-os { flex-shrink: 0; }
.recent-info { display: flex; flex-direction: column; gap: 2px; }
.recent-name { font-weight: 600; font-size: 14px; color: var(--text-primary); }
.recent-meta { font-size: 12px; color: var(--text-muted); }
.recent-right {
display: flex; align-items: center; gap: 12px;
}
.recent-time { font-size: 12px; color: var(--text-muted); }
.pve-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
@media (max-width: 900px) { .pve-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 480px) { .pve-grid { grid-template-columns: 1fr; } }
.pve-card {
padding: 14px; background: var(--card-hover); border-radius: var(--radius-sm);
border: 1px solid var(--border); transition: all .15s ease;
}
.pve-card:hover { border-color: var(--border-strong); }
.pve-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
}
.pve-icon { color: var(--accent); }
.pve-name { font-weight: 600; font-size: 14px; color: var(--text-primary); flex: 1; }
.pve-meta { font-size: 12px; color: var(--text-muted); }
</style>

View File

@@ -2,7 +2,9 @@
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="logo">LM</div>
<div class="logo">
<img src="/logo.svg" alt="LAN Manager" class="logo-img" />
</div>
<h1>LAN Manager</h1>
<p>轻量级局域网资产管理平台</p>
</div>
@@ -10,23 +12,27 @@
<el-form-item>
<div class="input-wrap">
<el-icon class="input-icon"><User /></el-icon>
<el-input v-model="form.username" placeholder="用户名" size="large" @keydown.enter="login" />
<el-input v-model="form.username" placeholder="用户名" size="large" @keydown.enter="login">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</div>
</el-form-item>
<el-form-item>
<div class="input-wrap">
<el-icon class="input-icon"><Lock /></el-icon>
<el-input v-model="form.password" type="password" show-password placeholder="密码" size="large" @keydown.enter="login" />
<el-input v-model="form.password" type="password" show-password placeholder="密码" size="large" @keydown.enter="login">
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</div>
</el-form-item>
<el-button type="primary" size="large" class="login-btn" :loading="loading" @click="login">登录</el-button>
<el-button type="primary" size="large" class="login-btn" :loading="loading" :icon="Connection" @click="login">登录</el-button>
<div class="login-guest">
<el-button text size="small" @click="guestLogin">访客模式进入</el-button>
<el-button text size="small" :icon="View" @click="guestLogin">访客模式进入</el-button>
</div>
</el-form>
</div>
<div class="login-footer">
© LAN Manager
<el-icon><Monitor /></el-icon> LAN Manager · 局域网资产管理
</div>
</div>
</template>
@@ -34,7 +40,7 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock } from '@element-plus/icons-vue'
import { User, Lock, Connection, View } from '@element-plus/icons-vue'
import { login as apiLogin, guestLogin as apiGuest } from '@/api'
import { setAuth } from '@/router'
import { ElMessage } from 'element-plus'
@@ -73,7 +79,7 @@ async function guestLogin() {
<style scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
background: linear-gradient(135deg, #0d1117 0%, #161b22 50%, #0d1117 100%);
display: flex;
flex-direction: column;
align-items: center;
@@ -84,12 +90,12 @@ async function guestLogin() {
.login-card {
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 36px;
backdrop-filter: blur(10px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
box-shadow: var(--shadow-lg);
transition: all .25s ease;
}
.login-header {
@@ -100,65 +106,62 @@ async function guestLogin() {
width: 56px;
height: 56px;
border-radius: 14px;
background: linear-gradient(135deg, #3b82f6, #60a5fa);
color: #fff;
font-weight: 800;
font-size: 22px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 14px;
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.35);
box-shadow: 0 10px 25px rgba(88, 166, 255, 0.25);
}
.logo-img {
width: 36px;
height: 36px;
object-fit: contain;
}
.login-header h1 {
color: #fff;
color: var(--text-primary);
font-size: 22px;
font-weight: 700;
margin: 0 0 6px;
}
.login-header p {
color: rgba(255, 255, 255, 0.55);
color: var(--text-muted);
font-size: 13px;
margin: 0;
}
.login-form {
width: 100%;
}
.login-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.login-form :deep(.el-form-item__content) {
display: block;
width: 100%;
}
.login-form { width: 100%; }
.login-form :deep(.el-form-item) { margin-bottom: 16px; }
.login-form :deep(.el-form-item__content) { display: block; width: 100%; }
.input-wrap {
position: relative;
width: 100%;
}
.input-wrap :deep(.el-input) {
width: 100%;
}
.input-wrap :deep(.el-input) { width: 100%; }
.input-wrap :deep(.el-input__wrapper) {
padding-left: 38px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
background: var(--card-hover);
box-shadow: 0 0 0 1px var(--border) inset;
transition: box-shadow .15s ease;
}
.input-wrap :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px var(--accent) inset;
}
.input-wrap :deep(.el-input__inner) {
color: #fff;
color: var(--text-primary);
font-size: 14px;
}
.input-wrap :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.45);
color: var(--text-muted);
}
.input-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.5);
color: var(--text-muted);
font-size: 16px;
z-index: 1;
}
@@ -175,15 +178,18 @@ async function guestLogin() {
margin-top: 14px;
}
.login-guest :deep(.el-button) {
color: rgba(255, 255, 255, 0.55);
color: var(--text-muted);
}
.login-guest :deep(.el-button:hover) {
color: #fff;
color: var(--text-secondary);
}
.login-footer {
margin-top: 24px;
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
color: var(--text-muted);
display: inline-flex;
align-items: center;
gap: 6px;
}
</style>

View File

@@ -2,29 +2,48 @@
<div class="page">
<div class="page-header">
<div>
<div class="page-title">操作日志</div>
<div class="page-title">
<el-icon><Document /></el-icon>
操作日志
</div>
<div class="page-subtitle">查看最近的系统操作记录</div>
</div>
<div class="header-actions">
<el-icon class="search-icon"><Search /></el-icon>
<el-input v-model="search" placeholder="搜索操作内容" clearable style="width: 240px" />
<el-button type="primary" @click="load">查询</el-button>
<el-button type="primary" :icon="Search" @click="load">查询</el-button>
</div>
</div>
<div class="card">
<div class="table-header"> {{ total }} 条记录</div>
<div class="table-header">
<el-icon><List /></el-icon> {{ total }} 条记录
</div>
<el-table :data="logs" stripe style="width: 100%" v-loading="loading" class="modern-table">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="action" label="操作" width="140" />
<el-table-column prop="action" label="操作" width="140">
<template #default="{ row }">
<el-tag size="small" :type="actionType(row.action)" effect="light" round>
<el-icon :size="10"><component :is="actionIcon(row.action)" /></el-icon>
{{ row.action }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_type" label="对象" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain" round>{{ row.target_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_name" label="对象名称" min-width="160" />
<el-table-column prop="target_name" label="对象名称" min-width="160">
<template #default="{ row }">
<el-icon :size="12" color="var(--accent)"><Monitor /></el-icon>
{{ row.target_name }}
</template>
</el-table-column>
<el-table-column prop="details" label="详情" min-width="240" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="170">
<template #default="{ row }">
<el-icon :size="10"><Clock /></el-icon>
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
@@ -46,6 +65,9 @@
<script setup>
import { ref, onMounted } from 'vue'
import {
Document, Search, List, Clock, Monitor, Plus, Delete, Edit, Connection, Warning
} from '@element-plus/icons-vue'
import { fetchLogs } from '@/api'
const logs = ref([])
@@ -65,6 +87,20 @@ async function load() {
loading.value = false
}
function actionType(action) {
if (action.includes('删除') || action.includes('停止')) return 'danger'
if (action.includes('创建') || action.includes('添加') || action.includes('启动')) return 'success'
if (action.includes('更新') || action.includes('编辑') || action.includes('修改')) return 'warning'
return 'info'
}
function actionIcon(action) {
if (action.includes('删除')) return 'Delete'
if (action.includes('创建') || action.includes('添加')) return 'Plus'
if (action.includes('更新') || action.includes('编辑') || action.includes('修改')) return 'Edit'
if (action.includes('登录')) return 'Connection'
if (action.includes('导入') || action.includes('导出')) return 'Document'
return 'Warning'
}
function formatTime(t) {
if (!t) return '-'
const d = new Date(t)
@@ -75,34 +111,24 @@ function formatTime(t) {
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-size: 18px;
font-weight: 700;
}
.page-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.search-icon {
color: var(--text-muted);
font-size: 16px;
margin-right: -4px;
}
.table-header {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.pagination-bar {
display: flex;
justify-content: flex-end;

View File

@@ -1,6 +1,5 @@
<template>
<div class="page" v-if="machine">
<!-- Header -->
<div class="detail-header">
<div class="header-left">
<el-button text circle @click="$router.back()">
@@ -8,24 +7,27 @@
</el-button>
<div>
<div class="host-title">
<el-icon class="host-icon" :size="22"><Platform /></el-icon>
{{ machine.hostname }}
<span class="status-badge" :class="machine.is_online ? 'online' : 'offline'">
<el-tag :type="machine.is_online ? 'success' : 'danger'" size="small" effect="light" round class="status-tag">
<el-icon :size="10"><component :is="machine.is_online ? CircleCheck : CircleClose" /></el-icon>
{{ machine.is_online ? '在线' : '离线' }}
</span>
</el-tag>
</div>
<div class="host-subtitle">
<span v-if="isAdmin">{{ machine.ip }}</span>
<span>{{ machine.os_type }}</span>
<span v-if="isAdmin"><el-icon :size="12"><Link /></el-icon> {{ machine.ip }}</span>
<span><el-icon :size="12"><Cpu /></el-icon> {{ machine.os_type }}</span>
<span v-if="machine.os_version">{{ machine.os_version }}</span>
</div>
</div>
</div>
<div class="header-actions" v-if="isAdmin">
<el-tag v-if="machine.pve_host_id && machine.pve_vmid" size="small" :type="vmStatusInfo._error ? 'info' : vmStatusInfo.status === 'running' ? 'success' : vmStatusInfo.status === 'stopped' ? 'danger' : 'info'" effect="light" class="vm-status-tag">
{{ vmStatusInfo._error ? 'VM检测失败' : vmStatusInfo.status === 'running' ? 'VM运行中' : vmStatusInfo.status === 'stopped' ? 'VM已停止' : 'VM检测中' }}
<el-tag v-if="machine.pve_host_id && machine.pve_vmid" size="small" :type="vmStatusInfo._error ? 'info' : vmStatusInfo.status === 'running' ? 'success' : 'danger'" effect="light" class="vm-status-tag" round>
<el-icon :size="10"><component :is="vmStatusInfo.status === 'running' ? VideoPlay : VideoPause" /></el-icon>
{{ vmStatusInfo._error ? 'VM检测失败' : vmStatusInfo.status === 'running' ? 'VM运行中' : 'VM已停止' }}
</el-tag>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="success" :icon="VideoPlay" :loading="vmLoading" @click="startVM" :disabled="vmStatusInfo.status === 'running' || vmStatusInfo._error">启动</el-button>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="warning" :icon="VideoPause" :loading="vmLoading" @click="stopVM" :disabled="vmStatusInfo.status === 'stopped' || vmStatusInfo._error">关闭</el-button>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="success" :icon="VideoPlay" :loading="vmLoading" @click="startVM" :disabled="vmStatusInfo.status === 'running' || vmStatusInfo._error" size="small">启动</el-button>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="warning" :icon="VideoPause" :loading="vmLoading" @click="stopVM" :disabled="vmStatusInfo.status === 'stopped' || vmStatusInfo._error" size="small">关闭</el-button>
<el-button text :icon="Edit" @click="openEdit(machine)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="delMachine">删除</el-button>
</div>
@@ -34,23 +36,30 @@
<div class="detail-grid">
<!-- Basic Info -->
<div class="card">
<div class="card-title">基本信息</div>
<div class="card-title">
<el-icon><Document /></el-icon> 基本信息
</div>
<div class="info-list">
<div class="info-item"><span class="info-label">IP</span><span class="info-value">{{ machine.ip }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label">MAC</span><span class="info-value">{{ machine.mac || '-' }}</span></div>
<div class="info-item"><span class="info-label">系统</span><span class="info-value">{{ machine.os_type }} {{ machine.os_version || '' }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label">SSH 端口</span><span class="info-value">{{ machine.ssh_port || 22 }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label">备注</span><span class="info-value text-muted">{{ machine.notes || '-' }}</span></div>
<div class="info-item"><span class="info-label"><el-icon><Link /></el-icon> IP</span><span class="info-value">{{ machine.ip }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label"><el-icon><Connection /></el-icon> MAC</span><span class="info-value">{{ machine.mac || '-' }}</span></div>
<div class="info-item"><span class="info-label"><el-icon><Cpu /></el-icon> 系统</span><span class="info-value">{{ machine.os_type }} {{ machine.os_version || '' }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label"><el-icon><Lock /></el-icon> SSH 端口</span><span class="info-value">{{ machine.ssh_port || 22 }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label"><el-icon><EditPen /></el-icon> 备注</span><span class="info-value text-muted">{{ machine.notes || '-' }}</span></div>
</div>
</div>
<!-- PVE VM Status -->
<div class="card" v-if="machine.pve_host_id && machine.pve_vmid && vmStatusInfo.status">
<div class="card-title">PVE 虚拟机状态</div>
<div class="card-title">
<el-icon><Grid /></el-icon> PVE 虚拟机状态
</div>
<div class="vm-status-grid">
<div class="vm-stat-item">
<span class="vm-stat-label">运行状态</span>
<span class="vm-stat-value" :class="vmStatusInfo.status">{{ vmStatusInfo.status === 'running' ? '运行中' : '已停止' }}</span>
<span class="vm-stat-value" :class="vmStatusInfo.status">
<el-icon :size="14"><component :is="vmStatusInfo.status === 'running' ? VideoPlay : VideoPause" /></el-icon>
{{ vmStatusInfo.status === 'running' ? '运行中' : '已停止' }}
</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.cpu !== undefined">
<span class="vm-stat-label">CPU 使用率</span>
@@ -69,10 +78,12 @@
<!-- System Status -->
<div class="card" v-if="machine.cpu_info || machine.memory_info || machine.disk_info">
<div class="card-title">系统状态</div>
<div class="card-title">
<el-icon><Odometer /></el-icon> 系统状态
</div>
<div class="status-pills">
<div v-if="machine.cpu_info" class="status-pill">
<div class="sp-icon cpu">CPU</div>
<div class="sp-icon cpu"><el-icon :size="16"><Cpu /></el-icon></div>
<div class="sp-info">
<div class="sp-title">{{ machine.cpu_info }}</div>
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.cpu_info) + '%' }"></div></div>
@@ -80,16 +91,16 @@
<div class="sp-num">{{ extractPercent(machine.cpu_info) }}%</div>
</div>
<div v-if="machine.memory_info" class="status-pill">
<div class="sp-icon mem">MEM</div>
<div class="sp-icon mem"><el-icon :size="16"><Memory /></el-icon></div>
<div class="sp-info">
<div class="sp-title">{{ machine.memory_info }}</div>
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.memory_info) + '%', background: getMemBarColor(extractPercent(machine.memory_info)) }"></div></div>
</div>
<div class="sp-num">{{ extractPercent(machine.memory_info) }}%</div>
</div>
<div v-if="machine.disk_info" class="status-pill disk-multi">
<div v-if="machine.disk_info" class="status-pill" :class="{ 'disk-multi': parseDisks(machine.disk_info).length > 1 }">
<template v-if="parseDisks(machine.disk_info).length <= 1">
<div class="sp-icon disk">DISK</div>
<div class="sp-icon disk"><el-icon :size="16"><Histogram /></el-icon></div>
<div class="sp-info">
<div class="sp-title">{{ machine.disk_info }}</div>
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.disk_info) + '%', background: getDiskBarColor(extractPercent(machine.disk_info)) }"></div></div>
@@ -97,13 +108,11 @@
<div class="sp-num">{{ extractPercent(machine.disk_info) }}%</div>
</template>
<template v-else>
<div class="sp-icon disk">DISK</div>
<div class="sp-icon disk"><el-icon :size="16"><Histogram /></el-icon></div>
<div class="sp-info multi-disk-info">
<div v-for="(d, idx) in parseDisks(machine.disk_info)" :key="idx" class="detail-disk-item">
<span class="dd-mount">{{ d.mount }}</span>
<div class="dd-bar-wrap">
<div class="dd-bar"><div class="dd-fill" :style="{ width: d.percent + '%', background: getDiskBarColor(d.percent) }"></div></div>
</div>
<div class="dd-bar-wrap"><div class="dd-bar"><div class="dd-fill" :style="{ width: d.percent + '%', background: getDiskBarColor(d.percent) }"></div></div></div>
<span class="dd-val">{{ d.detail }}</span>
<span class="dd-pct">{{ d.percent }}%</span>
</div>
@@ -119,22 +128,26 @@
<!-- SSH Section -->
<div class="card" v-if="isAdmin">
<div class="card-title">SSH 系统信息</div>
<div class="card-title">
<el-icon><SetUp /></el-icon> SSH 系统信息
</div>
<div v-if="machine.cpu_info || machine.memory_info || machine.disk_info || machine.uptime" class="sync-actions">
<el-tag size="small" effect="plain" round>上次同步 {{ formatTime(machine.ssh_synced_at) }}</el-tag>
<el-tag size="small" effect="plain" round>
<el-icon :size="10"><Clock /></el-icon> 上次同步 {{ formatTime(machine.ssh_synced_at) }}
</el-tag>
</div>
<div class="ssh-actions">
<el-button type="primary" :icon="Refresh" @click="openSSH">获取系统信息</el-button>
</div>
<div v-if="sshResult" class="ssh-result">
<div class="result-grid">
<div class="result-item"><span class="rl">主机名</span><span class="rv">{{ sshResult.hostname }}</span></div>
<div class="result-item"><span class="rl">系统版本</span><span class="rv">{{ sshResult.os_version }}</span></div>
<div class="result-item"><span class="rl">CPU</span><span class="rv">{{ sshResult.cpu }}</span></div>
<div class="result-item"><span class="rl">内存</span><span class="rv">{{ sshResult.memory }}</span></div>
<div class="result-item"><span class="rl">磁盘</span><span class="rv">{{ sshResult.disk }}</span></div>
<div class="result-item"><span class="rl">运行时间</span><span class="rv">{{ sshResult.uptime }}</span></div>
<div class="result-item full"><span class="rl">监听端口</span><span class="rv">{{ sshResult.listen_ports?.join(', ') }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Monitor /></el-icon> 主机名</span><span class="rv">{{ sshResult.hostname }}</span></div>
<div class="result-item"><span class="rl"><el-icon><InfoFilled /></el-icon> 系统版本</span><span class="rv">{{ sshResult.os_version }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Cpu /></el-icon> CPU</span><span class="rv">{{ sshResult.cpu }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Memory /></el-icon> 内存</span><span class="rv">{{ sshResult.memory }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Histogram /></el-icon> 磁盘</span><span class="rv">{{ sshResult.disk }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Timer /></el-icon> 运行时间</span><span class="rv">{{ sshResult.uptime }}</span></div>
<div class="result-item full"><span class="rl"><el-icon><Connection /></el-icon> 监听端口</span><span class="rv">{{ sshResult.listen_ports?.join(', ') }}</span></div>
</div>
</div>
</div>
@@ -142,13 +155,16 @@
<!-- Services -->
<div class="card">
<div class="card-title">
服务列表
<el-icon><Service /></el-icon> 服务列表
<el-button v-if="isAdmin" size="small" type="primary" :icon="Plus" @click="openServiceEdit()">添加服务</el-button>
</div>
<div v-if="services.length" class="service-list">
<div v-for="s in services" :key="s.id" class="service-row">
<div class="service-main">
<div class="service-name">{{ s.name }}</div>
<div class="service-name">
<el-icon :size="14" color="var(--accent)"><Connection /></el-icon>
{{ s.name }}
</div>
<div class="service-port">{{ s.port }} / {{ s.protocol }}</div>
</div>
<div class="service-target" v-if="isAdmin && s.target_machine_id">
@@ -166,9 +182,11 @@
<el-empty v-else description="暂无服务" :image-size="80" />
</div>
<!-- Offline Stats & Logs -->
<!-- Offline Stats -->
<div class="card" v-if="isAdmin">
<div class="card-title">离线统计</div>
<div class="card-title">
<el-icon><Warning /></el-icon> 离线统计
</div>
<div class="offline-stats">
<div class="stat-box">
<div class="stat-num">{{ machine.offline_count || 0 }}</div>
@@ -184,7 +202,9 @@
</div>
</div>
<div v-if="offlineLogs.length" class="offline-logs">
<div class="log-title">最近离线记录</div>
<div class="log-title">
<el-icon><List /></el-icon> 最近离线记录
</div>
<div v-for="log in offlineLogs.slice(0, 5)" :key="log.id" class="log-row">
<span class="log-time">{{ formatTime(log.started_at) }}</span>
<span class="log-reason">{{ log.reason || '未知原因' }}</span>
@@ -197,14 +217,14 @@
<!-- Relationships -->
<div class="card" v-if="isAdmin">
<div class="card-title">
关联关系
<el-icon><Link /></el-icon> 关联关系
<el-button size="small" type="primary" :icon="Plus" @click="openRelEdit()">添加关系</el-button>
</div>
<div v-if="relationships.length" class="rel-list">
<div v-for="r in relationships" :key="r.id" class="rel-row">
<div class="rel-arrow">
<span class="rel-host">{{ r.source_hostname }}</span>
<el-icon><Right /></el-icon>
<el-icon color="var(--accent)"><Right /></el-icon>
<span class="rel-host">{{ r.target_hostname }}</span>
</div>
<div class="rel-meta">
@@ -226,15 +246,19 @@
<el-dialog v-model="sshDialogVisible" title="SSH 获取系统信息" width="360px" class="modern-dialog">
<el-form :model="sshForm" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="sshForm.username" />
<el-input v-model="sshForm.username">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="sshForm.password" type="password" show-password />
<el-input v-model="sshForm.password" type="password" show-password>
<template #prefix><el-icon><Key /></el-icon></template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sshDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="sshLoading" @click="fetchSSH">连接</el-button>
<el-button type="primary" :loading="sshLoading" :icon="Connection" @click="fetchSSH">连接</el-button>
</template>
</el-dialog>
@@ -249,27 +273,25 @@
<el-form-item label="SSH密码"><el-input v-model="editing.ssh_password" type="password" show-password placeholder="输入则更新密码,留空保持不变" /></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-option label="Windows" value="Windows" />
<el-option label="macOS" value="macOS" />
<el-option label="Other" value="Other" />
<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" /></el-form-item>
<el-form-item label="备注"><el-input v-model="editing.notes" type="textarea" :rows="2" /></el-form-item>
<el-divider content-position="left">PVE 配置可选</el-divider>
<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" />
</el-form-item>
<el-form-item label="虚拟机 ID"><el-input v-model="editing.pve_vmid" placeholder="如 101" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveMachine">保存</el-button>
<el-button type="primary" :icon="Check" @click="saveMachine">保存</el-button>
</template>
</el-dialog>
@@ -291,14 +313,12 @@
<el-option v-for="m in allMachines.filter(x => x.id != machineId)" :key="m.id" :label="m.hostname" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="指向备注">
<el-input v-model="serviceEditing.target_notes" placeholder="如:反向代理到目标" />
</el-form-item>
<el-form-item label="指向备注"><el-input v-model="serviceEditing.target_notes" placeholder="如:反向代理到目标" /></el-form-item>
<el-form-item label="备注"><el-input v-model="serviceEditing.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="serviceDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveService">保存</el-button>
<el-button type="primary" :icon="Check" @click="saveService">保存</el-button>
</template>
</el-dialog>
@@ -329,7 +349,7 @@
</el-form>
<template #footer>
<el-button @click="relDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveRel">保存</el-button>
<el-button type="primary" :icon="Check" @click="saveRel">保存</el-button>
</template>
</el-dialog>
</div>
@@ -338,7 +358,12 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Edit, Delete, Plus, Refresh, Right, Timer, VideoPlay, VideoPause } from '@element-plus/icons-vue'
import {
ArrowLeft, Edit, Delete, Plus, Refresh, Right, Timer, VideoPlay, VideoPause,
Document, Odometer, Grid, Platform, Link, Connection, Cpu, Collection, Histogram,
SetUp, Clock, Warning, List, Check, User, Key, CircleCheck, CircleClose,
Monitor, Apple, QuestionFilled, InfoFilled, EditPen, Service
} from '@element-plus/icons-vue'
import {
fetchMachine, updateMachine, deleteMachine,
fetchServices, createService, updateService, deleteService,
@@ -423,7 +448,6 @@ async function loadMachine() {
const logsRes = await fetchOfflineLogs(machineId)
offlineLogs.value = logsRes.data
}
// 如果已有同步过的 SSH 信息,直接展示上次结果
if (machine.value && (machine.value.cpu_info || machine.value.memory_info || machine.value.disk_info || machine.value.uptime)) {
sshResult.value = {
hostname: machine.value.hostname,
@@ -450,11 +474,7 @@ async function loadRelationships() {
allMachines.value.forEach(m => map[m.id] = m.hostname)
relationships.value = res.data
.filter(r => r.source_machine_id == machineId || r.target_machine_id == machineId)
.map(r => ({
...r,
source_hostname: map[r.source_machine_id] || r.source_machine_id,
target_hostname: map[r.target_machine_id] || r.target_machine_id
}))
.map(r => ({ ...r, source_hostname: map[r.source_machine_id] || r.source_machine_id, target_hostname: map[r.target_machine_id] || r.target_machine_id }))
}
function openEdit(item) {
@@ -463,13 +483,11 @@ function openEdit(item) {
}
async function openSSH() {
// 如果已保存用户名,先尝试用数据库里的凭据自动获取
if (machine.value.ssh_username) {
sshLoading.value = true
try {
const res = await sshInfo(machineId, { username: machine.value.ssh_username, password: '' })
sshResult.value = res.data
// 保护用户自定义的主机名,不将 SSH 采集到的 hostname 写入数据库
const syncData = { ...sshResult.value }
delete syncData.hostname
await syncSSH(machineId, syncData)
@@ -479,13 +497,9 @@ async function openSSH() {
return
} catch (e) {
sshLoading.value = false
// 自动获取失败,继续弹出对话框让用户手动输入
}
}
sshForm.value = {
username: machine.value.ssh_username || '',
password: ''
}
sshForm.value = { username: machine.value.ssh_username || '', password: '' }
sshDialogVisible.value = true
}
@@ -514,11 +528,7 @@ async function startVM() {
ElMessage.success('虚拟机已启动')
await loadVMStatus()
loadMachine()
} catch (e) {
if (e !== 'cancel') {
// error handled by interceptor
}
}
} catch (e) { if (e !== 'cancel') {} }
vmLoading.value = false
}
@@ -530,11 +540,7 @@ async function stopVM() {
ElMessage.success('虚拟机已关闭')
await loadVMStatus()
loadMachine()
} catch (e) {
if (e !== 'cancel') {
// error handled by interceptor
}
}
} catch (e) { if (e !== 'cancel') {} }
vmLoading.value = false
}
@@ -553,7 +559,6 @@ async function fetchSSH() {
const res = await sshInfo(machineId, sshForm.value)
sshResult.value = res.data
sshDialogVisible.value = false
// 保护用户自定义的主机名,不将 SSH 采集到的 hostname 写入数据库
const syncData = { ...sshResult.value }
delete syncData.hostname
await syncSSH(machineId, syncData)
@@ -564,19 +569,13 @@ async function fetchSSH() {
}
function openServiceEdit(row) {
serviceEditing.value = row
? { ...row, target_machine_id: row.target_machine_id || null, target_notes: row.target_notes || '' }
: { name: '', port: 80, protocol: 'TCP', notes: '', target_machine_id: null, target_notes: '' }
serviceEditing.value = row ? { ...row, target_machine_id: row.target_machine_id || null, target_notes: row.target_notes || '' } : { name: '', port: 80, protocol: 'TCP', notes: '', target_machine_id: null, target_notes: '' }
serviceDialogVisible.value = true
}
async function saveService() {
const data = { ...serviceEditing.value }
if (data.id) {
await updateService(data.id, data)
} else {
await createService(machineId, data)
}
if (data.id) { await updateService(data.id, data) } else { await createService(machineId, data) }
ElMessage.success('保存成功')
serviceDialogVisible.value = false
loadServices()
@@ -592,19 +591,13 @@ async function delService(row) {
}
function openRelEdit(row) {
relEditing.value = row
? { ...row }
: { source_machine_id: Number(machineId), target_machine_id: null, relation_type: 'dependency', source_port: null, target_port: null, notes: '' }
relEditing.value = row ? { ...row } : { source_machine_id: Number(machineId), target_machine_id: null, relation_type: 'dependency', source_port: null, target_port: null, notes: '' }
relDialogVisible.value = true
}
async function saveRel() {
const data = { ...relEditing.value }
if (data.id) {
await updateRelationship(data.id, data)
} else {
await createRelationship(data)
}
if (data.id) { await updateRelationship(data.id, data) } else { await createRelationship(data) }
ElMessage.success('保存成功')
relDialogVisible.value = false
loadRelationships()
@@ -635,14 +628,14 @@ function extractPercent(str) {
}
function getMemBarColor(v) {
if (v >= 90) return '#f87171'
if (v >= 70) return '#fbbf24'
return '#34d399'
if (v >= 90) return 'var(--danger)'
if (v >= 70) return 'var(--warning)'
return 'var(--success)'
}
function getDiskBarColor(v) {
if (v >= 90) return '#f87171'
if (v >= 80) return '#fbbf24'
return '#60a5fa'
if (v >= 90) return 'var(--danger)'
if (v >= 80) return 'var(--warning)'
return 'var(--accent)'
}
function parseDisks(str) {
@@ -653,15 +646,10 @@ function parseDisks(str) {
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
}
// fallback: single disk old format
if (disks.length > 0) return disks
const pct = extractPercent(str)
const det = extractDetail(str)
if (det) {
return [{ mount: '', detail: det, percent: pct }]
}
const det = str ? str.replace(/\s*\(\d+(?:\.\d+)?%\)\s*/g, '').trim().split(',')[0].trim() : ''
if (det) return [{ mount: '', detail: det, percent: pct }]
return []
}
@@ -680,7 +668,6 @@ function formatDuration(seconds) {
if (s < 3600) return `${Math.floor(s / 60)}${s % 60}`
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h < 24) return `${h}${m}`
const d = Math.floor(h / 24)
const hr = h % 24
@@ -715,6 +702,7 @@ function formatUptime(seconds) {
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
flex-wrap: wrap;
}
.header-left {
display: flex;
@@ -728,65 +716,24 @@ function formatUptime(seconds) {
font-size: 20px;
font-weight: 700;
}
.status-badge {
.host-icon { color: var(--accent); }
.status-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
height: 22px;
padding: 0 8px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-badge.online { background: rgba(34,197,94,0.12); color: #15803d; }
.status-badge.offline { background: rgba(239,68,68,0.10); color: #b91c1c; }
html.dark .status-badge.online { background: rgba(52,211,153,0.15); color: #34d399; }
html.dark .status-badge.offline { background: rgba(248,113,113,0.15); color: #f87171; }
.vm-status-tag {
font-size: 12px;
height: 24px;
margin-right: 4px;
}
.vm-status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vm-stat-item {
display: flex;
flex-direction: column;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 10px 12px;
background: var(--surface-hover);
border-radius: 8px;
}
.vm-stat-label {
font-size: 11px;
color: var(--text-muted);
}
.vm-stat-value {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.vm-stat-value.running {
color: #15803d;
}
.vm-stat-value.stopped {
color: #b91c1c;
}
html.dark .vm-stat-value.running {
color: #34d399;
}
html.dark .vm-stat-value.stopped {
color: #f87171;
}
.host-subtitle {
margin-top: 4px;
font-size: 13px;
@@ -794,11 +741,12 @@ html.dark .vm-stat-value.stopped {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.host-subtitle span + span::before {
content: '·';
margin-right: 8px;
color: var(--text-muted);
.host-subtitle span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.detail-grid {
@@ -806,9 +754,7 @@ html.dark .vm-stat-value.stopped {
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.detail-grid > .card {
margin-bottom: 0;
}
.detail-grid > .card { margin-bottom: 0; }
@media (max-width: 900px) {
.detail-grid { grid-template-columns: 1fr; }
}
@@ -823,15 +769,38 @@ html.dark .vm-stat-value.stopped {
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: var(--surface-hover);
background: var(--card-hover);
border-radius: 8px;
font-size: 13px;
transition: background .2s ease;
}
.info-label { color: var(--text-secondary); }
.info-value { font-weight: 500; color: var(--text); }
.info-label {
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
}
.info-value { font-weight: 500; color: var(--text-primary); }
.text-muted { color: var(--text-muted); }
.vm-status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vm-stat-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
background: var(--card-hover);
border-radius: 8px;
}
.vm-stat-label { font-size: 11px; color: var(--text-muted); }
.vm-stat-value { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.vm-stat-value.running { color: var(--success); }
.vm-stat-value.stopped { color: var(--danger); }
.status-pills {
display: flex;
flex-direction: column;
@@ -842,7 +811,7 @@ html.dark .vm-stat-value.stopped {
align-items: center;
gap: 12px;
padding: 12px;
background: var(--surface-hover);
background: var(--card-hover);
border-radius: 10px;
transition: background .2s ease;
}
@@ -852,14 +821,14 @@ html.dark .vm-stat-value.stopped {
font-size: 11px; font-weight: 800; color: #fff;
flex-shrink: 0;
}
.sp-icon.cpu { background: linear-gradient(135deg, #3b82f6, #60a5fa); }
.sp-icon.mem { background: linear-gradient(135deg, #22c55e, #4ade80); }
.sp-icon.disk { background: linear-gradient(135deg, #f59e0b, #fbbf24); }
.sp-icon.cpu { background: linear-gradient(135deg, #58a6ff, #79b8ff); }
.sp-icon.mem { background: linear-gradient(135deg, #3fb950, #56d364); }
.sp-icon.disk { background: linear-gradient(135deg, #d29922, #e3b341); }
.sp-info { flex: 1; min-width: 0; }
.sp-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.sp-bar { height: 5px; background: var(--border-strong); border-radius: 3px; overflow: hidden; }
.sp-fill { height: 100%; border-radius: 3px; background: #3b82f6; transition: width .3s ease; }
.sp-num { font-size: 14px; font-weight: 700; color: var(--text); min-width: 36px; text-align: right; }
.sp-bar { height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; }
.sp-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width .3s ease; }
.sp-num { font-size: 14px; font-weight: 700; color: var(--text-primary); min-width: 36px; text-align: right; }
.status-pill.disk-multi { flex-wrap: wrap; }
.multi-disk-info {
@@ -877,24 +846,12 @@ html.dark .vm-stat-value.stopped {
gap: 10px;
font-size: 12px;
}
.dd-mount {
min-width: 50px;
color: var(--text-secondary);
font-weight: 500;
}
.dd-bar-wrap {
flex: 1;
min-width: 80px;
}
.dd-bar {
height: 5px;
background: var(--border-strong);
border-radius: 3px;
overflow: hidden;
}
.dd-mount { min-width: 50px; color: var(--text-secondary); font-weight: 500; }
.dd-bar-wrap { flex: 1; min-width: 80px; }
.dd-bar { height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; }
.dd-fill { height: 100%; border-radius: 3px; }
.dd-val { white-space: nowrap; color: var(--text-secondary); }
.dd-pct { min-width: 32px; text-align: right; font-weight: 700; color: var(--text); }
.dd-pct { min-width: 32px; text-align: right; font-weight: 700; color: var(--text-primary); }
.uptime-line {
margin-top: 14px;
@@ -904,19 +861,15 @@ html.dark .vm-stat-value.stopped {
font-size: 13px;
color: var(--text-secondary);
padding: 8px 10px;
background: var(--surface-hover);
background: var(--card-hover);
border-radius: 8px;
transition: background .2s ease;
}
.sync-actions {
margin-bottom: 10px;
}
.ssh-actions {
margin-bottom: 12px;
}
.sync-actions { margin-bottom: 10px; }
.ssh-actions { margin-bottom: 12px; }
.ssh-result {
background: var(--surface-hover);
background: var(--card-hover);
border-radius: 10px;
padding: 12px;
transition: background .2s ease;
@@ -932,11 +885,10 @@ html.dark .vm-stat-value.stopped {
gap: 2px;
}
.result-item.full { grid-column: 1 / -1; }
.rl { font-size: 11px; color: var(--text-muted); }
.rv { font-size: 13px; color: var(--text); font-weight: 500; }
.rl { font-size: 11px; color: var(--text-muted); display: inline-flex; align-items: center; gap: 4px; }
.rv { font-size: 13px; color: var(--text-primary); font-weight: 500; }
.service-list,
.rel-list {
.service-list, .rel-list {
display: flex;
flex-direction: column;
gap: 10px;
@@ -947,11 +899,12 @@ html.dark .vm-stat-value.stopped {
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: var(--surface-hover);
background: var(--card-hover);
border-radius: 10px;
flex-wrap: wrap;
transition: background .2s ease;
}
.service-row:hover { background: var(--border); }
.service-main {
display: flex;
align-items: center;
@@ -960,11 +913,14 @@ html.dark .vm-stat-value.stopped {
.service-name {
font-weight: 600;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.service-port {
font-size: 12px;
color: var(--text-muted);
background: var(--surface);
background: var(--card-bg);
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border);
@@ -974,20 +930,11 @@ html.dark .vm-stat-value.stopped {
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--primary);
}
.target-note {
color: var(--text-muted);
}
.service-note {
font-size: 12px;
color: var(--text-secondary);
flex: 1 1 100%;
}
.service-actions {
display: flex;
gap: 6px;
color: var(--accent);
}
.target-note { color: var(--text-muted); }
.service-note { font-size: 12px; color: var(--text-secondary); flex: 1 1 100%; }
.service-actions { display: flex; gap: 6px; }
.rel-row {
display: flex;
@@ -995,20 +942,19 @@ html.dark .vm-stat-value.stopped {
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: var(--surface-hover);
background: var(--card-hover);
border-radius: 10px;
flex-wrap: wrap;
transition: background .2s ease;
}
.rel-row:hover { background: var(--border); }
.rel-arrow {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.rel-host {
font-weight: 600;
}
.rel-host { font-weight: 600; }
.rel-meta {
display: flex;
align-items: center;
@@ -1016,19 +962,14 @@ html.dark .vm-stat-value.stopped {
font-size: 12px;
}
.rel-port {
background: var(--surface);
background: var(--card-bg);
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.rel-note {
color: var(--text-muted);
}
.rel-actions {
display: flex;
gap: 6px;
}
.rel-note { color: var(--text-muted); }
.rel-actions { display: flex; gap: 6px; }
.offline-stats {
display: grid;
@@ -1037,38 +978,32 @@ html.dark .vm-stat-value.stopped {
margin-bottom: 16px;
}
.stat-box {
background: var(--surface-hover);
background: var(--card-hover);
border-radius: 10px;
padding: 12px;
text-align: center;
border: 1px solid var(--border);
transition: background .2s ease;
}
.stat-num {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.stat-reason {
font-size: 13px;
font-weight: 600;
color: var(--danger);
word-break: break-all;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.stat-box:hover { background: var(--border); }
.stat-num { font-size: 20px; font-weight: 700; color: var(--text-primary); }
.stat-reason { font-size: 13px; font-weight: 600; color: var(--danger); word-break: break-all; }
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.offline-logs {
background: var(--surface-hover);
background: var(--card-hover);
border-radius: 10px;
padding: 12px;
border: 1px solid var(--border);
}
.log-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.log-row {
display: flex;
@@ -1078,23 +1013,9 @@ html.dark .vm-stat-value.stopped {
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.log-row:last-child {
border-bottom: none;
}
.log-time {
color: var(--text-muted);
min-width: 140px;
}
.log-reason {
flex: 1;
color: var(--text-secondary);
}
.log-dur {
color: var(--text);
font-weight: 500;
}
.log-dur.pending {
color: var(--warning);
}
.log-row:last-child { border-bottom: none; }
.log-time { color: var(--text-muted); min-width: 140px; }
.log-reason { flex: 1; color: var(--text-secondary); }
.log-dur { color: var(--text-primary); font-weight: 500; }
.log-dur.pending { color: var(--warning); }
</style>

View File

@@ -4,44 +4,75 @@
<div class="toolbar">
<div class="search-wrap">
<el-icon class="search-icon"><Search /></el-icon>
<el-input v-model="search" placeholder="搜索主机名" clearable @change="load" />
<el-input v-model="search" placeholder="搜索主机名或 IP" clearable @change="load" />
</div>
<el-select v-model="osFilter" placeholder="系统类型" clearable @change="load" class="os-select">
<el-option label="Linux" value="Linux" />
<el-option label="Windows" value="Windows" />
<el-option label="macOS" value="macOS" />
<el-option label="Other" value="Other" />
<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" @click="handleExport">导出数据</el-button>
<el-button v-if="isAdmin" type="warning" @click="handleImportClick">导入数据</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 -->
<div class="cards-grid">
<div v-for="m in machines" :key="m.id" class="server-card" :class="[{ 'guest-card': !isAdmin, 'offline-card': !m.is_online }]" @click="isAdmin && goDetail(m.id)">
<!-- Cards Grid -->
<div class="cards-grid" v-if="machines.length">
<div v-for="m in machines" :key="m.id" class="server-card"
:class="[{ 'guest-card': !isAdmin, 'offline-card': !m.is_online }]"
@click="isAdmin && goDetail(m.id)">
<div class="card-header">
<div class="title-row">
<span class="os-dot" :class="osClass(m.os_type)" :title="m.os_type"></span>
<div class="os-badge" :class="osClass(m.os_type)" :title="m.os_type">
<el-icon :size="12">
<component :is="osIcon(m.os_type)" />
</el-icon>
<span>{{ osShort(m.os_type) }}</span>
</div>
<span class="hostname">{{ m.hostname }}</span>
<span class="status-badge" :class="m.is_online ? 'online' : 'offline'">{{ m.is_online ? '在线' : '离线' }}</span>
<el-tag v-if="m.service_count" size="small" effect="plain" class="svc-tag" round>服务 {{ m.service_count }}</el-tag>
<el-tag :type="m.is_online ? 'success' : 'danger'" size="small" effect="light" round class="status-tag">
<el-icon :size="10"><component :is="m.is_online ? CircleCheck : CircleClose" /></el-icon>
{{ m.is_online ? '在线' : '离线' }}
</el-tag>
<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>
</div>
<div class="meta-row">
<span v-if="isAdmin" class="meta-ip">{{ m.ip }}</span>
<span class="meta-item">{{ m.os_type }}</span>
<span v-if="isAdmin" class="meta-ip">
<el-icon :size="10"><Link /></el-icon> {{ m.ip }}
</span>
<span class="meta-item">
<el-icon :size="10"><Cpu /></el-icon> {{ m.os_type }}
</span>
<span v-if="m.os_version" class="meta-item">{{ m.os_version }}</span>
<span v-if="m.uptime" class="meta-uptime">{{ m.uptime }}</span>
<span v-if="m.uptime" class="meta-uptime">
<el-icon :size="10"><Timer /></el-icon> {{ m.uptime }}
</span>
<span v-if="isAdmin && m.pve_host_id && m.pve_vmid" class="meta-pve">
<span class="vm-status" :class="m.pve_vm_status">{{ m.pve_vm_status === 'running' ? 'VM运行中' : m.pve_vm_status === 'stopped' ? 'VM已停止' : 'VM检测中' }}</span>
<el-tag 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>
</span>
</div>
</div>
<div v-if="m.cpu_info || m.memory_info || m.disk_info" class="stats-row">
<div v-if="m.cpu_info" class="stat-pill">
<div class="pill-icon cpu">C</div>
<div class="pill-icon cpu">
<el-icon :size="12"><Cpu /></el-icon>
</div>
<div class="pill-body">
<div class="pill-label">CPU</div>
<div class="pill-bar"><div class="pill-fill" :style="{ width: extractPercent(m.cpu_info) + '%' }"></div></div>
@@ -49,7 +80,9 @@
<div class="pill-value">{{ extractPercent(m.cpu_info) }}%</div>
</div>
<div v-if="m.memory_info" class="stat-pill">
<div class="pill-icon mem">M</div>
<div class="pill-icon mem">
<el-icon :size="12"><Collection /></el-icon>
</div>
<div class="pill-body">
<div class="pill-label">RAM</div>
<div class="pill-bar"><div class="pill-fill" :style="{ width: extractPercent(m.memory_info) + '%', background: getMemBarColor(extractPercent(m.memory_info)) }"></div></div>
@@ -57,7 +90,9 @@
<div class="pill-value">{{ extractDetail(m.memory_info) }}</div>
</div>
<div v-if="getMainDisk(m.disk_info)" class="stat-pill">
<div class="pill-icon disk">D</div>
<div class="pill-icon disk">
<el-icon :size="12"><Histogram /></el-icon>
</div>
<div class="pill-body">
<div class="pill-label">DISK</div>
<div class="pill-bar"><div class="pill-fill" :style="{ width: getMainDisk(m.disk_info).percent + '%', background: getDiskBarColor(getMainDisk(m.disk_info).percent) }"></div></div>
@@ -68,68 +103,101 @@
<div v-if="m.listen_ports" class="ports-row">
<el-tag v-for="p in m.listen_ports.split(',').slice(0,6)" :key="p" size="small" effect="plain" round class="port-tag">
{{ p.trim() }}
<el-icon :size="9"><Connection /></el-icon> {{ p.trim() }}
</el-tag>
<span v-if="m.listen_ports.split(',').length > 6" class="more-ports">+{{ m.listen_ports.split(',').length - 6 }}</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>
<el-empty v-if="!machines.length" description="暂无机器" />
<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="480px" class="modern-dialog" destroy-on-close>
<el-form :model="editing" label-width="90px">
<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" />
<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" />
<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" />
<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-option label="Windows" value="Windows" />
<el-option label="macOS" value="macOS" />
<el-option label="Other" value="Other" />
<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" />
<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">SSH 配置</el-divider>
<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" />
<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="输入则更新密码,留空保持不变" />
<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="记录用途、负责人等信息" />
<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">PVE 配置可选</el-divider>
<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" />
<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" @click="saveMachine">保存</el-button>
<el-button type="primary" :icon="Check" @click="saveMachine">保存</el-button>
</template>
</el-dialog>
</div>
@@ -138,7 +206,12 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, Search } from '@element-plus/icons-vue'
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
} from '@element-plus/icons-vue'
import { fetchMachines, createMachine, updateMachine, checkAuth, uiRefreshInterval, exportData, importData, fetchPVEHosts, fetchVMStatus } from '@/api'
import { getAuth } from '@/router'
import { ElMessage } from 'element-plus'
@@ -157,7 +230,6 @@ let timer = null
onMounted(async () => {
await load()
await checkAuth()
// 加载 PVE 主机列表
if (isAdmin) {
try {
const res = await fetchPVEHosts()
@@ -177,7 +249,6 @@ onUnmounted(() => {
async function load() {
const res = await fetchMachines({ search: search.value, os_type: osFilter.value })
machines.value = res.data
// 并行查询有关联 PVE 的机器的 VM 实时状态
if (isAdmin) {
const pveMachines = machines.value.filter(m => m.pve_host_id && m.pve_vmid)
await Promise.all(pveMachines.map(async (m) => {
@@ -268,8 +339,6 @@ function extractPercent(str) {
function extractDetail(str) {
if (!str) return ''
const cpuCore = str.match(/\((\d+)\s*cores\)/)
if (cpuCore) return cpuCore[1] + ' cores'
let s = str.replace(/\s*\(\d+(?:\.\d+)?%\)\s*/g, '').trim()
if (s.includes(',')) s = s.split(',')[0].trim()
s = s.replace(/^\/\s+/, '').trim()
@@ -278,14 +347,14 @@ function extractDetail(str) {
}
function getMemBarColor(v) {
if (v >= 90) return '#f87171'
if (v >= 70) return '#fbbf24'
return '#34d399'
if (v >= 90) return 'var(--danger)'
if (v >= 70) return 'var(--warning)'
return 'var(--success)'
}
function getDiskBarColor(v) {
if (v >= 90) return '#f87171'
if (v >= 80) return '#fbbf24'
return '#60a5fa'
if (v >= 90) return 'var(--danger)'
if (v >= 80) return 'var(--warning)'
return 'var(--accent)'
}
function parseDisks(str) {
@@ -296,28 +365,31 @@ function parseDisks(str) {
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
}
// fallback: single disk old format
if (disks.length > 0) return disks
const pct = extractPercent(str)
const det = extractDetail(str)
if (det) {
return [{ mount: '', detail: det, percent: pct }]
}
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 || null
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 '-'
@@ -334,6 +406,7 @@ function formatTime(t) {
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-wrap {
position: relative;
@@ -342,8 +415,6 @@ function formatTime(t) {
}
.search-wrap :deep(.el-input__wrapper) {
padding-left: 34px;
border-radius: 10px;
box-shadow: 0 0 0 1px var(--border) inset;
}
.search-icon {
position: absolute;
@@ -354,10 +425,6 @@ function formatTime(t) {
font-size: 16px;
z-index: 1;
}
.os-select :deep(.el-input__wrapper) {
border-radius: 10px;
box-shadow: 0 0 0 1px var(--border) inset;
}
.cards-grid {
display: grid;
@@ -365,21 +432,18 @@ function formatTime(t) {
gap: 16px;
}
@media (max-width: 900px) {
.cards-grid {
grid-template-columns: 1fr;
}
.cards-grid { grid-template-columns: 1fr; }
}
.server-card {
background: var(--surface);
background: var(--card-bg);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
box-shadow: var(--shadow-card);
border: 1px solid var(--border);
border-top-width: 3px;
border-top-color: var(--border-strong);
border-left: 3px solid var(--border);
cursor: pointer;
transition: transform .12s ease, box-shadow .2s ease, opacity .2s ease, border-color .2s ease;
transition: all .15s ease;
}
.server-card:hover {
transform: translateY(-2px);
@@ -391,13 +455,12 @@ function formatTime(t) {
}
.server-card.guest-card:hover {
transform: none;
box-shadow: var(--shadow);
box-shadow: var(--shadow-card);
border-color: var(--border);
}
.server-card.offline-card {
opacity: 0.65;
border-top-color: var(--border);
opacity: 0.7;
border-left-color: var(--danger);
}
.server-card.offline-card .hostname {
color: var(--text-muted);
@@ -412,54 +475,52 @@ function formatTime(t) {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.os-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.os-dot.os-linux { background: #3b82f6; }
.os-dot.os-windows { background: #22c55e; }
.os-dot.os-macos { background: #a855f7; }
.os-dot.os-other { background: #64748b; }
.status-badge {
.os-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
line-height: 1;
}
.status-badge.online {
background: rgba(34,197,94,0.12);
color: #15803d;
}
.status-badge.offline {
background: rgba(239,68,68,0.10);
color: #b91c1c;
}
html.dark .status-badge.online {
background: rgba(52,211,153,0.15);
color: #34d399;
}
html.dark .status-badge.offline {
background: rgba(248,113,113,0.15);
color: #f87171;
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);
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: 18px;
height: 20px;
padding: 0 8px;
color: var(--text-secondary);
border-color: var(--border);
display: inline-flex;
align-items: center;
gap: 4px;
}
.meta-row {
@@ -471,12 +532,15 @@ html.dark .status-badge.offline {
color: var(--text-secondary);
}
.meta-ip {
color: var(--text);
color: var(--text-primary);
font-weight: 500;
background: var(--surface-hover);
background: var(--card-hover);
border: 1px solid var(--border);
padding: 1px 6px;
padding: 2px 6px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.meta-item + .meta-item::before,
.meta-uptime::before {
@@ -484,7 +548,8 @@ html.dark .status-badge.offline {
margin: 0 6px;
color: var(--text-muted);
}
.meta-uptime { color: var(--success); }
.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; }
.stats-row {
margin-top: 14px;
@@ -501,25 +566,26 @@ html.dark .status-badge.offline {
display: flex;
align-items: center;
gap: 8px;
transition: background .2s ease, border-color .2s ease;
transition: all .2s ease;
}
.pill-icon {
width: 22px; height: 22px; border-radius: 5px;
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 { color: #7dd3fc; }
.pill-icon.mem { color: #86efac; }
.pill-icon.disk { color: #93c5fd; }
.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: rgba(255,255,255,0.85); font-weight: 600; }
.pill-bar { height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; }
.pill-fill { height: 100%; border-radius: 2px; background: rgba(255,255,255,0.9); transition: width .3s ease; }
.pill-value { font-size: 11px; font-weight: 700; color: #fff; white-space: nowrap; }
.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;
@@ -530,9 +596,12 @@ html.dark .status-badge.offline {
.port-tag {
font-size: 11px;
height: 20px;
padding: 0 8px;
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; }
@@ -540,29 +609,8 @@ html.dark .status-badge.offline {
margin-top: 10px;
font-size: 11px;
color: var(--text-muted);
}
.vm-status {
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 999px;
}
.vm-status.running {
background: rgba(34,197,94,0.12);
color: #15803d;
}
.vm-status.stopped {
background: rgba(239,68,68,0.10);
color: #b91c1c;
}
html.dark .vm-status.running {
background: rgba(52,211,153,0.15);
color: #34d399;
}
html.dark .vm-status.stopped {
background: rgba(248,113,113,0.15);
color: #f87171;
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>

View File

@@ -2,91 +2,375 @@
<div class="page">
<div class="page-header">
<div>
<div class="page-title">拓扑图</div>
<div class="page-subtitle">展示机器之间的服务指向和关联关系</div>
<div class="page-title">
<el-icon><Share /></el-icon>
拓扑图
</div>
<div class="page-subtitle">可视化展示机器之间的服务指向和关联关系</div>
</div>
<div class="header-actions">
<el-button-group>
<el-button text :icon="ZoomIn" @click="zoomIn">放大</el-button>
<el-button text :icon="ZoomOut" @click="zoomOut">缩小</el-button>
<el-button text :icon="FullScreen" @click="fitView">适应</el-button>
</el-button-group>
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="$router.push('/machines')">添加机器</el-button>
</div>
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="$router.push('/machines')">添加机器</el-button>
</div>
<div class="topo-card">
<!-- 控制面板 -->
<div class="topo-controls">
<div class="control-section">
<div class="control-label">
<el-icon><Operation /></el-icon> 布局
</div>
<el-radio-group v-model="layoutType" size="small" @change="changeLayout">
<el-radio-button label="force">力导向</el-radio-button>
<el-radio-button label="circular">环形</el-radio-button>
<el-radio-button label="dagre">层次</el-radio-button>
</el-radio-group>
</div>
<div class="control-section">
<div class="control-label">
<el-icon><Filter /></el-icon> 显示
</div>
<el-checkbox v-model="showServices" label="服务连线" size="small" @change="toggleEdgeType" />
<el-checkbox v-model="showRelations" label="关联连线" size="small" @change="toggleEdgeType" />
</div>
</div>
<div id="topo" class="topo-canvas"></div>
<!-- 图例 -->
<div class="legend">
<div class="legend-item"><span class="dot online"></span> 在线</div>
<div class="legend-item"><span class="dot offline"></span> 离线</div>
<div class="legend-item"><span class="line service"></span> 服务指向</div>
<div class="legend-item"><span class="line relation"></span> 关联关系</div>
<div class="legend-title">
<el-icon><InfoFilled /></el-icon> 图例
</div>
<div class="legend-item">
<span class="dot online"></span>
<span>在线</span>
</div>
<div class="legend-item">
<span class="dot offline"></span>
<span>离线</span>
</div>
<div class="legend-divider"></div>
<div class="legend-item">
<span class="line service"></span>
<span>服务指向</span>
</div>
<div class="legend-item">
<span class="line relation"></span>
<span>关联关系</span>
</div>
<div class="legend-divider"></div>
<div class="legend-item">
<span class="os-icon linux">L</span>
<span>Linux</span>
</div>
<div class="legend-item">
<span class="os-icon windows">W</span>
<span>Windows</span>
</div>
<div class="legend-item">
<span class="os-icon macos">M</span>
<span>macOS</span>
</div>
</div>
<!-- 选中节点信息面板 -->
<div class="node-info-panel" v-if="selectedNode" :class="{ visible: selectedNode }">
<div class="node-info-header">
<el-icon class="node-info-icon" :size="20"><Platform /></el-icon>
<div class="node-info-title">
<div class="node-info-name">{{ selectedNode.hostname }}</div>
<div class="node-info-status">
<span class="status-dot" :class="selectedNode.is_online ? 'online' : 'offline'"></span>
{{ selectedNode.is_online ? '在线' : '离线' }}
</div>
</div>
<el-button text circle size="small" @click="selectedNode = null">
<el-icon><Close /></el-icon>
</el-button>
</div>
<div class="node-info-body">
<div class="info-row">
<span class="info-label">IP</span>
<span class="info-value">{{ selectedNode.ip }}</span>
</div>
<div class="info-row">
<span class="info-label">系统</span>
<span class="info-value">{{ selectedNode.os_type }} {{ selectedNode.os_version || '' }}</span>
</div>
<div class="info-row" v-if="selectedNode.service_count">
<span class="info-label">服务</span>
<span class="info-value">{{ selectedNode.service_count }} </span>
</div>
<div class="info-row" v-if="selectedNode.uptime">
<span class="info-label">运行</span>
<span class="info-value">{{ selectedNode.uptime }}</span>
</div>
</div>
<div class="node-info-footer">
<el-button type="primary" size="small" :icon="View" @click="goDetail(selectedNode.id)">查看详情</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, onUnmounted } from 'vue'
import { onMounted, ref, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Graph } from '@antv/g6'
import { Plus } from '@element-plus/icons-vue'
import {
Share, Plus, ZoomIn, ZoomOut, FullScreen, Operation, Filter,
InfoFilled, Platform, Close, View
} from '@element-plus/icons-vue'
import { fetchMachines, fetchAllServices as fetchServicesAll, fetchRelationships } from '@/api'
import { getAuth } from '@/router'
const router = useRouter()
const isAdmin = getAuth().is_admin
let graph = null
let observer = null
const layoutType = ref('force')
const showServices = ref(true)
const showRelations = ref(true)
const selectedNode = ref(null)
const rawData = ref({ machines: [], services: [], relationships: [] })
function getIsDark() {
return document.documentElement.classList.contains('dark')
}
function themeFill() {
return getIsDark() ? '#0f172a' : '#ffffff'
return getIsDark() ? '#161b22' : '#ffffff'
}
function themeText() {
return getIsDark() ? '#f8fafc' : '#111827'
return getIsDark() ? '#c9d1d9' : '#1f2328'
}
function themeBg() {
return getIsDark() ? '#0d1117' : '#f6f8fa'
}
function themeBorder() {
return getIsDark() ? '#30363d' : '#d0d7de'
}
function themeBg() {
return getIsDark() ? '#020617' : '#fafafa'
function osColor(os) {
switch (os) {
case 'Linux': return '#58a6ff'
case 'Windows': return '#3fb950'
case 'macOS': return '#a371f7'
default: return '#8b949e'
}
}
function osIconText(os) {
switch (os) {
case 'Linux': return 'L'
case 'Windows': return 'W'
case 'macOS': return 'M'
default: return '?'
}
}
function osIconBg(os) {
switch (os) {
case 'Linux': return 'rgba(88,166,255,0.15)'
case 'Windows': return 'rgba(63,185,80,0.15)'
case 'macOS': return 'rgba(163,113,247,0.15)'
default: return 'rgba(139,148,158,0.15)'
}
}
function getLayoutConfig() {
const base = { preventOverlap: true, nodeSize: [160, 48] }
switch (layoutType.value) {
case 'force':
return { type: 'force', ...base, linkDistance: 160, nodeStrength: -100, edgeStrength: 0.3, collideStrength: 0.8 }
case 'circular':
return { type: 'circular', ...base, radius: null, startRadius: 100, endRadius: null }
case 'dagre':
return { type: 'dagre', ...base, rankdir: 'TB', align: 'UL', nodesep: 50, ranksep: 80 }
default:
return { type: 'force', ...base, linkDistance: 160, nodeStrength: -100, edgeStrength: 0.3 }
}
}
function buildNodes(machines) {
const isDark = getIsDark()
const fill = themeFill()
const text = themeText()
return machines.map(m => ({
id: String(m.id),
label: m.hostname,
data: m,
online: m.is_online,
os: m.os_type,
type: 'rect',
size: [160, 48],
style: {
fill,
stroke: osColor(m.os_type),
lineWidth: 2,
radius: 8,
cursor: 'pointer',
shadowColor: isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.06)',
shadowBlur: 12,
shadowOffsetY: 3,
},
labelCfg: {
style: {
fill: text,
fontSize: 13,
fontWeight: 600,
fontFamily: 'var(--font-family)',
}
},
// 状态指示 badge
badgeCfg: {
position: 'topRight',
style: {
fill: m.is_online ? '#3fb950' : '#f85149',
stroke: fill,
lineWidth: 2,
}
},
// 图标配置
icon: {
show: true,
width: 24,
height: 24,
img: null,
text: osIconText(m.os_type),
fill: osColor(m.os_type),
fontFamily: 'var(--font-family)',
fontSize: 12,
fontWeight: 700,
},
}))
}
function buildEdges(services, relationships, machineMap) {
const fill = themeFill()
const edges = []
if (showRelations.value) {
relationships.forEach(r => {
if (!machineMap[r.source_machine_id] || !machineMap[r.target_machine_id]) return
edges.push({
id: `rel-${r.id}`,
source: String(r.source_machine_id),
target: String(r.target_machine_id),
label: relLabel(r),
type: 'quadratic',
style: {
stroke: '#d29922',
lineWidth: 1.5,
lineDash: [6, 3],
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#d29922', d: 4 },
cursor: 'pointer',
},
labelCfg: {
style: {
fill: '#d29922',
fontSize: 11,
fontWeight: 500,
fontFamily: 'var(--font-family)',
background: { fill, padding: [3, 6], radius: 4 }
}
}
})
})
}
if (showServices.value) {
services.forEach(s => {
if (s.target_machine_id && machineMap[s.target_machine_id]) {
edges.push({
id: `svc-${s.id}`,
source: String(s.machine_id),
target: String(s.target_machine_id),
label: s.name,
type: 'quadratic',
style: {
stroke: '#58a6ff',
lineWidth: 2,
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#58a6ff', d: 4 },
cursor: 'pointer',
},
labelCfg: {
style: {
fill: '#58a6ff',
fontSize: 11,
fontWeight: 500,
fontFamily: 'var(--font-family)',
background: { fill, padding: [3, 6], radius: 4 }
}
}
})
}
})
}
return edges
}
function relLabel(r) {
const typeMap = { port_forward: '端口转发', dependency: '依赖', primary_secondary: '主从', custom: '自定义' }
const t = typeMap[r.relation_type] || r.relation_type
return r.source_port ? `${t} (${r.source_port})` : t
}
function renderGraph() {
if (!graph) return
const container = document.getElementById('topo')
if (!container || !graph) return
if (!container) return
container.style.background = themeBg()
const isDark = getIsDark()
const fill = themeFill()
const text = themeText()
graph.getNodes().forEach(node => {
const model = node.getModel()
graph.updateItem(node, {
style: {
fill: themeFill(),
fill,
stroke: osColor(model.os),
lineWidth: 2,
radius: 8,
shadowColor: getIsDark() ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.06)',
shadowBlur: 8,
shadowOffsetY: 2
shadowColor: isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.06)',
shadowBlur: 12,
shadowOffsetY: 3,
},
labelCfg: {
style: { fill: themeText(), fontSize: 13, fontWeight: 600 }
style: { fill: text, fontSize: 13, fontWeight: 600, fontFamily: 'var(--font-family)' }
},
badgeCfg: {
position: 'topRight',
style: {
fill: model.online ? '#22c55e' : '#ef4444',
stroke: themeFill(),
lineWidth: 2
fill: model.online ? '#3fb950' : '#f85149',
stroke: fill,
lineWidth: 2,
}
}
},
})
})
graph.getEdges().forEach(edge => {
const model = edge.getModel()
const isRel = String(model.id).startsWith('rel-')
const color = isRel ? '#f97316' : '#3b82f6'
const color = isRel ? '#d29922' : '#58a6ff'
graph.updateItem(edge, {
labelCfg: {
style: {
fill: color,
fontSize: 11,
fontWeight: 500,
background: { fill: themeFill(), padding: [2, 4], radius: 4 }
fontFamily: 'var(--font-family)',
background: { fill, padding: [3, 6], radius: 4 }
}
}
})
@@ -94,7 +378,7 @@ function renderGraph() {
graph.paint()
}
onMounted(async () => {
async function initGraph() {
const [machinesRes, servicesRes, relsRes] = await Promise.all([
fetchMachines(),
fetchServicesAll(),
@@ -104,248 +388,376 @@ onMounted(async () => {
const machines = machinesRes.data
const services = servicesRes.data
const relationships = relsRes.data
rawData.value = { machines, services, relationships }
const machineMap = {}
machines.forEach(m => machineMap[m.id] = m)
const isDark = getIsDark()
const fill = themeFill()
const text = themeText()
const nodes = machines.map(m => ({
id: String(m.id),
label: m.hostname,
online: m.is_online,
os: m.os_type,
style: {
fill,
stroke: osColor(m.os_type),
lineWidth: 2,
radius: 8,
shadowColor: isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.06)',
shadowBlur: 8,
shadowOffsetY: 2
},
labelCfg: {
style: { fill: text, fontSize: 13, fontWeight: 600 }
},
badgeCfg: {
position: 'topRight',
style: {
fill: m.is_online ? '#22c55e' : '#ef4444',
stroke: fill,
lineWidth: 2
}
}
}))
const edges = []
relationships.forEach(r => {
if (!machineMap[r.source_machine_id] || !machineMap[r.target_machine_id]) return
edges.push({
id: `rel-${r.id}`,
source: String(r.source_machine_id),
target: String(r.target_machine_id),
label: relLabel(r),
style: {
stroke: '#f97316',
lineDash: [4, 2],
lineWidth: 1.5,
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#f97316' }
},
labelCfg: {
style: { fill: '#f97316', fontSize: 11, fontWeight: 500, background: { fill, padding: [2, 4], radius: 4 } }
}
})
})
services.forEach(s => {
if (s.target_machine_id && machineMap[s.target_machine_id]) {
edges.push({
id: `svc-${s.id}`,
source: String(s.machine_id),
target: String(s.target_machine_id),
label: s.name,
style: {
stroke: '#3b82f6',
lineWidth: 2,
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#3b82f6' }
},
labelCfg: {
style: { fill: '#3b82f6', fontSize: 11, fontWeight: 500, background: { fill, padding: [2, 4], radius: 4 } }
}
})
}
})
const container = document.getElementById('topo')
if (!container) return
container.style.background = themeBg()
if (graph) {
graph.destroy()
graph = null
}
graph = new Graph({
container: 'topo',
width: container.clientWidth,
height: container.clientHeight,
layout: {
type: 'force',
preventOverlap: true,
linkDistance: 140,
nodeStrength: -80,
edgeStrength: 0.2
},
layout: getLayoutConfig(),
defaultNode: {
type: 'rect',
size: [110, 40]
size: [160, 48]
},
defaultEdge: {
type: 'line',
type: 'quadratic',
style: { endArrow: true }
},
modes: { default: ['drag-node', 'drag-canvas', 'zoom-canvas'] },
modes: {
default: ['drag-node', 'drag-canvas', 'zoom-canvas'],
},
fitView: true,
fitViewPadding: 20
fitViewPadding: 40,
})
const nodes = buildNodes(machines)
const edges = buildEdges(services, relationships, machineMap)
graph.data({ nodes, edges })
graph.render()
// 节点点击事件
graph.on('node:click', e => {
const nodeId = e.item.getModel().id
highlightNode(nodeId)
const model = e.item.getModel()
selectedNode.value = model.data
highlightNode(model.id)
})
window.addEventListener('resize', () => {
const c = document.getElementById('topo')
if (c && graph) {
graph.changeSize(c.clientWidth, c.clientHeight)
graph.fitView()
}
// 画布点击取消选中
graph.on('canvas:click', () => {
selectedNode.value = null
clearHighlight()
})
// Watch for theme changes
// 双击进入详情
graph.on('node:dblclick', e => {
const model = e.item.getModel()
router.push(`/machines/${model.id}`)
})
window.addEventListener('resize', handleResize)
// 监听主题变化
observer = new MutationObserver(() => {
renderGraph()
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
}
function changeLayout() {
if (!graph) return
const layout = getLayoutConfig()
graph.updateLayout(layout)
graph.fitView()
}
function toggleEdgeType() {
if (!graph) return
const { machines, services, relationships } = rawData.value
const machineMap = {}
machines.forEach(m => machineMap[m.id] = m)
const edges = buildEdges(services, relationships, machineMap)
// 保留节点,只更新边
const currentData = graph.save()
const nodes = currentData.nodes
graph.changeData({ nodes, edges })
graph.render()
graph.fitView()
}
function zoomIn() {
if (graph) graph.zoom(1.2)
}
function zoomOut() {
if (graph) graph.zoom(0.8)
}
function fitView() {
if (graph) graph.fitView()
}
function handleResize() {
const c = document.getElementById('topo')
if (c && graph) {
graph.changeSize(c.clientWidth, c.clientHeight)
graph.fitView()
}
}
function highlightNode(nodeId) {
if (!graph) return
const allEdges = graph.getEdges()
const allNodes = graph.getNodes()
const connectedNodeIds = new Set([nodeId])
allEdges.forEach(edge => {
const model = edge.getModel()
if (model.source === nodeId || model.target === nodeId) {
graph.setItemState(edge, 'active', true)
edge.update({ style: { opacity: 1, lineWidth: 2.5 } })
connectedNodeIds.add(model.source)
connectedNodeIds.add(model.target)
} else {
graph.setItemState(edge, 'inactive', true)
edge.update({ style: { opacity: 0.1 } })
}
})
allNodes.forEach(node => {
const model = node.getModel()
if (connectedNodeIds.has(model.id)) {
graph.setItemState(node, 'active', true)
node.update({ style: { opacity: 1, shadowBlur: 20 } })
} else {
graph.setItemState(node, 'inactive', true)
node.update({ style: { opacity: 0.3 } })
}
})
}
function clearHighlight() {
if (!graph) return
graph.getEdges().forEach(edge => {
graph.clearItemStates(edge)
edge.update({ style: { opacity: 1, lineWidth: edge.getModel().id.startsWith('rel-') ? 1.5 : 2 } })
})
graph.getNodes().forEach(node => {
graph.clearItemStates(node)
node.update({ style: { opacity: 1, shadowBlur: 12 } })
})
}
function goDetail(id) {
router.push(`/machines/${id}`)
}
onMounted(() => {
initGraph()
})
onUnmounted(() => {
if (observer) observer.disconnect()
window.removeEventListener('resize', handleResize)
if (graph) {
graph.destroy()
graph = null
}
})
function highlightNode(nodeId) {
if (!graph) return
const edges = graph.getEdges()
edges.forEach(edge => {
const model = edge.getModel()
if (model.source === nodeId || model.target === nodeId) {
graph.setItemState(edge, 'active', true)
edge.update({ style: { opacity: 1 } })
} else {
graph.setItemState(edge, 'inactive', true)
edge.update({ style: { opacity: 0.15 } })
}
})
setTimeout(() => {
edges.forEach(edge => {
graph.clearItemStates(edge)
edge.update({ style: { opacity: 1 } })
})
}, 2000)
}
function osColor(os) {
switch (os) {
case 'Linux': return '#3b82f6'
case 'Windows': return '#06b6d4'
case 'macOS': return '#a855f7'
default: return '#9ca3af'
}
}
function relLabel(r) {
const typeMap = { port_forward: '端口转发', dependency: '依赖', primary_secondary: '主从', custom: '自定义' }
const t = typeMap[r.relation_type] || r.relation_type
return r.source_port ? `${t} (${r.source_port})` : t
}
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: 18px;
font-weight: 700;
}
.page-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.topo-card {
background: var(--surface);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 12px;
box-shadow: var(--shadow-card);
position: relative;
height: calc(100vh - 220px);
min-height: 420px;
height: calc(100vh - 200px);
min-height: 480px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.topo-controls {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--card-bg);
z-index: 10;
flex-wrap: wrap;
}
.control-section {
display: flex;
align-items: center;
gap: 10px;
}
.control-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.topo-canvas {
flex: 1;
width: 100%;
height: 100%;
border-radius: 10px;
min-height: 0;
border-radius: 0 0 var(--radius) var(--radius);
overflow: hidden;
transition: background .2s ease;
transition: background .25s ease;
}
.legend {
position: absolute;
left: 16px;
bottom: 16px;
background: var(--surface);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
padding: 12px 14px;
display: flex;
gap: 14px;
box-shadow: var(--shadow);
backdrop-filter: blur(6px);
transition: background .2s ease, border-color .2s ease;
flex-direction: column;
gap: 8px;
box-shadow: var(--shadow-card);
backdrop-filter: blur(8px);
transition: all .25s ease;
z-index: 5;
min-width: 120px;
}
.legend-item {
.legend-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.legend-divider {
height: 1px;
background: var(--border);
margin: 2px 0;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.online { background: #22c55e; }
.dot.offline { background: #ef4444; }
.dot.online { background: #3fb950; box-shadow: 0 0 6px rgba(63, 185, 80, 0.4); }
.dot.offline { background: #f85149; box-shadow: 0 0 6px rgba(248, 81, 73, 0.4); }
.line {
width: 16px;
height: 2px;
border-radius: 1px;
flex-shrink: 0;
}
.line.service { background: #3b82f6; }
.line.service { background: #58a6ff; }
.line.relation {
background: repeating-linear-gradient(90deg, #f97316, #f97316 4px, transparent 4px, transparent 6px);
background: repeating-linear-gradient(90deg, #d29922, #d29922 4px, transparent 4px, transparent 7px);
height: 2px;
}
.os-icon {
width: 16px;
height: 16px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 800;
color: #fff;
flex-shrink: 0;
}
.os-icon.linux { background: #58a6ff; }
.os-icon.windows { background: #3fb950; }
.os-icon.macos { background: #a371f7; }
/* 节点信息面板 */
.node-info-panel {
position: absolute;
right: 16px;
top: 68px;
width: 260px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(8px);
z-index: 10;
transition: all .25s ease;
opacity: 0;
transform: translateX(10px);
pointer-events: none;
}
.node-info-panel.visible {
opacity: 1;
transform: translateX(0);
pointer-events: all;
}
.node-info-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.node-info-icon { color: var(--accent); flex-shrink: 0; }
.node-info-title { flex: 1; min-width: 0; }
.node-info-name {
font-weight: 700;
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-info-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.online { background: #3fb950; }
.status-dot.offline { background: #f85149; }
.node-info-body {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.info-label { color: var(--text-muted); font-weight: 500; }
.info-value { color: var(--text-primary); font-weight: 600; }
.node-info-footer {
padding: 10px 16px 14px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.topo-controls { gap: 12px; padding: 10px 12px; }
.legend { left: 8px; bottom: 8px; padding: 8px 10px; gap: 6px; }
.node-info-panel { right: 8px; top: 60px; width: 220px; }
}
</style>