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

View File

@@ -42,14 +42,18 @@ const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.Portal>
ref={ref} <Theme>
className={cn( <DropdownMenuPrimitive.SubContent
"z-50 min-w-[8rem] purcarte-blur overflow-hidden theme-card-style bg-popover p-1 text-popover-foreground", ref={ref}
className className={cn(
)} "z-50 min-w-[8rem] purcarte-blur overflow-hidden theme-card-style bg-popover p-1 text-popover-foreground",
{...props} className
/> )}
{...props}
/>
</Theme>
</DropdownMenuPrimitive.Portal>
)); ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.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 { useConfigItem } from "@/config";
import { DEFAULT_CONFIG } from "@/config/default";
export const allowedColors = [ export const allowedColors = [
"gray", "gray",
@@ -35,25 +36,24 @@ export type Colors = (typeof allowedColors)[number];
export const allowedAppearances = ["light", "dark", "system"] as const; export const allowedAppearances = ["light", "dark", "system"] as const;
export type Appearance = (typeof allowedAppearances)[number]; export type Appearance = (typeof allowedAppearances)[number];
export const THEME_DEFAULTS = {
appearance: "system" as Appearance,
color: "gray" as Colors,
} as const;
export interface ThemeContextType { export interface ThemeContextType {
appearance: "light" | "dark"; appearance: "light" | "dark";
rawAppearance: Appearance; rawAppearance: Appearance;
setAppearance: (appearance: Appearance) => void; setAppearance: (appearance: Appearance) => void;
color: Colors; color: Colors;
setColor: (color: Colors) => void; setColor: (color: Colors) => void;
viewMode: "grid" | "table";
setViewMode: (mode: "grid" | "table") => void;
} }
export const ThemeContext = createContext<ThemeContextType>({ export const ThemeContext = createContext<ThemeContextType>({
appearance: "light", appearance: "light",
rawAppearance: THEME_DEFAULTS.appearance, rawAppearance: DEFAULT_CONFIG.selectedDefaultAppearance as Appearance,
setAppearance: () => {}, setAppearance: () => {},
color: THEME_DEFAULTS.color, color: DEFAULT_CONFIG.selectThemeColor as Colors,
setColor: () => {}, 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"; return appearance as "light" | "dark";
}; };
import { useContext } from "react"; const useStoredState = <T>(
key: string,
export const useThemeManager = () => { defaultValue: T,
validator?: (value: any) => value is T
): [T, React.Dispatch<React.SetStateAction<T>>] => {
const enableLocalStorage = useConfigItem("enableLocalStorage"); const enableLocalStorage = useConfigItem("enableLocalStorage");
const defaultAppearance = useConfigItem("selectedDefaultAppearance");
const defaultColor = useConfigItem("selectThemeColor");
const [appearance, setAppearance] = useState<Appearance>(() => { const [state, setState] = useState<T>(() => {
if (enableLocalStorage) { if (enableLocalStorage) {
const storedAppearance = localStorage.getItem("appearance"); const storedValue = localStorage.getItem(key);
const cleanedAppearance = storedAppearance if (storedValue) {
? storedAppearance.replace(/^"|"$/g, "") const cleanedValue = storedValue.replace(/^"|"$/g, "");
: null; if (!validator || validator(cleanedValue)) {
if (allowedAppearances.includes(cleanedAppearance as Appearance)) { return cleanedValue as T;
return cleanedAppearance as Appearance; }
} }
} }
return (defaultAppearance as Appearance) || THEME_DEFAULTS.appearance; return defaultValue;
}); });
const [color, setColor] = useState<Colors>( useEffect(() => {
(defaultColor as Colors) || THEME_DEFAULTS.color 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(() => { useEffect(() => {
setColor((defaultColor as Colors) || THEME_DEFAULTS.color); setColor(defaultColor);
}, [defaultColor]); }, [defaultColor, setColor]);
const resolvedAppearance = useSystemTheme(appearance); const resolvedAppearance = useSystemTheme(appearance);
useEffect(() => {
if (enableLocalStorage) {
localStorage.setItem("appearance", appearance);
}
}, [appearance, enableLocalStorage]);
return { return {
appearance: resolvedAppearance, appearance: resolvedAppearance,
rawAppearance: appearance, rawAppearance: appearance,
setAppearance, setAppearance,
color, color,
setColor, setColor,
viewMode,
setViewMode,
}; };
}; };
export const useTheme = () => { 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 { createRoot } from "react-dom/client";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import "./index.css"; import "./index.css";
@@ -23,29 +23,10 @@ import { useConfigItem } from "@/config";
// 内部应用组件,在 ConfigProvider 内部使用配置 // 内部应用组件,在 ConfigProvider 内部使用配置
export const AppContent = () => { export const AppContent = () => {
const { appearance, color } = useTheme(); 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 [searchTerm, setSearchTerm] = useState("");
const enableVideoBackground = useConfigItem("enableVideoBackground"); const enableVideoBackground = useConfigItem("enableVideoBackground");
const videoBackgroundUrl = useConfigItem("videoBackgroundUrl"); const videoBackgroundUrl = useConfigItem("videoBackgroundUrl");
useEffect(() => {
if (enableLocalStorage) {
localStorage.setItem("nodeViewMode", viewMode);
}
}, [enableLocalStorage, viewMode]);
return ( return (
<> <>
{enableVideoBackground && videoBackgroundUrl && ( {enableVideoBackground && videoBackgroundUrl && (
@@ -63,19 +44,13 @@ export const AppContent = () => {
scaling="110%" scaling="110%"
style={{ backgroundColor: "transparent" }}> style={{ backgroundColor: "transparent" }}>
<div className="min-h-screen flex flex-col text-sm"> <div className="min-h-screen flex flex-col text-sm">
<Header <Header searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
viewMode={viewMode}
setViewMode={setViewMode}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route <Route
path="/" path="/"
element={ element={
<HomePage <HomePage
viewMode={viewMode}
searchTerm={searchTerm} searchTerm={searchTerm}
setSearchTerm={setSearchTerm} setSearchTerm={setSearchTerm}
/> />

View File

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