From e929d4842e984e6aba64d074b97728dc7d7837fa Mon Sep 17 00:00:00 2001 From: 13659 <1365957941@qq.com> Date: Sun, 6 Sep 2026 20:10:33 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=85=A8=E9=9D=A2=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=89=8D=E7=AB=AF=20UI=20=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 theme.css:替换刺眼青色背景为柔和灰蓝(#eef1f6),品牌色改为靛蓝(#5b6ef5) - 统一按钮体系:主按钮36px固定高度,菜单按钮30px,表格按钮28px,尺寸协调 - 登录页:服务器地址与端口并排布局,输入框40px高度统一,卡片阴影加深 - 面板页:导航栏56px高度,内容区居中限宽1400px,滚动条美化 - 概览页:auto-fit网格+340px最小宽度防止卡片不均,进程表全宽跨列 - 进程表格:table-layout:fixed + 列宽分配 + nowrap 防止串行溢出 - 所有文字元素加 white-space:nowrap 防止换行串行 - 更新 Electron 窗口背景色匹配新主题 --- src/main/index.js | 45 ++- src/preload/index.js | 1 + src/renderer/index.html | 2 +- src/renderer/src/main.css | 33 +- src/renderer/src/pages/Login.css | 149 +++++--- src/renderer/src/pages/Login.jsx | 48 +-- src/renderer/src/pages/Panel.css | 51 ++- src/renderer/src/pages/Panel.jsx | 9 +- src/renderer/src/pages/overview/CardShell.jsx | 51 +++ src/renderer/src/pages/overview/Overview.css | 324 ++++++++++++++++++ src/renderer/src/pages/overview/Overview.jsx | 18 + .../src/pages/overview/cards/CpuCard.jsx | 81 +++++ .../src/pages/overview/cards/DiskCard.jsx | 91 +++++ .../src/pages/overview/cards/MemoryCard.jsx | 82 +++++ .../src/pages/overview/cards/NetworkCard.jsx | 92 +++++ .../src/pages/overview/cards/ProcessCard.jsx | 172 ++++++++++ src/renderer/src/pages/overview/format.js | 27 ++ src/renderer/src/pages/overview/useMetric.js | 47 +++ src/renderer/src/theme.css | 138 ++++++++ 19 files changed, 1366 insertions(+), 95 deletions(-) create mode 100644 src/renderer/src/pages/overview/CardShell.jsx create mode 100644 src/renderer/src/pages/overview/Overview.css create mode 100644 src/renderer/src/pages/overview/Overview.jsx create mode 100644 src/renderer/src/pages/overview/cards/CpuCard.jsx create mode 100644 src/renderer/src/pages/overview/cards/DiskCard.jsx create mode 100644 src/renderer/src/pages/overview/cards/MemoryCard.jsx create mode 100644 src/renderer/src/pages/overview/cards/NetworkCard.jsx create mode 100644 src/renderer/src/pages/overview/cards/ProcessCard.jsx create mode 100644 src/renderer/src/pages/overview/format.js create mode 100644 src/renderer/src/pages/overview/useMetric.js create mode 100644 src/renderer/src/theme.css diff --git a/src/main/index.js b/src/main/index.js index 84e3411..282dba0 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -46,7 +46,7 @@ function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, - backgroundColor: '#d2f8fc', + backgroundColor: '#eef1f6', webPreferences: { preload: path.join(__dirname, '../preload/index.js'), sandbox: false, @@ -121,6 +121,49 @@ ipcMain.handle('ssh:disconnect', () => { ipcMain.handle('ssh:session', () => activeSession) +// 在活动的 SSH 连接上执行命令 +function execSsh(cmd, timeoutMs = 10000) { + return new Promise((resolve) => { + if (!activeConnection) { + resolve({ ok: false, message: 'SSH 连接不存在' }) + return + } + + let settled = false + let stdout = '' + let stderr = '' + + const timer = setTimeout(() => { + if (!settled) { + settled = true + resolve({ ok: false, message: '命令执行超时' }) + } + }, timeoutMs) + + activeConnection.exec(cmd, (err, stream) => { + if (err) { + clearTimeout(timer) + if (!settled) { + settled = true + resolve({ ok: false, message: err.message }) + } + return + } + stream.on('data', (chunk) => (stdout += chunk.toString())) + stream.stderr.on('data', (chunk) => (stderr += chunk.toString())) + stream.on('close', (code) => { + clearTimeout(timer) + if (!settled) { + settled = true + resolve({ ok: true, code, stdout, stderr }) + } + }) + }) + }) +} + +ipcMain.handle('ssh:exec', (event, { cmd, timeout }) => execSsh(cmd, timeout)) + // ---------- IPC:凭据 ---------- ipcMain.handle('cred:save', (event, { host, port, username, password }) => { const list = readCredentials() diff --git a/src/preload/index.js b/src/preload/index.js index f9b51f0..cdcdc56 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('panel', { connect: (config) => ipcRenderer.invoke('ssh:connect', config), disconnect: () => ipcRenderer.invoke('ssh:disconnect'), getSession: () => ipcRenderer.invoke('ssh:session'), + exec: (cmd, timeout) => ipcRenderer.invoke('ssh:exec', { cmd, timeout }), // 凭据(密码加密保存在本机) saveCredential: (cred) => ipcRenderer.invoke('cred:save', cred), listCredentials: () => ipcRenderer.invoke('cred:list'), diff --git a/src/renderer/index.html b/src/renderer/index.html index 16b0cc6..93a12ac 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,7 @@ - SSH Panel + SSH Panel · 服务器管理面板 span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; } .saved .del { - color: #c0392b; + flex-shrink: 0; border: none; background: none; + color: var(--ink-muted); cursor: pointer; - font-size: 13px; + font-size: 12px; + font-family: inherit; + padding: 4px 10px; + border-radius: var(--radius-sm); + transition: color 0.15s ease, background 0.15s ease; +} + +.saved .del:hover { + color: var(--status-critical); + background: rgba(239, 68, 68, 0.08); } diff --git a/src/renderer/src/pages/Login.jsx b/src/renderer/src/pages/Login.jsx index 7004bc5..5ddea94 100644 --- a/src/renderer/src/pages/Login.jsx +++ b/src/renderer/src/pages/Login.jsx @@ -63,28 +63,30 @@ export default function Login({ onConnected }) {

SSH Panel

连接到你的服务器

-
- - setHost(e.target.value)} - /> -
-
- - setPort(e.target.value)} - /> +
+
+ + setHost(e.target.value)} + /> +
+
+ + setPort(e.target.value)} + /> +
@@ -115,7 +117,7 @@ export default function Login({ onConnected }) { /> 记住密码(加密保存在本机) -

{status}

diff --git a/src/renderer/src/pages/Panel.css b/src/renderer/src/pages/Panel.css index 670ccd8..b219072 100644 --- a/src/renderer/src/pages/Panel.css +++ b/src/renderer/src/pages/Panel.css @@ -1,20 +1,27 @@ +/* ============================================================ + 顶部导航栏 + 主内容区 + ============================================================ */ .menu { position: fixed; top: 0; left: 0; right: 0; - height: 48px; - background: #123a6b; + height: 56px; + background: var(--brand); color: #ffffff; display: flex; align-items: center; - padding: 0 20px; + padding: 0 24px; gap: 28px; + z-index: 100; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); } .menu .brand { font-size: 16px; - font-weight: 600; + font-weight: 700; + letter-spacing: 0.01em; + white-space: nowrap; } .menu nav { @@ -24,44 +31,50 @@ } .menu nav a { - padding: 6px 14px; - border-radius: 6px; + padding: 7px 16px; + border-radius: var(--radius-btn); font-size: 14px; + font-weight: 500; cursor: pointer; user-select: none; + color: rgba(255, 255, 255, 0.8); + transition: background 0.15s ease, color 0.15s ease; + white-space: nowrap; } .menu nav a:hover { - background: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.1); + color: #ffffff; } .menu nav a.active { background: rgba(255, 255, 255, 0.18); + color: #ffffff; } .menu .right { display: flex; align-items: center; - gap: 12px; + gap: 14px; font-size: 13px; + white-space: nowrap; } -.menu .right button { - background: rgba(255, 255, 255, 0.14); - color: #ffffff; - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 6px; - padding: 5px 12px; - cursor: pointer; - font-size: 13px; +.menu .right span { + color: rgba(255, 255, 255, 0.78); + overflow: hidden; + text-overflow: ellipsis; } main { - padding: 68px 20px 20px; + padding: 76px 28px 28px; + max-width: 1400px; + margin: 0 auto; } main .hint { text-align: center; - color: #7a8a99; - margin-top: 120px; + color: var(--ink-muted); + font-size: 15px; + margin-top: 140px; } diff --git a/src/renderer/src/pages/Panel.jsx b/src/renderer/src/pages/Panel.jsx index 92874aa..6eaa9f0 100644 --- a/src/renderer/src/pages/Panel.jsx +++ b/src/renderer/src/pages/Panel.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import Overview from './overview/Overview' import './Panel.css' const NAV_ITEMS = ['概览', '容器', '终端'] @@ -25,11 +26,15 @@ export default function Panel({ session, onLogout }) { {session.username}@{session.host} - +
-

连接成功!这里将展示服务器信息,下一步从这里开始扩展。

+ {active === '概览' && } + {active === '容器' &&

容器功能待开发

} + {active === '终端' &&

终端功能待开发

}
) diff --git a/src/renderer/src/pages/overview/CardShell.jsx b/src/renderer/src/pages/overview/CardShell.jsx new file mode 100644 index 0000000..a666bad --- /dev/null +++ b/src/renderer/src/pages/overview/CardShell.jsx @@ -0,0 +1,51 @@ +// 卡片外壳:统一处理"检测中 / 正常 / 缺少依赖(置灰+安装按钮) / 安装中 / 出错"五种状态 +export default function CardShell({ + title, + subtitle, + status, + message, + installable, + onInstall, + onRetry, + children, +}) { + const dimmed = status === 'missing' || status === 'installing' + + return ( +
+
+

{title}

+ {status === 'ok' && subtitle ? {subtitle} : null} +
+
+ {status === 'checking' &&

正在检测服务器环境...

} + {status === 'ok' && children} + {status === 'missing' && ( +
+ {installable ? ( + <> +

此功能需要先在服务器上安装「{message}」

+ + + ) : ( +

当前服务器不支持此功能(需要 Linux 系统)

+ )} +
+ )} + {status === 'installing' && ( +

正在安装「{message}」,可能需要几分钟...

+ )} + {status === 'error' && ( +
+

{message}

+ +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/pages/overview/Overview.css b/src/renderer/src/pages/overview/Overview.css new file mode 100644 index 0000000..f92e0e7 --- /dev/null +++ b/src/renderer/src/pages/overview/Overview.css @@ -0,0 +1,324 @@ +/* ============================================================ + 概览页 —— 卡片网格 + 各类指标卡片 + ============================================================ */ +.overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 20px; + align-items: start; +} + +/* ---- 卡片外壳 ---- */ +.metric-card { + background: var(--card-bg); + border: 1px solid var(--hairline); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + padding: 22px 24px; + min-height: 180px; + display: flex; + flex-direction: column; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + overflow: hidden; +} + +.metric-card:hover { + box-shadow: var(--shadow-card-hover); + border-color: var(--hairline-strong); +} + +.metric-card header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.metric-card h3 { + font-size: 14px; + font-weight: 600; + color: var(--ink-secondary); + letter-spacing: 0.02em; + white-space: nowrap; +} + +.metric-card .sub { + font-size: 12px; + color: var(--ink-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-card .body { + flex: 1; + min-width: 0; +} + +.placeholder { + color: var(--ink-muted); + font-size: 14px; + padding-top: 20px; +} + +.error-text { + color: var(--status-critical); + font-size: 14px; + padding-top: 20px; +} + +/* ---- 大数字 ---- */ +.big-value { + font-size: 36px; + font-weight: 700; + color: var(--ink-strong); + letter-spacing: -0.01em; + line-height: 1.15; + font-variant-numeric: tabular-nums; +} + +.detail { + font-size: 13px; + color: var(--ink-secondary); + margin-top: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---- 仪表条 ---- */ +.meter { + height: 8px; + background: var(--track); + border-radius: 999px; + margin-top: 18px; + overflow: hidden; +} + +.meter-fill { + height: 100%; + border-radius: 999px; + transition: width 0.6s ease, background-color 0.3s ease; +} + +.meter-fill.good { + background: var(--status-good); +} + +.meter-fill.warning { + background: var(--status-warning); +} + +.meter-fill.serious { + background: var(--status-serious); +} + +.meter-fill.critical { + background: var(--status-critical); +} + +/* ---- 缺少依赖的置灰卡片 ---- */ +.metric-card.missing { + background: var(--surface-dim); + border: 1px dashed var(--hairline-strong); + box-shadow: none; +} + +.metric-card.missing:hover { + box-shadow: none; + border-color: var(--hairline-strong); +} + +.metric-card.missing h3, +.metric-card.missing .body { + color: var(--ink-muted); +} + +.missing-box, +.error-box { + text-align: center; + padding: 24px 0 8px; + font-size: 14px; + color: var(--ink-muted); + line-height: 1.6; +} + +.missing-box .btn, +.error-box .btn { + margin-top: 16px; +} + +.error-box { + color: var(--status-critical); +} + +/* ============================================================ + 网络速率卡片 + ============================================================ */ +.net-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; +} + +.net-row + .net-row { + border-top: 1px solid var(--divider); +} + +.net-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.net-dot.dot-1 { + background: var(--series-1); +} + +.net-dot.dot-2 { + background: var(--series-2); +} + +.net-row .label { + font-size: 14px; + color: var(--ink-secondary); + white-space: nowrap; +} + +.net-row .rate { + margin-left: auto; + font-size: 16px; + font-weight: 600; + color: var(--ink-strong); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* ============================================================ + 进程表格 —— 全宽跨列 + ============================================================ */ +.overview-grid > section:last-child { + grid-column: 1 / -1; +} + +.proc-table { + width: 100%; + font-size: 13px; + border-collapse: collapse; + table-layout: fixed; +} + +.proc-table th { + text-align: left; + color: var(--ink-muted); + font-weight: 500; + font-size: 12px; + letter-spacing: 0.02em; + padding: 4px 10px 10px; + border-bottom: 1px solid var(--divider); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.proc-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(0, 0, 0, 0.04); + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 列宽分配 */ +.proc-table th:nth-child(1), +.proc-table td:nth-child(1) { + width: auto; +} + +.proc-table th:nth-child(2), +.proc-table td:nth-child(2) { + width: 70px; +} + +.proc-table th:nth-child(3), +.proc-table td:nth-child(3) { + width: 90px; +} + +.proc-table th:nth-child(4), +.proc-table td:nth-child(4), +.proc-table th:nth-child(5), +.proc-table td:nth-child(5) { + width: 70px; +} + +.proc-table td.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.proc-table tbody tr { + transition: background 0.12s ease; +} + +.proc-table tbody tr:hover { + background: var(--surface-hover); +} + +/* 杀死进程按钮 */ +.proc-table .kill-cell { + text-align: right; + width: 90px; +} + +.kill-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0 12px; + border: 1px solid var(--hairline-strong); + background: transparent; + color: var(--ink-secondary); + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 500; + cursor: pointer; + font-family: inherit; + white-space: nowrap; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} + +.kill-btn:hover { + color: var(--status-critical); + border-color: rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.06); +} + +.kill-btn.confirm { + background: var(--status-critical); + border-color: var(--status-critical); + color: #ffffff; +} + +.kill-btn.confirm:hover { + background: #dc2626; + border-color: #dc2626; +} + +.kill-feedback { + margin-top: 12px; + font-size: 13px; +} + +.kill-feedback.ok { + color: var(--success-text); +} + +.kill-feedback.fail { + color: var(--status-critical); +} diff --git a/src/renderer/src/pages/overview/Overview.jsx b/src/renderer/src/pages/overview/Overview.jsx new file mode 100644 index 0000000..a479a42 --- /dev/null +++ b/src/renderer/src/pages/overview/Overview.jsx @@ -0,0 +1,18 @@ +import CpuCard from './cards/CpuCard' +import MemoryCard from './cards/MemoryCard' +import DiskCard from './cards/DiskCard' +import NetworkCard from './cards/NetworkCard' +import ProcessCard from './cards/ProcessCard' +import './Overview.css' + +export default function Overview() { + return ( +
+ + + + + +
+ ) +} diff --git a/src/renderer/src/pages/overview/cards/CpuCard.jsx b/src/renderer/src/pages/overview/cards/CpuCard.jsx new file mode 100644 index 0000000..9e2b2c8 --- /dev/null +++ b/src/renderer/src/pages/overview/cards/CpuCard.jsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' +import CardShell from '../CardShell' +import useMetric from '../useMetric' +import { usageStatus } from '../format' + +// 两次采样 /proc/stat 计算 CPU 占用率 +async function sampleCpu() { + const read = async () => { + const r = await window.panel.exec('head -n 1 /proc/stat') + if (!r.ok) throw new Error(r.message) + // 字段:cpu user nice system idle iowait irq softirq steal ... + const parts = r.stdout.trim().split(/\s+/).slice(1).map(Number) + const idle = (parts[3] || 0) + (parts[4] || 0) // idle + iowait + const total = parts.reduce((a, b) => a + b, 0) + return { idle, total } + } + + const a = await read() + await new Promise((resolve) => setTimeout(resolve, 700)) + const b = await read() + + const dIdle = b.idle - a.idle + const dTotal = b.total - a.total + if (dTotal <= 0) return null + return Math.min(100, Math.max(0, ((dTotal - dIdle) / dTotal) * 100)) +} + +export default function CpuCard() { + const metric = useMetric({ checkCmd: 'test -r /proc/stat' }) + const [usage, setUsage] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + if (metric.status !== 'ok') return + let alive = true + + const tick = async () => { + try { + const u = await sampleCpu() + if (alive) { + setUsage(u) + setError('') + } + } catch (e) { + if (alive) setError(e.message) + } + } + + tick() + const timer = setInterval(tick, 2000) + return () => { + alive = false + clearInterval(timer) + } + }, [metric.status]) + + return ( + + {error ? ( +

{error}

+ ) : ( + <> +

{usage === null ? '—' : `${usage.toFixed(1)}%`}

+
+
+
+ + )} + + ) +} diff --git a/src/renderer/src/pages/overview/cards/DiskCard.jsx b/src/renderer/src/pages/overview/cards/DiskCard.jsx new file mode 100644 index 0000000..2ef16ba --- /dev/null +++ b/src/renderer/src/pages/overview/cards/DiskCard.jsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react' +import CardShell from '../CardShell' +import useMetric from '../useMetric' +import { formatBytes, usageStatus } from '../format' + +// 依赖 df(coreutils),缺失时可自动安装 +const CHECK_CMD = 'command -v df' +const INSTALL_CMD = + 'SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO=sudo; ' + + 'if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update >/dev/null 2>&1 && $SUDO apt-get install -y coreutils; ' + + 'elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y coreutils; ' + + 'elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y coreutils; ' + + 'elif command -v apk >/dev/null 2>&1; then $SUDO apk add coreutils; ' + + 'else echo "无法识别服务器的包管理器"; exit 1; fi' + +async function fetchDisk() { + // df -Pk:POSIX 兼容的 1K 块输出,各发行版一致 + const r = await window.panel.exec('df -Pk / | tail -n 1') + if (!r.ok) throw new Error(r.message) + + const f = r.stdout.trim().split(/\s+/) + if (f.length < 6) return null + return { + size: Number(f[1]) * 1024, + used: Number(f[2]) * 1024, + pct: parseFloat(f[4]), + } +} + +export default function DiskCard() { + const metric = useMetric({ + checkCmd: CHECK_CMD, + installCmd: INSTALL_CMD, + installName: 'coreutils(df 命令)', + }) + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + if (metric.status !== 'ok') return + let alive = true + + const tick = async () => { + try { + const d = await fetchDisk() + if (alive) { + setData(d) + setError('') + } + } catch (e) { + if (alive) setError(e.message) + } + } + + tick() + const timer = setInterval(tick, 15000) + return () => { + alive = false + clearInterval(timer) + } + }, [metric.status]) + + return ( + + {error ? ( +

{error}

+ ) : ( + <> +

{data ? `${data.pct.toFixed(1)}%` : '—'}

+

+ {data ? `已用 ${formatBytes(data.used)} / 总共 ${formatBytes(data.size)}` : '加载中...'} +

+
+
+
+ + )} + + ) +} diff --git a/src/renderer/src/pages/overview/cards/MemoryCard.jsx b/src/renderer/src/pages/overview/cards/MemoryCard.jsx new file mode 100644 index 0000000..5843bf6 --- /dev/null +++ b/src/renderer/src/pages/overview/cards/MemoryCard.jsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react' +import CardShell from '../CardShell' +import useMetric from '../useMetric' +import { formatBytes, usageStatus } from '../format' + +async function fetchMemory() { + const r = await window.panel.exec("grep -E '^(MemTotal|MemAvailable):' /proc/meminfo") + if (!r.ok) throw new Error(r.message) + + const mem = {} + for (const line of r.stdout.split('\n')) { + const m = line.match(/^(MemTotal|MemAvailable):\s+(\d+)\s*kB/) + if (m) mem[m[1]] = Number(m[2]) * 1024 + } + if (!mem.MemTotal) return null + + const total = mem.MemTotal + const available = mem.MemAvailable ?? 0 + return { + total, + used: total - available, + pct: ((total - available) / total) * 100, + } +} + +export default function MemoryCard() { + const metric = useMetric({ checkCmd: 'test -r /proc/meminfo' }) + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + if (metric.status !== 'ok') return + let alive = true + + const tick = async () => { + try { + const d = await fetchMemory() + if (alive) { + setData(d) + setError('') + } + } catch (e) { + if (alive) setError(e.message) + } + } + + tick() + const timer = setInterval(tick, 3000) + return () => { + alive = false + clearInterval(timer) + } + }, [metric.status]) + + return ( + + {error ? ( +

{error}

+ ) : ( + <> +

{data ? `${data.pct.toFixed(1)}%` : '—'}

+

+ {data ? `已用 ${formatBytes(data.used)} / 总共 ${formatBytes(data.total)}` : '加载中...'} +

+
+
+
+ + )} + + ) +} diff --git a/src/renderer/src/pages/overview/cards/NetworkCard.jsx b/src/renderer/src/pages/overview/cards/NetworkCard.jsx new file mode 100644 index 0000000..683900e --- /dev/null +++ b/src/renderer/src/pages/overview/cards/NetworkCard.jsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react' +import CardShell from '../CardShell' +import useMetric from '../useMetric' +import { formatRate } from '../format' + +// 读取所有网卡(除 lo)的累计收发字节数 +async function readNetBytes() { + const r = await window.panel.exec('cat /proc/net/dev') + if (!r.ok) throw new Error(r.message) + + let rx = 0 + let tx = 0 + for (const line of r.stdout.split('\n').slice(2)) { + // 字段:iface: rx_bytes rx_packets ... tx_bytes ... + const m = line.trim().match(/^([^:]+):\s*(\d+)(?:\s+\d+){7}\s+(\d+)/) + if (!m || m[1] === 'lo') continue + rx += Number(m[2]) + tx += Number(m[3]) + } + return { rx, tx } +} + +export default function NetworkCard() { + const metric = useMetric({ checkCmd: 'test -r /proc/net/dev' }) + const [rates, setRates] = useState(null) // { rx, tx },单位 bytes/s + const [error, setError] = useState('') + const lastSample = useRef(null) // { rx, tx, ts } + + useEffect(() => { + if (metric.status !== 'ok') return + let alive = true + + const tick = async () => { + try { + const cur = await readNetBytes() + if (!alive) return + const ts = Date.now() + + if (lastSample.current) { + const prev = lastSample.current + const secs = (ts - prev.ts) / 1000 + if (secs > 0) { + setRates({ + rx: (cur.rx - prev.rx) / secs, + tx: (cur.tx - prev.tx) / secs, + }) + } + } + lastSample.current = { ...cur, ts } + setError('') + } catch (e) { + if (alive) setError(e.message) + } + } + + tick() + const timer = setInterval(tick, 2000) + return () => { + alive = false + clearInterval(timer) + } + }, [metric.status]) + + return ( + + {error ? ( +

{error}

+ ) : ( + <> +
+ + 下行 + {rates ? formatRate(rates.rx) : '—'} +
+
+ + 上行 + {rates ? formatRate(rates.tx) : '—'} +
+ + )} +
+ ) +} diff --git a/src/renderer/src/pages/overview/cards/ProcessCard.jsx b/src/renderer/src/pages/overview/cards/ProcessCard.jsx new file mode 100644 index 0000000..04a6479 --- /dev/null +++ b/src/renderer/src/pages/overview/cards/ProcessCard.jsx @@ -0,0 +1,172 @@ +import { useEffect, useRef, useState } from 'react' +import CardShell from '../CardShell' +import useMetric from '../useMetric' + +// 依赖 ps(procps),缺失时可自动安装 +const CHECK_CMD = 'command -v ps' +const INSTALL_CMD = + 'SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO=sudo; ' + + 'if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update >/dev/null 2>&1 && $SUDO apt-get install -y procps; ' + + 'elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y procps-ng; ' + + 'elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y procps-ng; ' + + 'elif command -v apk >/dev/null 2>&1; then $SUDO apk add procps; ' + + 'else echo "无法识别服务器的包管理器"; exit 1; fi' + +async function fetchProcesses() { + const psResult = await window.panel.exec( + 'ps -eo pid,user,pcpu,pmem,comm --sort=-pcpu --no-headers | head -n 12' + ) + if (!psResult.ok) throw new Error(psResult.message) + + const procs = psResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const parts = line.split(/\s+/) + return { + pid: parts[0], + user: parts[1], + cpu: parts[2], + mem: parts[3], + name: parts.slice(4).join(' ') || parts[3], + } + }) + + const countResult = await window.panel.exec('ps -e --no-headers | wc -l') + const total = countResult.ok ? Number(countResult.stdout.trim()) : null + + return { procs, total } +} + +export default function ProcessCard() { + const metric = useMetric({ + checkCmd: CHECK_CMD, + installCmd: INSTALL_CMD, + installName: 'procps(ps 命令)', + }) + const [procs, setProcs] = useState([]) + const [total, setTotal] = useState(null) + const [error, setError] = useState('') + const [confirmPid, setConfirmPid] = useState(null) // 二次确认中的 PID + const [feedback, setFeedback] = useState(null) // { ok, text } + const [refreshKey, setRefreshKey] = useState(0) // 杀死进程后立即刷新列表 + const timers = useRef([]) + + // 数据加载 + useEffect(() => { + if (metric.status !== 'ok') return + let alive = true + + const tick = async () => { + try { + const d = await fetchProcesses() + if (alive) { + setProcs(d.procs) + setTotal(d.total) + setError('') + } + } catch (e) { + if (alive) setError(e.message) + } + } + + tick() + const timer = setInterval(tick, 5000) + return () => { + alive = false + clearInterval(timer) + } + }, [metric.status, refreshKey]) + + // 组件卸载时清理所有未完成的定时器 + useEffect(() => () => timers.current.forEach(clearTimeout), []) + + const schedule = (fn, ms) => { + timers.current.push(setTimeout(fn, ms)) + } + + const showFeedback = (ok, text) => { + setFeedback({ ok, text }) + schedule(() => setFeedback(null), 4000) + } + + const handleKill = async (p) => { + // 第一次点击:进入确认状态,3 秒内不确认则自动取消 + if (confirmPid !== p.pid) { + setConfirmPid(p.pid) + schedule(() => setConfirmPid(null), 3000) + return + } + + setConfirmPid(null) + const r = await window.panel.exec(`kill -9 ${p.pid}`) + if (!r.ok) { + showFeedback(false, `杀死失败:${r.message}`) + } else if (r.code !== 0) { + showFeedback(false, `杀死失败:${(r.stderr || '').trim() || '权限不足'}`) + } else { + showFeedback(true, `已杀死进程 ${p.pid}(${p.name})`) + setRefreshKey((k) => k + 1) + } + } + + return ( + + {error ? ( +

{error}

+ ) : ( + <> + + + + + + + + + + + + + {procs.length === 0 ? ( + + + + ) : ( + procs.map((p) => ( + + + + + + + + + )) + )} + +
进程PID用户CPU%内存%
加载中...
{p.name}{p.pid}{p.user}{p.cpu}{p.mem} + +
+ {feedback && ( +

{feedback.text}

+ )} + + )} +
+ ) +} diff --git a/src/renderer/src/pages/overview/format.js b/src/renderer/src/pages/overview/format.js new file mode 100644 index 0000000..2dbab93 --- /dev/null +++ b/src/renderer/src/pages/overview/format.js @@ -0,0 +1,27 @@ +// 字节数格式化:1234 -> "1.2 KB" +export function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return '—' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let v = bytes + let i = 0 + while (v >= 1024 && i < units.length - 1) { + v /= 1024 + i++ + } + return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}` +} + +// 速率格式化:bytes/s +export function formatRate(bytesPerSec) { + if (!Number.isFinite(bytesPerSec) || bytesPerSec < 0) return '—' + return `${formatBytes(bytesPerSec)}/s` +} + +// 占用率分级(阈值 60/80/90),供仪表填充色使用 +// 颜色始终与数值标签成对出现,绝不单独承载含义 +export function usageStatus(pct) { + if (pct < 60) return 'good' + if (pct < 80) return 'warning' + if (pct < 90) return 'serious' + return 'critical' +} diff --git a/src/renderer/src/pages/overview/useMetric.js b/src/renderer/src/pages/overview/useMetric.js new file mode 100644 index 0000000..a7cfc29 --- /dev/null +++ b/src/renderer/src/pages/overview/useMetric.js @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' + +// 卡片环境检测: +// - 检测命令执行成功 -> ok,卡片正常展示数据 +// - 检测命令返回非 0 -> missing,卡片置灰;若有 installCmd 则提供"安装"按钮 +// - SSH 连接层面失败 -> error,可重试 +export default function useMetric({ checkCmd, installCmd, installName }) { + const [status, setStatus] = useState('checking') // checking | ok | missing | installing | error + const [message, setMessage] = useState('') + + const check = async () => { + const r = await window.panel.exec(checkCmd) + if (!r.ok) { + setStatus('error') + setMessage(r.message) + } else if (r.code === 0) { + setStatus('ok') + setMessage('') + } else { + setStatus('missing') + setMessage(installName || '未知依赖') + } + } + + const install = async () => { + setStatus('installing') + setMessage(installName || '') + const r = await window.panel.exec(installCmd, 600000) + if (!r.ok) { + setStatus('error') + setMessage(`安装失败:${r.message}`) + } else if (r.code !== 0) { + const detail = (r.stderr || r.stdout).trim() + setStatus('error') + setMessage(`安装失败:${detail.slice(-200)}`) + } else { + await check() // 安装成功后重新检测 + } + } + + useEffect(() => { + check() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { status, message, installable: !!installCmd, check, install } +} diff --git a/src/renderer/src/theme.css b/src/renderer/src/theme.css new file mode 100644 index 0000000..34b892c --- /dev/null +++ b/src/renderer/src/theme.css @@ -0,0 +1,138 @@ +/* ============================================================ + 设计令牌 —— 全局色彩 / 间距 / 阴影 / 圆角 + 集中管理,换肤或深色模式只需改此文件 + ============================================================ */ +:root { + color-scheme: light; + + /* ---- 表面层 ---- */ + --page-bg: #eef1f6; + --card-bg: #ffffff; + --surface-dim: #f6f7f9; + --surface-hover: #f0f4ff; + + /* ---- 文字(墨色令牌:主 / 次 / 弱) ---- */ + --ink-strong: #1a1d29; + --ink: #3d4050; + --ink-secondary: #6b7280; + --ink-muted: #9ca3af; + --success-text: #15803d; + + /* ---- 边框与轨道 ---- */ + --hairline: #e5e7eb; + --hairline-strong: #d1d5db; + --divider: #ececef; + --track: #e8eaed; + + /* ---- 品牌色:靛蓝(主按钮、导航栏、焦点环) ---- */ + --brand: #5b6ef5; + --brand-hover: #4759f0; + --brand-active: #3a48d8; + --brand-soft: rgba(91, 110, 245, 0.08); + --brand-soft-hover: rgba(91, 110, 245, 0.14); + + /* ---- 类别色:网络下行 / 上行 ---- */ + --series-1: #5b6ef5; + --series-2: #f59e0b; + + /* ---- 状态色 ---- */ + --status-good: #22c55e; + --status-warning: #f59e0b; + --status-serious: #f97316; + --status-critical: #ef4444; + + /* ---- 形状 ---- */ + --radius-card: 14px; + --radius-btn: 8px; + --radius-sm: 6px; + + /* ---- 阴影 ---- */ + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.06); + --shadow-card-hover: 0 2px 8px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.1); + --shadow-overlay: 0 8px 32px rgba(0, 0, 0, 0.12); + + /* ---- 间距基准 ---- */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; +} + +/* ============================================================ + 按钮体系 —— 统一尺寸,变体只改颜色 + ============================================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + padding: 0 18px; + border: 1px solid transparent; + border-radius: var(--radius-btn); + font-size: 14px; + font-weight: 500; + font-family: inherit; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, + box-shadow 0.15s ease; +} + +.btn:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.55; + cursor: default; +} + +.btn-block { + width: 100%; +} + +/* 主按钮 */ +.btn-primary { + background: var(--brand); + color: #ffffff; +} + +.btn-primary:hover:not(:disabled) { + background: var(--brand-hover); +} + +.btn-primary:active:not(:disabled) { + background: var(--brand-active); +} + +/* 幽灵按钮(描边) */ +.btn-ghost { + background: transparent; + color: var(--ink-secondary); + border-color: var(--hairline-strong); +} + +.btn-ghost:hover:not(:disabled) { + color: var(--ink-strong); + border-color: var(--brand); + background: var(--brand-soft); +} + +/* 深色导航栏上的按钮变体 */ +.btn-menu { + height: 30px; + padding: 0 14px; + font-size: 13px; + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: #ffffff; +} + +.btn-menu:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.3); +}