refactory: add tailwindcss and remove css

This commit is contained in:
zaunist committed 2025-05-22 03:13:58 +08:00
1 parent 7909ee9bbf
commit 7b9c8c1bc0
64 files changed
+4906 -3930

No files matched your search

+1834
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "gray",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+70 -30
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,41 +13,81 @@
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@radix-ui/react-avatar": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-form": "^0.0.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-select": "^1.2.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.4",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@radix-ui/themes": "^2.0.3",
"axios": "^1.4.0",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.4.1",
"react-router-dom": "^6.14.2",
"recharts": "^2.7.2",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"chart.js": "^4.4.9",
"chartjs-adapter-moment": "^1.0.1",
"react-chartjs-2": "^5.3.0"
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.0",
"react-day-picker": "8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.5.1",
"react-resizable-panels": "^3.0.2",
"react-router-dom": "^6.30.0",
"recharts": "^2.15.3",
"shadcn": "^2.5.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"vaul": "^1.1.2",
"zod": "^3.25.7"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230821.0",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"@vitejs/plugin-react": "^4.0.3",
"eslint": "^8.44.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"globals": "^13.20.0",
"@cloudflare/workers-types": "^4.20250520.0",
"@types/node": "^22.15.19",
"@types/react": "^18.3.21",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^8.57.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^13.24.0",
"tw-animate-css": "^1.3.0",
"typescript": "~5.1.6",
"vite": "^4.4.5"
"vite": "^6.0.0"
}
}
+11 -11
View File
@@ -1,7 +1,7 @@
import { Box, Card, Flex, Text, Badge, Tabs } from "@radix-ui/themes";
import { Box, Card, Flex, Text } from "@radix-ui/themes";
import { Tabs, TabsContent, TabsList, TabsTrigger,Badge } from "./ui";
import { GlobeIcon } from "@radix-ui/react-icons";
import MetricsChart from "./MetricsChart";
import "../styles/components.css";
import { useTranslation } from "react-i18next";
import { AgentCardProps, MetricType } from "../types";
@@ -66,7 +66,7 @@ const AgentCard = ({
];
return (
<Card className="agent-card">
<Card>
<Flex justify="between" align="center" p="4">
<Flex direction="column" gap="1">
<Flex align="center" gap="2">
@@ -105,17 +105,17 @@ const AgentCard = ({
<Box p="2" pt="0">
{/* 指标图表区域 */}
<Tabs.Root defaultValue="cpu">
<Tabs.List>
<Tabs defaultValue="cpu">
<TabsList>
{metricTypes.map((type) => (
<Tabs.Trigger key={type} value={type}>
<TabsTrigger key={type} value={type}>
{t(`agent.metrics.${type}.title`) || type.toUpperCase()}
</Tabs.Trigger>
</TabsTrigger>
))}
</Tabs.List>
</TabsList>
{metricTypes.map((type) => (
<Tabs.Content key={type} value={type}>
<TabsContent key={type} value={type}>
<MetricsChart
history={agent.metrics}
metricType={type}
@@ -124,9 +124,9 @@ const AgentCard = ({
networkInterface="en0"
loadType="1"
/>
</Tabs.Content>
</TabsContent>
))}
</Tabs.Root>
</Tabs>
</Box>
</Card>
);
+25 -17
View File
@@ -1,5 +1,11 @@
import React from "react";
import { DropdownMenu, Button, Text, Flex } from "@radix-ui/themes";
import { Text, Flex } from "@radix-ui/themes";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "./ui";
import { GlobeIcon, CheckIcon } from "@radix-ui/react-icons";
import { useLanguage } from "../providers/LanguageProvider";
import { useTranslation } from "react-i18next";
@@ -9,20 +15,22 @@ const LanguageSelector: React.FC = () => {
const { t } = useTranslation();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="2" className="nav-button">
<Flex align="center" gap="1">
<GlobeIcon width="14" height="14" />
<Text size="2" className="language-text">
{currentLanguage === "zh-CN" ? "中文" : "English"}
</Text>
</Flex>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu>
<DropdownMenuTrigger>
<Flex
align="center"
gap="1"
className="inline-flex items-center justify-center rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<GlobeIcon width="14" height="14" />
<Text size="2">
{currentLanguage === "zh-CN" ? "中文" : "English"}
</Text>
</Flex>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{availableLanguages.map((lang) => (
<DropdownMenu.Item
<DropdownMenuItem
key={lang.code}
onClick={() => changeLanguage(lang.code)}
>
@@ -39,10 +47,10 @@ const LanguageSelector: React.FC = () => {
<CheckIcon width="14" height="14" />
)}
</Flex>
</DropdownMenu.Item>
</DropdownMenuItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</DropdownMenuContent>
</DropdownMenu>
);
};
+52 -45
View File
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
import { Box, Flex, Text, Container, Theme, Separator } from "@radix-ui/themes";
import { Box, Flex, Text, Container, Theme } from "@radix-ui/themes";
import { Separator, Button, Toaster } from "./ui";
import Navbar from "./Navbar";
import "../styles/components.css";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faRss } from "@fortawesome/free-solid-svg-icons";
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
@@ -18,65 +18,72 @@ const Layout = ({ children }: LayoutProps) => {
return (
<Theme appearance="light">
<Flex direction="column" className="layout-container">
<Flex direction="column" >
{/* 顶部导航栏 */}
<Navbar />
{/* 主要内容 */}
<Box className="main-content">{children}</Box>
<Box>{children}</Box>
{/* 页脚 */}
<Box className="footer">
<Box>
<Container>
<Separator size="4" mb="4" color="gray" />
<Separator color="gray" />
<Flex justify="center" align="center" py="3" direction="column">
<Text size="2" color="gray">
{t("footer.copyright", { year: currentYear })}
</Text>
<Flex gap="3" mt="2">
<a
href="https://zaunist.com"
target="_blank"
rel="noopener noreferrer"
className="footer-link"
>
<FontAwesomeIcon
icon={faRss}
size="lg"
className="footer-link-icon"
/>
<Text size="2">{t("footer.blog")}</Text>
</a>
<a
href="https://www.youtube.com/@zaunist"
target="_blank"
rel="noopener noreferrer"
className="footer-link"
>
<FontAwesomeIcon
icon={faYoutube}
size="lg"
className="footer-link-icon"
/>
<Text size="2">{t("footer.youtube")}</Text>
</a>
<a
href="https://mail.mdzz.uk"
target="_blank"
rel="noopener noreferrer"
className="footer-link"
>
<FontAwesomeIcon
icon={faEnvelope}
size="lg"
className="footer-link-icon"
/>
<Text size="2">{t("footer.tempMail")}</Text>
</a>
<Button variant="link" asChild>
<a
href="https://zaunist.com"
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon
icon={faRss}
size="lg"
/>
<Text size="2">{t("footer.blog")}</Text>
</a>
</Button>
<Button variant="link" asChild>
<a
href="https://www.youtube.com/@zaunist"
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon
icon={faYoutube}
size="lg"
/>
<Text size="2">{t("footer.youtube")}</Text>
</a>
</Button>
<Button variant="link" asChild>
<a
href="https://mail.mdzz.uk"
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon
icon={faEnvelope}
size="lg"
/>
<Text size="2">{t("footer.tempMail")}</Text>
</a>
</Button>
</Flex>
</Flex>
</Container>
</Box>
<Toaster />
</Flex>
</Theme>
);
+48 -37
View File
@@ -5,7 +5,14 @@ import React, {
useCallback,
useState,
} from "react";
import { Box, Select, Flex } from "@radix-ui/themes";
import { Box, Flex } from "@radix-ui/themes";
import {
Select,
SelectContent,
SelectTrigger,
SelectItem,
SelectValue, // 添加 SelectValue 到导入列表
} from "./ui";
import { useTranslation } from "react-i18next";
import { Line } from "react-chartjs-2";
import { MetricHistory, MetricType } from "../types";
@@ -976,19 +983,21 @@ const MetricsChart: React.FC<MetricsChartProps> = ({
if (metricType === "disk" && availableDiskDevices.length > 0) {
return (
<Box mb="2">
<Select.Root
<Select
value={selectedDiskDevice}
onValueChange={setSelectedDiskDevice}
>
<Select.Trigger />
<Select.Content>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableDiskDevices.map((device) => (
<Select.Item key={device.value} value={device.value}>
<SelectItem key={device.value} value={device.value}>
{device.label}
</Select.Item>
</SelectItem>
))}
</Select.Content>
</Select.Root>
</SelectContent>
</Select>
</Box>
);
}
@@ -996,35 +1005,39 @@ const MetricsChart: React.FC<MetricsChartProps> = ({
if (metricType === "network" && availableNetworkInterfaces.length > 0) {
return (
<Flex gap="2" mb="2" align="center">
<Select.Root
<Select
value={selectedNetworkInterface}
onValueChange={setSelectedNetworkInterface}
>
<Select.Trigger />
<Select.Content>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableNetworkInterfaces.map((iface) => (
<Select.Item key={iface.value} value={iface.value}>
<SelectItem key={iface.value} value={iface.value}>
{iface.label}
</Select.Item>
</SelectItem>
))}
</Select.Content>
</Select.Root>
<Select.Root
</SelectContent>
</Select>
<Select
value={selectedNetworkMetric}
onValueChange={(value) =>
setSelectedNetworkMetric(value as "received" | "sent")
}
>
<Select.Trigger />
<Select.Content>
<Select.Item value="received">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="received">
{t("agent.metrics.network.received")}
</Select.Item>
<Select.Item value="sent">
</SelectItem>
<SelectItem value="sent">
{t("agent.metrics.network.sent")}
</Select.Item>
</Select.Content>
</Select.Root>
</SelectItem>
</SelectContent>
</Select>
</Flex>
);
}
@@ -1033,25 +1046,23 @@ const MetricsChart: React.FC<MetricsChartProps> = ({
if (metricType === "load") {
return (
<Box mb="2">
<Select.Root
<Select
value={selectedLoadType}
onValueChange={(value) =>
setSelectedLoadType(value as "1" | "5" | "15")
}
>
<Select.Trigger />
<Select.Content>
<Select.Item value="1">
{t("agent.metrics.load.1min")}
</Select.Item>
<Select.Item value="5">
{t("agent.metrics.load.5min")}
</Select.Item>
<Select.Item value="15">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t("agent.metrics.load.1min")}</SelectItem>
<SelectItem value="5">{t("agent.metrics.load.5min")}</SelectItem>
<SelectItem value="15">
{t("agent.metrics.load.15min")}
</Select.Item>
</Select.Content>
</Select.Root>
</SelectItem>
</SelectContent>
</Select>
</Box>
);
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { Box, Card, Flex, Text, Badge } from "@radix-ui/themes";
import { Box, Flex, Text } from "@radix-ui/themes";
import {
CheckCircledIcon,
CrossCircledIcon,
QuestionMarkCircledIcon,
} from "@radix-ui/react-icons";
import { Card, Badge } from "./ui";
import { MonitorWithDailyStatsAndStatusHistory } from "../types/monitors";
import "../styles/components.css";
import { useTranslation } from "react-i18next";
import StatusBar from "./StatusBar";
import ResponseTimeChart from "./ResponseTimeChart";
@@ -60,7 +60,7 @@ const MonitorCard = ({ monitor }: MonitorCardProps) => {
const currentStatus = monitor.status || "pending";
return (
<Card className="monitor-card">
<Card>
<Flex justify="between" align="start" p="4" gap="2" direction="column">
<Flex justify="between" align="center" style={{ width: "100%" }}>
<Flex align="center" gap="2">
+56 -57
View File
@@ -1,14 +1,17 @@
import { Link, useNavigate, useLocation } from "react-router-dom";
import { Box, Flex, Text, Container } from "@radix-ui/themes";
import {
Box,
Flex,
Text,
Separator,
Button,
Avatar,
AvatarImage,
DropdownMenu,
Separator,
Container,
} from "@radix-ui/themes";
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "./ui";
import {
ExitIcon,
PersonIcon,
@@ -22,7 +25,6 @@ import {
} from "@radix-ui/react-icons";
import { useAuth } from "../providers/AuthProvider";
import { useState, useEffect } from "react";
import "../styles/components.css";
import { useTranslation } from "react-i18next";
import LanguageSelector from "./LanguageSelector";
@@ -61,18 +63,18 @@ const Navbar = () => {
return (
<Box className={`navbar-wrapper ${isScrolled ? "scrolled" : ""}`}>
<Box className="navbar-container">
<Container size="3">
<Box >
<Container size="4">
<Flex
justify="between"
align="center"
py="2"
className="navbar-content"
>
{/* Logo 部分 */}
<Link to="/" className="navbar-logo-link">
<Link to="/" >
<Flex align="center" gap="2">
<Box className="navbar-logo">
<Box >
<PieChartIcon width="20" height="20" />
</Box>
<Text size="4" weight="bold">
@@ -85,14 +87,14 @@ const Navbar = () => {
<Flex align="center" gap="2">
{isAuthenticated ? (
<>
<Flex className="navbar-links" align="center">
<Flex align="center">
<Link
to="/dashboard"
className={`nav-link ${
isActive("/dashboard") ? "active" : ""
}`}
>
<Button variant="ghost" size="2" className="nav-button">
<Button variant="ghost">
<DashboardIcon width="14" height="14" />
<Text ml="1" size="2">
{t("navbar.dashboard")}
@@ -106,7 +108,7 @@ const Navbar = () => {
isActive("/monitors") ? "active" : ""
}`}
>
<Button variant="ghost" size="2" className="nav-button">
<Button variant="ghost">
<ActivityLogIcon width="14" height="14" />
<Text ml="1" size="2">
{t("navbar.apiMonitors")}
@@ -120,7 +122,7 @@ const Navbar = () => {
isActive("/agents") ? "active" : ""
}`}
>
<Button variant="ghost" size="2" className="nav-button">
<Button variant="ghost">
<CubeIcon width="14" height="14" />
<Text ml="1" size="2">
{t("navbar.agentMonitors")}
@@ -134,7 +136,7 @@ const Navbar = () => {
isActive("/status/config") ? "active" : ""
}`}
>
<Button variant="ghost" size="2" className="nav-button">
<Button variant="ghost">
<PieChartIcon width="14" height="14" />
<Text ml="1" size="2">
{t("navbar.statusPage")}
@@ -148,7 +150,7 @@ const Navbar = () => {
isActive("/notifications") ? "active" : ""
}`}
>
<Button variant="ghost" size="2" className="nav-button">
<Button variant="ghost">
<BellIcon width="14" height="14" />
<Text ml="1" size="2">
{t("navbar.notifications")}
@@ -157,83 +159,80 @@ const Navbar = () => {
</Link>
</Flex>
<Separator orientation="vertical" mx="3" />
<Separator orientation="vertical" />
{/* 语言选择器 */}
<LanguageSelector />
<Separator orientation="vertical" mx="3" />
<Separator orientation="vertical" />
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" className="user-menu-button">
<Flex align="center" gap="1">
<Avatar
size="1"
radius="full"
fallback={
user?.username?.charAt(0).toUpperCase() || "U"
}
color="blue"
<DropdownMenu>
<DropdownMenuTrigger>
<Flex
align="center"
gap="1"
className="inline-flex items-center justify-center rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Avatar>
<AvatarImage
alt={user?.username}
width="32"
height="32"
/>
<Box display={{ initial: "none", sm: "block" }}>
<Text size="2">{user?.username}</Text>
</Box>
<ChevronDownIcon width="12" height="12" />
</Flex>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" className="user-dropdown">
<DropdownMenu.Label>
</Avatar>
<Box display={{ initial: "none", sm: "block" }}>
<Text size="2">{user?.username}</Text>
</Box>
<ChevronDownIcon width="12" height="12" />
</Flex>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="user-dropdown">
<DropdownMenuLabel>
<Text size="1" color="gray">
{t("navbar.loggedInAs")}
</Text>
<Text size="2" weight="bold">
{user?.username}
</Text>
</DropdownMenu.Label>
</DropdownMenuLabel>
<DropdownMenu.Separator />
<DropdownMenuSeparator />
{user?.role === "admin" && (
<DropdownMenu.Item onClick={() => navigate("/users")}>
<DropdownMenuItem onClick={() => navigate("/users")}>
<Flex gap="2" align="center">
<UserIcon width="14" height="14" />
<Text size="2">{t("navbar.userManagement")}</Text>
</Flex>
</DropdownMenu.Item>
</DropdownMenuItem>
)}
<DropdownMenu.Item onClick={() => navigate("/profile")}>
<DropdownMenuItem onClick={() => navigate("/profile")}>
<Flex gap="2" align="center">
<PersonIcon width="14" height="14" />
<Text size="2">{t("navbar.profile")}</Text>
</Flex>
</DropdownMenu.Item>
</DropdownMenuItem>
<DropdownMenu.Separator />
<DropdownMenuSeparator />
<DropdownMenu.Item color="red" onClick={handleLogout}>
<DropdownMenuItem color="red" onClick={handleLogout}>
<Flex gap="2" align="center">
<ExitIcon width="14" height="14" />
<Text size="2">{t("navbar.logout")}</Text>
</Flex>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<Flex gap="2" align="center">
{/* 语言选择器 */}
<LanguageSelector />
<Separator orientation="vertical" mx="2" />
<Separator orientation="vertical" />
<Button
variant="ghost"
onClick={() => navigate("/login")}
size="2"
>
<Button variant="ghost" onClick={() => navigate("/login")}>
{t("navbar.login")}
</Button>
</Flex>
+54 -35
View File
@@ -1,5 +1,16 @@
import React, { useMemo, useState } from "react";
import { Box, Tooltip, Text, Dialog, Flex, Button } from "@radix-ui/themes";
import { Box, Text, Flex } from "@radix-ui/themes";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Button,
Dialog,
DialogContent,
DialogTrigger,
DialogTitle,
DialogClose,
} from "./ui";
import { useTranslation } from "react-i18next";
import { DailyStats } from "../types/monitors";
@@ -18,7 +29,8 @@ interface StatusBarProps {
*/
const StatusBar: React.FC<StatusBarProps> = ({ dailyStats = [] }) => {
const { t } = useTranslation();
const [selectedDayData, setSelectedDayData] = useState<EnrichedDailyStats | null>(null);
const [selectedDayData, setSelectedDayData] =
useState<EnrichedDailyStats | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
// console.log("StatusBar组件的dailyStats: ", dailyStats); // 调试信息可以暂时注释或移除
@@ -95,13 +107,13 @@ const StatusBar: React.FC<StatusBarProps> = ({ dailyStats = [] }) => {
>
{dailyHistory?.map((dayData) => {
// 确保 dayData 和 monitor_id 存在
const key = dayData && dayData.monitor_id
? `${dayData.monitor_id}-${dayData.date}-${Math.random()}`
: `day-${dayData?.date}-${Math.random()}`;
const key =
dayData && dayData.monitor_id
? `${dayData.monitor_id}-${dayData.date}-${Math.random()}`
: `day-${dayData?.date}-${Math.random()}`;
return (
<Tooltip
key={key}
content={
<Tooltip key={key}>
<TooltipContent>
<>
<Text as="span" size="1" mb="1">
{t("common.date")}:{" "}
@@ -122,34 +134,41 @@ const StatusBar: React.FC<StatusBarProps> = ({ dailyStats = [] }) => {
{dayData.availability.toFixed(2)}%
</Text>
</>
}
>
<Box
style={{
width: "100%",
height: "50px",
backgroundColor: getColor(dayData.status),
borderRadius: "2px",
transition: "background-color 0.2s",
cursor: "pointer",
padding: "0",
}}
onClick={() => handleDayClick(dayData)}
/>
</TooltipContent>
<TooltipTrigger>
<Box
style={{
width: "100%",
height: "50px",
backgroundColor: getColor(dayData.status),
borderRadius: "2px",
transition: "background-color 0.2s",
cursor: "pointer",
padding: "0",
}}
onClick={() => handleDayClick(dayData)}
/>
</TooltipTrigger>
</Tooltip>
);
})}
</Box>
{selectedDayData && (
<Dialog.Root open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog.Trigger />
<Dialog.Content style={{ maxWidth: 450 }}>
<Dialog.Title>
{t(`📅 ${new Date(selectedDayData.date).toLocaleDateString()} ${t("common.status")}: ${selectedDayData.status === "up"
? t("monitor.status.normal")
: t("monitor.status.failure")}`)}
</Dialog.Title>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger />
<DialogContent style={{ maxWidth: 450 }}>
<DialogTitle>
{t(
`📅 ${new Date(selectedDayData.date).toLocaleDateString()} ${t(
"common.status"
)}: ${
selectedDayData.status === "up"
? t("monitor.status.normal")
: t("monitor.status.failure")
}`
)}
</DialogTitle>
<Flex direction="column" gap="3">
<Text as="div" size="2">
@@ -183,14 +202,14 @@ const StatusBar: React.FC<StatusBarProps> = ({ dailyStats = [] }) => {
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="soft" color="gray">
<DialogClose>
<Button variant="secondary" color="gray">
{t("common.close")}
</Button>
</Dialog.Close>
</DialogClose>
</Flex>
</Dialog.Content>
</Dialog.Root>
</DialogContent>
</Dialog>
)}
</>
);
+24 -29
View File
@@ -1,4 +1,14 @@
import { Select, Text } from "@radix-ui/themes";
import { Text } from "@radix-ui/themes";
import {
Select,
SelectContent,
SelectItem,
SelectLabel,
SelectTrigger,
SelectSeparator,
SelectValue,
SelectGroup,
} from "./ui";
// 状态码选项定义
export const specificStatusCodes = [
@@ -61,47 +71,32 @@ const StatusCodeSelect = ({
}: StatusCodeSelectProps) => {
return (
<>
<Select.Root
<Select
name={name}
value={value.toString()}
onValueChange={(value) => onChange(parseInt(value))}
required={required}
>
<Select.Trigger style={{ width: "100%" }} />
<Select.Content position="popper" sideOffset={5}>
<SelectTrigger style={{ width: "100%" }}>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" sideOffset={5}>
{specificStatusCodes.map((group) => (
<Select.Group key={group.group}>
<Select.Label
style={{
fontWeight: "bold",
padding: "8px 12px",
color: "var(--gray-10)",
}}
>
{group.group}
</Select.Label>
<SelectGroup key={group.group}>
<SelectLabel>{group.group}</SelectLabel>
{group.codes.map((code) => (
<Select.Item
key={code.value}
value={code.value.toString()}
style={{
padding: code.isRange
? "8px 12px 8px 30px"
: "8px 12px 8px 40px",
fontWeight: code.isRange ? "bold" : "normal",
}}
>
<SelectItem key={code.value} value={code.value.toString()}>
{code.label}
</Select.Item>
</SelectItem>
))}
{group !==
specificStatusCodes[specificStatusCodes.length - 1] && (
<Select.Separator style={{ margin: "8px 0" }} />
<SelectSeparator style={{ margin: "8px 0" }} />
)}
</Select.Group>
</SelectGroup>
))}
</Select.Content>
</Select.Root>
</SelectContent>
</Select>
<Text size="1" color="gray" style={{ marginTop: "4px" }}>
选择预期的HTTP状态码或状态码范围
</Text>
+3 -10
View File
@@ -1,6 +1,6 @@
import { Box, Card, Flex, Heading, Text } from "@radix-ui/themes";
import { Box, Flex, Heading, Text } from "@radix-ui/themes";
import { Card } from "./ui";
import { ReactNode } from "react";
import "../styles/components.css";
interface StatusItem {
icon: ReactNode;
@@ -30,14 +30,7 @@ const StatusSummaryCard = ({ title, items }: StatusSummaryCardProps) => {
{items.map((item, index) => (
<Flex key={index} justify="between" align="center">
<Flex align="center" gap="2">
<Box
className="status-icon-container"
style={{
background: item.bgColor || "var(--gray-3)",
padding: "8px",
borderRadius: "6px",
}}
>
<Box className="status-icon-container">
<Box style={{ color: item.iconColor }}>{item.icon}</Box>
</Box>
<Text size="2">{item.label}</Text>
+146
View File
@@ -0,0 +1,146 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Trigger>
>(({ ...props }, ref) => (
<AlertDialogPrimitive.Trigger ref={ref} {...props} />
));
AlertDialogTrigger.displayName = AlertDialogPrimitive.Trigger.displayName;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80", // Simplified background for example, original had animations
// "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
// "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.ComponentProps<"div">) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props}
/>
);
const AlertDialogFooter = ({
className,
...props
}: React.ComponentProps<"div">) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
// "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
// className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
// className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/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",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col rounded-xl border py-4 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+18
View File
@@ -0,0 +1,18 @@
export * from "./card"
export * from "./textarea"
export * from "./badge"
export * from "./button"
export * from "./tabs"
export * from "./avatar"
export * from "./dropdown-menu"
export * from "./separator"
export * from "./select"
export * from "./tooltip"
export * from "./dialog"
export * from "./switch"
export * from "./alert-dialog"
export * from "./textarea"
export * from "./table"
export * from "./checkbox"
export * from "./sonner"
export * from "./input"
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+183
View File
@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+93
View File
@@ -0,0 +1,93 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn(
"flex flex-col outline-none shadow-none", // 修改点:添加 outline-none shadow-none
className
)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex items-center gap-1 py-0 pl-1 pr-2 shadow-none outline-none",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
// 基础布局和排版样式
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium",
// 为所有可动画属性启用平滑过渡
"transition-all",
// focus-visible 状态:提供清晰的聚焦环,增强可访问性
// 使用具体的颜色替换 ring-ring 和 ring-offset-background
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
// 禁用状态的样式
"disabled:pointer-events-none disabled:opacity-50",
// 默认(非激活)状态的样式:
// - 使用具体的颜色替换 text-muted-foreground 和 hover:text-primary
"text-slate-500 dark:text-slate-400 hover:text-gray-600 dark:hover:text-gray-400 border-2 border-transparent",
// 激活(选中)状态的样式:
// - 使用具体的颜色替换 data-[state=active]:text-primary
"data-[state=active]:text-gray-700 dark:data-[state=active]:text-gray-400 data-[state=active]:font-semibold",
// - 使用具体的颜色替换 data-[state=active]:bg-primary/10
"data-[state=active]:bg-gray-100 dark:data-[state=active]:bg-gray-500/20", // 示例:浅蓝色背景
// - 使用具体的颜色替换 data-[state=active]:border-primary
"data-[state=active]:border-gray-600 dark:data-[state=active]:border-gray-500",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"flex-1 outline-none",
"pt-2", // padding-top: 24px
"shadow-none", // box-shadow: none
"border-0", // border: none (ensuring no borders are applied)
className
)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
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
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+2 -2
View File
@@ -4,8 +4,8 @@ import { RouterProvider } from "react-router-dom";
import { AuthProvider } from "./providers/AuthProvider";
import { LanguageProvider } from "./providers/LanguageProvider";
import "@radix-ui/themes/styles.css";
import "./styles/index.css";
import "./locales/config";
import "./styles/global.css";
import "./i18n/config";
import router from "./router";
if ("serviceWorker" in navigator) {
+2 -3
View File
@@ -10,7 +10,6 @@ import {
import { Monitor, Agent } from "../types";
import { getDashboardData } from "../api/dashboard";
import StatusSummaryCard from "../components/StatusSummaryCard";
import "../styles/components.css";
import { useTranslation } from "react-i18next";
const Dashboard = () => {
@@ -106,8 +105,8 @@ const Dashboard = () => {
];
return (
<Box className="dashboard-container">
<Container size="3" py="5">
<Box>
<Container size="4">
<Box>
{/* 状态摘要 */}
<Box pb="6">
+9 -8
View File
@@ -1,4 +1,5 @@
import { Box, Flex, Heading, Text, Button, Card, Grid } from "@radix-ui/themes";
import { Box, Flex, Heading, Text, Grid } from "@radix-ui/themes";
import { Button, Card } from "@/components/ui";
import { Link } from "react-router-dom";
import {
ActivityLogIcon,
@@ -33,15 +34,15 @@ const Home = () => {
<Flex gap="4" mt="4">
{isAuthenticated ? (
<Button size="3" asChild>
<Button asChild>
<Link to="/dashboard">{t("home.getStarted")}</Link>
</Button>
) : (
<>
<Button size="3" asChild>
<Button asChild>
<Link to="/login">{t("home.getStarted")}</Link>
</Button>
<Button size="3" asChild>
<Button asChild>
<a
href="https://github.com/zaunist/xugou"
target="_blank"
@@ -61,28 +62,28 @@ const Home = () => {
{t("home.features.title")}
</Heading>
<Grid columns={{ initial: "1", sm: "2", md: "4" }} gap="4">
<Card size="3">
<Card>
<Flex direction="column" gap="2" align="center" p="4">
<ActivityLogIcon width="32" height="32" />
<Heading size="4">{t("home.features.monitoring")}</Heading>
<Text align="center">{t("home.features.monitoring.desc")}</Text>
</Flex>
</Card>
<Card size="3">
<Card>
<Flex direction="column" gap="2" align="center" p="4">
<DesktopIcon width="32" height="32" />
<Heading size="4">{t("home.features.dashboard")}</Heading>
<Text align="center">{t("home.features.dashboard.desc")}</Text>
</Flex>
</Card>
<Card size="3">
<Card>
<Flex direction="column" gap="2" align="center" p="4">
<LightningBoltIcon width="32" height="32" />
<Heading size="4">{t("home.features.alerts")}</Heading>
<Text align="center">{t("home.features.alerts.desc")}</Text>
</Flex>
</Card>
<Card size="3">
<Card>
<Flex direction="column" gap="2" align="center" p="4">
<MixerHorizontalIcon width="32" height="32" />
<Heading size="4">{t("home.features.statusPage")}</Heading>
+3 -2
View File
@@ -1,4 +1,5 @@
import { Flex, Heading, Text, Button } from "@radix-ui/themes";
import { Flex, Heading, Text } from "@radix-ui/themes";
import { Button } from "@/components/ui";
import { Link } from "react-router-dom";
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { useTranslation } from "react-i18next";
@@ -25,7 +26,7 @@ const NotFound = () => {
<Text align="center" style={{ maxWidth: "500px" }}>
{t("notFound.message")}
</Text>
<Button size="3" mt="4" asChild>
<Button asChild>
<Link to="/">{t("notFound.button")}</Link>
</Button>
</Flex>
+32 -76
View File
@@ -1,16 +1,9 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Flex,
Heading,
Text,
Button,
Card,
Badge,
Grid,
Avatar,
} from "@radix-ui/themes";
import { Box, Flex, Heading, Text, Grid, Container } from "@radix-ui/themes";
import { Button, Card, Badge, Avatar, AvatarFallback } from "@/components/ui";
import {
ArrowLeftIcon,
Pencil1Icon,
@@ -23,15 +16,11 @@ import {
GlobeIcon,
Link2Icon,
} from "@radix-ui/react-icons";
import * as Toast from "@radix-ui/react-toast";
import {
getAgent,
deleteAgent,
getAgentMetrics,
} from "../../api/agents";
import { getAgent, deleteAgent, getAgentMetrics } from "../../api/agents";
import { Agent } from "../../types/agents";
import { useTranslation } from "react-i18next";
import AgentCard from "../../components/AgentCard";
import { toast } from "sonner"; // Added
// 定义客户端状态颜色映射
const statusColors: Record<string, "red" | "green" | "yellow" | "gray"> = {
@@ -48,9 +37,6 @@ const AgentDetail = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [toastOpen, setToastOpen] = useState(false);
const [toastMessage, setToastMessage] = useState("");
const [toastType, setToastType] = useState<"success" | "error">("success");
const { t } = useTranslation();
const fetchAgentData = async () => {
@@ -141,33 +127,21 @@ const AgentDetail = () => {
try {
setDeleteLoading(true);
// 显示正在删除的Toast消息
setToastMessage(t("agent.deleting"));
setToastType("success");
setToastOpen(true);
toast.loading(t("agent.deleting")); // Added
const response = await deleteAgent(Number(id));
if (response.success) {
// 显示删除成功的Toast消息
setToastMessage(t("agent.deleteSuccess"));
setToastType("success");
setToastOpen(true);
// 延长延迟时间,确保Toast消息有足够时间显示
toast.success(t("agent.deleteSuccess"));
setTimeout(() => {
navigate("/agents");
}, 3000);
} else {
setToastMessage(response.message || t("agent.deleteError"));
setToastType("error");
setToastOpen(true);
toast.error(response.message || t("agent.deleteError"));
}
} catch (error) {
console.error("删除客户端失败:", error);
setToastMessage(t("agent.deleteError"));
setToastType("error");
setToastOpen(true);
toast.error(t("agent.deleteError"));
} finally {
setDeleteLoading(false);
}
@@ -176,7 +150,7 @@ const AgentDetail = () => {
if (loading) {
return (
<Box>
<Flex className="loading-container">
<Flex>
<Text>{t("agents.loadingDetail")}</Text>
</Flex>
</Box>
@@ -186,7 +160,7 @@ const AgentDetail = () => {
if (error) {
return (
<Box>
<Flex className="error-container">
<Flex>
<Card>
<Flex direction="column" align="center" gap="4">
<Heading size="6">{t("common.loadingError")}</Heading>
@@ -204,7 +178,7 @@ const AgentDetail = () => {
if (!agent) {
return (
<Box>
<Flex justify="center" align="center" style={{ minHeight: "60vh" }}>
<Flex justify="center" align="center">
<Card>
<Flex direction="column" align="center" gap="4">
<Heading size="6">{t("agents.notFound")}</Heading>
@@ -220,10 +194,10 @@ const AgentDetail = () => {
}
return (
<Box className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Container size="4">
<Flex justify="between" align="center">
<Flex align="center" gap="2">
<Button variant="soft" size="1" onClick={() => navigate("/agents")}>
<Button variant="secondary" onClick={() => navigate("/agents")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{t("agent.details")}</Heading>
@@ -234,17 +208,23 @@ const AgentDetail = () => {
</Badge>
</Flex>
<Flex gap="2">
<Button variant="soft" onClick={handleRefresh} disabled={loading}>
<Button
variant="secondary"
onClick={handleRefresh}
disabled={loading}
>
<ReloadIcon />
{t("common.refresh")}
</Button>
<Button variant="soft" onClick={() => navigate(`/agents/edit/${id}`)}>
<Button
variant="secondary"
onClick={() => navigate(`/agents/edit/${id}`)}
>
<Pencil1Icon />
{t("agent.edit")}
</Button>
<Button
variant="soft"
color="red"
variant="secondary"
onClick={handleDelete}
disabled={deleteLoading}
>
@@ -254,10 +234,12 @@ const AgentDetail = () => {
</Flex>
</Flex>
<Box className="detail-content">
<Card mb="4">
<Box>
<Card>
<Flex align="center" gap="4" mb="4">
<Avatar size="5" fallback={agent.name.charAt(0)} color="indigo" />
<Avatar>
<AvatarFallback>{agent.name.charAt(0)}</AvatarFallback>
</Avatar>
<Box>
<Heading size="5">{agent.name}</Heading>
</Box>
@@ -415,33 +397,7 @@ const AgentDetail = () => {
</Grid>
</Box>
</Box>
<Toast.Provider swipeDirection="right">
<Toast.Root
className={`ToastRoot`}
open={toastOpen}
onOpenChange={setToastOpen}
duration={3000}
style={{
backgroundColor:
toastType === "success" ? "var(--green-9)" : "var(--red-9)",
borderRadius: "8px",
zIndex: 9999,
}}
>
<Toast.Title className="ToastTitle">
{toastType === "success" ? t("common.success") : t("common.error")}
</Toast.Title>
<Toast.Description className="ToastDescription">
{toastMessage}
</Toast.Description>
<Toast.Close className="ToastClose">
<Cross2Icon />
</Toast.Close>
</Toast.Root>
<Toast.Viewport className="ToastViewport" />
</Toast.Provider>
</Box>
</Container>
);
};
+140 -149
View File
@@ -5,15 +5,29 @@ import {
Flex,
Heading,
Text,
IconButton,
Grid,
Container,
} from "@radix-ui/themes";
import {
Button,
Card,
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
Badge,
IconButton,
Dialog,
Grid,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
Tabs,
} from "@radix-ui/themes";
TabsList,
TabsTrigger,
} from "@/components/ui";
import {
PlusIcon,
Pencil1Icon,
@@ -23,11 +37,7 @@ import {
ViewGridIcon,
TrashIcon,
} from "@radix-ui/react-icons";
import {
getAllAgents,
deleteAgent,
getAgentMetrics,
} from "../../api/agents";
import { getAllAgents, deleteAgent, getAgentMetrics } from "../../api/agents";
import AgentCard from "../../components/AgentCard";
import { useTranslation } from "react-i18next";
import { Agent } from "../../types";
@@ -132,17 +142,9 @@ const AgentsList = () => {
return (
<Grid columns={{ initial: "1" }} gap="4">
{agents.map((agent) => (
<Box key={agent.id} style={{ position: "relative" }}>
<Box key={agent.id} className="relative">
<AgentCard agent={agent} />
<Flex
style={{
position: "absolute",
top: "10px",
right: "10px",
zIndex: 1,
}}
gap="2"
>
<Flex gap="2" className="absolute top-4 right-4">
<IconButton
variant="ghost"
size="1"
@@ -178,42 +180,28 @@ const AgentsList = () => {
// 展示表格视图
const renderTableView = () => {
return (
<Table.Root variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>
{t("agents.table.name")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("agents.table.host")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("agents.table.ip")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("agents.table.status")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("agents.table.os")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("agents.table.version")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("agents.table.actions")}
</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table>
<TableHeader>
<TableRow>
<TableCell>{t("agents.table.name")}</TableCell>
<TableCell>{t("agents.table.host")}</TableCell>
<TableCell>{t("agents.table.ip")}</TableCell>
<TableCell>{t("agents.table.status")}</TableCell>
<TableCell>{t("agents.table.os")}</TableCell>
<TableCell>{t("agents.table.version")}</TableCell>
<TableCell>{t("agents.table.actions")}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{agents.map((agent) => (
<Table.Row key={agent.id}>
<Table.Cell>
<TableRow key={agent.id}>
<TableCell>
<Text weight="medium">{agent.name}</Text>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<Text>{agent.hostname || t("common.notFound")}</Text>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<Text>
{agent.ip_addresses
? (() => {
@@ -230,8 +218,8 @@ const AgentsList = () => {
})()
: t("common.notFound")}
</Text>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<Badge color={statusColors[agent.status || "unknown"]}>
{agent.status === "active"
? t("agent.status.online")
@@ -239,14 +227,14 @@ const AgentsList = () => {
? t("agent.status.connecting")
: t("agent.status.offline")}
</Badge>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<Text>{agent.os || t("common.notFound")}</Text>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<Text>{agent.version || t("common.notFound")}</Text>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<Flex gap="2">
<IconButton
variant="soft"
@@ -268,18 +256,18 @@ const AgentsList = () => {
<TrashIcon />
</IconButton>
</Flex>
</Table.Cell>
</Table.Row>
</TableCell>
</TableRow>
))}
</Table.Body>
</Table.Root>
</TableBody>
</Table>
);
};
// 加载中显示
if (loading) {
return (
<Box className="page-container detail-page">
<Box>
<Flex justify="center" align="center" p="4">
<Text>{t("common.loading")}</Text>
</Flex>
@@ -291,12 +279,12 @@ const AgentsList = () => {
if (error) {
return (
<Box className="page-container detail-page">
<Card mb="4">
<Flex p="3" style={{ backgroundColor: "var(--red-3)" }}>
<Text style={{ color: "var(--red-9)" }}>{error}</Text>
<Card>
<Flex>
<Text>{error}</Text>
</Flex>
</Card>
<Button variant="soft" onClick={() => window.location.reload()} mt="2">
<Button variant="secondary" onClick={() => window.location.reload()}>
{t("common.retry")}
</Button>
</Box>
@@ -304,98 +292,101 @@ const AgentsList = () => {
}
return (
<Box>
<div className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Heading size="6">{t("agents.pageTitle")}</Heading>
<Flex gap="3">
<Tabs.Root defaultValue="card">
<Tabs.List>
<Tabs.Trigger
value="card"
onClick={() => setViewMode("card")}
title={t("agents.cardView")}
>
<ViewGridIcon />
</Tabs.Trigger>
<Tabs.Trigger
value="table"
onClick={() => setViewMode("table")}
title={t("agents.tableView")}
>
<LayoutIcon />
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<Button onClick={handleRefresh} disabled={loading}>
<ReloadIcon />
{t("common.refresh")}
</Button>
<Button
onClick={() => {
console.log(
"点击添加客户端按钮(空列表),准备导航到/agents/create"
);
try {
navigate("/agents/create");
} catch (err) {
console.error("导航到添加客户端页面失败:", err);
}
}}
>
<PlusIcon />
{t("agents.create")}
</Button>
</Flex>
</Flex>
<div className="detail-content">
{agents.length === 0 ? (
<Card>
<Flex
direction="column"
align="center"
justify="center"
p="6"
gap="3"
<Container size="4">
<Flex justify="between" align="center">
<Heading size="6">{t("agents.pageTitle")}</Heading>
<Flex gap="3">
<Tabs defaultValue="card">
<TabsList>
<TabsTrigger
value="card"
onClick={() => setViewMode("card")}
title={t("agents.cardView")}
>
<Text>{t("agents.noAgents")}</Text>
<Button onClick={() => navigate("/agents/create")}>
<PlusIcon />
{t("agents.create")}
</Button>
</Flex>
</Card>
) : viewMode === "table" ? (
// 表格视图
renderTableView()
) : (
// 卡片视图
renderCardView()
)}
</div>
</div>
<ViewGridIcon />
</TabsTrigger>
<TabsTrigger
value="table"
onClick={() => setViewMode("table")}
title={t("agents.tableView")}
>
<LayoutIcon />
</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant="secondary"
onClick={handleRefresh}
disabled={loading}
>
<ReloadIcon />
{t("common.refresh")}
</Button>
<Button
variant="secondary"
onClick={() => {
console.log(
"点击添加客户端按钮(空列表),准备导航到/agents/create"
);
try {
navigate("/agents/create");
} catch (err) {
console.error("导航到添加客户端页面失败:", err);
}
}}
>
<PlusIcon />
{t("agents.create")}
</Button>
</Flex>
</Flex>
<Box className="mt-4 space-x-2">
{agents.length === 0 ? (
<Card>
<Flex
direction="column"
align="center"
justify="center"
p="6"
gap="3"
>
<Text>{t("agents.noAgents")}</Text>
<Button onClick={() => navigate("/agents/create")}>
<PlusIcon />
{t("agents.create")}
</Button>
</Flex>
</Card>
) : viewMode === "table" ? (
// 表格视图
renderTableView()
) : (
// 卡片视图
renderCardView()
)}
</Box>
{/* 删除确认对话框 */}
<Dialog.Root open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<Dialog.Content>
<Dialog.Title>{t("common.deleteConfirmation")}</Dialog.Title>
<Dialog.Description>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogTitle>{t("common.deleteConfirmation")}</DialogTitle>
<DialogDescription>
{t("common.deleteConfirmMessage")}
</Dialog.Description>
</DialogDescription>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="soft" color="gray">
<DialogClose asChild>
<Button variant="secondary" color="gray">
{t("common.cancel")}
</Button>
</Dialog.Close>
</DialogClose>
<Button color="red" onClick={handleDeleteConfirm}>
{t("common.delete")}
</Button>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Box>
</DialogContent>
</Dialog>
</Container>
);
};
+95 -171
View File
@@ -1,15 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Flex,
Heading,
Text,
Button,
Card,
Code,
Separator,
} from "@radix-ui/themes";
import { Box, Flex, Heading, Text, Code } from "@radix-ui/themes";
import { Button, Card, Separator } from "@/components/ui";
import {
ArrowLeftIcon,
CopyIcon,
@@ -83,177 +75,109 @@ const CreateAgent = () => {
return (
<Box>
<div className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Button variant="soft" size="1" onClick={() => navigate("/agents")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{t("agent.form.title.create")}</Heading>
</Flex>
<Flex justify="between" align="center">
<Flex align="center" gap="2">
<Button variant="secondary" onClick={() => navigate("/agents")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{t("agent.form.title.create")}</Heading>
</Flex>
</Flex>
<Card>
<Flex direction="column" gap="5">
{/* 提示信息 */}
<Box>
<Flex gap="2">
<InfoCircledIcon />
<Text>{t("agent.add.note")}</Text>
</Flex>
</Box>
<div className="detail-content">
<Card>
<Flex direction="column" gap="5">
{/* 提示信息 */}
<Box
style={{
padding: "10px",
borderRadius: "6px",
backgroundColor: "var(--accent-3)",
color: "var(--accent-11)",
}}
>
<Flex gap="2">
<InfoCircledIcon />
<Text>{t("agent.add.note")}</Text>
</Flex>
</Box>
{/* 服务端地址 */}
<Box>
<Text as="label" size="2" weight="bold">
{t("agent.add.serverAddress")}
</Text>
<Flex gap="2">
<Text className="token-display">{serverUrl}</Text>
<Button variant="secondary" onClick={handleCopyServerUrl}>
{serverUrlCopied ? <CheckIcon /> : <CopyIcon />}
{serverUrlCopied ? t("common.copied") : t("common.copy")}
</Button>
</Flex>
<Text size="1" color="gray">
{t("agent.add.serverAddressHelp")}
</Text>
</Box>
{/* 服务端地址 */}
<Box>
<Text
as="label"
size="2"
weight="bold"
style={{ display: "block", marginBottom: "6px" }}
>
{t("agent.add.serverAddress")}
</Text>
{/* 客户端Token */}
<Box>
<Text as="label" size="2" weight="bold">
{t("agent.add.registrationToken")}
</Text>
{loading ? (
<Text>{t("agent.add.generatingToken")}</Text>
) : (
<>
<Flex gap="2">
<Text
className="token-display"
style={{
padding: "10px",
backgroundColor: "var(--gray-3)",
borderRadius: "4px",
fontFamily: "monospace",
fontSize: "14px",
flex: 1,
overflowX: "auto",
}}
>
{serverUrl}
</Text>
<Button variant="soft" onClick={handleCopyServerUrl}>
{serverUrlCopied ? <CheckIcon /> : <CopyIcon />}
{serverUrlCopied ? t("common.copied") : t("common.copy")}
<Text className="token-display">{token}</Text>
<Button variant="secondary" onClick={handleCopyToken}>
{tokenCopied ? <CheckIcon /> : <CopyIcon />}
{tokenCopied ? t("common.copied") : t("common.copy")}
</Button>
</Flex>
<Text size="1" color="gray" style={{ marginTop: "6px" }}>
{t("agent.add.serverAddressHelp")}
<Text size="1" color="gray">
{t("agent.add.tokenHelp")}
</Text>
</Box>
</>
)}
</Box>
{/* 客户端Token */}
<Box>
<Text
as="label"
size="2"
weight="bold"
style={{ display: "block", marginBottom: "6px" }}
>
{t("agent.add.registrationToken")}
</Text>
{loading ? (
<Text>{t("agent.add.generatingToken")}</Text>
) : (
<>
<Flex gap="2">
<Text
className="token-display"
style={{
padding: "10px",
backgroundColor: "var(--gray-3)",
borderRadius: "4px",
fontFamily: "monospace",
fontSize: "14px",
flex: 1,
overflowX: "auto",
}}
>
{token}
</Text>
<Button variant="soft" onClick={handleCopyToken}>
{tokenCopied ? <CheckIcon /> : <CopyIcon />}
{tokenCopied ? t("common.copied") : t("common.copy")}
</Button>
</Flex>
<Text size="1" color="gray" style={{ marginTop: "6px" }}>
{t("agent.add.tokenHelp")}
</Text>
</>
<Separator />
{/* 安装指南 */}
<Box>
<Flex align="baseline" gap="2" mb="3">
<Heading size="4">{t("agent.add.installGuide")}</Heading>
<Text size="2" color="gray">
{t(
"agent.add.installCommandHelpText",
"使用以下命令自动下载并安装 Agent:"
)}
</Box>
<Separator size="4" />
{/* 安装指南 */}
<Box>
<Flex align="baseline" gap="2" mb="3">
<Heading size="4">{t("agent.add.installGuide")}</Heading>
<Text size="2" color="gray">
{t(
"agent.add.installCommandHelpText",
"使用以下命令自动下载并安装 Agent:"
)}
</Text>
</Flex>
<Card
variant="surface"
style={{ backgroundColor: "var(--gray-2)" }}
>
<Flex direction="column" gap="3" p="3">
<Text as="div" size="2" weight="bold" mb="1">
{t(
"agent.add.oneLinerCommandTitle",
"一键安装命令 (Linux/macOS/Windows):"
)}
</Text>
<Code
size="2"
style={{
display: "block",
padding: "12px",
whiteSpace: "pre-wrap",
lineHeight: "1.5",
wordBreak: "break-all",
}}
>
{oneLinerInstallCommand}
</Code>
<Button
variant="soft"
onClick={handleCopyInstallCommand}
style={{ alignSelf: "flex-end" }}
>
{installCommandCopied ? <CheckIcon /> : <CopyIcon />}
{installCommandCopied
? t("common.copied")
: t("common.copy")}
</Button>
<Text size="1" color="gray" style={{ marginTop: "4px" }}>
{t(
"agent.add.oneLinerCommandNote",
"注意: 此脚本在 Linux 上安装服务时需要 sudo 权限。Windows 用户请参考脚本内的提示手动下载。"
)}
</Text>
</Flex>
</Card>
</Box>
{/* 返回按钮 */}
<Flex justify="end" gap="3">
<Button variant="soft" onClick={() => navigate("/agents")}>
{t("agent.add.returnToList")}
</Button>
</Flex>
</Text>
</Flex>
</Card>
</div>
</div>
<Card>
<Flex direction="column" gap="3" p="3">
<Text as="div" size="2" weight="bold" mb="1">
{t(
"agent.add.oneLinerCommandTitle",
"一键安装命令 (Linux/macOS/Windows):"
)}
</Text>
<Code size="2">{oneLinerInstallCommand}</Code>
<Button variant="secondary" onClick={handleCopyInstallCommand}>
{installCommandCopied ? <CheckIcon /> : <CopyIcon />}
{installCommandCopied ? t("common.copied") : t("common.copy")}
</Button>
<Text size="1" color="gray">
{t(
"agent.add.oneLinerCommandNote",
"注意: 此脚本在 Linux 上安装服务时需要 sudo 权限。Windows 用户请参考脚本内的提示手动下载。"
)}
</Text>
</Flex>
</Card>
</Box>
{/* 返回按钮 */}
<Flex justify="end" gap="3">
<Button variant="secondary" onClick={() => navigate("/agents")}>
{t("agent.add.returnToList")}
</Button>
</Flex>
</Flex>
</Card>
</Box>
);
};
+52 -100
View File
@@ -1,27 +1,17 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Flex,
Heading,
Text,
Button,
Card,
TextField,
} from "@radix-ui/themes";
import { ArrowLeftIcon, Cross2Icon } from "@radix-ui/react-icons";
import * as Toast from "@radix-ui/react-toast";
import { Box, Flex, Heading, Text, TextField } from "@radix-ui/themes";
import { Button, Card } from "@/components/ui";
import { ArrowLeftIcon } from "@radix-ui/react-icons";
import { getAgent, updateAgent } from "../../api/agents";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
const EditAgent = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(false);
const [notFound, setNotFound] = useState(false);
const [toastOpen, setToastOpen] = useState(false);
const [toastMessage, setToastMessage] = useState("");
const [toastType, setToastType] = useState<"success" | "error">("success");
const [formData, setFormData] = useState<{
name: string;
status: "active" | "inactive" | "connecting" | "unknown";
@@ -78,14 +68,14 @@ const EditAgent = () => {
const response = await updateAgent(Number(id), payload);
if (response.success) {
setToastMessage(t("agent.form.updateSuccess"));
setToastType("success");
setToastOpen(true);
toast.success(t("agent.form.updateSuccess"));
// 短暂延迟后导航,让用户有时间看到提示
setTimeout(() => {
navigate("/agents");
}, 1500);
} else {
toast.error(t("agent.form.updateError", "Failed to update agent."));
}
setLoading(false);
};
@@ -93,7 +83,7 @@ const EditAgent = () => {
if (notFound) {
return (
<Box>
<Flex justify="center" align="center" style={{ minHeight: "60vh" }}>
<Flex justify="center" align="center">
<Card>
<Flex direction="column" align="center" gap="4" p="4">
<Heading size="6">{t("agents.notFound")}</Heading>
@@ -110,90 +100,52 @@ const EditAgent = () => {
return (
<Box>
<div className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Button
variant="soft"
size="1"
onClick={() => navigate(`/agents/${id}`)}
>
<ArrowLeftIcon />
</Button>
<Heading size="6">
{t("agent.form.editingClient", { name: formData.name })}
</Heading>
</Flex>
<Flex justify="between" align="center">
<Flex align="center" gap="2">
<Button variant="secondary" onClick={() => navigate(`/agents/${id}`)}>
<ArrowLeftIcon />
</Button>
<Heading size="6">
{t("agent.form.editingClient", { name: formData.name })}
</Heading>
</Flex>
</Flex>
<Card>
<form onSubmit={handleSubmit}>
<Flex direction="column" gap="4">
<Box mb="4">
<Text as="label" size="2" mb="1" weight="bold">
{t("agent.form.name")}{" "}
<Text size="2" color="red">
*
</Text>
</Text>
<TextField.Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t("agent.form.namePlaceholder")}
required
/>
<Text size="1" color="gray" mt="1">
{t("agent.form.nameHelp")}
</Text>
</Box>
<div className="detail-content">
<Card>
<form onSubmit={handleSubmit}>
<Flex direction="column" gap="4">
<Box mb="4">
<Text as="label" size="2" mb="1" weight="bold">
{t("agent.form.name")}{" "}
<Text size="2" color="red">
*
</Text>
</Text>
<TextField.Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t("agent.form.namePlaceholder")}
required
/>
<Text size="1" color="gray" mt="1">
{t("agent.form.nameHelp")}
</Text>
</Box>
<Flex justify="end" mt="4" gap="2">
<Button
variant="soft"
onClick={() => navigate(`/agents/${id}`)}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{loading
? t("common.savingChanges")
: t("common.saveChanges")}
</Button>
</Flex>
</Flex>
</form>
</Card>
</div>
<Toast.Provider swipeDirection="right">
<Toast.Root
className="ToastRoot"
open={toastOpen}
onOpenChange={setToastOpen}
duration={2000}
style={{
backgroundColor:
toastType === "success" ? "var(--green-9)" : "var(--red-9)",
borderRadius: "8px",
}}
>
<Toast.Title className="ToastTitle">
{toastType === "success"
? t("common.success")
: t("common.error")}
</Toast.Title>
<Toast.Description className="ToastDescription">
{toastMessage}
</Toast.Description>
<Toast.Close className="ToastClose">
<Cross2Icon />
</Toast.Close>
</Toast.Root>
<Toast.Viewport className="ToastViewport" />
</Toast.Provider>
</div>
<Flex justify="end" mt="4" gap="2">
<Button
variant="secondary"
onClick={() => navigate(`/agents/${id}`)}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t("common.savingChanges") : t("common.saveChanges")}
</Button>
</Flex>
</Flex>
</form>
</Card>
</Box>
);
};
+17 -21
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Button, Card, Flex, Heading, Text } from "@radix-ui/themes";
import { Flex, Heading, Text } from "@radix-ui/themes";
import { Button, Card, Input } from "@/components/ui";
import { useAuth } from "../../providers/AuthProvider";
import { useTranslation } from "react-i18next";
@@ -69,26 +70,21 @@ const Login = () => {
<form onSubmit={handleSubmit}>
<Flex direction="column" gap="3">
<div className="input-wrapper">
<input
placeholder={t("login.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="text-input"
/>
</div>
<div className="input-wrapper">
<input
placeholder={t("login.password")}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="text-input"
/>
</div>
<Input
placeholder={t("login.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="text-gray-900"
/>
<Input
placeholder={t("login.password")}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="text-gray-900"
/>
<Button type="submit" disabled={isLoading}>
{isLoading ? t("common.loading") : t("login.button")}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { Button, Card, Flex, Heading, Text } from "@radix-ui/themes";
import { Flex, Heading, Text } from "@radix-ui/themes";
import { Button, Card } from "@/components/ui";
import { useAuth } from "../../providers/AuthProvider";
import { useTranslation } from "react-i18next";
+63 -98
View File
@@ -5,14 +5,23 @@ import {
Flex,
Heading,
Text,
Button,
Card,
TextField,
Select,
TextArea,
Table,
IconButton,
} from "@radix-ui/themes";
import {
Button,
Card,
Textarea,
Select,
SelectContent,
SelectTrigger,
SelectItem,
Table,
TableCell,
TableRow,
TableBody,
TableHeader,
} from "@/components/ui";
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@radix-ui/react-icons";
import { createMonitor } from "../../api/monitors";
import StatusCodeSelect from "../../components/StatusCodeSelect";
@@ -146,11 +155,7 @@ const CreateMonitor = () => {
<div className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Button
variant="soft"
size="1"
onClick={() => navigate("/monitors")}
>
<Button variant="secondary" onClick={() => navigate("/monitors")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{t("monitor.form.title.create")}</Heading>
@@ -163,11 +168,7 @@ const CreateMonitor = () => {
<Box pt="2">
<Flex direction="column" gap="4">
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.name")} *
</Text>
<TextField.Input
@@ -180,11 +181,7 @@ const CreateMonitor = () => {
</Box>
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
URL *
</Text>
<TextField.Input
@@ -197,38 +194,30 @@ const CreateMonitor = () => {
</Box>
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.method")} *
</Text>
<Select.Root
<Select
name="method"
value={formData.method}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, method: value }))
}
>
<Select.Trigger />
<Select.Content>
<Select.Item value="GET">GET</Select.Item>
<Select.Item value="POST">POST</Select.Item>
<Select.Item value="PUT">PUT</Select.Item>
<Select.Item value="DELETE">DELETE</Select.Item>
<Select.Item value="HEAD">HEAD</Select.Item>
</Select.Content>
</Select.Root>
<SelectTrigger />
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
</Box>
<Flex gap="4">
<Box style={{ flex: 1 }}>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Box>
<Text as="label" size="2">
{t("monitor.form.interval")} *
</Text>
<TextField.Input
@@ -244,12 +233,8 @@ const CreateMonitor = () => {
</Text>
</Box>
<Box style={{ flex: 1 }}>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Box>
<Text as="label" size="2">
{t("monitor.form.timeout")} *
</Text>
<TextField.Input
@@ -264,11 +249,7 @@ const CreateMonitor = () => {
</Flex>
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.expectedStatus")} *
</Text>
<StatusCodeSelect
@@ -279,39 +260,26 @@ const CreateMonitor = () => {
</Box>
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.headers")}
</Text>
<Box
style={{
border: "1px solid var(--gray-6)",
borderRadius: "6px",
padding: "8px",
marginBottom: "8px",
}}
>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>
<Box>
<Table>
<TableHeader>
<TableRow>
<TableCell>
{t("monitor.form.headerName")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
</TableCell>
<TableCell>
{t("monitor.form.headerValue")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell
style={{ width: "40px" }}
></Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHeader>
<TableBody>
{headers.map((header, index) => (
<Table.Row key={index}>
<Table.Cell>
<TableRow key={index}>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerNamePlaceholder"
@@ -325,8 +293,8 @@ const CreateMonitor = () => {
)
}
/>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerValuePlaceholder"
@@ -340,8 +308,8 @@ const CreateMonitor = () => {
)
}
/>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<IconButton
variant="soft"
color="red"
@@ -350,15 +318,14 @@ const CreateMonitor = () => {
>
<TrashIcon />
</IconButton>
</Table.Cell>
</Table.Row>
</TableCell>
</TableRow>
))}
</Table.Body>
</Table.Root>
</TableBody>
</Table>
<Flex justify="end" mt="2">
<Button
size="1"
variant="soft"
variant="secondary"
onClick={() =>
setHeaders([...headers, { key: "", value: "" }])
}
@@ -375,19 +342,14 @@ const CreateMonitor = () => {
{showBodyField && (
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.body")}
</Text>
<TextArea
<Textarea
name="body"
value={formData.body}
onChange={handleChange}
placeholder={t("monitor.form.bodyPlaceholder")}
style={{ minHeight: "100px" }}
/>
</Box>
)}
@@ -395,7 +357,10 @@ const CreateMonitor = () => {
</Box>
<Flex justify="end" mt="4" gap="2">
<Button variant="soft" onClick={() => navigate("/monitors")}>
<Button
variant="secondary"
onClick={() => navigate("/monitors")}
>
{t("monitor.form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
+64 -98
View File
@@ -5,14 +5,25 @@ import {
Flex,
Heading,
Text,
TextField,
IconButton,
Container,
} from "@radix-ui/themes";
import {
Button,
Card,
TextField,
Select,
SelectContent,
SelectItem,
SelectTrigger,
Table,
IconButton,
TextArea,
} from "@radix-ui/themes";
TableHeader,
TableBody,
TableRow,
TableCell,
Textarea,
} from "@/components/ui";
import {
ArrowLeftIcon,
UpdateIcon,
@@ -217,7 +228,7 @@ const EditMonitor = () => {
if (loadingData) {
return (
<Box>
<Flex justify="center" align="center" style={{ minHeight: "60vh" }}>
<Flex justify="center" align="center">
<Text>{t("common.loading")}</Text>
</Flex>
</Box>
@@ -227,7 +238,7 @@ const EditMonitor = () => {
if (error) {
return (
<Box>
<Flex justify="center" align="center" style={{ minHeight: "60vh" }}>
<Flex justify="center" align="center">
<Card>
<Flex direction="column" align="center" gap="4" p="4">
<Heading size="6">{t("monitor.notExist")}</Heading>
@@ -243,13 +254,12 @@ const EditMonitor = () => {
}
return (
<Box>
<Container size="4">
<div className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Button
variant="soft"
size="1"
variant="secondary"
onClick={() => navigate(`/monitors/${id}`)}
>
<ArrowLeftIcon />
@@ -266,11 +276,7 @@ const EditMonitor = () => {
<Box pt="2">
<Flex direction="column" gap="4">
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.name")} *
</Text>
<TextField.Input
@@ -283,11 +289,7 @@ const EditMonitor = () => {
</Box>
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
URL *
</Text>
<TextField.Input
@@ -300,38 +302,30 @@ const EditMonitor = () => {
</Box>
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.method")} *
</Text>
<Select.Root
<Select
name="method"
value={formData.method}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, method: value }))
}
>
<Select.Trigger />
<Select.Content>
<Select.Item value="GET">GET</Select.Item>
<Select.Item value="POST">POST</Select.Item>
<Select.Item value="PUT">PUT</Select.Item>
<Select.Item value="DELETE">DELETE</Select.Item>
<Select.Item value="HEAD">HEAD</Select.Item>
</Select.Content>
</Select.Root>
<SelectTrigger />
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
</Box>
<Flex gap="4">
<Box style={{ flex: 1 }}>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Box>
<Text as="label" size="2">
{t("monitor.form.interval")} *
</Text>
<TextField.Input
@@ -347,12 +341,8 @@ const EditMonitor = () => {
</Text>
</Box>
<Box style={{ flex: 1 }}>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Box>
<Text as="label" size="2">
{t("monitor.form.timeout")} *
</Text>
<TextField.Input
@@ -367,11 +357,7 @@ const EditMonitor = () => {
</Flex>
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.expectedStatus")} *
</Text>
<StatusCodeSelect
@@ -382,39 +368,25 @@ const EditMonitor = () => {
</Box>
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.headers")}
</Text>
<Box
style={{
border: "1px solid var(--gray-6)",
borderRadius: "6px",
padding: "8px",
marginBottom: "8px",
}}
>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>
<Box>
<Table>
<TableHeader>
<TableRow>
<TableCell>
{t("monitor.form.headerName")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
</TableCell>
<TableCell>
{t("monitor.form.headerValue")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell
style={{ width: "40px" }}
></Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{headers.map((header, index) => (
<Table.Row key={index}>
<Table.Cell>
<TableRow key={index}>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerNamePlaceholder"
@@ -428,8 +400,8 @@ const EditMonitor = () => {
)
}
/>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerValuePlaceholder"
@@ -443,8 +415,8 @@ const EditMonitor = () => {
)
}
/>
</Table.Cell>
<Table.Cell>
</TableCell>
<TableCell>
<IconButton
variant="soft"
color="red"
@@ -453,15 +425,14 @@ const EditMonitor = () => {
>
<TrashIcon />
</IconButton>
</Table.Cell>
</Table.Row>
</TableCell>
</TableRow>
))}
</Table.Body>
</Table.Root>
</TableBody>
</Table>
<Flex justify="end" mt="2">
<Button
size="1"
variant="soft"
variant="secondary"
onClick={() =>
setHeaders([...headers, { key: "", value: "" }])
}
@@ -478,19 +449,14 @@ const EditMonitor = () => {
{showBodyField && (
<Box>
<Text
as="label"
size="2"
style={{ marginBottom: "4px", display: "block" }}
>
<Text as="label" size="2">
{t("monitor.form.body")}
</Text>
<TextArea
<Textarea
name="body"
value={formData.body}
onChange={handleChange}
placeholder={t("monitor.form.bodyPlaceholder")}
style={{ minHeight: "100px" }}
/>
</Box>
)}
@@ -499,7 +465,7 @@ const EditMonitor = () => {
<Flex justify="end" mt="4" gap="2">
<Button
variant="soft"
variant="secondary"
onClick={() => navigate(`/monitors/${id}`)}
>
{t("monitor.form.cancel")}
@@ -515,7 +481,7 @@ const EditMonitor = () => {
</Card>
</div>
</div>
</Box>
</Container>
);
};
+20 -69
View File
@@ -1,23 +1,14 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Flex,
Heading,
Text,
Button,
Card,
Grid,
Badge,
} from "@radix-ui/themes";
import { Box, Flex, Heading, Text, Grid,Container } from "@radix-ui/themes";
import { Button, Card, Badge } from "@/components/ui";
import {
ArrowLeftIcon,
Pencil1Icon,
TrashIcon,
ReloadIcon,
Cross2Icon,
} from "@radix-ui/react-icons";
import * as Toast from "@radix-ui/react-toast";
import { toast } from "sonner";
import {
getMonitor,
deleteMonitor,
@@ -49,9 +40,6 @@ const MonitorDetail = () => {
useState<MonitorWithDailyStatsAndStatusHistory | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toastOpen, setToastOpen] = useState(false);
const [toastMessage, setToastMessage] = useState("");
const [toastType, setToastType] = useState<"success" | "error">("success");
const { t } = useTranslation();
// 组件加载时获取数据
@@ -102,13 +90,11 @@ const MonitorDetail = () => {
setLoading(true);
const response = await checkMonitor(parseInt(id));
if (response.success) {
setToastMessage(t("monitor.checkCompleted"));
setToastType("success");
setToastOpen(true);
toast.success(t("monitor.checkCompleted"));
} else {
setToastMessage(t("monitor.checkFailed"));
setToastType("error");
setToastOpen(true);
toast.error(t("monitor.checkFailed"));
}
setLoading(false);
};
@@ -119,18 +105,16 @@ const MonitorDetail = () => {
const response = await deleteMonitor(parseInt(id));
if (response.success) {
setToastMessage(t("monitor.deleteSuccess"));
setToastType("success");
setToastOpen(true);
toast.success(t("monitor.deleteSuccess")); // Added
// 短暂延迟后导航,让用户有时间看到提示
setTimeout(() => {
navigate("/monitors");
}, 1500);
} else {
setToastMessage(t("monitor.deleteFailed"));
setToastType("error");
setToastOpen(true);
toast.error(t("monitor.deleteFailed")); // Added
}
};
@@ -157,7 +141,7 @@ const MonitorDetail = () => {
<Text style={{ color: "var(--red-9)" }}>
{error || t("monitor.notExist")}
</Text>
<Button variant="soft" onClick={() => navigate("/monitors")} mt="2">
<Button variant="secondary" onClick={() => navigate("/monitors")}>
{t("monitor.returnToList")}
</Button>
</Box>
@@ -165,15 +149,10 @@ const MonitorDetail = () => {
}
return (
<Box className="monitor-detail" p="4">
<div>
<Container size="4">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Button
variant="soft"
size="1"
onClick={() => navigate("/monitors")}
>
<Button variant="secondary" onClick={() => navigate("/monitors")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{monitor.name}</Heading>
@@ -186,18 +165,18 @@ const MonitorDetail = () => {
</Badge>
</Flex>
<Flex gap="2">
<Button variant="soft" onClick={handleCheck}>
<Button variant="secondary" onClick={handleCheck}>
<ReloadIcon />
{t("monitor.manualCheck")}
</Button>
<Button
variant="soft"
variant="secondary"
onClick={() => navigate(`/monitors/edit/${id}`)}
>
<Pencil1Icon />
{t("monitor.edit")}
</Button>
<Button variant="soft" color="red" onClick={handleDelete}>
<Button variant="secondary" onClick={handleDelete}>
<TrashIcon />
{t("monitor.delete")}
</Button>
@@ -238,7 +217,7 @@ const MonitorDetail = () => {
</Flex>
</Card>
{/* 添加响应时间图表 */}
<Card mt="4">
<Card>
<Flex direction="column" gap="3" mt="4">
<Heading size="4">{t("monitor.oneDayHistory")}</Heading>
<Box>
@@ -249,7 +228,7 @@ const MonitorDetail = () => {
</Box>
</Flex>
</Card>
<Card mt="4">
<Card>
<Flex direction="column" gap="3">
<Heading size="4">{t("monitor.MonthsHistory")}</Heading>
<Box>
@@ -258,35 +237,7 @@ const MonitorDetail = () => {
</Flex>
</Card>
</Box>
<Toast.Provider swipeDirection="right">
<Toast.Root
className="ToastRoot"
open={toastOpen}
onOpenChange={setToastOpen}
duration={3000}
style={{
backgroundColor:
toastType === "success" ? "var(--green-9)" : "var(--red-9)",
borderRadius: "8px",
zIndex: 9999,
}}
>
<Toast.Title className="ToastTitle">
{toastType === "success"
? t("common.success")
: t("common.error")}
</Toast.Title>
<Toast.Description className="ToastDescription">
{toastMessage}
</Toast.Description>
<Toast.Close className="ToastClose">
<Cross2Icon />
</Toast.Close>
</Toast.Root>
<Toast.Viewport className="ToastViewport" />
</Toast.Provider>
</div>
</Box>
</Container>
);
};
+188 -198
View File
@@ -5,15 +5,29 @@ import {
Flex,
Heading,
Text,
IconButton,
Grid,
Container,
} from "@radix-ui/themes";
import {
Button,
Table,
Badge,
Card,
IconButton,
Grid,
Tabs,
Dialog,
} from "@radix-ui/themes";
TableBody,
TableCell,
TableRow,
TableHeader,
TabsList,
TabsTrigger,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui";
import {
PlusIcon,
Pencil1Icon,
@@ -160,7 +174,7 @@ const MonitorsList = () => {
// 加载中显示
if (loading) {
return (
<Box className="page-container detail-page">
<Box>
<Flex justify="center" align="center" p="4">
<Text>{t("common.loading")}</Text>
</Flex>
@@ -171,13 +185,13 @@ const MonitorsList = () => {
// 错误显示
if (error) {
return (
<Box className="page-container detail-page">
<Card mb="4">
<Flex p="3" style={{ backgroundColor: "var(--red-3)" }}>
<Text style={{ color: "var(--red-9)" }}>{error}</Text>
<Box>
<Card>
<Flex>
<Text>{error}</Text>
</Flex>
</Card>
<Button variant="soft" onClick={() => window.location.reload()} mt="2">
<Button variant="secondary" onClick={() => window.location.reload()}>
{t("monitors.retry")}
</Button>
</Box>
@@ -185,205 +199,181 @@ const MonitorsList = () => {
}
return (
<Box>
<div className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Heading size="6">{t("monitors.pageTitle")}</Heading>
<Flex gap="3">
<Tabs.Root defaultValue="grid">
<Tabs.List>
<Tabs.Trigger value="grid" onClick={() => setView("grid")}>
<ViewGridIcon />
</Tabs.Trigger>
<Tabs.Trigger value="list" onClick={() => setView("list")}>
<LayoutIcon />
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<Button onClick={handleRefresh} disabled={loading}>
<ReloadIcon />
{t("monitors.refresh")}
</Button>
<Button onClick={() => navigate("/monitors/create")}>
<PlusIcon />
{t("monitors.create")}
</Button>
</Flex>
<Container size="4">
<Flex justify="between" align="center">
<Heading size="6">{t("monitors.pageTitle")}</Heading>
<Flex className="mt-4 space-x-2">
<Tabs defaultValue="grid">
<TabsList>
<TabsTrigger value="grid" onClick={() => setView("grid")}>
<ViewGridIcon />
</TabsTrigger>
<TabsTrigger value="list" onClick={() => setView("list")}>
<LayoutIcon />
</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant="secondary"
onClick={handleRefresh}
disabled={loading}
>
<ReloadIcon />
{t("monitors.refresh")}
</Button>
<Button
variant="secondary"
onClick={() => navigate("/monitors/create")}
>
<PlusIcon />
{t("monitors.create")}
</Button>
</Flex>
</Flex>
<div className="detail-content">
{monitors.length === 0 ? (
<Card>
<Flex
direction="column"
align="center"
justify="center"
p="6"
gap="3"
>
<Text>{t("monitors.notFound")}</Text>
<Button onClick={() => navigate("/monitors/create")}>
<PlusIcon />
{t("monitors.addOne")}
</Button>
</Flex>
</Card>
) : view === "list" ? (
// 列表视图
<Table.Root variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>
{t("monitors.table.name")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("monitors.table.url")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("monitors.table.status")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("monitors.table.responseTime")}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
{t("monitors.table.actions")}
</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Container className="mt-4 space-x-2">
{monitors.length === 0 ? (
<Card>
<Flex
direction="column"
align="center"
justify="center"
p="6"
gap="3"
>
<Text>{t("monitors.notFound")}</Text>
<Button onClick={() => navigate("/monitors/create")}>
<PlusIcon />
{t("monitors.addOne")}
</Button>
</Flex>
</Card>
) : view === "list" ? (
// 列表视图
<Table>
<TableHeader>
<TableRow>
<TableCell>{t("monitors.table.name")}</TableCell>
<TableCell>{t("monitors.table.url")}</TableCell>
<TableCell>{t("monitors.table.status")}</TableCell>
<TableCell>{t("monitors.table.responseTime")}</TableCell>
<TableCell>{t("monitors.table.actions")}</TableCell>
</TableRow>
</TableHeader>
<Table.Body>
{monitors.map((monitor) => (
<Table.Row key={`${monitor.id}-${Math.random()}`}>
<Table.Cell>
<Text weight="medium">{monitor.name}</Text>
</Table.Cell>
<Table.Cell>
<Text style={{ wordBreak: "break-all" }}>
{monitor.url}
</Text>
</Table.Cell>
<Table.Cell>
<Flex align="center" gap="2">
<StatusIcon status={monitor.status} />
<Badge color={statusColors[monitor.status]}>
{monitor.status === "up"
? t("monitors.status.up")
: monitor.status === "down"
? t("monitors.status.down")
: t("monitor.status.pending")}
</Badge>
</Flex>
</Table.Cell>
<Table.Cell>
<Text>
{monitor.response_time
? `${monitor.response_time}ms`
: "-"}
</Text>
</Table.Cell>
<Table.Cell>
<Flex gap="2">
<IconButton
variant="soft"
onClick={() => navigate(`/monitors/${monitor.id}`)}
title={t("monitors.viewDetails")}
>
<InfoCircledIcon />
</IconButton>
<IconButton
variant="soft"
onClick={() =>
navigate(`/monitors/edit/${monitor.id}`)
}
title={t("monitors.edit")}
>
<Pencil1Icon />
</IconButton>
<IconButton
variant="soft"
color="red"
onClick={() => handleDeleteClick(monitor.id)}
title={t("monitors.delete")}
>
<TrashIcon />
</IconButton>
</Flex>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
) : (
// 网格视图 - 使用 MonitorCard 组件
<Grid columns={{ initial: "1" }} gap="4">
<TableBody>
{monitors.map((monitor) => (
<Box
key={`${monitor.id}-${Math.random()}`}
style={{ position: "relative" }}
>
<MonitorCard monitor={monitor} />
<Flex
style={{
position: "absolute",
top: "10px",
right: "10px",
zIndex: 1,
}}
gap="2"
>
<IconButton
variant="ghost"
size="1"
onClick={() => navigate(`/monitors/${monitor.id}`)}
title={t("monitors.viewDetails")}
>
<InfoCircledIcon />
</IconButton>
<IconButton
variant="ghost"
size="1"
onClick={() => navigate(`/monitors/edit/${monitor.id}`)}
title={t("monitors.edit")}
>
<Pencil1Icon />
</IconButton>
<IconButton
variant="ghost"
size="1"
color="red"
onClick={() => handleDeleteClick(monitor.id)}
title={t("monitors.delete")}
>
<TrashIcon />
</IconButton>
</Flex>
</Box>
<TableRow key={`${monitor.id}-${Math.random()}`}>
<TableCell>
<Text weight="medium">{monitor.name}</Text>
</TableCell>
<TableCell>
<Text>{monitor.url}</Text>
</TableCell>
<TableCell>
<Flex align="center" gap="2">
<StatusIcon status={monitor.status} />
<Badge color={statusColors[monitor.status]}>
{monitor.status === "up"
? t("monitors.status.up")
: monitor.status === "down"
? t("monitors.status.down")
: t("monitor.status.pending")}
</Badge>
</Flex>
</TableCell>
<TableCell>
<Text>
{monitor.response_time
? `${monitor.response_time}ms`
: "-"}
</Text>
</TableCell>
<TableCell>
<Flex gap="2">
<IconButton
variant="soft"
onClick={() => navigate(`/monitors/${monitor.id}`)}
title={t("monitors.viewDetails")}
>
<InfoCircledIcon />
</IconButton>
<IconButton
variant="soft"
onClick={() => navigate(`/monitors/edit/${monitor.id}`)}
title={t("monitors.edit")}
>
<Pencil1Icon />
</IconButton>
<IconButton
variant="soft"
color="red"
onClick={() => handleDeleteClick(monitor.id)}
title={t("monitors.delete")}
>
<TrashIcon />
</IconButton>
</Flex>
</TableCell>
</TableRow>
))}
</Grid>
)}
</div>
</div>
</TableBody>
</Table>
) : (
// 网格视图 - 使用 MonitorCard 组件
<Grid columns={{ initial: "1" }} gap="4">
{monitors.map((monitor) => (
<Box key={`${monitor.id}-${Math.random()}`} className="relative">
<MonitorCard monitor={monitor} />
<Flex gap="2" className="absolute top-4 right-4">
<IconButton
variant="ghost"
size="1"
onClick={() => navigate(`/monitors/${monitor.id}`)}
title={t("monitors.viewDetails")}
>
<InfoCircledIcon />
</IconButton>
<IconButton
variant="ghost"
size="1"
onClick={() => navigate(`/monitors/edit/${monitor.id}`)}
title={t("monitors.edit")}
>
<Pencil1Icon />
</IconButton>
<IconButton
variant="ghost"
size="1"
color="red"
onClick={() => handleDeleteClick(monitor.id)}
title={t("monitors.delete")}
>
<TrashIcon />
</IconButton>
</Flex>
</Box>
))}
</Grid>
)}
</Container>
{/* 删除确认对话框 */}
<Dialog.Root open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<Dialog.Content>
<Dialog.Title>{t("common.deleteConfirmation")}</Dialog.Title>
<Dialog.Description>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogTitle>{t("common.deleteConfirmation")}</DialogTitle>
<DialogDescription>
{t("common.deleteConfirmMessage")}
</Dialog.Description>
</DialogDescription>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="soft" color="gray">
{t("common.cancel")}
</Button>
</Dialog.Close>
<Button color="red" onClick={handleDeleteConfirm}>
{t("common.delete")}
</Button>
<DialogClose>
<Button variant="secondary">{t("common.cancel")}</Button>
</DialogClose>
<Button onClick={handleDeleteConfirm}>{t("common.delete")}</Button>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Box>
</DialogContent>
</Dialog>
</Container>
);
};
File diff suppressed because it is too large. Load diff
+8 -9
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { Box, Flex, Heading, Text, Grid, Badge, Theme } from "@radix-ui/themes";
import { Box, Flex, Heading, Text, Grid, Theme } from "@radix-ui/themes";
import { Badge } from "@/components/ui";
import { getStatusPageData } from "../../api/status";
import AgentCard from "../../components/AgentCard";
import MonitorCard from "../../components/MonitorCard";
@@ -74,10 +75,8 @@ const StatusPage = () => {
<Theme appearance="light">
<Box>
<div className="page-container">
<Flex justify="center" align="center" style={{ minHeight: "50vh" }}>
<Text size="3" style={{ color: "var(--red-9)" }}>
{error}
</Text>
<Flex justify="center" align="center">
<Text size="3">{error}</Text>
</Flex>
</div>
</Box>
@@ -90,7 +89,7 @@ const StatusPage = () => {
<Theme appearance="light">
<Box>
<div className="page-container">
<Flex justify="center" align="center" style={{ minHeight: "50vh" }}>
<Flex justify="center" align="center">
<Text size="3">{t("common.loading")}</Text>
</Flex>
</div>
@@ -114,11 +113,11 @@ const StatusPage = () => {
<Heading size="9" align="center">
{pageTitle}
</Heading>
<Text size="5" align="center" style={{ maxWidth: "800px" }}>
<Text size="5" align="center">
{pageDescription}
</Text>
<Flex gap="2" mt="2">
<Badge size="2">
<Badge>
{t("statusPage.lastUpdated")}: {t("statusPage.justNow")}
</Badge>
</Flex>
@@ -146,7 +145,7 @@ const StatusPage = () => {
</Heading>
<Grid columns={{ initial: "1" }} gap="4">
{data.agents.map((agent) => (
<Box key={agent.id} style={{ position: "relative" }}>
<Box key={agent.id}>
<AgentCard agent={agent} showIpAddress={false} />
</Box>
))}
+249 -476
View File
@@ -4,21 +4,23 @@ import {
Flex,
Heading,
Text,
Card,
Button,
TextField,
TextArea,
Tabs,
Container,
} from "@radix-ui/themes";
import { EyeOpenIcon, CopyIcon, CheckIcon } from "@radix-ui/react-icons";
import * as Toast from "@radix-ui/react-toast";
import {
getStatusPageConfig,
saveStatusPageConfig,
} from "../../api/status";
Card,
Button,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
Checkbox,
} from "@/components/ui";
import { EyeOpenIcon, CopyIcon, CheckIcon } from "@radix-ui/react-icons";
import { toast } from "sonner";
import { getStatusPageConfig, saveStatusPageConfig } from "../../api/status";
import { StatusPageConfig as StatusConfig } from "../../types/status";
import "../../styles/components.css";
import { useTranslation } from "react-i18next";
// 监控项带选择状态
@@ -49,9 +51,6 @@ interface StatusConfigWithDetails {
const StatusPageConfig = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showSuccessToast, setShowSuccessToast] = useState(false);
const [showErrorToast, setShowErrorToast] = useState(false);
const [toastMessage, setToastMessage] = useState("");
const [copied, setCopied] = useState(false);
const { t } = useTranslation();
@@ -161,13 +160,9 @@ const StatusPageConfig = () => {
const response = await saveStatusPageConfig(configToSave);
if (response) {
setToastMessage(t("statusPageConfig.configSaved"));
setShowSuccessToast(true);
setTimeout(() => setShowSuccessToast(false), 3000);
toast.success(t("statusPageConfig.configSaved"));
} else {
setToastMessage(response || t("statusPageConfig.saveError"));
setShowErrorToast(true);
setTimeout(() => setShowErrorToast(false), 3000);
toast.error(t("statusPageConfig.saveError"));
}
setSaving(false);
};
@@ -191,471 +186,249 @@ const StatusPageConfig = () => {
}
return (
<Box>
<Container>
<div className="page-container detail-page">
{/* 美化顶部导航栏 */}
<Box mb="5">
<Flex
justify="between"
align="center"
className="detail-header"
py="3"
<Container>
<Box>
<Flex justify="between" align="center">
<Flex align="center">
<Heading size="5" weight="medium">
{t("statusPageConfig.title")}
</Heading>
</Flex>
<Flex align="center">
<Button
variant="secondary"
className="mr-2"
onClick={handlePreview}
>
<Flex align="center" gap="2">
<Heading size="5" weight="medium">
{t("statusPageConfig.title")}
</Heading>
</Flex>
<Flex gap="3" align="center">
<Button
variant="ghost"
size="2"
className="nav-button config-button"
onClick={handlePreview}
>
<EyeOpenIcon width="16" height="16" />
<Text size="2">{t("statusPageConfig.preview")}</Text>
</Button>
<Button
variant="soft"
size="2"
className="nav-button config-button"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<>
<span
style={{
width: "16px",
height: "16px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
<EyeOpenIcon width="8" height="8" />
<Text size="2">{t("statusPageConfig.preview")}</Text>
</Button>
<Button variant="secondary" onClick={handleSave} disabled={saving}>
{saving ? (
<>
<span
style={{
width: "16px",
height: "16px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z"
fill="currentColor"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z"
fill="currentColor"
>
<animateTransform
attributeName="transform"
type="rotate"
dur="0.75s"
values="0 12 12;360 12 12"
repeatCount="indefinite"
/>
</path>
</svg>
</span>
<Text size="2">{t("common.savingChanges")}</Text>
</>
) : (
<>
<CheckIcon width="16" height="16" />
<Text size="2">{t("statusPageConfig.save")}</Text>
</>
)}
</Button>
</Flex>
</Flex>
</Box>
<div className="detail-content">
<Card size="2">
<Tabs.Root defaultValue="general" className="config-tabs">
<Tabs.List className="config-tabs-list">
<Tabs.Trigger value="general" className="tab-trigger">
{t("statusPageConfig.general")}
</Tabs.Trigger>
<Tabs.Trigger value="services" className="tab-trigger">
{t("statusPageConfig.services")}
</Tabs.Trigger>
<Tabs.Trigger value="agents" className="tab-trigger">
{t("statusPageConfig.agents")}
</Tabs.Trigger>
</Tabs.List>
<Box pt="5" px="2" className="tab-content-container">
<Tabs.Content value="general" className="tab-content">
<Flex direction="column" gap="5">
<Box>
<Text
as="label"
size="2"
weight="medium"
style={{ marginBottom: "6px", display: "block" }}
>
{t("statusPageConfig.pageTitle")}
</Text>
<TextField.Input
name="title"
value={config.title}
onChange={handleChange}
placeholder={t(
"statusPageConfig.pageTitlePlaceholder"
)}
size="3"
<animateTransform
attributeName="transform"
type="rotate"
dur="0.75s"
values="0 12 12;360 12 12"
repeatCount="indefinite"
/>
</Box>
</path>
</svg>
</span>
<Text size="2">{t("common.savingChanges")}</Text>
</>
) : (
<>
<CheckIcon width="8" height="8" />
<Text size="2">{t("statusPageConfig.save")}</Text>
</>
)}
</Button>
</Flex>
</Flex>
</Box>
<Card className="mt-4">
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">
{t("statusPageConfig.general")}
</TabsTrigger>
<TabsTrigger value="services">
{t("statusPageConfig.services")}
</TabsTrigger>
<TabsTrigger value="agents">
{t("statusPageConfig.agents")}
</TabsTrigger>
</TabsList>
<Box>
<Text
as="label"
size="2"
weight="medium"
style={{ marginBottom: "6px", display: "block" }}
>
{t("statusPageConfig.pageDescription")}
</Text>
<TextArea
name="description"
value={config.description}
onChange={handleChange}
placeholder={t(
"statusPageConfig.pageDescriptionPlaceholder"
)}
style={{ minHeight: "80px" }}
size="3"
/>
</Box>
<Box>
<Text
as="label"
size="2"
weight="medium"
style={{ marginBottom: "6px", display: "block" }}
>
{t("statusPageConfig.publicUrl")}
</Text>
<Flex gap="2">
<Box style={{ flex: 1, position: "relative" }}>
<TextField.Input
value={config.publicUrl}
readOnly
style={{
width: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
paddingRight: "16px",
}}
size="3"
/>
</Box>
<Button
variant="ghost"
size="2"
className="nav-button copy-button"
onClick={handleCopyUrl}
>
{copied ? (
<>
<CheckIcon width="16" height="16" />
<Text size="2">{t("common.copied")}</Text>
</>
) : (
<>
<CopyIcon width="16" height="16" />
<Text size="2">{t("common.copy")}</Text>
</>
)}
</Button>
</Flex>
<Text
size="1"
color="gray"
style={{
marginTop: "6px",
display: "block",
lineHeight: "1.5",
}}
>
{t("statusPageConfig.publicUrlHelp")}
</Text>
</Box>
<Box
style={{
background: "var(--gray-2)",
padding: "12px 14px",
borderRadius: "8px",
marginTop: "4px",
}}
>
<Text
as="div"
size="2"
color="gray"
style={{ lineHeight: "1.5" }}
>
{t("statusPageConfig.selectionNote")}
</Text>
</Box>
</Flex>
</Tabs.Content>
<Tabs.Content value="services" className="tab-content">
<Flex direction="column" gap="5">
<Text size="2" color="gray" mb="3">
{t("statusPageConfig.selectServicesPrompt")}
</Text>
{config.monitors.length === 0 ? (
<Text color="gray">{t("monitors.noMonitors")}</Text>
) : (
<Box>
{config.monitors.map((monitor) => {
console.log(
`【${t("statusPageConfig.serviceRendering")}】${
monitor.name
}(${monitor.id}), ${t(
"statusPageConfig.selectedStatus"
)}: ${monitor.selected}`
);
return (
<Flex
key={monitor.id}
align="center"
justify="between"
className="service-item"
>
<Text size="3">{monitor.name}</Text>
<div className="custom-checkbox">
<input
type="checkbox"
id={`monitor-${monitor.id}`}
checked={monitor.selected}
onChange={(e) => {
console.log(
`${t(
"statusPageConfig.monitorStatusChange"
)}: ${monitor.name}(${monitor.id}), ${t(
"statusPageConfig.from"
)} ${monitor.selected} ${t(
"statusPageConfig.to"
)} ${e.target.checked}`
);
handleMonitorChange(
monitor.id,
e.target.checked
);
}}
/>
<label
htmlFor={`monitor-${monitor.id}`}
className={
monitor.selected ? "checked" : ""
}
></label>
</div>
</Flex>
);
})}
</Box>
)}
</Flex>
</Tabs.Content>
<Tabs.Content value="agents" className="tab-content">
<Flex direction="column" gap="5">
<Text size="2" color="gray" mb="3">
{t("statusPageConfig.selectAgentsPrompt")}
</Text>
{config.agents.length === 0 ? (
<Text color="gray">{t("agents.noAgents")}</Text>
) : (
<Box>
{config.agents.map((agent) => {
console.log(
`【${t("statusPageConfig.agentRendering")}】${
agent.name
}(${agent.id}), ${t(
"statusPageConfig.selectedStatus"
)}: ${agent.selected}`
);
return (
<Flex
key={agent.id}
align="center"
justify="between"
className="service-item"
>
<Text size="3">{agent.name}</Text>
<div className="custom-checkbox">
<input
type="checkbox"
id={`agent-${agent.id}`}
checked={agent.selected}
onChange={(e) => {
console.log(
`${t(
"statusPageConfig.agentStatusChange"
)}: ${agent.name}(${agent.id}), ${t(
"statusPageConfig.from"
)} ${agent.selected} ${t(
"statusPageConfig.to"
)} ${e.target.checked}`
);
handleAgentChange(
agent.id,
e.target.checked
);
}}
/>
<label
htmlFor={`agent-${agent.id}`}
className={agent.selected ? "checked" : ""}
></label>
</div>
</Flex>
);
})}
</Box>
)}
</Flex>
</Tabs.Content>
<Box pt="2" px="2">
<TabsContent value="general">
<Flex direction="column" gap="2">
<Box>
<Text as="label" className="text-sm font-medium">
{t("statusPageConfig.pageTitle")}
</Text>
<TextField.Input
name="title"
value={config.title}
onChange={handleChange}
placeholder={t("statusPageConfig.pageTitlePlaceholder")}
/>
</Box>
</Tabs.Root>
</Card>
</div>
</div>
{/* Toast 提示 */}
<Toast.Provider swipeDirection="right">
<Toast.Viewport className="ToastViewport" />
<Box>
<Text as="label" className="text-sm font-medium">
{t("statusPageConfig.pageDescription")}
</Text>
<Textarea
name="description"
value={config.description}
onChange={handleChange}
placeholder={t(
"statusPageConfig.pageDescriptionPlaceholder"
)}
/>
</Box>
{showSuccessToast && (
<Toast.Root
className="ToastRoot"
duration={3000}
style={{ backgroundColor: "var(--green-9)", borderRadius: "8px" }}
>
<div className="ToastContent">
<div
className="ToastIcon"
style={{
backgroundColor: "var(--green-10)",
borderRadius: "50%",
padding: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CheckIcon color="white" width="18" height="18" />
</div>
<div className="ToastText">
<div
className="ToastTitle"
style={{ fontSize: "15px", fontWeight: "600" }}
>
{t("common.success")}
</div>
<div
className="ToastDescription"
style={{ fontSize: "13px", opacity: "0.9" }}
>
{toastMessage}
</div>
</div>
<Toast.Close
className="ToastClose"
style={{
opacity: "0.8",
backgroundColor: "transparent",
border: "none",
color: "white",
fontSize: "20px",
cursor: "pointer",
padding: "4px 8px",
}}
aria-label={t("common.close")}
>
×
</Toast.Close>
</div>
</Toast.Root>
)}
<Box>
<Text as="label" className="text-sm font-medium">
{t("statusPageConfig.publicUrl")}
</Text>
<Flex gap="2">
<Box className="flex-1 relative">
<TextField.Input
value={config.publicUrl}
readOnly
className="w-full overflow-hidden text-ellipsis whitespace-nowrap pr-4"
/>
</Box>
<Button variant="secondary" onClick={handleCopyUrl}>
{copied ? (
<>
<CheckIcon width="16" height="16" />
<Text>{t("common.copied")}</Text>
</>
) : (
<>
<CopyIcon width="16" height="16" />
<Text>{t("common.copy")}</Text>
</>
)}
</Button>
</Flex>
<Text className="text-xs text-gray-500">
{t("statusPageConfig.publicUrlHelp")}
</Text>
</Box>
{showErrorToast && (
<Toast.Root
className="ToastRoot"
duration={3000}
style={{ backgroundColor: "var(--red-9)", borderRadius: "8px" }}
>
<div className="ToastContent">
<div
className="ToastIcon"
style={{
backgroundColor: "var(--red-10)",
borderRadius: "50%",
padding: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.5 0.875C3.83475 0.875 0.875 3.83475 0.875 7.5C0.875 11.1652 3.83475 14.125 7.5 14.125C11.1652 14.125 14.125 11.1652 14.125 7.5C14.125 3.83475 11.1652 0.875 7.5 0.875ZM7.5 8.5C6.94772 8.5 6.5 8.05228 6.5 7.5C6.5 6.94772 6.94772 6.5 7.5 6.5C8.05228 6.5 8.5 6.94772 8.5 7.5C8.5 8.05228 8.05228 8.5 7.5 8.5ZM6.75 4.25L7 6.25H8L8.25 4.25H6.75Z"
fill="white"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</div>
<div className="ToastText">
<div
className="ToastTitle"
style={{ fontSize: "15px", fontWeight: "600" }}
>
{t("common.error")}
</div>
<div
className="ToastDescription"
style={{ fontSize: "13px", opacity: "0.9" }}
>
{toastMessage}
</div>
</div>
<Toast.Close
className="ToastClose"
style={{
opacity: "0.8",
backgroundColor: "transparent",
border: "none",
color: "white",
fontSize: "20px",
cursor: "pointer",
padding: "4px 8px",
}}
aria-label={t("common.close")}
>
×
</Toast.Close>
</div>
</Toast.Root>
)}
</Toast.Provider>
</Container>
</Box>
<Box className="mt-1">
<Text className="text-sm font-medium text-ellipsis">
{t("statusPageConfig.selectionNote")}
</Text>
</Box>
</Flex>
</TabsContent>
<TabsContent value="services">
<Flex direction="column">
<Text>{t("statusPageConfig.selectServicesPrompt")}</Text>
{config.monitors.length === 0 ? (
<Text>{t("monitors.noMonitors")}</Text>
) : (
<Box>
{config.monitors.map((monitor) => {
console.log(
`【${t("statusPageConfig.serviceRendering")}】${
monitor.name
}(${monitor.id}), ${t(
"statusPageConfig.selectedStatus"
)}: ${monitor.selected}`
);
return (
<Flex
key={monitor.id}
className="items-center justify-between p-2 hover:bg-gray-50 rounded-md"
>
<Text>{monitor.name}</Text>
<Checkbox
id={`monitor-${monitor.id}`}
className="ml-auto"
checked={monitor.selected}
onCheckedChange={(checked) => {
const newCheckedState = !!checked;
console.log(
`${t(
"statusPageConfig.monitorStatusChange"
)}: ${monitor.name}(${monitor.id}), ${t(
"statusPageConfig.from"
)} ${monitor.selected} ${t(
"statusPageConfig.to"
)} ${newCheckedState}`
);
handleMonitorChange(monitor.id, newCheckedState);
}}
/>
</Flex>
);
})}
</Box>
)}
</Flex>
</TabsContent>
<TabsContent value="agents">
<Flex direction="column">
<Text>{t("statusPageConfig.selectAgentsPrompt")}</Text>
{config.agents.length === 0 ? (
<Text color="gray">{t("agents.noAgents")}</Text>
) : (
<Box>
{config.agents.map((agent) => {
console.log(
`【${t("statusPageConfig.agentRendering")}】${
agent.name
}(${agent.id}), ${t(
"statusPageConfig.selectedStatus"
)}: ${agent.selected}`
);
return (
<Flex
key={agent.id}
className="items-center justify-between p-2 hover:bg-gray-50 rounded-md"
>
<Text size="3">{agent.name}</Text>
<Checkbox
id={`agent-${agent.id}`}
checked={agent.selected}
className="ml-auto"
onCheckedChange={(checked) => {
const newCheckedState = !!checked;
console.log(
`${t("statusPageConfig.agentStatusChange")}: ${
agent.name
}(${agent.id}), ${t("statusPageConfig.from")} ${
agent.selected
} ${t(
"statusPageConfig.to"
)} ${newCheckedState}`
);
handleAgentChange(agent.id, newCheckedState);
}}
/>
</Flex>
);
})}
</Box>
)}
</Flex>
</TabsContent>
</Box>
</Tabs>
</Card>
</Container>
);
};
+2 -9
View File
@@ -1,13 +1,6 @@
import { useState, useEffect } from "react";
import {
Button,
Card,
Flex,
Heading,
Text,
TextField,
Box,
} from "@radix-ui/themes";
import { Flex, Heading, Text, TextField, Box } from "@radix-ui/themes";
import { Button, Card } from "@/components/ui";
import { useAuth } from "../../providers/AuthProvider";
import { updateUser, changePassword } from "../../api/users";
import { UpdateUserRequest, ChangePasswordRequest } from "../../types/users";
+30 -39
View File
@@ -1,13 +1,15 @@
import { useState, useEffect } from "react";
import { Table, Text, Flex, Heading, Box } from "@radix-ui/themes";
import {
Button,
Table,
Text,
Flex,
Heading,
AlertDialog,
Box,
} from "@radix-ui/themes";
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogAction,
} from "@/components/ui";
import { getAllUsers, deleteUser } from "../../api/users";
import { User } from "../../types";
import { useNavigate } from "react-router-dom";
@@ -107,48 +109,37 @@ const UsersList = () => {
<Table.Cell>{user.role}</Table.Cell>
<Table.Cell>
<Flex gap="2">
<Button
size="1"
onClick={() => navigate(`/users/${user.id}`)}
>
<Button variant="secondary" onClick={() => navigate(`/users/${user.id}`)}>
{t("common.edit")}
</Button>
<AlertDialog.Root>
<AlertDialog.Trigger>
<Button size="1" color="red">
{t("common.delete")}
</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>
<AlertDialog>
<AlertDialogTrigger>
{t("common.delete")}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>
{t("common.deleteConfirmation")}
</AlertDialog.Title>
<AlertDialog.Description>
</AlertDialogTitle>
<AlertDialogDescription>
{t("users.deleteConfirm", {
username: user.username,
})}
</AlertDialog.Description>
</AlertDialogDescription>
<Flex gap="3" mt="4" justify="end">
<AlertDialog.Cancel>
<Button variant="soft" color="gray">
<AlertDialogCancel>
{t("common.cancel")}
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action>
<Button
variant="solid"
color="red"
onClick={() => {
setUserToDelete(user.id);
handleDelete();
}}
>
{t("common.delete")}
</Button>
</AlertDialog.Action>
</AlertDialogCancel>
<AlertDialogAction onClick={() => {
setUserToDelete(user.id);
handleDelete();
}}>
{t("common.delete")}
</AlertDialogAction>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
</AlertDialogContent>
</AlertDialog>
</Flex>
</Table.Cell>
</Table.Row>
+1 -1
View File
@@ -5,7 +5,7 @@ import React, {
useEffect,
ReactNode,
} from "react";
import i18n from "../locales/config";
import i18n from "../i18n/config";
import { LanguageContextType } from "../types/language";
const availableLanguages = [
-165
View File
@@ -1,165 +0,0 @@
/* 通用动画定义 */
/* 淡入效果 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 淡出效果 */
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* 从上方滑入 */
@keyframes slideInFromTop {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* 从下方滑入 */
@keyframes slideInFromBottom {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* 缩放入场 */
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* 脉冲动画 - 适用于状态指示器 */
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(0, 200, 0, 0.4);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 6px rgba(0, 200, 0, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(0, 200, 0, 0);
}
}
/* 呼吸灯效果 */
@keyframes breathe {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
/* 旋转动画 */
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 边框闪烁 */
@keyframes borderPulse {
0% {
border-color: rgba(0, 100, 255, 0.4);
}
50% {
border-color: rgba(0, 100, 255, 1);
}
100% {
border-color: rgba(0, 100, 255, 0.4);
}
}
/* 悬浮效果类 */
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* 通用过渡效果类 */
.transition-all {
transition: all 0.3s ease;
}
.transition-transform {
transition: transform 0.3s ease;
}
.transition-opacity {
transition: opacity 0.3s ease;
}
/* 动画应用类 */
.animate-fadeIn {
animation: fadeIn 0.3s ease forwards;
}
.animate-slideInFromTop {
animation: slideInFromTop 0.3s ease forwards;
}
.animate-slideInFromBottom {
animation: slideInFromBottom 0.3s ease forwards;
}
.animate-scaleIn {
animation: scaleIn 0.3s ease forwards;
}
.animate-pulse {
animation: pulse 2s infinite;
}
.animate-breathe {
animation: breathe 2s infinite;
}
.animate-rotate {
animation: rotate 1s linear infinite;
}
.animate-borderPulse {
animation: borderPulse 2s infinite;
}
-801
View File
@@ -1,801 +0,0 @@
/* 状态页配置组件样式 */
.page-container.detail-page {
max-width: 1000px;
margin: 0 auto;
padding: 20px 16px;
}
.detail-header {
padding: 16px 0;
}
.detail-content {
animation: fade-in 0.3s ease-out;
}
/* 微动画 */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Toast 样式 */
.ToastViewport {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
padding: 0;
gap: 12px;
width: 380px;
max-width: 100vw;
margin: 0;
list-style: none;
z-index: 2147483647;
outline: none;
}
.ToastRoot {
background-color: var(--green-9);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 16px;
color: white;
transform-origin: bottom right;
animation: toast-slide-in 0.2s ease-out;
}
.ToastRoot[style*="background-color: var(--red-9)"] {
background-color: var(--red-9);
}
.ToastContent {
display: flex;
align-items: center;
gap: 10px;
}
.ToastIcon {
display: flex;
align-items: center;
justify-content: center;
}
.ToastText {
flex: 1;
}
.ToastTitle {
font-weight: 600;
margin-bottom: 4px;
}
.ToastDescription {
font-size: 0.9em;
opacity: 0.9;
}
.ToastClose {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
padding: 0 4px;
opacity: 0.8;
}
.ToastClose:hover {
opacity: 1;
}
/* 服务项样式 */
.service-item {
padding: 10px 14px;
border-radius: 8px;
transition: all 0.2s ease;
margin-bottom: 6px;
display: flex;
align-items: center;
justify-content: space-between;
}
.service-item:hover {
background-color: var(--gray-3);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
}
/* 自定义复选框样式 */
.custom-checkbox {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.custom-checkbox input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.custom-checkbox label {
position: relative;
cursor: pointer;
display: inline-block;
width: 22px;
height: 22px;
border: 2px solid var(--gray-6);
border-radius: 6px;
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-sizing: border-box;
background-color: var(--gray-1);
}
/* 选中状态的样式 */
.custom-checkbox input[type="checkbox"]:checked + label,
.custom-checkbox label.checked {
background-color: var(--blue-9);
border-color: var(--blue-9);
box-shadow: 0 0 0 2px rgba(0, 120, 255, 0.1);
}
/* 选中状态的勾选图标 */
.custom-checkbox input[type="checkbox"]:checked + label:after,
.custom-checkbox label.checked:after {
content: "";
position: absolute;
left: 7px;
top: 3px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
transition: all 0.2s ease;
}
.custom-checkbox input[type="checkbox"]:focus + label {
box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.15);
}
.custom-checkbox input[type="checkbox"]:hover:not(:checked) + label {
background-color: var(--gray-3);
border-color: var(--gray-7);
transform: scale(1.05);
}
/* 动画效果 */
@keyframes toast-slide-in {
from {
transform: translateX(calc(100% + 24px));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 导航栏样式 */
.navbar-wrapper {
position: sticky;
top: 0;
z-index: 100;
width: 100%;
background-color: rgb(255, 255, 255);
border-bottom: 1px solid var(--gray-3);
transition: all 0.3s ease;
}
.navbar-wrapper.scrolled {
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(230, 230, 230, 0.7);
}
.navbar-wrapper:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.navbar-container {
width: 100%;
}
.navbar-content {
height: 60px;
transition: height 0.3s ease;
}
.scrolled .navbar-content {
height: 54px;
}
.navbar-logo-link {
text-decoration: none;
color: inherit;
position: relative;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.navbar-logo-link:hover {
transform: scale(1.03);
}
.navbar-logo-link:active {
transform: scale(0.98);
}
.navbar-logo {
display: flex;
align-items: center;
justify-content: center;
color: var(--blue-9);
transition: color 0.3s ease, transform 0.3s ease;
}
.navbar-logo-link:hover .navbar-logo {
color: var(--blue-10);
transform: rotate(-5deg);
}
.navbar-links {
display: flex;
gap: 4px;
}
.nav-link {
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
}
.nav-button {
transition: all 0.25s ease;
border-radius: 6px;
position: relative;
color: var(--gray-10);
font-weight: 500;
padding: 8px 12px;
}
.nav-button:hover {
background-color: var(--gray-2);
color: var(--gray-12);
transform: translateY(-1px);
}
.nav-button:active {
transform: translateY(0);
}
/* 删除激活状态的下划线,改为背景色变化 */
.nav-link.active .nav-button {
background-color: var(--gray-2);
color: var(--blue-9);
font-weight: 600;
}
/* 添加底部滑入动画效果 */
.nav-link::after {
content: "";
position: absolute;
bottom: -2px;
left: 50%;
width: 0;
height: 2px;
background-color: var(--blue-9);
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 0;
}
.nav-link:hover::after,
.nav-link.active::after {
width: 20px;
left: calc(50% - 10px);
opacity: 1;
}
/* 添加点击波纹效果 */
.nav-button::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: width 0.6s ease, height 0.6s ease, opacity 0.6s ease;
z-index: -1;
}
.nav-button:active::before {
width: 120%;
height: 300%;
opacity: 1;
transition: width 0.3s ease, height 0.3s ease, opacity 0s;
}
/* 配置页按钮样式 */
.config-button {
min-width: 110px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 16px;
box-sizing: border-box;
gap: 8px;
border-radius: 6px;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
margin: 0;
line-height: 1;
position: relative;
vertical-align: middle;
}
.config-button:hover {
transform: translateY(-1px);
}
.config-button:active {
transform: translateY(0);
}
.config-button [data-size="2"] {
font-size: 14px;
font-weight: 500;
line-height: 1;
display: inline-flex;
align-items: center;
position: relative;
}
.config-button svg {
display: inline-flex;
vertical-align: middle;
flex-shrink: 0;
position: relative;
top: 0;
}
.copy-button {
min-width: 80px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.user-menu-button {
transition: all 0.25s ease;
border-radius: 8px;
padding: 6px 8px;
}
.user-menu-button:hover {
background-color: var(--gray-2);
transform: translateY(-1px);
}
.user-menu-button:active {
transform: translateY(0);
}
.user-dropdown {
min-width: 220px;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid var(--gray-3);
animation: dropdown-fade 0.25s cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: top right;
overflow: hidden;
}
/* 下拉菜单项动画 */
[data-radix-dropdown-menu-content] [data-radix-dropdown-menu-item] {
transition: all 0.2s ease;
position: relative;
margin: 2px 4px;
border-radius: 6px;
background: transparent;
}
[data-radix-dropdown-menu-content] [data-radix-dropdown-menu-item]:hover {
background-color: var(--gray-2);
transform: translateX(2px);
}
[data-radix-dropdown-menu-content]
[data-radix-dropdown-menu-item][data-accent-color="red"]:hover {
background-color: var(--red-2);
}
/* 菜单分隔线动画 */
[data-radix-dropdown-menu-content] [data-radix-dropdown-menu-separator] {
margin: 6px 0;
transition: opacity 0.2s ease;
}
/* 菜单内容动画 */
[data-radix-dropdown-menu-content]
[data-radix-dropdown-menu-item]
[data-size="2"] {
font-size: 13px;
transition: transform 0.2s ease;
}
[data-radix-dropdown-menu-content]
[data-radix-dropdown-menu-item]:hover
[data-size="2"] {
transform: translateX(2px);
}
@keyframes dropdown-fade {
from {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 页面切换过渡动画 */
.main-content {
animation: page-fade-in 0.4s ease;
}
@keyframes page-fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 标签页样式 */
/* 配置页标签样式 */
.config-tabs {
outline: none !important;
box-shadow: none !important;
}
.config-tabs-list {
border-bottom: none !important;
padding: 0 8px 0 4px;
margin-bottom: 0;
gap: 4px;
outline: none !important;
box-shadow: none !important;
border-top: none !important;
border-left: none !important;
border-right: none !important;
}
/* Radix UI Tabs 样式重置 - 强力覆盖 */
/* 这些选择器会覆盖 Radix UI 的默认样式 */
button[data-radix-collection-item][role="tab"],
button[data-orientation="horizontal"][role="tab"],
button[data-state][role="tab"],
.rt-TabsTrigger,
.tab-trigger,
[role="tab"] {
all: unset !important;
box-shadow: none !important;
border: none !important;
outline: none !important;
cursor: pointer;
margin: 0 2px;
font-size: 14px;
position: relative;
padding: 10px 14px;
background-color: transparent;
border-radius: 4px 4px 0 0;
letter-spacing: 0.01em;
color: var(--gray-11);
text-decoration: none !important;
user-select: none;
-webkit-user-select: none;
transition: all 0.25s ease;
}
button[data-radix-collection-item][role="tab"]:focus,
button[data-orientation="horizontal"][role="tab"]:focus,
button[data-state][role="tab"]:focus,
.rt-TabsTrigger:focus,
.tab-trigger:focus,
[role="tab"]:focus {
outline: none !important;
box-shadow: none !important;
border: none !important;
}
button[data-radix-collection-item][role="tab"]:focus-visible,
button[data-orientation="horizontal"][role="tab"]:focus-visible,
button[data-state][role="tab"]:focus-visible,
.rt-TabsTrigger:focus-visible,
.tab-trigger:focus-visible,
[role="tab"]:focus-visible {
outline: none !important;
box-shadow: none !important;
border: none !important;
background-color: var(--gray-2);
}
button[data-radix-collection-item][role="tab"][data-state="active"],
button[data-orientation="horizontal"][role="tab"][data-state="active"],
button[data-state="active"][role="tab"],
.rt-TabsTrigger[data-state="active"],
.tab-trigger[data-state="active"],
[role="tab"][data-state="active"] {
color: var(--blue-9);
font-weight: 600;
background-color: transparent;
}
button[data-radix-collection-item][role="tab"][data-state="active"]::after,
button[data-orientation="horizontal"][role="tab"][data-state="active"]::after,
button[data-state="active"][role="tab"]::after,
.rt-TabsTrigger[data-state="active"]::after,
.tab-trigger[data-state="active"]::after,
[role="tab"][data-state="active"]::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 2px;
background-color: transparent;
transform: scaleX(1);
transform-origin: center;
}
button[data-radix-collection-item][role="tab"]:hover,
button[data-orientation="horizontal"][role="tab"]:hover,
button[data-state][role="tab"]:hover,
.rt-TabsTrigger:hover,
.tab-trigger:hover,
[role="tab"]:hover {
color: var(--blue-10);
background-color: var(--gray-2);
transform: translateY(-1px);
box-shadow: none !important;
border: none !important;
outline: none !important;
}
button[data-radix-collection-item][role="tab"]:not(
[data-state="active"]
):hover::after,
button[data-orientation="horizontal"][role="tab"]:not(
[data-state="active"]
):hover::after,
button[data-state][role="tab"]:not([data-state="active"]):hover::after,
.rt-TabsTrigger:not([data-state="active"]):hover::after,
.tab-trigger:not([data-state="active"]):hover::after,
[role="tab"]:not([data-state="active"]):hover::after {
content: none;
}
button[data-radix-collection-item][role="tab"]:active,
button[data-orientation="horizontal"][role="tab"]:active,
button[data-state][role="tab"]:active,
.rt-TabsTrigger:active,
.tab-trigger:active,
[role="tab"]:active {
transform: translateY(0);
background-color: var(--gray-3);
box-shadow: none !important;
border: none !important;
outline: none !important;
}
/* 标签内容区样式 */
[data-radix-tabs-content],
[role="tabpanel"],
.rt-TabsContent,
.tab-content {
padding-top: 24px !important;
outline: none !important;
border: none !important;
box-shadow: none !important;
animation: tab-content-fade-in 0.3s ease;
}
.tab-content-container {
border-top: none !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
}
@keyframes tab-content-fade-in {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 标签列表样式 */
[data-orientation="horizontal"] {
border-bottom: none !important;
padding-bottom: 0 !important;
outline: none !important;
box-shadow: none !important;
border-top: none !important;
border-left: none !important;
border-right: none !important;
}
/* 解决Safari特定渲染问题 */
@media not all and (min-resolution: 0.001dpcm) {
@supports (-webkit-appearance: none) {
button[role="tab"],
.tab-trigger,
.rt-TabsTrigger {
border: none !important;
outline: none !important;
box-shadow: none !important;
}
}
}
/* 表单输入字段样式 */
[data-radix-text-field-input] {
transition: all 0.2s ease;
border-color: var(--gray-5);
font-size: 14px !important;
box-sizing: border-box;
padding-right: 8px;
}
[data-radix-text-field-input]:hover {
border-color: var(--gray-7);
}
[data-radix-text-field-input]:focus {
border-color: var(--blue-8);
box-shadow: 0 0 0 1px var(--blue-5);
}
[data-radix-text-field-input][readonly] {
background-color: var(--gray-1);
color: var(--gray-11);
cursor: default;
}
textarea {
transition: all 0.2s ease;
border-color: var(--gray-5) !important;
resize: vertical;
}
textarea:hover {
border-color: var(--gray-7) !important;
}
textarea:focus {
border-color: var(--blue-8) !important;
box-shadow: 0 0 0 1px var(--blue-5);
}
/* 布局样式 */
.layout-container {
min-height: 100vh;
width: 100%;
max-width: 100%;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
width: 100%;
max-width: 100%;
padding-bottom: 40px;
}
.footer {
width: 100%;
padding: 16px 0;
margin-top: auto;
}
/* 仪表盘样式 */
.dashboard-container {
width: 100%;
min-height: calc(100vh - 112px);
padding-bottom: 40px;
}
.status-summary-card {
height: 100%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.status-summary-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.status-icon-container {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
transition: transform 0.2s ease;
}
.monitor-card,
.agent-card {
height: 100%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.monitor-card:hover,
.agent-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.footer-link {
display: flex;
align-items: center;
color: var(--accent-9);
text-decoration: none;
}
.footer-link-icon {
margin-right: 8px;
}
/* 覆盖 Radix UI TabsTrigger 底部边框 */
html body .rt-TabsTrigger:where([data-state="active"])::before,
html body .rt-TabsTrigger[data-state="active"]::before {
background-color: none !important;
display: none !important;
height: 0 !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* 确保所有相关的伪元素都被覆盖 */
html body button[role="tab"][data-state="active"]::before,
html body [role="tab"][data-state="active"]::before,
html body .tab-trigger[data-state="active"]::before {
background-color: none !important;
display: none !important;
height: 0 !important;
opacity: 0 !important;
visibility: hidden !important;
}
+110 -186
View File
@@ -1,197 +1,121 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* 全局样式设置 */
:root {
--max-content-width: 1200px;
--content-padding: 0 16px;
--header-height: 64px;
--footer-height: 56px;
--page-top-spacing: 24px;
--section-spacing: 16px;
/* 基础字体设置 */
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.13 0.028 261.692);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.028 261.692);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692);
--primary: oklch(0.21 0.034 264.665);
--primary-foreground: oklch(0.985 0.002 247.839);
--secondary: oklch(0.967 0.003 264.542);
--secondary-foreground: oklch(0.21 0.034 264.665);
--muted: oklch(0.967 0.003 264.542);
--muted-foreground: oklch(0.551 0.027 264.364);
--accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
--ring: oklch(0.707 0.022 261.325);
--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.002 247.839);
--sidebar-foreground: oklch(0.13 0.028 261.692);
--sidebar-primary: oklch(0.21 0.034 264.665);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.967 0.003 264.542);
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
--sidebar-border: oklch(0.928 0.006 264.531);
--sidebar-ring: oklch(0.707 0.022 261.325);
}
/* 重置默认样式 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
@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);
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
width: 100%;
overflow-x: hidden;
.dark {
--background: oklch(0.13 0.028 261.692);
--foreground: oklch(0.985 0.002 247.839);
--card: oklch(0.21 0.034 264.665);
--card-foreground: oklch(0.985 0.002 247.839);
--popover: oklch(0.21 0.034 264.665);
--popover-foreground: oklch(0.985 0.002 247.839);
--primary: oklch(0.928 0.006 264.531);
--primary-foreground: oklch(0.21 0.034 264.665);
--secondary: oklch(0.278 0.033 256.848);
--secondary-foreground: oklch(0.985 0.002 247.839);
--muted: oklch(0.278 0.033 256.848);
--muted-foreground: oklch(0.707 0.022 261.325);
--accent: oklch(0.278 0.033 256.848);
--accent-foreground: oklch(0.985 0.002 247.839);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--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.21 0.034 264.665);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
/* 链接样式 */
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
/* 标题样式 */
h1 {
font-size: 3.2em;
line-height: 1.1;
}
/* 按钮基础样式 */
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
/* 浅色主题设置 */
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
@layer base {
* {
@apply border-border outline-ring/50;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
body {
@apply bg-background text-foreground;
}
}
/* App样式 */
#root {
width: 100%;
min-height: 100vh;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
/* 页面容器样式 */
.page-container {
max-width: var(--max-content-width);
margin: 0 auto;
width: 100%;
padding: var(--content-padding);
}
/* 主要内容区域样式 */
.main-content {
min-height: calc(100vh - var(--header-height) - var(--footer-height));
width: 100%;
}
/* 详情页顶部间隙 */
.detail-page {
padding-top: var(--page-top-spacing);
}
/* 详情页标题栏 */
.detail-header {
margin-bottom: var(--section-spacing);
}
/* 详情页内容区域 */
.detail-content {
margin-top: var(--section-spacing);
}
/* 表单输入样式 */
.input-wrapper {
position: relative;
width: 100%;
}
.text-input {
width: 100%;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
border: 1px solid var(--gray-7);
border-radius: 4px;
background-color: var(--color-panel);
color: var(--gray-12);
transition: border-color 0.2s ease;
}
.text-input:focus {
outline: none;
border-color: var(--accent-8);
box-shadow: 0 0 0 1px var(--accent-8);
}
.text-input::placeholder {
color: var(--gray-9);
}
/* 通用卡片样式 */
.card {
padding: 2em;
}
/* 响应式布局辅助类 */
@media (max-width: 768px) {
.hide-on-mobile {
display: none !important;
}
}
@media (min-width: 769px) {
.hide-on-desktop {
display: none !important;
}
}
/* 辅助类 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
-14
View File
@@ -1,14 +0,0 @@
/* 导入全局基础样式 */
@import "./global.css";
/* 导入动画样式 */
@import "./animations.css";
/* 导入组件样式 */
@import "./components.css";
/* 导入模拟 Tailwind 类样式 */
@import "./tailwind-classes.css";
/* 导入页面样式 */
@import "./pages.css";
-218
View File
@@ -1,218 +0,0 @@
/* AgentDetail页面样式 */
.detail-page .detail-header {
margin-bottom: 1.5rem;
}
.detail-content {
margin-top: 1rem;
}
.system-info-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.system-info-label {
font-weight: bold;
}
.system-info-value {
color: var(--gray-12);
}
/* 加载状态和错误状态样式 */
.loading-container,
.error-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
/* AgentsList页面样式 */
.agent-card {
position: relative;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.agent-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.status-indicator-active {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--green-9);
animation: pulse 2s infinite;
}
.status-indicator-connecting {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--amber-9);
}
.status-indicator-inactive {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--red-9);
}
.status-bar-active {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(to bottom, var(--green-9), var(--cyan-9));
}
.status-bar-connecting {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(to bottom, var(--amber-9), var(--yellow-9));
}
.status-bar-inactive {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(to bottom, var(--red-9), var(--amber-9));
}
/* 登录页面样式 */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9fafb;
padding: 12px 16px;
}
.login-container {
max-width: 400px;
width: 100%;
margin: 0 auto;
}
.login-header {
text-align: center;
margin-bottom: 24px;
}
.login-title {
margin-top: 24px;
text-align: center;
font-size: 1.875rem; /* 相当于text-3xl */
font-weight: 800; /* 相当于font-extrabold */
color: #111827; /* 相当于text-gray-900 */
}
.login-subtitle {
margin-top: 8px;
text-align: center;
font-size: 0.875rem; /* 相当于text-sm */
color: #4b5563; /* 相当于text-gray-600 */
}
.login-link {
font-weight: 500;
color: #2563eb; /* 相当于text-blue-600 */
text-decoration: none;
}
.login-link:hover {
color: #3b82f6; /* 相当于text-blue-500 */
}
.login-form {
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.login-input-group {
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.login-input {
display: block;
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db; /* 相当于border-gray-300 */
color: #111827; /* 相当于text-gray-900 */
font-size: 0.875rem; /* 相当于sm:text-sm */
}
.login-input:focus {
outline: none;
border-color: #3b82f6; /* 相当于focus:border-blue-500 */
z-index: 10; /* 相当于focus:z-10 */
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25); /* 相当于focus:ring-blue-500 */
}
.login-input-top {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-bottom: none;
}
.login-input-bottom {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
.login-error {
color: #dc2626; /* 相当于text-red-600 */
font-size: 0.875rem; /* 相当于text-sm */
margin-top: 8px;
}
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.login-remember {
display: flex;
align-items: center;
}
.login-checkbox {
height: 16px;
width: 16px;
color: #2563eb; /* 相当于text-blue-600 */
border-color: #d1d5db; /* 相当于border-gray-300 */
border-radius: 4px;
}
.login-checkbox-label {
margin-left: 8px;
display: block;
font-size: 0.875rem; /* 相当于text-sm */
color: #111827; /* 相当于text-gray-900 */
}
.login-forgot {
font-size: 0.875rem; /* 相当于text-sm */
}
-240
View File
@@ -1,240 +0,0 @@
/* 模拟 Login.tsx 中使用的 Tailwind 类名样式 */
/* 布局类 */
.min-h-screen {
min-height: 100vh;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.w-full {
width: 100%;
}
.max-w-md {
max-width: 28rem;
}
.space-y-8 > * + * {
margin-top: 2rem;
}
.space-y-6 > * + * {
margin-top: 1.5rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.mt-8 {
margin-top: 2rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.ml-2 {
margin-left: 0.5rem;
}
/* 间距类 */
.py-12 {
padding-top: 3rem;
padding-bottom: 3rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
/* 响应式尺寸 */
@media (min-width: 640px) {
.sm\:px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.sm\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
}
@media (min-width: 1024px) {
.lg\:px-8 {
padding-left: 2rem;
padding-right: 2rem;
}
}
/* 颜色类 */
.bg-gray-50 {
background-color: #f9fafb;
}
.text-gray-600 {
color: #4b5563;
}
.text-gray-900 {
color: #111827;
}
.text-red-600 {
color: #dc2626;
}
.text-blue-600 {
color: #2563eb;
}
.border-gray-300 {
border-color: #d1d5db;
}
.placeholder-gray-500::placeholder {
color: #6b7280;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
/* 文本样式 */
.font-medium {
font-weight: 500;
}
.font-extrabold {
font-weight: 800;
}
.text-center {
text-align: center;
}
.block {
display: block;
}
.relative {
position: relative;
}
/* 表单元素样式 */
.appearance-none {
appearance: none;
}
.rounded-md {
border-radius: 0.375rem;
}
.rounded-none {
border-radius: 0;
}
.rounded-t-md {
border-top-left-radius: 0.375rem;
border-top-right-radius: 0.375rem;
}
.rounded-b-md {
border-bottom-left-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
.border {
border-width: 1px;
border-style: solid;
}
.rounded {
border-radius: 0.25rem;
}
/* 特殊样式 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.shadow-sm {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.-space-y-px > * + * {
margin-top: -1px;
}
/* 输入框焦点样式 */
.focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.focus\:ring-blue-500:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
}
.focus\:border-blue-500:focus {
border-color: #3b82f6;
}
.focus\:z-10:focus {
z-index: 10;
}
/* 悬停效果 */
.hover\:text-blue-500:hover {
color: #3b82f6;
}
/* 复选框样式 */
.h-4 {
height: 1rem;
}
.w-4 {
width: 1rem;
}
-124
View File
@@ -1,124 +0,0 @@
/**
* 工具函数集合
*/
/**
* 格式化日期
* @param dateString 日期字符串
* @param locale 地区设置
* @returns 格式化后的日期字符串
*/
export const formatDate = (
dateString: string,
locale: string = "zh-CN"
): string => {
const date = new Date(dateString);
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
/**
* 截断文本
* @param text 需要截断的文本
* @param maxLength 最大长度
* @returns 截断后的文本
*/
export const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
return `${text.substring(0, maxLength)}...`;
};
/**
* 防抖函数
* @param func 要执行的函数
* @param wait 等待时间(毫秒)
* @returns 防抖处理后的函数
*/
export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: number | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = window.setTimeout(() => {
func(...args);
}, wait);
};
};
/**
* 节流函数
* @param func 要执行的函数
* @param limit 时间限制(毫秒)
* @returns 节流处理后的函数
*/
export const throttle = <T extends (...args: any[]) => any>(
func: T,
limit: number
): ((...args: Parameters<T>) => void) => {
let inThrottle: boolean = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
};
/**
* 获取查询参数
* @param search 查询字符串
* @returns 查询参数对象
*/
export const getQueryParams = (search: string): Record<string, string> => {
const params = new URLSearchParams(search);
const result: Record<string, string> = {};
params.forEach((value, key) => {
result[key] = value;
});
return result;
};
/**
* 深拷贝对象
* @param obj 要拷贝的对象
* @returns 拷贝后的对象
*/
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") {
return obj;
}
return JSON.parse(JSON.stringify(obj));
};
/**
* 随机ID生成器
* @param length ID长度
* @returns 随机ID
*/
export const generateId = (length: number = 8): string => {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
+8 -2
View File
@@ -20,7 +20,13 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
/* Path Aliases */
"baseUrl": ".", // 相对于 tsconfig.app.json 文件,即 frontend/ 目录
"paths": {
"@/*": ["./src/*"] // 将 @/* 解析到 frontend/src/*
}
},
"include": ["src"]
}
}
+13 -6
View File
@@ -1,13 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"), // __dirname 指向 vite.config.ts 文件所在的目录
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8787',
"/api": {
target: "http://localhost:8787",
changeOrigin: true,
// 如果后端 API 不包含 /api 前缀,可以重写路径
// rewrite: (path) => path.replace(/^\/api/, '')
@@ -17,4 +24,4 @@ export default defineConfig({
// 如果需要指定前端开发服务器端口
port: 5173,
},
})
});