Compare commits

..

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

21 changed files with 1137 additions and 260 deletions

View File

@ -3,20 +3,29 @@
<head>
<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; // 是否显示服务器详细信息
</script>
<style>
/* Prevent FOUC in Safari */
html:not(.dark) * {
html:not(.dark):not(.light) * {
visibility: hidden;
}
:root {
color-scheme: dark;
--bg: #242424;
color-scheme: light;
--bg: #ffffff;
}
html.dark {
@ -24,16 +33,21 @@
--bg: #242424;
}
html.light {
color-scheme: light;
--bg: #ffffff;
}
html {
background: transparent !important;
background-color: var(--bg) !important;
}
body {
background: transparent !important;
background-color: var(--bg) !important;
}
#root {
background: transparent !important;
background-color: var(--bg) !important;
visibility: hidden;
}
@ -53,10 +67,31 @@
</style>
<script>
;(function () {
const storageKey = "vite-ui-theme"
const theme = localStorage.getItem(storageKey) || "system"
const root = document.documentElement
root.classList.remove("light")
root.classList.add("dark")
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", "#242424")
function updateThemeColor(isDark) {
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
window.addEventListener("load", () => {

View File

@ -1,14 +1,17 @@
import { useQuery } from "@tanstack/react-query"
import React, { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { Route, BrowserRouter as Router, Routes } from "react-router-dom"
import { DashCommand } from "./components/DashCommand"
import ErrorBoundary from "./components/ErrorBoundary"
import Footer from "./components/Footer"
import Header, { RefreshToast } from "./components/Header"
import { useBackground } from "./hooks/use-background"
import { useTheme } from "./hooks/use-theme"
import { InjectContext } from "./lib/inject"
import { fetchSetting } from "./lib/nezha-api"
import { cn } from "./lib/utils"
import ErrorPage from "./pages/ErrorPage"
import NotFound from "./pages/NotFound"
import Server from "./pages/Server"
@ -21,8 +24,10 @@ const App: React.FC = () => {
refetchOnMount: true,
refetchOnWindowFocus: true,
})
const { i18n } = useTranslation()
const { setTheme } = useTheme()
const [isCustomCodeInjected, setIsCustomCodeInjected] = useState(false)
const { backgroundImage: customBackgroundImage } = useBackground()
useEffect(() => {
if (settingData?.data?.config?.custom_code) {
@ -54,38 +59,45 @@ const App: React.FC = () => {
return null
}
if (settingData?.data?.config?.language && !localStorage.getItem("language")) {
i18n.changeLanguage(settingData?.data?.config?.language)
}
const customMobileBackgroundImage = window.CustomMobileBackgroundImage !== "" ? window.CustomMobileBackgroundImage : undefined
return (
<Router basename={import.meta.env.BASE_URL}>
<ErrorBoundary>
<div className="relative min-h-screen">
{/* 固定定位的背景层 */}
{customBackgroundImage && (
<div
className="fixed inset-0 bg-cover bg-no-repeat bg-center dark:brightness-75"
style={{
backgroundImage: `url(https://random-api.czl.net/pic/normal)`,
zIndex: -1
}}
className={cn("fixed inset-0 z-0 bg-cover min-h-lvh bg-no-repeat bg-center dark:brightness-75", {
"hidden sm:block": customMobileBackgroundImage,
})}
style={{ backgroundImage: `url(${customBackgroundImage})` }}
/>
{/* 毛玻璃蒙版层 */}
)}
{customMobileBackgroundImage && (
<div
className="fixed inset-0 backdrop-blur-sm bg-black/80"
style={{
zIndex: -1
}}
className={cn("fixed inset-0 z-0 bg-cover min-h-lvh bg-no-repeat bg-center sm:hidden dark:brightness-75")}
style={{ backgroundImage: `url(${customMobileBackgroundImage})` }}
/>
<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 />
<Header />
<DashCommand />
<div className="flex-1">
<Routes>
<Route path="/" element={<Server />} />
<Route path="/server/:id" element={<ServerDetail />} />
<Route path="/error" element={<ErrorPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<Footer />
</main>
</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 { m } from "framer-motion"
import { createRef, useEffect, useRef, useState } from "react"
import ServerFlag from "@/components/ServerFlag"
import { createRef, useEffect, useRef } from "react"
export default function GroupSwitch({
tabs,
currentTab,
setCurrentTab,
isCountrySwitch = false
}: {
tabs: string[]
currentTab: string
setCurrentTab: (tab: string) => void
isCountrySwitch?: boolean
}) {
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 tagRefs = useRef(tabs.map(() => createRef<HTMLDivElement>()))
useEffect(() => {
// 检测暗黑模式
setIsDarkMode(document.documentElement.classList.contains('dark'))
const container = scrollRef.current
if (!container) return
// 监听主题变化
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setIsDarkMode(document.documentElement.classList.contains('dark'))
const isOverflowing = container.scrollWidth > container.clientWidth
if (!isOverflowing) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
container.scrollLeft += e.deltaY
}
})
})
observer.observe(document.documentElement, { attributes: true })
container.addEventListener("wheel", onWheel, { passive: false })
return () => {
observer.disconnect()
container.removeEventListener("wheel", onWheel)
}
}, [])
// 处理标签点击
function handleClick(tab: string) {
// 避免重复点击当前选中的标签
if (tab === currentTab) return;
useEffect(() => {
const savedGroup = sessionStorage.getItem("selectedGroup")
if (savedGroup && tabs.includes(savedGroup)) {
setCurrentTab(savedGroup)
}
}, [tabs, setCurrentTab])
try {
// 直接调用父组件传递的回调
setCurrentTab(tab);
console.log(`[${isCountrySwitch ? '国家' : '分组'}] 切换到: ${tab}`);
useEffect(() => {
const currentTagRef = tagRefs.current[tabs.indexOf(currentTab)]
// 手动滚动到可见区域
const index = tabs.indexOf(tab);
if (index !== -1 && tagRefs.current[index]?.current) {
tagRefs.current[index].current?.scrollIntoView({
if (currentTagRef && currentTagRef.current) {
currentTagRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center"
});
}
} catch (error) {
console.error('切换标签出错:', error);
}
inline: "center",
})
}
}, [currentTab])
return (
<div className={cn(
"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 ref={scrollRef} className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]">
<div
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,
@ -79,9 +63,9 @@ export default function GroupSwitch({
>
{tabs.map((tab: string, index: number) => (
<div
key={isCountrySwitch ? `country-${tab}` : `group-${tab}`}
key={tab}
ref={tagRefs.current[index]}
onClick={() => handleClick(tab)}
onClick={() => setCurrentTab(tab)}
className={cn(
"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",
@ -89,22 +73,15 @@ export default function GroupSwitch({
>
{currentTab === tab && (
<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={{
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",
borderRadius: 46,
}}
/>
)}
<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>
</div>
</div>

View File

@ -1,3 +1,4 @@
import { ModeToggle } from "@/components/ThemeSwitcher"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { useBackground } from "@/hooks/use-background"
@ -6,13 +7,14 @@ import { fetchLoginUser, fetchSetting } from "@/lib/nezha-api"
import { cn } from "@/lib/utils"
import NumberFlow, { NumberFlowGroup } from "@number-flow/react"
import { useQuery } from "@tanstack/react-query"
import { AnimatePresence } from "framer-motion"
import { AnimatePresence, m } from "framer-motion"
import { ImageMinus } from "lucide-react"
import { DateTime } from "luxon"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { LanguageSwitcher } from "./LanguageSwitcher"
import { Loader, LoadingSpinner } from "./loading/Loader"
import { Button } from "./ui/button"
@ -74,7 +76,7 @@ function Header() {
const customBackgroundImage = backgroundImage
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
onClick={() => {
@ -101,6 +103,8 @@ function Header() {
<Links />
<DashboardLink />
</div>
<LanguageSwitcher />
<ModeToggle />
{(customBackgroundImage || sessionStorage.getItem("savedBackgroundImage")) && (
<Button
variant="outline"
@ -191,14 +195,18 @@ export function RefreshToast() {
return (
<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"
>
<section className="flex items-center gap-1.5">
<LoadingSpinner />
<p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
</section>
</div>
</m.div>
</AnimatePresence>
)
}
@ -277,7 +285,7 @@ function Overview() {
return () => clearInterval(timer)
}, [])
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>
<div className="flex items-center gap-1.5">
<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

@ -383,6 +383,9 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
<Cpu className="size-[14px] mr-1 text-blue-500" />
<span className="text-xs">CPU</span>
</div>
<span className={cn("text-xs font-bold", getColorClass(cpu))}>
{cpu.toFixed(0)}%
</span>
</div>
<ServerUsageBar value={cpu} />
{/* CPU信息 */}
@ -425,6 +428,9 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
</div>
<span className="text-xs">{t("serverCard.mem")}</span>
</div>
<span className={cn("text-xs font-bold", getColorClass(mem))}>
{mem.toFixed(0)}%
</span>
</div>
<ServerUsageBar value={mem} />
{/* 内存信息 */}
@ -471,6 +477,9 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
<HardDrive className="size-[14px] mr-1 text-amber-500" />
<span className="text-xs">{t("serverCard.stg")}</span>
</div>
<span className={cn("text-xs font-bold", getColorClass(stg))}>
{stg.toFixed(0)}%
</span>
</div>
<ServerUsageBar value={stg} />
{/* 存储信息 */}
@ -487,14 +496,14 @@ export default function ServerCard({ now, serverInfo, cycleStats, groupName }: S
<div className="flex justify-between items-center mb-1">
<div className="flex items-center">
<ArrowUp className="size-[14px] text-blue-500 mr-1" />
<span className="text-xs">Up</span>
<span className="text-xs">{t("serverCard.upload")}</span>
</div>
<span className="text-xs font-medium">{formatSpeed(up)}</span>
</div>
<div className="flex justify-between items-center mt-2">
<div className="flex items-center">
<ArrowDown className="size-[14px] text-green-500 mr-1" />
<span className="text-xs">Down</span>
<span className="text-xs">{t("serverCard.download")}</span>
</div>
<span className="text-xs font-medium">{formatSpeed(down)}</span>
</div>

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

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

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

View File

@ -3,7 +3,7 @@
@tailwind utilities;
: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;
font-weight: 400;
@ -92,8 +92,7 @@
@apply scroll-smooth;
}
body {
@apply text-foreground;
background: transparent !important;
@apply bg-background text-foreground;
/* font-feature-settings: "rlig" 1, "calt" 1; */
font-synthesis-weight: none;
text-rendering: optimizeLegibility;

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

View File

@ -1,4 +1,5 @@
import GlobalMap from "@/components/GlobalMap"
import GroupSwitch from "@/components/GroupSwitch"
import ServerCard from "@/components/ServerCard"
import ServerOverview from "@/components/ServerOverview"
import { ServiceTracker } from "@/components/ServiceTracker"
@ -16,9 +17,8 @@ import { NezhaWebsocketResponse } from "@/types/nezha-api"
import { ServerGroup } from "@/types/nezha-api"
import { ArrowDownIcon, ArrowUpIcon, ArrowsUpDownIcon, ChartBarSquareIcon, MapIcon } from "@heroicons/react/20/solid"
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 DirectCountrySelect from "@/components/DirectCountrySelect"
export default function Servers() {
const { t } = useTranslation()
@ -33,15 +33,7 @@ export default function Servers() {
const [showMap, setShowMap] = useState<string>("0")
const containerRef = useRef<HTMLDivElement>(null)
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 [currentCountry, setCurrentCountry] = useState<string>("All")
// 保存是否已经初始化了筛选状态
const initializedRef = useRef<boolean>(false)
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
@ -52,16 +44,11 @@ export default function Servers() {
}
}
const handleCountryChange = useCallback((newCountry: string) => {
countryRef.current = newCountry;
// 强制立即更新状态
setCurrentCountry(newCountry);
// 保存到会话存储
sessionStorage.setItem("selectedCountry", newCountry);
sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0));
}, []);
const handleTagChange = (newGroup: string) => {
setCurrentGroup(newGroup)
sessionStorage.setItem("selectedGroup", newGroup)
sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0))
}
useEffect(() => {
const showServicesState = localStorage.getItem("showServices")
@ -84,54 +71,22 @@ export default function Servers() {
}
}, [])
// 仅在组件挂载时初始化一次状态
useEffect(() => {
if (initializedRef.current) return;
const savedGroup = sessionStorage.getItem("selectedGroup") || "All"
const savedCountry = sessionStorage.getItem("selectedCountry") || "All"
groupRef.current = savedGroup
countryRef.current = savedCountry
setCurrentGroup(savedGroup)
setCurrentCountry(savedCountry)
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 availableCountries = nezhaWsData?.servers
? [...new Set(nezhaWsData.servers.map(server => server.country_code?.toLowerCase()))]
.filter(Boolean)
.sort()
: []
const countryTabs = [
const groupTabs = [
"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数据
@ -167,25 +122,11 @@ export default function Servers() {
let filteredServers =
nezhaWsData?.servers?.filter((server) => {
// 组筛选
if (currentGroup !== "All") {
if (currentGroup === "All") return true
const group = groupData?.data?.find(
(g: ServerGroup) => g.group.name === currentGroup && Array.isArray(g.servers) && g.servers.includes(server.id),
)
if (!group) {
return false
}
}
// 国家筛选
if (currentCountry !== "All") {
const serverCountry = server.country_code?.toUpperCase()
if (serverCountry !== currentCountry) {
return false
}
}
return true
return !!group
}) || []
const totalServers = filteredServers.length || 0
@ -274,7 +215,7 @@ export default function Servers() {
})
return (
<div className="mx-auto w-full max-w-7xl px-0">
<div className="mx-auto w-full max-w-5xl px-0">
<ServerOverview
total={totalServers}
online={onlineServers}
@ -284,7 +225,7 @@ export default function Servers() {
upSpeed={upSpeed}
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">
<button
onClick={() => {
@ -330,6 +271,7 @@ export default function Servers() {
})}
/>
</button>
<GroupSwitch tabs={groupTabs} currentTab={currentGroup} setCurrentTab={handleTagChange} />
</section>
<Popover onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
@ -392,17 +334,7 @@ export default function Servers() {
</div>
{showMap === "1" && <GlobalMap now={nezhaWsData.now} serverList={nezhaWsData?.servers || []} />}
{showServices === "1" && <ServiceTracker serverList={filteredServers} />}
{/* 优化直接国家选择器 */}
<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">
<section ref={containerRef} className="grid grid-cols-1 gap-4 md:grid-cols-3 mt-6 server-card-list">
{filteredServers.map((serverInfo) => {
// 查找服务器所属的分组
const serverGroup = groupData?.data?.find(

View File

@ -24,7 +24,7 @@ export default function ServerDetail() {
}
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} />
<section className="flex items-center my-2 w-full">
<Separator className="flex-1" />