feat(i18n): add Chinese (中文) language support

- Add lightweight i18n system using React Context + localStorage
- Zero external dependencies, integrates with existing Ant Design locale
- Create locales/en.json and locales/zh.json with full translations
- LanguageProvider wraps ConfigProvider for seamless antd locale switching
- Add LanguageSwitcher component to navbar
- Covers: nav, models, API keys, users, logs, settings, common actions

Closes #28526
This commit is contained in:
2099383411 2026-06-16 10:49:44 +08:00
parent 5c41aabab0
commit e2c60c6c85
7 changed files with 615 additions and 0 deletions

36
src/app/layout.tsx Normal file
View file

@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import AntdGlobalProvider from "@/contexts/AntdGlobalProvider";
import { AuthProvider } from "@/contexts/AuthContext";
import { LanguageProvider } from "@/contexts/LanguageContext";
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "LiteLLM Dashboard",
description: "LiteLLM Proxy Admin UI",
icons: { icon: "/get_favicon" },
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<ReactQueryProvider>
<LanguageProvider>
<AntdGlobalProvider>
<AuthProvider>{children}</AuthProvider>
</AntdGlobalProvider>
</LanguageProvider>
</ReactQueryProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import React from "react";
import { Button } from "antd";
import { useLanguage } from "@/contexts/LanguageContext";
/**
* Inline language toggle used in navbar.
* Shows the opposite language (中文 ↔ English).
*/
export function LanguageSwitcher() {
const { locale, setLocale, t } = useLanguage();
const toggle = () => setLocale(locale === "en" ? "zh" : "en");
return (
<Button
type="text"
size="small"
onClick={toggle}
className="text-gray-600 hover:text-gray-900"
title={t("language.label")}
>
{t("language.switch_to")}
</Button>
);
}
/**
* Wraps children in LanguageProvider.
* This should be placed above ConfigProvider in the component tree.
*/
export function LanguageProviderWrapper({ children }: { children: React.ReactNode }) {
return <LanguageProvider>{children}</LanguageProvider>;
}
export { LanguageProvider };

167
src/components/navbar.tsx Normal file
View file

@ -0,0 +1,167 @@
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { useWorker } from "@/hooks/useWorker";
import { getProxyBaseUrl } from "@/components/networking";
import { useTheme } from "@/contexts/ThemeContext";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { Tag } from "antd";
import Link from "next/link";
import React from "react";
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass";
import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell";
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
import { LanguageSwitcher } from "./LanguageSwitcher";
import WorkerDropdown from "./Navbar/WorkerDropdown/WorkerDropdown";
interface NavbarProps {
accessToken: string | null;
isPublicPage: boolean;
sidebarCollapsed?: boolean;
onToggleSidebar?: () => void;
}
const Navbar: React.FC<NavbarProps> = ({
accessToken,
isPublicPage = false,
sidebarCollapsed = false,
onToggleSidebar,
}) => {
const baseUrl = getProxyBaseUrl();
const proxySettings = useProxySettings(accessToken);
const { logoUrl } = useTheme();
const { data: healthData } = useHealthReadinessDetails(accessToken);
const version = healthData?.litellm_version;
const disableBouncingIcon = useDisableBouncingIcon();
const hideCommunityLinks = useDisableShowPrompts();
const { isControlPlane, selectedWorker } = useWorker();
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const imageUrl = logoUrl || `${baseUrl}/get_image`;
const handleLogout = () => {
clearTokenCookies();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
window.location.href = proxySettings.PROXY_LOGOUT_URL || "";
};
const handleWorkerSwitch = (workerId: string) => {
clearTokenCookies();
clearStoredReturnUrl();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
window.location.href = `/ui/login?worker=${encodeURIComponent(workerId)}`;
};
return (
<nav className="sticky top-0 z-10 border-b border-gray-200 bg-white">
<div className="w-full">
<div className="flex h-14 items-center px-4">
<div className="flex flex-shrink-0 items-center">
{onToggleSidebar && (
<button
onClick={onToggleSidebar}
className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<span className="text-lg">{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}</span>
</button>
)}
<div className="flex items-center gap-2">
<Link href={baseUrl ? baseUrl : "/"} className="flex items-center">
<div className="relative">
<div className="flex h-10 max-w-48 items-center justify-center overflow-hidden">
<img
src={imageUrl}
alt="LiteLLM Brand"
className="h-auto max-h-full w-auto max-w-full object-contain"
/>
</div>
</div>
</Link>
{version && (
<div className="relative">
{!disableBouncingIcon && (
<span
className="absolute -left-2 -top-1 animate-bounce text-lg"
style={{ animationDuration: "2s" }}
title="Thanks for using LiteLLM!"
>
🌑
</span>
)}
<Tag className="relative z-10 cursor-pointer text-xs font-medium">
<a
href="https://docs.litellm.ai/release_notes"
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0"
>
v{version}
</a>
</Tag>
</div>
)}
</div>
</div>
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-4">
{showWorkerSwitch && (
<div className="flex shrink-0 items-center">
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
</div>
)}
<nav
aria-label="Product documentation"
className={`flex min-w-0 items-center gap-2 ${showWorkerSwitch ? "border-l border-gray-200 pl-4" : ""}`}
>
<a
href="https://docs.litellm.ai/docs/"
target="_blank"
rel="noopener noreferrer"
className={NAV_PRODUCT_LINK_CLASS}
>
Docs
{/* Layout parity with Blog chevron — intentional single-level link */}
<DownOutlined className="pointer-events-none text-[10px] opacity-0" aria-hidden />
</a>
<BlogDropdown />
</nav>
{/* Language switcher */}
<div className="flex shrink-0 items-center">
<LanguageSwitcher />
</div>
{!hideCommunityLinks && (
<div className="flex shrink-0 items-center border-l border-gray-200 pl-4">
<CommunityEngagementButtons />
</div>
)}
{!isPublicPage && (
<div className="flex shrink-0 items-center border-l border-gray-200 pl-4">
<div className="flex items-center gap-0.5 rounded-lg bg-gray-50 px-1 py-0 transition-colors hover:bg-gray-100">
<NotificationsBell />
<span className="mx-0.5 h-6 w-px shrink-0 bg-gray-200" aria-hidden />
<UserDropdown onLogout={handleLogout} />
</div>
</div>
)}
</div>
{/* Dark mode toggle: keep disabled until the dashboard supports dark styles end-to-end. */}
</div>
</div>
</nav>
);
};
export default Navbar;

View file

@ -0,0 +1,61 @@
"use client";
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
import { ConfigProvider } from "antd";
import enUS from "antd/locale/en_US";
import zhCN from "antd/locale/zh_CN";
import { Locale, DEFAULT_LOCALE, LOCALE_STORAGE_KEY, t as translate } from "@/i18n";
interface LanguageContextValue {
locale: Locale;
setLocale: (l: Locale) => void;
t: (key: string) => string;
}
const LanguageContext = createContext<LanguageContextValue>({
locale: DEFAULT_LOCALE,
setLocale: () => {},
t: (key) => key,
});
export function useLanguage() {
return useContext(LanguageContext);
}
const antdLocales: Record<Locale, any> = {
en: enUS,
zh: zhCN,
};
export function LanguageProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(DEFAULT_LOCALE);
// Hydrate from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === "zh" || stored === "en") {
setLocaleState(stored);
}
} catch {
// localStorage not available
}
}, []);
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
try {
localStorage.setItem(LOCALE_STORAGE_KEY, l);
} catch {
// ignore
}
}, []);
const t = useCallback((key: string) => translate(key, locale), [locale]);
return (
<LanguageContext.Provider value={{ locale, setLocale, t }}>
<ConfigProvider locale={antdLocales[locale]}>{children}</ConfigProvider>
</LanguageContext.Provider>
);
}

54
src/i18n/index.ts Normal file
View file

@ -0,0 +1,54 @@
/**
* LiteLLM i18n - Lightweight translation system
*
* Supports English (default) and Chinese (中文).
* Uses React Context for state + localStorage for persistence.
* Zero external dependencies.
*/
import en from "./locales/en.json";
import zh from "./locales/zh.json";
export type Locale = "en" | "zh";
export type TranslationMap = Record<string, Record<string, string>>;
const translations: Record<Locale, TranslationMap> = { en, zh };
/**
* Resolve a dotted key like "nav.dashboard" against the current locale.
*/
export function t(key: string, locale: Locale = "en"): string {
const map = translations[locale] || translations.en;
const parts = key.split(".");
let current: any = map;
for (const part of parts) {
if (current == null) break;
current = current[part];
}
if (typeof current === "string") return current;
// Fallback to English
let fallback: any = translations.en;
for (const part of parts) {
if (fallback == null) break;
fallback = fallback[part];
}
if (typeof fallback === "string") return fallback;
// Ultimate fallback: return the key itself
return key;
}
/** All supported locales (used by the switcher) */
export const SUPPORTED_LOCALES: { value: Locale; label: string }[] = [
{ value: "en", label: "English" },
{ value: "zh", label: "中文" },
];
/** LocalStorage key */
export const LOCALE_STORAGE_KEY = "litellm_locale";
/** Default locale */
export const DEFAULT_LOCALE: Locale = "en";
export { translations };

130
src/i18n/locales/en.json Normal file
View file

@ -0,0 +1,130 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search",
"refresh": "Refresh",
"loading": "Loading...",
"no_data": "No data",
"confirm": "Confirm",
"back": "Back",
"next": "Next",
"submit": "Submit",
"reset": "Reset",
"copy": "Copy",
"copied": "Copied!",
"close": "Close",
"export": "Export",
"import": "Import",
"more": "More",
"actions": "Actions",
"status": "Status",
"enabled": "Enabled",
"disabled": "Disabled",
"active": "Active",
"inactive": "Inactive",
"yes": "Yes",
"no": "No",
"on": "On",
"off": "Off",
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Info"
},
"nav": {
"dashboard": "Dashboard",
"models": "Models & Endpoints",
"api_keys": "API Keys",
"users": "Users",
"teams": "Teams",
"logs": "Logs",
"usage": "Usage",
"spend": "Cost Tracking",
"settings": "Settings",
"admin": "Admin Panel",
"playground": "Playground",
"budgets": "Budgets",
"caching": "Caching",
"guardrails": "Guardrails",
"hooks": "Webhooks",
"model_hub": "Model Hub",
"router": "Router Settings",
"organizations": "Organizations",
"credentials": "Credentials",
"agents": "Agents",
"prompts": "Prompts",
"mcp": "MCP Servers",
"policies": "Policies",
"skills": "Skills",
"workflows": "Workflows",
"memory": "Memory",
"vector_stores": "Vector Stores",
"search_tools": "Search Tools",
"tag_management": "Tag Management",
"access_groups": "Access Groups",
"ui_theme": "UI Theme"
},
"models": {
"title": "Models & Endpoints",
"add_model": "Add Model",
"model_name": "Model Name",
"provider": "Provider",
"api_base": "API Base URL",
"api_key": "API Key",
"rpm": "RPM",
"tpm": "TPM",
"max_retries": "Max Retries",
"timeout": "Timeout (s)",
"custom_llm_provider": "Provider",
"litellm_params": "LiteLLM Parameters",
"model_info": "Model Info",
"deployment_name": "Deployment Name",
"no_models": "No models configured",
"delete_confirm": "Are you sure you want to delete this model?"
},
"keys": {
"title": "API Keys",
"create_key": "Create Key",
"key_name": "Key Name",
"key": "Key",
"models": "Models",
"max_budget": "Max Budget",
"budget_duration": "Budget Duration",
"expires_at": "Expires At",
"never": "Never",
"no_keys": "No API keys created",
"delete_confirm": "Are you sure you want to delete this key?"
},
"users": {
"title": "Users",
"create_user": "Create User",
"user_email": "Email",
"user_role": "Role",
"user_id": "User ID",
"no_users": "No users found"
},
"logs": {
"title": "Logs",
"request_id": "Request ID",
"model": "Model",
"status_code": "Status",
"tokens": "Tokens",
"cost": "Cost",
"duration": "Duration",
"timestamp": "Time"
},
"settings": {
"title": "Settings",
"general": "General",
"proxy": "Proxy Settings",
"alerting": "Alerting"
},
"language": {
"switch_to": "中文",
"label": "Language"
}
}

130
src/i18n/locales/zh.json Normal file
View file

@ -0,0 +1,130 @@
{
"common": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"create": "创建",
"search": "搜索",
"refresh": "刷新",
"loading": "加载中...",
"no_data": "暂无数据",
"confirm": "确认",
"back": "返回",
"next": "下一步",
"submit": "提交",
"reset": "重置",
"copy": "复制",
"copied": "已复制!",
"close": "关闭",
"export": "导出",
"import": "导入",
"more": "更多",
"actions": "操作",
"status": "状态",
"enabled": "已启用",
"disabled": "已禁用",
"active": "活跃",
"inactive": "不活跃",
"yes": "是",
"no": "否",
"on": "开",
"off": "关",
"success": "成功",
"error": "错误",
"warning": "警告",
"info": "提示"
},
"nav": {
"dashboard": "仪表盘",
"models": "模型与端点",
"api_keys": "API 密钥",
"users": "用户管理",
"teams": "团队管理",
"logs": "日志",
"usage": "用量统计",
"spend": "费用追踪",
"settings": "设置",
"admin": "管理面板",
"playground": "调试沙盒",
"budgets": "预算管理",
"caching": "缓存设置",
"guardrails": "安全护栏",
"hooks": "Webhooks",
"model_hub": "模型中心",
"router": "路由设置",
"organizations": "组织管理",
"credentials": "凭证管理",
"agents": "智能代理",
"prompts": "提示词",
"mcp": "MCP 服务",
"policies": "策略管理",
"skills": "技能管理",
"workflows": "工作流",
"memory": "记忆管理",
"vector_stores": "向量存储",
"search_tools": "搜索工具",
"tag_management": "标签管理",
"access_groups": "访问组",
"ui_theme": "界面主题"
},
"models": {
"title": "模型与端点",
"add_model": "添加模型",
"model_name": "模型名称",
"provider": "提供商",
"api_base": "API 地址",
"api_key": "API 密钥",
"rpm": "每分钟请求数",
"tpm": "每分钟 Token 数",
"max_retries": "最大重试次数",
"timeout": "超时 (秒)",
"custom_llm_provider": "提供商",
"litellm_params": "LiteLLM 参数",
"model_info": "模型信息",
"deployment_name": "部署名称",
"no_models": "未配置模型",
"delete_confirm": "确定要删除此模型吗?"
},
"keys": {
"title": "API 密钥",
"create_key": "创建密钥",
"key_name": "密钥名称",
"key": "密钥",
"models": "模型",
"max_budget": "最大预算",
"budget_duration": "预算周期",
"expires_at": "过期时间",
"never": "永不过期",
"no_keys": "暂无 API 密钥",
"delete_confirm": "确定要删除此密钥吗?"
},
"users": {
"title": "用户管理",
"create_user": "创建用户",
"user_email": "邮箱",
"user_role": "角色",
"user_id": "用户 ID",
"no_users": "暂无用户"
},
"logs": {
"title": "日志",
"request_id": "请求 ID",
"model": "模型",
"status_code": "状态码",
"tokens": "Token 数",
"cost": "费用",
"duration": "耗时",
"timestamp": "时间"
},
"settings": {
"title": "设置",
"general": "常规设置",
"proxy": "代理设置",
"alerting": "告警设置"
},
"language": {
"switch_to": "English",
"label": "语言"
}
}