| 1 | /** |
| 2 | * GeneralTab - 通用设置 Tab |
| 3 | */ |
| 4 | |
| 5 | 'use client' |
| 6 | |
| 7 | import { setCookie } from 'cookies-next' |
| 8 | import { Loader2 } from 'lucide-react' |
| 9 | import { useTheme } from 'next-themes' |
| 10 | import Image from 'next/image' |
| 11 | import { useRouter } from 'next/navigation' |
| 12 | import { useTransClient } from '@/app/i18n/client' |
| 13 | import { getAllLanguageOptions } from '@/app/i18n/languageConfig' |
| 14 | import { cookieName } from '@/app/i18n/settings' |
| 15 | import { |
| 16 | Select, |
| 17 | SelectContent, |
| 18 | SelectItem, |
| 19 | SelectTrigger, |
| 20 | SelectValue, |
| 21 | } from '@/components/ui/select' |
| 22 | import { useGetClientLng } from '@/hooks/useSystem' |
| 23 | import { cn } from '@/utils/className' |
| 24 | import { useState } from 'react' |
| 25 | |
| 26 | import darkColorImg from '../images/darkColor.png' |
| 27 | import followSystemImg from '../images/followSystem.png' |
| 28 | // 主题图片 |
| 29 | import lightColorImg from '../images/lightColor.png' |
| 30 | |
| 31 | // 使用统一的语言配置 |
| 32 | const languageOptions = getAllLanguageOptions() |
| 33 | |
| 34 | /** 主题选项配置 */ |
| 35 | const themeOptions: { value: string, labelKey: string, image: typeof lightColorImg }[] = [ |
| 36 | { value: 'light', labelKey: 'general.themeLight', image: lightColorImg }, |
| 37 | { value: 'dark', labelKey: 'general.themeDark', image: darkColorImg }, |
| 38 | { value: 'system', labelKey: 'general.themeSystem', image: followSystemImg }, |
| 39 | ] |
| 40 | |
| 41 | export function GeneralTab() { |
| 42 | const { t } = useTransClient('settings') |
| 43 | const router = useRouter() |
| 44 | const lng = useGetClientLng() |
| 45 | const [isChangingLanguage, setIsChangingLanguage] = useState(false) |
| 46 | |
| 47 | // 使用 next-themes 管理主题 |
| 48 | const { theme, setTheme } = useTheme() |
| 49 | |
| 50 | const handleLanguageChange = async (newLng: string) => { |
| 51 | if (isChangingLanguage || newLng === lng) |
| 52 | return |
| 53 | |
| 54 | setIsChangingLanguage(true) |
| 55 | |
| 56 | try { |
| 57 | // 关键修复:在路由跳转前同步设置 cookie,避免竞态条件 |
| 58 | setCookie(cookieName, newLng, { path: '/' }) |
| 59 | |
| 60 | const currentPath = location.pathname |
| 61 | const pathWithoutLang = currentPath.replace(`/${lng}`, '') || '/' |
| 62 | const newPath = `/${newLng}${pathWithoutLang}` |
| 63 | |
| 64 | await router.push(newPath) |
| 65 | router.refresh() |
| 66 | } |
| 67 | catch (error) { |
| 68 | console.error('Language change failed:', error) |
| 69 | setIsChangingLanguage(false) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | return ( |
| 74 | <div className="w-full space-y-8"> |
| 75 | {/* 外观主题 */} |
| 76 | <div> |
| 77 | <h4 className="text-sm font-medium text-foreground mb-1">{t('general.theme')}</h4> |
| 78 | <p className="text-sm text-muted-foreground mb-4">{t('general.themeDesc')}</p> |
| 79 | <div className="flex flex-wrap gap-3 md:gap-4"> |
| 80 | {themeOptions.map((option) => { |
| 81 | const isActive = theme === option.value |
| 82 | return ( |
| 83 | <button |
| 84 | key={option.value} |
| 85 | onClick={() => setTheme(option.value)} |
| 86 | className="flex flex-col items-center gap-2 group" |
| 87 | > |
| 88 | {/* 图片容器 */} |
| 89 | <div |
| 90 | className={cn( |
| 91 | 'relative w-16 h-11 md:w-20 md:h-14 rounded-lg overflow-hidden border-2 transition-all', |
| 92 | isActive |
| 93 | ? 'border-primary shadow-sm' |
| 94 | : 'border-border hover:border-muted-foreground', |
| 95 | )} |
| 96 | > |
| 97 | <Image |
| 98 | src={option.image} |
| 99 | alt={t(option.labelKey)} |
| 100 | fill |
| 101 | className="object-cover" |
| 102 | /> |
| 103 | </div> |
| 104 | {/* 标签 */} |
| 105 | <span |
| 106 | className={cn( |
| 107 | 'text-xs transition-colors', |
| 108 | isActive ? 'text-primary font-medium' : 'text-muted-foreground', |
| 109 | )} |
| 110 | > |
| 111 | {t(option.labelKey)} |
| 112 | </span> |
| 113 | </button> |
| 114 | ) |
| 115 | })} |
| 116 | </div> |
| 117 | </div> |
| 118 | |
| 119 | {/* 网站语言 */} |
| 120 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 121 | <div className="min-w-0 flex-1"> |
| 122 | <h4 className="text-sm font-medium text-foreground">{t('general.language')}</h4> |
| 123 | <p className="mt-0.5 text-sm text-muted-foreground">{t('general.languageDesc')}</p> |
| 124 | </div> |
| 125 | <Select value={lng} onValueChange={handleLanguageChange} disabled={isChangingLanguage}> |
| 126 | <SelectTrigger className="h-9 w-full sm:w-[140px]"> |
| 127 | {isChangingLanguage ? ( |
| 128 | <div className="flex items-center gap-2"> |
| 129 | <Loader2 className="h-4 w-4 animate-spin" /> |
| 130 | <span className="text-muted-foreground">{t('general.changingLanguage')}</span> |
| 131 | </div> |
| 132 | ) : ( |
| 133 | <SelectValue /> |
| 134 | )} |
| 135 | </SelectTrigger> |
| 136 | <SelectContent> |
| 137 | {languageOptions.map(option => ( |
| 138 | <SelectItem key={option.value} value={option.value}> |
| 139 | {option.label} |
| 140 | </SelectItem> |
| 141 | ))} |
| 142 | </SelectContent> |
| 143 | </Select> |
| 144 | </div> |
| 145 | |
| 146 | </div> |
| 147 | ) |
| 148 | } |
| 149 |