init: 初始化

This commit is contained in:
Montia37
2025-08-13 04:32:05 +08:00
commit af6f7b1d09
343 changed files with 9919 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
.loader {
position: relative;
margin: 0 auto;
width: 100px;
height: 100px;
}
.loader:before {
content: "";
display: block;
padding-top: 100%;
}
.circular {
-webkit-animation: rotate 2s linear infinite;
animation: rotate 2s linear infinite;
height: 100%;
-webkit-transform-origin: center center;
transform-origin: center center;
width: 100%;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}
.path {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
-webkit-animation: dash 1.5s ease-in-out infinite;
animation: dash 1.5s ease-in-out infinite;
stroke-linecap: round;
stroke: #3b82f6;
}
@-webkit-keyframes rotate {
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes rotate {
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@-webkit-keyframes dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}

View File

@@ -0,0 +1,34 @@
import "./Loading.css";
type LoadingProps = {
text?: string;
children?: React.ReactNode;
size?: number;
};
const Loading = ({ text, children, size }: LoadingProps) => {
return (
<div className="flex items-center justify-center flex-col">
<div className={`showbox scale-${size ? size * 10 : 50}`}>
<div className="loader">
<svg className="circular" viewBox="25 25 50 50">
<circle
className="path"
cx="50"
cy="50"
r="20"
fill="none"
strokeWidth="2"
strokeMiterlimit="10"
/>
</svg>
</div>
</div>
<p className="text-lg font-bold">Loading...</p>
<p className="text-sm text-muted-foreground mb-4">{text}</p>
<div>{children}</div>
</div>
);
};
export default Loading;

View 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 字符串拆分为逻辑上的字符数组。
// 对于一个国家旗帜 emojichars 数组的长度将是 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;

View 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;

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants };

View File

@@ -0,0 +1,83 @@
import * as React from "react";
import { cn } from "@/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card backdrop-blur-xs text-card-foreground shadow",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-secondary-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

351
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,351 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}>
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{formatter
? formatter(
item.value,
item.name ?? "",
item,
index,
item.payload
)
: item.value}
</span>
)}
</div>
</>
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,193 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { DotFilledIcon } from "@radix-ui/react-icons";
import { cn } from "@/utils";
import { Check, ChevronRight } from "lucide-react";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
};

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-secondary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

107
src/components/ui/tag.tsx Normal file
View File

@@ -0,0 +1,107 @@
import { Badge } from "@radix-ui/themes";
import React from "react";
import { cn } from "@/utils";
interface TagProps extends React.HTMLAttributes<HTMLDivElement> {
tags: string[];
}
const colors: Array<
| "ruby"
| "gray"
| "gold"
| "bronze"
| "brown"
| "yellow"
| "amber"
| "orange"
| "tomato"
| "red"
| "crimson"
| "pink"
| "plum"
| "purple"
| "violet"
| "iris"
| "indigo"
| "blue"
| "cyan"
| "teal"
| "jade"
| "green"
| "grass"
| "lime"
| "mint"
| "sky"
> = [
"ruby",
"gray",
"gold",
"bronze",
"brown",
"yellow",
"amber",
"orange",
"tomato",
"red",
"crimson",
"pink",
"plum",
"purple",
"violet",
"iris",
"indigo",
"blue",
"cyan",
"teal",
"jade",
"green",
"grass",
"lime",
"mint",
"sky",
];
// 解析带颜色的标签
const parseTagWithColor = (tag: string) => {
const colorMatch = tag.match(/<(\w+)>$/);
if (colorMatch) {
const color = colorMatch[1].toLowerCase();
const text = tag.replace(/<\w+>$/, "");
// 检查颜色是否在支持的颜色列表中
if (colors.includes(color as any)) {
return { text, color: color as (typeof colors)[number] };
}
}
return { text: tag, color: null };
};
const Tag = React.forwardRef<HTMLDivElement, TagProps>(
({ className, tags, ...props }, ref) => {
return (
<div
ref={ref}
className={cn("flex flex-wrap gap-1", className)}
{...props}>
{tags.map((tag, index) => {
const { text, color } = parseTagWithColor(tag);
const badgeColor = color || colors[index % colors.length];
return (
<Badge
key={index}
color={badgeColor}
variant="soft"
className="text-sm">
<label className="text-xs">{text}</label>
</Badge>
);
})}
</div>
);
}
);
Tag.displayName = "Tag";
export { Tag };

View File

@@ -0,0 +1,71 @@
import {
useState,
useEffect,
createContext,
useContext,
type ReactNode,
} from "react";
import { wsService } from "../services/api";
import type { NodeStats } from "../types/node";
interface LiveData {
online: string[];
data: { [uuid: string]: NodeStats };
}
interface LiveDataContextType {
liveData: LiveData | null;
}
const LiveDataContext = createContext<LiveDataContextType | null>(null);
// eslint-disable-next-line react-refresh/only-export-components
export const useLiveData = () => {
const context = useContext(LiveDataContext);
if (!context) {
throw new Error("useLiveData must be used within a LiveDataProvider");
}
return context;
};
interface LiveDataProviderProps {
children: ReactNode;
enableWebSocket?: boolean;
}
export const LiveDataProvider = ({
children,
enableWebSocket = true,
}: LiveDataProviderProps) => {
const [liveData, setLiveData] = useState<LiveData | null>(null);
useEffect(() => {
if (!enableWebSocket) {
wsService.disconnect();
setLiveData(null);
return;
}
const handleWebSocketData = (data: LiveData) => {
if (data.online && data.data) {
setLiveData({
online: [...data.online],
data: { ...data.data },
});
}
};
const unsubscribe = wsService.subscribe(handleWebSocketData);
wsService.connect();
return () => {
unsubscribe();
};
}, [enableWebSocket]);
return (
<LiveDataContext.Provider value={{ liveData }}>
{children}
</LiveDataContext.Provider>
);
};

View File

@@ -0,0 +1,168 @@
import {
useState,
useEffect,
useCallback,
createContext,
useContext,
type ReactNode,
} from "react";
import { apiService } from "../services/api";
import type { NodeData, PublicInfo, HistoryRecord } from "../types/node";
// The core logic from the original useNodeData.ts, now kept internal to this file.
function useNodesInternal() {
const [staticNodes, setStaticNodes] = useState<NodeData[]>([]);
const [publicSettings, setPublicSettings] = useState<PublicInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchNodes = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nodeData, publicSettings] = await Promise.all([
apiService.getNodes(),
apiService.getPublicSettings(),
]);
const sortedNodes = nodeData.sort((a, b) => a.weight - b.weight);
setStaticNodes(sortedNodes);
setPublicSettings(publicSettings);
} catch (err) {
setError(err instanceof Error ? err.message : "获取节点数据失败");
} finally {
setLoading(false);
}
}, []);
const refreshNodes = useCallback(async () => {
await fetchNodes();
}, [fetchNodes]);
useEffect(() => {
fetchNodes();
}, [fetchNodes]);
const getNodeDetails = useCallback(async (uuid: string) => {
try {
const recentStats = await apiService.getNodeRecentStats(uuid);
return { recentStats };
} catch (err) {
console.error("Failed to fetch node recent stats:", err);
return null;
}
}, []);
const getLoadHistory = useCallback(
async (uuid: string, hours: number = 24) => {
try {
const loadHistory = await apiService.getLoadHistory(uuid, hours);
return loadHistory;
} catch (err) {
console.error("Failed to fetch load history:", err);
return null;
}
},
[]
);
const getPingHistory = useCallback(
async (uuid: string, hours: number = 24) => {
try {
const pingHistory = await apiService.getPingHistory(uuid, hours);
return pingHistory;
} catch (err) {
console.error("Failed to fetch ping history:", err);
return null;
}
},
[]
);
const getRecentLoadHistory = useCallback(async (uuid: string) => {
try {
const recentStats = await apiService.getNodeRecentStats(uuid);
if (!recentStats) return null;
const records: HistoryRecord[] = recentStats.map((stat) => ({
client: uuid,
time: stat.updated_at,
cpu: stat.cpu.usage,
ram: stat.ram.used,
disk: stat.disk.used,
load: stat.load.load1,
net_in: stat.network.down,
net_out: stat.network.up,
process: stat.process,
connections: stat.connections.tcp + stat.connections.udp,
gpu: 0,
ram_total: stat.ram.total,
swap: stat.swap.used,
swap_total: stat.swap.total,
temp: 0,
disk_total: stat.disk.total,
net_total_up: stat.network.totalUp,
net_total_down: stat.network.totalDown,
connections_udp: stat.connections.udp,
}));
return { count: records.length, records };
} catch (err) {
console.error("Failed to fetch recent load history:", err);
return null;
}
}, []);
const getNodesByGroup = useCallback(
(group: string) => {
return staticNodes.filter((node) => node.group === group);
},
[staticNodes]
);
const getGroups = useCallback(() => {
return Array.from(
new Set(staticNodes.map((node) => node.group).filter(Boolean))
);
}, [staticNodes]);
return {
nodes: staticNodes,
publicSettings,
loading,
error,
refreshNodes,
getNodeDetails,
getLoadHistory,
getPingHistory,
getRecentLoadHistory,
getNodesByGroup,
getGroups,
};
}
type NodeDataContextType = ReturnType<typeof useNodesInternal>;
const NodeDataContext = createContext<NodeDataContextType | null>(null);
// eslint-disable-next-line react-refresh/only-export-components
export const useNodeData = () => {
const context = useContext(NodeDataContext);
if (!context) {
throw new Error("useNodeData must be used within a NodeDataProvider");
}
return context;
};
interface NodeDataProviderProps {
children: ReactNode;
}
export const NodeDataProvider = ({ children }: NodeDataProviderProps) => {
const nodeData = useNodesInternal();
return (
<NodeDataContext.Provider value={nodeData}>
{children}
</NodeDataContext.Provider>
);
};

139
src/hooks/useLoadCharts.ts Normal file
View File

@@ -0,0 +1,139 @@
import { useState, useEffect, useMemo } from "react";
import { useNodeData } from "@/contexts/NodeDataContext";
import type { HistoryRecord, NodeData, NodeStats } from "@/types/node";
import { useLiveData } from "@/contexts/LiveDataContext";
export const useLoadCharts = (node: NodeData | null, hours: number) => {
const { getLoadHistory, getRecentLoadHistory } = useNodeData();
const { liveData } = useLiveData();
const [historicalData, setHistoricalData] = useState<HistoryRecord[]>([]);
const [realtimeData, setRealtimeData] = useState<HistoryRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isRealtime = hours === 0;
// Fetch historical data
useEffect(() => {
if (isRealtime || !node?.uuid) return;
const fetchHistoricalData = async () => {
setLoading(true);
setError(null);
try {
const data = await getLoadHistory(node.uuid, hours);
setHistoricalData(data?.records || []);
setRealtimeData([]); // Clear realtime data
} catch (err: any) {
setError(err.message || "Failed to fetch historical data");
} finally {
setLoading(false);
}
};
fetchHistoricalData();
}, [node?.uuid, hours, getLoadHistory, isRealtime]);
// Fetch initial real-time data and handle WebSocket updates
useEffect(() => {
if (!isRealtime || !node?.uuid) return;
const fetchInitialRealtimeData = async () => {
setLoading(true);
setError(null);
try {
const data = await getRecentLoadHistory(node.uuid);
setRealtimeData(data?.records || []);
setHistoricalData([]); // Clear historical data
} catch (err: any) {
setError(err.message || "Failed to fetch initial real-time data");
} finally {
setLoading(false);
}
};
fetchInitialRealtimeData();
}, [node?.uuid, getRecentLoadHistory, isRealtime]);
// Separate effect for WebSocket updates
useEffect(() => {
if (!isRealtime || !node?.uuid || !liveData?.data[node.uuid]) return;
const stats: NodeStats = liveData.data[node.uuid];
const newRecord: HistoryRecord = {
client: node.uuid,
time: new Date(stats.updated_at).toISOString(),
cpu: stats.cpu.usage,
ram: stats.ram.used,
disk: stats.disk.used,
load: stats.load.load1,
net_in: stats.network.down,
net_out: stats.network.up,
process: stats.process,
connections: stats.connections.tcp,
gpu: 0,
ram_total: stats.ram.total,
swap: stats.swap.used,
swap_total: stats.swap.total,
temp: 0,
disk_total: stats.disk.total,
net_total_up: stats.network.totalUp,
net_total_down: stats.network.totalDown,
connections_udp: stats.connections.udp,
};
setRealtimeData((prevHistory) => {
if (
prevHistory.length > 0 &&
new Date(prevHistory[prevHistory.length - 1].time).getTime() ===
new Date(newRecord.time).getTime()
) {
return prevHistory;
}
const updatedHistory = [...prevHistory, newRecord];
return updatedHistory.length > 600
? updatedHistory.slice(updatedHistory.length - 600)
: updatedHistory;
});
}, [liveData, node?.uuid, isRealtime]);
const historicalChartData = useMemo(() => {
return historicalData.map((record) => ({
time: new Date(record.time).getTime(),
cpu: record.cpu,
ram: record.ram,
disk: record.disk,
load: record.load,
net_out: record.net_out,
net_in: record.net_in,
connections: record.connections,
process: record.process,
swap: record.swap,
connections_udp: record.connections_udp,
}));
}, [historicalData]);
const realtimeChartData = useMemo(() => {
return realtimeData.map((record) => ({
time: new Date(record.time).getTime(),
cpu: record.cpu,
ram: record.ram,
disk: record.disk,
load: record.load,
net_out: record.net_out,
net_in: record.net_in,
connections: record.connections,
process: record.process,
swap: record.swap,
connections_udp: record.connections_udp,
}));
}, [realtimeData]);
const chartData = isRealtime ? realtimeChartData : historicalChartData;
return {
loading,
error,
chartData,
};
};

21
src/hooks/useMobile.ts Normal file
View File

@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@@ -0,0 +1,68 @@
import { useMemo } from "react";
import type { NodeWithStatus } from "@/types/node";
import { formatPrice } from "@/utils";
export const useNodeCommons = (node: NodeWithStatus) => {
const { stats, isOnline } = useMemo(() => {
return {
stats: node.stats,
isOnline: node.status === "online",
};
}, [node]);
const price = formatPrice(node.price, node.currency, node.billing_cycle);
const cpuUsage = stats && isOnline ? stats.cpu.usage : 0;
const memUsage =
stats && isOnline && stats.ram.total > 0
? (stats.ram.used / stats.ram.total) * 100
: 0;
const swapUsage =
stats && isOnline && stats.swap.total > 0
? (stats.swap.used / stats.swap.total) * 100
: 0;
const diskUsage =
stats && isOnline && stats.disk.total > 0
? (stats.disk.used / stats.disk.total) * 100
: 0;
const load = stats
? `${stats.load.load1.toFixed(2)} | ${stats.load.load5.toFixed(
2
)} | ${stats.load.load15.toFixed(2)}`
: "N/A";
const daysLeft = node.expired_at
? Math.ceil(
(new Date(node.expired_at).getTime() - new Date().getTime()) /
(1000 * 60 * 60 * 24)
)
: null;
const tagList = [
price,
`${daysLeft && daysLeft < 0 ? "已过期" : ""}${
daysLeft && daysLeft >= 0 && daysLeft < 36500
? "余 " + daysLeft + " 天"
: ""
}`,
...(typeof node.tags === "string"
? node.tags
.split(";")
.map((tag) => tag.trim())
.filter(Boolean)
: []),
];
return {
stats,
isOnline,
tagList,
cpuUsage,
memUsage,
swapUsage,
diskUsage,
load,
daysLeft,
};
};

42
src/hooks/usePingChart.ts Normal file
View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import { useNodeData } from "@/contexts/NodeDataContext";
import type { PingHistoryResponse, NodeData } from "@/types/node";
export const usePingChart = (node: NodeData | null, hours: number) => {
const { getPingHistory } = useNodeData();
const [pingHistory, setPingHistory] = useState<PingHistoryResponse | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!node?.uuid) {
setPingHistory(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
const fetchHistory = async () => {
try {
const data = await getPingHistory(node.uuid, hours);
setPingHistory(data);
} catch (err: any) {
setError(err.message || "Failed to fetch history data");
} finally {
setLoading(false);
}
};
fetchHistory();
}, [node?.uuid, hours, getPingHistory]);
return {
loading,
error,
pingHistory,
};
};

40
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,40 @@
import { useState, useEffect } from "react";
type Theme = "light" | "dark" | "system";
export const useTheme = () => {
const [theme, setTheme] = useState<Theme>(() => {
const storedTheme = localStorage.getItem("appearance");
if (
storedTheme === "light" ||
storedTheme === "dark" ||
storedTheme === "system"
) {
return storedTheme;
}
return "system";
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
localStorage.setItem("appearance", theme);
}, [theme]);
const toggleTheme = () => {
const newTheme = theme === "light" ? "dark" : "light";
setTheme(newTheme);
};
return { theme, toggleTheme };
};

147
src/index.css Normal file
View File

@@ -0,0 +1,147 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0 / 0.5);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.55 0.25 27);
--destructive-transparent: oklch(0.55 0.25 27 / 0.1);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Frosted Glass Variables */
--frosted-bg-light: rgba(255, 255, 255, 0.1);
--frosted-border-light: rgba(255, 255, 255, 0.2);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0 / 0.5);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.8 0.15 20);
--destructive-transparent: oklch(0.8 0.15 20 / 0.25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* Frosted Glass Variables */
--frosted-bg-dark: rgba(0, 0, 0, 0.1);
--frosted-border-dark: rgba(0, 0, 0, 0.2);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background;
}
button {
cursor: pointer;
}
}
.striped-bg-red-translucent-diagonal {
background-image: linear-gradient(
45deg,
var(--destructive-transparent) 25%,
transparent 25%,
transparent 50%,
var(--destructive-transparent) 50%,
var(--destructive-transparent) 75%,
transparent 75%,
transparent 100%
);
background-size: 60px 60px;
}

84
src/main.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { StrictMode, useState, useEffect, lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import "./index.css";
import "@radix-ui/themes/styles.css";
import { Theme } from "@radix-ui/themes";
import { Header } from "@/components/sections/Header";
import { useTheme } from "@/hooks/useTheme";
import { NodeDataProvider } from "@/contexts/NodeDataContext";
import { LiveDataProvider } from "@/contexts/LiveDataContext";
import { useNodeData } from "@/contexts/NodeDataContext";
import Footer from "@/components/sections/Footer";
import Loading from "./components/loading";
const HomePage = lazy(() => import("@/pages/Home"));
const InstancePage = lazy(() => import("@/pages/instance"));
const NotFoundPage = lazy(() => import("@/pages/NotFound"));
// eslint-disable-next-line react-refresh/only-export-components
const App = () => {
const { theme, toggleTheme } = useTheme();
const { publicSettings } = useNodeData();
const [viewMode, setViewMode] = useState<"card" | "list">(() => {
const savedMode = localStorage.getItem("nodeViewMode");
if (savedMode === "table") {
return "list";
}
return "card";
});
const [searchTerm, setSearchTerm] = useState("");
const sitename = publicSettings ? publicSettings.sitename : "Komari";
useEffect(() => {
document.title = sitename;
}, [sitename]);
useEffect(() => {
const modeToStore = viewMode === "card" ? "grid" : "table";
localStorage.setItem("nodeViewMode", modeToStore);
}, [viewMode]);
return (
<Theme
appearance="inherit"
scaling="110%"
style={{ backgroundColor: "transparent" }}>
<div className="min-h-screen flex flex-col text-sm">
<Header
viewMode={viewMode}
setViewMode={setViewMode}
theme={theme}
toggleTheme={toggleTheme}
sitename={sitename}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
<Suspense fallback={<Loading />}>
<Routes>
<Route
path="/"
element={<HomePage viewMode={viewMode} searchTerm={searchTerm} />}
/>
<Route path="/instance/:uuid" element={<InstancePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
<Footer />
</div>
</Theme>
);
};
createRoot(document.getElementById("root")!).render(
<StrictMode>
<NodeDataProvider>
<LiveDataProvider>
<Router>
<App />
</Router>
</LiveDataProvider>
</NodeDataProvider>
</StrictMode>
);

145
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,145 @@
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { StatsBar } from "@/components/sections/StatsBar";
import { NodeCard } from "@/components/sections/NodeCard";
import { NodeListHeader } from "@/components/sections/NodeListHeader";
import { NodeListItem } from "@/components/sections/NodeListItem";
import Loading from "@/components/loading";
import type { NodeWithStatus } from "@/types/node";
import { useNodeData } from "@/contexts/NodeDataContext";
import { useLiveData } from "@/contexts/LiveDataContext";
interface HomePageProps {
viewMode: "card" | "list";
searchTerm: string;
}
const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
const { nodes: staticNodes, loading, getGroups } = useNodeData();
const { liveData } = useLiveData();
const [selectedGroup, setSelectedGroup] = useState("所有");
const [displayOptions, setDisplayOptions] = useState({
time: true,
online: true,
regions: true,
traffic: true,
speed: true,
});
const [currentTime] = useState(new Date());
const combinedNodes = useMemo<NodeWithStatus[]>(() => {
if (!staticNodes) return [];
return staticNodes.map((node) => {
const isOnline = liveData?.online.includes(node.uuid) ?? false;
const stats = isOnline ? liveData?.data[node.uuid] : undefined;
return {
...node,
status: isOnline ? "online" : "offline",
stats: stats,
};
});
}, [staticNodes, liveData]);
const groups = useMemo(() => ["所有", ...getGroups()], [getGroups]);
const filteredNodes = useMemo(() => {
return combinedNodes
.filter(
(node: NodeWithStatus) =>
selectedGroup === "所有" || node.group === selectedGroup
)
.filter((node: NodeWithStatus) =>
node.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [combinedNodes, selectedGroup, searchTerm]);
const stats = useMemo(() => {
return {
onlineCount: filteredNodes.filter((n) => n.status === "online").length,
totalCount: filteredNodes.length,
uniqueRegions: new Set(filteredNodes.map((n) => n.region)).size,
totalTrafficUp: filteredNodes.reduce(
(acc, node) => acc + (node.stats?.network.totalUp || 0),
0
),
totalTrafficDown: filteredNodes.reduce(
(acc, node) => acc + (node.stats?.network.totalDown || 0),
0
),
currentSpeedUp: filteredNodes.reduce(
(acc, node) => acc + (node.stats?.network.up || 0),
0
),
currentSpeedDown: filteredNodes.reduce(
(acc, node) => acc + (node.stats?.network.down || 0),
0
),
};
}, [filteredNodes]);
return (
<div className="w-[90%] max-w-screen-2xl mx-auto flex-1 flex flex-col pb-10">
<StatsBar
displayOptions={displayOptions}
setDisplayOptions={setDisplayOptions}
stats={stats}
loading={loading}
currentTime={currentTime}
/>
<main className="flex-1 px-4 pb-4">
<div className="flex overflow-auto whitespace-nowrap overflow-x-auto items-center min-w-[300px] text-secondary-foreground box-border border space-x-4 px-4 rounded-lg mb-4 bg-card backdrop-blur-[10px]">
<span></span>
{groups.map((group: string) => (
<Button
key={group}
variant={selectedGroup === group ? "secondary" : "ghost"}
size="sm"
onClick={() => setSelectedGroup(group)}>
{group}
</Button>
))}
</div>
<div className="space-y-4">
{loading ? (
<Loading text="正在努力获取数据中..." />
) : filteredNodes.length > 0 ? (
<div
className={
viewMode === "card"
? ""
: "space-y-2 bg-card overflow-auto backdrop-blur-[10px] rounded-lg p-2"
}>
<div
className={
viewMode === "card"
? "grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4"
: "min-w-[1080px]"
}>
{viewMode === "list" && <NodeListHeader />}
{filteredNodes.map((node: NodeWithStatus) =>
viewMode === "card" ? (
<NodeCard key={node.uuid} node={node} />
) : (
<NodeListItem key={node.uuid} node={node} />
)
)}
</div>
</div>
) : (
<div className="text-center py-12">
<p className="text-lg font-bold"></p>
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
</main>
</div>
);
};
export default HomePage;

31
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useNavigate } from "react-router-dom";
export default function NotFound() {
const navigate = useNavigate();
return (
<div className="flex flex-grow items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl font-bold">404 - Not Found</CardTitle>
<CardDescription>
The page you are looking for does not exist.
</CardDescription>
</CardHeader>
<CardFooter>
<Button onClick={() => navigate("/")} className="w-full">
Go to Home
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { NodeWithStatus } from "@/types/node";
import { useMemo, memo } from "react";
interface InstanceProps {
node: NodeWithStatus;
}
const formatUptime = (uptime: number) => {
if (!uptime) return "N/A";
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = Math.floor(uptime % 60);
let result = "";
if (days > 0) result += `${days}`;
if (hours > 0 || days > 0) result += `${hours}`;
if (minutes > 0 || hours > 0 || days > 0) result += `${minutes}`;
result += `${seconds}`;
return result.trim();
};
const formatBytes = (bytes: number, unit: "KB" | "MB" | "GB" = "GB") => {
if (bytes === 0) return `0 ${unit}`;
const k = 1024;
switch (unit) {
case "KB":
return `${(bytes / k).toFixed(2)} KB`;
case "MB":
return `${(bytes / (k * k)).toFixed(2)} MB`;
case "GB":
return `${(bytes / (k * k * k)).toFixed(2)} GB`;
default:
return `${bytes} B`;
}
};
const Instance = memo(({ node }: InstanceProps) => {
const { stats, isOnline } = useMemo(() => {
return {
stats: node.stats,
isOnline: node.status === "online",
};
}, [node]);
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<p className="text-muted-foreground">CPU</p>
<p>{`${node.cpu_name} (x${node.cpu_cores})`}</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>{node.arch}</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>{node.virtualization}</p>
</div>
<div>
<p className="text-muted-foreground">GPU</p>
<p>{node.gpu_name || "N/A"}</p>
</div>
<div className="md:col-span-2">
<p className="text-muted-foreground"></p>
<p>{node.os}</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
{stats && isOnline
? `${formatBytes(stats.ram.used)} / ${formatBytes(
node.mem_total
)}`
: `N/A / ${formatBytes(node.mem_total)}`}
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
{stats && isOnline
? `${formatBytes(stats.swap.used, "MB")} / ${formatBytes(
node.swap_total
)}`
: `N/A / ${formatBytes(node.swap_total)}`}
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
{stats && isOnline
? `${formatBytes(stats.disk.used)} / ${formatBytes(
node.disk_total
)}`
: `N/A / ${formatBytes(node.disk_total)}`}
</p>
</div>
<div className="md:col-span-2">
<p className="text-muted-foreground"></p>
<p>
{stats && isOnline
? `${formatBytes(stats.network.up, "KB")}/s ↓ ${formatBytes(
stats.network.down,
"KB"
)}/s`
: "N/A"}
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
{stats && isOnline
? `${formatBytes(stats.network.totalUp)}${formatBytes(
stats.network.totalDown
)}`
: "N/A"}
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>{formatUptime(stats?.uptime || 0)}</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
{stats && isOnline
? new Date(stats.updated_at).toLocaleString()
: "N/A"}
</p>
</div>
</CardContent>
</Card>
);
});
export default Instance;

View File

@@ -0,0 +1,634 @@
import { memo, useCallback, useMemo, useState, useEffect, useRef } from "react";
import {
AreaChart,
Area,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
import { ChartContainer } from "@/components/ui/chart";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import type { NodeData, NodeStats } from "@/types/node";
import { formatBytes } from "@/utils";
import fillMissingTimePoints, { type RecordFormat } from "@/utils/RecordHelper";
import { Flex } from "@radix-ui/themes";
import Loading from "@/components/loading";
import { useNodeData } from "@/contexts/NodeDataContext";
interface LoadChartsProps {
node: NodeData;
hours: number;
data?: RecordFormat[];
liveData?: NodeStats;
}
const ChartTitle = (text: string, left: React.ReactNode) => {
return (
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{text}</CardTitle>
<span className="text-sm font-bold">{left}</span>
</CardHeader>
);
};
const LoadCharts = memo(
({ node, hours, data = [], liveData: live_data }: LoadChartsProps) => {
const { getLoadHistory } = useNodeData();
const [historicalData, setHistoricalData] = useState<RecordFormat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isRealtime = hours === 0;
useEffect(() => {
if (!isRealtime) {
const fetchHistoricalData = async () => {
setLoading(true);
setError(null);
try {
const data = await getLoadHistory(node.uuid, hours);
setHistoricalData(data?.records || []);
} catch (err: any) {
setError(err.message || "Failed to fetch historical data");
} finally {
setLoading(false);
}
};
fetchHistoricalData();
} else {
// For realtime, we expect data to be passed via props.
// We can set loading to false if data is present.
setLoading(false);
}
}, [node.uuid, hours, getLoadHistory, isRealtime]);
const minute = 60;
const hour = minute * 60;
const chartData = isRealtime
? data
: hours === 1
? fillMissingTimePoints(historicalData ?? [], minute, hour, minute * 2)
: (() => {
const interval = hours > 120 ? hour : minute * 15;
const maxGap = interval * 2;
return fillMissingTimePoints(
historicalData ?? [],
interval,
hour * hours,
maxGap
);
})();
const chartDataLengthRef = useRef(0);
chartDataLengthRef.current = chartData.length;
const timeFormatter = useCallback((value: any, index: number) => {
if (chartDataLengthRef.current === 0) {
return "";
}
if (index === 0 || index === chartDataLengthRef.current - 1) {
return new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
return "";
}, []);
const lableFormatter = useCallback(
(value: any) => {
const date = new Date(value);
if (hours === 0) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
return date.toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
},
[hours]
);
const cn = "flex flex-col w-full h-full gap-4 justify-between";
const chartMargin = {
top: 10,
right: 10,
bottom: 10,
left: 10,
};
const colors = ["#F38181", "#FCE38A", "#EAFFD0", "#95E1D3"];
const primaryColor = colors[0];
const secondaryColor = colors[1];
const percentageFormatter = (value: number) => {
return `${value.toFixed(2)}%`;
};
const memoryChartData = useMemo(() => {
return chartData.map((item) => ({
time: item.time,
ram: ((item.ram ?? 0) / (node?.mem_total ?? 1)) * 100,
ram_raw: item.ram,
swap: ((item.swap ?? 0) / (node?.swap_total ?? 1)) * 100,
swap_raw: item.swap,
}));
}, [chartData, node?.mem_total, node?.swap_total]);
// 通用自定义 Tooltip 组件
const CustomTooltip = ({ active, payload, label, chartType }: any) => {
if (!active || !payload || !payload.length) return null;
return (
<div className="bg-background/80 p-3 border rounded-lg shadow-lg max-w-xs">
<p className="text-xs font-medium text-muted-foreground mb-2">
{lableFormatter(label)}
</p>
<div className="space-y-1">
{payload.map((item: any, index: number) => {
let value = item.value;
let displayName = item.name || item.dataKey;
// 根据图表类型和 dataKey 格式化值和显示名称
switch (chartType) {
case "cpu":
value = percentageFormatter(value);
displayName = "CPU 使用率";
break;
case "memory":
if (item.dataKey === "ram") {
const rawValue = item.payload?.ram_raw;
if (rawValue !== undefined) {
value = `${formatBytes(rawValue)} (${value.toFixed(0)}%)`;
} else {
value = percentageFormatter(value);
}
displayName = "内存";
} else if (item.dataKey === "swap") {
const rawValue = item.payload?.swap_raw;
if (rawValue !== undefined) {
value = `${formatBytes(rawValue)} (${value.toFixed(0)}%)`;
} else {
value = percentageFormatter(value);
}
displayName = "交换";
}
break;
case "disk":
value = formatBytes(value);
displayName = "磁盘使用";
break;
case "network":
if (item.dataKey === "net_in") {
value = `${formatBytes(value)}/s`;
displayName = "下载";
} else if (item.dataKey === "net_out") {
value = `${formatBytes(value)}/s`;
displayName = "上传";
}
break;
case "connections":
if (item.dataKey === "connections") {
displayName = "TCP 连接";
} else if (item.dataKey === "connections_udp") {
displayName = "UDP 连接";
}
value = value.toString();
break;
case "process":
displayName = "进程数";
value = value.toString();
break;
default:
value = value.toString();
}
return (
<div
key={`${item.dataKey}-${index}`}
className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: item.color }}
/>
<span className="text-sm font-medium text-foreground">
{displayName}:
</span>
</div>
<span className="text-sm font-bold ml-2">{value}</span>
</div>
);
})}
</div>
</div>
);
};
return (
<div className="relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
<Loading text="正在加载图表数据..." />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
<p className="text-red-500">{error}</p>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* CPU */}
<Card className={cn}>
{ChartTitle(
"CPU",
live_data?.cpu?.usage ? `${live_data.cpu.usage.toFixed(2)}%` : "-"
)}
<ChartContainer
config={{
cpu: {
label: "CPU",
color: primaryColor,
},
}}>
<AreaChart data={chartData} margin={chartMargin}>
<CartesianGrid
strokeDasharray="2 4"
vertical={false}
stroke="var(--gray-a3)"
/>
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tickFormatter={timeFormatter}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
domain={[0, 100]}
tickFormatter={(value, index) =>
index !== 0 ? `${value}%` : ""
}
orientation="left"
type="number"
tick={{ dx: -10 }}
mirror={true}
/>
<Tooltip
cursor={false}
content={(props) => (
<CustomTooltip {...props} chartType="cpu" />
)}
/>
<Area
dataKey="cpu"
animationDuration={0}
stroke={primaryColor}
fill={primaryColor}
opacity={0.8}
dot={false}
/>
</AreaChart>
</ChartContainer>
</Card>
{/* Ram */}
<Card className={cn}>
{ChartTitle(
"内存",
<Flex gap="0" direction="column" align="end" className="text-sm">
<label>
{live_data?.ram?.used
? `${formatBytes(live_data.ram.used)} / ${formatBytes(
node?.mem_total || 0
)}`
: "-"}
</label>
<label>
{live_data?.swap?.used
? `${formatBytes(live_data.swap.used)} / ${formatBytes(
node?.swap_total || 0
)}`
: "-"}
</label>
</Flex>
)}
<ChartContainer
config={{
ram: {
label: "Ram",
color: primaryColor,
},
swap: {
label: "Swap",
color: secondaryColor,
},
}}>
<AreaChart data={memoryChartData} margin={chartMargin}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tickFormatter={timeFormatter}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
domain={[0, 100]}
tickFormatter={(value, index) =>
index !== 0 ? `${value}%` : ""
}
orientation="left"
type="number"
tick={{ dx: -10 }}
mirror={true}
/>
<Tooltip
cursor={false}
content={(props) => (
<CustomTooltip {...props} chartType="memory" />
)}
/>
<Area
dataKey="ram"
animationDuration={0}
stroke={primaryColor}
fill={primaryColor}
opacity={0.8}
dot={false}
/>
<Area
dataKey="swap"
animationDuration={0}
stroke={secondaryColor}
fill={secondaryColor}
opacity={0.8}
dot={false}
/>
</AreaChart>
</ChartContainer>
</Card>
{/* Disk */}
<Card className={cn}>
{ChartTitle(
"磁盘",
live_data?.disk?.used
? `${formatBytes(live_data.disk.used)} / ${formatBytes(
node?.disk_total || 0
)}`
: "-"
)}
<ChartContainer
config={{
disk: {
label: "Disk",
color: primaryColor,
},
}}>
<AreaChart data={chartData} margin={chartMargin}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tickFormatter={timeFormatter}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
domain={[0, node?.disk_total || 100]}
tickFormatter={(value, index) =>
index !== 0 ? `${formatBytes(value)}` : ""
}
orientation="left"
type="number"
tick={{ dx: -10 }}
mirror={true}
/>
<Tooltip
cursor={false}
content={(props) => (
<CustomTooltip {...props} chartType="disk" />
)}
/>
<Area
dataKey="disk"
animationDuration={0}
stroke={primaryColor}
fill={primaryColor}
opacity={0.8}
dot={false}
/>
</AreaChart>
</ChartContainer>
</Card>
{/* Network */}
<Card className={cn}>
{ChartTitle(
"网络",
<Flex gap="0" align="end" direction="column" className="text-sm">
<span> {formatBytes(live_data?.network.up || 0)}/s</span>
<span> {formatBytes(live_data?.network.down || 0)}/s</span>
</Flex>
)}
<ChartContainer
config={{
net_in: {
label: "网络下载",
color: primaryColor,
},
net_out: {
label: "网络上传",
color: colors[3],
},
}}>
<LineChart data={chartData} margin={chartMargin}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tickFormatter={timeFormatter}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value, index) =>
index !== 0 ? `${formatBytes(value)}` : ""
}
orientation="left"
type="number"
tick={{ dx: -10 }}
mirror={true}
/>
<Tooltip
cursor={false}
content={(props) => (
<CustomTooltip {...props} chartType="network" />
)}
/>
<Line
dataKey="net_in"
animationDuration={0}
stroke={primaryColor}
fill={primaryColor}
opacity={0.8}
dot={false}
/>
<Line
dataKey="net_out"
animationDuration={0}
stroke={colors[3]}
fill={colors[3]}
opacity={0.8}
dot={false}
/>
</LineChart>
</ChartContainer>
</Card>
{/* Connections */}
<Card className={cn}>
{ChartTitle(
"连接数",
<Flex gap="0" align="end" direction="column" className="text-sm">
<span>TCP: {live_data?.connections.tcp}</span>
<span>UDP: {live_data?.connections.udp}</span>
</Flex>
)}
<ChartContainer
config={{
connections: {
label: "TCP",
color: primaryColor,
},
connections_udp: {
label: "UDP",
color: colors[3],
},
}}>
<LineChart data={chartData} margin={chartMargin}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tickFormatter={timeFormatter}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value, index) =>
index !== 0 ? `${value}` : ""
}
orientation="left"
type="number"
tick={{ dx: -10 }}
mirror={true}
/>
<Tooltip
cursor={false}
content={(props) => (
<CustomTooltip {...props} chartType="connections" />
)}
/>
<Line
dataKey="connections"
animationDuration={0}
stroke={primaryColor}
opacity={0.8}
dot={false}
name="TCP"
/>
<Line
dataKey="connections_udp"
animationDuration={0}
stroke={secondaryColor}
opacity={0.8}
dot={false}
name="UDP"
/>
</LineChart>
</ChartContainer>
</Card>
{/* Process */}
<Card className={cn}>
{ChartTitle("进程数", live_data?.process || "-")}
<ChartContainer
config={{
process: {
label: "进程数",
color: primaryColor,
},
}}>
<LineChart data={chartData} margin={chartMargin}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tickFormatter={timeFormatter}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value, index) =>
index !== 0 ? `${value}` : ""
}
orientation="left"
type="number"
tick={{ dx: -10 }}
mirror={true}
/>
<Tooltip
cursor={false}
content={(props) => (
<CustomTooltip {...props} chartType="process" />
)}
/>
<Line
dataKey="process"
animationDuration={0}
stroke={primaryColor}
opacity={0.8}
dot={false}
/>
</LineChart>
</ChartContainer>
</Card>
</div>
</div>
);
}
);
export default LoadCharts;

View File

@@ -0,0 +1,284 @@
import { memo, useState, useMemo, useCallback, useEffect } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Brush,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Label } from "@radix-ui/react-label";
import type { NodeData } from "@/types/node";
import Loading from "@/components/loading";
import { usePingChart } from "@/hooks/usePingChart";
import fillMissingTimePoints, { cutPeakValues } from "@/utils/RecordHelper";
import { Button } from "@/components/ui/button";
interface PingChartProps {
node: NodeData;
hours: number;
}
const PingChart = memo(({ node, hours }: PingChartProps) => {
const { loading, error, pingHistory } = usePingChart(node, hours);
const [visiblePingTasks, setVisiblePingTasks] = useState<number[]>([]);
const [timeRange, setTimeRange] = useState<[number, number] | null>(null);
const [cutPeak, setCutPeak] = useState(false);
useEffect(() => {
if (pingHistory?.tasks) {
setVisiblePingTasks(pingHistory.tasks.map((t) => t.id));
}
}, [pingHistory]);
const lableFormatter = useCallback(
(value: any) => {
const date = new Date(value);
if (hours === 0) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
return date.toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
},
[hours]
);
const chartData = useMemo(() => {
if (!pingHistory || !pingHistory.records || !pingHistory.tasks) return [];
const grouped: Record<string, any> = {};
const timeKeys: number[] = [];
for (const rec of pingHistory.records) {
const t = new Date(rec.time).getTime();
let foundKey = null;
for (const key of timeKeys) {
if (Math.abs(key - t) <= 1500) {
foundKey = key;
break;
}
}
const useKey = foundKey !== null ? foundKey : t;
if (!grouped[useKey]) {
grouped[useKey] = { time: useKey };
if (foundKey === null) timeKeys.push(useKey);
}
grouped[useKey][rec.task_id] = rec.value === -1 ? null : rec.value;
}
let full = Object.values(grouped).sort((a: any, b: any) => a.time - b.time);
if (hours !== 0) {
const task = pingHistory.tasks;
let interval = task[0]?.interval || 60;
let maxGap = interval * 1.2;
const selectedHours = timeRange
? (timeRange[1] - timeRange[0]) / (1000 * 60 * 60)
: hours;
if (selectedHours > 24) {
interval *= 60;
}
full = fillMissingTimePoints(full, interval, hours * 60 * 60, maxGap);
full = full.map((d: any) => ({
...d,
time: new Date(d.time).getTime(),
}));
}
if (cutPeak && pingHistory.tasks.length > 0) {
const taskKeys = pingHistory.tasks.map((task) => String(task.id));
full = cutPeakValues(full, taskKeys);
}
return full;
}, [pingHistory, hours, cutPeak, timeRange]);
const handleTaskVisibilityToggle = (taskId: number) => {
setVisiblePingTasks((prev) =>
prev.includes(taskId)
? prev.filter((id) => id !== taskId)
: [...prev, taskId]
);
};
const stringToColor = useCallback((str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += ("00" + value.toString(16)).substr(-2);
}
return color;
}, []);
return (
<div className="relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
<Loading text="正在加载图表数据..." />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
<p className="text-red-500">{error}</p>
</div>
)}
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-sm font-medium">Ping </CardTitle>
<div className="flex items-center space-x-2 mt-2">
<Switch
id="peak-shaving"
checked={cutPeak}
onCheckedChange={setCutPeak}
/>
<Label htmlFor="peak-shaving"></Label>
</div>
</div>
<div className="flex flex-wrap gap-2 justify-end">
{(pingHistory?.tasks || []).map((task) => {
const values = chartData
.map((d) => d[task.id])
.filter((v) => v !== null && v !== undefined) as number[];
const loss =
chartData.length > 0
? (1 - values.length / chartData.length) * 100
: 0;
const min = values.length > 0 ? Math.min(...values) : 0;
const isVisible = visiblePingTasks.includes(task.id);
return (
<div key={task.id} className="flex flex-col items-center">
<Button
variant={isVisible ? "default" : "outline"}
size="sm"
className="h-8 px-2"
onClick={() => handleTaskVisibilityToggle(task.id)}
style={{
backgroundColor: isVisible
? stringToColor(task.name)
: undefined,
color: isVisible ? "white" : undefined,
}}>
{task.name}
<span className="text-xs mt-1">
{loss.toFixed(1)}% | {min.toFixed(0)}ms
</span>
</Button>
</div>
);
})}
</div>
</div>
</CardHeader>
<CardContent>
{pingHistory?.tasks && pingHistory.tasks.length > 0 ? (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
type="number"
dataKey="time"
{...(chartData.length > 1 && {
domain: ["dataMin", "dataMax"],
})}
tickFormatter={(time) => {
const date = new Date(time);
if (hours === 0) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
return date.toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}}
scale="time"
/>
<YAxis />
<Tooltip labelFormatter={lableFormatter} />
{pingHistory.tasks.map((task) => (
<Line
key={task.id}
type={cutPeak ? "basis" : "linear"}
dataKey={String(task.id)}
name={task.name}
stroke={stringToColor(task.name)}
strokeWidth={2}
hide={!visiblePingTasks.includes(task.id)}
dot={false}
connectNulls={false}
/>
))}
<Brush
dataKey="time"
height={30}
stroke="#8884d8"
tickFormatter={(time) => {
const date = new Date(time);
if (hours === 0) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleDateString([], {
month: "2-digit",
day: "2-digit",
});
}}
onChange={(e: any) => {
if (
e.startIndex !== undefined &&
e.endIndex !== undefined &&
chartData[e.startIndex] &&
chartData[e.endIndex]
) {
setTimeRange([
chartData[e.startIndex].time,
chartData[e.endIndex].time,
]);
} else {
setTimeRange(null);
}
}}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[400px] flex items-center justify-center">
<p></p>
</div>
)}
</CardContent>
</Card>
</div>
);
});
export default PingChart;

View File

@@ -0,0 +1,238 @@
import { useState, useEffect, lazy, Suspense, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useNodeData } from "@/contexts/NodeDataContext";
import { useLiveData } from "@/contexts/LiveDataContext";
import type { NodeData, NodeWithStatus } from "@/types/node";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Instance from "./Instance";
const LoadCharts = lazy(() => import("./LoadCharts"));
const PingChart = lazy(() => import("./PingChart"));
import Loading from "@/components/loading";
import Flag from "@/components/sections/Flag";
const InstancePage = () => {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const {
nodes: staticNodes,
publicSettings,
loading: nodesLoading,
} = useNodeData();
const { liveData } = useLiveData();
const { getRecentLoadHistory } = useNodeData();
const [staticNode, setStaticNode] = useState<NodeData | null>(null);
const [chartType, setChartType] = useState<"load" | "ping">("load");
const [loadHours, setLoadHours] = useState<number>(0);
const [pingHours, setPingHours] = useState<number>(1); // 默认1小时
const [realtimeChartData, setRealtimeChartData] = useState<any[]>([]);
const maxRecordPreserveTime = publicSettings?.record_preserve_time || 0; // 默认0表示关闭
const maxPingRecordPreserveTime =
publicSettings?.ping_record_preserve_time || 24; // 默认1天
const timeRanges = useMemo(() => {
return [
{ label: "实时", hours: 0 },
{ label: "1小时", hours: 1 },
{ label: "6小时", hours: 6 },
{ label: "1天", hours: 24 },
{ label: "7天", hours: 168 },
{ label: "30天", hours: 720 },
];
}, []);
const pingTimeRanges = useMemo(() => {
const filtered = timeRanges.filter(
(range) => range.hours !== 0 && range.hours <= maxPingRecordPreserveTime
);
if (maxPingRecordPreserveTime > 720) {
filtered.push({
label: `${maxPingRecordPreserveTime}小时`,
hours: maxPingRecordPreserveTime,
});
}
return filtered;
}, [timeRanges, maxPingRecordPreserveTime]);
const loadTimeRanges = useMemo(() => {
const filtered = timeRanges.filter(
(range) => range.hours <= maxRecordPreserveTime
);
if (maxRecordPreserveTime > 720) {
filtered.push({
label: `${maxRecordPreserveTime}小时`,
hours: maxRecordPreserveTime,
});
}
return filtered;
}, [timeRanges, maxRecordPreserveTime]);
useEffect(() => {
const foundNode = staticNodes.find((n) => n.uuid === uuid);
setStaticNode(foundNode || null);
}, [staticNodes, uuid]);
// Effect for fetching initial realtime data
useEffect(() => {
if (uuid && loadHours === 0) {
const fetchInitialData = async () => {
try {
const data = await getRecentLoadHistory(uuid);
setRealtimeChartData(data?.records || []);
} catch (error) {
console.error("Failed to fetch initial realtime chart data:", error);
setRealtimeChartData([]);
}
};
fetchInitialData();
}
}, [uuid, loadHours, getRecentLoadHistory]);
// Effect for handling live data updates
useEffect(() => {
if (loadHours !== 0 || !liveData?.data || !uuid || !liveData.data[uuid]) {
return;
}
const stats = liveData.data[uuid];
const newRecord = {
client: uuid,
time: new Date(stats.updated_at).toISOString(),
cpu: stats.cpu.usage,
ram: stats.ram.used,
disk: stats.disk.used,
load: stats.load.load1,
net_in: stats.network.down,
net_out: stats.network.up,
process: stats.process,
connections: stats.connections.tcp,
gpu: 0,
ram_total: stats.ram.total,
swap: stats.swap.used,
swap_total: stats.swap.total,
temp: 0,
disk_total: stats.disk.total,
net_total_up: stats.network.totalUp,
net_total_down: stats.network.totalDown,
connections_udp: stats.connections.udp,
};
setRealtimeChartData((prev) => {
const updated = [...prev, newRecord];
return updated.length > 600 ? updated.slice(-600) : updated;
});
}, [liveData, uuid, loadHours]);
const node = useMemo(() => {
if (!staticNode) return null;
const isOnline = liveData?.online.includes(staticNode.uuid) ?? false;
const stats = isOnline ? liveData?.data[staticNode.uuid] : undefined;
return {
...staticNode,
status: isOnline ? "online" : "offline",
stats,
};
}, [staticNode, liveData]);
if (!node || !staticNode) {
if (nodesLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loading text="正在获取节点信息..." />
</div>
);
}
return (
<div className="flex items-center justify-center h-full">
</div>
);
}
return (
<div className="w-[90%] max-w-screen-2xl mx-auto flex-1 flex flex-col pb-15 p-4 space-y-4">
<div className="flex items-center justify-between bg-card box-border border rounded-lg p-4 mb-4 text-secondary-foreground">
<div className="flex items-center gap-2 min-w-0">
<Button
className="bg-card flex-shrink-0"
variant="ghost"
size="icon"
onClick={() => navigate(-1)}>
<ArrowLeft />
</Button>
<div className="flex items-center gap-2 min-w-0">
<Flag flag={node.region}></Flag>
<span className="text-xl md:text-2xl font-bold">{node.name}</span>
</div>
<span className="text-sm text-secondary-foreground flex-shrink-0">
{node.status === "online" ? "在线" : "离线"}
</span>
</div>
</div>
<Instance node={node as NodeWithStatus} />
<div className="flex justify-center space-x-2">
<Button
variant={chartType === "load" ? "secondary" : "ghost"}
onClick={() => setChartType("load")}>
</Button>
<Button
variant={chartType === "ping" ? "secondary" : "ghost"}
onClick={() => setChartType("ping")}>
</Button>
</div>
{chartType === "load" ? (
<div className="flex justify-center space-x-2">
{loadTimeRanges.map((range) => (
<Button
key={range.label}
variant={loadHours === range.hours ? "secondary" : "ghost"}
size="sm"
onClick={() => setLoadHours(range.hours)}>
{range.label}
</Button>
))}
</div>
) : (
<div className="flex justify-center space-x-2">
{pingTimeRanges.map((range) => (
<Button
key={range.label}
variant={pingHours === range.hours ? "secondary" : "ghost"}
size="sm"
onClick={() => setPingHours(range.hours)}>
{range.label}
</Button>
))}
</div>
)}
<Suspense
fallback={
<div className="flex items-center justify-center h-96">
<Loading text="正在加载图表..." />
</div>
}>
{chartType === "load" && staticNode ? (
<LoadCharts
node={staticNode}
hours={loadHours}
data={realtimeChartData}
liveData={liveData?.data[staticNode.uuid]}
/>
) : chartType === "ping" && staticNode ? (
<PingChart node={staticNode} hours={pingHours} />
) : null}
</Suspense>
</div>
);
};
export default InstancePage;

210
src/services/api.ts Normal file
View File

@@ -0,0 +1,210 @@
// API 服务 - 用于与 Komari 后端通信
import type {
NodeData,
NodeStats,
ApiResponse,
PublicInfo,
HistoryRecord,
PingHistoryResponse,
} from "@/types/node";
class ApiService {
private baseUrl: string;
constructor() {
// 使用相对路径,这样在部署时会自动适配
this.baseUrl = "";
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${this.baseUrl}${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("API request failed:", error);
return {
status: "error",
message: error instanceof Error ? error.message : "Unknown error",
data: null as any,
};
}
}
// 获取所有节点信息
async getNodes(): Promise<NodeData[]> {
const response = await this.get<NodeData[]>("/api/nodes");
return response.status === "success" ? response.data : [];
}
// 获取指定节点的最近状态
async getNodeRecentStats(uuid: string): Promise<NodeStats[]> {
const response = await this.get<NodeStats[]>(`/api/recent/${uuid}`);
return response.status === "success" ? response.data : [];
}
// 获取负载历史记录
async getLoadHistory(
uuid: string,
hours: number = 24
): Promise<{ count: number; records: HistoryRecord[] } | null> {
const response = await this.get<{
count: number;
records: HistoryRecord[];
}>(`/api/records/load?uuid=${uuid}&hours=${hours}`);
return response.status === "success" ? response.data : null;
}
// 获取 Ping 历史记录
async getPingHistory(
uuid: string,
hours: number = 24
): Promise<PingHistoryResponse | null> {
const response = await this.get<PingHistoryResponse>(
`/api/records/ping?uuid=${uuid}&hours=${hours}`
);
return response.status === "success" ? response.data : null;
}
// 获取公开设置
async getPublicSettings(): Promise<PublicInfo | null> {
const response = await this.get<PublicInfo>("/api/public");
return response.status === "success" ? response.data : null;
}
// 获取版本信息
async getVersion(): Promise<{ version: string; hash: string }> {
const response = await this.get<{ version: string; hash: string }>(
"/api/version"
);
return response.status === "success"
? response.data
: { version: "unknown", hash: "unknown" };
}
// 获取用户信息
async getUserInfo(): Promise<any> {
const response = await this.get<any>("/api/me");
return response.status === "success" ? response.data : null;
}
}
// 创建 API 服务实例
export const apiService = new ApiService();
// WebSocket 连接管理
export class WebSocketService {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectInterval = 5000;
private listeners: Set<(data: any) => void> = new Set();
private url: string;
private statusInterval: ReturnType<typeof setInterval> | null = null;
constructor(url: string = "") {
this.url = url;
}
connect() {
// 如果已有连接,则不重复连接
if (this.ws && this.ws.readyState < 2) {
return;
}
try {
this.ws = new WebSocket(
this.url ||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${
window.location.host
}/api/clients`
);
this.ws.onopen = () => {
console.log("WebSocket connected");
this.reconnectAttempts = 0;
// 发送获取数据请求
this.send("get");
// 启动定时状态更新
this.startStatusUpdates();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.status === "success" && data.data) {
// 直接将收到的数据传递给监听器
this.listeners.forEach((listener) => listener(data.data));
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
this.ws.onclose = () => {
console.log("WebSocket disconnected");
this.stopStatusUpdates();
this.reconnect();
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
} catch (error) {
console.error("Failed to connect WebSocket:", error);
this.reconnect();
}
}
private reconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(
`Attempting to reconnect... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`
);
setTimeout(() => this.connect(), this.reconnectInterval);
} else {
console.error("Max reconnection attempts reached");
}
}
send(data: string) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
}
subscribe(listener: (data: any) => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
this.stopStatusUpdates();
}
}
private startStatusUpdates() {
if (this.statusInterval) {
clearInterval(this.statusInterval);
}
this.statusInterval = setInterval(() => {
this.send("get");
}, 2000);
}
private stopStatusUpdates() {
if (this.statusInterval) {
clearInterval(this.statusInterval);
this.statusInterval = null;
}
}
}
// 创建 WebSocket 服务实例
export const wsService = new WebSocketService();

43
src/types/LiveData.ts Normal file
View File

@@ -0,0 +1,43 @@
export type LiveData = {
online: string[];
data: { [key: string]: Record };
};
export type Record = {
cpu: {
usage: number;
};
ram: {
used: number;
};
swap: {
used: number;
};
load: {
load1: number;
load5: number;
load15: number;
};
disk: {
used: number;
};
network: {
up: number;
down: number;
totalUp: number;
totalDown: number;
};
connections: {
tcp: number;
udp: number;
};
uptime: number;
process: number;
message: string;
updated_at: string;
};
export type LiveDataResponse = {
data: LiveData;
status: string;
};

101
src/types/node.d.ts vendored Normal file
View File

@@ -0,0 +1,101 @@
export interface NodeData {
uuid: string;
name: string;
cpu_name: string;
virtualization: string;
arch: string;
cpu_cores: number;
os: string;
gpu_name: string;
region: string;
mem_total: number;
swap_total: number;
disk_total: number;
weight: number;
price: number;
billing_cycle: number;
currency: string;
expired_at: string | null;
group: string;
tags: string;
created_at: string;
updated_at: string;
}
export interface NodeStats {
cpu: { usage: number };
ram: { total: number; used: number };
swap: { total: number; used: number };
disk: { total: number; used: number };
network: { up: number; down: number; totalUp: number; totalDown: number };
load: { load1: number; load5: number; load15: number };
uptime: number;
process: number;
connections: { tcp: number; udp: number };
message: string;
updated_at: string;
}
export interface NodeWithStatus extends NodeData {
status: "online" | "offline";
stats?: NodeStats;
}
export interface ApiResponse<T> {
status: "success" | "error";
message: string;
data: T;
}
export interface PublicInfo {
allow_cors: boolean;
custom_body: string;
custom_head: string;
description: string;
disable_password_login: boolean;
oauth_enable: boolean;
ping_record_preserve_time: number;
record_enabled: boolean;
record_preserve_time: number;
sitename: string;
}
export interface HistoryRecord {
client: string;
time: string;
cpu: number;
gpu: number;
ram: number;
ram_total: number;
swap: number;
swap_total: number;
load: number;
temp: number;
disk: number;
disk_total: number;
net_in: number;
net_out: number;
net_total_up: number;
net_total_down: number;
process: number;
connections: number;
connections_udp: number;
}
export interface PingHistoryRecord {
task_id: number;
time: string;
value: number;
}
export interface PingTask {
id: number;
interval: number;
name: string;
}
export interface PingHistoryResponse {
count: number;
records: PingHistoryRecord[];
tasks: PingTask[];
}

357
src/utils/RecordHelper.tsx Normal file
View File

@@ -0,0 +1,357 @@
import type { Record } from "@/types/LiveData";
export interface RecordFormat {
client: string;
time: string;
cpu: number | null;
gpu: number | null;
ram: number | null;
ram_total: number | null;
swap: number | null;
swap_total: number | null;
load: number | null;
temp: number | null;
disk: number | null;
disk_total: number | null;
net_in: number | null;
net_out: number | null;
net_total_up: number | null;
net_total_down: number | null;
process: number | null;
connections: number | null;
connections_udp: number | null;
}
export function liveDataToRecords(
client: string,
liveData: Record[]
): RecordFormat[] {
if (!liveData) return [];
return liveData.map((data) => ({
client: client,
time: data.updated_at || "",
cpu: data.cpu.usage ?? 0,
gpu: 0,
ram: data.ram.used ?? 0,
ram_total: 0,
swap: data.swap.used ?? 0,
swap_total: 0,
load: data.load.load1 ?? 0,
temp: 0,
disk: data.disk.used ?? 0,
disk_total: 0,
net_in: data.network?.down ?? 0,
net_out: data.network?.up ?? 0,
net_total_up: data.network?.totalUp ?? 0,
net_total_down: data.network?.totalDown ?? 0,
process: data.process ?? 0,
connections: data.connections.tcp ?? 0,
connections_udp: data.connections.udp ?? 0,
}));
}
/**
* Creates a template object by recursively setting all numeric properties to null.
* This is used to create placeholder items for missing time points.
* @param obj The object to use as a template.
* @returns A new object with the same structure, but with null for all numeric values.
*/
function createNullTemplate(obj: any): any {
if (obj === null || obj === undefined) return null;
if (typeof obj === "number") return null;
if (typeof obj === "string" || typeof obj === "boolean") return obj;
if (Array.isArray(obj)) return obj.map(createNullTemplate);
if (typeof obj === "object") {
const res: any = {};
for (const k in obj) {
if (k === "updated_at" || k === "time") continue;
res[k] = createNullTemplate(obj[k]);
}
return res;
}
return null;
}
/**
* Fills in missing time points in a dataset. Operates in two modes:
* 1. Fixed-Length (default): Generates a dataset of a specific duration (`totalSeconds`) ending at the last data point.
* 2. Variable-Length: If `totalSeconds` is set to `null`, it fills gaps between the first and last data points without enforcing a total duration.
*
* @param data The input data array, should have `time` or `updated_at` properties.
* @param intervalSec The interval in seconds between each time point.
* @param totalSeconds The total duration of the data to display in seconds. Set to `null` to fill from the first to the last data point instead.
* @param matchToleranceSec The tolerance in seconds for matching a data point to a time point. Defaults to `intervalSec`.
* @returns A new array with missing time points filled with null values.
*/
export default function fillMissingTimePoints<
T extends { time?: string; updated_at?: string }
>(
data: T[],
intervalSec: number = 10,
totalSeconds: number | null = 180,
matchToleranceSec?: number
): T[] {
if (!data.length) return [];
const getTime = (item: T) =>
new Date(item.time ?? item.updated_at ?? "").getTime();
// Performance: Pre-calculate timestamps to avoid redundant parsing during sort and search.
const timedData = data.map((item) => ({ item, timeMs: getTime(item) }));
timedData.sort((a, b) => a.timeMs - b.timeMs);
const firstItem = timedData[0];
const lastItem = timedData[timedData.length - 1];
const end = lastItem.timeMs;
const interval = intervalSec * 1000;
// NEW: Determine the start time based on whether totalSeconds is set for a fixed length.
const start =
totalSeconds !== null && totalSeconds > 0
? end - totalSeconds * 1000 + interval // Fixed-length mode
: firstItem.timeMs; // Variable-length mode: start from the first data point
// Generate the ideal time points for the chart's x-axis.
const timePoints: number[] = [];
for (let t = start; t <= end; t += interval) {
timePoints.push(t);
}
// Create a template with null values for missing data points.
const nullTemplate = createNullTemplate(lastItem.item);
let dataIdx = 0;
const matchToleranceMs = (matchToleranceSec ?? intervalSec) * 1000;
const filled: T[] = timePoints.map((t) => {
let found: T | undefined = undefined;
// Advance the data pointer past points that are too old for the current time point.
while (
dataIdx < timedData.length &&
timedData[dataIdx].timeMs < t - matchToleranceMs
) {
dataIdx++;
}
// Check if the current data point is within the tolerance window of the ideal time point.
if (
dataIdx < timedData.length &&
Math.abs(timedData[dataIdx].timeMs - t) <= matchToleranceMs
) {
found = timedData[dataIdx].item;
}
if (found) {
// If a point is found, use it, but align its time to the grid.
return { ...found, time: new Date(t).toISOString() };
}
// If no point is found, insert the null template.
return { ...nullTemplate, time: new Date(t).toISOString() } as T;
});
return filled;
}
/**
* EWMA指数加权移动平均
* 使用指数加权移动平均算法平滑数据,同时检测并过滤突变值,填充 null/undefined 值
*
* @param data 输入数据数组,每个元素应该包含数值型属性
* @param keys 需要处理的数值属性名数组
* @param alpha 平滑因子
* @param windowSize 突变检测窗口大小
* @param spikeThreshold 突变阈值
* @returns 处理后的数据数组
*/
export function cutPeakValues<T extends { [key: string]: any }>(
data: T[],
keys: string[],
alpha: number = 0.1,
windowSize: number = 15,
spikeThreshold: number = 0.3
): T[] {
if (!data || data.length === 0) return data;
const result = [...data];
const halfWindow = Math.floor(windowSize / 2);
// 为每个需要处理的键执行突变检测和EWMA平滑
for (const key of keys) {
// 第一步:检测并移除突变值
for (let i = 0; i < result.length; i++) {
const currentValue = result[i][key];
// 如果当前值是有效数值,检查是否为突变
if (currentValue != null && typeof currentValue === "number") {
const neighborValues: number[] = [];
// 收集窗口范围内的邻近有效值
for (
let j = Math.max(0, i - halfWindow);
j <= Math.min(result.length - 1, i + halfWindow);
j++
) {
if (j === i) continue; // 跳过当前值
const neighbor = result[j][key];
if (neighbor != null && typeof neighbor === "number") {
neighborValues.push(neighbor);
}
}
// 如果有足够的邻近值进行突变检测
if (neighborValues.length >= 2) {
const neighborSum = neighborValues.reduce((sum, val) => sum + val, 0);
const neighborMean =
neighborValues.length > 0 ? neighborSum / neighborValues.length : 0;
// 检测突变:如果当前值与邻近值平均值的相对差异超过阈值
if (neighborMean > 0) {
const relativeChange =
Math.abs(currentValue - neighborMean) / neighborMean;
if (relativeChange > spikeThreshold) {
// 标记为突变设置为null稍后用EWMA填充
result[i] = { ...result[i], [key]: null };
}
} else if (Math.abs(currentValue) > 10) {
// 如果邻近值平均值接近0但当前值很大也视为突变
result[i] = { ...result[i], [key]: null };
}
}
}
}
// 第二步使用EWMA平滑和填充
let ewma: number | null = null;
for (let i = 0; i < result.length; i++) {
const currentValue = result[i][key];
// 如果当前值是有效数值
if (currentValue != null && typeof currentValue === "number") {
if (ewma === null) {
// 第一个有效值作为初始EWMA值
ewma = currentValue;
} else {
// EWMA = α * 当前值 + (1-α) * 前一个EWMA值
ewma = alpha * currentValue + (1 - alpha) * ewma;
}
result[i] = { ...result[i], [key]: ewma };
} else if (ewma !== null) {
// 如果当前值无效但已有EWMA值用EWMA值填充
result[i] = { ...result[i], [key]: ewma };
}
// 如果当前值无效且还没有EWMA值保持原值null/undefined
}
}
return result;
}
/**
* 计算丢包率
* 根据图表数据计算丢包率null或undefined的数据视为丢包
*
* @param chartData 图表数据数组包含填充的null值
* @param taskId 任务ID
* @returns 丢包率百分比保留1位小数
*/
export function calculateLossRate(chartData: any[], taskId: number): number {
if (!chartData || chartData.length === 0) return 0;
const totalCount = chartData.length;
const lostCount = chartData.filter(
(dataPoint) => dataPoint[taskId] === null || dataPoint[taskId] === undefined
).length;
const lossRate = (lostCount / totalCount) * 100;
return Math.round(lossRate * 10) / 10; // 保留1位小数
}
/**
* 根据保留时间对数据进行采样
* 避免渲染过多的数据点
*
* @param data 原始数据数组
* @param retentionHours 数据保留时间(小时)
* @param isMiniChart 是否是迷你图表(采样更激进)
* @returns 采样后的数据数组
*/
export function sampleDataByRetention(
data: any[],
retentionHours: number,
isMiniChart: boolean = false
): any[] {
if (!data || data.length === 0) return [];
let sampleInterval: number;
// 根据保留时间确定采样间隔(分钟)
if (isMiniChart) {
// MiniChart 使用更激进的采样,减少点数
if (retentionHours <= 72) {
sampleInterval = 5; // 最多5分钟一个点
} else if (retentionHours <= 168) {
sampleInterval = 30; // 最多30分钟一个点
} else if (retentionHours <= 720) {
sampleInterval = 60; // 最多60分钟一个点
} else if (retentionHours <= 2160) {
sampleInterval = 120; // 最多120分钟一个点
} else {
sampleInterval = 180; // 最多180分钟一个点
}
} else {
// 主图表的采样间隔
if (retentionHours <= 72) {
sampleInterval = 1; // 最多1分钟一个点
} else if (retentionHours <= 168) {
sampleInterval = 15; // 最多15分钟一个点
} else if (retentionHours <= 720) {
sampleInterval = 30; // 最多30分钟一个点
} else if (retentionHours <= 2160) {
sampleInterval = 60; // 最多60分钟一个点
} else {
sampleInterval = 90; // 最多90分钟一个点
}
}
// 如果数据点间隔已经大于采样间隔,直接返回原数据
if (data.length <= 2) return data;
const result: any[] = [];
const sampleIntervalMs = sampleInterval * 60 * 1000; // 转换为毫秒
// 始终保留第一个数据点
result.push(data[0]);
let lastSampledTime = new Date(data[0].time).getTime();
// 采样中间的数据点
for (let i = 1; i < data.length - 1; i++) {
const currentTime = new Date(data[i].time).getTime();
// 如果距离上一个采样点的时间间隔大于等于采样间隔,则保留该点
if (currentTime - lastSampledTime >= sampleIntervalMs) {
result.push(data[i]);
lastSampledTime = currentTime;
}
}
// 始终保留最后一个数据点
if (data.length > 1) {
const lastPoint = data[data.length - 1];
const lastTime = new Date(lastPoint.time).getTime();
// 如果最后一个点距离上一个采样点太近,替换上一个采样点
if (
result.length > 1 &&
lastTime - lastSampledTime < sampleIntervalMs / 2
) {
result[result.length - 1] = lastPoint;
} else {
result.push(lastPoint);
}
}
return result;
}

78
src/utils/formatHelper.ts Normal file
View File

@@ -0,0 +1,78 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Helper function to format bytes
export const formatBytes = (bytes: number, isSpeed = false, decimals = 2) => {
if (bytes === 0) return isSpeed ? "0 B/s" : "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = isSpeed
? ["B/s", "KB/s", "MB/s", "GB/s", "TB/s"]
: ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
};
// Helper function to format uptime
export const formatUptime = (seconds: number) => {
if (isNaN(seconds) || seconds < 0) {
return "N/A";
}
const days = Math.floor(seconds / (3600 * 24));
seconds -= days * 3600 * 24;
const hrs = Math.floor(seconds / 3600);
seconds -= hrs * 3600;
const mns = Math.floor(seconds / 60);
let uptimeString = "";
if (days > 0) {
uptimeString += `${days}`;
}
if (hrs > 0) {
uptimeString += `${hrs}小时`;
}
if (mns > 0 && days === 0) {
// Only show minutes if uptime is less than a day
uptimeString += `${mns}分钟`;
}
if (uptimeString === "") {
return "刚刚";
}
return uptimeString;
};
export const formatPrice = (
price: number,
currency: string,
billingCycle: number
) => {
if (price === -1) return "免费";
if (price === 0) return "未设置";
if (!currency || !billingCycle) return "N/A";
let cycleStr = `${billingCycle}`;
if (billingCycle < 0) {
return `${currency}${price.toFixed(2)}`;
} else if (billingCycle === 30 || billingCycle === 31) {
cycleStr = "月";
} else if (billingCycle >= 89 && billingCycle <= 92) {
cycleStr = "季";
} else if (billingCycle >= 180 && billingCycle <= 183) {
cycleStr = "半年";
} else if (billingCycle >= 364 && billingCycle <= 366) {
cycleStr = "年";
} else if (billingCycle >= 730 && billingCycle <= 732) {
cycleStr = "两年";
} else if (billingCycle >= 1095 && billingCycle <= 1097) {
cycleStr = "三年";
} else if (billingCycle >= 1825 && billingCycle <= 1827) {
cycleStr = "五年";
}
return `${currency}${price.toFixed(2)}/${cycleStr}`;
};

4
src/utils/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from "./formatHelper";
export * from "./regionHelper";
export * from "./osImageHelper";
export * from "./RecordHelper";

217
src/utils/osImageHelper.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* OS Image Helper - 根据字符串匹配返回操作系统图像路径
*/
// 操作系统匹配配置
interface OSConfig {
name: string;
image: string;
keywords: string[];
}
// 操作系统匹配组
const osConfigs: OSConfig[] = [
{
name: "AlmaLinux",
image: "/assets/os-alma.svg",
keywords: ["alma", "almalinux"],
},
{
name: "Alpine Linux",
image: "/assets/os-alpine.webp",
keywords: ["alpine", "alpine linux"],
},
{
name: "CentOS",
image: "/assets/os-centos.svg",
keywords: ["centos", "cent os"],
},
{
name: "Debian",
image: "/assets/os-debian.svg",
keywords: ["debian", "deb"],
},
{
name: "Ubuntu",
image: "/assets/os-ubuntu.svg",
keywords: ["ubuntu", "elementary"],
},
{
name: "Windows",
image: "/assets/os-windows.svg",
keywords: ["windows", "win", "microsoft", "ms"],
},
{
name: "Arch Linux",
image: "/assets/os-arch.svg",
keywords: ["arch", "archlinux", "arch linux"],
},
{
name: "Kali Linux",
image: "/assets/os-kail.svg",
keywords: ["kail", "kali", "kali linux"],
},
{
name: "iStoreOS",
image: "/assets/os-istore.png",
keywords: ["istore", "istoreos", "istore os"],
},
{
name: "OpenWrt",
image: "/assets/os-openwrt.svg",
keywords: ["openwrt", "open wrt", "open-wrt", "qwrt"],
},
{
name: "ImmortalWrt",
image: "/assets/os-openwrt.svg",
keywords: ["immortalwrt", "immortal", "emmortal"],
},
{
name: "NixOS",
image: "/assets/os-nix.svg",
keywords: ["nixos", "nix os", "nix"],
},
{
name: "Rocky Linux",
image: "/assets/os-rocky.svg",
keywords: ["rocky", "rocky linux"],
},
{
name: "Fedora",
image: "/assets/os-fedora.svg",
keywords: ["fedora"],
},
{
name: "openSUSE",
image: "/assets/os-openSUSE.svg",
keywords: ["opensuse", "suse"],
},
{
name: "Gentoo",
image: "/assets/os-gentoo.svg",
keywords: ["gentoo"],
},
{
name: "Red Hat",
image: "/assets/os-redhat.svg",
keywords: ["redhat", "rhel", "red hat"],
},
{
name: "Linux Mint",
image: "/assets/os-mint.svg",
keywords: ["mint", "linux mint"],
},
{
name: "Manjaro",
image: "/assets/os-manjaro-.svg",
keywords: ["manjaro"],
},
{
name: "Synology DSM",
image: "/assets/os-synology.ico",
keywords: ["synology", "dsm", "synology dsm"],
},
{
name: "Proxmox VE",
image: "/assets/os-proxmox.ico",
keywords: ["proxmox", "proxmox ve"],
},
{
name: "macOS",
image: "/assets/os-macos.svg",
keywords: ["macos"],
},
];
// 默认配置
const defaultOSConfig: OSConfig = {
name: "Unknown",
image: "/assets/TablerHelp.svg",
keywords: ["unknown"],
};
/**
* 根据输入字符串查找匹配的操作系统配置
* @param osString - 操作系统相关的字符串
* @returns 匹配的操作系统配置,如果没有匹配则返回默认配置
*/
function findOSConfig(osString: string): OSConfig {
if (!osString) {
return defaultOSConfig;
}
const normalizedInput = osString.toLowerCase().trim();
// 遍历匹配配置
for (const config of osConfigs) {
for (const keyword of config.keywords) {
if (normalizedInput.includes(keyword)) {
return config;
}
}
}
// 如果没有匹配到,返回默认配置
return defaultOSConfig;
}
/**
* 根据输入字符串匹配返回操作系统图像路径
* @param osString - 操作系统相关的字符串
* @returns 匹配的操作系统图像路径,如果没有匹配则返回默认图像
*/
export function getOSImage(osString: string): string {
return findOSConfig(osString).image;
}
/**
* 获取所有可用的操作系统图像
* @returns 所有操作系统图像的映射表
*/
export function getAllOSImages(): Record<string, string> {
const imageMap: Record<string, string> = {};
osConfigs.forEach((config) => {
const key = config.keywords[0]; // 使用第一个关键词作为键
imageMap[key] = config.image;
});
imageMap.unknown = defaultOSConfig.image;
return imageMap;
}
/**
* 根据输入字符串匹配返回操作系统名称
* @param osString - 操作系统相关的字符串
* @returns 匹配的操作系统名称
*/
export function getOSName(osString: string): string {
const config = findOSConfig(osString);
// 如果匹配到具体的操作系统,返回其名称
if (config !== defaultOSConfig) {
return config.name;
}
// 如果没有匹配到,从输入字符串中提取名称
if (!osString) {
return "Unknown";
}
// 使用空格或斜杠分割,取第一个部分
const parts = osString.trim().split(/[\s/]/);
return parts[0] || "Unknown";
}
/**
* 检查是否为支持的操作系统
* @param osString - 操作系统相关的字符串
* @returns 是否为支持的操作系统
*/
export function isSupportedOS(osString: string): boolean {
if (!osString) return false;
const config = findOSConfig(osString);
return config !== defaultOSConfig;
}

645
src/utils/regionHelper.ts Normal file
View File

@@ -0,0 +1,645 @@
// 地区emoji到名称的映射
export const emojiToRegionMap: Record<
string,
{ en: string; zh: string; aliases: string[] }
> = {
"🇭🇰": {
en: "Hong Kong",
zh: "香港",
aliases: ["hk", "hongkong", "hong kong", "香港", "HK"],
},
"🇨🇳": {
en: "China",
zh: "中国",
aliases: ["cn", "china", "中国", "中华人民共和国", "prc", "CN"],
},
"🇺🇸": {
en: "United States",
zh: "美国",
aliases: [
"us",
"usa",
"united states",
"america",
"美国",
"美利坚",
"US",
"USA",
],
},
"🇯🇵": {
en: "Japan",
zh: "日本",
aliases: ["jp", "japan", "日本", "JP"],
},
"🇰🇷": {
en: "South Korea",
zh: "韩国",
aliases: ["kr", "korea", "south korea", "韩国", "南韩", "KR"],
},
"🇸🇬": {
en: "Singapore",
zh: "新加坡",
aliases: ["sg", "singapore", "新加坡", "SG"],
},
"🇹🇼": {
en: "Taiwan",
zh: "台湾",
aliases: ["tw", "taiwan", "台湾", "台灣", "TW"],
},
"🇬🇧": {
en: "United Kingdom",
zh: "英国",
aliases: [
"gb",
"uk",
"united kingdom",
"britain",
"英国",
"英國",
"GB",
"UK",
],
},
"🇩🇪": {
en: "Germany",
zh: "德国",
aliases: ["de", "germany", "deutschland", "德国", "德國", "DE"],
},
"🇫🇷": {
en: "France",
zh: "法国",
aliases: ["fr", "france", "法国", "法國", "FR"],
},
"🇨🇦": {
en: "Canada",
zh: "加拿大",
aliases: ["ca", "canada", "加拿大", "CA"],
},
"🇦🇺": {
en: "Australia",
zh: "澳大利亚",
aliases: ["au", "australia", "澳大利亚", "澳洲", "AU"],
},
"🇷🇺": {
en: "Russia",
zh: "俄罗斯",
aliases: ["ru", "russia", "俄罗斯", "俄國", "RU"],
},
"🇮🇳": {
en: "India",
zh: "印度",
aliases: ["in", "india", "印度", "IN"],
},
"🇧🇷": {
en: "Brazil",
zh: "巴西",
aliases: ["br", "brazil", "巴西", "BR"],
},
"🇳🇱": {
en: "Netherlands",
zh: "荷兰",
aliases: ["nl", "netherlands", "holland", "荷兰", "荷蘭", "NL"],
},
"🇮🇹": {
en: "Italy",
zh: "意大利",
aliases: ["it", "italy", "意大利", "IT"],
},
"🇪🇸": {
en: "Spain",
zh: "西班牙",
aliases: ["es", "spain", "西班牙", "ES"],
},
"🇸🇪": {
en: "Sweden",
zh: "瑞典",
aliases: ["se", "sweden", "瑞典", "SE"],
},
"🇳🇴": {
en: "Norway",
zh: "挪威",
aliases: ["no", "norway", "挪威", "NO"],
},
"🇫🇮": {
en: "Finland",
zh: "芬兰",
aliases: ["fi", "finland", "芬兰", "芬蘭", "FI"],
},
"🇨🇭": {
en: "Switzerland",
zh: "瑞士",
aliases: ["ch", "switzerland", "瑞士", "CH"],
},
"🇦🇹": {
en: "Austria",
zh: "奥地利",
aliases: ["at", "austria", "奥地利", "奧地利", "AT"],
},
"🇧🇪": {
en: "Belgium",
zh: "比利时",
aliases: ["be", "belgium", "比利时", "比利時", "BE"],
},
"🇵🇹": {
en: "Portugal",
zh: "葡萄牙",
aliases: ["pt", "portugal", "葡萄牙", "PT"],
},
"🇬🇷": {
en: "Greece",
zh: "希腊",
aliases: ["gr", "greece", "希腊", "希臘", "GR"],
},
"🇹🇷": {
en: "Turkey",
zh: "土耳其",
aliases: ["tr", "turkey", "土耳其", "TR"],
},
"🇵🇱": {
en: "Poland",
zh: "波兰",
aliases: ["pl", "poland", "波兰", "波蘭", "PL"],
},
"🇨🇿": {
en: "Czech Republic",
zh: "捷克",
aliases: ["cz", "czech", "czech republic", "捷克", "CZ"],
},
"🇭🇺": {
en: "Hungary",
zh: "匈牙利",
aliases: ["hu", "hungary", "匈牙利", "HU"],
},
"🇷🇴": {
en: "Romania",
zh: "罗马尼亚",
aliases: ["ro", "romania", "罗马尼亚", "羅馬尼亞", "RO"],
},
"🇧🇬": {
en: "Bulgaria",
zh: "保加利亚",
aliases: ["bg", "bulgaria", "保加利亚", "保加利亞", "BG"],
},
"🇭🇷": {
en: "Croatia",
zh: "克罗地亚",
aliases: ["hr", "croatia", "克罗地亚", "克羅地亞", "HR"],
},
"🇸🇮": {
en: "Slovenia",
zh: "斯洛文尼亚",
aliases: ["si", "slovenia", "斯洛文尼亚", "斯洛文尼亞", "SI"],
},
"🇸🇰": {
en: "Slovakia",
zh: "斯洛伐克",
aliases: ["sk", "slovakia", "斯洛伐克", "SK"],
},
"🇱🇻": {
en: "Latvia",
zh: "拉脱维亚",
aliases: ["lv", "latvia", "拉脱维亚", "拉脫維亞", "LV"],
},
"🇱🇹": {
en: "Lithuania",
zh: "立陶宛",
aliases: ["lt", "lithuania", "立陶宛", "LT"],
},
"🇪🇪": {
en: "Estonia",
zh: "爱沙尼亚",
aliases: ["ee", "estonia", "爱沙尼亚", "愛沙尼亞", "EE"],
},
"🇲🇽": {
en: "Mexico",
zh: "墨西哥",
aliases: ["mx", "mexico", "墨西哥", "MX"],
},
"🇦🇷": {
en: "Argentina",
zh: "阿根廷",
aliases: ["ar", "argentina", "阿根廷", "AR"],
},
"🇨🇱": {
en: "Chile",
zh: "智利",
aliases: ["cl", "chile", "智利", "CL"],
},
"🇨🇴": {
en: "Colombia",
zh: "哥伦比亚",
aliases: ["co", "colombia", "哥伦比亚", "哥倫比亞", "CO"],
},
"🇵🇪": {
en: "Peru",
zh: "秘鲁",
aliases: ["pe", "peru", "秘鲁", "秘魯", "PE"],
},
"🇻🇪": {
en: "Venezuela",
zh: "委内瑞拉",
aliases: ["ve", "venezuela", "委内瑞拉", "委內瑞拉", "VE"],
},
"🇺🇾": {
en: "Uruguay",
zh: "乌拉圭",
aliases: ["uy", "uruguay", "乌拉圭", "烏拉圭", "UY"],
},
"🇪🇨": {
en: "Ecuador",
zh: "厄瓜多尔",
aliases: ["ec", "ecuador", "厄瓜多尔", "厄瓜多爾", "EC"],
},
"🇧🇴": {
en: "Bolivia",
zh: "玻利维亚",
aliases: ["bo", "bolivia", "玻利维亚", "玻利維亞", "BO"],
},
"🇵🇾": {
en: "Paraguay",
zh: "巴拉圭",
aliases: ["py", "paraguay", "巴拉圭", "PY"],
},
"🇬🇾": {
en: "Guyana",
zh: "圭亚那",
aliases: ["gy", "guyana", "圭亚那", "圭亞那", "GY"],
},
"🇸🇷": {
en: "Suriname",
zh: "苏里南",
aliases: ["sr", "suriname", "苏里南", "蘇里南", "SR"],
},
"🇫🇰": {
en: "Falkland Islands",
zh: "福克兰群岛",
aliases: ["fk", "falkland", "福克兰", "福克蘭", "FK"],
},
"🇬🇫": {
en: "French Guiana",
zh: "法属圭亚那",
aliases: ["gf", "french guiana", "法属圭亚那", "法屬圭亞那", "GF"],
},
"🇵🇦": {
en: "Panama",
zh: "巴拿马",
aliases: ["pa", "panama", "巴拿马", "巴拿馬", "PA"],
},
"🇨🇷": {
en: "Costa Rica",
zh: "哥斯达黎加",
aliases: ["cr", "costa rica", "哥斯达黎加", "哥斯達黎加", "CR"],
},
"🇳🇮": {
en: "Nicaragua",
zh: "尼加拉瓜",
aliases: ["ni", "nicaragua", "尼加拉瓜", "NI"],
},
"🇭🇳": {
en: "Honduras",
zh: "洪都拉斯",
aliases: ["hn", "honduras", "洪都拉斯", "HN"],
},
"🇬🇹": {
en: "Guatemala",
zh: "危地马拉",
aliases: ["gt", "guatemala", "危地马拉", "危地馬拉", "GT"],
},
"🇧🇿": {
en: "Belize",
zh: "伯利兹",
aliases: ["bz", "belize", "伯利兹", "伯利茲", "BZ"],
},
"🇸🇻": {
en: "El Salvador",
zh: "萨尔瓦多",
aliases: ["sv", "el salvador", "萨尔瓦多", "薩爾瓦多", "SV"],
},
"🇯🇲": {
en: "Jamaica",
zh: "牙买加",
aliases: ["jm", "jamaica", "牙买加", "牙買加", "JM"],
},
"🇨🇺": {
en: "Cuba",
zh: "古巴",
aliases: ["cu", "cuba", "古巴", "CU"],
},
"🇩🇴": {
en: "Dominican Republic",
zh: "多明尼加",
aliases: ["do", "dominican", "多明尼加", "DO"],
},
"🇭🇹": {
en: "Haiti",
zh: "海地",
aliases: ["ht", "haiti", "海地", "HT"],
},
"🇧🇸": {
en: "Bahamas",
zh: "巴哈马",
aliases: ["bs", "bahamas", "巴哈马", "巴哈馬", "BS"],
},
"🇧🇧": {
en: "Barbados",
zh: "巴巴多斯",
aliases: ["bb", "barbados", "巴巴多斯", "BB"],
},
"🇹🇹": {
en: "Trinidad and Tobago",
zh: "特立尼达和多巴哥",
aliases: ["tt", "trinidad", "特立尼达", "特立尼達", "TT"],
},
"🇵🇭": {
en: "Philippines",
zh: "菲律宾",
aliases: ["ph", "philippines", "菲律宾", "菲律賓", "PH"],
},
"🇹🇭": {
en: "Thailand",
zh: "泰国",
aliases: ["th", "thailand", "泰国", "泰國", "TH"],
},
"🇻🇳": {
en: "Vietnam",
zh: "越南",
aliases: ["vn", "vietnam", "越南", "VN"],
},
"🇲🇾": {
en: "Malaysia",
zh: "马来西亚",
aliases: ["my", "malaysia", "马来西亚", "馬來西亞", "MY"],
},
"🇮🇩": {
en: "Indonesia",
zh: "印度尼西亚",
aliases: ["id", "indonesia", "印度尼西亚", "印尼", "ID"],
},
"🇱🇦": {
en: "Laos",
zh: "老挝",
aliases: ["la", "laos", "老挝", "老撾", "LA"],
},
"🇰🇭": {
en: "Cambodia",
zh: "柬埔寨",
aliases: ["kh", "cambodia", "柬埔寨", "KH"],
},
"🇲🇲": {
en: "Myanmar",
zh: "缅甸",
aliases: ["mm", "myanmar", "burma", "缅甸", "緬甸", "MM"],
},
"🇧🇳": {
en: "Brunei",
zh: "文莱",
aliases: ["bn", "brunei", "文莱", "汶萊", "BN"],
},
"🇪🇬": {
en: "Egypt",
zh: "埃及",
aliases: ["eg", "egypt", "埃及", "EG"],
},
"🇿🇦": {
en: "South Africa",
zh: "南非",
aliases: ["za", "south africa", "南非", "ZA"],
},
"🇳🇬": {
en: "Nigeria",
zh: "尼日利亚",
aliases: ["ng", "nigeria", "尼日利亚", "尼日利亞", "NG"],
},
"🇰🇪": {
en: "Kenya",
zh: "肯尼亚",
aliases: ["ke", "kenya", "肯尼亚", "肯亞", "KE"],
},
"🇪🇹": {
en: "Ethiopia",
zh: "埃塞俄比亚",
aliases: ["et", "ethiopia", "埃塞俄比亚", "埃塞俄比亞", "ET"],
},
"🇬🇭": {
en: "Ghana",
zh: "加纳",
aliases: ["gh", "ghana", "加纳", "迦納", "GH"],
},
"🇺🇬": {
en: "Uganda",
zh: "乌干达",
aliases: ["ug", "uganda", "乌干达", "烏干達", "UG"],
},
"🇹🇿": {
en: "Tanzania",
zh: "坦桑尼亚",
aliases: ["tz", "tanzania", "坦桑尼亚", "坦尚尼亞", "TZ"],
},
"🇷🇼": {
en: "Rwanda",
zh: "卢旺达",
aliases: ["rw", "rwanda", "卢旺达", "盧旺達", "RW"],
},
"🇿🇼": {
en: "Zimbabwe",
zh: "津巴布韦",
aliases: ["zw", "zimbabwe", "津巴布韦", "辛巴威", "ZW"],
},
"🇿🇲": {
en: "Zambia",
zh: "赞比亚",
aliases: ["zm", "zambia", "赞比亚", "尚比亞", "ZM"],
},
"🇧🇼": {
en: "Botswana",
zh: "博茨瓦纳",
aliases: ["bw", "botswana", "博茨瓦纳", "波札那", "BW"],
},
"🇳🇦": {
en: "Namibia",
zh: "纳米比亚",
aliases: ["na", "namibia", "纳米比亚", "納米比亞", "NA"],
},
"🇲🇦": {
en: "Morocco",
zh: "摩洛哥",
aliases: ["ma", "morocco", "摩洛哥", "MA"],
},
"🇩🇿": {
en: "Algeria",
zh: "阿尔及利亚",
aliases: ["dz", "algeria", "阿尔及利亚", "阿爾及利亞", "DZ"],
},
"🇹🇳": {
en: "Tunisia",
zh: "突尼斯",
aliases: ["tn", "tunisia", "突尼斯", "TN"],
},
"🇱🇾": {
en: "Libya",
zh: "利比亚",
aliases: ["ly", "libya", "利比亚", "利比亞", "LY"],
},
"🇸🇩": {
en: "Sudan",
zh: "苏丹",
aliases: ["sd", "sudan", "苏丹", "蘇丹", "SD"],
},
"🇸🇸": {
en: "South Sudan",
zh: "南苏丹",
aliases: ["ss", "south sudan", "南苏丹", "南蘇丹", "SS"],
},
"🇨🇩": {
en: "Democratic Republic of Congo",
zh: "刚果民主共和国",
aliases: ["cd", "congo", "drc", "刚果", "剛果", "CD"],
},
"🇨🇬": {
en: "Republic of Congo",
zh: "刚果共和国",
aliases: ["cg", "congo", "刚果", "剛果", "CG"],
},
"🇨🇫": {
en: "Central African Republic",
zh: "中非共和国",
aliases: ["cf", "central african", "中非", "CF"],
},
"🇨🇲": {
en: "Cameroon",
zh: "喀麦隆",
aliases: ["cm", "cameroon", "喀麦隆", "喀麥隆", "CM"],
},
"🇹🇩": {
en: "Chad",
zh: "乍得",
aliases: ["td", "chad", "乍得", "TD"],
},
"🇳🇪": {
en: "Niger",
zh: "尼日尔",
aliases: ["ne", "niger", "尼日尔", "尼日爾", "NE"],
},
"🇲🇱": {
en: "Mali",
zh: "马里",
aliases: ["ml", "mali", "马里", "馬利", "ML"],
},
"🇧🇫": {
en: "Burkina Faso",
zh: "布基纳法索",
aliases: ["bf", "burkina", "布基纳法索", "布吉納法索", "BF"],
},
"🇸🇳": {
en: "Senegal",
zh: "塞内加尔",
aliases: ["sn", "senegal", "塞内加尔", "塞內加爾", "SN"],
},
"🇬🇲": {
en: "Gambia",
zh: "冈比亚",
aliases: ["gm", "gambia", "冈比亚", "甘比亞", "GM"],
},
"🇬🇼": {
en: "Guinea-Bissau",
zh: "几内亚比绍",
aliases: ["gw", "guinea-bissau", "几内亚比绍", "幾內亞比索", "GW"],
},
"🇬🇳": {
en: "Guinea",
zh: "几内亚",
aliases: ["gn", "guinea", "几内亚", "幾內亞", "GN"],
},
"🇸🇱": {
en: "Sierra Leone",
zh: "塞拉利昂",
aliases: ["sl", "sierra leone", "塞拉利昂", "SL"],
},
"🇱🇷": {
en: "Liberia",
zh: "利比里亚",
aliases: ["lr", "liberia", "利比里亚", "賴比瑞亞", "LR"],
},
"🇨🇮": {
en: "Ivory Coast",
zh: "科特迪瓦",
aliases: ["ci", "ivory coast", "科特迪瓦", "象牙海岸", "CI"],
},
"🇹🇬": {
en: "Togo",
zh: "多哥",
aliases: ["tg", "togo", "多哥", "TG"],
},
"🇧🇯": {
en: "Benin",
zh: "贝宁",
aliases: ["bj", "benin", "贝宁", "貝寧", "BJ"],
},
};
/**
* 检查地区emoji是否匹配搜索词
* @param regionEmoji 地区emoji🇭🇰
* @param searchTerm 搜索词
* @returns 是否匹配
*/
export const isRegionMatch = (
regionEmoji: string,
searchTerm: string
): boolean => {
const lowerSearchTerm = searchTerm.toLowerCase().trim();
// 直接匹配emoji
if (regionEmoji === searchTerm) {
return true;
}
// 从映射表中查找
const regionInfo = emojiToRegionMap[regionEmoji];
if (!regionInfo) {
// 如果映射表中没有,则只进行简单的包含匹配
return regionEmoji.toLowerCase().includes(lowerSearchTerm);
}
// 检查英文名称
if (regionInfo.en.toLowerCase().includes(lowerSearchTerm)) {
return true;
}
// 检查中文名称
if (regionInfo.zh.includes(lowerSearchTerm)) {
return true;
}
// 检查别名
return regionInfo.aliases.some((alias) =>
alias.toLowerCase().includes(lowerSearchTerm)
);
};
/**
* 获取地区的显示名称
* @param regionEmoji 地区emoji
* @param language 语言 ('en' | 'zh')
* @returns 地区名称
*/
export const getRegionDisplayName = (
regionEmoji: string,
language: "en" | "zh" = "zh"
): string => {
const regionInfo = emojiToRegionMap[regionEmoji];
if (!regionInfo) {
return regionEmoji;
}
return language === "zh" ? regionInfo.zh : regionInfo.en;
};
/**
* 获取所有支持的地区emoji列表
* @returns 地区emoji数组
*/
export const getSupportedRegions = (): string[] => {
return Object.keys(emojiToRegionMap);
};

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />