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
)
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 (