fix(dashboard): fix Total Domains filtering and theme switching, add form attributes

This commit is contained in:
2026-09-05 12:45:31 -04:00
parent b1ccf11634
commit 73822f08eb
8 changed files with 106 additions and 15 deletions
+5 -1
View File
@@ -85,7 +85,11 @@ const MainApp: React.FC = () => {
(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}
+22 -7
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";
@@ -94,13 +101,21 @@ export function ThemeProvider({
}; };
}, [theme, hour, isSystemDark]); }, [theme, hour, isSystemDark]);
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,
}),
[theme, handleSetTheme],
);
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,
-1
View File
@@ -13,7 +13,6 @@ export function initCustomConfig() {
: "https://loohui.com/wp-content/uploads/images/background_day.jpg"; : "https://loohui.com/wp-content/uploads/images/background_day.jpg";
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";
+7 -4
View File
@@ -136,13 +136,14 @@ 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]);
const customBackgroundImage = const customBackgroundImage =
@@ -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;
+15
View File
@@ -89,6 +89,9 @@ 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();
window.ForceTheme = "";
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 +190,16 @@ 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");
});
}); });
+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");
});
}); });