From 0433c2eabb14c4657eb537a61559980cf134c20e Mon Sep 17 00:00:00 2001 From: an9xyz Date: Wed, 27 May 2026 15:27:07 +0800 Subject: [PATCH 1/2] feat(i18n): add i18next foundation and migrate Login + MainLayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set up the i18n infrastructure so the admin UI can render in zh-CN or en-US, aligned with the backend i18n contract (Accept-Language header, i18n_lang cookie, en-US fallback). - Add i18next, react-i18next, i18next-browser-languagedetector - Configure detector chain: ?lang -> i18n_lang cookie -> navigator - Wire antd ConfigProvider locale to follow i18n.language (zhCN/enUS) - Inject Accept-Language into axios requests - Extract Login and MainLayout strings into nav/layout/login namespaces - Align glossary: "Space 管理" -> "空间管理" / "Spaces" --- package-lock.json | 96 +++++++++++++++++++++++++++++- package.json | 3 + src/api/index.ts | 2 + src/i18n/index.ts | 41 +++++++++++++ src/i18n/locales/en-US/common.json | 10 ++++ src/i18n/locales/en-US/layout.json | 11 ++++ src/i18n/locales/en-US/login.json | 13 ++++ src/i18n/locales/en-US/nav.json | 10 ++++ src/i18n/locales/zh-CN/common.json | 10 ++++ src/i18n/locales/zh-CN/layout.json | 11 ++++ src/i18n/locales/zh-CN/login.json | 13 ++++ src/i18n/locales/zh-CN/nav.json | 10 ++++ src/layouts/MainLayout.tsx | 73 +++++++++++------------ src/main.tsx | 23 ++++++- src/pages/Login/index.tsx | 24 ++++---- 15 files changed, 296 insertions(+), 54 deletions(-) create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en-US/common.json create mode 100644 src/i18n/locales/en-US/layout.json create mode 100644 src/i18n/locales/en-US/login.json create mode 100644 src/i18n/locales/en-US/nav.json create mode 100644 src/i18n/locales/zh-CN/common.json create mode 100644 src/i18n/locales/zh-CN/layout.json create mode 100644 src/i18n/locales/zh-CN/login.json create mode 100644 src/i18n/locales/zh-CN/nav.json diff --git a/package-lock.json b/package-lock.json index 2c69882..10a27f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,11 @@ "antd": "^5.24.6", "axios": "^1.9.0", "dayjs": "^1.11.13", + "i18next": "^26.3.0", + "i18next-browser-languagedetector": "^8.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.8", "react-router-dom": "^6.30.0", "zustand": "^5.0.3" }, @@ -2686,6 +2689,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2712,6 +2724,43 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "26.3.0", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-26.3.0.tgz", + "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3844,6 +3893,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", @@ -4305,7 +4381,7 @@ "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4365,6 +4441,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", @@ -5483,6 +5568,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 67b288d..1e9894e 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,11 @@ "antd": "^5.24.6", "axios": "^1.9.0", "dayjs": "^1.11.13", + "i18next": "^26.3.0", + "i18next-browser-languagedetector": "^8.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.8", "react-router-dom": "^6.30.0", "zustand": "^5.0.3" }, diff --git a/src/api/index.ts b/src/api/index.ts index 12181f4..876fac7 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,5 @@ import axios, { AxiosError } from 'axios' +import i18n from '../i18n' import { useAuthStore } from '../store/auth' export class ApiError extends Error { @@ -22,6 +23,7 @@ api.interceptors.request.use((config) => { if (token) { config.headers.token = token } + config.headers['Accept-Language'] = i18n.language return config }) diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..9a6fe2c --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,41 @@ +import i18n from 'i18next' +import LanguageDetector from 'i18next-browser-languagedetector' +import { initReactI18next } from 'react-i18next' +import commonEN from './locales/en-US/common.json' +import commonZH from './locales/zh-CN/common.json' +import navEN from './locales/en-US/nav.json' +import navZH from './locales/zh-CN/nav.json' +import layoutEN from './locales/en-US/layout.json' +import layoutZH from './locales/zh-CN/layout.json' +import loginEN from './locales/en-US/login.json' +import loginZH from './locales/zh-CN/login.json' + +export const SUPPORTED_LANGUAGES = ['en-US', 'zh-CN'] as const +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number] + +export const LANG_COOKIE = 'i18n_lang' +export const FALLBACK_LANGUAGE: SupportedLanguage = 'en-US' + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + 'en-US': { common: commonEN, nav: navEN, layout: layoutEN, login: loginEN }, + 'zh-CN': { common: commonZH, nav: navZH, layout: layoutZH, login: loginZH }, + }, + fallbackLng: FALLBACK_LANGUAGE, + supportedLngs: SUPPORTED_LANGUAGES, + defaultNS: 'common', + ns: ['common', 'nav', 'layout', 'login'], + interpolation: { escapeValue: false }, + detection: { + order: ['querystring', 'cookie', 'navigator'], + lookupQuerystring: 'lang', + lookupCookie: LANG_COOKIE, + caches: ['cookie'], + cookieMinutes: 60 * 24 * 365, + }, + }) + +export default i18n diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json new file mode 100644 index 0000000..0e67382 --- /dev/null +++ b/src/i18n/locales/en-US/common.json @@ -0,0 +1,10 @@ +{ + "app": { + "name": "OCTO Admin" + }, + "language": { + "label": "Language", + "en-US": "English", + "zh-CN": "简体中文" + } +} diff --git a/src/i18n/locales/en-US/layout.json b/src/i18n/locales/en-US/layout.json new file mode 100644 index 0000000..f3b72c0 --- /dev/null +++ b/src/i18n/locales/en-US/layout.json @@ -0,0 +1,11 @@ +{ + "breadcrumb.admin": "Admin", + "header.notifications": "Notifications", + "header.help": "Help", + "header.logout": "Logout", + "theme.label": "Theme", + "theme.tooltip": "Theme: {{name}}", + "theme.light": "Light", + "theme.dark": "Dark", + "theme.auto": "Auto" +} diff --git a/src/i18n/locales/en-US/login.json b/src/i18n/locales/en-US/login.json new file mode 100644 index 0000000..05d8bb4 --- /dev/null +++ b/src/i18n/locales/en-US/login.json @@ -0,0 +1,13 @@ +{ + "subtitle": "Admin Console", + "username.placeholder": "Username", + "username.required": "Please enter your username", + "password.placeholder": "Password", + "password.required": "Please enter your password", + "submit": "Sign in", + "success": "Signed in", + "failure": "Sign-in failed", + "forgotPassword": "Forgot password?", + "contactAdmin": "Contact admin", + "footer": "© {{year}} Octo · Admin Console" +} diff --git a/src/i18n/locales/en-US/nav.json b/src/i18n/locales/en-US/nav.json new file mode 100644 index 0000000..8e716c2 --- /dev/null +++ b/src/i18n/locales/en-US/nav.json @@ -0,0 +1,10 @@ +{ + "dashboard": "Dashboard", + "users": "Users", + "groups": "Groups", + "spaces": "Spaces", + "appBots": "App Bots", + "systemSetting": "System Settings", + "backup": "Backups", + "download": "Downloads" +} diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json new file mode 100644 index 0000000..4580da3 --- /dev/null +++ b/src/i18n/locales/zh-CN/common.json @@ -0,0 +1,10 @@ +{ + "app": { + "name": "OCTO 管理后台" + }, + "language": { + "label": "语言", + "en-US": "English", + "zh-CN": "简体中文" + } +} diff --git a/src/i18n/locales/zh-CN/layout.json b/src/i18n/locales/zh-CN/layout.json new file mode 100644 index 0000000..75e9d88 --- /dev/null +++ b/src/i18n/locales/zh-CN/layout.json @@ -0,0 +1,11 @@ +{ + "breadcrumb.admin": "管理后台", + "header.notifications": "通知", + "header.help": "帮助", + "header.logout": "退出登录", + "theme.label": "主题", + "theme.tooltip": "主题:{{name}}", + "theme.light": "浅色", + "theme.dark": "深色", + "theme.auto": "跟随系统" +} diff --git a/src/i18n/locales/zh-CN/login.json b/src/i18n/locales/zh-CN/login.json new file mode 100644 index 0000000..87c5baf --- /dev/null +++ b/src/i18n/locales/zh-CN/login.json @@ -0,0 +1,13 @@ +{ + "subtitle": "管理后台", + "username.placeholder": "用户名", + "username.required": "请输入用户名", + "password.placeholder": "密码", + "password.required": "请输入密码", + "submit": "登录", + "success": "登录成功", + "failure": "登录失败", + "forgotPassword": "忘记密码?", + "contactAdmin": "联系管理员", + "footer": "© {{year}} Octo · 管理后台" +} diff --git a/src/i18n/locales/zh-CN/nav.json b/src/i18n/locales/zh-CN/nav.json new file mode 100644 index 0000000..187c1d0 --- /dev/null +++ b/src/i18n/locales/zh-CN/nav.json @@ -0,0 +1,10 @@ +{ + "dashboard": "仪表盘", + "users": "用户管理", + "groups": "群组管理", + "spaces": "空间管理", + "appBots": "应用 Bot", + "systemSetting": "系统配置", + "backup": "备份管理", + "download": "下载配置" +} diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 3122de4..76f4c8a 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { Layout, Menu, Avatar, Dropdown, Tooltip, Breadcrumb } from 'antd' import type { MenuProps } from 'antd' +import { useTranslation } from 'react-i18next' import { UserOutlined, TeamOutlined, @@ -27,37 +28,12 @@ const { Header, Sider, Content } = Layout type MenuItem = { key: string; icon: React.ReactNode; label: string } -const baseMenuItems: MenuItem[] = [ - { key: '/dashboard', icon: , label: '仪表盘' }, - { key: '/users', icon: , label: '用户管理' }, - { key: '/groups', icon: , label: '群组管理' }, - { key: '/spaces', icon: , label: 'Space 管理' }, -] - -const appBotMenuItem: MenuItem = { - key: '/app-bots', - icon: , - label: '应用 Bot', -} - -const tailMenuItems: MenuItem[] = [ - { key: '/system-setting', icon: , label: '系统配置' }, - { key: '/backup', icon: , label: '备份管理' }, - { key: '/download', icon: , label: '下载配置' }, -] - const themeIcon: Record = { light: , dark: , auto: , } -const themeLabel: Record = { - light: '浅色', - dark: '深色', - auto: '跟随系统', -} - const MainLayout: React.FC = () => { const [collapsed, setCollapsed] = useState(false) const navigate = useNavigate() @@ -66,19 +42,38 @@ const MainLayout: React.FC = () => { const { theme, effective, setTheme } = useTheme() const appBotsAvailable = useFeatureStore((s) => s.appBotsAvailable) const probeAppBots = useFeatureStore((s) => s.probeAppBots) + const { t } = useTranslation(['nav', 'layout']) useEffect(() => { void probeAppBots() }, [probeAppBots]) - const menuItems = useMemo( - () => - appBotsAvailable === true - ? [...baseMenuItems, appBotMenuItem, ...tailMenuItems] - : [...baseMenuItems, ...tailMenuItems], - [appBotsAvailable], + const themeLabel = useMemo>( + () => ({ + light: t('layout:theme.light'), + dark: t('layout:theme.dark'), + auto: t('layout:theme.auto'), + }), + [t], ) + const menuItems = useMemo(() => { + const base: MenuItem[] = [ + { key: '/dashboard', icon: , label: t('nav:dashboard') }, + { key: '/users', icon: , label: t('nav:users') }, + { key: '/groups', icon: , label: t('nav:groups') }, + { key: '/spaces', icon: , label: t('nav:spaces') }, + ] + const tail: MenuItem[] = [ + { key: '/system-setting', icon: , label: t('nav:systemSetting') }, + { key: '/backup', icon: , label: t('nav:backup') }, + { key: '/download', icon: , label: t('nav:download') }, + ] + return appBotsAvailable === true + ? [...base, { key: '/app-bots', icon: , label: t('nav:appBots') }, ...tail] + : [...base, ...tail] + }, [appBotsAvailable, t]) + const handleMenuClick = ({ key }: { key: string }) => { navigate(key) } @@ -177,7 +172,7 @@ const MainLayout: React.FC = () => { }} href="/dashboard" > - 管理后台 + {t('layout:breadcrumb.admin')} ), }, @@ -191,8 +186,8 @@ const MainLayout: React.FC = () => { trigger={['click']} placement="bottomRight" > - - @@ -206,13 +201,13 @@ const MainLayout: React.FC = () => { margin: '0 6px', }} /> - - - - @@ -222,7 +217,7 @@ const MainLayout: React.FC = () => { { key: 'logout', icon: , - label: '退出登录', + label: t('layout:header.logout'), onClick: handleLogout, }, ], diff --git a/src/main.tsx b/src/main.tsx index 35d672c..c3cdc28 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,15 +2,32 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { ConfigProvider } from 'antd' import zhCN from 'antd/locale/zh_CN' +import enUS from 'antd/locale/en_US' +import type { Locale } from 'antd/es/locale' +import { useTranslation } from 'react-i18next' import App from './App' +import './i18n' import './styles/theme.css' import './index.css' import './styles/admin.css' -ReactDOM.createRoot(document.getElementById('root')!).render( - - +const ANTD_LOCALES: Record = { + 'en-US': enUS, + 'zh-CN': zhCN, +} + +function LocalizedApp() { + const { i18n } = useTranslation() + const locale = ANTD_LOCALES[i18n.language] ?? enUS + return ( + + ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + ) diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 4f6ce34..d579d82 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { Form, Input, Button, Card, message } from 'antd' import { UserOutlined, LockOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' import { login } from '../../api/auth' import { useAuthStore } from '../../store/auth' @@ -14,16 +15,17 @@ export default function Login() { const [loading, setLoading] = useState(false) const navigate = useNavigate() const authLogin = useAuthStore((state) => state.loginSuper) + const { t } = useTranslation('login') const onFinish = async (values: LoginForm) => { setLoading(true) try { const data = await login(values) authLogin(data.token, data.name, data.role) - message.success('登录成功') + message.success(t('success')) navigate('/dashboard') } catch (error) { - message.error((error as Error).message || '登录失败') + message.error((error as Error).message || t('failure')) } finally { setLoading(false) } @@ -62,24 +64,24 @@ export default function Login() { O

Octo

-

管理后台

+

{t('subtitle')}

- } placeholder="用户名" /> + } placeholder={t('username.placeholder')} /> - } placeholder="密码" /> + } placeholder={t('password.placeholder')} />
@@ -97,11 +99,11 @@ export default function Login() { }} > - 忘记密码? + {t('forgotPassword')} · - 联系管理员 + {t('contactAdmin')} @@ -113,7 +115,7 @@ export default function Login() { textAlign: 'center', }} > - © {new Date().getFullYear()} Octo · 管理后台 + {t('footer', { year: new Date().getFullYear() })}

) From 8ba802bfde7407a583ddf9090463af9e54cec1e4 Mon Sep 17 00:00:00 2001 From: an9xyz Date: Wed, 27 May 2026 15:39:15 +0800 Subject: [PATCH 2/2] fix(i18n): use resolvedLanguage and harden language cookie Address P2 review feedback on PR #56: - Use i18n.resolvedLanguage (guaranteed in supportedLngs) instead of i18n.language for the outbound Accept-Language header and antd locale lookup, preventing non-allowlisted tags from leaking through. - Replace deprecated cookieMinutes with cookieOptions; set sameSite=lax, secure=true on HTTPS, explicit path=/. --- src/api/index.ts | 4 ++-- src/i18n/index.ts | 7 ++++++- src/main.tsx | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index 876fac7..868eb71 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,5 +1,5 @@ import axios, { AxiosError } from 'axios' -import i18n from '../i18n' +import i18n, { FALLBACK_LANGUAGE } from '../i18n' import { useAuthStore } from '../store/auth' export class ApiError extends Error { @@ -23,7 +23,7 @@ api.interceptors.request.use((config) => { if (token) { config.headers.token = token } - config.headers['Accept-Language'] = i18n.language + config.headers['Accept-Language'] = i18n.resolvedLanguage ?? FALLBACK_LANGUAGE return config }) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 9a6fe2c..a5431fc 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -34,7 +34,12 @@ i18n lookupQuerystring: 'lang', lookupCookie: LANG_COOKIE, caches: ['cookie'], - cookieMinutes: 60 * 24 * 365, + cookieOptions: { + maxAge: 60 * 60 * 24 * 365, + sameSite: 'lax', + secure: window.location.protocol === 'https:', + path: '/', + }, }, }) diff --git a/src/main.tsx b/src/main.tsx index c3cdc28..712b773 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -18,7 +18,8 @@ const ANTD_LOCALES: Record = { function LocalizedApp() { const { i18n } = useTranslation() - const locale = ANTD_LOCALES[i18n.language] ?? enUS + const lang = i18n.resolvedLanguage ?? i18n.language + const locale = ANTD_LOCALES[lang] ?? enUS return (