mirror of
https://github.com/fankes/komari-theme-purcarte.git
synced 2025-10-19 20:09:24 +08:00
init: 初始化
This commit is contained in:
104
src/components/sections/Flag.tsx
Normal file
104
src/components/sections/Flag.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as React from "react";
|
||||
import { Box } from "@radix-ui/themes";
|
||||
|
||||
interface FlagProps {
|
||||
flag: string; // 地区代码 (例如 "SG", "US") 或旗帜 emoji (例如 "🇸🇬", "🇺🇳")
|
||||
size?: string; // 可选的尺寸 prop,用于未来扩展
|
||||
}
|
||||
|
||||
/**
|
||||
* 算法:将由两个区域指示符符号组成的 emoji 转换为对应的两字母国家代码。
|
||||
* 例如:🇸🇬 (由两个区域指示符组成) -> SG
|
||||
* @param emoji 输入的 emoji 字符串
|
||||
* @returns 转换后的两字母国家代码(例如 "SG"),如果不是有效的旗帜 emoji 则返回 null。
|
||||
*/
|
||||
const getCountryCodeFromFlagEmoji = (emoji: string): string | null => {
|
||||
// 使用 Array.from() 来正确处理 Unicode 代理对,将 emoji 字符串拆分为逻辑上的字符数组。
|
||||
// 对于一个国家旗帜 emoji,chars 数组的长度将是 2 (每个元素是一个区域指示符字符)。
|
||||
const chars = Array.from(emoji);
|
||||
|
||||
// 国家旗帜 emoji 应该由且仅由两个区域指示符字符组成
|
||||
if (chars.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取两个区域指示符字符的 Unicode 码点
|
||||
const codePoint1 = chars[0].codePointAt(0)!;
|
||||
const codePoint2 = chars[1].codePointAt(0)!;
|
||||
|
||||
// 区域指示符符号的 Unicode 范围是从 U+1F1E6 (🇦) 到 U+1F1FF (🇿)
|
||||
const REGIONAL_INDICATOR_START = 0x1f1e6; // 🇦 的 Unicode 码点
|
||||
const ASCII_ALPHA_START = 0x41; // A 的 ASCII 码点
|
||||
|
||||
// 检查两个码点是否都在区域指示符范围内
|
||||
if (
|
||||
codePoint1 >= REGIONAL_INDICATOR_START &&
|
||||
codePoint1 <= 0x1f1ff &&
|
||||
codePoint2 >= REGIONAL_INDICATOR_START &&
|
||||
codePoint2 <= 0x1f1ff
|
||||
) {
|
||||
// 算法转换:通过计算与 'A' 对应的区域指示符的偏移量,将区域指示符码点转换回对应的 ASCII 字母码点
|
||||
const letter1 = String.fromCodePoint(
|
||||
codePoint1 - REGIONAL_INDICATOR_START + ASCII_ALPHA_START
|
||||
);
|
||||
const letter2 = String.fromCodePoint(
|
||||
codePoint2 - REGIONAL_INDICATOR_START + ASCII_ALPHA_START
|
||||
);
|
||||
return `${letter1}${letter2}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const Flag = React.memo(({ flag, size }: FlagProps) => {
|
||||
let imgSrc: string;
|
||||
let altText: string;
|
||||
let resolvedFlagFileName: string; // 最终用于构建文件名的字符串 (例如 "SG", "UN")
|
||||
|
||||
// 1. **算法处理:** 尝试将输入作为由区域指示符组成的旗帜 emoji 进行转换
|
||||
const countryCodeFromEmoji = getCountryCodeFromFlagEmoji(flag);
|
||||
|
||||
if (countryCodeFromEmoji) {
|
||||
resolvedFlagFileName = countryCodeFromEmoji; // 例如,如果输入是 "🇸🇬",则这里得到 "SG"
|
||||
}
|
||||
// 2. **直接识别:** 如果不是区域指示符 emoji,检查是否是两字母的字母组合(ISO 国家代码)
|
||||
else if (flag && flag.length === 2 && /^[a-zA-Z]{2}$/.test(flag)) {
|
||||
resolvedFlagFileName = flag.toUpperCase(); // 例如,如果输入是 "us",则这里得到 "US"
|
||||
}
|
||||
// 3. **硬编码处理特殊 Emoji:** 对于无法通过算法转换的特殊 emoji(例如 🇺🇳, 🌐),
|
||||
// 因为它们不符合区域指示符模式,且不使用映射表,只能通过硬编码来识别。
|
||||
else if (flag === "🇺🇳" || flag === "🌐") {
|
||||
resolvedFlagFileName = "UN"; // 例如,如果输入是 "🇺🇳",则这里得到 "UN"
|
||||
}
|
||||
// 4. **回退:** 对于任何其他无法识别的输入(包括不符合上述规则的 emoji 或非两字母代码),
|
||||
// 使用默认的 "UN" 旗帜作为回退。
|
||||
else {
|
||||
resolvedFlagFileName = "UN";
|
||||
}
|
||||
|
||||
// 构建本地图片路径
|
||||
imgSrc = `/assets/flags/${resolvedFlagFileName}.svg`;
|
||||
// 构建 alt 文本和 aria-label
|
||||
altText = `地区旗帜: ${resolvedFlagFileName}`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
className={`self-center flex-shrink-0 ${
|
||||
size ? `w-${size} h-${size}` : "w-6 h-6"
|
||||
}`}
|
||||
style={{ display: "inline-flex", alignItems: "center" }}
|
||||
aria-label={altText}>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={altText}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
// 确保 displayName 以便在 React DevTools 中识别
|
||||
Flag.displayName = "Flag";
|
||||
|
||||
export default Flag;
|
29
src/components/sections/Footer.tsx
Normal file
29
src/components/sections/Footer.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="fixed shadow-inner bottom-0 left-0 right-0 p-2 text-center bg-white/10 dark:bg-gray-800/10 backdrop-blur-md border-t border-white/20 dark:border-white/10 z-50">
|
||||
<p className="flex justify-center text-sm text-gray-700 dark:text-gray-200 text-shadow-lg whitespace-pre">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://github.com/komari-monitor/komari"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 transition-colors">
|
||||
Komari Monitor
|
||||
</a>
|
||||
{" | "}
|
||||
Theme by{" "}
|
||||
<a
|
||||
href=""
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 transition-colors">
|
||||
PurCarte
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
120
src/components/sections/Header.tsx
Normal file
120
src/components/sections/Header.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Search,
|
||||
Grid3X3,
|
||||
Table2,
|
||||
Moon,
|
||||
Sun,
|
||||
CircleUserIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
|
||||
interface HeaderProps {
|
||||
viewMode: "card" | "list";
|
||||
setViewMode: (mode: "card" | "list") => void;
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
sitename: string;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
}
|
||||
|
||||
export const Header = ({
|
||||
viewMode,
|
||||
setViewMode,
|
||||
theme,
|
||||
toggleTheme,
|
||||
sitename,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
}: HeaderProps) => {
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const isInstancePage = location.pathname.startsWith("/instance");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<header className="bg-background/60 backdrop-blur-[10px] border-b border-border/60 sticky top-0 flex items-center justify-center shadow-sm z-10">
|
||||
<div className="w-[90%] max-w-screen-2xl px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center text-shadow-lg text-accent-foreground">
|
||||
<a href="/" className="text-2xl font-bold">
|
||||
{sitename}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{!isInstancePage && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{isSearchOpen && (
|
||||
<div className="absolute top-full left-0 w-full bg-background/80 backdrop-blur-md p-2 border-b border-border/60 shadow-sm z-10 transition-all duration-300 ease-in-out">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索服务器..."
|
||||
className="w-full"
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSearchTerm(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isSearchOpen ? "w-48" : "w-0"
|
||||
}`}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索服务器..."
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
isSearchOpen ? "opacity-100" : "opacity-0"
|
||||
} ${!isSearchOpen && "invisible"}`}
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSearchTerm(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}>
|
||||
<Search className="size-5 text-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === "card" ? "list" : "card")
|
||||
}>
|
||||
{viewMode === "card" ? (
|
||||
<Table2 className="size-5 text-primary" />
|
||||
) : (
|
||||
<Grid3X3 className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme}>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="size-5 text-primary" />
|
||||
) : (
|
||||
<Moon className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
<a href="/admin" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="icon">
|
||||
<CircleUserIcon className="size-5 text-primary" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
188
src/components/sections/NodeCard.tsx
Normal file
188
src/components/sections/NodeCard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatBytes, formatUptime, getOSImage } from "@/utils";
|
||||
import type { NodeWithStatus } from "@/types/node";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react";
|
||||
import Flag from "./Flag";
|
||||
import { Tag } from "../ui/tag";
|
||||
import { useNodeCommons } from "@/hooks/useNodeCommons";
|
||||
|
||||
interface NodeCardProps {
|
||||
node: NodeWithStatus;
|
||||
}
|
||||
|
||||
const ProgressBar = ({
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
value: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 dark:bg-gray-700">
|
||||
<div
|
||||
className={`h-3 rounded-full ${className}`}
|
||||
style={{ width: `${value}%` }}></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const NodeCard = ({ node }: NodeCardProps) => {
|
||||
const {
|
||||
stats,
|
||||
isOnline,
|
||||
tagList,
|
||||
cpuUsage,
|
||||
memUsage,
|
||||
swapUsage,
|
||||
diskUsage,
|
||||
load,
|
||||
daysLeft,
|
||||
} = useNodeCommons(node);
|
||||
|
||||
const getProgressBarClass = (percentage: number) => {
|
||||
if (percentage > 90) return "bg-red-600";
|
||||
if (percentage > 50) return "bg-yellow-400";
|
||||
return "bg-green-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col mx-auto bg-card backdrop-blur-xs w-full min-w-[280px] max-w-sm ${
|
||||
isOnline
|
||||
? ""
|
||||
: "striped-bg-red-translucent-diagonal ring-2 ring-red-500/50"
|
||||
}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flag flag={node.region}></Flag>
|
||||
<img
|
||||
src={getOSImage(node.os)}
|
||||
alt={node.os}
|
||||
className="w-6 h-6 rounded-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
<CardTitle className="text-base font-bold">
|
||||
<Link to={`/instance/${node.uuid}`}>{node.name}</Link>
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Tag tags={tagList} />
|
||||
</div>
|
||||
<div className="border-t border-border/60 my-2"></div>
|
||||
<div className="flex items-center justify-around whitespace-nowrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<CpuIcon className="size-4 text-blue-600 flex-shrink-0" />
|
||||
<span className="text-secondary-foreground">
|
||||
{node.cpu_cores} Cores
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MemoryStickIcon className="size-4 text-green-600 flex-shrink-0" />
|
||||
<span className="text-secondary-foreground">
|
||||
{formatBytes(node.mem_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDriveIcon className="size-4 text-red-600 flex-shrink-0" />
|
||||
<span className="text-secondary-foreground">
|
||||
{formatBytes(node.disk_total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">CPU</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={cpuUsage}
|
||||
className={getProgressBarClass(cpuUsage)}
|
||||
/>
|
||||
<span className="w-12 text-right">{cpuUsage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">内存</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={memUsage}
|
||||
className={getProgressBarClass(memUsage)}
|
||||
/>
|
||||
<span className="w-12 text-right">{memUsage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{node.swap_total > 0 ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">SWAP</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={swapUsage}
|
||||
className={getProgressBarClass(swapUsage)}
|
||||
/>
|
||||
<span className="w-12 text-right">{swapUsage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">SWAP</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar value={0} />
|
||||
<span className="w-12 text-right">OFF</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">硬盘</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={diskUsage}
|
||||
className={getProgressBarClass(diskUsage)}
|
||||
/>
|
||||
<span className="w-12 text-right">{diskUsage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/60 my-2"></div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-secondary-foreground">网络</span>
|
||||
<div>
|
||||
<span>↑ {stats ? formatBytes(stats.network.up, true) : "N/A"}</span>
|
||||
<span className="ml-2">
|
||||
↓ {stats ? formatBytes(stats.network.down, true) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-secondary-foreground">流量</span>
|
||||
<div>
|
||||
<span>↑ {stats ? formatBytes(stats.network.totalUp) : "N/A"}</span>
|
||||
<span className="ml-2">
|
||||
↓ {stats ? formatBytes(stats.network.totalDown) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-secondary-foreground">负载</span>
|
||||
<span>{load}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="text-secondary-foreground">到期</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{daysLeft !== null && daysLeft > 36500
|
||||
? "长期"
|
||||
: node.expired_at
|
||||
? new Date(node.expired_at).toLocaleDateString()
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-l border-border/60 mx-2"></div>
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="text-secondary-foreground">在线</span>
|
||||
<span>
|
||||
{isOnline && stats ? formatUptime(stats.uptime) : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
14
src/components/sections/NodeListHeader.tsx
Normal file
14
src/components/sections/NodeListHeader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export const NodeListHeader = () => {
|
||||
return (
|
||||
<div className="text-primary font-bold grid grid-cols-12 text-center shadow-md gap-4 p-2 items-center rounded-lg bg-card/50 transition-colors duration-200">
|
||||
<div className="col-span-3">节点名称</div>
|
||||
<div className="col-span-1">CPU</div>
|
||||
<div className="col-span-1">内存</div>
|
||||
<div className="col-span-1">SWAP</div>
|
||||
<div className="col-span-1">硬盘</div>
|
||||
<div className="col-span-2">网络</div>
|
||||
<div className="col-span-2">流量</div>
|
||||
<div className="col-span-1">负载</div>
|
||||
</div>
|
||||
);
|
||||
};
|
112
src/components/sections/NodeListItem.tsx
Normal file
112
src/components/sections/NodeListItem.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { formatBytes, formatUptime } from "@/utils";
|
||||
import type { NodeWithStatus } from "@/types/node";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react";
|
||||
import Flag from "./Flag";
|
||||
import { Tag } from "../ui/tag";
|
||||
import { useNodeCommons } from "@/hooks/useNodeCommons";
|
||||
|
||||
interface NodeListItemProps {
|
||||
node: NodeWithStatus;
|
||||
}
|
||||
|
||||
export const NodeListItem = ({ node }: NodeListItemProps) => {
|
||||
const {
|
||||
stats,
|
||||
isOnline,
|
||||
tagList,
|
||||
cpuUsage,
|
||||
memUsage,
|
||||
swapUsage,
|
||||
diskUsage,
|
||||
load,
|
||||
daysLeft,
|
||||
} = useNodeCommons(node);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid grid-cols-12 text-center shadow-md gap-4 p-2 items-center rounded-lg ${
|
||||
isOnline
|
||||
? ""
|
||||
: "striped-bg-red-translucent-diagonal ring-2 ring-red-500/50"
|
||||
} text-secondary-foreground transition-colors duration-200`}>
|
||||
<div className="col-span-3 flex items-center text-left">
|
||||
<Flag flag={node.region} />
|
||||
<div className="ml-2 w-full">
|
||||
<div className="text-base font-bold">
|
||||
<Link to={`/instance/${node.uuid}`}>{node.name}</Link>
|
||||
</div>
|
||||
<Tag className="text-xs" tags={tagList} />
|
||||
<div className="flex text-xs">
|
||||
<div className="flex">
|
||||
<span className="text-secondary-foreground">到期:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{daysLeft !== null && daysLeft > 36500
|
||||
? "长期"
|
||||
: node.expired_at
|
||||
? new Date(node.expired_at).toLocaleDateString()
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-l border-border/60 mx-2"></div>
|
||||
<div className="flex">
|
||||
<span className="text-secondary-foreground">在线:</span>
|
||||
<span>
|
||||
{isOnline && stats ? formatUptime(stats.uptime) : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="gap-1 flex items-center justify-center whitespace-nowrap">
|
||||
<CpuIcon className="inline-block size-4 flex-shrink-0 text-blue-600" />
|
||||
{node.cpu_cores} Cores
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{isOnline ? `${cpuUsage.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="gap-1 flex items-center justify-center whitespace-nowrap">
|
||||
<MemoryStickIcon className="inline-block size-4 flex-shrink-0 text-green-600" />
|
||||
{formatBytes(node.mem_total)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{isOnline ? `${memUsage.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
{node.swap_total > 0 ? (
|
||||
<div className="col-span-1">
|
||||
{isOnline ? `${swapUsage.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-span-1 text-secondary-foreground">OFF</div>
|
||||
)}
|
||||
<div className="col-span-1">
|
||||
<div className="gap-1 flex items-center justify-center whitespace-nowrap">
|
||||
<HardDriveIcon className="inline-block size-4 flex-shrink-0 text-red-600" />
|
||||
{formatBytes(node.disk_total)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{isOnline ? `${diskUsage.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span>
|
||||
↑ {stats ? formatBytes(stats.network.up, true) : "N/A"}↓{" "}
|
||||
{stats ? formatBytes(stats.network.down, true) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span>
|
||||
↑ {stats ? formatBytes(stats.network.totalUp) : "N/A"}↓{" "}
|
||||
{stats ? formatBytes(stats.network.totalDown) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<span>{load}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
186
src/components/sections/StatsBar.tsx
Normal file
186
src/components/sections/StatsBar.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatBytes } from "@/utils";
|
||||
|
||||
interface StatsBarProps {
|
||||
displayOptions: {
|
||||
time: boolean;
|
||||
online: boolean;
|
||||
regions: boolean;
|
||||
traffic: boolean;
|
||||
speed: boolean;
|
||||
};
|
||||
setDisplayOptions: (options: any) => void;
|
||||
stats: {
|
||||
onlineCount: number;
|
||||
totalCount: number;
|
||||
uniqueRegions: number;
|
||||
totalTrafficUp: number;
|
||||
totalTrafficDown: number;
|
||||
currentSpeedUp: number;
|
||||
currentSpeedDown: number;
|
||||
};
|
||||
loading: boolean;
|
||||
currentTime: Date;
|
||||
}
|
||||
|
||||
export const StatsBar = ({
|
||||
displayOptions,
|
||||
setDisplayOptions,
|
||||
stats,
|
||||
loading,
|
||||
currentTime,
|
||||
}: StatsBarProps) => {
|
||||
return (
|
||||
<div className="bg-card backdrop-blur-[10px] min-w-[300px] rounded-lg box-border border text-secondary-foreground m-4 px-4 md:text-base text-sm relative flex items-center min-h-[5rem]">
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Settings2 />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>状态显示设置</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>当前时间</span>
|
||||
<Switch
|
||||
checked={displayOptions.time}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, time: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>当前在线</span>
|
||||
<Switch
|
||||
checked={displayOptions.online}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, online: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>点亮地区</span>
|
||||
<Switch
|
||||
checked={displayOptions.regions}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, regions: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>流量概览</span>
|
||||
<Switch
|
||||
checked={displayOptions.traffic}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, traffic: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>网络速率</span>
|
||||
<Switch
|
||||
checked={displayOptions.speed}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, speed: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div
|
||||
className="grid w-full gap-2 text-center items-center"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gridAutoRows: "min-content",
|
||||
}}>
|
||||
{displayOptions.time && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
当前时间
|
||||
</label>
|
||||
<label className="font-medium -mt-2 text-md">
|
||||
{currentTime.toLocaleTimeString()}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayOptions.online && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
当前在线
|
||||
</label>
|
||||
<label className="font-medium -mt-2 text-md">
|
||||
{loading ? "..." : `${stats.onlineCount} / ${stats.totalCount}`}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayOptions.regions && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
点亮地区
|
||||
</label>
|
||||
<label className="font-medium -mt-2 text-md">
|
||||
{loading ? "..." : stats.uniqueRegions}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayOptions.traffic && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
流量概览
|
||||
</label>
|
||||
<div className="font-medium -mt-2 text-md">
|
||||
{loading ? (
|
||||
"..."
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{`↑ ${formatBytes(stats.totalTrafficUp)}`}</span>
|
||||
<span>{`↓ ${formatBytes(stats.totalTrafficDown)}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayOptions.speed && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
网络速率
|
||||
</label>
|
||||
<div className="font-medium -mt-2 text-md">
|
||||
{loading ? (
|
||||
"..."
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{`↑ ${formatBytes(stats.currentSpeedUp)}/s`}</span>
|
||||
<span>{`↓ ${formatBytes(stats.currentSpeedDown)}/s`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user