refactor: 全面重构前端 UI 样式

- 重构 theme.css:替换刺眼青色背景为柔和灰蓝(#eef1f6),品牌色改为靛蓝(#5b6ef5)
- 统一按钮体系:主按钮36px固定高度,菜单按钮30px,表格按钮28px,尺寸协调
- 登录页:服务器地址与端口并排布局,输入框40px高度统一,卡片阴影加深
- 面板页:导航栏56px高度,内容区居中限宽1400px,滚动条美化
- 概览页:auto-fit网格+340px最小宽度防止卡片不均,进程表全宽跨列
- 进程表格:table-layout:fixed + 列宽分配 + nowrap 防止串行溢出
- 所有文字元素加 white-space:nowrap 防止换行串行
- 更新 Electron 窗口背景色匹配新主题
This commit is contained in:
2026-09-06 20:10:33 +08:00
parent 0acd71d76d
commit e929d4842e
19 changed files with 1366 additions and 95 deletions
+44 -1
View File
@@ -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()
+1
View File
@@ -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'),
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SSH Panel</title>
<title>SSH Panel · 服务器管理面板</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:"
+30 -3
View File
@@ -1,3 +1,5 @@
@import './theme.css';
* {
margin: 0;
padding: 0;
@@ -7,10 +9,35 @@
html,
body {
height: 100%;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
body {
background: #d2f8fc;
color: #2c3e50;
background: var(--page-bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
height: 100%;
}
/* 全局滚动条美化(WebKit) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
+103 -46
View File
@@ -1,98 +1,130 @@
/* ============================================================
登录页
============================================================ */
.wrap {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-lg);
}
.card {
width: 400px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 40, 60, 0.15);
padding: 36px 40px;
width: 420px;
max-width: 100%;
background: var(--card-bg);
border: 1px solid var(--hairline);
border-radius: var(--radius-card);
box-shadow: var(--shadow-overlay);
padding: 40px 36px 32px;
}
.card h1 {
font-size: 24px;
color: #123a6b;
font-weight: 700;
color: var(--brand);
text-align: center;
letter-spacing: 0.01em;
}
.card .subtitle {
text-align: center;
color: #7a8a99;
font-size: 13px;
margin: 6px 0 24px;
color: var(--ink-muted);
font-size: 14px;
margin: 8px 0 28px;
}
.field {
margin-bottom: 14px;
margin-bottom: 18px;
}
.field label {
display: block;
font-size: 13px;
color: #4a5a6a;
margin-bottom: 4px;
font-weight: 500;
color: var(--ink-secondary);
margin-bottom: 6px;
}
.field input {
width: 100%;
padding: 9px 12px;
border: 1px solid #cfd9e3;
border-radius: 6px;
height: 40px;
padding: 0 14px;
border: 1px solid var(--hairline-strong);
border-radius: var(--radius-btn);
font-size: 14px;
font-family: inherit;
color: var(--ink-strong);
background: var(--card-bg);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field input::placeholder {
color: var(--ink-muted);
}
.field input:focus {
border-color: #123a6b;
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-soft);
}
/* 端口字段和服务器地址可以并排 */
.field-row {
display: flex;
gap: 12px;
}
.field-row .field {
flex: 1;
margin-bottom: 18px;
}
.field-row .field:nth-child(2) {
max-width: 120px;
flex: 0 0 120px;
}
.remember {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
font-size: 13px;
color: #4a5a6a;
margin-bottom: 18px;
}
.connect-btn {
width: 100%;
padding: 10px;
background: #123a6b;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 15px;
color: var(--ink-secondary);
margin-bottom: 22px;
cursor: pointer;
user-select: none;
}
.connect-btn:disabled {
opacity: 0.6;
cursor: default;
.remember input {
accent-color: var(--brand);
width: 16px;
height: 16px;
cursor: pointer;
}
.status {
min-height: 20px;
margin-top: 12px;
margin-top: 14px;
font-size: 13px;
text-align: center;
color: #c0392b;
color: var(--status-critical);
}
/* ---- 已保存服务器列表 ---- */
.saved {
margin-top: 24px;
border-top: 1px solid #e6edf4;
padding-top: 16px;
margin-top: 28px;
border-top: 1px solid var(--divider);
padding-top: 20px;
}
.saved h2 {
font-size: 14px;
color: #4a5a6a;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--ink-muted);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.saved ul {
@@ -103,25 +135,50 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-btn);
font-size: 13px;
cursor: pointer;
color: var(--ink);
transition: background 0.15s ease;
}
.saved li:hover {
background: #eef4f9;
background: var(--brand-soft);
}
.saved li.empty {
cursor: default;
color: #9aa7b5;
color: var(--ink-muted);
justify-content: center;
}
.saved li.empty:hover {
background: none;
}
.saved li > 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);
}
+3 -1
View File
@@ -63,6 +63,7 @@ export default function Login({ onConnected }) {
<h1>SSH Panel</h1>
<p className="subtitle">连接到你的服务器</p>
<form onSubmit={handleSubmit}>
<div className="field-row">
<div className="field">
<label htmlFor="host">服务器地址</label>
<input
@@ -86,6 +87,7 @@ export default function Login({ onConnected }) {
onChange={(e) => setPort(e.target.value)}
/>
</div>
</div>
<div className="field">
<label htmlFor="username">用户名</label>
<input
@@ -115,7 +117,7 @@ export default function Login({ onConnected }) {
/>
记住密码(加密保存在本机)
</label>
<button className="connect-btn" type="submit" disabled={connecting}>
<button className="btn btn-primary btn-block" type="submit" disabled={connecting}>
{connecting ? '正在连接...' : '连接'}
</button>
<p className="status">{status}</p>
+32 -19
View File
@@ -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;
}
+7 -2
View File
@@ -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 }) {
<span>
{session.username}@{session.host}
</span>
<button onClick={onLogout}>断开连接</button>
<button className="btn btn-menu" onClick={onLogout}>
断开连接
</button>
</div>
</header>
<main>
<p className="hint">连接成功!这里将展示服务器信息,下一步从这里开始扩展。</p>
{active === '概览' && <Overview />}
{active === '容器' && <p className="hint">容器功能待开发</p>}
{active === '终端' && <p className="hint">终端功能待开发</p>}
</main>
</>
)
@@ -0,0 +1,51 @@
// 卡片外壳:统一处理"检测中 / 正常 / 缺少依赖(置灰+安装按钮) / 安装中 / 出错"五种状态
export default function CardShell({
title,
subtitle,
status,
message,
installable,
onInstall,
onRetry,
children,
}) {
const dimmed = status === 'missing' || status === 'installing'
return (
<section className={`metric-card ${dimmed ? 'missing' : ''}`}>
<header>
<h3>{title}</h3>
{status === 'ok' && subtitle ? <span className="sub">{subtitle}</span> : null}
</header>
<div className="body">
{status === 'checking' && <p className="placeholder">正在检测服务器环境...</p>}
{status === 'ok' && children}
{status === 'missing' && (
<div className="missing-box">
{installable ? (
<>
<p>此功能需要先在服务器上安装「{message}」</p>
<button className="btn btn-primary" onClick={onInstall}>
安装
</button>
</>
) : (
<p>当前服务器不支持此功能(需要 Linux 系统)</p>
)}
</div>
)}
{status === 'installing' && (
<p className="placeholder">正在安装「{message}」,可能需要几分钟...</p>
)}
{status === 'error' && (
<div className="error-box">
<p>{message}</p>
<button className="btn btn-ghost" onClick={onRetry}>
重试
</button>
</div>
)}
</div>
</section>
)
}
@@ -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);
}
@@ -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 (
<div className="overview-grid">
<CpuCard />
<MemoryCard />
<DiskCard />
<NetworkCard />
<ProcessCard />
</div>
)
}
@@ -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 (
<CardShell
title="CPU 占用"
status={metric.status}
message={metric.message}
installable={metric.installable}
onInstall={metric.install}
onRetry={metric.check}
>
{error ? (
<p className="error-text">{error}</p>
) : (
<>
<p className="big-value">{usage === null ? '—' : `${usage.toFixed(1)}%`}</p>
<div className="meter">
<div
className={`meter-fill ${usageStatus(usage ?? 0)}`}
style={{ width: `${usage ?? 0}%` }}
/>
</div>
</>
)}
</CardShell>
)
}
@@ -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 (
<CardShell
title="硬盘"
subtitle="根分区 /"
status={metric.status}
message={metric.message}
installable={metric.installable}
onInstall={metric.install}
onRetry={metric.check}
>
{error ? (
<p className="error-text">{error}</p>
) : (
<>
<p className="big-value">{data ? `${data.pct.toFixed(1)}%` : '—'}</p>
<p className="detail">
{data ? `已用 ${formatBytes(data.used)} / 总共 ${formatBytes(data.size)}` : '加载中...'}
</p>
<div className="meter">
<div
className={`meter-fill ${usageStatus(data?.pct ?? 0)}`}
style={{ width: `${data?.pct ?? 0}%` }}
/>
</div>
</>
)}
</CardShell>
)
}
@@ -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 (
<CardShell
title="内存"
status={metric.status}
message={metric.message}
installable={metric.installable}
onInstall={metric.install}
onRetry={metric.check}
>
{error ? (
<p className="error-text">{error}</p>
) : (
<>
<p className="big-value">{data ? `${data.pct.toFixed(1)}%` : '—'}</p>
<p className="detail">
{data ? `已用 ${formatBytes(data.used)} / 总共 ${formatBytes(data.total)}` : '加载中...'}
</p>
<div className="meter">
<div
className={`meter-fill ${usageStatus(data?.pct ?? 0)}`}
style={{ width: `${data?.pct ?? 0}%` }}
/>
</div>
</>
)}
</CardShell>
)
}
@@ -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 (
<CardShell
title="网络"
subtitle="实时速率"
status={metric.status}
message={metric.message}
installable={metric.installable}
onInstall={metric.install}
onRetry={metric.check}
>
{error ? (
<p className="error-text">{error}</p>
) : (
<>
<div className="net-row">
<span className="net-dot dot-1" />
<span className="label">下行</span>
<span className="rate">{rates ? formatRate(rates.rx) : '—'}</span>
</div>
<div className="net-row">
<span className="net-dot dot-2" />
<span className="label">上行</span>
<span className="rate">{rates ? formatRate(rates.tx) : '—'}</span>
</div>
</>
)}
</CardShell>
)
}
@@ -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 (
<CardShell
title="进程"
subtitle={total !== null ? `共 ${total} 个 · CPU 占用前 12` : 'CPU 占用前 12'}
status={metric.status}
message={metric.message}
installable={metric.installable}
onInstall={metric.install}
onRetry={metric.check}
>
{error ? (
<p className="error-text">{error}</p>
) : (
<>
<table className="proc-table">
<thead>
<tr>
<th>进程</th>
<th>PID</th>
<th>用户</th>
<th className="num">CPU%</th>
<th className="num">内存%</th>
<th className="kill-cell"></th>
</tr>
</thead>
<tbody>
{procs.length === 0 ? (
<tr>
<td colSpan="6">加载中...</td>
</tr>
) : (
procs.map((p) => (
<tr key={p.pid}>
<td>{p.name}</td>
<td>{p.pid}</td>
<td>{p.user}</td>
<td className="num">{p.cpu}</td>
<td className="num">{p.mem}</td>
<td className="kill-cell">
<button
className={`kill-btn ${confirmPid === p.pid ? 'confirm' : ''}`}
onClick={() => handleKill(p)}
>
{confirmPid === p.pid ? '确认杀死' : '杀死'}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
{feedback && (
<p className={`kill-feedback ${feedback.ok ? 'ok' : 'fail'}`}>{feedback.text}</p>
)}
</>
)}
</CardShell>
)
}
+27
View File
@@ -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'
}
@@ -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 }
}
+138
View File
@@ -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);
}