| 1 | 'use client' |
| 2 | |
| 3 | import type { FlatNamespace } from 'i18next' |
| 4 | import type { UseTranslationOptions, UseTranslationResponse } from 'react-i18next' |
| 5 | import { getCookie, setCookie } from 'cookies-next' |
| 6 | import i18next from 'i18next' |
| 7 | import LanguageDetector from 'i18next-browser-languagedetector' |
| 8 | import resourcesToBackend from 'i18next-resources-to-backend' |
| 9 | import { useEffect, useMemo, useState } from 'react' |
| 10 | import { initReactI18next, useTranslation } from 'react-i18next' |
| 11 | |
| 12 | import { useGetClientLng } from '@/hooks/useSystem' |
| 13 | import { cookieName, getOptions, languages } from './settings' |
| 14 | |
| 15 | const runsOnServerSide = typeof window === 'undefined' |
| 16 | |
| 17 | function getClientPathLanguage() { |
| 18 | if (runsOnServerSide) |
| 19 | return undefined |
| 20 | |
| 21 | const pathLng = window.location.pathname.split('/')[1] |
| 22 | return pathLng && languages.includes(pathLng) ? pathLng : undefined |
| 23 | } |
| 24 | |
| 25 | const initialClientLng = getClientPathLanguage() |
| 26 | |
| 27 | // 全局标志:是否禁用语言自动切换(用于图片生成等场景) |
| 28 | let disableLanguageSwitch = false |
| 29 | |
| 30 | /** |
| 31 | * 临时禁用语言自动切换 |
| 32 | * 用于在离屏渲染 React 组件时防止语言被意外切换 |
| 33 | */ |
| 34 | export function setDisableLanguageSwitch(disabled: boolean): void { |
| 35 | disableLanguageSwitch = disabled |
| 36 | } |
| 37 | |
| 38 | // on client side the normal singleton is ok |
| 39 | i18next |
| 40 | .use(initReactI18next) |
| 41 | .use(LanguageDetector) |
| 42 | .use( |
| 43 | resourcesToBackend( |
| 44 | (language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`), |
| 45 | ), |
| 46 | ) |
| 47 | // .use(LocizeBackend) // locize backend could be used on client side, but prefer to keep it in sync with server side |
| 48 | .init({ |
| 49 | ...getOptions(initialClientLng), |
| 50 | lng: initialClientLng, |
| 51 | detection: { |
| 52 | order: ['path', 'htmlTag', 'cookie', 'navigator'], |
| 53 | }, |
| 54 | preload: runsOnServerSide ? languages : [], |
| 55 | }) |
| 56 | |
| 57 | export function useTransClient<Ns extends string | undefined = undefined>( |
| 58 | ns?: Ns | Ns[], |
| 59 | options?: UseTranslationOptions<FlatNamespace>, |
| 60 | ): UseTranslationResponse<string, FlatNamespace> { |
| 61 | const i18nextCookie = getCookie(cookieName) |
| 62 | const lng = useGetClientLng() |
| 63 | if (typeof lng !== 'string') |
| 64 | throw new Error('useT is only available inside /app/[lng]') |
| 65 | |
| 66 | const nsKey = Array.isArray(ns) ? ns.join('|') : ns ?? '' |
| 67 | const stableNs = useMemo(() => ns, [nsKey]) |
| 68 | const translationOptions: UseTranslationOptions<FlatNamespace> = useMemo(() => ({ |
| 69 | ...options, |
| 70 | lng, |
| 71 | }), [lng, options]) |
| 72 | |
| 73 | if (runsOnServerSide && i18next.resolvedLanguage !== lng) { |
| 74 | i18next.changeLanguage(lng) |
| 75 | } |
| 76 | else { |
| 77 | const [activeLng, setActiveLng] = useState(i18next.resolvedLanguage) |
| 78 | |
| 79 | // 监听 i18next 语言变化 |
| 80 | useEffect(() => { |
| 81 | if (activeLng === i18next.resolvedLanguage) |
| 82 | return |
| 83 | setActiveLng(i18next.resolvedLanguage) |
| 84 | }, [activeLng, i18next.resolvedLanguage]) |
| 85 | |
| 86 | // 强制同步 URL 参数中的语言到 i18next |
| 87 | useEffect(() => { |
| 88 | // 如果禁用了语言切换(如图片生成场景),跳过 |
| 89 | if (disableLanguageSwitch) |
| 90 | return |
| 91 | |
| 92 | if (!lng) |
| 93 | return |
| 94 | |
| 95 | // 始终使用 URL 参数中的语言,忽略 i18next 的自动检测 |
| 96 | if (i18next.resolvedLanguage !== lng) { |
| 97 | i18next.changeLanguage(lng).then(() => { |
| 98 | // 语言切换完成后,强制重新渲染 |
| 99 | setActiveLng(currentLng => currentLng === lng ? currentLng : lng) |
| 100 | }) |
| 101 | } |
| 102 | else { |
| 103 | // 即使语言相同,也确保状态同步 |
| 104 | setActiveLng(currentLng => currentLng === lng ? currentLng : lng) |
| 105 | } |
| 106 | }, [lng]) |
| 107 | |
| 108 | // 同步 cookie |
| 109 | useEffect(() => { |
| 110 | if (i18nextCookie === lng) |
| 111 | return |
| 112 | setCookie(cookieName, lng, { path: '/' }) |
| 113 | }, [lng, i18nextCookie]) |
| 114 | } |
| 115 | // 强制类型断言以匹配 react-i18next 的签名 |
| 116 | return useTranslation(stableNs as Ns | Ns[] | undefined, translationOptions) as UseTranslationResponse< |
| 117 | string, |
| 118 | FlatNamespace |
| 119 | > |
| 120 | } |
| 121 | |
| 122 | export default i18next |
| 123 | |
| 124 | // 静态方法,注意这个方法的国际化不会自动更新 |
| 125 | export function directTrans<Ns extends FlatNamespace>(ns: Ns, key: string): string { |
| 126 | // @ts-ignore |
| 127 | const t = (key: string) => i18next.t(key, { ns }) |
| 128 | // @ts-ignore |
| 129 | return t(key) |
| 130 | } |
| 131 |