Compare commits

..

No commits in common. "main" and "v0.0.1" have entirely different histories.
main ... v0.0.1

24 changed files with 1378 additions and 551 deletions

View File

@ -3,20 +3,29 @@
<head> <head>
<script> <script>
// 在页面渲染前就执行主题初始化 // 在页面渲染前就执行主题初始化
document.documentElement.classList.add("dark") try {
const storageKey = "vite-ui-theme"
let theme = localStorage.getItem(storageKey)
if (theme === "system" || !theme) {
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
}
document.documentElement.classList.add(theme)
} catch (e) {
document.documentElement.classList.add("light")
}
// 全局配置变量 // 全局配置变量
window.ShowServerDetails = true; // 是否显示服务器详细信息 window.ShowServerDetails = true; // 是否显示服务器详细信息
</script> </script>
<style> <style>
/* Prevent FOUC in Safari */ /* Prevent FOUC in Safari */
html:not(.dark) * { html:not(.dark):not(.light) * {
visibility: hidden; visibility: hidden;
} }
:root { :root {
color-scheme: dark; color-scheme: light;
--bg: #242424; --bg: #ffffff;
} }
html.dark { html.dark {
@ -24,16 +33,21 @@
--bg: #242424; --bg: #242424;
} }
html.light {
color-scheme: light;
--bg: #ffffff;
}
html { html {
background: transparent !important; background-color: var(--bg) !important;
} }
body { body {
background: transparent !important; background-color: var(--bg) !important;
} }
#root { #root {
background: transparent !important; background-color: var(--bg) !important;
visibility: hidden; visibility: hidden;
} }
@ -53,10 +67,31 @@
</style> </style>
<script> <script>
;(function () { ;(function () {
const storageKey = "vite-ui-theme"
const theme = localStorage.getItem(storageKey) || "system"
const root = document.documentElement const root = document.documentElement
root.classList.remove("light")
root.classList.add("dark") function updateThemeColor(isDark) {
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", "#242424") const themeColor = isDark ? "#242424" : "#fafafa"
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
}
function setTheme(newTheme) {
root.classList.remove("light", "dark")
root.classList.add(newTheme)
updateThemeColor(newTheme === "dark")
}
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
setTheme(systemTheme)
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
setTheme(e.matches ? "dark" : "light")
})
} else {
setTheme(theme)
}
// Add loaded class after React has mounted // Add loaded class after React has mounted
window.addEventListener("load", () => { window.addEventListener("load", () => {

View File

@ -1,14 +1,17 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import React, { useEffect, useState } from "react" import React, { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { Route, BrowserRouter as Router, Routes } from "react-router-dom" import { Route, BrowserRouter as Router, Routes } from "react-router-dom"
import { DashCommand } from "./components/DashCommand" import { DashCommand } from "./components/DashCommand"
import ErrorBoundary from "./components/ErrorBoundary" import ErrorBoundary from "./components/ErrorBoundary"
import Footer from "./components/Footer" import Footer from "./components/Footer"
import Header, { RefreshToast } from "./components/Header" import Header, { RefreshToast } from "./components/Header"
import { useBackground } from "./hooks/use-background"
import { useTheme } from "./hooks/use-theme" import { useTheme } from "./hooks/use-theme"
import { InjectContext } from "./lib/inject" import { InjectContext } from "./lib/inject"
import { fetchSetting } from "./lib/nezha-api" import { fetchSetting } from "./lib/nezha-api"
import { cn } from "./lib/utils"
import ErrorPage from "./pages/ErrorPage" import ErrorPage from "./pages/ErrorPage"
import NotFound from "./pages/NotFound" import NotFound from "./pages/NotFound"
import Server from "./pages/Server" import Server from "./pages/Server"
@ -21,8 +24,10 @@ const App: React.FC = () => {
refetchOnMount: true, refetchOnMount: true,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}) })
const { i18n } = useTranslation()
const { setTheme } = useTheme() const { setTheme } = useTheme()
const [isCustomCodeInjected, setIsCustomCodeInjected] = useState(false) const [isCustomCodeInjected, setIsCustomCodeInjected] = useState(false)
const { backgroundImage: customBackgroundImage } = useBackground()
useEffect(() => { useEffect(() => {
if (settingData?.data?.config?.custom_code) { if (settingData?.data?.config?.custom_code) {
@ -54,38 +59,45 @@ const App: React.FC = () => {
return null return null
} }
if (settingData?.data?.config?.language && !localStorage.getItem("language")) {
i18n.changeLanguage(settingData?.data?.config?.language)
}
const customMobileBackgroundImage = window.CustomMobileBackgroundImage !== "" ? window.CustomMobileBackgroundImage : undefined
return ( return (
<Router basename={import.meta.env.BASE_URL}> <Router basename={import.meta.env.BASE_URL}>
<ErrorBoundary> <ErrorBoundary>
<div className="relative min-h-screen">
{/* 固定定位的背景层 */} {/* 固定定位的背景层 */}
{customBackgroundImage && (
<div <div
className="fixed inset-0 bg-cover bg-no-repeat bg-center dark:brightness-75" className={cn("fixed inset-0 z-0 bg-cover min-h-lvh bg-no-repeat bg-center dark:brightness-75", {
style={{ "hidden sm:block": customMobileBackgroundImage,
backgroundImage: `url(https://random-api.czl.net/pic/normal)`, })}
zIndex: -1 style={{ backgroundImage: `url(${customBackgroundImage})` }}
}}
/> />
{/* 毛玻璃蒙版层 */} )}
{customMobileBackgroundImage && (
<div <div
className="fixed inset-0 backdrop-blur-sm bg-black/80" className={cn("fixed inset-0 z-0 bg-cover min-h-lvh bg-no-repeat bg-center sm:hidden dark:brightness-75")}
style={{ style={{ backgroundImage: `url(${customMobileBackgroundImage})` }}
zIndex: -1
}}
/> />
)}
<main className="relative flex min-h-screen flex-col gap-2 p-2 md:p-6 md:pt-4 bg-transparent"> <div
className={cn("flex min-h-screen w-full flex-col", {
"bg-background": !customBackgroundImage,
})}
>
<main className="flex z-20 min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 p-4 md:p-10 md:pt-8">
<RefreshToast /> <RefreshToast />
<Header /> <Header />
<DashCommand /> <DashCommand />
<div className="flex-1">
<Routes> <Routes>
<Route path="/" element={<Server />} /> <Route path="/" element={<Server />} />
<Route path="/server/:id" element={<ServerDetail />} /> <Route path="/server/:id" element={<ServerDetail />} />
<Route path="/error" element={<ErrorPage />} /> <Route path="/error" element={<ErrorPage />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</div>
<Footer /> <Footer />
</main> </main>
</div> </div>

View File

@ -1,49 +0,0 @@
import { cn } from "@/lib/utils";
import ServerFlag from "@/components/ServerFlag";
type DirectCountrySelectProps = {
countries: string[];
currentCountry: string;
onChange: (country: string) => void;
};
// 这是一个简单的直接选择组件,避免可能的事件传播问题
export default function DirectCountrySelect({
countries,
currentCountry,
onChange
}: DirectCountrySelectProps) {
return (
<div className="w-full">
<div className="flex flex-wrap gap-1.5 pb-1">
<button
className={cn(
"px-3 py-1.5 text-xs rounded-md transition-all border",
currentCountry === "All"
? "bg-blue-500 text-white border-blue-600 hover:bg-blue-600 shadow-sm"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700"
)}
onClick={() => onChange("All")}
>
ALL
</button>
{countries.map((country) => (
<button
key={country}
className={cn(
"px-3 py-1.5 text-xs rounded-md flex items-center gap-1.5 transition-all border",
currentCountry === country
? "bg-blue-500 text-white border-blue-600 hover:bg-blue-600 shadow-sm"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700"
)}
onClick={() => onChange(country)}
>
<ServerFlag country_code={country.toLowerCase()} className="text-[12px]" />
{country}
</button>
))}
</div>
</div>
);
}

View File

@ -1,77 +1,61 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { m } from "framer-motion" import { m } from "framer-motion"
import { createRef, useEffect, useRef, useState } from "react" import { createRef, useEffect, useRef } from "react"
import ServerFlag from "@/components/ServerFlag"
export default function GroupSwitch({ export default function GroupSwitch({
tabs, tabs,
currentTab, currentTab,
setCurrentTab, setCurrentTab,
isCountrySwitch = false
}: { }: {
tabs: string[] tabs: string[]
currentTab: string currentTab: string
setCurrentTab: (tab: string) => void setCurrentTab: (tab: string) => void
isCountrySwitch?: boolean
}) { }) {
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
const [isDarkMode, setIsDarkMode] = useState(false)
// 使用一个唯一的ID来确保各个组件的layoutId不会冲突
const layoutIdPrefix = isCountrySwitch ? "country-switch-" : "tab-switch-"
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const tagRefs = useRef(tabs.map(() => createRef<HTMLDivElement>())) const tagRefs = useRef(tabs.map(() => createRef<HTMLDivElement>()))
useEffect(() => { useEffect(() => {
// 检测暗黑模式 const container = scrollRef.current
setIsDarkMode(document.documentElement.classList.contains('dark')) if (!container) return
// 监听主题变化 const isOverflowing = container.scrollWidth > container.clientWidth
const observer = new MutationObserver((mutations) => { if (!isOverflowing) return
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') { const onWheel = (e: WheelEvent) => {
setIsDarkMode(document.documentElement.classList.contains('dark')) e.preventDefault()
container.scrollLeft += e.deltaY
} }
})
})
observer.observe(document.documentElement, { attributes: true }) container.addEventListener("wheel", onWheel, { passive: false })
return () => { return () => {
observer.disconnect() container.removeEventListener("wheel", onWheel)
} }
}, []) }, [])
// 处理标签点击 useEffect(() => {
function handleClick(tab: string) { const savedGroup = sessionStorage.getItem("selectedGroup")
// 避免重复点击当前选中的标签 if (savedGroup && tabs.includes(savedGroup)) {
if (tab === currentTab) return; setCurrentTab(savedGroup)
}
}, [tabs, setCurrentTab])
try { useEffect(() => {
// 直接调用父组件传递的回调 const currentTagRef = tagRefs.current[tabs.indexOf(currentTab)]
setCurrentTab(tab);
console.log(`[${isCountrySwitch ? '国家' : '分组'}] 切换到: ${tab}`);
// 手动滚动到可见区域 if (currentTagRef && currentTagRef.current) {
const index = tabs.indexOf(tab); currentTagRef.current.scrollIntoView({
if (index !== -1 && tagRefs.current[index]?.current) {
tagRefs.current[index].current?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "nearest", block: "nearest",
inline: "center" inline: "center",
}); })
}
} catch (error) {
console.error('切换标签出错:', error);
}
} }
}, [currentTab])
return ( return (
<div className={cn( <div ref={scrollRef} className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]">
"scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]",
isCountrySwitch ? "border-l border-stone-200 dark:border-stone-700 ml-1 pl-1" : ""
)} ref={scrollRef}>
<div <div
className={cn("flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800", { className={cn("flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800", {
"bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage, "bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage,
@ -79,9 +63,9 @@ export default function GroupSwitch({
> >
{tabs.map((tab: string, index: number) => ( {tabs.map((tab: string, index: number) => (
<div <div
key={isCountrySwitch ? `country-${tab}` : `group-${tab}`} key={tab}
ref={tagRefs.current[index]} ref={tagRefs.current[index]}
onClick={() => handleClick(tab)} onClick={() => setCurrentTab(tab)}
className={cn( className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500", "relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500",
currentTab === tab ? "text-black dark:text-white" : "text-stone-400 dark:text-stone-500", currentTab === tab ? "text-black dark:text-white" : "text-stone-400 dark:text-stone-500",
@ -89,22 +73,15 @@ export default function GroupSwitch({
> >
{currentTab === tab && ( {currentTab === tab && (
<m.div <m.div
layoutId={`${layoutIdPrefix}${isCountrySwitch ? 'country-' : 'group-'}${tab}`} layoutId="tab-switch"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{ style={{
position: "absolute",
inset: 0,
zIndex: 10,
height: "100%",
width: "100%",
backgroundColor: isDarkMode ? "rgb(68 64 60)" : "white", // bg-stone-700 : white
boxShadow: isDarkMode ? "0 1px 3px 0 rgba(255, 255, 255, 0.05)" : "0 1px 3px 0 rgba(0, 0, 0, 0.05)",
originY: "0px", originY: "0px",
borderRadius: 46, borderRadius: 46,
}} }}
/> />
)} )}
<div className="relative z-20 flex items-center gap-1"> <div className="relative z-20 flex items-center gap-1">
{isCountrySwitch && tab !== "All" && <ServerFlag country_code={tab.toLowerCase()} className="text-[10px]" />}
<p className="whitespace-nowrap">{tab}</p> <p className="whitespace-nowrap">{tab}</p>
</div> </div>
</div> </div>

View File

@ -1,3 +1,4 @@
import { ModeToggle } from "@/components/ThemeSwitcher"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { useBackground } from "@/hooks/use-background" import { useBackground } from "@/hooks/use-background"
@ -6,13 +7,14 @@ import { fetchLoginUser, fetchSetting } from "@/lib/nezha-api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import NumberFlow, { NumberFlowGroup } from "@number-flow/react" import NumberFlow, { NumberFlowGroup } from "@number-flow/react"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { AnimatePresence } from "framer-motion" import { AnimatePresence, m } from "framer-motion"
import { ImageMinus } from "lucide-react" import { ImageMinus } from "lucide-react"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { LanguageSwitcher } from "./LanguageSwitcher"
import { Loader, LoadingSpinner } from "./loading/Loader" import { Loader, LoadingSpinner } from "./loading/Loader"
import { Button } from "./ui/button" import { Button } from "./ui/button"
@ -74,7 +76,7 @@ function Header() {
const customBackgroundImage = backgroundImage const customBackgroundImage = backgroundImage
return ( return (
<div className="mx-auto w-full max-w-7xl"> <div className="mx-auto w-full max-w-5xl">
<section className="flex items-center justify-between header-top"> <section className="flex items-center justify-between header-top">
<section <section
onClick={() => { onClick={() => {
@ -101,6 +103,8 @@ function Header() {
<Links /> <Links />
<DashboardLink /> <DashboardLink />
</div> </div>
<LanguageSwitcher />
<ModeToggle />
{(customBackgroundImage || sessionStorage.getItem("savedBackgroundImage")) && ( {(customBackgroundImage || sessionStorage.getItem("savedBackgroundImage")) && (
<Button <Button
variant="outline" variant="outline"
@ -191,14 +195,18 @@ export function RefreshToast() {
return ( return (
<AnimatePresence> <AnimatePresence>
<div <m.div
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
transition={{ type: "spring", duration: 0.8 }}
className="fixed left-1/2 -translate-x-1/2 top-8 z-[999] flex items-center justify-between gap-4 rounded-[50px] border-[1px] border-solid bg-white px-2 py-1.5 shadow-xl shadow-black/5 dark:border-stone-700 dark:bg-stone-800 dark:shadow-none" className="fixed left-1/2 -translate-x-1/2 top-8 z-[999] flex items-center justify-between gap-4 rounded-[50px] border-[1px] border-solid bg-white px-2 py-1.5 shadow-xl shadow-black/5 dark:border-stone-700 dark:bg-stone-800 dark:shadow-none"
> >
<section className="flex items-center gap-1.5"> <section className="flex items-center gap-1.5">
<LoadingSpinner /> <LoadingSpinner />
<p className="text-[12.5px] font-medium">{t("refreshing")}...</p> <p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
</section> </section>
</div> </m.div>
</AnimatePresence> </AnimatePresence>
) )
} }
@ -277,7 +285,7 @@ function Overview() {
return () => clearInterval(timer) return () => clearInterval(timer)
}, []) }, [])
return ( return (
<section className={"mt-6 flex flex-col md:mt-8 header-timer"}> <section className={"mt-10 flex flex-col md:mt-16 header-timer"}>
<p className="text-base font-semibold">👋 {t("overview")}</p> <p className="text-base font-semibold">👋 {t("overview")}</p>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<p className="text-sm font-medium opacity-50">{t("whereTheTimeIs")}</p> <p className="text-sm font-medium opacity-50">{t("whereTheTimeIs")}</p>

View File

@ -0,0 +1,54 @@
"use client"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { CheckCircleIcon, LanguageIcon } from "@heroicons/react/20/solid"
import { useTranslation } from "react-i18next"
export function LanguageSwitcher() {
const { t, i18n } = useTranslation()
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
const locale = i18n.languages[0]
const handleSelect = (e: Event, newLocale: string) => {
e.preventDefault() // 阻止默认的关闭行为
i18n.changeLanguage(newLocale)
}
const localeItems = [
{ name: t("language.zh-CN"), code: "zh-CN" },
{ name: t("language.zh-TW"), code: "zh-TW" },
{ name: t("language.en-US"), code: "en-US" },
{ name: t("language.ru-RU"), code: "ru-RU" },
{ name: t("language.es-ES"), code: "es-ES" },
{ name: t("language.de-DE"), code: "de-DE" },
{ name: t("language.ta-IN"), code: "ta-IN" },
]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn("rounded-full px-[9px] bg-white dark:bg-black", {
"bg-white/70 dark:bg-black/70": customBackgroundImage,
})}
>
<LanguageIcon className="size-4" />
<span className="sr-only">Change language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col gap-0.5" align="end">
{localeItems.map((item) => (
<DropdownMenuItem key={item.code} onSelect={(e) => handleSelect(e, item.code)} className={locale === item.code ? "bg-muted gap-3" : ""}>
{item.name} {locale === item.code && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -90,30 +90,21 @@ export const NetworkChartClient = React.memo(function NetworkChart({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const defaultChart = "All"
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
const forcePeakCutEnabled = (window.ForcePeakCutEnabled as boolean) ?? false const forcePeakCutEnabled = (window.ForcePeakCutEnabled as boolean) ?? false
// Change from string to string array for multi-selection const [activeChart, setActiveChart] = React.useState(defaultChart)
const [activeCharts, setActiveCharts] = React.useState<string[]>([])
const [isPeakEnabled, setIsPeakEnabled] = React.useState(forcePeakCutEnabled) const [isPeakEnabled, setIsPeakEnabled] = React.useState(forcePeakCutEnabled)
// Function to clear all selected charts const handleButtonClick = useCallback(
const clearAllSelections = useCallback(() => { (chart: string) => {
setActiveCharts([]) setActiveChart((prev) => (prev === chart ? defaultChart : chart))
}, []) },
[defaultChart],
// Updated to handle multiple selections )
const handleButtonClick = useCallback((chart: string) => {
setActiveCharts((prev) => {
// If chart is already selected, remove it
if (prev.includes(chart)) {
return prev.filter((c) => c !== chart)
}
// Otherwise, add it to selected charts
return [...prev, chart]
})
}, [])
const getColorByIndex = useCallback( const getColorByIndex = useCallback(
(chart: string) => { (chart: string) => {
@ -128,7 +119,7 @@ export const NetworkChartClient = React.memo(function NetworkChart({
chartDataKey.map((key) => ( chartDataKey.map((key) => (
<button <button
key={key} key={key}
data-active={activeCharts.includes(key)} data-active={activeChart === key}
className={`relative z-30 flex cursor-pointer grow basis-0 flex-col justify-center gap-1 border-b border-neutral-200 dark:border-neutral-800 px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-6`} className={`relative z-30 flex cursor-pointer grow basis-0 flex-col justify-center gap-1 border-b border-neutral-200 dark:border-neutral-800 px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-6`}
onClick={() => handleButtonClick(key)} onClick={() => handleButtonClick(key)}
> >
@ -136,27 +127,13 @@ export const NetworkChartClient = React.memo(function NetworkChart({
<span className="text-md font-bold leading-none sm:text-lg">{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms</span> <span className="text-md font-bold leading-none sm:text-lg">{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms</span>
</button> </button>
)), )),
[chartDataKey, activeCharts, chartData, handleButtonClick], [chartDataKey, activeChart, chartData, handleButtonClick],
) )
const chartLines = useMemo(() => { const chartLines = useMemo(() => {
// If we have active charts selected, render only those if (activeChart !== defaultChart) {
if (activeCharts.length > 0) { return <Line isAnimationActive={false} strokeWidth={1} type="linear" dot={false} dataKey="avg_delay" stroke={getColorByIndex(activeChart)} />
return activeCharts.map((chart) => (
<Line
key={chart}
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey={chart} // Change from "avg_delay" to the actual chart key name
stroke={getColorByIndex(chart)}
name={chart}
connectNulls={true}
/>
))
} }
// Otherwise show all charts (default view)
return chartDataKey.map((key) => ( return chartDataKey.map((key) => (
<Line <Line
key={key} key={key}
@ -169,16 +146,14 @@ export const NetworkChartClient = React.memo(function NetworkChart({
connectNulls={true} connectNulls={true}
/> />
)) ))
}, [activeCharts, chartDataKey, getColorByIndex]) }, [activeChart, defaultChart, chartDataKey, getColorByIndex])
const processedData = useMemo(() => { const processedData = useMemo(() => {
if (!isPeakEnabled) { if (!isPeakEnabled) {
// Always use formattedData when multiple charts are selected or none selected return activeChart === defaultChart ? formattedData : chartData[activeChart]
return formattedData
} }
// For peak cutting, always use the formatted data which contains all series const data = (activeChart === defaultChart ? formattedData : chartData[activeChart]) as ResultItem[]
const data = formattedData
const windowSize = 11 // 增加窗口大小以获取更好的统计效果 const windowSize = 11 // 增加窗口大小以获取更好的统计效果
const alpha = 0.3 // EWMA平滑因子 const alpha = 0.3 // EWMA平滑因子
@ -225,16 +200,14 @@ export const NetworkChartClient = React.memo(function NetworkChart({
const window = data.slice(index - windowSize + 1, index + 1) const window = data.slice(index - windowSize + 1, index + 1)
const smoothed = { ...point } as ResultItem const smoothed = { ...point } as ResultItem
// Process all chart keys or just the selected ones if (activeChart === defaultChart) {
const keysToProcess = activeCharts.length > 0 ? activeCharts : chartDataKey chartDataKey.forEach((key) => {
keysToProcess.forEach((key) => {
const values = window.map((w) => w[key]).filter((v) => v !== undefined && v !== null) as number[] const values = window.map((w) => w[key]).filter((v) => v !== undefined && v !== null) as number[]
if (values.length > 0) { if (values.length > 0) {
const processed = processValues(values) const processed = processValues(values)
if (processed !== null) { if (processed !== null) {
// Apply EWMA smoothing // 应用EWMA平滑
if (ewmaHistory[key] === undefined) { if (ewmaHistory[key] === undefined) {
ewmaHistory[key] = processed ewmaHistory[key] = processed
} else { } else {
@ -244,10 +217,26 @@ export const NetworkChartClient = React.memo(function NetworkChart({
} }
} }
}) })
} else {
const values = window.map((w) => w.avg_delay).filter((v) => v !== undefined && v !== null) as number[]
if (values.length > 0) {
const processed = processValues(values)
if (processed !== null) {
// 应用EWMA平滑
if (ewmaHistory["current"] === undefined) {
ewmaHistory["current"] = processed
} else {
ewmaHistory["current"] = alpha * processed + (1 - alpha) * ewmaHistory["current"]
}
smoothed.avg_delay = ewmaHistory["current"]
}
}
}
return smoothed return smoothed
}) })
}, [isPeakEnabled, activeCharts, formattedData, chartDataKey]) }, [isPeakEnabled, activeChart, formattedData, chartData, chartDataKey, defaultChart])
return ( return (
<Card <Card
@ -271,15 +260,6 @@ export const NetworkChartClient = React.memo(function NetworkChart({
<div className="flex flex-wrap w-full">{chartButtons}</div> <div className="flex flex-wrap w-full">{chartButtons}</div>
</CardHeader> </CardHeader>
<CardContent className="pr-2 pl-0 py-4 sm:pt-6 sm:pb-6 sm:pr-6 sm:pl-2"> <CardContent className="pr-2 pl-0 py-4 sm:pt-6 sm:pb-6 sm:pr-6 sm:pl-2">
<div className="relative">
{activeCharts.length > 0 && (
<button
className="absolute -top-2 right-1 z-10 text-xs px-2 py-1 bg-stone-100/80 dark:bg-stone-800/80 backdrop-blur-sm rounded-[5px] text-muted-foreground hover:text-foreground transition-colors"
onClick={clearAllSelections}
>
{t("monitor.clearSelections", "Clear")} ({activeCharts.length})
</button>
)}
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full"> <ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
<LineChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}> <LineChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
@ -329,11 +309,10 @@ export const NetworkChartClient = React.memo(function NetworkChart({
/> />
} }
/> />
<ChartLegend content={<ChartLegendContent />} /> {activeChart === defaultChart && <ChartLegend content={<ChartLegendContent />} />}
{chartLines} {chartLines}
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@ -9,9 +9,10 @@ import { useNavigate } from "react-router-dom"
import PlanInfo from "./PlanInfo" import PlanInfo from "./PlanInfo"
import BillingInfo from "./billingInfo" import BillingInfo from "./billingInfo"
import { Badge } from "./ui/badge"
import { Card, CardContent, CardHeader, CardFooter } from "./ui/card" import { Card, CardContent, CardHeader, CardFooter } from "./ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
import { ArrowDown, ArrowUp, Clock, Cpu, HardDrive, Server, Activity, BarChart3 } from "lucide-react" import { ArrowDown, ArrowUp, Clock, Cpu, HardDrive, Server, Activity, BarChart3, Calendar } from "lucide-react"
interface ServerCardProps { interface ServerCardProps {
now: number; now: number;
@ -19,10 +20,9 @@ interface ServerCardProps {
cycleStats?: { cycleStats?: {
[key: string]: CycleTransferData [key: string]: CycleTransferData
}; };
groupName?: string;
} }
export default function ServerCard({ now, serverInfo, cycleStats, groupName }: ServerCardProps) { export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { const {
@ -45,9 +45,7 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
udp, udp,
process, process,
uptime, uptime,
arch, last_active_time_string
swap,
swap_total
} = formatNezhaInfo( } = formatNezhaInfo(
now, now,
serverInfo, serverInfo,
@ -62,6 +60,8 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
// @ts-expect-error ShowNetTransfer is a global variable // @ts-expect-error ShowNetTransfer is a global variable
const showNetTransfer = window.ShowNetTransfer as boolean const showNetTransfer = window.ShowNetTransfer as boolean
// @ts-expect-error ShowServerDetails is a global variable
const showServerDetails = window.ShowServerDetails !== undefined ? window.ShowServerDetails as boolean : true
const parsedData = parsePublicNote(public_note) const parsedData = parsePublicNote(public_note)
@ -181,16 +181,6 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
return "bg-emerald-500" return "bg-emerald-500"
} }
// 格式化大数值超过1000显示为k格式
const formatLargeNumber = (num: number) => {
if (num >= 10000) {
return `${Math.floor(num / 1000)}k+`
} else if (num >= 1000) {
return `${(num / 1000).toFixed(1)}k`
}
return num.toString()
}
if (!online) { if (!online) {
return ( return (
<Card <Card
@ -201,17 +191,7 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
onClick={cardClick} onClick={cardClick}
> >
<div className="absolute top-0 left-0 w-1 h-full bg-red-500 rounded-l-md"></div> <div className="absolute top-0 left-0 w-1 h-full bg-red-500 rounded-l-md"></div>
<CardContent className="p-4">
{/* 离线卡片的分组标签 */}
{groupName && (
<div className="absolute top-2 right-2">
<div className="px-1.5 py-0.5 text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400 rounded-sm border border-red-200 dark:border-red-800">
{groupName}
</div>
</div>
)}
<CardContent className="p-4 pt-6">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<span className="h-3 w-3 shrink-0 rounded-full bg-red-500 shadow-sm pulse-animation shadow-red-300 dark:shadow-red-900"></span> <span className="h-3 w-3 shrink-0 rounded-full bg-red-500 shadow-sm pulse-animation shadow-red-300 dark:shadow-red-900"></span>
{showFlag && <ServerFlag country_code={country_code} />} {showFlag && <ServerFlag country_code={country_code} />}
@ -282,20 +262,14 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
)} )}
onClick={cardClick} onClick={cardClick}
> >
{/* 左侧状态条 */}
<div className="absolute top-0 left-0 w-1 h-full bg-green-500 rounded-l-md"></div> <div className="absolute top-0 left-0 w-1 h-full bg-green-500 rounded-l-md"></div>
<CardHeader className="p-4 pb-2 pt-2"> <CardHeader className="p-4 pb-2">
<div className="flex justify-between"> <div className="flex justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="h-3 w-3 shrink-0 rounded-full bg-green-500 shadow-sm shadow-green-300 dark:shadow-green-900 animate-pulse"></span> <span className="h-3 w-3 shrink-0 rounded-full bg-green-500 shadow-sm shadow-green-300 dark:shadow-green-900 animate-pulse"></span>
{showFlag && <ServerFlag country_code={country_code} />} {showFlag && <ServerFlag country_code={country_code} />}
<h3 className="font-bold text-sm truncate">{name}</h3> <h3 className="font-bold text-sm truncate">{name}</h3>
{groupName && (
<div className="px-1.5 py-0.5 text-[10px] font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400 rounded-sm border border-green-200 dark:border-green-800">
{groupName}
</div>
)}
</div> </div>
<div className="flex items-center text-xs gap-2 text-muted-foreground"> <div className="flex items-center text-xs gap-2 text-muted-foreground">
@ -324,6 +298,13 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
<span>{formatUptime(uptime, t)}</span> <span>{formatUptime(uptime, t)}</span>
</div> </div>
)} )}
{last_active_time_string && (
<div className="flex items-center text-xs text-muted-foreground">
<Calendar className="size-[12px] mr-1" />
<span>{last_active_time_string}</span>
</div>
)}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@ -383,31 +364,11 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
<Cpu className="size-[14px] mr-1 text-blue-500" /> <Cpu className="size-[14px] mr-1 text-blue-500" />
<span className="text-xs">CPU</span> <span className="text-xs">CPU</span>
</div> </div>
<span className={cn("text-xs font-bold", getColorClass(cpu))}>
{cpu.toFixed(0)}%
</span>
</div> </div>
<ServerUsageBar value={cpu} /> <ServerUsageBar value={cpu} />
{/* CPU信息 */}
{cpu_info && cpu_info.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1 text-[10px]">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded px-1.5 py-0.5 text-center">
{cpu_info[0].includes("Physical") ? "pCPU: " : "vCPU: "}
{cpu_info[0].match(/(\d+)\s+(?:Physical|Virtual)\s+Core/)?.[1] || "-"}
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[250px] text-xs whitespace-pre-wrap p-2">
{cpu_info.join("\n")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{arch && (
<div className="bg-green-500/10 text-green-600 dark:text-green-400 rounded px-1.5 py-0.5 text-center">
{arch}
</div>
)}
</div>
)}
</div> </div>
{/* 内存使用率 */} {/* 内存使用率 */}
@ -425,43 +386,11 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
</div> </div>
<span className="text-xs">{t("serverCard.mem")}</span> <span className="text-xs">{t("serverCard.mem")}</span>
</div> </div>
<span className={cn("text-xs font-bold", getColorClass(mem))}>
{mem.toFixed(0)}%
</span>
</div> </div>
<ServerUsageBar value={mem} /> <ServerUsageBar value={mem} />
{/* 内存信息 */}
<div className="mt-1.5 flex flex-col gap-1 text-[10px]">
<div className="bg-purple-500/10 text-purple-600 dark:text-purple-400 rounded px-1.5 py-0.5 text-center">
{mem_total > 0 ? formatBytes(mem_total) : "-"}
</div>
{swap_total > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 rounded px-1.5 py-0.5 text-center",
Number(swap) > 90 ? "bg-red-500/10 text-red-600 dark:text-red-400" :
Number(swap) > 70 ? "bg-orange-500/10 text-orange-600 dark:text-orange-400" : "")}>
SWAP:{swap.toFixed(0)}%
</div>
</TooltipTrigger>
<TooltipContent className="text-xs">
<div className="flex flex-col gap-1 p-2">
<div className="flex justify-between items-center gap-3">
<span>:</span>
<span>{formatBytes(swap_total)}</span>
</div>
<div className="flex justify-between items-center gap-3">
<span>使:</span>
<span className={getColorClass(Number(swap))}>{swap.toFixed(1)}%</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div className="bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded px-1.5 py-0.5 text-center">
-
</div>
)}
</div>
</div> </div>
{/* 存储使用率 */} {/* 存储使用率 */}
@ -471,12 +400,11 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
<HardDrive className="size-[14px] mr-1 text-amber-500" /> <HardDrive className="size-[14px] mr-1 text-amber-500" />
<span className="text-xs">{t("serverCard.stg")}</span> <span className="text-xs">{t("serverCard.stg")}</span>
</div> </div>
<span className={cn("text-xs font-bold", getColorClass(stg))}>
{stg.toFixed(0)}%
</span>
</div> </div>
<ServerUsageBar value={stg} /> <ServerUsageBar value={stg} />
{/* 存储信息 */}
<div className="mt-1.5 bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded px-1.5 py-0.5 text-center text-[10px]">
{disk_total > 0 ? formatBytes(disk_total) : "-"}
</div>
</div> </div>
</div> </div>
@ -487,14 +415,14 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-1">
<div className="flex items-center"> <div className="flex items-center">
<ArrowUp className="size-[14px] text-blue-500 mr-1" /> <ArrowUp className="size-[14px] text-blue-500 mr-1" />
<span className="text-xs">Up</span> <span className="text-xs">{t("serverCard.upload")}</span>
</div> </div>
<span className="text-xs font-medium">{formatSpeed(up)}</span> <span className="text-xs font-medium">{formatSpeed(up)}</span>
</div> </div>
<div className="flex justify-between items-center mt-2"> <div className="flex justify-between items-center mt-2">
<div className="flex items-center"> <div className="flex items-center">
<ArrowDown className="size-[14px] text-green-500 mr-1" /> <ArrowDown className="size-[14px] text-green-500 mr-1" />
<span className="text-xs">Down</span> <span className="text-xs">{t("serverCard.download")}</span>
</div> </div>
<span className="text-xs font-medium">{formatSpeed(down)}</span> <span className="text-xs font-medium">{formatSpeed(down)}</span>
</div> </div>
@ -502,20 +430,64 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
{/* 连接数与进程数 */} {/* 连接数与进程数 */}
<div className="bg-muted/40 rounded-lg p-2 grid grid-cols-2 gap-2"> <div className="bg-muted/40 rounded-lg p-2 grid grid-cols-2 gap-2">
<div className="flex items-center min-w-0"> <div className="flex items-center">
<Server className="size-[14px] text-indigo-500 mr-1 flex-shrink-0" /> <Server className="size-[14px] text-indigo-500 mr-1" />
<span className="text-xs truncate" title={`TCP连接: ${tcp}`}>T: {formatLargeNumber(tcp)}</span> <span className="text-xs">T: {tcp}</span>
</div> </div>
<div className="flex items-center min-w-0"> <div className="flex items-center">
<Server className="size-[14px] text-pink-500 mr-1 flex-shrink-0" /> <Server className="size-[14px] text-pink-500 mr-1" />
<span className="text-xs truncate" title={`UDP连接: ${udp}`}>U: {formatLargeNumber(udp)}</span> <span className="text-xs">U: {udp}</span>
</div> </div>
<div className="flex items-center min-w-0 col-span-2"> <div className="flex items-center">
<Activity className="size-[14px] text-orange-500 mr-1 flex-shrink-0" /> <Activity className="size-[14px] text-orange-500 mr-1" />
<span className="text-xs truncate" title={`进程数: ${process}`}>P: {formatLargeNumber(process)}</span> <span className="text-xs">P: {process}</span>
</div> </div>
</div> </div>
</div> </div>
{/* 服务器详细信息区域 */}
{showServerDetails && (
<div className="mt-3 flex items-center flex-wrap gap-1.5">
{/* CPU信息 */}
{cpu_info && cpu_info.length > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-[10px] py-0 h-5 bg-blue-500/10 hover:bg-blue-500/20">
{cpu_info[0].includes("Physical") ? "pCPU: " : "vCPU: "}
{cpu_info[0].match(/(\d+)\s+(?:Physical|Virtual)\s+Core/)?.[1] || "-"}
</Badge>
</TooltipTrigger>
<TooltipContent className="text-xs">
{cpu_info.join(", ")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* 内存大小 */}
{mem_total > 0 ? (
<Badge variant="outline" className="text-[10px] py-0 h-5 bg-purple-500/10 hover:bg-purple-500/20">
RAM: {formatBytes(mem_total)}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] py-0 h-5 bg-purple-500/10 hover:bg-purple-500/20">
RAM: -
</Badge>
)}
{/* 存储大小 */}
{disk_total > 0 ? (
<Badge variant="outline" className="text-[10px] py-0 h-5 bg-amber-500/10 hover:bg-amber-500/20">
DISK: {formatBytes(disk_total)}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] py-0 h-5 bg-amber-500/10 hover:bg-amber-500/20">
DISK: -
</Badge>
)}
</div>
)}
</CardContent> </CardContent>
<CardFooter className="p-4 pt-0 flex flex-col gap-2 pb-3"> <CardFooter className="p-4 pt-0 flex flex-col gap-2 pb-3">

View File

@ -0,0 +1,39 @@
"use client"
import { useTheme } from "@/hooks/use-theme"
import { useEffect } from "react"
export function ThemeColorManager() {
const { theme } = useTheme()
useEffect(() => {
const updateThemeColor = () => {
const currentTheme = theme
const meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
const newMeta = document.createElement("meta")
newMeta.name = "theme-color"
document.head.appendChild(newMeta)
}
const themeColor =
currentTheme === "dark"
? "hsl(30 15% 8%)" // 深色模式背景色
: "hsl(0 0% 98%)" // 浅色模式背景色
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
}
// Update on mount and theme change
updateThemeColor()
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
mediaQuery.addEventListener("change", updateThemeColor)
return () => mediaQuery.removeEventListener("change", updateThemeColor)
}, [theme])
return null
}

View File

@ -1,9 +1,11 @@
import { ReactNode, createContext } from "react" import { ReactNode, createContext, useEffect, useState } from "react"
export type Theme = "dark" export type Theme = "dark" | "light" | "system"
type ThemeProviderProps = { type ThemeProviderProps = {
children: ReactNode children: ReactNode
defaultTheme?: Theme
storageKey?: string
} }
type ThemeProviderState = { type ThemeProviderState = {
@ -12,22 +14,40 @@ type ThemeProviderState = {
} }
const initialState: ThemeProviderState = { const initialState: ThemeProviderState = {
theme: "dark", theme: "system",
setTheme: () => null, setTheme: () => null,
} }
const ThemeProviderContext = createContext<ThemeProviderState>(initialState) const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({ children }: ThemeProviderProps) { export function ThemeProvider({ children, storageKey = "vite-ui-theme" }: ThemeProviderProps) {
const root = window.document.documentElement const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || "system")
root.classList.remove("light")
root.classList.add("dark")
const themeColor = "hsl(30 15% 8%)"
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
const value: ThemeProviderState = { useEffect(() => {
theme: "dark", const root = window.document.documentElement
setTheme: () => null,
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
root.classList.add(systemTheme)
const themeColor = systemTheme === "dark" ? "hsl(30 15% 8%)" : "hsl(0 0% 98%)"
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
return
}
root.classList.add(theme)
const themeColor = theme === "dark" ? "hsl(30 15% 8%)" : "hsl(0 0% 98%)"
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
} }
return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider> return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>

View File

@ -0,0 +1,53 @@
import { Theme } from "@/components/ThemeProvider"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { CheckCircleIcon } from "@heroicons/react/20/solid"
import { Moon, Sun } from "lucide-react"
import { useTranslation } from "react-i18next"
import { useTheme } from "../hooks/use-theme"
export function ModeToggle() {
const { t } = useTranslation()
const { setTheme, theme } = useTheme()
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
const handleSelect = (e: Event, newTheme: Theme) => {
e.preventDefault()
setTheme(newTheme)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn("rounded-full px-[9px] bg-white dark:bg-black", {
"bg-white/70 dark:bg-black/70": customBackgroundImage,
})}
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col gap-0.5" align="end">
<DropdownMenuItem className={cn({ "gap-3 bg-muted": theme === "light" })} onSelect={(e) => handleSelect(e, "light")}>
{t("theme.light")}
{theme === "light" && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
<DropdownMenuItem className={cn({ "gap-3 bg-muted": theme === "dark" })} onSelect={(e) => handleSelect(e, "dark")}>
{t("theme.dark")}
{theme === "dark" && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
<DropdownMenuItem className={cn({ "gap-3 bg-muted": theme === "system" })} onSelect={(e) => handleSelect(e, "system")}>
{t("theme.system")}
{theme === "system" && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,8 +1,8 @@
import { createContext } from "react" import { createContext } from "react"
export type SortType = "default" | "name" | "uptime" | "system" | "cpu" | "mem" | "disk" | "up" | "down" | "up total" | "down total" export type SortType = "default" | "name" | "uptime" | "system" | "cpu" | "mem" | "stg" | "up" | "down" | "up total" | "down total"
export const SORT_TYPES: SortType[] = ["default", "name", "uptime", "system", "cpu", "mem", "disk", "up", "down", "up total", "down total"] export const SORT_TYPES: SortType[] = ["default", "name", "uptime", "system", "cpu", "mem", "stg", "up", "down", "up total", "down total"]
export type SortOrder = "asc" | "desc" export type SortOrder = "asc" | "desc"

View File

@ -1,21 +1,54 @@
import i18n from "i18next" import i18n from "i18next"
import { initReactI18next } from "react-i18next" import { initReactI18next } from "react-i18next"
import deTranslation from "./locales/de/translation.json"
import enTranslation from "./locales/en/translation.json" import enTranslation from "./locales/en/translation.json"
import esTranslation from "./locales/es/translation.json"
import ruTranslation from "./locales/ru/translation.json"
import taTranslation from "./locales/ta/translation.json"
import zhCNTranslation from "./locales/zh-CN/translation.json"
import zhTWTranslation from "./locales/zh-TW/translation.json"
const resources = { const resources = {
"en-US": { "en-US": {
translation: enTranslation, translation: enTranslation,
}, },
"zh-CN": {
translation: zhCNTranslation,
},
"zh-TW": {
translation: zhTWTranslation,
},
"de-DE": {
translation: deTranslation,
},
"es-ES": {
translation: esTranslation,
},
"ru-RU": {
translation: ruTranslation,
},
"ta-IN": {
translation: taTranslation,
},
}
const getStoredLanguage = () => {
return localStorage.getItem("language") || "en-US"
} }
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
resources, resources,
lng: "en-US", lng: getStoredLanguage(), // 使用localStorage中存储的语言或默认值
fallbackLng: "en-US", fallbackLng: "en-US", // 当前语言的翻译没有找到时,使用的备选语言
interpolation: { interpolation: {
escapeValue: false, escapeValue: false, // react已经安全地转义
}, },
}) })
// 添加语言改变时的处理函数
i18n.on("languageChanged", (lng) => {
localStorage.setItem("language", lng)
})
export default i18n export default i18n

View File

@ -3,7 +3,7 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
font-family: -apple-system, BlinkMacSystemFont, 'Noto Sans SC', system-ui, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
@ -92,8 +92,7 @@
@apply scroll-smooth; @apply scroll-smooth;
} }
body { body {
@apply text-foreground; @apply bg-background text-foreground;
background: transparent !important;
/* font-feature-settings: "rlig" 1, "calt" 1; */ /* font-feature-settings: "rlig" 1, "calt" 1; */
font-synthesis-weight: none; font-synthesis-weight: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;

View File

@ -1,9 +1,9 @@
export function formatBytes(bytes: number, decimals: number = 2) { export function formatBytes(bytes: number, decimals: number = 2) {
if (!+bytes) return "0 B" if (!+bytes) return "0 Bytes"
const k = 1024 const k = 1024
const dm = decimals < 0 ? 0 : decimals const dm = decimals < 0 ? 0 : decimals
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))

View File

@ -0,0 +1,120 @@
{
"refreshing": "Aktualisieren",
"serviceTracker": {
"uptime": "Uptime",
"today": "Heute",
"noService": "Keine Servicedaten",
"daysAgo": "vor Tagen",
"loading": "Laden..."
},
"serverCard": {
"uptime": "Uptime",
"mem": "MEM",
"upload": "Upload",
"download": "Download",
"system": "System",
"stg": "STG",
"totalDownload": "Download",
"days": "Tage",
"hours": "Stunden",
"totalUpload": "Upload"
},
"serverDetail": {
"unknown": "Unbekannt",
"arch": "Arch",
"status": "Status",
"online": "Online",
"days": "Tage",
"upload": "Upload",
"download": "Download",
"offline": "Offline",
"uptime": "Uptime",
"version": "Version",
"mem": "Speicher",
"disk": "Festplatte",
"region": "Region",
"system": "System",
"lastActive": "Letzte Aktivität",
"temperature": "Temperatur"
},
"theme": {
"system": "Folgen Sie dem System",
"light": "Hell",
"dark": "Dunkel"
},
"monitor": {
"monitorCount": "Services",
"noData": "Kein Server Monitoring Daten, bitte fügen sie zuerst einen Monitor hinzu",
"avgDelay": "Latenz"
},
"billingInfo": {
"error": "Fehler",
"remaining": "Verbleibend",
"indefinite": "Unbestimmt",
"expired": "Verfallen",
"days": "tage",
"price": "Preis",
"free": "Kostenlos",
"usage-baseed": "Verwendungsbasiert"
},
"overview": "Überblick",
"map": {
"Regions": "Regionen",
"Servers": "server",
"Distributions": "Server sind verteilt in"
},
"pwa": {
"reload": "Update",
"newContent": "Neue Inhalte verfügbar",
"offlineReady": "Anwendung bereit, offline zu verwenden"
},
"error": {
"pageNotFound": "Seite nicht gefunden",
"backToHome": "Zurück zur Startseite"
},
"whereTheTimeIs": "Wo die Zeit ist",
"info": {
"websocketConnecting": "WebSocket verbindet",
"websocketConnected": "WebSocket verbunden",
"websocketDisconnected": "WebSocket getrennt",
"processing": "Verarbeiten..."
},
"tabSwitch": {
"Network": "Netzwerk",
"Detail": "Detail"
},
"nezha": "Nezha Monitoring",
"dashboard": "Dashboard",
"serverDetailChart": {
"upload": "Upload",
"download": "Download",
"process": "Prozess",
"disk": "Festplatte",
"mem": "Speicher",
"swap": "Swap"
},
"language": {
"zh-TW": "Traditionelles Chinesisch",
"en-US": "Englisch",
"zh-CN": "vereinfachtes Chinesisch"
},
"online": "Online",
"offline": "Offline",
"serverOverview": {
"totalServers": "Server insgesamt",
"onlineServers": "Online Server",
"offlineServers": "Offline Server",
"totalBandwidth": "Gesamte Bandbreite",
"speed": "Geschwindigkeit",
"network": "Netzwerk"
},
"cycleTransfer": {
"used": "benutzt",
"total": "gesamt",
"nextUpdate": "nächstes update"
},
"footer": {
"themeBy": "Design von "
},
"login": "Login"
}

View File

@ -0,0 +1,131 @@
{
"serviceTracker": {
"delay": "Latencia",
"noService": "No hay datos de servicio",
"uptime": "Tiempo de actividad",
"daysAgo": "días atrás",
"today": "Hoy",
"loading": "Cargando..."
},
"serverDetail": {
"disk": "Disco",
"region": "Región",
"system": "Sistema Operativo",
"lastActive": "Última vez activo",
"temperature": "Temperatura",
"bootTime": "Inicio del sistema",
"arch": "Arch",
"status": "Estado",
"online": "En línea",
"version": "Versión",
"offline": "Fuera de línea",
"unknown": "Desconocido",
"days": "Días",
"hours": "Horas",
"download": "Bajada",
"uptime": "Tiempo de actividad",
"mem": "Memoria",
"upload": "Subida"
},
"serverDetailChart": {
"process": "Procesos",
"disk": "Disco",
"mem": "Memoria",
"swap": "Swap",
"upload": "Subida",
"download": "Bajada"
},
"language": {
"en-US": "Inglés",
"zh-TW": "Chino Tradicional",
"zh-CN": "Chino simplificado"
},
"TypeCommand": "Escriba un comando o busca...",
"Shortcuts": "Atajos",
"Home": "Inicio",
"login": "Iniciar sesión",
"online": "En línea",
"offline": "Fuerda de línea",
"whereTheTimeIs": "Hora actual",
"serverOverview": {
"totalBandwidth": "Ancho de banda total",
"speed": "Velocidad",
"network": "Red",
"onlineServers": "Servidores en línea",
"totalServers": "Total de Servidores",
"offlineServers": "Servidores fuera de línea"
},
"map": {
"Regions": "Regiones",
"Servers": "Servidores",
"Distributions": "Servidores distribuidos en"
},
"overview": "Descripción general",
"dashboard": "Panel",
"nezha": "Monitoreo Nezha",
"serverCard": {
"mem": "Ram",
"days": "Días",
"hours": "Horas",
"upload": "Subida",
"download": "Bajada",
"system": "Sistema operativo",
"uptime": "Tiempo de actividad",
"totalUpload": "Subida",
"totalDownload": "Bajada",
"stg": "Almacenamiento"
},
"cycleTransfer": {
"used": "Usado",
"total": "total",
"nextUpdate": "próxima actualización"
},
"tabSwitch": {
"Detail": "Detalle",
"Network": "Red"
},
"monitor": {
"avgDelay": "Latencia",
"noData": "No hay datos de servidores, primero agregue un monitor de servicio",
"monitorCount": "Servicios"
},
"error": {
"pageNotFound": "Página no encontrada",
"backToHome": "Volver al Inicio"
},
"theme": {
"system": "Sistema",
"dark": "Oscuro",
"light": "Claro"
},
"billingInfo": {
"remaining": "Restante",
"error": "error",
"days": "días",
"price": "Precio",
"free": "Gratis",
"indefinite": "Indedinido",
"expired": "Expirado",
"usage-baseed": "Basado en el uso"
},
"pwa": {
"offlineReady": "Aplicacion lista para trabajar fuera de línea",
"newContent": "Nuevo contenido disponible",
"reload": "Actualizar"
},
"info": {
"websocketConnecting": "Conexión WebSocket",
"websocketDisconnected": "WebSocket desconectado",
"websocketConnected": "WebSocket conectado",
"processing": "Procesando..."
},
"NoResults": "No se encontraron resultados.",
"refreshing": "Actualizando",
"Servers": "Servidores",
"ToggleLightMode": "Activar el modo claro",
"ToggleDarkMode": "Activar el modo oscuro",
"ToggleSystemMode": "Activar modo del sistema",
"footer": {
"themeBy": "Tema por. "
}
}

View File

@ -0,0 +1,135 @@
{
"map": {
"Servers": "сервера",
"Distributions": "Серверы распределены в",
"Regions": "Регионы"
},
"serverDetailChart": {
"disk": "Диск",
"download": "Скачивание",
"swap": "Swap",
"upload": "Загрузка",
"mem": "Mem",
"process": "Процесс"
},
"serverCard": {
"system": "Система",
"hours": "Часов",
"uptime": "Аптайм",
"download": "Скачивание",
"mem": "MEM",
"stg": "STG",
"upload": "Загрузка",
"totalUpload": "Загружено",
"totalDownload": "Скачано",
"days": "Дней"
},
"tabSwitch": {
"Detail": "Детали",
"Network": "Сеть"
},
"whereTheTimeIs": "Где время",
"theme": {
"dark": "Темная тема",
"light": "Светлая тема",
"system": "Как в Системе"
},
"login": "Логин",
"language": {
"zh-TW": "Традиционный китайский",
"zh-CN": "Упрощенный китайский",
"en-US": "Английский",
"de-DE": "Немецкий",
"ta-IN": "Тамильский",
"ru-RU": "Русский",
"es-ES": "Испанский"
},
"overview": "Обзор",
"info": {
"websocketConnecting": "WebSocket подключение",
"websocketConnected": "WebSocket подключен",
"websocketDisconnected": "WebSocket отключен",
"processing": "Обработка..."
},
"cycleTransfer": {
"nextUpdate": "следующее обновление",
"used": "использовано",
"total": "всего"
},
"dashboard": "Панель",
"online": "Онлайн",
"refreshing": "Обновление",
"serverOverview": {
"totalServers": "Всего Серверов",
"totalBandwidth": "Общая пропускная способность",
"network": "Сеть",
"speed": "Скорость",
"onlineServers": "Серверы в сети",
"offlineServers": "Серверы не в сети"
},
"serviceTracker": {
"noService": "Нет данных о сервисе",
"delay": "Задержка",
"daysAgo": "Дней назад",
"today": "Сегодня",
"uptime": "Аптайм",
"loading": "Загрузка..."
},
"serverDetail": {
"status": "Статус",
"days": "Дней",
"hours": "Часов",
"offline": "Оффлайн",
"uptime": "Аптайм",
"arch": "Arch",
"mem": "Mem",
"disk": "Диск",
"system": "Система",
"lastActive": "Время последней активности",
"download": "Скачивание",
"unknown": "Неизвестно",
"version": "Версия",
"online": "В сети",
"region": "Регион",
"bootTime": "Время загрузки",
"upload": "Загрузка",
"temperature": "Температура"
},
"monitor": {
"noData": "Нет данных мониторинга сервера, пожалуйста, сначала добавьте монитор службы",
"avgDelay": "Задержка",
"monitorCount": "Сервисы"
},
"pwa": {
"newContent": "Доступен новый контент",
"reload": "Обновить",
"offlineReady": "Приложение готово работать в офлайн-режиме"
},
"billingInfo": {
"remaining": "Осталось",
"error": "ошибка",
"indefinite": "Неопределено",
"expired": "Истекло",
"price": "Цена",
"free": "Бесплатно",
"days": "дней",
"usage-baseed": "Оплата по использованию"
},
"TypeCommand": "Введите команду или выполните поиск...",
"Servers": "Серверы",
"ToggleLightMode": "Переключить на светлую тему",
"Home": "Главная",
"offline": "Оффлайн",
"error": {
"pageNotFound": "Страница не найдена",
"backToHome": "Вернуться на главную"
},
"ToggleDarkMode": "Переключить на темную тему",
"Shortcuts": "Горячие клавиши",
"ToggleSystemMode": "Использовать системную тему",
"footer": {
"themeBy": "Тема от "
},
"NoResults": "Ничего не найдено.",
"nezha": "Nezha Monitoring"
}

View File

@ -0,0 +1,120 @@
{
"nezha": "கண்காணிப்பு",
"overview": "கண்ணோட்டம்",
"dashboard": "முகப்புப்பெட்டி",
"login": "புகுபதிவு",
"serverCard": {
"mem": "மெம்",
"stg": "Stg",
"days": "நாட்கள்",
"hours": "மணி",
"upload": "பதிவேற்றும்",
"download": "பதிவிறக்கம்",
"system": "மண்டலம்",
"uptime": "நேரம்",
"totalUpload": "பதிவேற்றும்",
"totalDownload": "பதிவிறக்கம்"
},
"online": "ஆன்லைனில்",
"offline": "இணையமில்லாமல்",
"whereTheTimeIs": "நேரம் இருக்கும் இடம்",
"refreshing": "புத்துணர்ச்சி",
"info": {
"websocketConnecting": "வெப்சாக்கெட் இணைத்தல்",
"websocketConnected": "வெப்சாக்கெட் இணைக்கப்பட்டுள்ளது",
"websocketDisconnected": "வெப்சாக்கெட் துண்டிக்கப்பட்டது",
"processing": "செயலாக்கம் ..."
},
"serverOverview": {
"totalServers": "மொத்த சேவையகங்கள்",
"onlineServers": "நிகழ்நிலை சேவையகங்கள்",
"offlineServers": "இணைப்பில்லாத சேவையகங்கள்",
"totalBandwidth": "மொத்த அலைவரிசை",
"speed": "வேகம்",
"network": "பிணையம்"
},
"map": {
"Distributions": "சேவையகங்கள் விநியோகிக்கப்படுகின்றன",
"Regions": "பகுதிகள்",
"Servers": "சேவையகங்கள்"
},
"cycleTransfer": {
"used": "பயன்படுத்தப்பட்டது",
"total": "மொத்தம்",
"nextUpdate": "அடுத்த புதுப்பிப்பு"
},
"serverDetail": {
"offline": "இணையமில்லாமல்",
"unknown": "தெரியவில்லை",
"uptime": "நேரம்",
"version": "பதிப்பு",
"arch": "மான்",
"mem": "மெம்",
"disk": "வட்டு",
"region": "பகுதி",
"system": "மண்டலம்",
"status": "நிலை",
"online": "ஆன்லைனில்",
"days": "நாட்கள்",
"upload": "பதிவேற்றும்",
"download": "பதிவிறக்கம்",
"lastActive": "கடைசி செயலில் நேரம்",
"temperature": "வெப்பநிலை"
},
"serverDetailChart": {
"swap": "இடமாற்றம்",
"upload": "பதிவேற்றும்",
"download": "பதிவிறக்கம்",
"process": "செயல்முறை",
"disk": "வட்டு",
"mem": "மெம்"
},
"footer": {
"themeBy": "மூலம் கருப்பொருள் "
},
"language": {
"zh-CN": "எளிமைப்படுத்தப்பட்ட சீன",
"zh-TW": "பாரம்பரிய சீன",
"en-US": "ஆங்கிலம்"
},
"theme": {
"light": "ஒளி",
"dark": "இருண்ட",
"system": "மண்டலம்"
},
"error": {
"pageNotFound": "பக்கம் கிடைக்கவில்லை",
"backToHome": "வீட்டிற்கு திரும்பவும்"
},
"tabSwitch": {
"Detail": "விவரம்",
"Network": "பிணையம்"
},
"monitor": {
"noData": "சேவையக மானிட்டர் தரவு இல்லை, முதலில் ஒரு பணி மானிட்டரைச் சேர்க்கவும்",
"avgDelay": "சுணக்கம்",
"monitorCount": "சேவைகள்"
},
"pwa": {
"offlineReady": "ஆஃப்லைனில் வேலை செய்ய பயன்பாடு தயாராக உள்ளது",
"newContent": "புதிய உள்ளடக்கம் கிடைக்கிறது",
"reload": "புதுப்பிப்பு"
},
"billingInfo": {
"remaining": "மீதமுள்ள",
"error": "பிழை",
"indefinite": "காலவரையற்றது",
"expired": "காலாவதியான",
"days": "நாட்கள்",
"price": "விலை",
"free": "இலவசம்",
"usage-baseed": "பயன்பாடு அடிப்படையிலானது"
},
"serviceTracker": {
"noService": "பணி தரவு இல்லை",
"uptime": "நேரம்",
"daysAgo": "சில நாட்களுக்கு முன்பு",
"today": "இன்று",
"loading": "ஏற்றுகிறது ..."
}
}

View File

@ -0,0 +1,136 @@
{
"nezha": "服务器监控",
"overview": "概览",
"dashboard": "管理后台",
"login": "登录",
"online": "在线",
"offline": "离线",
"whereTheTimeIs": "当前时间",
"refreshing": "刷新中",
"info": {
"websocketConnecting": "WebSocket 连接中",
"websocketConnected": "WebSocket 连接成功",
"websocketDisconnected": "WebSocket 连接断开",
"processing": "处理中..."
},
"serverOverview": {
"totalServers": "服务器总数",
"onlineServers": "在线服务器",
"offlineServers": "离线服务器",
"totalBandwidth": "总流量",
"speed": "速率",
"network": "网络"
},
"map": {
"Distributions": "服务器分布在",
"Regions": "个区域",
"Servers": "个服务器"
},
"serverCard": {
"mem": "内存",
"stg": "存储",
"days": "天",
"hours": "小时",
"upload": "上传",
"download": "下载",
"system": "系统",
"uptime": "运行时间",
"totalUpload": "总上传",
"totalDownload": "总下载",
"cpu": "处理器",
"vcpu": "虚拟核心",
"tcp": "TCP连接",
"udp": "UDP连接",
"process": "进程数"
},
"cycleTransfer": {
"used": "已使用",
"total": "总计",
"nextUpdate": "下次更新"
},
"serviceTracker": {
"noService": "无服务数据",
"uptime": "在线率",
"delay": "延迟",
"daysAgo": "天前",
"today": "今天",
"loading": "加载中..."
},
"serverDetail": {
"status": "状态",
"online": "在线",
"days": "天",
"hours": "小时",
"offline": "离线",
"unknown": "未知",
"uptime": "运行时间",
"version": "版本",
"arch": "架构",
"mem": "内存",
"disk": "磁盘",
"region": "区域",
"system": "系统",
"upload": "上传",
"download": "下载",
"lastActive": "最后上报时间",
"temperature": "温度",
"bootTime": "启动时间"
},
"serverDetailChart": {
"process": "进程数",
"disk": "磁盘",
"mem": "内存",
"swap": "虚拟内存",
"upload": "上传",
"download": "下载"
},
"footer": {
"themeBy": "主题-"
},
"language": {
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"en": "English"
},
"theme": {
"light": "亮色",
"dark": "暗色",
"system": "跟随系统"
},
"error": {
"pageNotFound": "页面不存在",
"backToHome": "回到主页"
},
"tabSwitch": {
"Detail": "详情",
"Network": "网络"
},
"monitor": {
"noData": "没有服务监控数据,请在管理后台服务页添加监控任务",
"avgDelay": "延迟",
"monitorCount": "个监控服务"
},
"pwa": {
"offlineReady": "应用可以离线使用了",
"newContent": "发现新版本",
"reload": "更新"
},
"billingInfo": {
"remaining": "剩余天数",
"error": "计算错误",
"indefinite": "永久",
"expired": "已过期",
"days": "天",
"price": "价格",
"free": "免费",
"usage-baseed": "按量计费"
},
"TypeCommand": "输入命令或搜索",
"NoResults": "结果为空",
"Servers": "服务器",
"Shortcuts": "快捷键",
"ToggleLightMode": "切换亮色模式",
"ToggleDarkMode": "切换暗色模式",
"ToggleSystemMode": "切换系统模式",
"Home": "首页"
}

View File

@ -0,0 +1,127 @@
{
"nezha": "服務器監控",
"overview": "概覽",
"dashboard": "管理後台",
"login": "登錄",
"online": "在線",
"offline": "離線",
"whereTheTimeIs": "目前時間",
"refreshing": "刷新中",
"info": {
"websocketConnecting": "WebSocket 連接中",
"websocketConnected": "WebSocket 連接成功",
"websocketDisconnected": "WebSocket 連接斷開",
"processing": "處理中..."
},
"serverOverview": {
"totalServers": "總服務器",
"onlineServers": "線上服務器",
"offlineServers": "離線服務器",
"totalBandwidth": "總帶寬",
"speed": "速率",
"network": "網路"
},
"map": {
"Distributions": "服務器分布在",
"Regions": "個區域",
"Servers": "個服務器"
},
"serverCard": {
"mem": "內存",
"stg": "存儲",
"days": "天",
"hours": "小時",
"upload": "上傳",
"download": "下載",
"system": "系統",
"uptime": "運行時間",
"totalUpload": "總上傳",
"totalDownload": "總下載"
},
"cycleTransfer": {
"used": "已使用",
"total": "總量",
"nextUpdate": "下次更新"
},
"serviceTracker": {
"noService": "無服務數據",
"uptime": "在線率",
"delay": "延遲",
"daysAgo": "天前",
"today": "今天",
"loading": "載入中..."
},
"serverDetail": {
"status": "狀態",
"online": "線上",
"days": "天",
"hours": "小時",
"offline": "離線",
"unknown": "未知",
"uptime": "運行時間",
"version": "版本",
"arch": "架構",
"mem": "內存",
"disk": "磁盤",
"region": "地區",
"system": "系統",
"upload": "上傳",
"download": "下載",
"lastActive": "最後上報時間",
"temperature": "溫度",
"bootTime": "啟動時間"
},
"serverDetailChart": {
"process": "進程數",
"disk": "磁盤",
"mem": "內存",
"swap": "虛擬記憶體",
"upload": "上傳",
"download": "下載"
},
"footer": {
"themeBy": "主題-"
},
"language": {
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"en-US": "English"
},
"theme": {
"light": "亮色",
"dark": "暗色",
"system": "跟隨系統"
},
"error": {
"pageNotFound": "頁面不存在",
"backToHome": "回到主頁"
},
"tabSwitch": {
"detail": "詳細資訊",
"network": "網路"
},
"monitor": {
"noData": "沒有服務監控數據,請在管理後台服務新增監控任務",
"status": "狀態",
"avgDelay": "延遲",
"monitorCount": "個監控"
},
"billingInfo": {
"remaining": "剩餘天數",
"error": "獲取失敗",
"indefinite": "無限期",
"expired": "已過期",
"days": "天",
"price": "價格",
"free": "免費",
"usage-baseed": "按量計費"
},
"TypeCommand": "輸入命令或搜尋",
"NoResults": "沒有結果",
"Servers": "伺服器",
"Shortcuts": "快捷鍵",
"ToggleLightMode": "切換亮色模式",
"ToggleDarkMode": "切換暗色模式",
"ToggleSystemMode": "切換系統模式",
"Home": "首頁"
}

View File

@ -4,6 +4,7 @@ import ReactDOM from "react-dom/client"
import { Toaster } from "sonner" import { Toaster } from "sonner"
import App from "./App" import App from "./App"
import { ThemeColorManager } from "./components/ThemeColorManager"
import { ThemeProvider } from "./components/ThemeProvider" import { ThemeProvider } from "./components/ThemeProvider"
import { MotionProvider } from "./components/motion/motion-provider" import { MotionProvider } from "./components/motion/motion-provider"
import { SortProvider } from "./context/sort-provider" import { SortProvider } from "./context/sort-provider"
@ -17,7 +18,8 @@ const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<MotionProvider> <MotionProvider>
<ThemeProvider> <ThemeProvider storageKey="vite-ui-theme">
<ThemeColorManager />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<WebSocketProvider url="/api/v1/ws/server"> <WebSocketProvider url="/api/v1/ws/server">
<StatusProvider> <StatusProvider>

View File

@ -1,4 +1,5 @@
import GlobalMap from "@/components/GlobalMap" import GlobalMap from "@/components/GlobalMap"
import GroupSwitch from "@/components/GroupSwitch"
import ServerCard from "@/components/ServerCard" import ServerCard from "@/components/ServerCard"
import ServerOverview from "@/components/ServerOverview" import ServerOverview from "@/components/ServerOverview"
import { ServiceTracker } from "@/components/ServiceTracker" import { ServiceTracker } from "@/components/ServiceTracker"
@ -16,9 +17,8 @@ import { NezhaWebsocketResponse } from "@/types/nezha-api"
import { ServerGroup } from "@/types/nezha-api" import { ServerGroup } from "@/types/nezha-api"
import { ArrowDownIcon, ArrowUpIcon, ArrowsUpDownIcon, ChartBarSquareIcon, MapIcon } from "@heroicons/react/20/solid" import { ArrowDownIcon, ArrowUpIcon, ArrowsUpDownIcon, ChartBarSquareIcon, MapIcon } from "@heroicons/react/20/solid"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { useCallback, useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import DirectCountrySelect from "@/components/DirectCountrySelect"
export default function Servers() { export default function Servers() {
const { t } = useTranslation() const { t } = useTranslation()
@ -33,15 +33,7 @@ export default function Servers() {
const [showMap, setShowMap] = useState<string>("0") const [showMap, setShowMap] = useState<string>("0")
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [settingsOpen, setSettingsOpen] = useState<boolean>(false) const [settingsOpen, setSettingsOpen] = useState<boolean>(false)
// 使用ref存储筛选状态防止WebSocket消息刷新时重置
const groupRef = useRef<string>("All")
const countryRef = useRef<string>("All")
const [currentGroup, setCurrentGroup] = useState<string>("All") const [currentGroup, setCurrentGroup] = useState<string>("All")
const [currentCountry, setCurrentCountry] = useState<string>("All")
// 保存是否已经初始化了筛选状态
const initializedRef = useRef<boolean>(false)
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
@ -52,16 +44,11 @@ export default function Servers() {
} }
} }
const handleCountryChange = useCallback((newCountry: string) => { const handleTagChange = (newGroup: string) => {
countryRef.current = newCountry; setCurrentGroup(newGroup)
sessionStorage.setItem("selectedGroup", newGroup)
// 强制立即更新状态 sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0))
setCurrentCountry(newCountry); }
// 保存到会话存储
sessionStorage.setItem("selectedCountry", newCountry);
sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0));
}, []);
useEffect(() => { useEffect(() => {
const showServicesState = localStorage.getItem("showServices") const showServicesState = localStorage.getItem("showServices")
@ -84,54 +71,22 @@ export default function Servers() {
} }
}, []) }, [])
// 仅在组件挂载时初始化一次状态
useEffect(() => { useEffect(() => {
if (initializedRef.current) return;
const savedGroup = sessionStorage.getItem("selectedGroup") || "All" const savedGroup = sessionStorage.getItem("selectedGroup") || "All"
const savedCountry = sessionStorage.getItem("selectedCountry") || "All"
groupRef.current = savedGroup
countryRef.current = savedCountry
setCurrentGroup(savedGroup) setCurrentGroup(savedGroup)
setCurrentCountry(savedCountry)
restoreScrollPosition() restoreScrollPosition()
// 如果没有保存值,初始化存储
if (!sessionStorage.getItem("selectedGroup")) {
sessionStorage.setItem("selectedGroup", "All")
}
if (!sessionStorage.getItem("selectedCountry")) {
sessionStorage.setItem("selectedCountry", "All")
}
initializedRef.current = true
}, []) }, [])
// 当WebSocket消息更新时确保UI状态与ref同步
useEffect(() => {
if (!lastMessage || !initializedRef.current) return;
// 保持用户选择的筛选状态
setCurrentGroup(groupRef.current)
setCurrentCountry(countryRef.current)
}, [lastMessage])
const nezhaWsData = lastMessage ? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse) : null const nezhaWsData = lastMessage ? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse) : null
// 获取所有可用的国家代码 const groupTabs = [
const availableCountries = nezhaWsData?.servers
? [...new Set(nezhaWsData.servers.map(server => server.country_code?.toLowerCase()))]
.filter(Boolean)
.sort()
: []
const countryTabs = [
"All", "All",
...availableCountries.map(code => code.toUpperCase()) ...(groupData?.data
?.filter((item: ServerGroup) => {
return Array.isArray(item.servers) && item.servers.some((serverId) => nezhaWsData?.servers?.some((server) => server.id === serverId))
})
?.map((item: ServerGroup) => item.group.name) || []),
] ]
// 获取cycle_transfer_stats数据 // 获取cycle_transfer_stats数据
@ -167,25 +122,11 @@ export default function Servers() {
let filteredServers = let filteredServers =
nezhaWsData?.servers?.filter((server) => { nezhaWsData?.servers?.filter((server) => {
// 组筛选 if (currentGroup === "All") return true
if (currentGroup !== "All") {
const group = groupData?.data?.find( const group = groupData?.data?.find(
(g: ServerGroup) => g.group.name === currentGroup && Array.isArray(g.servers) && g.servers.includes(server.id), (g: ServerGroup) => g.group.name === currentGroup && Array.isArray(g.servers) && g.servers.includes(server.id),
) )
if (!group) { return !!group
return false
}
}
// 国家筛选
if (currentCountry !== "All") {
const serverCountry = server.country_code?.toUpperCase()
if (serverCountry !== currentCountry) {
return false
}
}
return true
}) || [] }) || []
const totalServers = filteredServers.length || 0 const totalServers = filteredServers.length || 0
@ -249,10 +190,10 @@ export default function Servers() {
comparison = (a.state?.cpu ?? 0) - (b.state?.cpu ?? 0) comparison = (a.state?.cpu ?? 0) - (b.state?.cpu ?? 0)
break break
case "mem": case "mem":
comparison = (formatNezhaInfo(nezhaWsData.now, a).mem ?? 0) - (formatNezhaInfo(nezhaWsData.now, b).mem ?? 0) comparison = (a.state?.mem_used ?? 0) - (b.state?.mem_used ?? 0)
break break
case "disk": case "stg":
comparison = (formatNezhaInfo(nezhaWsData.now, a).disk ?? 0) - (formatNezhaInfo(nezhaWsData.now, b).disk ?? 0) comparison = (a.state?.disk_used ?? 0) - (b.state?.disk_used ?? 0)
break break
case "up": case "up":
comparison = (a.state?.net_out_speed ?? 0) - (b.state?.net_out_speed ?? 0) comparison = (a.state?.net_out_speed ?? 0) - (b.state?.net_out_speed ?? 0)
@ -274,7 +215,7 @@ export default function Servers() {
}) })
return ( return (
<div className="mx-auto w-full max-w-7xl px-0"> <div className="mx-auto w-full max-w-5xl px-0">
<ServerOverview <ServerOverview
total={totalServers} total={totalServers}
online={onlineServers} online={onlineServers}
@ -284,7 +225,7 @@ export default function Servers() {
upSpeed={upSpeed} upSpeed={upSpeed}
downSpeed={downSpeed} downSpeed={downSpeed}
/> />
<div className="flex mt-4 items-center justify-between gap-2 server-overview-controls"> <div className="flex mt-6 items-center justify-between gap-2 server-overview-controls">
<section className="flex items-center gap-2 w-full overflow-hidden"> <section className="flex items-center gap-2 w-full overflow-hidden">
<button <button
onClick={() => { onClick={() => {
@ -330,6 +271,7 @@ export default function Servers() {
})} })}
/> />
</button> </button>
<GroupSwitch tabs={groupTabs} currentTab={currentGroup} setCurrentTab={handleTagChange} />
</section> </section>
<Popover onOpenChange={setSettingsOpen}> <Popover onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -392,33 +334,15 @@ export default function Servers() {
</div> </div>
{showMap === "1" && <GlobalMap now={nezhaWsData.now} serverList={nezhaWsData?.servers || []} />} {showMap === "1" && <GlobalMap now={nezhaWsData.now} serverList={nezhaWsData?.servers || []} />}
{showServices === "1" && <ServiceTracker serverList={filteredServers} />} {showServices === "1" && <ServiceTracker serverList={filteredServers} />}
<section ref={containerRef} className="grid grid-cols-1 gap-4 md:grid-cols-3 mt-6 server-card-list">
{/* 优化直接国家选择器 */} {filteredServers.map((serverInfo) => (
<div className="mt-3">
<DirectCountrySelect
countries={countryTabs.filter(tab => tab !== "All")}
currentCountry={currentCountry}
onChange={handleCountryChange}
/>
</div>
<section ref={containerRef} className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mt-4 server-card-list">
{filteredServers.map((serverInfo) => {
// 查找服务器所属的分组
const serverGroup = groupData?.data?.find(
(g: ServerGroup) => Array.isArray(g.servers) && g.servers.includes(serverInfo.id)
);
return (
<ServerCard <ServerCard
now={nezhaWsData.now} now={nezhaWsData.now}
key={serverInfo.id} key={serverInfo.id}
serverInfo={serverInfo} serverInfo={serverInfo}
cycleStats={cycleTransferStats} cycleStats={cycleTransferStats}
groupName={serverGroup?.group.name}
/> />
); ))}
})}
</section> </section>
</div> </div>
) )

View File

@ -24,7 +24,7 @@ export default function ServerDetail() {
} }
return ( return (
<div className="mx-auto w-full max-w-7xl px-0 flex flex-col gap-4 server-info"> <div className="mx-auto w-full max-w-5xl px-0 flex flex-col gap-4 server-info">
<ServerDetailOverview server_id={server_id} /> <ServerDetailOverview server_id={server_id} />
<section className="flex items-center my-2 w-full"> <section className="flex items-center my-2 w-full">
<Separator className="flex-1" /> <Separator className="flex-1" />