From 1c1f73904363a3cb4eef14641fd203090a6c6f36 Mon Sep 17 00:00:00 2001 From: Montia37 Date: Fri, 15 Aug 2025 19:27:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(theme):=20=E6=96=B0=E5=A2=9E=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=8F=AF=E9=85=8D=E7=BD=AE=E9=A1=B9=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BB=A3=E7=A0=81=E9=80=BB=E8=BE=91=E5=92=8C=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `komari-theme.json` 中添加了新的配置选项 - 支持自定义标题栏、内容区、实例页面和通用UI元素 - 优化部分组件调用逻辑 - 优化页面样式 --- README.md | 15 +- komari-theme.json | 125 ++++- src/components/sections/Background.ts | 39 -- src/components/sections/Header.tsx | 60 ++- src/components/sections/NodeCard.tsx | 15 +- src/components/ui/progress-bar.tsx | 13 + src/components/ui/tag.tsx | 72 ++- src/config/ConfigContext.ts | 6 + src/config/ConfigProvider.tsx | 73 +++ src/config/default.ts | 41 +- src/config/hooks.ts | 26 + src/config/index.ts | 5 + src/hooks/useLoadCharts.ts | 74 ++- src/hooks/useTheme.ts | 5 +- src/main.tsx | 83 ++- src/pages/Home.tsx | 57 ++- src/pages/instance/Instance.tsx | 128 +++-- src/pages/instance/LoadCharts.tsx | 702 ++++++++++++-------------- src/pages/instance/PingChart.tsx | 11 +- src/pages/instance/index.tsx | 136 ++--- src/types/node.d.ts | 2 + 21 files changed, 922 insertions(+), 766 deletions(-) delete mode 100644 src/components/sections/Background.ts create mode 100644 src/components/ui/progress-bar.tsx create mode 100644 src/config/ConfigContext.ts create mode 100644 src/config/ConfigProvider.tsx create mode 100644 src/config/hooks.ts create mode 100644 src/config/index.ts diff --git a/README.md b/README.md index f0fcf82..fa2f9f9 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,6 @@ > [!NOTE] > 本主题在 Gemini 的辅助下完成,融合了官方主题的部分设计与个人审美偏好,旨在提供一种简洁、美观的磨砂玻璃质感界面 -> [!WARNING] -> -> **当前版本注意事项** -> -> - [ ] `Instance` 页面尚在完善中,目前仅基于官方样式进行了微调 -> - [x] 延迟信息图表的有较大问题仍需优化(优化完成) -> -> 如果您对以上页面的功能和展示有较高要求,建议暂时选用 [社区中的其他主题](https://komari-document.pages.dev/community/theme)。 - ## 🚀 快速开始 ### 安装与启用 @@ -35,10 +26,12 @@ #### Komari v1.0.5 及以上版本 -如果 Komari 版本为 v1.0.5 或更高版本,可直接在 `Komari 后台 > PurCarte设置` 中配置背景图片等主题选项,无需手动添加自定义代码 +如果 Komari 版本为 v1.0.5 或更高版本,可直接在 `Komari 后台 > PurCarte设置` 中配置背景图片等主题选项,无需手动添加自定义代码,如已添加自定义代码需要删去背景相关 style 避免干扰 #### 旧版本配置方法 +
+ 对于旧版本,请在 `Komari 后台 > 设置 > 站点 > 自定义 Body` 处添加以下代码并保存: ```html @@ -57,6 +50,8 @@ ``` +
+ ## 🛠️ 本地开发 1. **克隆仓库** diff --git a/komari-theme.json b/komari-theme.json index 158ca15..eaea359 100644 --- a/komari-theme.json +++ b/komari-theme.json @@ -2,7 +2,7 @@ "name": "Komari Theme PurCart", "short": "PurCarte", "description": "A frosted glass theme for Komari", - "version": "0.1.3", + "version": "1.0.0", "author": "Montia & Gemini", "url": "https://github.com/Montia37/Komari-theme-purcarte", "preview": "preview.png", @@ -10,16 +10,135 @@ "type": "managed", "data": [ { - "name": "背景", + "name": "样式调整", "type": "title" }, { "key": "backgroundImage", "name": "背景图片链接", "type": "string", - "required": false, "default": "/assets/Moonlit-Scenery.webp", "help": "目前仅支持单张背景图片(eg: https://test.com/1.png)" + }, + { + "key": "tagDefaultColorList", + "name": "标签默认颜色列表", + "type": "string", + "default": "ruby,gray,gold,bronze,brown,yellow,amber,orange,tomato,red,crimson,pink,plum,purple,violet,iris,indigo,blue,cyan,teal,jade,green,grass,lime,mint,sky", + "help": "标签默认颜色列表,用于修改默认解析颜色顺序以及使用的颜色池,逗号分隔(可用的颜色列表请参考:https://www.radix-ui.com/themes/docs/theme/color,改完没有生效则说明填写有误)" + }, + { + "name": "标题栏设置", + "type": "title" + }, + { + "key": "enableLogo", + "name": "启用标题栏左侧 Logo", + "type": "switch", + "default": false, + "help": "启用后默认在标题栏左侧显示 Logo" + }, + { + "key": "logoUrl", + "name": "Logo 图片链接", + "type": "string", + "required": false, + "default": "/assets/logo.png", + "help": "Logo 图片链接(eg: https://test.com/logo.png)" + }, + { + "key": "enableTitle", + "name": "启用标题栏标题", + "type": "switch", + "default": true, + "help": "启用后默认在顶栏左侧显示标题" + }, + { + "key": "titleText", + "name": "标题栏标题文本", + "type": "string", + "default": "", + "help": "标题栏左侧显示的文本(留空则使用站点标题)" + }, + { + "key": "enableSearchButton", + "name": "启用标题栏按钮", + "type": "switch", + "default": true, + "help": "启用后默认在标题栏右侧显示搜索按钮" + }, + { + "key": "selectedDefaultView", + "name": "默认展示视图", + "type": "select", + "options": [ + "grid", + "table" + ], + "default": "grid", + "help": "设置默认展示视图为网格或表格(优先使用 localStorage)" + }, + { + "key": "selectedDefaultAppearance", + "name": "默认外观", + "type": "select", + "options": [ + "light", + "dark", + "system" + ], + "default": "system", + "help": "设置默认外观为浅色、深色或系统主题(优先使用 localStorage)" + }, + { + "key": "enableAdminButton", + "name": "启用管理按钮", + "type": "switch", + "default": true, + "help": "启用后默认在标题栏右侧显示管理按钮" + }, + { + "name": "内容设置", + "type": "title" + }, + { + "key": "enableStatsBar", + "name": "启用统计栏", + "type": "switch", + "default": true, + "help": "启用后默认显示统计栏" + }, + { + "key": "enableGroupedBar", + "name": "启用分组栏", + "type": "switch", + "default": true, + "help": "启用后默认显示分组栏" + }, + { + "name": "Instance 设置", + "type": "title" + }, + { + "key": "enableInstanceDetail", + "name": "启用 Instance 详情信息", + "type": "switch", + "default": true, + "help": "启用后默认显示 Instance 详情" + }, + { + "key": "enablePingChart", + "name": "启用延迟图表", + "type": "switch", + "default": true, + "help": "启用后默认显示延迟图表" + }, + { + "key": "pingChatrtMaxPoints", + "name": "延迟图表最大渲染点数", + "type": "number", + "default": 0, + "help": "设置延迟图表的最大渲染点数来优化图表渲染,0 表示不限制,推荐值为 2000 或更小的值" } ] } diff --git a/src/components/sections/Background.ts b/src/components/sections/Background.ts deleted file mode 100644 index 3a8192b..0000000 --- a/src/components/sections/Background.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { BACKGROUND } from "@/config/default"; -import type { PublicInfo } from "@/types/node.d"; - -/** - * 动态效果不佳,暂时仅使用静态背景 - */ -interface ThemeSettings { - backgroundImage?: string; // 背景图片URL -} - -interface BackgroundProps { - publicSettings: PublicInfo; -} - -function Background({ publicSettings }: BackgroundProps) { - const theme = (publicSettings?.theme_settings as ThemeSettings) || {}; - - // 使用 useMemo 缓存背景图片列表,避免每次渲染时重新计算 - const imageUrl = useMemo(() => { - return theme.backgroundImage - ? theme.backgroundImage - : BACKGROUND.backgroundImage; - }, [theme.backgroundImage]); - - // 背景切换逻辑 - useEffect(() => { - // 当当前图片URL变化时,更新CSS变量 - document.body.style.setProperty( - "--body-background-url", - `url(${imageUrl})` - ); - }, [imageUrl]); - - // 此组件不渲染任何可见内容 - return null; -} - -export default Background; diff --git a/src/components/sections/Header.tsx b/src/components/sections/Header.tsx index f56f95a..b13ab30 100644 --- a/src/components/sections/Header.tsx +++ b/src/components/sections/Header.tsx @@ -8,16 +8,16 @@ import { Sun, CircleUserIcon, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; import { useIsMobile } from "@/hooks/useMobile"; +import { useConfigItem } from "@/config"; interface HeaderProps { - viewMode: "card" | "list"; - setViewMode: (mode: "card" | "list") => void; + viewMode: "grid" | "table"; + setViewMode: (mode: "grid" | "table") => void; theme: string; toggleTheme: () => void; - sitename: string; searchTerm: string; setSearchTerm: (term: string) => void; } @@ -27,7 +27,6 @@ export const Header = ({ setViewMode, theme, toggleTheme, - sitename, searchTerm, setSearchTerm, }: HeaderProps) => { @@ -35,13 +34,30 @@ export const Header = ({ const location = useLocation(); const isInstancePage = location.pathname.startsWith("/instance"); const isMobile = useIsMobile(); + const enableTitle = useConfigItem("enableTitle"); + const sitename = useConfigItem("titleText"); + const enableLogo = useConfigItem("enableLogo"); + const logoUrl = useConfigItem("logoUrl"); + const enableSearchButton = useConfigItem("enableSearchButton"); + const enableAdminButton = useConfigItem("enableAdminButton"); + + useEffect(() => { + if (sitename) { + document.title = sitename; + } + }, [sitename]); return (
- - {sitename} + + {enableLogo && logoUrl && ( + logo + )} + {enableTitle && ( + {sitename} + )}
@@ -81,19 +97,21 @@ export const Header = ({ />
)} - + {enableSearchButton && ( + + )} - - - + {enableAdminButton && ( + + + + )}
diff --git a/src/components/sections/NodeCard.tsx b/src/components/sections/NodeCard.tsx index 050f46b..0bf3a7b 100644 --- a/src/components/sections/NodeCard.tsx +++ b/src/components/sections/NodeCard.tsx @@ -6,25 +6,12 @@ import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react"; import Flag from "./Flag"; import { Tag } from "../ui/tag"; import { useNodeCommons } from "@/hooks/useNodeCommons"; +import { ProgressBar } from "../ui/progress-bar"; interface NodeCardProps { node: NodeWithStatus; } -const ProgressBar = ({ - value, - className, -}: { - value: number; - className?: string; -}) => ( -
-
-
-); - export const NodeCard = ({ node }: NodeCardProps) => { const { stats, diff --git a/src/components/ui/progress-bar.tsx b/src/components/ui/progress-bar.tsx new file mode 100644 index 0000000..3472993 --- /dev/null +++ b/src/components/ui/progress-bar.tsx @@ -0,0 +1,13 @@ +export const ProgressBar = ({ + value, + className, +}: { + value: number; + className?: string; +}) => ( +
+
+
+); diff --git a/src/components/ui/tag.tsx b/src/components/ui/tag.tsx index 25f5ecd..56d4d5d 100644 --- a/src/components/ui/tag.tsx +++ b/src/components/ui/tag.tsx @@ -1,12 +1,14 @@ import { Badge } from "@radix-ui/themes"; import React from "react"; import { cn } from "@/utils"; +import { useConfigItem } from "@/config"; interface TagProps extends React.HTMLAttributes { tags: string[]; } -const colors: Array< +// 定义颜色类型 +type ColorType = | "ruby" | "gray" | "gold" @@ -32,65 +34,61 @@ const colors: Array< | "grass" | "lime" | "mint" - | "sky" -> = [ - "ruby", - "gray", - "gold", - "bronze", - "brown", - "yellow", - "amber", - "orange", - "tomato", - "red", - "crimson", - "pink", - "plum", - "purple", - "violet", - "iris", - "indigo", - "blue", - "cyan", - "teal", - "jade", - "green", - "grass", - "lime", - "mint", - "sky", -]; + | "sky"; +// 默认颜色列表,将在组件内部使用 +const defaultColorList: ColorType[] = [ + "blue", + "red", + "green", + "yellow", + "purple", + "cyan", + "orange", + "pink", +]; // 解析带颜色的标签 -const parseTagWithColor = (tag: string) => { +const parseTagWithColor = (tag: string, availableColors: ColorType[]) => { const colorMatch = tag.match(/<(\w+)>$/); if (colorMatch) { const color = colorMatch[1].toLowerCase(); const text = tag.replace(/<\w+>$/, ""); // 检查颜色是否在支持的颜色列表中 - if (colors.includes(color as any)) { - return { text, color: color as (typeof colors)[number] }; + if (availableColors.includes(color as ColorType)) { + return { text, color: color as ColorType }; } } - return { text: tag, color: null }; + return { text: tag, color: null as unknown as ColorType | null }; }; const Tag = React.forwardRef( ({ className, tags, ...props }, ref) => { + // 在组件内部使用 useConfigItem 钩子 + const tagDefaultColorList = useConfigItem("tagDefaultColorList"); + + // 解析配置的颜色列表 + const colorList = React.useMemo(() => { + if (!tagDefaultColorList) { + return defaultColorList; + } + return tagDefaultColorList + .split(",") + .map((color: string) => color.trim()) as ColorType[]; + }, [tagDefaultColorList]); + return (
{tags.map((tag, index) => { - const { text, color } = parseTagWithColor(tag); - const badgeColor = color || colors[index % colors.length]; + const { text, color } = parseTagWithColor(tag, colorList); + const badgeColor = color || colorList[index % colorList.length]; return ( diff --git a/src/config/ConfigContext.ts b/src/config/ConfigContext.ts new file mode 100644 index 0000000..e57961b --- /dev/null +++ b/src/config/ConfigContext.ts @@ -0,0 +1,6 @@ +import { createContext } from "react"; +import type { ConfigOptions } from "./default"; +import { DEFAULT_CONFIG } from "./default"; + +// 创建配置上下文 +export const ConfigContext = createContext(DEFAULT_CONFIG); diff --git a/src/config/ConfigProvider.tsx b/src/config/ConfigProvider.tsx new file mode 100644 index 0000000..8c921eb --- /dev/null +++ b/src/config/ConfigProvider.tsx @@ -0,0 +1,73 @@ +import { type ReactNode, useEffect, useMemo } from "react"; +import type { PublicInfo } from "@/types/node.d"; +import { ConfigContext } from "./ConfigContext"; +import { DEFAULT_CONFIG, type ConfigOptions } from "./default"; + +// 配置提供者属性类型 +interface ConfigProviderProps { + publicSettings: PublicInfo | null; // 公共设置,可能为 null + children: ReactNode; +} + +/** + * 配置提供者组件,用于将配置传递给子组件 + */ +export function ConfigProvider({ + publicSettings, + children, +}: ConfigProviderProps) { + const theme = useMemo(() => { + return (publicSettings?.theme_settings as ConfigOptions) || {}; + }, [publicSettings?.theme_settings]); + + // 使用 useMemo 缓存背景图片,避免每次渲染时重新计算 + const backgroundImage = useMemo(() => { + return theme.backgroundImage || ""; + }, [theme.backgroundImage]); + + // 背景切换逻辑 + useEffect(() => { + if (backgroundImage) { + document.body.style.setProperty( + "--body-background-url", + `url(${backgroundImage})` + ); + } else { + document.body.style.removeProperty("--body-background-url"); + } + }, [backgroundImage]); + + const config: ConfigOptions = useMemo( + () => ({ + tagDefaultColorList: + theme.tagDefaultColorList || DEFAULT_CONFIG.tagDefaultColorList, + enableLogo: theme.enableLogo ?? DEFAULT_CONFIG.enableLogo, + logoUrl: theme.logoUrl || DEFAULT_CONFIG.logoUrl, + enableTitle: theme.enableTitle ?? DEFAULT_CONFIG.enableTitle, + titleText: theme.titleText || DEFAULT_CONFIG.titleText, + enableSearchButton: + theme.enableSearchButton ?? DEFAULT_CONFIG.enableSearchButton, + selectedDefaultView: + theme.selectedDefaultView || DEFAULT_CONFIG.selectedDefaultView, + selectedDefaultAppearance: + theme.selectedDefaultAppearance || + DEFAULT_CONFIG.selectedDefaultAppearance, + enableAdminButton: + theme.enableAdminButton ?? DEFAULT_CONFIG.enableAdminButton, + enableStatsBar: theme.enableStatsBar ?? DEFAULT_CONFIG.enableStatsBar, + enableGroupedBar: + theme.enableGroupedBar ?? DEFAULT_CONFIG.enableGroupedBar, + enableInstanceDetail: + theme.enableInstanceDetail ?? DEFAULT_CONFIG.enableInstanceDetail, + enablePingChart: theme.enablePingChart ?? DEFAULT_CONFIG.enablePingChart, + pingChartMaxPoints: + theme.pingChartMaxPoints || DEFAULT_CONFIG.pingChartMaxPoints, + backgroundImage, + }), + [theme, backgroundImage] + ); + + return ( + {children} + ); +} diff --git a/src/config/default.ts b/src/config/default.ts index 4a2b603..8f10b9a 100644 --- a/src/config/default.ts +++ b/src/config/default.ts @@ -1,5 +1,38 @@ -export const BACKGROUND = { - backgroundImage: "", - // switchTime: 10, // 10 seconds - // transition: "background-image 0.8s ease-in-out", // CSS transition for background change +// 配置类型定义 +export interface ConfigOptions { + backgroundImage?: string; // 背景图片URL + tagDefaultColorList?: string; // 标签默认颜色列表 + enableLogo?: boolean; // 是否启用Logo + logoUrl?: string; // Logo图片URL + enableTitle?: boolean; // 是否启用标题 + titleText?: string; // 标题文本 + enableSearchButton?: boolean; // 是否启用搜索按钮 + selectedDefaultView?: "grid" | "table"; // 默认视图模式 + selectedDefaultAppearance?: "light" | "dark" | "system"; // 默认外观模式 + enableAdminButton?: boolean; // 是否启用管理员按钮 + enableStatsBar?: boolean; // 是否启用统计栏 + enableGroupedBar?: boolean; // 是否启用分组栏 + enableInstanceDetail?: boolean; // 是否启用实例详情 + enablePingChart?: boolean; // 是否启用延迟图表 + pingChartMaxPoints?: number; // 延迟图表最大点数 +} + +// 默认配置值 +export const DEFAULT_CONFIG: ConfigOptions = { + backgroundImage: "/assets/Moonlit-Scenery.webp", + tagDefaultColorList: + "ruby,gray,gold,bronze,brown,yellow,amber,orange,tomato,red,crimson,pink,plum,purple,violet,iris,indigo,blue,cyan,teal,jade,green,grass,lime,mint,sky", + enableLogo: false, + logoUrl: "/assets/logo.png", + enableTitle: true, + titleText: "Komari", + enableSearchButton: true, + selectedDefaultView: "grid", + selectedDefaultAppearance: "system", + enableAdminButton: true, + enableStatsBar: true, + enableGroupedBar: true, + enableInstanceDetail: true, + enablePingChart: true, + pingChartMaxPoints: 0, }; diff --git a/src/config/hooks.ts b/src/config/hooks.ts new file mode 100644 index 0000000..a8040c3 --- /dev/null +++ b/src/config/hooks.ts @@ -0,0 +1,26 @@ +import { useContext } from "react"; +import type { ConfigOptions } from "./default"; +import { ConfigContext } from "./ConfigContext"; + +/** + * 使用全局配置 Hook,用于获取当前应用配置 + * @returns 配置对象 + */ +export function useAppConfig(): ConfigOptions { + return useContext(ConfigContext); +} + +/** + * 使用特定配置项 Hook,直接获取某个配置项的值 + * @param key 配置项键名 + * @returns 配置项的值 + */ +export function useConfigItem( + key: K +): ConfigOptions[K] { + const config = useContext(ConfigContext); + return config[key]; +} + +// 导出配置类型 +export type { ConfigOptions } from "./default"; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..24e54ac --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,5 @@ +// 从各个文件中导出 +export { ConfigProvider } from "./ConfigProvider"; +export { useAppConfig, useConfigItem } from "./hooks"; +export type { ConfigOptions } from "./hooks"; +export { DEFAULT_CONFIG } from "./default"; diff --git a/src/hooks/useLoadCharts.ts b/src/hooks/useLoadCharts.ts index c30e105..be30529 100644 --- a/src/hooks/useLoadCharts.ts +++ b/src/hooks/useLoadCharts.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import { useNodeData } from "@/contexts/NodeDataContext"; import type { HistoryRecord, NodeData, NodeStats } from "@/types/node"; import { useLiveData } from "@/contexts/LiveDataContext"; +import fillMissingTimePoints from "@/utils/RecordHelper"; export const useLoadCharts = (node: NodeData | null, hours: number) => { const { getLoadHistory, getRecentLoadHistory } = useNodeData(); @@ -97,43 +98,60 @@ export const useLoadCharts = (node: NodeData | null, hours: number) => { }); }, [liveData, node?.uuid, isRealtime]); - const historicalChartData = useMemo(() => { - return historicalData.map((record) => ({ + const chartData = useMemo(() => { + const rawData = isRealtime ? realtimeData : historicalData; + const mappedData = rawData.map((record) => ({ + ...record, time: new Date(record.time).getTime(), - cpu: record.cpu, - ram: record.ram, - disk: record.disk, - load: record.load, - net_out: record.net_out, - net_in: record.net_in, - connections: record.connections, - process: record.process, - swap: record.swap, - connections_udp: record.connections_udp, })); - }, [historicalData]); - const realtimeChartData = useMemo(() => { - return realtimeData.map((record) => ({ - time: new Date(record.time).getTime(), - cpu: record.cpu, - ram: record.ram, - disk: record.disk, - load: record.load, - net_out: record.net_out, - net_in: record.net_in, - connections: record.connections, - process: record.process, - swap: record.swap, - connections_udp: record.connections_udp, + if (isRealtime) { + return mappedData; + } + + const minute = 60; + const hour = minute * 60; + + const stringifiedData = mappedData.map((d) => ({ + ...d, + time: new Date(d.time).toISOString(), })); - }, [realtimeData]); - const chartData = isRealtime ? realtimeChartData : historicalChartData; + let filledData; + if (hours === 1) { + filledData = fillMissingTimePoints( + stringifiedData, + minute, + hour, + minute * 2 + ); + } else { + const interval = hours > 120 ? hour : minute * 15; + const maxGap = interval * 2; + filledData = fillMissingTimePoints( + stringifiedData, + interval, + hour * hours, + maxGap + ); + } + return filledData.map((d) => ({ ...d, time: new Date(d.time!).getTime() })); + }, [isRealtime, realtimeData, historicalData, hours]); + + const memoryChartData = useMemo(() => { + return chartData.map((item) => ({ + ...item, + ram: ((item.ram ?? 0) / (node?.mem_total ?? 1)) * 100, + ram_raw: item.ram, + swap: ((item.swap ?? 0) / (node?.swap_total ?? 1)) * 100, + swap_raw: item.swap, + })); + }, [chartData, node?.mem_total, node?.swap_total]); return { loading, error, chartData, + memoryChartData, }; }; diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index 9a45429..75c5e3f 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -1,8 +1,11 @@ import { useState, useEffect } from "react"; +import { useConfigItem } from "@/config/hooks"; type Theme = "light" | "dark" | "system"; export const useTheme = () => { + const defaultAppearance = useConfigItem("selectedDefaultAppearance"); + const [theme, setTheme] = useState(() => { const storedTheme = localStorage.getItem("appearance"); if ( @@ -12,7 +15,7 @@ export const useTheme = () => { ) { return storedTheme; } - return "system"; + return (defaultAppearance as Theme) || "system"; }); useEffect(() => { diff --git a/src/main.tsx b/src/main.tsx index a9eb2ba..1488e7e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,7 +5,7 @@ import "./index.css"; import "@radix-ui/themes/styles.css"; import { Theme } from "@radix-ui/themes"; import { Header } from "@/components/sections/Header"; -import Background from "@/components/sections/Background"; +import { ConfigProvider } from "@/config"; import { useTheme } from "@/hooks/useTheme"; import { NodeDataProvider } from "@/contexts/NodeDataContext"; import { LiveDataProvider } from "@/contexts/LiveDataContext"; @@ -17,60 +17,57 @@ const HomePage = lazy(() => import("@/pages/Home")); const InstancePage = lazy(() => import("@/pages/instance")); const NotFoundPage = lazy(() => import("@/pages/NotFound")); +import { useConfigItem } from "@/config"; + // eslint-disable-next-line react-refresh/only-export-components const App = () => { const { theme, toggleTheme } = useTheme(); const { publicSettings } = useNodeData(); - const [viewMode, setViewMode] = useState<"card" | "list">(() => { + const defaultView = useConfigItem("selectedDefaultView"); + + const [viewMode, setViewMode] = useState<"grid" | "table">(() => { const savedMode = localStorage.getItem("nodeViewMode"); - if (savedMode === "table") { - return "list"; - } - return "card"; + return savedMode === "grid" || savedMode === "table" + ? savedMode + : defaultView || "grid"; }); const [searchTerm, setSearchTerm] = useState(""); - const sitename = publicSettings ? publicSettings.sitename : "Komari"; - useEffect(() => { - document.title = sitename; - }, [sitename]); - - useEffect(() => { - const modeToStore = viewMode === "card" ? "grid" : "table"; - localStorage.setItem("nodeViewMode", modeToStore); + localStorage.setItem("nodeViewMode", viewMode); }, [viewMode]); return ( - - {/* 使用背景组件 */} - {publicSettings && } -
-
- }> - - } - /> - } /> - } /> - - -
-
-
+ + +
+
+ }> + + + } + /> + } /> + } /> + + +
+
+
+
); }; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f91c9eb..0f226d9 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -8,9 +8,10 @@ import Loading from "@/components/loading"; import type { NodeWithStatus } from "@/types/node"; import { useNodeData } from "@/contexts/NodeDataContext"; import { useLiveData } from "@/contexts/LiveDataContext"; +import { useConfigItem } from "@/config"; interface HomePageProps { - viewMode: "card" | "list"; + viewMode: "grid" | "table"; searchTerm: string; } @@ -18,6 +19,8 @@ const HomePage: React.FC = ({ viewMode, searchTerm }) => { const { nodes: staticNodes, loading, getGroups } = useNodeData(); const { liveData } = useLiveData(); const [selectedGroup, setSelectedGroup] = useState("所有"); + const enableGroupedBar = useConfigItem("enableGroupedBar"); + const enableStatsBar = useConfigItem("enableStatsBar"); const [displayOptions, setDisplayOptions] = useState({ time: true, online: true, @@ -80,47 +83,51 @@ const HomePage: React.FC = ({ viewMode, searchTerm }) => { return (
- + {enableStatsBar && ( + + )}
-
- 分组 - {groups.map((group: string) => ( - - ))} -
+ {enableGroupedBar && ( +
+ 分组 + {groups.map((group: string) => ( + + ))} +
+ )} -
+
{loading ? ( ) : filteredNodes.length > 0 ? (
- {viewMode === "list" && } + {viewMode === "table" && } {filteredNodes.map((node: NodeWithStatus) => - viewMode === "card" ? ( + viewMode === "grid" ? ( ) : ( diff --git a/src/pages/instance/Instance.tsx b/src/pages/instance/Instance.tsx index 1f60bbc..4849ebf 100644 --- a/src/pages/instance/Instance.tsx +++ b/src/pages/instance/Instance.tsx @@ -1,40 +1,30 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { NodeWithStatus } from "@/types/node"; import { useMemo, memo } from "react"; +import { formatBytes, formatUptime } from "@/utils"; interface InstanceProps { node: NodeWithStatus; } -const formatUptime = (uptime: number) => { - if (!uptime) return "N/A"; - const days = Math.floor(uptime / 86400); - const hours = Math.floor((uptime % 86400) / 3600); - const minutes = Math.floor((uptime % 3600) / 60); - const seconds = Math.floor(uptime % 60); +const formatTrafficLimit = ( + limit?: number, + type?: "sum" | "max" | "min" | "up" | "down" +) => { + if (!limit) return "未设置"; - let result = ""; - if (days > 0) result += `${days} 天 `; - if (hours > 0 || days > 0) result += `${hours} 时 `; - if (minutes > 0 || hours > 0 || days > 0) result += `${minutes} 分 `; - result += `${seconds} 秒`; + const limitText = formatBytes(limit); - return result.trim(); -}; + const typeText = + { + sum: "总和", + max: "最大值", + min: "最小值", + up: "上传", + down: "下载", + }[type || "max"] || ""; -const formatBytes = (bytes: number, unit: "KB" | "MB" | "GB" = "GB") => { - if (bytes === 0) return `0 ${unit}`; - const k = 1024; - switch (unit) { - case "KB": - return `${(bytes / k).toFixed(2)} KB`; - case "MB": - return `${(bytes / (k * k)).toFixed(2)} MB`; - case "GB": - return `${(bytes / (k * k * k)).toFixed(2)} GB`; - default: - return `${bytes} B`; - } + return `${limitText} (${typeText})`; }; const Instance = memo(({ node }: InstanceProps) => { @@ -47,33 +37,33 @@ const Instance = memo(({ node }: InstanceProps) => { return ( - + 详细信息 - -
-

CPU

-

{`${node.cpu_name} (x${node.cpu_cores})`}

-
-
-

架构

-

{node.arch}

-
-
-

虚拟化

-

{node.virtualization}

-
-
-

GPU

-

{node.gpu_name || "N/A"}

-
+
-

操作系统

-

{node.os}

+

CPU

+

{`${node.cpu_name} (x${node.cpu_cores})`}

-

内存

-

+

架构

+

{node.arch}

+
+
+

虚拟化

+

{node.virtualization}

+
+
+

GPU

+

{node.gpu_name || "N/A"}

+
+
+

操作系统

+

{node.os}

+
+
+

内存

+

{stats && isOnline ? `${formatBytes(stats.ram.used)} / ${formatBytes( node.mem_total @@ -82,18 +72,18 @@ const Instance = memo(({ node }: InstanceProps) => {

-

交换

-

+

交换

+

{stats && isOnline - ? `${formatBytes(stats.swap.used, "MB")} / ${formatBytes( + ? `${formatBytes(stats.swap.used)} / ${formatBytes( node.swap_total )}` : `N/A / ${formatBytes(node.swap_total)}`}

-

磁盘

-

+

磁盘

+

{stats && isOnline ? `${formatBytes(stats.disk.used)} / ${formatBytes( node.disk_total @@ -101,20 +91,24 @@ const Instance = memo(({ node }: InstanceProps) => { : `N/A / ${formatBytes(node.disk_total)}`}

+
+

运行时间

+

{formatUptime(stats?.uptime || 0)}

+
-

网络

-

+

实时网络

+

{stats && isOnline - ? `↑ ${formatBytes(stats.network.up, "KB")}/s ↓ ${formatBytes( + ? `↑ ${formatBytes(stats.network.up, true)} ↓ ${formatBytes( stats.network.down, - "KB" - )}/s` + true + )}` : "N/A"}

-
-

总流量

-

+

+

总流量

+

{stats && isOnline ? `↑ ${formatBytes(stats.network.totalUp)} ↓ ${formatBytes( stats.network.totalDown @@ -122,13 +116,15 @@ const Instance = memo(({ node }: InstanceProps) => { : "N/A"}

-
-

运行时间

-

{formatUptime(stats?.uptime || 0)}

+
+

流量限制

+

+ {formatTrafficLimit(node.traffic_limit, node.traffic_limit_type)} +

-

最后上报

-

+

最后上报

+

{stats && isOnline ? new Date(stats.updated_at).toLocaleString() : "N/A"} diff --git a/src/pages/instance/LoadCharts.tsx b/src/pages/instance/LoadCharts.tsx index 54c6f66..8557596 100644 --- a/src/pages/instance/LoadCharts.tsx +++ b/src/pages/instance/LoadCharts.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState, useEffect, useRef } from "react"; +import { memo, useCallback, useRef } from "react"; import { AreaChart, Area, @@ -13,410 +13,352 @@ import { ChartContainer } from "@/components/ui/chart"; import { Card, CardHeader, CardTitle } from "@/components/ui/card"; import type { NodeData, NodeStats } from "@/types/node"; import { formatBytes } from "@/utils"; -import fillMissingTimePoints, { type RecordFormat } from "@/utils/RecordHelper"; import { Flex } from "@radix-ui/themes"; import Loading from "@/components/loading"; -import { useNodeData } from "@/contexts/NodeDataContext"; +import { useLoadCharts } from "@/hooks/useLoadCharts"; interface LoadChartsProps { node: NodeData; hours: number; - data?: RecordFormat[]; liveData?: NodeStats; } -const LoadCharts = memo( - ({ node, hours, data = [], liveData }: LoadChartsProps) => { - const { getLoadHistory } = useNodeData(); - const [historicalData, setHistoricalData] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); +const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => { + const { loading, error, chartData, memoryChartData } = useLoadCharts( + node, + hours + ); - const isRealtime = hours === 0; + const chartDataLengthRef = useRef(0); + chartDataLengthRef.current = chartData.length; - // 获取历史数据 - useEffect(() => { - if (!isRealtime) { - const fetchHistoricalData = async () => { - setLoading(true); - setError(null); - try { - const data = await getLoadHistory(node.uuid, hours); - setHistoricalData(data?.records || []); - } catch (err: any) { - setError(err.message || "获取历史数据失败"); - } finally { - setLoading(false); - } - }; - fetchHistoricalData(); - } else { - setLoading(false); - } - }, [node.uuid, hours, getLoadHistory, isRealtime]); + // 格式化函数 + const timeFormatter = useCallback((value: any, index: number) => { + if (chartDataLengthRef.current === 0) return ""; + if (index === 0 || index === chartDataLengthRef.current - 1) { + return new Date(value).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + } + return ""; + }, []); - // 准备图表数据 - const minute = 60; - const hour = minute * 60; - const chartData = isRealtime - ? data - : hours === 1 - ? fillMissingTimePoints(historicalData ?? [], minute, hour, minute * 2) - : (() => { - const interval = hours > 120 ? hour : minute * 15; - const maxGap = interval * 2; - return fillMissingTimePoints( - historicalData ?? [], - interval, - hour * hours, - maxGap - ); - })(); - - const chartDataLengthRef = useRef(0); - chartDataLengthRef.current = chartData.length; - - // 内存图表数据转换 - const memoryChartData = chartData.map((item) => ({ - ...item, - ram: ((item.ram ?? 0) / (node?.mem_total ?? 1)) * 100, - ram_raw: item.ram, - swap: ((item.swap ?? 0) / (node?.swap_total ?? 1)) * 100, - swap_raw: item.swap, - })); - - // 格式化函数 - const timeFormatter = useCallback((value: any, index: number) => { - if (chartDataLengthRef.current === 0) return ""; - if (index === 0 || index === chartDataLengthRef.current - 1) { - return new Date(value).toLocaleTimeString([], { + const labelFormatter = useCallback( + (value: any) => { + const date = new Date(value); + if (hours === 0) { + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", + second: "2-digit", }); } - return ""; - }, []); + return date.toLocaleString([], { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + }, + [hours] + ); - const labelFormatter = useCallback( - (value: any) => { - const date = new Date(value); - if (hours === 0) { - return date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - } - return date.toLocaleString([], { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); - }, - [hours] - ); + // 样式和颜色 + const cn = "flex flex-col w-full h-full gap-4 justify-between"; + const chartMargin = { top: 10, right: 16, bottom: 10, left: 16 }; + const colors = ["#F38181", "#FCE38A", "#EAFFD0", "#95E1D3"]; - // 样式和颜色 - const cn = "flex flex-col w-full h-full gap-4 justify-between"; - const chartMargin = { top: 10, right: 16, bottom: 10, left: 16 }; - const colors = ["#F38181", "#FCE38A", "#EAFFD0", "#95E1D3"]; - - // 图表配置 - const chartConfigs = [ - { - id: "cpu", - title: "CPU", - type: "area", - value: liveData?.cpu?.usage ? `${liveData.cpu.usage.toFixed(2)}%` : "-", - dataKey: "cpu", - yAxisDomain: [0, 100], - yAxisFormatter: (value: number, index: number) => - index !== 0 ? `${value}%` : "", - color: colors[0], - data: chartData, - tooltipFormatter: (value: number) => `${value.toFixed(2)}%`, - tooltipLabel: "CPU 使用率", - }, - { - id: "memory", - title: "内存", - type: "area", - value: ( - - - - - ), - series: [ - { - dataKey: "ram", - color: colors[0], - tooltipLabel: "内存", - tooltipFormatter: (value: number, raw: any) => - `${formatBytes(raw?.ram_raw || 0)} (${value.toFixed(0)}%)`, - }, - { - dataKey: "swap", - color: colors[1], - tooltipLabel: "交换", - tooltipFormatter: (value: number, raw: any) => - `${formatBytes(raw?.swap_raw || 0)} (${value.toFixed(0)}%)`, - }, - ], - yAxisDomain: [0, 100], - yAxisFormatter: (value: number, index: number) => - index !== 0 ? `${value}%` : "", - data: memoryChartData, - }, - { - id: "disk", - title: "磁盘", - type: "area", - value: liveData?.disk?.used - ? `${formatBytes(liveData.disk.used)} / ${formatBytes( - node?.disk_total || 0 - )}` - : "-", - dataKey: "disk", - yAxisDomain: [0, node?.disk_total || 100], - yAxisFormatter: (value: number, index: number) => - index !== 0 ? formatBytes(value) : "", - color: colors[0], - data: chartData, - tooltipFormatter: (value: number) => formatBytes(value), - tooltipLabel: "磁盘使用", - }, - { - id: "network", - title: "网络", - type: "line", - value: ( - <> - - ↑ {formatBytes(liveData?.network.up || 0)}/s - ↓ {formatBytes(liveData?.network.down || 0)}/s - - - ), - series: [ - { - dataKey: "net_in", - color: colors[0], - tooltipLabel: "下载", - tooltipFormatter: (value: number) => `${formatBytes(value)}/s`, - }, - { - dataKey: "net_out", - color: colors[3], - tooltipLabel: "上传", - tooltipFormatter: (value: number) => `${formatBytes(value)}/s`, - }, - ], - yAxisFormatter: (value: number, index: number) => - index !== 0 ? formatBytes(value) : "", - data: chartData, - }, - { - id: "connections", - title: "连接数", - type: "line", - value: ( + // 图表配置 + const chartConfigs = [ + { + id: "cpu", + title: "CPU", + type: "area", + value: liveData?.cpu?.usage ? `${liveData.cpu.usage.toFixed(2)}%` : "-", + dataKey: "cpu", + yAxisDomain: [0, 100], + yAxisFormatter: (value: number, index: number) => + index !== 0 ? `${value}%` : "", + color: colors[0], + data: chartData, + tooltipFormatter: (value: number) => `${value.toFixed(2)}%`, + tooltipLabel: "CPU 使用率", + }, + { + id: "memory", + title: "内存", + type: "area", + value: ( + + + + + ), + series: [ + { + dataKey: "ram", + color: colors[0], + tooltipLabel: "内存", + tooltipFormatter: (value: number, raw: any) => + `${formatBytes(raw?.ram_raw || 0)} (${value.toFixed(0)}%)`, + }, + { + dataKey: "swap", + color: colors[1], + tooltipLabel: "交换", + tooltipFormatter: (value: number, raw: any) => + `${formatBytes(raw?.swap_raw || 0)} (${value.toFixed(0)}%)`, + }, + ], + yAxisDomain: [0, 100], + yAxisFormatter: (value: number, index: number) => + index !== 0 ? `${value}%` : "", + data: memoryChartData, + }, + { + id: "disk", + title: "磁盘", + type: "area", + value: liveData?.disk?.used + ? `${formatBytes(liveData.disk.used)} / ${formatBytes( + node?.disk_total || 0 + )}` + : "-", + dataKey: "disk", + yAxisDomain: [0, node?.disk_total || 100], + yAxisFormatter: (value: number, index: number) => + index !== 0 ? formatBytes(value) : "", + color: colors[0], + data: chartData, + tooltipFormatter: (value: number) => formatBytes(value), + tooltipLabel: "磁盘使用", + }, + { + id: "network", + title: "网络", + type: "line", + value: ( + <> - TCP: {liveData?.connections.tcp} - UDP: {liveData?.connections.udp} + ↑ {formatBytes(liveData?.network.up || 0)}/s + ↓ {formatBytes(liveData?.network.down || 0)}/s - ), - series: [ - { - dataKey: "connections", - color: colors[0], - tooltipLabel: "TCP 连接", - }, - { - dataKey: "connections_udp", - color: colors[1], - tooltipLabel: "UDP 连接", - }, - ], - data: chartData, - }, - { - id: "process", - title: "进程数", - type: "line", - value: liveData?.process || "-", - dataKey: "process", - color: colors[0], - data: chartData, - tooltipLabel: "进程数", - }, - ]; + + ), + series: [ + { + dataKey: "net_in", + color: colors[0], + tooltipLabel: "下载", + tooltipFormatter: (value: number) => `${formatBytes(value)}/s`, + }, + { + dataKey: "net_out", + color: colors[3], + tooltipLabel: "上传", + tooltipFormatter: (value: number) => `${formatBytes(value)}/s`, + }, + ], + yAxisFormatter: (value: number, index: number) => + index !== 0 ? formatBytes(value) : "", + data: chartData, + }, + { + id: "connections", + title: "连接数", + type: "line", + value: ( + + TCP: {liveData?.connections.tcp} + UDP: {liveData?.connections.udp} + + ), + series: [ + { + dataKey: "connections", + color: colors[0], + tooltipLabel: "TCP 连接", + }, + { + dataKey: "connections_udp", + color: colors[1], + tooltipLabel: "UDP 连接", + }, + ], + data: chartData, + }, + { + id: "process", + title: "进程数", + type: "line", + value: liveData?.process || "-", + dataKey: "process", + color: colors[0], + data: chartData, + tooltipLabel: "进程数", + }, + ]; - // 通用提示组件 - const CustomTooltip = ({ active, payload, label, chartConfig }: any) => { - if (!active || !payload || !payload.length) return null; - - return ( -

-

- {labelFormatter(label)} -

-
- {payload.map((item: any, index: number) => { - const series = chartConfig.series - ? chartConfig.series.find( - (s: any) => s.dataKey === item.dataKey - ) - : { - dataKey: chartConfig.dataKey, - tooltipLabel: chartConfig.tooltipLabel, - tooltipFormatter: chartConfig.tooltipFormatter, - }; - - let value = item.value; - if (series?.tooltipFormatter) { - value = series.tooltipFormatter(value, item.payload); - } else { - value = value.toString(); - } - - return ( -
-
-
- - {series?.tooltipLabel || item.dataKey}: - -
- {value} -
- ); - })} -
-
- ); - }; - - // 根据配置渲染图表 - const renderChart = (config: any) => { - const ChartComponent = config.type === "area" ? AreaChart : LineChart; - const DataComponent = - config.type === "area" ? Area : (Line as React.ComponentType); - - const chartConfig = config.series - ? config.series.reduce((acc: any, series: any) => { - acc[series.dataKey] = { - label: series.tooltipLabel || series.dataKey, - color: series.color, - }; - return acc; - }, {}) - : { - [config.dataKey]: { - label: config.tooltipLabel || config.dataKey, - color: config.color, - }, - }; - - return ( - - - - {config.title} - - {config.value} - - - - - - - ( - - )} - /> - {config.series ? ( - config.series.map((series: any) => ( - - )) - ) : ( - - )} - - - - ); - }; + // 通用提示组件 + const CustomTooltip = ({ active, payload, label, chartConfig }: any) => { + if (!active || !payload || !payload.length) return null; return ( -
- {loading && ( -
- -
- )} - {error && ( -
-

{error}

-
- )} -
- {chartConfigs.map(renderChart)} +
+

+ {labelFormatter(label)} +

+
+ {payload.map((item: any, index: number) => { + const series = chartConfig.series + ? chartConfig.series.find((s: any) => s.dataKey === item.dataKey) + : { + dataKey: chartConfig.dataKey, + tooltipLabel: chartConfig.tooltipLabel, + tooltipFormatter: chartConfig.tooltipFormatter, + }; + + let value = item.value; + if (series?.tooltipFormatter) { + value = series.tooltipFormatter(value, item.payload); + } else { + value = value.toString(); + } + + return ( +
+
+
+ + {series?.tooltipLabel || item.dataKey}: + +
+ {value} +
+ ); + })}
); - } -); + }; + + // 根据配置渲染图表 + const renderChart = (config: any) => { + const ChartComponent = config.type === "area" ? AreaChart : LineChart; + const DataComponent = + config.type === "area" ? Area : (Line as React.ComponentType); + + const chartConfig = config.series + ? config.series.reduce((acc: any, series: any) => { + acc[series.dataKey] = { + label: series.tooltipLabel || series.dataKey, + color: series.color, + }; + return acc; + }, {}) + : { + [config.dataKey]: { + label: config.tooltipLabel || config.dataKey, + color: config.color, + }, + }; + + return ( + + + {config.title} + {config.value} + + + + + + + ( + + )} + /> + {config.series ? ( + config.series.map((series: any) => ( + + )) + ) : ( + + )} + + + + ); + }; + + return ( +
+ {loading && ( +
+ +
+ )} + {error && ( +
+

{error}

+
+ )} +
+ {chartConfigs.map(renderChart)} +
+
+ ); +}); export default LoadCharts; diff --git a/src/pages/instance/PingChart.tsx b/src/pages/instance/PingChart.tsx index a2c8ca9..7d6aee7 100644 --- a/src/pages/instance/PingChart.tsx +++ b/src/pages/instance/PingChart.tsx @@ -17,6 +17,7 @@ import Loading from "@/components/loading"; import { usePingChart } from "@/hooks/usePingChart"; import fillMissingTimePoints, { cutPeakValues } from "@/utils/RecordHelper"; import { Button } from "@/components/ui/button"; +import { useConfigItem } from "@/config"; interface PingChartProps { node: NodeData; @@ -28,6 +29,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => { const [visiblePingTasks, setVisiblePingTasks] = useState([]); const [timeRange, setTimeRange] = useState<[number, number] | null>(null); const [cutPeak, setCutPeak] = useState(false); + const maxPointsToRender = useConfigItem("pingChartMaxPoints") || 0; // 0表示不限制 useEffect(() => { if (pingHistory?.tasks) { @@ -106,12 +108,11 @@ const PingChart = memo(({ node, hours }: PingChartProps) => { } // 添加渲染硬限制以防止崩溃,即使在间隔调整后也是如此 - const MAX_POINTS_TO_RENDER = 0; - if (full.length > MAX_POINTS_TO_RENDER && MAX_POINTS_TO_RENDER > 0) { + if (full.length > maxPointsToRender && maxPointsToRender > 0) { console.log( - `数据量过大 (${full.length}), 降采样至 ${MAX_POINTS_TO_RENDER} 个点。` + `数据量过大 (${full.length}), 降采样至 ${maxPointsToRender} 个点。` ); - const samplingFactor = Math.ceil(full.length / MAX_POINTS_TO_RENDER); + const samplingFactor = Math.ceil(full.length / maxPointsToRender); const sampledData = []; for (let i = 0; i < full.length; i += samplingFactor) { sampledData.push(full[i]); @@ -125,7 +126,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => { } return full; - }, [pingHistory, hours, cutPeak]); + }, [pingHistory, hours, maxPointsToRender, cutPeak]); const handleTaskVisibilityToggle = (taskId: number) => { setVisiblePingTasks((prev) => diff --git a/src/pages/instance/index.tsx b/src/pages/instance/index.tsx index dfbc503..ad107c4 100644 --- a/src/pages/instance/index.tsx +++ b/src/pages/instance/index.tsx @@ -10,6 +10,7 @@ const LoadCharts = lazy(() => import("./LoadCharts")); const PingChart = lazy(() => import("./PingChart")); import Loading from "@/components/loading"; import Flag from "@/components/sections/Flag"; +import { useConfigItem } from "@/config"; const InstancePage = () => { const { uuid } = useParams<{ uuid: string }>(); @@ -20,12 +21,13 @@ const InstancePage = () => { loading: nodesLoading, } = useNodeData(); const { liveData } = useLiveData(); - const { getRecentLoadHistory } = useNodeData(); + useNodeData(); const [staticNode, setStaticNode] = useState(null); const [chartType, setChartType] = useState<"load" | "ping">("load"); const [loadHours, setLoadHours] = useState(0); const [pingHours, setPingHours] = useState(1); // 默认1小时 - const [realtimeChartData, setRealtimeChartData] = useState([]); + const enableInstanceDetail = useConfigItem("enableInstanceDetail"); + const enablePingChart = useConfigItem("enablePingChart"); const maxRecordPreserveTime = publicSettings?.record_preserve_time || 0; // 默认0表示关闭 const maxPingRecordPreserveTime = @@ -76,57 +78,6 @@ const InstancePage = () => { setStaticNode(foundNode || null); }, [staticNodes, uuid]); - // Effect for fetching initial realtime data - useEffect(() => { - if (uuid && loadHours === 0) { - const fetchInitialData = async () => { - try { - const data = await getRecentLoadHistory(uuid); - setRealtimeChartData(data?.records || []); - } catch (error) { - console.error("Failed to fetch initial realtime chart data:", error); - setRealtimeChartData([]); - } - }; - fetchInitialData(); - } - }, [uuid, loadHours, getRecentLoadHistory]); - - // Effect for handling live data updates - useEffect(() => { - if (loadHours !== 0 || !liveData?.data || !uuid || !liveData.data[uuid]) { - return; - } - - const stats = liveData.data[uuid]; - const newRecord = { - client: uuid, - time: new Date(stats.updated_at).toISOString(), - cpu: stats.cpu.usage, - ram: stats.ram.used, - disk: stats.disk.used, - load: stats.load.load1, - net_in: stats.network.down, - net_out: stats.network.up, - process: stats.process, - connections: stats.connections.tcp, - gpu: 0, - ram_total: stats.ram.total, - swap: stats.swap.used, - swap_total: stats.swap.total, - temp: 0, - disk_total: stats.disk.total, - net_total_up: stats.network.totalUp, - net_total_down: stats.network.totalDown, - connections_udp: stats.connections.udp, - }; - - setRealtimeChartData((prev) => { - const updated = [...prev, newRecord]; - return updated.length > 600 ? updated.slice(-600) : updated; - }); - }, [liveData, uuid, loadHours]); - const node = useMemo(() => { if (!staticNode) return null; const isOnline = liveData?.online.includes(staticNode.uuid) ?? false; @@ -174,45 +125,49 @@ const InstancePage = () => {
- + {enableInstanceDetail && } -
- - +
+
+ + {enablePingChart && ( + + )} +
+ {chartType === "load" ? ( +
+ {loadTimeRanges.map((range) => ( + + ))} +
+ ) : ( +
+ {pingTimeRanges.map((range) => ( + + ))} +
+ )}
- {chartType === "load" ? ( -
- {loadTimeRanges.map((range) => ( - - ))} -
- ) : ( -
- {pingTimeRanges.map((range) => ( - - ))} -
- )} { ) : chartType === "ping" && staticNode ? ( diff --git a/src/types/node.d.ts b/src/types/node.d.ts index 138a100..9e81f9c 100644 --- a/src/types/node.d.ts +++ b/src/types/node.d.ts @@ -18,6 +18,8 @@ export interface NodeData { expired_at: string | null; group: string; tags: string; + traffic_limit?: number; + traffic_limit_type?: "sum" | "max" | "min" | "up" | "down"; created_at: string; updated_at: string; } From 1c83db156020270f2041a8f1b404a7b34201def2 Mon Sep 17 00:00:00 2001 From: Montia37 Date: Fri, 15 Aug 2025 19:33:46 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E9=85=8D=E7=BD=AE=20options=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- komari-theme.json | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/komari-theme.json b/komari-theme.json index eaea359..89ef98a 100644 --- a/komari-theme.json +++ b/komari-theme.json @@ -71,10 +71,7 @@ "key": "selectedDefaultView", "name": "默认展示视图", "type": "select", - "options": [ - "grid", - "table" - ], + "options": "grid,table", "default": "grid", "help": "设置默认展示视图为网格或表格(优先使用 localStorage)" }, @@ -82,11 +79,7 @@ "key": "selectedDefaultAppearance", "name": "默认外观", "type": "select", - "options": [ - "light", - "dark", - "system" - ], + "options": "system,light,dark", "default": "system", "help": "设置默认外观为浅色、深色或系统主题(优先使用 localStorage)" },