Compare commits

..
2 Commits
11 changed files with 275 additions and 70 deletions
+45 -31
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { DateTime } from "luxon";
import type React from "react"; import type React from "react";
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -40,52 +39,67 @@ const MainApp: React.FC = () => {
retry: false, retry: false,
}); });
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const { setTheme } = useTheme(); const { setTheme, effectiveTheme } = useTheme();
const [isCustomCodeInjected, setIsCustomCodeInjected] = useState(false); const [isCustomCodeInjected, setIsCustomCodeInjected] = useState(false);
const { backgroundImage: customBackgroundImage } = useBackground(); const { backgroundImage: customBackgroundImage, updateBackground } =
useBackground();
useEffect(() => { useEffect(() => {
loadServerDetail(); loadServerDetail();
}, []); }, []);
useEffect(() => { useEffect(() => {
const updateConfig = () => { const config = settingData?.data?.config;
const config = settingData?.data?.config; if (config) {
if (config) { if (config.custom_code) {
if (config.custom_code) { InjectContext(config.custom_code);
InjectContext(config.custom_code); setIsCustomCodeInjected(true);
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;
} }
};
updateConfig(); // 同步自定义配置到全局变量
const interval = setInterval(updateConfig, 60000); // Check every minute if (config.custom_logo) window.CustomLogo = config.custom_logo;
return () => clearInterval(interval); 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]); }, [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 = const forceTheme =
(window.ForceTheme as string) !== "" ? window.ForceTheme : undefined; (window.ForceTheme as string) !== "" ? window.ForceTheme : undefined;
useEffect(() => { useEffect(() => {
if (forceTheme === "dark" || forceTheme === "light") { const savedTheme = localStorage.getItem("vite-ui-theme");
if (
(!savedTheme || savedTheme === "system") &&
(forceTheme === "dark" || forceTheme === "light")
) {
setTheme(forceTheme); setTheme(forceTheme);
} }
}, [forceTheme, setTheme]); }, [forceTheme, setTheme]);
+2
View File
@@ -91,6 +91,8 @@ export function DashCommand() {
return ( return (
<CommandDialog open={isOpen} onOpenChange={closeCommand}> <CommandDialog open={isOpen} onOpenChange={closeCommand}>
<CommandInput <CommandInput
id="dash-command-search"
name="dashCommandSearch"
placeholder={t("TypeCommand")} placeholder={t("TypeCommand")}
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
+37 -19
View File
@@ -1,5 +1,12 @@
import { DateTime } from "luxon"; 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"; export type Theme = "dark" | "light" | "system" | "scheduled";
@@ -12,11 +19,13 @@ type ThemeProviderProps = {
type ThemeProviderState = { type ThemeProviderState = {
theme: Theme; theme: Theme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
effectiveTheme: "light" | "dark";
}; };
const initialState: ThemeProviderState = { const initialState: ThemeProviderState = {
theme: "system", theme: "system",
setTheme: () => null, setTheme: () => null,
effectiveTheme: "light",
}; };
const ThemeProviderContext = createContext<ThemeProviderState>(initialState); 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(() => { useEffect(() => {
const root = window.document.documentElement; const root = window.document.documentElement;
@@ -72,17 +92,6 @@ export function ThemeProvider({
}; };
root.classList.add("disable-transitions"); 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); applyTheme(effectiveTheme);
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
@@ -92,15 +101,24 @@ export function ThemeProvider({
return () => { return () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
}; };
}, [theme, hour, isSystemDark]); }, [effectiveTheme]);
const value = { const handleSetTheme = useCallback(
theme, (nextTheme: Theme) => {
setTheme: (theme: Theme) => { localStorage.setItem(storageKey, nextTheme);
localStorage.setItem(storageKey, theme); setTheme(nextTheme);
setTheme(theme);
}, },
}; [storageKey],
);
const value = useMemo(
() => ({
theme,
setTheme: handleSetTheme,
effectiveTheme,
}),
[theme, handleSetTheme, effectiveTheme],
);
return ( return (
<ThemeProviderContext.Provider value={value}> <ThemeProviderContext.Provider value={value}>
+2
View File
@@ -46,6 +46,8 @@ const CommandInput = React.forwardRef<
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
ref={ref} ref={ref}
id={props.id ?? "command-palette-search"}
name={props.name ?? "commandPaletteSearch"}
className={cn( 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", "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, className,
+6 -4
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
declare global { declare global {
interface Window { interface Window {
CustomBackgroundImage: string; CustomBackgroundImage: string;
CustomBackgroundImageDay?: string;
CustomBackgroundImageNight?: string;
CustomMobileBackgroundImage: string; CustomMobileBackgroundImage: string;
ForceShowServices: boolean; ForceShowServices: boolean;
ForceCardInline: boolean; ForceCardInline: boolean;
@@ -17,7 +19,7 @@ const BACKGROUND_CHANGE_EVENT = "backgroundChange";
export function useBackground() { export function useBackground() {
const [backgroundImage, setBackgroundImage] = useState<string | undefined>( const [backgroundImage, setBackgroundImage] = useState<string | undefined>(
undefined, () => window.CustomBackgroundImage || undefined,
); );
useEffect(() => { useEffect(() => {
@@ -61,10 +63,10 @@ export function useBackground() {
}; };
}, []); }, []);
const updateBackground = (newBackground: string | undefined) => { const updateBackground = useCallback((newBackground: string | undefined) => {
window.CustomBackgroundImage = newBackground || ""; window.CustomBackgroundImage = newBackground || "";
window.dispatchEvent(new Event(BACKGROUND_CHANGE_EVENT)); window.dispatchEvent(new Event(BACKGROUND_CHANGE_EVENT));
}; }, []);
return { backgroundImage, updateBackground }; return { backgroundImage, updateBackground };
} }
+29 -7
View File
@@ -2,18 +2,40 @@ import { DateTime } from "luxon";
export function initCustomConfig() { export function initCustomConfig() {
try { 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 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) let isDark = false;
// although the goal is to hardcode these for "consistency". 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 window.CustomBackgroundImage = isDark
? "https://loohui.com/wp-content/uploads/images/background.jpg" ? window.CustomBackgroundImageNight
: "https://loohui.com/wp-content/uploads/images/background_day.jpg"; : window.CustomBackgroundImageDay;
window.CustomMobileBackgroundImage = window.CustomBackgroundImage; window.CustomMobileBackgroundImage = window.CustomBackgroundImage;
window.ForceTheme = isNight ? "dark" : "light";
/* LOGO / 副标题 / 链接 */ /* LOGO / 副标题 / 链接 */
window.CustomLogo = "https://loohui.com/wp-content/uploads/images/pet.png"; window.CustomLogo = "https://loohui.com/wp-content/uploads/images/pet.png";
+8 -5
View File
@@ -136,12 +136,13 @@ export default function Servers({
queryFn: getDomains, queryFn: getDomains,
}); });
// 当用户点击 "在线" 或 "离线" 或 "总服务器数" 时,status 会改变,我们就自动切回服务器视图 const prevStatusRef = useRef(status);
useEffect(() => { useEffect(() => {
// 只有在 status 改变时才触发,避免无限循环 if (prevStatusRef.current !== status) {
const currentStatus = status || "all"; prevStatusRef.current = status;
if (currentStatus !== "all" || activeView === "domains") { if (activeView === "domains") {
setActiveView("servers"); setActiveView("servers");
}
} }
}, [status, activeView]); }, [status, activeView]);
@@ -586,6 +587,8 @@ export default function Servers({
</span> </span>
<select <select
aria-label="Sort metric" aria-label="Sort metric"
id="server-sort-metric"
name="serverSortMetric"
value={sortType} value={sortType}
onChange={(e) => { onChange={(e) => {
const val = e.target.value as typeof sortType; const val = e.target.value as typeof sortType;
+80 -2
View File
@@ -9,6 +9,8 @@ const appMocks = vi.hoisted(() => ({
fetchSetting: vi.fn(), fetchSetting: vi.fn(),
injectContext: vi.fn(), injectContext: vi.fn(),
setTheme: vi.fn(), setTheme: vi.fn(),
updateBackground: vi.fn(),
effectiveTheme: "light" as "light" | "dark",
})); }));
vi.mock("../components/DashCommand", () => ({ vi.mock("../components/DashCommand", () => ({
@@ -38,11 +40,17 @@ vi.mock("../pages/ServerDetail", () => ({
})); }));
vi.mock("../hooks/use-background", () => ({ vi.mock("../hooks/use-background", () => ({
useBackground: () => ({ backgroundImage: appMocks.backgroundImage }), useBackground: () => ({
backgroundImage: appMocks.backgroundImage,
updateBackground: appMocks.updateBackground,
}),
})); }));
vi.mock("../hooks/use-theme", () => ({ vi.mock("../hooks/use-theme", () => ({
useTheme: () => ({ setTheme: appMocks.setTheme }), useTheme: () => ({
setTheme: appMocks.setTheme,
effectiveTheme: appMocks.effectiveTheme,
}),
})); }));
vi.mock("../lib/inject", () => ({ vi.mock("../lib/inject", () => ({
@@ -89,6 +97,13 @@ describe("App", () => {
appMocks.backgroundImage = undefined; appMocks.backgroundImage = undefined;
appMocks.fetchSetting.mockResolvedValue(settingResponse()); appMocks.fetchSetting.mockResolvedValue(settingResponse());
appMocks.injectContext.mockResolvedValue(undefined); 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 () => { 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(); 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");
});
}); });
+12 -1
View File
@@ -9,11 +9,12 @@ import {
import { useTheme } from "@/hooks/use-theme"; import { useTheme } from "@/hooks/use-theme";
function ThemeProbe() { function ThemeProbe() {
const { setTheme, theme } = useTheme(); const { setTheme, theme, effectiveTheme } = useTheme();
return ( return (
<div> <div>
<p data-testid="theme-state">{theme}</p> <p data-testid="theme-state">{theme}</p>
<p data-testid="effective-theme-state">{effectiveTheme}</p>
<button type="button" onClick={() => setTheme("dark")}> <button type="button" onClick={() => setTheme("dark")}>
dark dark
</button> </button>
@@ -43,11 +44,17 @@ describe("ThemeProvider", () => {
); );
expect(screen.getByTestId("theme-state")).toHaveTextContent("system"); expect(screen.getByTestId("theme-state")).toHaveTextContent("system");
expect(screen.getByTestId("effective-theme-state")).toHaveTextContent(
"light",
);
expect(document.documentElement).toHaveClass("light"); expect(document.documentElement).toHaveClass("light");
await user.click(screen.getByRole("button", { name: "dark" })); await user.click(screen.getByRole("button", { name: "dark" }));
expect(screen.getByTestId("theme-state")).toHaveTextContent("dark"); expect(screen.getByTestId("theme-state")).toHaveTextContent("dark");
expect(screen.getByTestId("effective-theme-state")).toHaveTextContent(
"dark",
);
expect(localStorage.getItem("theme-test")).toBe("dark"); expect(localStorage.getItem("theme-test")).toBe("dark");
expect(document.documentElement).toHaveClass("dark"); expect(document.documentElement).toHaveClass("dark");
expect(document.documentElement.style.colorScheme).toBe("dark"); expect(document.documentElement.style.colorScheme).toBe("dark");
@@ -57,6 +64,10 @@ describe("ThemeProvider", () => {
); );
await user.click(screen.getByRole("button", { name: "light" })); 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"); expect(document.documentElement).toHaveClass("light");
}); });
+52 -1
View File
@@ -52,12 +52,32 @@ vi.mock("@/components/ServerOverview", () => ({
offline, offline,
online, online,
total, total,
onViewChange,
}: { }: {
offline: number; offline: number;
online: number; online: number;
total: 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.getByText("alpha")).toBeInTheDocument();
expect(screen.queryByText("beta")).not.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");
});
}); });
+2
View File
@@ -2,6 +2,8 @@
interface Window { interface Window {
CustomBackgroundImage: string; CustomBackgroundImage: string;
CustomBackgroundImageDay?: string;
CustomBackgroundImageNight?: string;
CustomMobileBackgroundImage: string; CustomMobileBackgroundImage: string;
ForceTheme: string; ForceTheme: string;
CustomLogo: string; CustomLogo: string;