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:
+44
-1
@@ -46,7 +46,7 @@ function createWindow() {
|
|||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 800,
|
height: 800,
|
||||||
backgroundColor: '#d2f8fc',
|
backgroundColor: '#eef1f6',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, '../preload/index.js'),
|
preload: path.join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
@@ -121,6 +121,49 @@ ipcMain.handle('ssh:disconnect', () => {
|
|||||||
|
|
||||||
ipcMain.handle('ssh:session', () => activeSession)
|
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:凭据 ----------
|
// ---------- IPC:凭据 ----------
|
||||||
ipcMain.handle('cred:save', (event, { host, port, username, password }) => {
|
ipcMain.handle('cred:save', (event, { host, port, username, password }) => {
|
||||||
const list = readCredentials()
|
const list = readCredentials()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('panel', {
|
|||||||
connect: (config) => ipcRenderer.invoke('ssh:connect', config),
|
connect: (config) => ipcRenderer.invoke('ssh:connect', config),
|
||||||
disconnect: () => ipcRenderer.invoke('ssh:disconnect'),
|
disconnect: () => ipcRenderer.invoke('ssh:disconnect'),
|
||||||
getSession: () => ipcRenderer.invoke('ssh:session'),
|
getSession: () => ipcRenderer.invoke('ssh:session'),
|
||||||
|
exec: (cmd, timeout) => ipcRenderer.invoke('ssh:exec', { cmd, timeout }),
|
||||||
// 凭据(密码加密保存在本机)
|
// 凭据(密码加密保存在本机)
|
||||||
saveCredential: (cred) => ipcRenderer.invoke('cred:save', cred),
|
saveCredential: (cred) => ipcRenderer.invoke('cred:save', cred),
|
||||||
listCredentials: () => ipcRenderer.invoke('cred:list'),
|
listCredentials: () => ipcRenderer.invoke('cred:list'),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>SSH Panel</title>
|
<title>SSH Panel · 服务器管理面板</title>
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
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:"
|
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import './theme.css';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -7,10 +9,35 @@
|
|||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #d2f8fc;
|
background: var(--page-bg);
|
||||||
color: #2c3e50;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,130 @@
|
|||||||
|
/* ============================================================
|
||||||
|
登录页
|
||||||
|
============================================================ */
|
||||||
.wrap {
|
.wrap {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: 400px;
|
width: 420px;
|
||||||
background: #ffffff;
|
max-width: 100%;
|
||||||
border-radius: 12px;
|
background: var(--card-bg);
|
||||||
box-shadow: 0 8px 30px rgba(0, 40, 60, 0.15);
|
border: 1px solid var(--hairline);
|
||||||
padding: 36px 40px;
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-overlay);
|
||||||
|
padding: 40px 36px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h1 {
|
.card h1 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #123a6b;
|
font-weight: 700;
|
||||||
|
color: var(--brand);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card .subtitle {
|
.card .subtitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #7a8a99;
|
color: var(--ink-muted);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
margin: 6px 0 24px;
|
margin: 8px 0 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field label {
|
.field label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #4a5a6a;
|
font-weight: 500;
|
||||||
margin-bottom: 4px;
|
color: var(--ink-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input {
|
.field input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 12px;
|
height: 40px;
|
||||||
border: 1px solid #cfd9e3;
|
padding: 0 14px;
|
||||||
border-radius: 6px;
|
border: 1px solid var(--hairline-strong);
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
background: var(--card-bg);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input:focus {
|
.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 {
|
.remember {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #4a5a6a;
|
color: var(--ink-secondary);
|
||||||
margin-bottom: 18px;
|
margin-bottom: 22px;
|
||||||
}
|
|
||||||
|
|
||||||
.connect-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
background: #123a6b;
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 15px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connect-btn:disabled {
|
.remember input {
|
||||||
opacity: 0.6;
|
accent-color: var(--brand);
|
||||||
cursor: default;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
margin-top: 12px;
|
margin-top: 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #c0392b;
|
color: var(--status-critical);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 已保存服务器列表 ---- */
|
||||||
.saved {
|
.saved {
|
||||||
margin-top: 24px;
|
margin-top: 28px;
|
||||||
border-top: 1px solid #e6edf4;
|
border-top: 1px solid var(--divider);
|
||||||
padding-top: 16px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved h2 {
|
.saved h2 {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #4a5a6a;
|
font-weight: 600;
|
||||||
margin-bottom: 10px;
|
color: var(--ink-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved ul {
|
.saved ul {
|
||||||
@@ -103,25 +135,50 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 10px;
|
gap: 8px;
|
||||||
border-radius: 6px;
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: var(--ink);
|
||||||
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved li:hover {
|
.saved li:hover {
|
||||||
background: #eef4f9;
|
background: var(--brand-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved li.empty {
|
.saved li.empty {
|
||||||
cursor: default;
|
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 {
|
.saved .del {
|
||||||
color: #c0392b;
|
flex-shrink: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
color: var(--ink-muted);
|
||||||
cursor: pointer;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export default function Login({ onConnected }) {
|
|||||||
<h1>SSH Panel</h1>
|
<h1>SSH Panel</h1>
|
||||||
<p className="subtitle">连接到你的服务器</p>
|
<p className="subtitle">连接到你的服务器</p>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="field-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="host">服务器地址</label>
|
<label htmlFor="host">服务器地址</label>
|
||||||
<input
|
<input
|
||||||
@@ -86,6 +87,7 @@ export default function Login({ onConnected }) {
|
|||||||
onChange={(e) => setPort(e.target.value)}
|
onChange={(e) => setPort(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="username">用户名</label>
|
<label htmlFor="username">用户名</label>
|
||||||
<input
|
<input
|
||||||
@@ -115,7 +117,7 @@ export default function Login({ onConnected }) {
|
|||||||
/>
|
/>
|
||||||
记住密码(加密保存在本机)
|
记住密码(加密保存在本机)
|
||||||
</label>
|
</label>
|
||||||
<button className="connect-btn" type="submit" disabled={connecting}>
|
<button className="btn btn-primary btn-block" type="submit" disabled={connecting}>
|
||||||
{connecting ? '正在连接...' : '连接'}
|
{connecting ? '正在连接...' : '连接'}
|
||||||
</button>
|
</button>
|
||||||
<p className="status">{status}</p>
|
<p className="status">{status}</p>
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
|
/* ============================================================
|
||||||
|
顶部导航栏 + 主内容区
|
||||||
|
============================================================ */
|
||||||
.menu {
|
.menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 48px;
|
height: 56px;
|
||||||
background: #123a6b;
|
background: var(--brand);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 0 24px;
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu .brand {
|
.menu .brand {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu nav {
|
.menu nav {
|
||||||
@@ -24,44 +31,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu nav a {
|
.menu nav a {
|
||||||
padding: 6px 14px;
|
padding: 7px 16px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-btn);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
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 {
|
.menu nav a:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu nav a.active {
|
.menu nav a.active {
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu .right {
|
.menu .right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu .right button {
|
.menu .right span {
|
||||||
background: rgba(255, 255, 255, 0.14);
|
color: rgba(255, 255, 255, 0.78);
|
||||||
color: #ffffff;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
text-overflow: ellipsis;
|
||||||
border-radius: 6px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding: 68px 20px 20px;
|
padding: 76px 28px 28px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
main .hint {
|
main .hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #7a8a99;
|
color: var(--ink-muted);
|
||||||
margin-top: 120px;
|
font-size: 15px;
|
||||||
|
margin-top: 140px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import Overview from './overview/Overview'
|
||||||
import './Panel.css'
|
import './Panel.css'
|
||||||
|
|
||||||
const NAV_ITEMS = ['概览', '容器', '终端']
|
const NAV_ITEMS = ['概览', '容器', '终端']
|
||||||
@@ -25,11 +26,15 @@ export default function Panel({ session, onLogout }) {
|
|||||||
<span>
|
<span>
|
||||||
{session.username}@{session.host}
|
{session.username}@{session.host}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={onLogout}>断开连接</button>
|
<button className="btn btn-menu" onClick={onLogout}>
|
||||||
|
断开连接
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<p className="hint">连接成功!这里将展示服务器信息,下一步从这里开始扩展。</p>
|
{active === '概览' && <Overview />}
|
||||||
|
{active === '容器' && <p className="hint">容器功能待开发</p>}
|
||||||
|
{active === '终端' && <p className="hint">终端功能待开发</p>}
|
||||||
</main>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user