diff --git a/src/components/setting/setting-verge-basic.tsx b/src/components/setting/setting-verge-basic.tsx index 34660f4e..34b7d7c5 100644 --- a/src/components/setting/setting-verge-basic.tsx +++ b/src/components/setting/setting-verge-basic.tsx @@ -19,7 +19,7 @@ import getSystem from "@/utils/get-system"; import { routers } from "@/pages/_routers"; import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { ContentCopyRounded } from "@mui/icons-material"; -import { languages } from "@/services/i18n"; +import { supportedLanguages } from "@/services/i18n"; import { showNotice } from "@/services/noticeService"; interface Props { @@ -28,7 +28,7 @@ interface Props { const OS = getSystem(); -const languageOptions = Object.entries(languages).map(([code, _]) => { +const languageOptions = supportedLanguages.map((code) => { const labels: { [key: string]: string } = { en: "English", ru: "Русский", @@ -39,8 +39,13 @@ const languageOptions = Object.entries(languages).map(([code, _]) => { ar: "العربية", ko: "한국어", tr: "Türkçe", + de: "Deutsch", + es: "Español", + jp: "日本語", + zhtw: "繁體中文", }; - return { code, label: labels[code] }; + const label = labels[code] || code; + return { code, label }; }); const SettingVergeBasic = ({ onError }: Props) => { diff --git a/src/hooks/use-i18n.ts b/src/hooks/use-i18n.ts new file mode 100644 index 00000000..f20baf9a --- /dev/null +++ b/src/hooks/use-i18n.ts @@ -0,0 +1,45 @@ +import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { changeLanguage, supportedLanguages } from "@/services/i18n"; +import { useVerge } from "./use-verge"; + +export const useI18n = () => { + const { i18n, t } = useTranslation(); + const { patchVerge } = useVerge(); + const [isLoading, setIsLoading] = useState(false); + + const switchLanguage = useCallback( + async (language: string) => { + if (!supportedLanguages.includes(language)) { + console.warn(`Unsupported language: ${language}`); + return; + } + + if (i18n.language === language) { + return; + } + + setIsLoading(true); + try { + await changeLanguage(language); + + if (patchVerge) { + await patchVerge({ language }); + } + } catch (error) { + console.error("Failed to change language:", error); + } finally { + setIsLoading(false); + } + }, + [i18n.language, patchVerge], + ); + + return { + currentLanguage: i18n.language, + supportedLanguages, + switchLanguage, + isLoading, + t, + }; +}; diff --git a/src/main.tsx b/src/main.tsx index d1c30806..7bc6ed8c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,7 +13,7 @@ import { ComposeContextProvider } from "foxact/compose-context-provider"; import { BrowserRouter } from "react-router-dom"; import { BaseErrorBoundary } from "./components/base"; import Layout from "./pages/_layout"; -import "./services/i18n"; +import { initializeLanguage } from "./services/i18n"; import { LoadingCacheProvider, ThemeModeProvider, @@ -39,29 +39,47 @@ document.addEventListener("keydown", (event) => { ["F", "G", "H", "J", "P", "Q", "R", "U"].includes( event.key.toUpperCase(), )); - disabledShortcuts && event.preventDefault(); + if (disabledShortcuts) { + event.preventDefault(); + } }); -const contexts = [ - , - , - , -]; +const initializeApp = async () => { + try { + await initializeLanguage("zh"); -const root = createRoot(container); -root.render( - - - - - - - - - - - , -); + const contexts = [ + , + , + , + ]; + + const root = createRoot(container); + root.render( + + + + + + + + + + + , + ); + } catch (error) { + console.error("[main.tsx] 应用初始化失败:", error); + const root = createRoot(container); + root.render( +
+ 应用初始化失败: {error instanceof Error ? error.message : String(error)} +
, + ); + } +}; + +initializeApp(); // 错误处理 window.addEventListener("error", (event) => { diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index ac939581..bf761d81 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,5 +1,4 @@ import dayjs from "dayjs"; -import i18next from "i18next"; import relativeTime from "dayjs/plugin/relativeTime"; import { SWRConfig, mutate } from "swr"; import { useEffect, useCallback, useState, useRef } from "react"; @@ -11,6 +10,7 @@ import { routers } from "./_routers"; import { getAxios } from "@/services/api"; import { forceRefreshClashConfig } from "@/services/cmds"; import { useVerge } from "@/hooks/use-verge"; +import { useI18n } from "@/hooks/use-i18n"; import LogoSvg from "@/assets/image/logo.svg?react"; import iconLight from "@/assets/image/icon_light.svg?react"; import iconDark from "@/assets/image/icon_dark.svg?react"; @@ -158,6 +158,7 @@ const Layout = () => { const [enableLog] = useEnableLog(); const [logLevel] = useLocalStorage("log:log-level", "info"); const { language, start_page } = verge ?? {}; + const { switchLanguage } = useI18n(); const navigate = useNavigate(); const location = useLocation(); const routersEles = useRoutes(routers); @@ -439,9 +440,9 @@ const Layout = () => { useEffect(() => { if (language) { dayjs.locale(language === "zh" ? "zh-cn" : language); - i18next.changeLanguage(language); + switchLanguage(language); } - }, [language]); + }, [language, switchLanguage]); useEffect(() => { if (start_page) { diff --git a/src/services/i18n.ts b/src/services/i18n.ts index d3947ad4..adc8345a 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -1,29 +1,59 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import en from "@/locales/en.json"; -import ru from "@/locales/ru.json"; -import zh from "@/locales/zh.json"; -import fa from "@/locales/fa.json"; -import tt from "@/locales/tt.json"; -import id from "@/locales/id.json"; -import ar from "@/locales/ar.json"; -import ko from "@/locales/ko.json"; -import tr from "@/locales/tr.json"; -export const languages = { en, ru, zh, fa, tt, id, ar, ko, tr }; +export const supportedLanguages = [ + "en", + "ru", + "zh", + "fa", + "tt", + "id", + "ar", + "ko", + "tr", + "de", + "es", + "jp", + "zhtw", +]; -const resources = Object.fromEntries( - Object.entries(languages).map(([key, value]) => [ - key, - { translation: value }, - ]), +export const languages: Record = supportedLanguages.reduce( + (acc, lang) => { + acc[lang] = {}; + return acc; + }, + {} as Record, ); +export const loadLanguage = async (language: string) => { + try { + const module = await import(`@/locales/${language}.json`); + return module.default; + } catch (error) { + console.warn(`Failed to load language ${language}, fallback to zh`); + const fallback = await import("@/locales/zh.json"); + return fallback.default; + } +}; + i18n.use(initReactI18next).init({ - resources, + resources: {}, lng: "zh", fallbackLng: "zh", interpolation: { escapeValue: false, }, }); + +export const changeLanguage = async (language: string) => { + if (!i18n.hasResourceBundle(language, "translation")) { + const resources = await loadLanguage(language); + i18n.addResourceBundle(language, "translation", resources); + } + + await i18n.changeLanguage(language); +}; + +export const initializeLanguage = async (initialLanguage: string = "zh") => { + await changeLanguage(initialLanguage); +};