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