mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 09:40:14 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc0830aa5 | ||
|
|
73822f08eb |
+45
-31
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DateTime } from "luxon";
|
||||
import type React from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -40,52 +39,67 @@ const MainApp: React.FC = () => {
|
||||
retry: false,
|
||||
});
|
||||
const { i18n } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
const { setTheme, effectiveTheme } = useTheme();
|
||||
const [isCustomCodeInjected, setIsCustomCodeInjected] = useState(false);
|
||||
const { backgroundImage: customBackgroundImage } = useBackground();
|
||||
const { backgroundImage: customBackgroundImage, updateBackground } =
|
||||
useBackground();
|
||||
|
||||
useEffect(() => {
|
||||
loadServerDetail();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updateConfig = () => {
|
||||
const config = settingData?.data?.config;
|
||||
if (config) {
|
||||
if (config.custom_code) {
|
||||
InjectContext(config.custom_code);
|
||||
setIsCustomCodeInjected(true);
|
||||
}
|
||||
|
||||
// 同步自定义配置到全局变量
|
||||
if (config.custom_logo) window.CustomLogo = config.custom_logo;
|
||||
if (config.custom_description)
|
||||
window.CustomDesc = config.custom_description;
|
||||
if (config.custom_links) window.CustomLinks = config.custom_links;
|
||||
|
||||
const hour = DateTime.now().hour;
|
||||
const isNight = hour >= 18 || hour < 6;
|
||||
|
||||
if (isNight && config.background_image_night) {
|
||||
window.CustomBackgroundImage = config.background_image_night;
|
||||
} else if (!isNight && config.background_image_day) {
|
||||
window.CustomBackgroundImage = config.background_image_day;
|
||||
}
|
||||
window.CustomMobileBackgroundImage = window.CustomBackgroundImage;
|
||||
const config = settingData?.data?.config;
|
||||
if (config) {
|
||||
if (config.custom_code) {
|
||||
InjectContext(config.custom_code);
|
||||
setIsCustomCodeInjected(true);
|
||||
}
|
||||
};
|
||||
|
||||
updateConfig();
|
||||
const interval = setInterval(updateConfig, 60000); // Check every minute
|
||||
return () => clearInterval(interval);
|
||||
// 同步自定义配置到全局变量
|
||||
if (config.custom_logo) window.CustomLogo = config.custom_logo;
|
||||
if (config.custom_description)
|
||||
window.CustomDesc = config.custom_description;
|
||||
if (config.custom_links) window.CustomLinks = config.custom_links;
|
||||
|
||||
if (config.background_image_day) {
|
||||
window.CustomBackgroundImageDay = config.background_image_day;
|
||||
}
|
||||
if (config.background_image_night) {
|
||||
window.CustomBackgroundImageNight = config.background_image_night;
|
||||
}
|
||||
}
|
||||
}, [settingData]);
|
||||
|
||||
// 监听有效主题以及配置变化,动态切换日夜背景
|
||||
useEffect(() => {
|
||||
const config = settingData?.data?.config;
|
||||
const bgDay =
|
||||
config?.background_image_day || window.CustomBackgroundImageDay;
|
||||
const bgNight =
|
||||
config?.background_image_night || window.CustomBackgroundImageNight;
|
||||
|
||||
const targetBg =
|
||||
effectiveTheme === "dark"
|
||||
? bgNight || window.CustomBackgroundImage
|
||||
: bgDay || window.CustomBackgroundImage;
|
||||
|
||||
if (targetBg) {
|
||||
updateBackground?.(targetBg);
|
||||
window.CustomMobileBackgroundImage = targetBg;
|
||||
}
|
||||
}, [effectiveTheme, settingData, updateBackground]);
|
||||
|
||||
// 检测是否强制指定了主题颜色
|
||||
const forceTheme =
|
||||
(window.ForceTheme as string) !== "" ? window.ForceTheme : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (forceTheme === "dark" || forceTheme === "light") {
|
||||
const savedTheme = localStorage.getItem("vite-ui-theme");
|
||||
if (
|
||||
(!savedTheme || savedTheme === "system") &&
|
||||
(forceTheme === "dark" || forceTheme === "light")
|
||||
) {
|
||||
setTheme(forceTheme);
|
||||
}
|
||||
}, [forceTheme, setTheme]);
|
||||
|
||||
@@ -91,6 +91,8 @@ export function DashCommand() {
|
||||
return (
|
||||
<CommandDialog open={isOpen} onOpenChange={closeCommand}>
|
||||
<CommandInput
|
||||
id="dash-command-search"
|
||||
name="dashCommandSearch"
|
||||
placeholder={t("TypeCommand")}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { createContext, type ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type Theme = "dark" | "light" | "system" | "scheduled";
|
||||
|
||||
@@ -12,11 +19,13 @@ type ThemeProviderProps = {
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
effectiveTheme: "light" | "dark";
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
effectiveTheme: "light",
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
@@ -51,6 +60,17 @@ export function ThemeProvider({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const effectiveTheme: "light" | "dark" = useMemo(() => {
|
||||
if (theme === "system") {
|
||||
return isSystemDark ? "dark" : "light";
|
||||
}
|
||||
if (theme === "scheduled") {
|
||||
const isNight = hour >= 18 || hour < 6;
|
||||
return isNight ? "dark" : "light";
|
||||
}
|
||||
return theme;
|
||||
}, [theme, isSystemDark, hour]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
@@ -72,17 +92,6 @@ export function ThemeProvider({
|
||||
};
|
||||
|
||||
root.classList.add("disable-transitions");
|
||||
|
||||
let effectiveTheme: "light" | "dark" = "light";
|
||||
if (theme === "system") {
|
||||
effectiveTheme = isSystemDark ? "dark" : "light";
|
||||
} else if (theme === "scheduled") {
|
||||
const isNight = hour >= 18 || hour < 6;
|
||||
effectiveTheme = isNight ? "dark" : "light";
|
||||
} else {
|
||||
effectiveTheme = theme;
|
||||
}
|
||||
|
||||
applyTheme(effectiveTheme);
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -92,15 +101,24 @@ export function ThemeProvider({
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [theme, hour, isSystemDark]);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
const handleSetTheme = useCallback(
|
||||
(nextTheme: Theme) => {
|
||||
localStorage.setItem(storageKey, nextTheme);
|
||||
setTheme(nextTheme);
|
||||
},
|
||||
};
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme: handleSetTheme,
|
||||
effectiveTheme,
|
||||
}),
|
||||
[theme, handleSetTheme, effectiveTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
|
||||
@@ -46,6 +46,8 @@ const CommandInput = React.forwardRef<
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
id={props.id ?? "command-palette-search"}
|
||||
name={props.name ?? "commandPaletteSearch"}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CustomBackgroundImage: string;
|
||||
CustomBackgroundImageDay?: string;
|
||||
CustomBackgroundImageNight?: string;
|
||||
CustomMobileBackgroundImage: string;
|
||||
ForceShowServices: boolean;
|
||||
ForceCardInline: boolean;
|
||||
@@ -17,7 +19,7 @@ const BACKGROUND_CHANGE_EVENT = "backgroundChange";
|
||||
|
||||
export function useBackground() {
|
||||
const [backgroundImage, setBackgroundImage] = useState<string | undefined>(
|
||||
undefined,
|
||||
() => window.CustomBackgroundImage || undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,10 +63,10 @@ export function useBackground() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateBackground = (newBackground: string | undefined) => {
|
||||
const updateBackground = useCallback((newBackground: string | undefined) => {
|
||||
window.CustomBackgroundImage = newBackground || "";
|
||||
window.dispatchEvent(new Event(BACKGROUND_CHANGE_EVENT));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { backgroundImage, updateBackground };
|
||||
}
|
||||
|
||||
@@ -2,18 +2,40 @@ import { DateTime } from "luxon";
|
||||
|
||||
export function initCustomConfig() {
|
||||
try {
|
||||
// Default day / night background images
|
||||
window.CustomBackgroundImageDay =
|
||||
window.CustomBackgroundImageDay ||
|
||||
"https://loohui.com/wp-content/uploads/images/background_day.jpg";
|
||||
window.CustomBackgroundImageNight =
|
||||
window.CustomBackgroundImageNight ||
|
||||
"https://loohui.com/wp-content/uploads/images/background.jpg";
|
||||
|
||||
const savedTheme = localStorage.getItem("vite-ui-theme");
|
||||
const systemDark =
|
||||
typeof window !== "undefined" && window.matchMedia
|
||||
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
: false;
|
||||
const hour = DateTime.now().hour;
|
||||
const isNight = hour >= 18 || hour < 6;
|
||||
const isScheduledNight = hour >= 18 || hour < 6;
|
||||
|
||||
// Use default values if window variables are not already set (e.g. by backend custom_code)
|
||||
// although the goal is to hardcode these for "consistency".
|
||||
let isDark = false;
|
||||
if (savedTheme === "dark") {
|
||||
isDark = true;
|
||||
} else if (savedTheme === "light") {
|
||||
isDark = false;
|
||||
} else if (savedTheme === "scheduled") {
|
||||
isDark = isScheduledNight;
|
||||
} else if (savedTheme === "system") {
|
||||
isDark = systemDark;
|
||||
} else {
|
||||
isDark = isScheduledNight;
|
||||
}
|
||||
|
||||
window.CustomBackgroundImage = isNight
|
||||
? "https://loohui.com/wp-content/uploads/images/background.jpg"
|
||||
: "https://loohui.com/wp-content/uploads/images/background_day.jpg";
|
||||
window.CustomBackgroundImage = isDark
|
||||
? window.CustomBackgroundImageNight
|
||||
: window.CustomBackgroundImageDay;
|
||||
|
||||
window.CustomMobileBackgroundImage = window.CustomBackgroundImage;
|
||||
window.ForceTheme = isNight ? "dark" : "light";
|
||||
|
||||
/* LOGO / 副标题 / 链接 */
|
||||
window.CustomLogo = "https://loohui.com/wp-content/uploads/images/pet.png";
|
||||
|
||||
@@ -136,12 +136,13 @@ export default function Servers({
|
||||
queryFn: getDomains,
|
||||
});
|
||||
|
||||
// 当用户点击 "在线" 或 "离线" 或 "总服务器数" 时,status 会改变,我们就自动切回服务器视图
|
||||
const prevStatusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
// 只有在 status 改变时才触发,避免无限循环
|
||||
const currentStatus = status || "all";
|
||||
if (currentStatus !== "all" || activeView === "domains") {
|
||||
setActiveView("servers");
|
||||
if (prevStatusRef.current !== status) {
|
||||
prevStatusRef.current = status;
|
||||
if (activeView === "domains") {
|
||||
setActiveView("servers");
|
||||
}
|
||||
}
|
||||
}, [status, activeView]);
|
||||
|
||||
@@ -586,6 +587,8 @@ export default function Servers({
|
||||
</span>
|
||||
<select
|
||||
aria-label="Sort metric"
|
||||
id="server-sort-metric"
|
||||
name="serverSortMetric"
|
||||
value={sortType}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as typeof sortType;
|
||||
|
||||
+80
-2
@@ -9,6 +9,8 @@ const appMocks = vi.hoisted(() => ({
|
||||
fetchSetting: vi.fn(),
|
||||
injectContext: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
updateBackground: vi.fn(),
|
||||
effectiveTheme: "light" as "light" | "dark",
|
||||
}));
|
||||
|
||||
vi.mock("../components/DashCommand", () => ({
|
||||
@@ -38,11 +40,17 @@ vi.mock("../pages/ServerDetail", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-background", () => ({
|
||||
useBackground: () => ({ backgroundImage: appMocks.backgroundImage }),
|
||||
useBackground: () => ({
|
||||
backgroundImage: appMocks.backgroundImage,
|
||||
updateBackground: appMocks.updateBackground,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-theme", () => ({
|
||||
useTheme: () => ({ setTheme: appMocks.setTheme }),
|
||||
useTheme: () => ({
|
||||
setTheme: appMocks.setTheme,
|
||||
effectiveTheme: appMocks.effectiveTheme,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/inject", () => ({
|
||||
@@ -89,6 +97,13 @@ describe("App", () => {
|
||||
appMocks.backgroundImage = undefined;
|
||||
appMocks.fetchSetting.mockResolvedValue(settingResponse());
|
||||
appMocks.injectContext.mockResolvedValue(undefined);
|
||||
appMocks.setTheme.mockClear();
|
||||
appMocks.updateBackground.mockClear();
|
||||
appMocks.effectiveTheme = "light";
|
||||
window.ForceTheme = "";
|
||||
window.CustomBackgroundImageDay = "/day.png";
|
||||
window.CustomBackgroundImageNight = "/night.png";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders the main shell after settings load and applies global theme/background settings", async () => {
|
||||
@@ -187,4 +202,67 @@ describe("App", () => {
|
||||
|
||||
expect(await screen.findByText("server-detail-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not overwrite user-selected theme with ForceTheme if user previously saved a theme", async () => {
|
||||
localStorage.setItem("vite-ui-theme", "light");
|
||||
Object.assign(window, {
|
||||
ForceTheme: "dark",
|
||||
});
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(await screen.findByText("server-page")).toBeInTheDocument();
|
||||
expect(appMocks.setTheme).not.toHaveBeenCalledWith("dark");
|
||||
});
|
||||
|
||||
it("updates background to day background when effective theme is light", async () => {
|
||||
appMocks.effectiveTheme = "light";
|
||||
window.CustomBackgroundImageDay = "/day.png";
|
||||
window.CustomBackgroundImageNight = "/night.png";
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(appMocks.updateBackground).toHaveBeenCalledWith("/day.png");
|
||||
});
|
||||
expect(window.CustomMobileBackgroundImage).toBe("/day.png");
|
||||
});
|
||||
|
||||
it("updates background to night background when effective theme is dark", async () => {
|
||||
appMocks.effectiveTheme = "dark";
|
||||
window.CustomBackgroundImageDay = "/day.png";
|
||||
window.CustomBackgroundImageNight = "/night.png";
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(appMocks.updateBackground).toHaveBeenCalledWith("/night.png");
|
||||
});
|
||||
expect(window.CustomMobileBackgroundImage).toBe("/night.png");
|
||||
});
|
||||
|
||||
it("applies day and night background images from backend settings config", async () => {
|
||||
appMocks.effectiveTheme = "light";
|
||||
appMocks.fetchSetting.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
config: {
|
||||
...settingResponse().data.config,
|
||||
background_image_day: "/backend-day.png",
|
||||
background_image_night: "/backend-night.png",
|
||||
},
|
||||
version: "1.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(appMocks.updateBackground).toHaveBeenCalledWith(
|
||||
"/backend-day.png",
|
||||
);
|
||||
});
|
||||
expect(window.CustomBackgroundImageDay).toBe("/backend-day.png");
|
||||
expect(window.CustomBackgroundImageNight).toBe("/backend-night.png");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
import { useTheme } from "@/hooks/use-theme";
|
||||
|
||||
function ThemeProbe() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const { setTheme, theme, effectiveTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p data-testid="theme-state">{theme}</p>
|
||||
<p data-testid="effective-theme-state">{effectiveTheme}</p>
|
||||
<button type="button" onClick={() => setTheme("dark")}>
|
||||
dark
|
||||
</button>
|
||||
@@ -43,11 +44,17 @@ describe("ThemeProvider", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("theme-state")).toHaveTextContent("system");
|
||||
expect(screen.getByTestId("effective-theme-state")).toHaveTextContent(
|
||||
"light",
|
||||
);
|
||||
expect(document.documentElement).toHaveClass("light");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "dark" }));
|
||||
|
||||
expect(screen.getByTestId("theme-state")).toHaveTextContent("dark");
|
||||
expect(screen.getByTestId("effective-theme-state")).toHaveTextContent(
|
||||
"dark",
|
||||
);
|
||||
expect(localStorage.getItem("theme-test")).toBe("dark");
|
||||
expect(document.documentElement).toHaveClass("dark");
|
||||
expect(document.documentElement.style.colorScheme).toBe("dark");
|
||||
@@ -57,6 +64,10 @@ describe("ThemeProvider", () => {
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "light" }));
|
||||
expect(screen.getByTestId("theme-state")).toHaveTextContent("light");
|
||||
expect(screen.getByTestId("effective-theme-state")).toHaveTextContent(
|
||||
"light",
|
||||
);
|
||||
expect(document.documentElement).toHaveClass("light");
|
||||
});
|
||||
|
||||
|
||||
@@ -52,12 +52,32 @@ vi.mock("@/components/ServerOverview", () => ({
|
||||
offline,
|
||||
online,
|
||||
total,
|
||||
onViewChange,
|
||||
}: {
|
||||
offline: number;
|
||||
online: number;
|
||||
total: number;
|
||||
totalDomains?: number;
|
||||
onViewChange?: (view: "servers" | "domains") => void;
|
||||
}) => (
|
||||
<div data-testid="server-overview">{`${total}:${online}:${offline}`}</div>
|
||||
<div data-testid="server-overview">
|
||||
<span>{`${total}:${online}:${offline}`}</span>
|
||||
{onViewChange && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="switch-to-domains"
|
||||
onClick={() => onViewChange("domains")}
|
||||
>
|
||||
domains-view
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/DomainStatus", () => ({
|
||||
DomainStatus: () => (
|
||||
<div data-testid="domain-status">domain-status-content</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -542,4 +562,35 @@ describe("Servers page", () => {
|
||||
expect(screen.getByText("alpha")).toBeInTheDocument();
|
||||
expect(screen.queryByText("beta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to domains view when Total Domains is selected and does not immediately revert", async () => {
|
||||
const online = createServer({ id: 1, name: "alpha" });
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderServerPage({
|
||||
connected: true,
|
||||
lastData: websocketPayload([online]),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("server-card")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("switch-to-domains"));
|
||||
|
||||
// Server controls/cards should now be hidden
|
||||
expect(screen.queryByTestId("server-card")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("domain-status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders sort metric select with id and name attributes", async () => {
|
||||
const online = createServer({ id: 1, name: "alpha" });
|
||||
|
||||
renderServerPage({
|
||||
connected: true,
|
||||
lastData: websocketPayload([online]),
|
||||
});
|
||||
|
||||
const selectElement = screen.getByRole("combobox", { name: "Sort metric" });
|
||||
expect(selectElement).toHaveAttribute("id", "server-sort-metric");
|
||||
expect(selectElement).toHaveAttribute("name", "serverSortMetric");
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+2
@@ -2,6 +2,8 @@
|
||||
|
||||
interface Window {
|
||||
CustomBackgroundImage: string;
|
||||
CustomBackgroundImageDay?: string;
|
||||
CustomBackgroundImageNight?: string;
|
||||
CustomMobileBackgroundImage: string;
|
||||
ForceTheme: string;
|
||||
CustomLogo: string;
|
||||
|
||||
Reference in New Issue
Block a user