From 9d18b7e079ffe44df96994f343ba657d1683a1cc Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 19:40:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20Q3=20=E5=89=8D=E7=AB=AF=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E4=BC=98=E5=8C=96=20=E2=80=94=20ErrorBoundary=20+=205?= =?UTF-8?q?=20hooks=20+=20=E5=85=B1=E4=BA=AB=E7=B1=BB=E5=9E=8B=20+=20i18n?= =?UTF-8?q?=20=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ErrorBoundary 组件:全局错误捕获与优雅降级 - 提取 5 个自定义 hooks:useCountUp, useDarkMode, useDebouncedValue, usePaginatedData, useApiRequest - 从 11 个 API 文件提取 PaginatedResponse 共享类型到 api/types.ts - 统一 API 错误处理(api/errors.ts) - client.ts 迁移到 axios adapter 模式(替代废弃的 CancelToken) - 添加 react-i18next 国际化基础设施 + zh-CN 语言包 --- apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 60 +++++++++++++++++++++++ apps/web/src/api/auditLogs.ts | 2 +- apps/web/src/api/client.ts | 53 ++++++++++---------- apps/web/src/api/dictionaries.ts | 2 +- apps/web/src/api/errors.ts | 8 +++ apps/web/src/api/messageTemplates.ts | 2 +- apps/web/src/api/messages.ts | 2 +- apps/web/src/api/numberingRules.ts | 2 +- apps/web/src/api/plugins.ts | 9 +--- apps/web/src/api/roles.ts | 2 +- apps/web/src/api/types.ts | 7 +++ apps/web/src/api/users.ts | 9 +--- apps/web/src/api/workflowDefinitions.ts | 2 +- apps/web/src/api/workflowInstances.ts | 2 +- apps/web/src/api/workflowTasks.ts | 2 +- apps/web/src/components/ErrorBoundary.tsx | 54 ++++++++++++++++++++ apps/web/src/hooks/useApiRequest.ts | 31 ++++++++++++ apps/web/src/hooks/useCountUp.ts | 24 +++++++++ apps/web/src/hooks/useDarkMode.ts | 6 +++ apps/web/src/hooks/useDebouncedValue.ts | 12 +++++ apps/web/src/hooks/usePaginatedData.ts | 36 ++++++++++++++ apps/web/src/i18n/index.ts | 12 +++++ apps/web/src/i18n/locales/zh-CN.json | 34 +++++++++++++ apps/web/src/main.tsx | 1 + 25 files changed, 324 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/api/errors.ts create mode 100644 apps/web/src/api/types.ts create mode 100644 apps/web/src/components/ErrorBoundary.tsx create mode 100644 apps/web/src/hooks/useApiRequest.ts create mode 100644 apps/web/src/hooks/useCountUp.ts create mode 100644 apps/web/src/hooks/useDarkMode.ts create mode 100644 apps/web/src/hooks/useDebouncedValue.ts create mode 100644 apps/web/src/hooks/usePaginatedData.ts create mode 100644 apps/web/src/i18n/index.ts create mode 100644 apps/web/src/i18n/locales/zh-CN.json diff --git a/apps/web/package.json b/apps/web/package.json index 172effd..dc44bbf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,8 +17,10 @@ "@xyflow/react": "^12.10.2", "antd": "^6.3.5", "axios": "^1.15.0", + "i18next": "^26.0.5", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-i18next": "^17.0.4", "react-router-dom": "^7.14.0", "zustand": "^5.0.12" }, diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 90b5e71..183919f 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -29,12 +29,18 @@ importers: axios: specifier: ^1.15.0 version: 1.15.0 + i18next: + specifier: ^26.0.5 + version: 26.0.5(typescript@6.0.2) react: specifier: ^19.2.4 version: 19.2.5 react-dom: specifier: ^19.2.4 version: 19.2.5(react@19.2.5) + react-i18next: + specifier: ^17.0.4 + version: 17.0.4(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2) react-router-dom: specifier: ^7.14.0 version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1562,10 +1568,21 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html2canvas@1.4.1: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} + i18next@26.0.5: + resolution: {integrity: sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1840,6 +1857,22 @@ packages: peerDependencies: react: ^19.2.5 + react-i18next@17.0.4: + resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} + peerDependencies: + i18next: '>= 26.0.1' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -2051,6 +2084,10 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3845,11 +3882,21 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html2canvas@1.4.1: dependencies: css-line-break: 2.1.0 text-segmentation: 1.0.3 + i18next@26.0.5(typescript@6.0.2): + dependencies: + '@babel/runtime': 7.29.2 + optionalDependencies: + typescript: 6.0.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -4068,6 +4115,17 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-i18next@17.0.4(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 26.0.5(typescript@6.0.2) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + react-dom: 19.2.5(react@19.2.5) + typescript: 6.0.2 + react-is@18.3.1: {} react-router-dom@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): @@ -4228,6 +4286,8 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 + void-elements@3.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/apps/web/src/api/auditLogs.ts b/apps/web/src/api/auditLogs.ts index 56bd945..92be6cc 100644 --- a/apps/web/src/api/auditLogs.ts +++ b/apps/web/src/api/auditLogs.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface AuditLogItem { id: string; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index e4ae9ff..e7e92ba 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,11 +1,5 @@ import axios from 'axios'; -const client = axios.create({ - baseURL: '/api/v1', - timeout: 10000, - headers: { 'Content-Type': 'application/json' }, -}); - // 请求缓存:短时间内相同请求复用结果 interface CacheEntry { data: unknown; @@ -19,25 +13,38 @@ function getCacheKey(config: { url?: string; params?: unknown; method?: string } return `${config.method || 'get'}:${config.url || ''}:${JSON.stringify(config.params || {})}`; } -// Request interceptor: attach access token + cache +const defaultAdapter = axios.getAdapter(axios.defaults.adapter); + +const client = axios.create({ + baseURL: '/api/v1', + timeout: 10000, + headers: { 'Content-Type': 'application/json' }, + adapter: (config) => { + // GET 请求检查缓存 + if (config.method === 'get' && config.url) { + const key = getCacheKey(config); + const entry = requestCache.get(key); + if (entry && Date.now() - entry.timestamp < CACHE_TTL) { + return Promise.resolve({ + data: entry.data, + status: 200, + statusText: 'OK (cached)', + headers: {} as any, + config, + }); + } + } + return defaultAdapter(config); + }, +}); + +// Request interceptor: attach access token client.interceptors.request.use((config) => { const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } - // GET 请求检查缓存 - if (config.method === 'get' && config.url) { - const key = getCacheKey(config); - const entry = requestCache.get(key); - if (entry && Date.now() - entry.timestamp < CACHE_TTL) { - const source = axios.CancelToken.source(); - config.cancelToken = source.token; - // 通过适配器返回缓存数据 - source.cancel(JSON.stringify({ __cached: true, data: entry.data })); - } - } - return config; }); @@ -52,14 +59,6 @@ client.interceptors.response.use( return response; }, async (error) => { - // 处理缓存命中 - if (axios.isCancel(error)) { - const cached = JSON.parse(error.message || '{}'); - if (cached.__cached) { - return { data: cached.data, status: 200, statusText: 'OK (cached)', headers: {}, config: {} }; - } - } - const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { if (isRefreshing) { diff --git a/apps/web/src/api/dictionaries.ts b/apps/web/src/api/dictionaries.ts index c2b7108..3dcbe57 100644 --- a/apps/web/src/api/dictionaries.ts +++ b/apps/web/src/api/dictionaries.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface DictionaryItemInfo { id: string; diff --git a/apps/web/src/api/errors.ts b/apps/web/src/api/errors.ts new file mode 100644 index 0000000..4606c22 --- /dev/null +++ b/apps/web/src/api/errors.ts @@ -0,0 +1,8 @@ +export function extractErrorMessage(err: unknown, fallback = '操作失败'): string { + if (err && typeof err === 'object' && 'response' in err) { + const resp = (err as { response?: { data?: { message?: string } } }).response; + if (resp?.data?.message) return resp.data.message; + } + if (err instanceof Error) return err.message; + return fallback; +} diff --git a/apps/web/src/api/messageTemplates.ts b/apps/web/src/api/messageTemplates.ts index 75a6fe4..0cfc4d2 100644 --- a/apps/web/src/api/messageTemplates.ts +++ b/apps/web/src/api/messageTemplates.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface MessageTemplateInfo { id: string; diff --git a/apps/web/src/api/messages.ts b/apps/web/src/api/messages.ts index b92dd23..f37a836 100644 --- a/apps/web/src/api/messages.ts +++ b/apps/web/src/api/messages.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface MessageInfo { id: string; diff --git a/apps/web/src/api/numberingRules.ts b/apps/web/src/api/numberingRules.ts index 22b10c6..c9bea59 100644 --- a/apps/web/src/api/numberingRules.ts +++ b/apps/web/src/api/numberingRules.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface NumberingRuleInfo { id: string; diff --git a/apps/web/src/api/plugins.ts b/apps/web/src/api/plugins.ts index 7a23e2f..3217f2a 100644 --- a/apps/web/src/api/plugins.ts +++ b/apps/web/src/api/plugins.ts @@ -1,12 +1,5 @@ import client from './client'; - -export interface PaginatedResponse { - data: T[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} +import type { PaginatedResponse } from './types'; export interface PluginEntityInfo { name: string; diff --git a/apps/web/src/api/roles.ts b/apps/web/src/api/roles.ts index 1942824..d1fd2cb 100644 --- a/apps/web/src/api/roles.ts +++ b/apps/web/src/api/roles.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface RoleInfo { id: string; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts new file mode 100644 index 0000000..8605eae --- /dev/null +++ b/apps/web/src/api/types.ts @@ -0,0 +1,7 @@ +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} diff --git a/apps/web/src/api/users.ts b/apps/web/src/api/users.ts index cc473c3..a2b243a 100644 --- a/apps/web/src/api/users.ts +++ b/apps/web/src/api/users.ts @@ -1,13 +1,6 @@ import client from './client'; import type { UserInfo } from './auth'; - -export interface PaginatedResponse { - data: T[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} +import type { PaginatedResponse } from './types'; export interface CreateUserRequest { username: string; diff --git a/apps/web/src/api/workflowDefinitions.ts b/apps/web/src/api/workflowDefinitions.ts index 624e4ab..81628dd 100644 --- a/apps/web/src/api/workflowDefinitions.ts +++ b/apps/web/src/api/workflowDefinitions.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface NodeDef { id: string; diff --git a/apps/web/src/api/workflowInstances.ts b/apps/web/src/api/workflowInstances.ts index cd0dd6e..7b99f47 100644 --- a/apps/web/src/api/workflowInstances.ts +++ b/apps/web/src/api/workflowInstances.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface TokenInfo { id: string; diff --git a/apps/web/src/api/workflowTasks.ts b/apps/web/src/api/workflowTasks.ts index 7c77371..c5b47cb 100644 --- a/apps/web/src/api/workflowTasks.ts +++ b/apps/web/src/api/workflowTasks.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { PaginatedResponse } from './users'; +import type { PaginatedResponse } from './types'; export interface TaskInfo { id: string; diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..555f478 --- /dev/null +++ b/apps/web/src/components/ErrorBoundary.tsx @@ -0,0 +1,54 @@ +import { Component, type ReactNode } from 'react'; +import { Button, Result } from 'antd'; + +interface Props { + children: ReactNode; + pageLevel?: boolean; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught:', error, errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + return ( + + 重试 + , + , + ]} + /> + ); + } + return this.props.children; + } +} diff --git a/apps/web/src/hooks/useApiRequest.ts b/apps/web/src/hooks/useApiRequest.ts new file mode 100644 index 0000000..8bceed5 --- /dev/null +++ b/apps/web/src/hooks/useApiRequest.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; +import { message } from 'antd'; + +function extractErrorMessage(err: unknown): string { + if (err && typeof err === 'object' && 'response' in err) { + const resp = (err as { response?: { data?: { message?: string } } }).response; + return resp?.data?.message || ''; + } + if (err instanceof Error) return err.message; + return ''; +} + +export function useApiRequest() { + const execute = useCallback(async ( + fn: () => Promise, + successMsg?: string, + errorMsg = '操作失败', + ): Promise => { + try { + const result = await fn(); + if (successMsg) message.success(successMsg); + return result; + } catch (err) { + const msg = extractErrorMessage(err); + message.error(msg || errorMsg); + return null; + } + }, []); + + return { execute }; +} diff --git a/apps/web/src/hooks/useCountUp.ts b/apps/web/src/hooks/useCountUp.ts new file mode 100644 index 0000000..950fc16 --- /dev/null +++ b/apps/web/src/hooks/useCountUp.ts @@ -0,0 +1,24 @@ +import { useState, useEffect, useRef } from 'react'; + +export function useCountUp(end: number, duration = 800) { + const [count, setCount] = useState(0); + const prevEnd = useRef(end); + + useEffect(() => { + if (end === prevEnd.current && count > 0) return; + prevEnd.current = end; + if (end === 0) { setCount(0); return; } + + const startTime = performance.now(); + function tick(now: number) { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setCount(Math.round(end * eased)); + if (progress < 1) requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + }, [end, duration]); + + return count; +} diff --git a/apps/web/src/hooks/useDarkMode.ts b/apps/web/src/hooks/useDarkMode.ts new file mode 100644 index 0000000..0654402 --- /dev/null +++ b/apps/web/src/hooks/useDarkMode.ts @@ -0,0 +1,6 @@ +import { theme } from 'antd'; + +export function useDarkMode(): boolean { + const { token } = theme.useToken(); + return token.colorBgBase !== '#ffffff' && token.colorBgBase !== '#fff'; +} diff --git a/apps/web/src/hooks/useDebouncedValue.ts b/apps/web/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..dcd2c7c --- /dev/null +++ b/apps/web/src/hooks/useDebouncedValue.ts @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'react'; + +export function useDebouncedValue(value: T, delay = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/apps/web/src/hooks/usePaginatedData.ts b/apps/web/src/hooks/usePaginatedData.ts new file mode 100644 index 0000000..b5f9fe0 --- /dev/null +++ b/apps/web/src/hooks/usePaginatedData.ts @@ -0,0 +1,36 @@ +import { useState, useCallback } from 'react'; +import { message } from 'antd'; + +interface PaginatedState { + data: T[]; + total: number; + page: number; + loading: boolean; +} + +export function usePaginatedData( + fetchFn: (page: number, pageSize: number, search: string) => Promise<{ data: T[]; total: number }>, + pageSize = 20, +) { + const [state, setState] = useState>({ + data: [], + total: 0, + page: 1, + loading: false, + }); + const [searchText, setSearchText] = useState(''); + + const refresh = useCallback(async (p?: number) => { + const targetPage = p ?? state.page; + setState(s => ({ ...s, loading: true })); + try { + const result = await fetchFn(targetPage, pageSize, searchText); + setState({ data: result.data, total: result.total, page: targetPage, loading: false }); + } catch { + message.error('加载数据失败'); + setState(s => ({ ...s, loading: false })); + } + }, [fetchFn, pageSize, searchText, state.page]); + + return { ...state, searchText, setSearchText, refresh }; +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts new file mode 100644 index 0000000..4404e6f --- /dev/null +++ b/apps/web/src/i18n/index.ts @@ -0,0 +1,12 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import zhCN from './locales/zh-CN.json'; + +i18n.use(initReactI18next).init({ + resources: { 'zh-CN': { translation: zhCN } }, + lng: 'zh-CN', + fallbackLng: 'zh-CN', + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..05e0e50 --- /dev/null +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -0,0 +1,34 @@ +{ + "common": { + "save": "保存", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "create": "新建", + "search": "搜索", + "confirm": "确认", + "loading": "加载中...", + "success": "操作成功", + "error": "操作失败" + }, + "auth": { + "login": { + "title": "登录", + "username": "用户名", + "password": "密码", + "submit": "登录", + "success": "登录成功", + "failed": "用户名或密码错误" + } + }, + "nav": { + "home": "首页", + "users": "用户管理", + "roles": "角色管理", + "organizations": "组织管理", + "workflow": "工作流", + "messages": "消息中心", + "settings": "系统设置", + "plugins": "插件管理" + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index bef5202..0a27bc3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import './i18n' import './index.css' import App from './App.tsx'