perf: 优化亮暗模式切换方式

This commit is contained in:
Montia37
2025-09-09 23:50:18 +08:00
parent 5df55fc916
commit 3251d69b92
5 changed files with 190 additions and 125 deletions

View File

@@ -6,6 +6,7 @@ import {
Table2,
Moon,
Sun,
SunMoon,
CircleUserIcon,
Menu,
} from "lucide-react";
@@ -18,23 +19,19 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface HeaderProps {
viewMode: "grid" | "table";
setViewMode: (mode: "grid" | "table") => void;
searchTerm: string;
setSearchTerm: (term: string) => void;
}
export const Header = ({
viewMode,
setViewMode,
searchTerm,
setSearchTerm,
}: HeaderProps) => {
const { appearance, setAppearance } = useTheme();
export const Header = ({ searchTerm, setSearchTerm }: HeaderProps) => {
const { rawAppearance, setAppearance, viewMode, setViewMode } = useTheme();
const [isSearchOpen, setIsSearchOpen] = useState(false);
const location = useLocation();
const isInstancePage = location.pathname.startsWith("/instance");
@@ -52,10 +49,6 @@ export const Header = ({
}
}, [sitename]);
const toggleAppearance = () => {
setAppearance(appearance === "light" ? "dark" : "light");
};
return (
<header className="purcarte-blur border-b border-(--accent-a4) shadow-sm shadow-(color:--accent-a4) sticky top-0 flex items-center justify-center z-10">
<div className="w-[90%] max-w-screen-2xl px-4 py-2 flex items-center justify-between">
@@ -128,16 +121,35 @@ export const Header = ({
{viewMode === "grid" ? "表格视图" : "网格视图"}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={toggleAppearance}>
{appearance === "dark" ? (
<Sun className="size-4 mr-2 text-primary" />
) : (
<Moon className="size-4 mr-2 text-primary" />
)}
<span>
{appearance === "dark" ? "浅色模式" : "深色模式"}
</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{rawAppearance === "light" ? (
<Sun className="size-4 mr-2 text-primary" />
) : rawAppearance === "dark" ? (
<Moon className="size-4 mr-2 text-primary" />
) : (
<SunMoon className="size-4 mr-2 text-primary" />
)}
<span></span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="purcarte-blur border-(--accent-4)/50 rounded-xl">
<DropdownMenuItem
onClick={() => setAppearance("light")}>
<Sun className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setAppearance("dark")}>
<Moon className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setAppearance("system")}>
<SunMoon className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{enableAdminButton && (
<DropdownMenuItem asChild>
<a
@@ -195,16 +207,35 @@ export const Header = ({
<Grid3X3 className="size-5 text-primary" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={toggleAppearance}>
{appearance === "dark" ? (
<Sun className="size-5 text-primary" />
) : (
<Moon className="size-5 text-primary" />
)}
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{rawAppearance === "light" ? (
<Sun className="size-5 text-primary" />
) : rawAppearance === "dark" ? (
<Moon className="size-5 text-primary" />
) : (
<SunMoon className="size-5 text-primary" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="purcarte-blur mt-[.5rem] border-(--accent-4)/50 rounded-xl">
<DropdownMenuItem onClick={() => setAppearance("light")}>
<Sun className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAppearance("dark")}>
<Moon className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAppearance("system")}>
<SunMoon className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{enableAdminButton && (
<a href="/admin" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="icon">
@@ -232,16 +263,34 @@ export const Header = ({
<DropdownMenuContent
align="end"
className="animate-in slide-in-from-top-5 duration-300 purcarte-blur border-(--accent-4)/50 rounded-xl">
<DropdownMenuItem onClick={toggleAppearance}>
{appearance === "dark" ? (
<Sun className="size-4 mr-2 text-primary" />
) : (
<Moon className="size-4 mr-2 text-primary" />
)}
<span>
{appearance === "dark" ? "浅色模式" : "深色模式"}
</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{rawAppearance === "light" ? (
<Sun className="size-4 mr-2 text-primary" />
) : rawAppearance === "dark" ? (
<Moon className="size-4 mr-2 text-primary" />
) : (
<SunMoon className="size-4 mr-2 text-primary" />
)}
<span></span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="purcarte-blur border-(--accent-4)/50 rounded-xl">
<DropdownMenuItem
onClick={() => setAppearance("light")}>
<Sun className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAppearance("dark")}>
<Moon className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setAppearance("system")}>
<SunMoon className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{enableAdminButton && (
<DropdownMenuItem asChild>
<a
@@ -258,16 +307,35 @@ export const Header = ({
</DropdownMenu>
) : (
<>
<Button
variant="ghost"
size="icon"
onClick={toggleAppearance}>
{appearance === "dark" ? (
<Sun className="size-5 text-primary" />
) : (
<Moon className="size-5 text-primary" />
)}
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{rawAppearance === "light" ? (
<Sun className="size-5 text-primary" />
) : rawAppearance === "dark" ? (
<Moon className="size-5 text-primary" />
) : (
<SunMoon className="size-5 text-primary" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="purcarte-blur mt-[.5rem] border-(--accent-4)/50 rounded-xl">
<DropdownMenuItem onClick={() => setAppearance("light")}>
<Sun className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAppearance("dark")}>
<Moon className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAppearance("system")}>
<SunMoon className="size-4 mr-2 text-primary" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{enableAdminButton && (
<a href="/admin" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="icon">

View File

@@ -42,14 +42,18 @@ const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] purcarte-blur overflow-hidden theme-card-style bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
<DropdownMenuPrimitive.Portal>
<Theme>
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] purcarte-blur overflow-hidden theme-card-style bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
</Theme>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, createContext } from "react";
import { useState, useEffect, createContext, useContext } from "react";
import { useConfigItem } from "@/config";
import { DEFAULT_CONFIG } from "@/config/default";
export const allowedColors = [
"gray",
@@ -35,25 +36,24 @@ export type Colors = (typeof allowedColors)[number];
export const allowedAppearances = ["light", "dark", "system"] as const;
export type Appearance = (typeof allowedAppearances)[number];
export const THEME_DEFAULTS = {
appearance: "system" as Appearance,
color: "gray" as Colors,
} as const;
export interface ThemeContextType {
appearance: "light" | "dark";
rawAppearance: Appearance;
setAppearance: (appearance: Appearance) => void;
color: Colors;
setColor: (color: Colors) => void;
viewMode: "grid" | "table";
setViewMode: (mode: "grid" | "table") => void;
}
export const ThemeContext = createContext<ThemeContextType>({
appearance: "light",
rawAppearance: THEME_DEFAULTS.appearance,
rawAppearance: DEFAULT_CONFIG.selectedDefaultAppearance as Appearance,
setAppearance: () => {},
color: THEME_DEFAULTS.color,
color: DEFAULT_CONFIG.selectThemeColor as Colors,
setColor: () => {},
viewMode: DEFAULT_CONFIG.selectedDefaultView as "grid" | "table",
setViewMode: () => {},
});
/**
@@ -98,48 +98,69 @@ export const useSystemTheme = (appearance: Appearance): "light" | "dark" => {
return appearance as "light" | "dark";
};
import { useContext } from "react";
export const useThemeManager = () => {
const useStoredState = <T>(
key: string,
defaultValue: T,
validator?: (value: any) => value is T
): [T, React.Dispatch<React.SetStateAction<T>>] => {
const enableLocalStorage = useConfigItem("enableLocalStorage");
const defaultAppearance = useConfigItem("selectedDefaultAppearance");
const defaultColor = useConfigItem("selectThemeColor");
const [appearance, setAppearance] = useState<Appearance>(() => {
const [state, setState] = useState<T>(() => {
if (enableLocalStorage) {
const storedAppearance = localStorage.getItem("appearance");
const cleanedAppearance = storedAppearance
? storedAppearance.replace(/^"|"$/g, "")
: null;
if (allowedAppearances.includes(cleanedAppearance as Appearance)) {
return cleanedAppearance as Appearance;
const storedValue = localStorage.getItem(key);
if (storedValue) {
const cleanedValue = storedValue.replace(/^"|"$/g, "");
if (!validator || validator(cleanedValue)) {
return cleanedValue as T;
}
}
}
return (defaultAppearance as Appearance) || THEME_DEFAULTS.appearance;
return defaultValue;
});
const [color, setColor] = useState<Colors>(
(defaultColor as Colors) || THEME_DEFAULTS.color
useEffect(() => {
if (enableLocalStorage) {
localStorage.setItem(key, String(state));
}
}, [key, state, enableLocalStorage]);
return [state, setState];
};
export const useThemeManager = () => {
const defaultAppearance = useConfigItem(
"selectedDefaultAppearance"
) as Appearance;
const defaultColor = useConfigItem("selectThemeColor") as Colors;
const defaultView = useConfigItem("selectedDefaultView") as "grid" | "table";
const [appearance, setAppearance] = useStoredState<Appearance>(
"appearance",
defaultAppearance,
(v): v is Appearance => allowedAppearances.includes(v)
);
const [color, setColor] = useStoredState<Colors>("color", defaultColor);
const [viewMode, setViewMode] = useStoredState<"grid" | "table">(
"nodeViewMode",
defaultView
);
useEffect(() => {
setColor((defaultColor as Colors) || THEME_DEFAULTS.color);
}, [defaultColor]);
setColor(defaultColor);
}, [defaultColor, setColor]);
const resolvedAppearance = useSystemTheme(appearance);
useEffect(() => {
if (enableLocalStorage) {
localStorage.setItem("appearance", appearance);
}
}, [appearance, enableLocalStorage]);
return {
appearance: resolvedAppearance,
rawAppearance: appearance,
setAppearance,
color,
setColor,
viewMode,
setViewMode,
};
};
export const useTheme = () => {

View File

@@ -1,4 +1,4 @@
import { StrictMode, useState, useEffect, lazy, Suspense } from "react";
import { StrictMode, useState, lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import "./index.css";
@@ -23,29 +23,10 @@ import { useConfigItem } from "@/config";
// 内部应用组件,在 ConfigProvider 内部使用配置
export const AppContent = () => {
const { appearance, color } = useTheme();
const defaultView = useConfigItem("selectedDefaultView");
const enableLocalStorage = useConfigItem("enableLocalStorage");
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
if (enableLocalStorage) {
const savedMode = localStorage.getItem("nodeViewMode");
const cleanedMode = savedMode ? savedMode.replace(/^"|"$/g, "") : null;
if (cleanedMode === "grid" || cleanedMode === "table") {
return cleanedMode;
}
}
return defaultView || "grid";
});
const [searchTerm, setSearchTerm] = useState("");
const enableVideoBackground = useConfigItem("enableVideoBackground");
const videoBackgroundUrl = useConfigItem("videoBackgroundUrl");
useEffect(() => {
if (enableLocalStorage) {
localStorage.setItem("nodeViewMode", viewMode);
}
}, [enableLocalStorage, viewMode]);
return (
<>
{enableVideoBackground && videoBackgroundUrl && (
@@ -63,19 +44,13 @@ export const AppContent = () => {
scaling="110%"
style={{ backgroundColor: "transparent" }}>
<div className="min-h-screen flex flex-col text-sm">
<Header
viewMode={viewMode}
setViewMode={setViewMode}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
<Header searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
<Suspense fallback={<Loading />}>
<Routes>
<Route
path="/"
element={
<HomePage
viewMode={viewMode}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>

View File

@@ -9,6 +9,7 @@ import type { NodeWithStatus } from "@/types/node";
import { useNodeData } from "@/contexts/NodeDataContext";
import { useLiveData } from "@/contexts/LiveDataContext";
import { useAppConfig } from "@/config";
import { useTheme } from "@/hooks/useTheme";
import {
Card,
CardDescription,
@@ -18,7 +19,6 @@ import {
} from "@/components/ui/card";
interface HomePageProps {
viewMode: "grid" | "table";
searchTerm: string;
setSearchTerm: (term: string) => void;
}
@@ -28,11 +28,8 @@ const homeStateCache = {
scrollPosition: 0,
};
const HomePage: React.FC<HomePageProps> = ({
viewMode,
searchTerm,
setSearchTerm,
}) => {
const HomePage: React.FC<HomePageProps> = ({ searchTerm, setSearchTerm }) => {
const { viewMode } = useTheme();
const { nodes: staticNodes, loading, getGroups } = useNodeData();
const { liveData } = useLiveData();
const [selectedGroup, setSelectedGroup] = useState(