feat: UI 全面升级,新增 Dashboard 仪表盘
- 新增 Dashboard.vue 首页(统计卡片、系统分布、在线率趋势) - 路由调整:根路径 / 默认重定向到 /dashboard - 新增品牌图标(logo.svg、favicon) - 登录页、侧边栏、拓扑图视觉增强 - 全局 CSS 变量重定义(GitHub 风格) - 拓扑图新增布局切换和过滤控制 - 更新 README.md 部署指导,删除旧二进制部署说明
This commit is contained in:
@@ -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
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 778 B |
13
web/public/logo.svg
Normal file
13
web/public/logo.svg
Normal 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 |
401
web/src/App.vue
401
web/src/App.vue
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
362
web/src/views/Dashboard.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user