Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Nacos provides four major functions.
* **Service and MetaData Management**

Nacos provides an easy-to-use service dashboard to help you manage your services metadata, configuration, kubernetes DNS, service health and metrics statistics.


The web console is available in the following languages: `中文`, `English`, `Русский`(only new UI)


## Quick Start
Expand Down
4 changes: 2 additions & 2 deletions console-ui-next/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
| UI 组件库 | Shadcn/ui(基于 Radix UI) |
| 状态管理 | Zustand v5(stores: auth, server, namespace, app, config, history, service) |
| 路由 | React Router v7(HashRouter, 懒加载, AuthGuard/AdminGuard/GuestGuard) |
| 国际化 | react-i18next(zh-CN / en-US) |
| 国际化 | react-i18next(zh-CN / en-US / ru-RU) |
| HTTP 客户端 | Axios(请求/响应拦截器, Token 处理) |
| 代码编辑器 | @monaco-editor/react |

Expand Down Expand Up @@ -310,7 +310,7 @@ console-ui-next/
│ │ ├── layout/ # 布局组件(sidebar.tsx, header.tsx)
│ │ └── ui/ # Shadcn/ui 组件(21+ 个)
│ ├── layouts/ # 应用布局(AppLayout.tsx)
│ ├── locales/ # 国际化 JSON 文件(zh-CN.json, en-US.json)
│ ├── locales/ # 国际化 JSON 文件(zh-CN.json, en-US.json, ru-RU.json)
│ ├── pages/ # 页面组件
│ │ ├── configurationManagement/ # 配置列表
│ │ ├── newconfig/ # 新建配置
Expand Down
62 changes: 42 additions & 20 deletions console-ui-next/src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useLocation, useNavigate } from 'react-router-dom';
import {
Sun,
Moon,
Languages,
LogOut,
KeyRound,
Layers,
Expand All @@ -21,6 +20,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
Expand All @@ -37,10 +38,11 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { ChangePasswordDialog } from '@/components/layout/change-password-dialog';
import { SUPPORTED_LANGUAGES, getLanguageMeta, isSupportedLanguage } from '@/locales/languages';

function getBaseUrl(language: string) {
return language.toLowerCase() === 'en-us' ? 'https://nacos.io/en/' : 'https://nacos.io/';
}
// Not translated on purpose: users who switched to a language they cannot read
// must still recognize the language selector.
const LANGUAGE_SELECTOR_LABEL = 'Lang.';

interface NavLink {
key: string;
Expand All @@ -66,7 +68,7 @@ const NAV_LINKS: NavLink[] = [
];

export function Header() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { theme, setTheme, language, setLanguage, sidebarCollapsed, toggleSidebar } = useAppStore();
Expand All @@ -75,14 +77,17 @@ export function Header() {
const { authEnabled } = useServerStore();
const [changePasswordOpen, setChangePasswordOpen] = useState(false);

const baseUrl = getBaseUrl(language);
const currentLanguage = getLanguageMeta(language);
const baseUrl = currentLanguage.docsBase;
const sidebarToggleLabel = sidebarCollapsed ? t('common.expand') : t('common.collapse');

const toggleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');
const toggleLanguage = () => {
const newLang = language === 'zh-CN' ? 'en-US' : 'zh-CN';
setLanguage(newLang);
window.location.reload();
const handleLanguageChange = (value: string) => {
if (!isSupportedLanguage(value) || value === language) {
return;
}
setLanguage(value);
i18n.changeLanguage(value);
};

const handleNamespaceChange = (value: string) => {
Expand Down Expand Up @@ -178,15 +183,32 @@ export function Header() {

<Separator orientation="vertical" className="mx-1 h-5" />

{/* Language switch */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" onClick={toggleLanguage}>
<Languages size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('header.languageSwitch')}</TooltipContent>
</Tooltip>
{/* Language selector */}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 gap-1 px-2 text-xs text-muted-foreground"
aria-label={t('header.language')}
>
{LANGUAGE_SELECTOR_LABEL} {currentLanguage.shortLabel}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{t('header.language')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuRadioGroup value={language} onValueChange={handleLanguageChange}>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, { label }]) => (
<DropdownMenuRadioItem key={code} value={code} className="text-xs">
{label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>

{/* Theme toggle */}
<Tooltip>
Expand All @@ -195,7 +217,7 @@ export function Header() {
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</Button>
</TooltipTrigger>
<TooltipContent>{theme === 'light' ? 'Dark mode' : 'Light mode'}</TooltipContent>
<TooltipContent>{theme === 'light' ? t('header.darkMode') : t('header.lightMode')}</TooltipContent>
</Tooltip>

{/* User menu */}
Expand Down
2 changes: 1 addition & 1 deletion console-ui-next/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ export function Sidebar() {
{/* Core Section Label */}
{!sidebarCollapsed && (
<div className="px-2 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('menu.configManagement').split('').length > 0 ? '核心功能' : 'Core'}
{t('menu.coreFeatures')}
</div>
)}

Expand Down
13 changes: 6 additions & 7 deletions console-ui-next/src/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { detectBrowserLanguage, isSupportedLanguage, type Language } from '@/locales/languages';

const LANGUAGE_KEY = 'docsite_language';
const THEME_KEY = 'nacos_theme';
const SIDEBAR_KEY = 'nacos_sidebar_collapsed';
const TOKEN_KEY = 'token';
const LOGIN_PAGE_ENABLED_KEY = 'login_page_enabled';

// Language
export function getLanguage(): 'zh-CN' | 'en-US' {
export function getLanguage(): Language {
const lang = localStorage.getItem(LANGUAGE_KEY);
if (lang === 'zh-CN' || lang === 'en-US') {
if (isSupportedLanguage(lang)) {
return lang;
}
// Detect browser language
const browserLang = navigator.language || (navigator as { userLanguage?: string }).userLanguage;
if (browserLang?.startsWith('zh')) {
return 'zh-CN';
}
return 'en-US';
return detectBrowserLanguage(browserLang);
}

export function setLanguage(language: 'zh-CN' | 'en-US'): void {
export function setLanguage(language: Language): void {
localStorage.setItem(LANGUAGE_KEY, language);
}

Expand Down
77 changes: 77 additions & 0 deletions console-ui-next/src/locales/__tests__/languages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright 1999-2026 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
DEFAULT_LANGUAGE,
EN_US,
FALLBACK_LANGUAGE,
LANGUAGE_CODES,
RU_RU,
SUPPORTED_LANGUAGES,
ZH_CN,
detectBrowserLanguage,
getLanguageMeta,
isSupportedLanguage,
} from '../languages';

function readLocale(code: string) {
return JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../${code}.json`), 'utf8'),
) as Record<string, unknown>;
}

function flattenKeys(record: Record<string, unknown>, prefix = ''): string[] {
return Object.entries(record).flatMap(([key, value]) =>
value && typeof value === 'object'
? flattenKeys(value as Record<string, unknown>, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
}

describe('console languages', () => {
// Inputs are raw navigator.language values, so they stay plain strings
it('detects the browser language by its primary subtag', () => {
expect(detectBrowserLanguage('zh')).toBe(ZH_CN);
expect(detectBrowserLanguage('zh-TW')).toBe(ZH_CN);
expect(detectBrowserLanguage('ru')).toBe(RU_RU);
expect(detectBrowserLanguage('ru-RU')).toBe(RU_RU);
expect(detectBrowserLanguage('en-GB')).toBe(EN_US);
expect(detectBrowserLanguage('fr-FR')).toBe(FALLBACK_LANGUAGE);
expect(detectBrowserLanguage(undefined)).toBe(FALLBACK_LANGUAGE);
});

it('accepts only registered language codes', () => {
expect(isSupportedLanguage(RU_RU)).toBe(true);
expect(isSupportedLanguage('ru')).toBe(false);
expect(isSupportedLanguage(null)).toBe(false);
expect(isSupportedLanguage('toString')).toBe(false);
});

it('resolves language meta with the default language for unknown codes', () => {
expect(getLanguageMeta(RU_RU)).toBe(SUPPORTED_LANGUAGES[RU_RU]);
expect(getLanguageMeta('unknown')).toBe(SUPPORTED_LANGUAGES[DEFAULT_LANGUAGE]);
});

it('keeps the same translation keys in every locale file', () => {
const enKeys = flattenKeys(readLocale(EN_US)).sort();
for (const code of LANGUAGE_CODES) {
expect(flattenKeys(readLocale(code)).sort()).toEqual(enKeys);
}
});
});
13 changes: 8 additions & 5 deletions console-ui-next/src/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"community": "COMMUNITY",
"enterprise": "ENTERPRISE EDITION",
"mcp": "MCP Marketplace",
"languageSwitch": "中",
"language": "Language",
"darkMode": "Dark mode",
"lightMode": "Light mode",
"logout": "Logout",
"changePassword": "Change Password"
},
Expand Down Expand Up @@ -45,6 +47,7 @@
"skillRegistry": "Skill Registry",
"promptRegistry": "Prompt Registry",
"agentSpecRegistry": "AgentSpec Registry",
"coreFeatures": "Core",
"platformManagement": "Platform",
"namespace": "Namespace",
"clusterManagement": "Cluster",
Expand Down Expand Up @@ -440,6 +443,10 @@
"useExistService": "Use Existing Service",
"directConnect": "Direct Connect",
"httpToMcp": "HTTP to MCP",
"restToMcp": "Conversion",
"editServerDesc": "Edit the MCP Server configuration",
"newVersionDesc": "Create a new version based on the current one",
"createServerDesc": "Configure a new MCP Server draft",
"mcpServerEndpoint": "MCP Server Endpoint URL",
"address": "Address",
"port": "Port",
Expand Down Expand Up @@ -891,7 +898,6 @@
"noLabels": "No labels",
"noVersions": "No version history",
"totalVersions": "{{count}} versions in total",
"currentVersion": "Current",
"searchPlaceholder": "Search Prompt Key",
"totalPrompts": "{{total}} Prompts",
"deleteConfirm": "Delete Prompt \"{{name}}\"?",
Expand Down Expand Up @@ -985,8 +991,6 @@
"editDraft": "Edit Draft",
"createPromptDesc": "Create a new Prompt draft with template and variables.",
"createDraftFromDesc": "Create a new draft based on version {{version}}",
"newVersion": "New Version",
"newVersionPlaceholder": "e.g. 0.0.2 or v3 (leave empty for auto)",
"status": "Status"
},
"namespace": {
Expand Down Expand Up @@ -1436,7 +1440,6 @@
"masterSwitchDesc": "Disabling hides all versions from Agents",
"skillDisabledWarning": "Skill is disabled, version changes won't take effect",
"versionActions": "Version Actions",
"labelsUpdateSuccess": "Labels updated",
"labelManagement": "Version Labels",
"labelBindDesc": "Select labels to bind to version {{version}}, or create new ones.",
"searchOrCreateLabel": "Search or create label...",
Expand Down
13 changes: 9 additions & 4 deletions console-ui-next/src/locales/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ import { getLanguage } from '@/lib/storage';

import zhCN from './zh-CN.json';
import enUS from './en-US.json';
import ruRU from './ru-RU.json';
import { EN_US, FALLBACK_LANGUAGE, RU_RU, ZH_CN, type Language } from './languages';

const resources = {
'zh-CN': {
const resources: Record<Language, { translation: Record<string, unknown> }> = {
[ZH_CN]: {
translation: zhCN,
},
'en-US': {
[EN_US]: {
translation: enUS,
},
[RU_RU]: {
translation: ruRU,
},
};

i18n.use(initReactI18next).init({
resources,
lng: getLanguage(),
fallbackLng: 'en-US',
fallbackLng: FALLBACK_LANGUAGE,
interpolation: {
escapeValue: false,
},
Expand Down
53 changes: 53 additions & 0 deletions console-ui-next/src/locales/languages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 1999-2026 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Registry of console UI languages. Keep free of i18n imports so storage can use it.
export const ZH_CN = 'zh-CN';
export const EN_US = 'en-US';
export const RU_RU = 'ru-RU';

// Key order defines the order in the language selector.
export const SUPPORTED_LANGUAGES = {
[ZH_CN]: { label: '中文', shortLabel: '中', docsBase: 'https://nacos.io/' },
[EN_US]: { label: 'English', shortLabel: 'EN', docsBase: 'https://nacos.io/en/' },
[RU_RU]: { label: 'Русский', shortLabel: 'RU', docsBase: 'https://nacos.io/en/' },
} as const;

export type Language = keyof typeof SUPPORTED_LANGUAGES;

export const LANGUAGE_CODES = Object.keys(SUPPORTED_LANGUAGES) as Language[];

// Console language used until the stored or browser language is resolved.
export const DEFAULT_LANGUAGE: Language = ZH_CN;

// Language for browsers whose locale is not supported, and the i18n fallback for missing keys.
export const FALLBACK_LANGUAGE: Language = EN_US;

export function isSupportedLanguage(value: unknown): value is Language {
// hasOwn instead of `in`, so inherited keys like "toString" are rejected
return typeof value === 'string' && Object.hasOwn(SUPPORTED_LANGUAGES, value);
}

export function getLanguageMeta(language: string) {
return SUPPORTED_LANGUAGES[isSupportedLanguage(language) ? language : DEFAULT_LANGUAGE];
}

// Match a browser locale such as "ru", "ru-RU" or "zh-TW" by its primary subtag
export function detectBrowserLanguage(browserLang?: string | null): Language {
const prefix = browserLang?.toLowerCase().split('-')[0];
const match = LANGUAGE_CODES.find((code) => code.toLowerCase().startsWith(`${prefix}-`));
return match ?? FALLBACK_LANGUAGE;
}
Loading
Loading