diff --git a/src/App.tsx b/src/App.tsx index 242c3a6..ef376b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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,46 +39,57 @@ 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; diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 71143fc..e54dea7 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -19,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(initialState); @@ -58,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; @@ -79,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(() => { @@ -99,7 +101,7 @@ export function ThemeProvider({ return () => { window.clearTimeout(timeoutId); }; - }, [theme, hour, isSystemDark]); + }, [effectiveTheme]); const handleSetTheme = useCallback( (nextTheme: Theme) => { @@ -113,8 +115,9 @@ export function ThemeProvider({ () => ({ theme, setTheme: handleSetTheme, + effectiveTheme, }), - [theme, handleSetTheme], + [theme, handleSetTheme, effectiveTheme], ); return ( diff --git a/src/hooks/use-background.ts b/src/hooks/use-background.ts index 56d61fe..f88fe6d 100644 --- a/src/hooks/use-background.ts +++ b/src/hooks/use-background.ts @@ -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( - 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 }; } diff --git a/src/lib/custom-config.ts b/src/lib/custom-config.ts index 19eda64..1e60757 100644 --- a/src/lib/custom-config.ts +++ b/src/lib/custom-config.ts @@ -2,15 +2,38 @@ 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; diff --git a/src/test/app.test.tsx b/src/test/app.test.tsx index 87cd41e..10a1475 100644 --- a/src/test/app.test.tsx +++ b/src/test/app.test.tsx @@ -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", () => ({ @@ -90,7 +98,11 @@ describe("App", () => { 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(); }); @@ -202,4 +214,55 @@ describe("App", () => { 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"); + }); }); diff --git a/src/test/components/ThemeProvider.test.tsx b/src/test/components/ThemeProvider.test.tsx index 3fbb659..526c711 100644 --- a/src/test/components/ThemeProvider.test.tsx +++ b/src/test/components/ThemeProvider.test.tsx @@ -9,11 +9,12 @@ import { import { useTheme } from "@/hooks/use-theme"; function ThemeProbe() { - const { setTheme, theme } = useTheme(); + const { setTheme, theme, effectiveTheme } = useTheme(); return (

{theme}

+

{effectiveTheme}

@@ -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"); }); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 672cd18..473c14d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,8 @@ interface Window { CustomBackgroundImage: string; + CustomBackgroundImageDay?: string; + CustomBackgroundImageNight?: string; CustomMobileBackgroundImage: string; ForceTheme: string; CustomLogo: string;