-
-
setPort(e.target.value)}
- />
+
@@ -115,7 +117,7 @@ export default function Login({ onConnected }) {
/>
记住密码(加密保存在本机)
-
- 连接成功!这里将展示服务器信息,下一步从这里开始扩展。
+ {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' && (
+
+ )}
+
+
+ )
+}
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}
+ ) : (
+ <>
+
+
+
+ | 进程 |
+ PID |
+ 用户 |
+ CPU% |
+ 内存% |
+ |
+
+
+
+ {procs.length === 0 ? (
+
+ | 加载中... |
+
+ ) : (
+ procs.map((p) => (
+
+ | {p.name} |
+ {p.pid} |
+ {p.user} |
+ {p.cpu} |
+ {p.mem} |
+
+ handleKill(p)}
+ >
+ {confirmPid === p.pid ? '确认杀死' : '杀死'}
+
+ |
+
+ ))
+ )}
+
+
+ {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);
+}