Compare commits

..

22 Commits
v0.0.1 ... main

Author SHA1 Message Date
dbe3725aaf 优化 ServerCard 组件,移除 CPU、内存和存储的百分比显示,简化界面信息,同时将上传和下载标签修改为简短形式,提升用户体验。 2025-06-20 11:03:42 +08:00
4e3d24975c 优化样式设置,将背景颜色改为透明,提升界面整洁性。同时在 App 组件中添加毛玻璃蒙版层,增强视觉效果。 2025-06-16 10:48:59 +08:00
d9995fab5f 更新 Vite 配置中的 API 代理地址,改为使用远程服务器。同时优化 App 组件的背景层逻辑,移除不必要的背景图设置,简化代码结构,提升用户体验。 2025-06-16 09:54:42 +08:00
63b5dd1893 调整 ServerDetail 组件的布局,将最大宽度从 5xl 增加到 7xl,以提升界面整洁性和用户体验。 2025-06-14 08:05:29 +08:00
3f7985ecf2 优化 App、Header 和 Server 组件的布局,调整样式以提升用户体验和界面整洁性。同时移除不必要的动画效果,简化代码结构。 2025-06-14 08:00:56 +08:00
3958b3b35c 移除主题切换相关组件,简化主题管理逻辑,默认设置为暗黑模式,提升代码整洁性和用户体验。 2025-06-14 07:31:06 +08:00
9090b407cc 移除 Header 组件中的语言切换器,进一步简化国际化逻辑,提升代码整洁性。 2025-06-14 07:21:53 +08:00
372cd247ae 移除多语言支持相关的组件和翻译文件,简化国际化逻辑,默认语言设置为英语,提升代码整洁性和维护性。 2025-06-14 07:19:24 +08:00
49af2059b8 更新 index.css,优化字体设置以提升跨平台兼容性和用户体验。 2025-05-26 17:58:14 +08:00
d5f3548af4 优化 GroupSwitch 组件的标签点击处理逻辑,移除冗余的滚动逻辑,简化代码结构。同时在 Server 页面中引入直接国家选择器,提升用户体验和状态管理的清晰度。 2025-05-07 15:52:25 +08:00
b800ce816a 优化 GroupSwitch 组件,移除调试信息以提升代码整洁性。同时在 Server 页面中引入 ref 存储筛选状态,确保 WebSocket 消息更新时 UI 状态与筛选状态同步,提升用户体验。 2025-05-07 15:35:30 +08:00
eeddef0efc 更新 GroupSwitch 组件,增加唯一 ID 前缀以避免布局冲突,优化标签点击处理逻辑。同时在 Server 页面中添加调试信息,记录组和国家切换的状态,提升代码可读性和调试便利性。 2025-05-07 15:31:01 +08:00
20ca646e9a 更新 GroupSwitch 组件,增加暗黑模式支持和国家切换功能,同时优化状态管理逻辑以适应不同的切换需求。在 Server 页面中添加国家筛选功能,提升用户体验。 2025-05-07 15:26:40 +08:00
wood chen
3c6e8c1730
Merge pull request #16 from hamster1963/main
sync
2025-04-29 02:43:36 +08:00
06f2e04ba8 更新 ServerCard 组件,增加分组名称的显示功能,优化布局以提升信息展示的清晰度和可读性。同时在 Server 页面中查找服务器所属的分组并传递相关信息。 2025-04-29 02:34:46 +08:00
仓鼠
8438bd4d6d
fix: update sorting logic to use formatted memory and disk values for accurate comparisons (#42) 2025-04-27 15:08:27 +08:00
8eec93aff4 更新 ServerCard 组件,优化 CPU 和内存信息的显示,增加 SWAP 使用率的提示功能,提升信息展示的清晰度和可读性。同时调整字节格式化函数,简化单位显示。 2025-04-26 16:27:01 +08:00
56812a52c3 优化 ServerCard 组件的连接数和进程数显示布局,使用网格布局提升信息展示的清晰度和可读性。 2025-04-26 15:18:33 +08:00
a491e2ad54 更新 i18n.js 文件,移除不再使用的语言翻译,简化资源配置。同时在 ServerCard 组件中增加大数值格式化功能,优化连接数和进程数的显示方式,提升信息展示的清晰度和可读性。 2025-04-26 15:14:28 +08:00
hamster1963
20bba90a49 fix: update button background color for better visibility in dark mode 2025-04-25 10:55:02 +08:00
仓鼠
ed35e8c122
Feat: Multiple choice (#41)
* feat: enable multi-selection for charts in NetworkChart component

* feat: add clear selections button for active charts in NetworkChart component

* chore: auto-fix linting and formatting issues
2025-04-25 10:50:01 +08:00
hamster1963
963765343b fix: update sort type from 'stg' to 'disk' for accurate sorting in server context 2025-04-25 10:26:27 +08:00
24 changed files with 550 additions and 1377 deletions

View File

@ -3,29 +3,20 @@
<head> <head>
<script> <script>
// 在页面渲染前就执行主题初始化 // 在页面渲染前就执行主题初始化
try { document.documentElement.classList.add("dark")
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):not(.light) * { html:not(.dark) * {
visibility: hidden; visibility: hidden;
} }
:root { :root {
color-scheme: light; color-scheme: dark;
--bg: #ffffff; --bg: #242424;
} }
html.dark { html.dark {
@ -33,21 +24,16 @@
--bg: #242424; --bg: #242424;
} }
html.light {
color-scheme: light;
--bg: #ffffff;
}
html { html {
background-color: var(--bg) !important; background: transparent !important;
} }
body { body {
background-color: var(--bg) !important; background: transparent !important;
} }
#root { #root {
background-color: var(--bg) !important; background: transparent !important;
visibility: hidden; visibility: hidden;
} }
@ -67,31 +53,10 @@
</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")
function updateThemeColor(isDark) { root.classList.add("dark")
const themeColor = isDark ? "#242424" : "#fafafa" document.querySelector('meta[name="theme-color"]')?.setAttribute("content", "#242424")
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,17 +1,14 @@
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"
@ -24,10 +21,8 @@ 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) {
@ -59,45 +54,38 @@ 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={cn("fixed inset-0 z-0 bg-cover min-h-lvh bg-no-repeat bg-center dark:brightness-75", { className="fixed inset-0 bg-cover bg-no-repeat bg-center dark:brightness-75"
"hidden sm:block": customMobileBackgroundImage, style={{
})} backgroundImage: `url(https://random-api.czl.net/pic/normal)`,
style={{ backgroundImage: `url(${customBackgroundImage})` }} zIndex: -1
}}
/> />
)} {/* 毛玻璃蒙版层 */}
{customMobileBackgroundImage && (
<div <div
className={cn("fixed inset-0 z-0 bg-cover min-h-lvh bg-no-repeat bg-center sm:hidden dark:brightness-75")} className="fixed inset-0 backdrop-blur-sm bg-black/80"
style={{ backgroundImage: `url(${customMobileBackgroundImage})` }} style={{
zIndex: -1
}}
/> />
)}
<div <main className="relative flex min-h-screen flex-col gap-2 p-2 md:p-6 md:pt-4 bg-transparent">
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

@ -0,0 +1,49 @@
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,61 +1,77 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { m } from "framer-motion" import { m } from "framer-motion"
import { createRef, useEffect, useRef } from "react" import { createRef, useEffect, useRef, useState } 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 // 检测暗黑模式
if (!container) return setIsDarkMode(document.documentElement.classList.contains('dark'))
const isOverflowing = container.scrollWidth > container.clientWidth // 监听主题变化
if (!isOverflowing) return const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
const onWheel = (e: WheelEvent) => { if (mutation.attributeName === 'class') {
e.preventDefault() setIsDarkMode(document.documentElement.classList.contains('dark'))
container.scrollLeft += e.deltaY
} }
})
})
container.addEventListener("wheel", onWheel, { passive: false }) observer.observe(document.documentElement, { attributes: true })
return () => { return () => {
container.removeEventListener("wheel", onWheel) observer.disconnect()
} }
}, []) }, [])
useEffect(() => { // 处理标签点击
const savedGroup = sessionStorage.getItem("selectedGroup") function handleClick(tab: string) {
if (savedGroup && tabs.includes(savedGroup)) { // 避免重复点击当前选中的标签
setCurrentTab(savedGroup) if (tab === currentTab) return;
}
}, [tabs, setCurrentTab])
useEffect(() => { try {
const currentTagRef = tagRefs.current[tabs.indexOf(currentTab)] // 直接调用父组件传递的回调
setCurrentTab(tab);
console.log(`[${isCountrySwitch ? '国家' : '分组'}] 切换到: ${tab}`);
if (currentTagRef && currentTagRef.current) { // 手动滚动到可见区域
currentTagRef.current.scrollIntoView({ const index = tabs.indexOf(tab);
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 ref={scrollRef} className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"> <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 <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,
@ -63,9 +79,9 @@ export default function GroupSwitch({
> >
{tabs.map((tab: string, index: number) => ( {tabs.map((tab: string, index: number) => (
<div <div
key={tab} key={isCountrySwitch ? `country-${tab}` : `group-${tab}`}
ref={tagRefs.current[index]} ref={tagRefs.current[index]}
onClick={() => setCurrentTab(tab)} onClick={() => handleClick(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",
@ -73,15 +89,22 @@ export default function GroupSwitch({
> >
{currentTab === tab && ( {currentTab === tab && (
<m.div <m.div
layoutId="tab-switch" layoutId={`${layoutIdPrefix}${isCountrySwitch ? 'country-' : 'group-'}${tab}`}
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,4 +1,3 @@
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"
@ -7,14 +6,13 @@ 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, m } from "framer-motion" import { AnimatePresence } 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"
@ -76,7 +74,7 @@ function Header() {
const customBackgroundImage = backgroundImage const customBackgroundImage = backgroundImage
return ( return (
<div className="mx-auto w-full max-w-5xl"> <div className="mx-auto w-full max-w-7xl">
<section className="flex items-center justify-between header-top"> <section className="flex items-center justify-between header-top">
<section <section
onClick={() => { onClick={() => {
@ -103,8 +101,6 @@ function Header() {
<Links /> <Links />
<DashboardLink /> <DashboardLink />
</div> </div>
<LanguageSwitcher />
<ModeToggle />
{(customBackgroundImage || sessionStorage.getItem("savedBackgroundImage")) && ( {(customBackgroundImage || sessionStorage.getItem("savedBackgroundImage")) && (
<Button <Button
variant="outline" variant="outline"
@ -195,18 +191,14 @@ export function RefreshToast() {
return ( return (
<AnimatePresence> <AnimatePresence>
<m.div <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>
</m.div> </div>
</AnimatePresence> </AnimatePresence>
) )
} }
@ -285,7 +277,7 @@ function Overview() {
return () => clearInterval(timer) return () => clearInterval(timer)
}, []) }, [])
return ( return (
<section className={"mt-10 flex flex-col md:mt-16 header-timer"}> <section className={"mt-6 flex flex-col md:mt-8 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

@ -1,54 +0,0 @@
"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,21 +90,30 @@ 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
const [activeChart, setActiveChart] = React.useState(defaultChart) // Change from string to string array for multi-selection
const [activeCharts, setActiveCharts] = React.useState<string[]>([])
const [isPeakEnabled, setIsPeakEnabled] = React.useState(forcePeakCutEnabled) const [isPeakEnabled, setIsPeakEnabled] = React.useState(forcePeakCutEnabled)
const handleButtonClick = useCallback( // Function to clear all selected charts
(chart: string) => { const clearAllSelections = useCallback(() => {
setActiveChart((prev) => (prev === chart ? defaultChart : chart)) setActiveCharts([])
}, }, [])
[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) => {
@ -119,7 +128,7 @@ export const NetworkChartClient = React.memo(function NetworkChart({
chartDataKey.map((key) => ( chartDataKey.map((key) => (
<button <button
key={key} key={key}
data-active={activeChart === key} data-active={activeCharts.includes(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)}
> >
@ -127,13 +136,27 @@ 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, activeChart, chartData, handleButtonClick], [chartDataKey, activeCharts, chartData, handleButtonClick],
) )
const chartLines = useMemo(() => { const chartLines = useMemo(() => {
if (activeChart !== defaultChart) { // If we have active charts selected, render only those
return <Line isAnimationActive={false} strokeWidth={1} type="linear" dot={false} dataKey="avg_delay" stroke={getColorByIndex(activeChart)} /> if (activeCharts.length > 0) {
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}
@ -146,14 +169,16 @@ export const NetworkChartClient = React.memo(function NetworkChart({
connectNulls={true} connectNulls={true}
/> />
)) ))
}, [activeChart, defaultChart, chartDataKey, getColorByIndex]) }, [activeCharts, chartDataKey, getColorByIndex])
const processedData = useMemo(() => { const processedData = useMemo(() => {
if (!isPeakEnabled) { if (!isPeakEnabled) {
return activeChart === defaultChart ? formattedData : chartData[activeChart] // Always use formattedData when multiple charts are selected or none selected
return formattedData
} }
const data = (activeChart === defaultChart ? formattedData : chartData[activeChart]) as ResultItem[] // For peak cutting, always use the formatted data which contains all series
const data = formattedData
const windowSize = 11 // 增加窗口大小以获取更好的统计效果 const windowSize = 11 // 增加窗口大小以获取更好的统计效果
const alpha = 0.3 // EWMA平滑因子 const alpha = 0.3 // EWMA平滑因子
@ -200,14 +225,16 @@ 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
if (activeChart === defaultChart) { // Process all chart keys or just the selected ones
chartDataKey.forEach((key) => { const keysToProcess = activeCharts.length > 0 ? activeCharts : chartDataKey
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) {
// 应用EWMA平滑 // Apply EWMA smoothing
if (ewmaHistory[key] === undefined) { if (ewmaHistory[key] === undefined) {
ewmaHistory[key] = processed ewmaHistory[key] = processed
} else { } else {
@ -217,26 +244,10 @@ 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, activeChart, formattedData, chartData, chartDataKey, defaultChart]) }, [isPeakEnabled, activeCharts, formattedData, chartDataKey])
return ( return (
<Card <Card
@ -260,6 +271,15 @@ 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} />
@ -309,10 +329,11 @@ export const NetworkChartClient = React.memo(function NetworkChart({
/> />
} }
/> />
{activeChart === defaultChart && <ChartLegend content={<ChartLegendContent />} />} <ChartLegend content={<ChartLegendContent />} />
{chartLines} {chartLines}
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@ -9,10 +9,9 @@ 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, Calendar } from "lucide-react" import { ArrowDown, ArrowUp, Clock, Cpu, HardDrive, Server, Activity, BarChart3 } from "lucide-react"
interface ServerCardProps { interface ServerCardProps {
now: number; now: number;
@ -20,9 +19,10 @@ interface ServerCardProps {
cycleStats?: { cycleStats?: {
[key: string]: CycleTransferData [key: string]: CycleTransferData
}; };
groupName?: string;
} }
export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardProps) { export default function ServerCard({ now, serverInfo, cycleStats, groupName }: ServerCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { const {
@ -45,7 +45,9 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
udp, udp,
process, process,
uptime, uptime,
last_active_time_string arch,
swap,
swap_total
} = formatNezhaInfo( } = formatNezhaInfo(
now, now,
serverInfo, serverInfo,
@ -60,8 +62,6 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
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,6 +181,16 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
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
@ -191,7 +201,17 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
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} />}
@ -262,14 +282,20 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
)} )}
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"> <CardHeader className="p-4 pb-2 pt-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">
@ -298,13 +324,6 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
<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>
@ -364,11 +383,31 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
<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>
{/* 内存使用率 */} {/* 内存使用率 */}
@ -386,11 +425,43 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
</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>
{/* 存储使用率 */} {/* 存储使用率 */}
@ -400,11 +471,12 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
<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>
@ -415,14 +487,14 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
<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">{t("serverCard.upload")}</span> <span className="text-xs">Up</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">{t("serverCard.download")}</span> <span className="text-xs">Down</span>
</div> </div>
<span className="text-xs font-medium">{formatSpeed(down)}</span> <span className="text-xs font-medium">{formatSpeed(down)}</span>
</div> </div>
@ -430,64 +502,20 @@ export default function ServerCard({ now, serverInfo, cycleStats }: ServerCardPr
{/* 连接数与进程数 */} {/* 连接数与进程数 */}
<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"> <div className="flex items-center min-w-0">
<Server className="size-[14px] text-indigo-500 mr-1" /> <Server className="size-[14px] text-indigo-500 mr-1 flex-shrink-0" />
<span className="text-xs">T: {tcp}</span> <span className="text-xs truncate" title={`TCP连接: ${tcp}`}>T: {formatLargeNumber(tcp)}</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center min-w-0">
<Server className="size-[14px] text-pink-500 mr-1" /> <Server className="size-[14px] text-pink-500 mr-1 flex-shrink-0" />
<span className="text-xs">U: {udp}</span> <span className="text-xs truncate" title={`UDP连接: ${udp}`}>U: {formatLargeNumber(udp)}</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center min-w-0 col-span-2">
<Activity className="size-[14px] text-orange-500 mr-1" /> <Activity className="size-[14px] text-orange-500 mr-1 flex-shrink-0" />
<span className="text-xs">P: {process}</span> <span className="text-xs truncate" title={`进程数: ${process}`}>P: {formatLargeNumber(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

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

@ -1,53 +0,0 @@
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" | "stg" | "up" | "down" | "up total" | "down total" export type 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 const SORT_TYPES: SortType[] = ["default", "name", "uptime", "system", "cpu", "mem", "disk", "up", "down", "up total", "down total"]
export type SortOrder = "asc" | "desc" export type SortOrder = "asc" | "desc"

View File

@ -1,54 +1,21 @@
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: getStoredLanguage(), // 使用localStorage中存储的语言或默认值 lng: "en-US",
fallbackLng: "en-US", // 当前语言的翻译没有找到时,使用的备选语言 fallbackLng: "en-US",
interpolation: { interpolation: {
escapeValue: false, // react已经安全地转义 escapeValue: false,
}, },
}) })
// 添加语言改变时的处理函数
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: system-ui, Avenir, Helvetica, Arial, sans-serif; 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;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
@ -92,7 +92,8 @@
@apply scroll-smooth; @apply scroll-smooth;
} }
body { body {
@apply bg-background text-foreground; @apply 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 Bytes" if (!+bytes) return "0 B"
const k = 1024 const k = 1024
const dm = decimals < 0 ? 0 : decimals const dm = decimals < 0 ? 0 : decimals
const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))

View File

@ -1,120 +0,0 @@
{
"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

@ -1,131 +0,0 @@
{
"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

@ -1,135 +0,0 @@
{
"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

@ -1,120 +0,0 @@
{
"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

@ -1,136 +0,0 @@
{
"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

@ -1,127 +0,0 @@
{
"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,7 +4,6 @@ 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"
@ -18,8 +17,7 @@ const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<MotionProvider> <MotionProvider>
<ThemeProvider storageKey="vite-ui-theme"> <ThemeProvider>
<ThemeColorManager />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<WebSocketProvider url="/api/v1/ws/server"> <WebSocketProvider url="/api/v1/ws/server">
<StatusProvider> <StatusProvider>

View File

@ -1,5 +1,4 @@
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"
@ -17,8 +16,9 @@ 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 { useEffect, useRef, useState } from "react" import { useCallback, 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,7 +33,15 @@ 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
@ -44,11 +52,16 @@ export default function Servers() {
} }
} }
const handleTagChange = (newGroup: string) => { const handleCountryChange = useCallback((newCountry: string) => {
setCurrentGroup(newGroup) countryRef.current = newCountry;
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")
@ -71,22 +84,54 @@ 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",
...(groupData?.data ...availableCountries.map(code => code.toUpperCase())
?.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数据
@ -122,11 +167,25 @@ 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),
) )
return !!group if (!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
@ -190,10 +249,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 = (a.state?.mem_used ?? 0) - (b.state?.mem_used ?? 0) comparison = (formatNezhaInfo(nezhaWsData.now, a).mem ?? 0) - (formatNezhaInfo(nezhaWsData.now, b).mem ?? 0)
break break
case "stg": case "disk":
comparison = (a.state?.disk_used ?? 0) - (b.state?.disk_used ?? 0) comparison = (formatNezhaInfo(nezhaWsData.now, a).disk ?? 0) - (formatNezhaInfo(nezhaWsData.now, b).disk ?? 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)
@ -215,7 +274,7 @@ export default function Servers() {
}) })
return ( return (
<div className="mx-auto w-full max-w-5xl px-0"> <div className="mx-auto w-full max-w-7xl px-0">
<ServerOverview <ServerOverview
total={totalServers} total={totalServers}
online={onlineServers} online={onlineServers}
@ -225,7 +284,7 @@ export default function Servers() {
upSpeed={upSpeed} upSpeed={upSpeed}
downSpeed={downSpeed} downSpeed={downSpeed}
/> />
<div className="flex mt-6 items-center justify-between gap-2 server-overview-controls"> <div className="flex mt-4 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={() => {
@ -271,7 +330,6 @@ 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>
@ -334,15 +392,33 @@ 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-5xl px-0 flex flex-col gap-4 server-info"> <div className="mx-auto w-full max-w-7xl 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" />