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

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

View File

@@ -13,6 +13,7 @@
- **SSH 系统信息获取**支持手动触发也可配置自动同步SSH 密码采用 AES-256-GCM 加密存储 - **SSH 系统信息获取**支持手动触发也可配置自动同步SSH 密码采用 AES-256-GCM 加密存储
- **数据导入/导出**JSON 格式完整备份与恢复 - **数据导入/导出**JSON 格式完整备份与恢复
- **操作日志**:记录所有增删改操作及来源 IP - **操作日志**:记录所有增删改操作及来源 IP
- **PVE 虚拟机管理**:支持 Proxmox VE 节点管理、VM 状态查看、远程启停操作
## 技术栈 ## 技术栈
@@ -38,7 +39,7 @@ go run .
前端开发服务器默认在 `http://localhost:5173`,并代理 `/api``http://127.0.0.1:8080` 前端开发服务器默认在 `http://localhost:5173`,并代理 `/api``http://127.0.0.1:8080`
### 生产构建(单二进制部署) ### 生产构建
```bash ```bash
make build make build
@@ -81,33 +82,37 @@ docker run -d \
lan-manager lan-manager
``` ```
### 方式三:使用预构建镜像(如果已发布到镜像仓库 ### 方式三:服务器部署(本地构建二进制 + 服务器构建镜像
适用于服务器网络较慢、无法直接拉取 node/golang 镜像的场景:
```bash ```bash
# 1. 本地交叉编译 Linux 二进制
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o server/lan-manager-linux ./server/main.go
# 2. 本地构建前端
cd web && npm run build
# 3. 将二进制和 dist/ 上传到服务器
cp server/lan-manager-linux /server/path/
cp -r web/dist /server/path/
# 4. 在服务器上构建镜像(只需 alpine 基础镜像,无需 node/golang
cd /server/path
docker build -t lan-manager .
# 5. 运行容器
docker run -d \ docker run -d \
--name lan-manager \ --name lan-manager \
-p 8080:8080 \ -p 8080:8080 \
-v $(pwd)/data:/app/data \ -v $(pwd)/data:/app/data \
-e ADMIN_PASS=your-secure-password \ -e ADMIN_PASS=your-secure-password \
your-registry/lan-manager:latest -e ENCRYPT_KEY=your-encrypt-key \
-e WEB_STATIC_PATH=/app/static \
--restart unless-stopped \
lan-manager
``` ```
## Debian 12 一键部署
```bash
# 1. 解压部署包
tar -xzf deploy/lan-manager-debian12.tar.gz
cd lan-manager-debian12
# 2. 编辑环境变量(务必修改 ADMIN_PASS 和 ENCRYPT_KEY
nano lan-manager.env
# 3. 执行安装脚本
sudo bash install.sh
```
安装完成后服务自动启动,通过 `systemctl status lan-manager` 查看状态。
## Nginx 反向代理示例 ## Nginx 反向代理示例
```nginx ```nginx
@@ -153,9 +158,6 @@ server {
``` ```
lan-manager/ lan-manager/
├── deploy/ # Debian 12 部署包
├── docs/ # 需求文档与计划
├── scripts/ # systemd 服务示例
├── server/ # Go 后端 ├── server/ # Go 后端
│ ├── main.go │ ├── main.go
│ ├── config/ │ ├── config/
@@ -167,7 +169,8 @@ lan-manager/
│ └── static/ # 嵌入的前端构建产物 │ └── static/ # 嵌入的前端构建产物
├── web/ # Vue 前端 ├── web/ # Vue 前端
│ ├── src/ │ ├── src/
── dist/ ── public/ # 静态资源logo、favicon 等)
│ └── dist/ # 构建产物
├── Dockerfile # Docker 构建文件 ├── Dockerfile # Docker 构建文件
├── docker-compose.yml # Docker Compose 配置 ├── docker-compose.yml # Docker Compose 配置
├── Makefile ├── Makefile
@@ -189,3 +192,4 @@ lan-manager/
2. **网络访问**:容器需要访问宿主机网络或目标局域网,默认使用 bridge 网络模式 2. **网络访问**:容器需要访问宿主机网络或目标局域网,默认使用 bridge 网络模式
3. **Ping 权限**:容器内可能需要 `NET_RAW` 能力才能执行 ICMP ping已在 Dockerfile 中通过 `setcap` 配置 3. **Ping 权限**:容器内可能需要 `NET_RAW` 能力才能执行 ICMP ping已在 Dockerfile 中通过 `setcap` 配置
4. **环境变量**:建议通过 `.env` 文件或 Docker Compose 管理敏感配置 4. **环境变量**:建议通过 `.env` 文件或 Docker Compose 管理敏感配置
5. **静态文件**:使用 `WEB_STATIC_PATH` 环境变量时,需确保路径在容器内存在且可读

View File

@@ -3,8 +3,13 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <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" /> <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> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

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

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

After

Width:  |  Height:  |  Size: 674 B

View File

@@ -16,156 +16,373 @@ onMounted(() => {
</script> </script>
<style> <style>
/* Light mode (default) */ /* ─── Light Mode ─── */
:root { :root {
--bg: #f8fafc; --page-bg: #f6f8fa;
--surface: #ffffff; --card-bg: #ffffff;
--surface-hover: #f8fafc; --card-hover: #f6f8fa;
--text: #0f172a; --sidebar-bg: #ffffff;
--text-secondary: #475569; --text-primary: #1f2328;
--text-muted: #94a3b8; --text-secondary: #57606a;
--border: #e2e8f0; --text-muted: #8c959f;
--border-strong: #cbd5e1; --border: #d0d7de;
--primary: #3b82f6; --border-strong: #c8cdd1;
--primary-weak: #eff6ff; --accent: #0969da;
--success: #22c55e; --accent-weak: #ddf4ff;
--warning: #f59e0b; --accent-hover: #0550ae;
--danger: #ef4444; --success: #1a7f37;
--radius: 14px; --success-weak: #dafbe1;
--shadow: 0 1px 3px rgba(2, 8, 23, 0.05), 0 1px 2px rgba(2, 8, 23, 0.03); --warning: #9a6700;
--shadow-lg: 0 10px 30px rgba(2, 8, 23, 0.08); --warning-weak: #fff8c5;
--danger: #cf222e;
--pill-bg: #0f172a; --danger-weak: #ffebe9;
--pill-text: #f8fafc; --radius: 12px;
--pill-border: rgba(255,255,255,0.06); --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 { html.dark {
--bg: #020617; --page-bg: #0d1117;
--surface: #0f172a; --card-bg: #161b22;
--surface-hover: #1e293b; --card-hover: #1c2128;
--text: #f8fafc; --sidebar-bg: #010409;
--text-secondary: #94a3b8; --text-primary: #c9d1d9;
--text-muted: #64748b; --text-secondary: #8b949e;
--border: #1e293b; --text-muted: #6e7681;
--border-strong: #334155; --border: #30363d;
--primary: #60a5fa; --border-strong: #484f58;
--primary-weak: rgba(59,130,246,0.15); --accent: #58a6ff;
--success: #34d399; --accent-weak: rgba(88, 166, 255, 0.15);
--warning: #fbbf24; --accent-hover: #79b8ff;
--danger: #f87171; --success: #3fb950;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3); --success-weak: rgba(63, 185, 80, 0.15);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5); --warning: #d29922;
--warning-weak: rgba(210, 153, 34, 0.15);
--pill-bg: #1e293b; --danger: #f85149;
--pill-text: #f8fafc; --danger-weak: rgba(248, 81, 73, 0.15);
--pill-border: #334155; --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; } * { box-sizing: border-box; }
html, body, #app { html, body, #app {
margin: 0; margin: 0; padding: 0; height: 100%;
padding: 0; font-family: var(--font-family);
height: 100%; background: var(--page-bg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; color: var(--text-primary);
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
transition: background .2s ease, color .2s ease; transition: background .25s ease, color .25s ease;
} }
.page { .page {
padding: 22px; padding: 24px;
max-width: 1400px; max-width: 1440px;
margin: 0 auto; margin: 0 auto;
} }
.card { .page-header {
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;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; 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 { .el-button {
border-radius: 8px; border-radius: var(--radius-sm);
font-weight: 500; font-weight: 500;
transition: all .15s ease;
} }
.el-button--primary { .el-button--primary {
--el-button-bg-color: #3b82f6; --el-button-bg-color: var(--accent);
--el-button-border-color: #3b82f6; --el-button-border-color: var(--accent);
--el-button-hover-bg-color: #2563eb; --el-button-hover-bg-color: var(--accent-hover);
--el-button-hover-border-color: #2563eb; --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 { html.dark .el-button--primary {
--el-button-bg-color: #3b82f6; --el-button-bg-color: #1f6feb;
--el-button-border-color: #3b82f6; --el-button-border-color: #1f6feb;
--el-button-hover-bg-color: #2563eb; --el-button-hover-bg-color: #388bfd;
--el-button-hover-border-color: #2563eb; --el-button-hover-border-color: #388bfd;
--el-button-text-color: #fff;
} }
.el-input__wrapper { .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 { .el-tag {
border-radius: 6px; border-radius: 6px;
font-weight: 500;
} }
.el-dialog { .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 { .el-dialog__header {
padding: 18px 20px 10px; padding: 20px 24px 12px;
font-weight: 700; font-weight: 700;
font-size: 16px;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
margin-right: 0;
} }
.el-dialog__body { .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 {
--el-table-header-bg-color: var(--surface-hover); --el-table-header-bg-color: var(--card-hover);
--el-table-row-hover-bg-color: var(--surface-hover); --el-table-row-hover-bg-color: var(--card-hover);
--el-table-border-color: var(--border); --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; overflow: hidden;
background: var(--card-bg);
} }
.el-table th.el-table__cell { .el-table th.el-table__cell {
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
background: var(--card-hover);
} }
.el-table .cell {
font-size: 13px;
}
.el-pagination .btn-prev, .el-pagination .btn-prev,
.el-pagination .btn-next, .el-pagination .btn-next,
.el-pager li { .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 */ .el-form-item__label {
::-webkit-scrollbar { color: var(--text-secondary);
width: 8px; font-weight: 500;
height: 8px;
} }
.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 { ::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.25); background: rgba(128, 128, 128, 0.25);
border-radius: 4px; border-radius: 4px;
} }
html.dark ::-webkit-scrollbar-thumb { html.dark ::-webkit-scrollbar-thumb { background: rgba(110, 118, 129, 0.4); }
background: rgba(148,163,184,0.25); ::-webkit-scrollbar-track { background: transparent; }
/* ─── Animations ─── */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
} }
::-webkit-scrollbar-track { @keyframes pulse {
background: transparent; 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> </style>

View File

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

View File

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

View File

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

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

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

View File

@@ -2,7 +2,9 @@
<div class="login-page"> <div class="login-page">
<div class="login-card"> <div class="login-card">
<div class="login-header"> <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> <h1>LAN Manager</h1>
<p>轻量级局域网资产管理平台</p> <p>轻量级局域网资产管理平台</p>
</div> </div>
@@ -10,23 +12,27 @@
<el-form-item> <el-form-item>
<div class="input-wrap"> <div class="input-wrap">
<el-icon class="input-icon"><User /></el-icon> <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> </div>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<div class="input-wrap"> <div class="input-wrap">
<el-icon class="input-icon"><Lock /></el-icon> <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> </div>
</el-form-item> </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"> <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> </div>
</el-form> </el-form>
</div> </div>
<div class="login-footer"> <div class="login-footer">
© LAN Manager <el-icon><Monitor /></el-icon> LAN Manager · 局域网资产管理
</div> </div>
</div> </div>
</template> </template>
@@ -34,7 +40,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' 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 { login as apiLogin, guestLogin as apiGuest } from '@/api'
import { setAuth } from '@/router' import { setAuth } from '@/router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -73,7 +79,7 @@ async function guestLogin() {
<style scoped> <style scoped>
.login-page { .login-page {
min-height: 100vh; 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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -84,12 +90,12 @@ async function guestLogin() {
.login-card { .login-card {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
background: rgba(255, 255, 255, 0.06); background: var(--card-bg);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--border);
border-radius: 20px; border-radius: var(--radius);
padding: 36px; padding: 36px;
backdrop-filter: blur(10px); box-shadow: var(--shadow-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); transition: all .25s ease;
} }
.login-header { .login-header {
@@ -100,65 +106,62 @@ async function guestLogin() {
width: 56px; width: 56px;
height: 56px; height: 56px;
border-radius: 14px; border-radius: 14px;
background: linear-gradient(135deg, #3b82f6, #60a5fa); background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: #fff;
font-weight: 800;
font-size: 22px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 0 auto 14px; 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 { .login-header h1 {
color: #fff; color: var(--text-primary);
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
margin: 0 0 6px; margin: 0 0 6px;
} }
.login-header p { .login-header p {
color: rgba(255, 255, 255, 0.55); color: var(--text-muted);
font-size: 13px; font-size: 13px;
margin: 0; margin: 0;
} }
.login-form { .login-form { width: 100%; }
width: 100%; .login-form :deep(.el-form-item) { margin-bottom: 16px; }
} .login-form :deep(.el-form-item__content) { display: block; width: 100%; }
.login-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.login-form :deep(.el-form-item__content) {
display: block;
width: 100%;
}
.input-wrap { .input-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
} }
.input-wrap :deep(.el-input) { .input-wrap :deep(.el-input) { width: 100%; }
width: 100%;
}
.input-wrap :deep(.el-input__wrapper) { .input-wrap :deep(.el-input__wrapper) {
padding-left: 38px; padding-left: 38px;
border-radius: 10px; border-radius: 10px;
background: rgba(255, 255, 255, 0.08); background: var(--card-hover);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1) inset; 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) { .input-wrap :deep(.el-input__inner) {
color: #fff; color: var(--text-primary);
font-size: 14px; font-size: 14px;
} }
.input-wrap :deep(.el-input__inner::placeholder) { .input-wrap :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.45); color: var(--text-muted);
} }
.input-icon { .input-icon {
position: absolute; position: absolute;
left: 12px; left: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: rgba(255, 255, 255, 0.5); color: var(--text-muted);
font-size: 16px; font-size: 16px;
z-index: 1; z-index: 1;
} }
@@ -175,15 +178,18 @@ async function guestLogin() {
margin-top: 14px; margin-top: 14px;
} }
.login-guest :deep(.el-button) { .login-guest :deep(.el-button) {
color: rgba(255, 255, 255, 0.55); color: var(--text-muted);
} }
.login-guest :deep(.el-button:hover) { .login-guest :deep(.el-button:hover) {
color: #fff; color: var(--text-secondary);
} }
.login-footer { .login-footer {
margin-top: 24px; margin-top: 24px;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.35); color: var(--text-muted);
display: inline-flex;
align-items: center;
gap: 6px;
} }
</style> </style>

View File

@@ -2,29 +2,48 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<div class="page-title">操作日志</div> <div class="page-title">
<el-icon><Document /></el-icon>
操作日志
</div>
<div class="page-subtitle">查看最近的系统操作记录</div> <div class="page-subtitle">查看最近的系统操作记录</div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<el-icon class="search-icon"><Search /></el-icon>
<el-input v-model="search" placeholder="搜索操作内容" clearable style="width: 240px" /> <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> </div>
<div class="card"> <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 :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="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"> <el-table-column prop="target_type" label="对象" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" effect="plain" round>{{ row.target_type }}</el-tag> <el-tag size="small" effect="plain" round>{{ row.target_type }}</el-tag>
</template> </template>
</el-table-column> </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="details" label="详情" min-width="240" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="170"> <el-table-column prop="created_at" label="时间" width="170">
<template #default="{ row }"> <template #default="{ row }">
<el-icon :size="10"><Clock /></el-icon>
{{ formatTime(row.created_at) }} {{ formatTime(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
@@ -46,6 +65,9 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' 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' import { fetchLogs } from '@/api'
const logs = ref([]) const logs = ref([])
@@ -65,6 +87,20 @@ async function load() {
loading.value = false 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) { function formatTime(t) {
if (!t) return '-' if (!t) return '-'
const d = new Date(t) const d = new Date(t)
@@ -75,34 +111,24 @@ function formatTime(t) {
</script> </script>
<style scoped> <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 { .header-actions {
display: flex; display: flex;
align-items: center;
gap: 10px; gap: 10px;
} }
.search-icon {
color: var(--text-muted);
font-size: 16px;
margin-right: -4px;
}
.table-header { .table-header {
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 10px; margin-bottom: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
} }
.pagination-bar { .pagination-bar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

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

View File

@@ -4,44 +4,75 @@
<div class="toolbar"> <div class="toolbar">
<div class="search-wrap"> <div class="search-wrap">
<el-icon class="search-icon"><Search /></el-icon> <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> </div>
<el-select v-model="osFilter" placeholder="系统类型" clearable @change="load" class="os-select"> <el-select v-model="osFilter" placeholder="系统类型" clearable @change="load" class="os-select">
<el-option label="Linux" value="Linux" /> <el-option label="Linux" value="Linux">
<el-option label="Windows" value="Windows" /> <el-icon><Platform /></el-icon> Linux
<el-option label="macOS" value="macOS" /> </el-option>
<el-option label="Other" value="Other" /> <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-select>
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openEdit()">添加机器</el-button> <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="success" :icon="Download" @click="handleExport">导出</el-button>
<el-button v-if="isAdmin" type="warning" @click="handleImportClick">导入数据</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"> <input ref="importFileRef" type="file" accept=".json" style="display:none" @change="handleImportFile">
</div> </div>
<!-- Cards --> <!-- Cards Grid -->
<div class="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 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="card-header">
<div class="title-row"> <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="hostname">{{ m.hostname }}</span>
<span class="status-badge" :class="m.is_online ? 'online' : 'offline'">{{ m.is_online ? '在线' : '离线' }}</span> <el-tag :type="m.is_online ? 'success' : 'danger'" size="small" effect="light" round class="status-tag">
<el-tag v-if="m.service_count" size="small" effect="plain" class="svc-tag" round>服务 {{ m.service_count }}</el-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>
<div class="meta-row"> <div class="meta-row">
<span v-if="isAdmin" class="meta-ip">{{ m.ip }}</span> <span v-if="isAdmin" class="meta-ip">
<span class="meta-item">{{ m.os_type }}</span> <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.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 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> </span>
</div> </div>
</div> </div>
<div v-if="m.cpu_info || m.memory_info || m.disk_info" class="stats-row"> <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 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-body">
<div class="pill-label">CPU</div> <div class="pill-label">CPU</div>
<div class="pill-bar"><div class="pill-fill" :style="{ width: extractPercent(m.cpu_info) + '%' }"></div></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 class="pill-value">{{ extractPercent(m.cpu_info) }}%</div>
</div> </div>
<div v-if="m.memory_info" class="stat-pill"> <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-body">
<div class="pill-label">RAM</div> <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> <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 class="pill-value">{{ extractDetail(m.memory_info) }}</div>
</div> </div>
<div v-if="getMainDisk(m.disk_info)" class="stat-pill"> <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-body">
<div class="pill-label">DISK</div> <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> <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"> <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"> <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> </el-tag>
<span v-if="m.listen_ports.split(',').length > 6" class="more-ports">+{{ m.listen_ports.split(',').length - 6 }}</span> <span v-if="m.listen_ports.split(',').length > 6" class="more-ports">+{{ m.listen_ports.split(',').length - 6 }}</span>
</div> </div>
<div v-if="m.ssh_synced_at" class="sync-time"> <div v-if="m.ssh_synced_at" class="sync-time">
<el-icon :size="10"><Clock /></el-icon>
同步于 {{ formatTime(m.ssh_synced_at) }} 同步于 {{ formatTime(m.ssh_synced_at) }}
</div> </div>
</div> </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 --> <!-- Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="editing.id ? '编辑机器' : '添加机器'" width="480px" class="modern-dialog" destroy-on-close> <el-dialog v-model="dialogVisible" :title="editing.id ? '编辑机器' : '添加机器'" width="500px" class="modern-dialog" destroy-on-close>
<el-form :model="editing" label-width="90px"> <el-form :model="editing" label-width="100px">
<el-form-item label="主机名" required> <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>
<el-form-item label="IP" required> <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>
<el-form-item label="MAC"> <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>
<el-form-item label="系统" required> <el-form-item label="系统" required>
<el-select v-model="editing.os_type" style="width:100%"> <el-select v-model="editing.os_type" style="width:100%">
<el-option label="Linux" value="Linux" /> <el-option label="Linux" value="Linux">
<el-option label="Windows" value="Windows" /> <el-icon><Platform /></el-icon> Linux
<el-option label="macOS" value="macOS" /> </el-option>
<el-option label="Other" value="Other" /> <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-select>
</el-form-item> </el-form-item>
<el-form-item label="系统版本"> <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-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-form-item label="SSH 端口">
<el-input-number v-model="editing.ssh_port" :min="1" :max="65535" style="width:100%" /> <el-input-number v-model="editing.ssh_port" :min="1" :max="65535" style="width:100%" />
</el-form-item> </el-form-item>
<el-form-item label="SSH 用户"> <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>
<el-form-item label="SSH 密码"> <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>
<el-form-item label="备注"> <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-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-form-item label="PVE 主机">
<el-select v-model="editing.pve_host_id" placeholder="选择 PVE 主机" clearable style="width:100%"> <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-option v-for="h in pveHosts" :key="h.id" :label="h.name" :value="h.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="虚拟机 ID"> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <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> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -138,7 +206,12 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' 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 { fetchMachines, createMachine, updateMachine, checkAuth, uiRefreshInterval, exportData, importData, fetchPVEHosts, fetchVMStatus } from '@/api'
import { getAuth } from '@/router' import { getAuth } from '@/router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -157,7 +230,6 @@ let timer = null
onMounted(async () => { onMounted(async () => {
await load() await load()
await checkAuth() await checkAuth()
// 加载 PVE 主机列表
if (isAdmin) { if (isAdmin) {
try { try {
const res = await fetchPVEHosts() const res = await fetchPVEHosts()
@@ -177,7 +249,6 @@ onUnmounted(() => {
async function load() { async function load() {
const res = await fetchMachines({ search: search.value, os_type: osFilter.value }) const res = await fetchMachines({ search: search.value, os_type: osFilter.value })
machines.value = res.data machines.value = res.data
// 并行查询有关联 PVE 的机器的 VM 实时状态
if (isAdmin) { if (isAdmin) {
const pveMachines = machines.value.filter(m => m.pve_host_id && m.pve_vmid) const pveMachines = machines.value.filter(m => m.pve_host_id && m.pve_vmid)
await Promise.all(pveMachines.map(async (m) => { await Promise.all(pveMachines.map(async (m) => {
@@ -268,8 +339,6 @@ function extractPercent(str) {
function extractDetail(str) { function extractDetail(str) {
if (!str) return '' 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() let s = str.replace(/\s*\(\d+(?:\.\d+)?%\)\s*/g, '').trim()
if (s.includes(',')) s = s.split(',')[0].trim() if (s.includes(',')) s = s.split(',')[0].trim()
s = s.replace(/^\/\s+/, '').trim() s = s.replace(/^\/\s+/, '').trim()
@@ -278,14 +347,14 @@ function extractDetail(str) {
} }
function getMemBarColor(v) { function getMemBarColor(v) {
if (v >= 90) return '#f87171' if (v >= 90) return 'var(--danger)'
if (v >= 70) return '#fbbf24' if (v >= 70) return 'var(--warning)'
return '#34d399' return 'var(--success)'
} }
function getDiskBarColor(v) { function getDiskBarColor(v) {
if (v >= 90) return '#f87171' if (v >= 90) return 'var(--danger)'
if (v >= 80) return '#fbbf24' if (v >= 80) return 'var(--warning)'
return '#60a5fa' return 'var(--accent)'
} }
function parseDisks(str) { function parseDisks(str) {
@@ -296,28 +365,31 @@ function parseDisks(str) {
while ((m = regex.exec(str)) !== null) { while ((m = regex.exec(str)) !== null) {
disks.push({ mount: m[1], detail: m[2], percent: parseInt(m[3], 10) }) disks.push({ mount: m[1], detail: m[2], percent: parseInt(m[3], 10) })
} }
if (disks.length > 0) { if (disks.length > 0) return disks
return disks
}
// fallback: single disk old format
const pct = extractPercent(str) const pct = extractPercent(str)
const det = extractDetail(str) const det = extractDetail(str)
if (det) { if (det) return [{ mount: '', detail: det, percent: pct }]
return [{ mount: '', detail: det, percent: pct }]
}
return [] return []
} }
function getMainDisk(str) { function getMainDisk(str) {
const disks = parseDisks(str) const disks = parseDisks(str)
const root = disks.find(d => d.mount === '/') const root = disks.find(d => d.mount === '/')
return root || null return root || disks[0] || null
} }
function osClass(os) { function osClass(os) {
const map = { Linux: 'os-linux', Windows: 'os-windows', macOS: 'os-macos' } const map = { Linux: 'os-linux', Windows: 'os-windows', macOS: 'os-macos' }
return map[os] || 'os-other' 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) { function formatTime(t) {
if (!t) return '-' if (!t) return '-'
@@ -334,6 +406,7 @@ function formatTime(t) {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 20px; margin-bottom: 20px;
flex-wrap: wrap;
} }
.search-wrap { .search-wrap {
position: relative; position: relative;
@@ -342,8 +415,6 @@ function formatTime(t) {
} }
.search-wrap :deep(.el-input__wrapper) { .search-wrap :deep(.el-input__wrapper) {
padding-left: 34px; padding-left: 34px;
border-radius: 10px;
box-shadow: 0 0 0 1px var(--border) inset;
} }
.search-icon { .search-icon {
position: absolute; position: absolute;
@@ -354,10 +425,6 @@ function formatTime(t) {
font-size: 16px; font-size: 16px;
z-index: 1; z-index: 1;
} }
.os-select :deep(.el-input__wrapper) {
border-radius: 10px;
box-shadow: 0 0 0 1px var(--border) inset;
}
.cards-grid { .cards-grid {
display: grid; display: grid;
@@ -365,21 +432,18 @@ function formatTime(t) {
gap: 16px; gap: 16px;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.cards-grid { .cards-grid { grid-template-columns: 1fr; }
grid-template-columns: 1fr;
}
} }
.server-card { .server-card {
background: var(--surface); background: var(--card-bg);
border-radius: var(--radius); border-radius: var(--radius);
padding: 18px; padding: 18px;
box-shadow: var(--shadow); box-shadow: var(--shadow-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-top-width: 3px; border-left: 3px solid var(--border);
border-top-color: var(--border-strong);
cursor: pointer; cursor: pointer;
transition: transform .12s ease, box-shadow .2s ease, opacity .2s ease, border-color .2s ease; transition: all .15s ease;
} }
.server-card:hover { .server-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -391,13 +455,12 @@ function formatTime(t) {
} }
.server-card.guest-card:hover { .server-card.guest-card:hover {
transform: none; transform: none;
box-shadow: var(--shadow); box-shadow: var(--shadow-card);
border-color: var(--border); border-color: var(--border);
} }
.server-card.offline-card { .server-card.offline-card {
opacity: 0.65; opacity: 0.7;
border-top-color: var(--border); border-left-color: var(--danger);
} }
.server-card.offline-card .hostname { .server-card.offline-card .hostname {
color: var(--text-muted); color: var(--text-muted);
@@ -412,54 +475,52 @@ function formatTime(t) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap;
} }
.os-dot { .os-badge {
width: 10px; display: flex;
height: 10px; align-items: center;
border-radius: 50%; gap: 4px;
flex-shrink: 0; padding: 3px 8px;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); border-radius: 6px;
}
.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 {
font-size: 11px; font-size: 11px;
padding: 2px 8px; font-weight: 700;
border-radius: 999px; color: #fff;
font-weight: 600; flex-shrink: 0;
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;
} }
.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 { .hostname {
font-weight: 700; font-weight: 700;
font-size: 15px; 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 { .svc-tag {
font-size: 11px; font-size: 11px;
height: 18px; height: 20px;
padding: 0 8px; padding: 0 8px;
color: var(--text-secondary); color: var(--text-secondary);
border-color: var(--border); border-color: var(--border);
display: inline-flex;
align-items: center;
gap: 4px;
} }
.meta-row { .meta-row {
@@ -471,12 +532,15 @@ html.dark .status-badge.offline {
color: var(--text-secondary); color: var(--text-secondary);
} }
.meta-ip { .meta-ip {
color: var(--text); color: var(--text-primary);
font-weight: 500; font-weight: 500;
background: var(--surface-hover); background: var(--card-hover);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 1px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
} }
.meta-item + .meta-item::before, .meta-item + .meta-item::before,
.meta-uptime::before { .meta-uptime::before {
@@ -484,7 +548,8 @@ html.dark .status-badge.offline {
margin: 0 6px; margin: 0 6px;
color: var(--text-muted); 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 { .stats-row {
margin-top: 14px; margin-top: 14px;
@@ -501,25 +566,26 @@ html.dark .status-badge.offline {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
transition: background .2s ease, border-color .2s ease; transition: all .2s ease;
} }
.pill-icon { .pill-icon {
width: 22px; height: 22px; border-radius: 5px; width: 24px; height: 24px; border-radius: 5px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800; font-size: 10px; font-weight: 800;
background: rgba(255,255,255,0.10); background: rgba(255,255,255,0.10);
flex-shrink: 0; flex-shrink: 0;
color: #fff;
} }
.pill-icon.cpu { color: #7dd3fc; } .pill-icon.cpu { background: rgba(88, 166, 255, 0.3); color: #58a6ff; }
.pill-icon.mem { color: #86efac; } .pill-icon.mem { background: rgba(63, 185, 80, 0.3); color: #3fb950; }
.pill-icon.disk { color: #93c5fd; } .pill-icon.disk { background: rgba(163, 113, 247, 0.3); color: #a371f7; }
.pill-body { .pill-body {
flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; 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-label { font-size: 10px; color: var(--text-muted); font-weight: 600; }
.pill-bar { height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; } .pill-bar { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
.pill-fill { height: 100%; border-radius: 2px; background: rgba(255,255,255,0.9); transition: width .3s ease; } .pill-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width .3s ease; }
.pill-value { font-size: 11px; font-weight: 700; color: #fff; white-space: nowrap; } .pill-value { font-size: 11px; font-weight: 700; color: var(--text-primary); white-space: nowrap; }
.ports-row { .ports-row {
margin-top: 12px; margin-top: 12px;
@@ -530,9 +596,12 @@ html.dark .status-badge.offline {
.port-tag { .port-tag {
font-size: 11px; font-size: 11px;
height: 20px; height: 20px;
padding: 0 8px; padding: 0 7px;
color: var(--text-secondary); color: var(--text-secondary);
border-color: var(--border); border-color: var(--border);
display: inline-flex;
align-items: center;
gap: 4px;
} }
.more-ports { font-size: 11px; color: var(--text-muted); line-height: 20px; } .more-ports { font-size: 11px; color: var(--text-muted); line-height: 20px; }
@@ -540,29 +609,8 @@ html.dark .status-badge.offline {
margin-top: 10px; margin-top: 10px;
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
} display: inline-flex;
align-items: center;
.vm-status { gap: 4px;
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;
} }
</style> </style>

View File

@@ -2,91 +2,375 @@
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<div class="page-title">拓扑图</div> <div class="page-title">
<div class="page-subtitle">展示机器之间的服务指向和关联关系</div> <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> </div>
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="$router.push('/machines')">添加机器</el-button>
</div> </div>
<div class="topo-card"> <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 id="topo" class="topo-canvas"></div>
<!-- 图例 -->
<div class="legend"> <div class="legend">
<div class="legend-item"><span class="dot online"></span> 在线</div> <div class="legend-title">
<div class="legend-item"><span class="dot offline"></span> 离线</div> <el-icon><InfoFilled /></el-icon> 图例
<div class="legend-item"><span class="line service"></span> 服务指向</div> </div>
<div class="legend-item"><span class="line relation"></span> 关联关系</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> </div>
</div> </div>
</template> </template>
<script setup> <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 { 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 { fetchMachines, fetchAllServices as fetchServicesAll, fetchRelationships } from '@/api'
import { getAuth } from '@/router' import { getAuth } from '@/router'
const router = useRouter()
const isAdmin = getAuth().is_admin const isAdmin = getAuth().is_admin
let graph = null let graph = null
let observer = 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() { function getIsDark() {
return document.documentElement.classList.contains('dark') return document.documentElement.classList.contains('dark')
} }
function themeFill() { function themeFill() {
return getIsDark() ? '#0f172a' : '#ffffff' return getIsDark() ? '#161b22' : '#ffffff'
} }
function themeText() { function themeText() {
return getIsDark() ? '#f8fafc' : '#111827' return getIsDark() ? '#c9d1d9' : '#1f2328'
}
function themeBg() {
return getIsDark() ? '#0d1117' : '#f6f8fa'
}
function themeBorder() {
return getIsDark() ? '#30363d' : '#d0d7de'
} }
function themeBg() { function osColor(os) {
return getIsDark() ? '#020617' : '#fafafa' 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() { function renderGraph() {
if (!graph) return
const container = document.getElementById('topo') const container = document.getElementById('topo')
if (!container || !graph) return if (!container) return
container.style.background = themeBg() container.style.background = themeBg()
const isDark = getIsDark()
const fill = themeFill()
const text = themeText()
graph.getNodes().forEach(node => { graph.getNodes().forEach(node => {
const model = node.getModel() const model = node.getModel()
graph.updateItem(node, { graph.updateItem(node, {
style: { style: {
fill: themeFill(), fill,
stroke: osColor(model.os), stroke: osColor(model.os),
lineWidth: 2, lineWidth: 2,
radius: 8, radius: 8,
shadowColor: getIsDark() ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.06)', shadowColor: isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.06)',
shadowBlur: 8, shadowBlur: 12,
shadowOffsetY: 2 shadowOffsetY: 3,
}, },
labelCfg: { labelCfg: {
style: { fill: themeText(), fontSize: 13, fontWeight: 600 } style: { fill: text, fontSize: 13, fontWeight: 600, fontFamily: 'var(--font-family)' }
}, },
badgeCfg: { badgeCfg: {
position: 'topRight', position: 'topRight',
style: { style: {
fill: model.online ? '#22c55e' : '#ef4444', fill: model.online ? '#3fb950' : '#f85149',
stroke: themeFill(), stroke: fill,
lineWidth: 2 lineWidth: 2,
} }
} },
}) })
}) })
graph.getEdges().forEach(edge => { graph.getEdges().forEach(edge => {
const model = edge.getModel() const model = edge.getModel()
const isRel = String(model.id).startsWith('rel-') const isRel = String(model.id).startsWith('rel-')
const color = isRel ? '#f97316' : '#3b82f6' const color = isRel ? '#d29922' : '#58a6ff'
graph.updateItem(edge, { graph.updateItem(edge, {
labelCfg: { labelCfg: {
style: { style: {
fill: color, fill: color,
fontSize: 11, fontSize: 11,
fontWeight: 500, 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() graph.paint()
} }
onMounted(async () => { async function initGraph() {
const [machinesRes, servicesRes, relsRes] = await Promise.all([ const [machinesRes, servicesRes, relsRes] = await Promise.all([
fetchMachines(), fetchMachines(),
fetchServicesAll(), fetchServicesAll(),
@@ -104,248 +388,376 @@ onMounted(async () => {
const machines = machinesRes.data const machines = machinesRes.data
const services = servicesRes.data const services = servicesRes.data
const relationships = relsRes.data const relationships = relsRes.data
rawData.value = { machines, services, relationships }
const machineMap = {} const machineMap = {}
machines.forEach(m => machineMap[m.id] = m) 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') const container = document.getElementById('topo')
if (!container) return
container.style.background = themeBg() container.style.background = themeBg()
if (graph) {
graph.destroy()
graph = null
}
graph = new Graph({ graph = new Graph({
container: 'topo', container: 'topo',
width: container.clientWidth, width: container.clientWidth,
height: container.clientHeight, height: container.clientHeight,
layout: { layout: getLayoutConfig(),
type: 'force',
preventOverlap: true,
linkDistance: 140,
nodeStrength: -80,
edgeStrength: 0.2
},
defaultNode: { defaultNode: {
type: 'rect', type: 'rect',
size: [110, 40] size: [160, 48]
}, },
defaultEdge: { defaultEdge: {
type: 'line', type: 'quadratic',
style: { endArrow: true } style: { endArrow: true }
}, },
modes: { default: ['drag-node', 'drag-canvas', 'zoom-canvas'] }, modes: {
default: ['drag-node', 'drag-canvas', 'zoom-canvas'],
},
fitView: true, fitView: true,
fitViewPadding: 20 fitViewPadding: 40,
}) })
const nodes = buildNodes(machines)
const edges = buildEdges(services, relationships, machineMap)
graph.data({ nodes, edges }) graph.data({ nodes, edges })
graph.render() graph.render()
// 节点点击事件
graph.on('node:click', e => { graph.on('node:click', e => {
const nodeId = e.item.getModel().id const model = e.item.getModel()
highlightNode(nodeId) selectedNode.value = model.data
highlightNode(model.id)
}) })
window.addEventListener('resize', () => { // 画布点击取消选中
const c = document.getElementById('topo') graph.on('canvas:click', () => {
if (c && graph) { selectedNode.value = null
graph.changeSize(c.clientWidth, c.clientHeight) clearHighlight()
graph.fitView()
}
}) })
// 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(() => { observer = new MutationObserver(() => {
renderGraph() renderGraph()
}) })
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) 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(() => { onUnmounted(() => {
if (observer) observer.disconnect() if (observer) observer.disconnect()
window.removeEventListener('resize', handleResize)
if (graph) { if (graph) {
graph.destroy() graph.destroy()
graph = null 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> </script>
<style scoped> <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 { .topo-card {
background: var(--surface); background: var(--card-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow); box-shadow: var(--shadow-card);
padding: 12px;
position: relative; position: relative;
height: calc(100vh - 220px); height: calc(100vh - 200px);
min-height: 420px; 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 { .topo-canvas {
flex: 1;
width: 100%; width: 100%;
height: 100%; min-height: 0;
border-radius: 10px; border-radius: 0 0 var(--radius) var(--radius);
overflow: hidden; overflow: hidden;
transition: background .2s ease; transition: background .25s ease;
} }
.legend { .legend {
position: absolute; position: absolute;
left: 16px; left: 16px;
bottom: 16px; bottom: 16px;
background: var(--surface); background: var(--card-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: var(--radius-sm);
padding: 10px 12px; padding: 12px 14px;
display: flex; display: flex;
gap: 14px; flex-direction: column;
box-shadow: var(--shadow); gap: 8px;
backdrop-filter: blur(6px); box-shadow: var(--shadow-card);
transition: background .2s ease, border-color .2s ease; backdrop-filter: blur(8px);
transition: all .25s ease;
z-index: 5;
min-width: 120px;
} }
.legend-item { .legend-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 12px; 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); color: var(--text-secondary);
} }
.legend-divider {
height: 1px;
background: var(--border);
margin: 2px 0;
}
.dot { .dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0;
} }
.dot.online { background: #22c55e; } .dot.online { background: #3fb950; box-shadow: 0 0 6px rgba(63, 185, 80, 0.4); }
.dot.offline { background: #ef4444; } .dot.offline { background: #f85149; box-shadow: 0 0 6px rgba(248, 81, 73, 0.4); }
.line { .line {
width: 16px; width: 16px;
height: 2px; height: 2px;
border-radius: 1px; border-radius: 1px;
flex-shrink: 0;
} }
.line.service { background: #3b82f6; } .line.service { background: #58a6ff; }
.line.relation { .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; 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> </style>