'use client'; import { useEffect, useState } from 'react'; import { Sun, Moon } from 'lucide-react'; import { cn } from '@/lib/utils'; type Theme = 'light' | 'dark'; const STORAGE_KEY = 'novalon-theme'; /** * 读取当前应生效的主题: * 1. 优先用用户显式选择(localStorage,与 layout.tsx FOUC 脚本同一 key) * 2. 回退到 FOUC 脚本已同步写入 的值 * 3. 默认浅色 * 这样即便用户刷新,状态也与首帧 FOUC 脚本完全一致,无闪烁。 */ function resolveTheme(): Theme { if (typeof window === 'undefined') return 'light'; const stored = window.localStorage.getItem(STORAGE_KEY); if (stored === 'light' || stored === 'dark') return stored; const attr = document.documentElement.getAttribute('data-theme'); if (attr === 'light' || attr === 'dark') return attr as Theme; return 'light'; } function applyTheme(theme: Theme) { document.documentElement.setAttribute('data-theme', theme); try { window.localStorage.setItem(STORAGE_KEY, theme); } catch { /* 隐私模式等写入失败时忽略:仅本次会话生效 */ } } export function ThemeToggle({ className }: { className?: string }) { // 初始为 null,挂载后再解析真实主题,避免 SSR/CSR 水合不一致 const [theme, setTheme] = useState(null); const [mounted, setMounted] = useState(false); // 必须挂载后读取:FOUC 脚本已在 同步写入 data-theme, // 此处从 DOM/localStorage 还原真实主题。SSR 阶段 window 不可用, // 故无法用 useState 惰性初始化(否则首帧与客户端水合不一致)。 useEffect(() => { // 主题同步必须从挂载后 DOM 读取,此 setState 无法避免 // eslint-disable-next-line react-hooks/set-state-in-effect setTheme(resolveTheme()); setMounted(true); }, []); const isDark = theme === 'dark'; const toggle = () => { const next: Theme = isDark ? 'light' : 'dark'; setTheme(next); applyTheme(next); }; return ( ); } export default ThemeToggle;