Browse Source

feat: 添加深色模式支持

- 集成 next-themes 提供主题切换功能
- 新增 ThemeSwitcher 组件支持浅色/深色/跟随系统
- 在 Dashboard 头部和设置页面导航添加主题切换器
- 添加多语言支持(中英日俄)主题相关翻译
- 配置 ThemeProvider 支持 class 切换和系统主题检测
- 修复 HTML 标签 hydration 警告

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
ding113 3 months ago
parent
commit
99bd3c4fbe

+ 6 - 1
messages/en/common.json

@@ -40,5 +40,10 @@
   "info": "Info",
   "noData": "No data",
   "emptyState": "No data to display",
-  "core": "Core"
+  "core": "Core",
+  "appearance": "Appearance",
+  "theme": "Theme",
+  "light": "Light",
+  "dark": "Dark",
+  "system": "System"
 }

+ 6 - 1
messages/ja/common.json

@@ -40,5 +40,10 @@
   "info": "情報",
   "noData": "データがありません",
   "emptyState": "表示するデータがありません",
-  "core": "コア"
+  "core": "コア",
+  "appearance": "外観",
+  "theme": "テーマ",
+  "light": "ライト",
+  "dark": "ダーク",
+  "system": "システム設定"
 }

+ 6 - 1
messages/ru/common.json

@@ -40,5 +40,10 @@
   "info": "Информация",
   "noData": "Нет данных",
   "emptyState": "Нечего отображать",
-  "core": "Основной"
+  "core": "Основной",
+  "appearance": "Внешний вид",
+  "theme": "Тема",
+  "light": "Светлая",
+  "dark": "Тёмная",
+  "system": "Системная"
 }

+ 6 - 1
messages/zh-CN/common.json

@@ -40,5 +40,10 @@
   "info": "信息",
   "noData": "暂无数据",
   "emptyState": "没有数据可显示",
-  "core": "核心"
+  "core": "核心",
+  "appearance": "外观",
+  "theme": "主题",
+  "light": "浅色",
+  "dark": "深色",
+  "system": "跟随系统"
 }

+ 6 - 1
messages/zh-TW/common.json

@@ -40,5 +40,10 @@
   "info": "資訊",
   "noData": "暫無資料",
   "emptyState": "沒有資料可顯示",
-  "core": "核心"
+  "core": "核心",
+  "appearance": "外觀",
+  "theme": "主題",
+  "light": "淺色",
+  "dark": "深色",
+  "system": "跟隨系統"
 }

+ 2 - 0
src/app/[locale]/dashboard/_components/dashboard-header.tsx

@@ -6,6 +6,7 @@ import { DashboardNav, type DashboardNavItem } from "./dashboard-nav";
 import { UserMenu } from "./user-menu";
 import { VersionUpdateNotifier } from "@/components/customs/version-update-notifier";
 import { LanguageSwitcher } from "@/components/ui/language-switcher";
+import { ThemeSwitcher } from "@/components/ui/theme-switcher";
 import { useTranslations } from "next-intl";
 
 interface DashboardHeaderProps {
@@ -33,6 +34,7 @@ export function DashboardHeader({ session }: DashboardHeaderProps) {
       <div className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between px-6">
         <DashboardNav items={items} />
         <div className="flex items-center gap-3">
+          <ThemeSwitcher />
           <LanguageSwitcher size="sm" />
           {session && <VersionUpdateNotifier />}
           {session ? (

+ 1 - 1
src/app/[locale]/layout.tsx

@@ -72,7 +72,7 @@ export default async function RootLayout({
   const messages = await getMessages();
 
   return (
-    <html lang={locale}>
+    <html lang={locale} suppressHydrationWarning>
       <body className="antialiased">
         <NextIntlClientProvider messages={messages}>
           <AppProviders>

+ 14 - 0
src/app/[locale]/settings/_components/settings-nav.tsx

@@ -4,6 +4,8 @@ import { Link, usePathname } from "@/i18n/routing";
 
 import { cn } from "@/lib/utils";
 import type { SettingsNavItem } from "../_lib/nav-items";
+import { ThemeSwitcher } from "@/components/ui/theme-switcher";
+import { useTranslations } from "next-intl";
 
 interface SettingsNavProps {
   items: SettingsNavItem[];
@@ -11,6 +13,7 @@ interface SettingsNavProps {
 
 export function SettingsNav({ items }: SettingsNavProps) {
   const pathname = usePathname();
+  const t = useTranslations("common");
 
   if (items.length === 0) {
     return null;
@@ -65,6 +68,17 @@ export function SettingsNav({ items }: SettingsNavProps) {
           );
         })}
       </ul>
+      <div className="mt-3 rounded-lg border border-dashed border-border/80 bg-muted/40 p-3">
+        <div className="flex items-center justify-between gap-3">
+          <div className="space-y-1">
+            <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+              {t("appearance")}
+            </p>
+            <p className="text-sm text-foreground/90">{t("theme")}</p>
+          </div>
+          <ThemeSwitcher />
+        </div>
+      </div>
     </nav>
   );
 }

+ 13 - 1
src/app/providers.tsx

@@ -2,6 +2,7 @@
 
 import { useState, type ReactNode } from "react";
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ThemeProvider } from "next-themes";
 
 interface AppProvidersProps {
   children: ReactNode;
@@ -10,5 +11,16 @@ interface AppProvidersProps {
 export function AppProviders({ children }: AppProvidersProps) {
   const [queryClient] = useState(() => new QueryClient());
 
-  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
+  return (
+    <ThemeProvider
+      attribute="class"
+      defaultTheme="system"
+      enableSystem
+      storageKey="claude-code-hub-theme"
+      enableColorScheme
+      disableTransitionOnChange
+    >
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    </ThemeProvider>
+  );
 }

+ 123 - 0
src/components/ui/theme-switcher.tsx

@@ -0,0 +1,123 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { Laptop, Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuLabel,
+  DropdownMenuRadioGroup,
+  DropdownMenuRadioItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+
+type ThemeValue = "light" | "dark" | "system";
+
+interface ThemeSwitcherProps {
+  className?: string;
+  size?: "sm" | "default";
+  showLabel?: boolean;
+}
+
+export function ThemeSwitcher({
+  className,
+  size = "sm",
+  showLabel = false,
+}: ThemeSwitcherProps) {
+  const { theme, resolvedTheme, setTheme } = useTheme();
+  const t = useTranslations("common");
+  const [mounted, setMounted] = useState(false);
+
+  useEffect(() => {
+    setMounted(true);
+  }, []);
+
+  const activeTheme = useMemo<ThemeValue>(() => {
+    if (!mounted) return "system";
+    if (theme === "system") {
+      return (resolvedTheme as ThemeValue) ?? "system";
+    }
+    return (theme as ThemeValue) ?? "system";
+  }, [mounted, resolvedTheme, theme]);
+
+  const options: { value: ThemeValue; icon: typeof Sun }[] = [
+    { value: "light", icon: Sun },
+    { value: "dark", icon: Moon },
+    { value: "system", icon: Laptop },
+  ];
+
+  const labelMap: Record<ThemeValue, string> = {
+    light: t("light"),
+    dark: t("dark"),
+    system: t("system"),
+  };
+
+  const triggerSize = size === "sm" ? "icon" : "default";
+
+  if (!mounted) {
+    return (
+      <Button
+        aria-label={t("theme")}
+        variant="ghost"
+        size={triggerSize}
+        className={cn(
+          "relative rounded-full border border-border/60 bg-card/60 text-muted-foreground",
+          triggerSize === "icon" && "size-9",
+          className
+        )}
+        disabled
+      >
+        <Sun className="size-4 animate-pulse opacity-60" />
+        {showLabel && <span className="ml-2 text-sm">{t("theme")}</span>}
+      </Button>
+    );
+  }
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button
+          aria-label={t("theme")}
+          variant="ghost"
+          size={triggerSize}
+          className={cn(
+            "relative rounded-full border border-border/60 bg-card/70 text-foreground shadow-xs transition-all duration-200 hover:border-border hover:bg-accent/60 hover:text-accent-foreground",
+            triggerSize === "icon" && "size-9",
+            showLabel && "min-w-[7.5rem] justify-start gap-2 px-3",
+            className
+          )}
+        >
+          <Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
+          <Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
+          {showLabel && (
+            <span className="text-sm font-medium leading-none">{labelMap[activeTheme]}</span>
+          )}
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end" className="min-w-[10rem]" sideOffset={8}>
+        <DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
+        <DropdownMenuRadioGroup
+          value={activeTheme}
+          onValueChange={(value) => setTheme(value as ThemeValue)}
+          className="pt-1"
+        >
+          {options.map(({ value, icon: Icon }) => (
+            <DropdownMenuRadioItem
+              key={value}
+              value={value}
+              className="flex items-center gap-2 capitalize"
+            >
+              <Icon className="size-4" />
+              <span>{labelMap[value]}</span>
+            </DropdownMenuRadioItem>
+          ))}
+        </DropdownMenuRadioGroup>
+      </DropdownMenuContent>
+    </DropdownMenu>
+  );
+}