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