fix(i18n): switch language via popover menu and complete Russian Kimi translations

This commit is contained in:
LTbinglingfeng
2026-02-07 01:13:11 +08:00
parent 700bff1d03
commit 385117d01a
3 changed files with 134 additions and 50 deletions

View File

@@ -194,26 +194,17 @@ export function MainLayout() {
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedLanguage = event.target.value;
if (!isSupportedLanguage(selectedLanguage)) {
return;
}
setLanguage(selectedLanguage);
},
[setLanguage]
);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const languageMenuRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
@@ -313,6 +304,32 @@ export function MainLayout() {
};
}, []);
useEffect(() => {
if (!languageMenuOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!languageMenuRef.current?.contains(event.target as Node)) {
setLanguageMenuOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setLanguageMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [languageMenuOpen]);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
setBrandExpanded(true);
@@ -332,6 +349,21 @@ export function MainLayout() {
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const toggleLanguageMenu = useCallback(() => {
setLanguageMenuOpen((prev) => !prev);
}, []);
const handleLanguageSelect = useCallback(
(nextLanguage: string) => {
if (!isSupportedLanguage(nextLanguage)) {
return;
}
setLanguage(nextLanguage);
setLanguageMenuOpen(false);
},
[setLanguage]
);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
@@ -580,22 +612,35 @@ export function MainLayout() {
>
{headerIcons.update}
</Button>
<div className="language-select-wrapper" title={t('language.switch')}>
<span className="language-select-icon" aria-hidden="true">
{headerIcons.language}
</span>
<select
className="language-select"
value={language}
onChange={handleLanguageChange}
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={toggleLanguageMenu}
title={t('language.switch')}
aria-label={t('language.switch')}
aria-haspopup="menu"
aria-expanded={languageMenuOpen}
>
{LANGUAGE_ORDER.map((lang) => (
<option key={lang} value={lang}>
{t(LANGUAGE_LABEL_KEYS[lang])}
</option>
))}
</select>
{headerIcons.language}
</Button>
{languageMenuOpen && (
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
{LANGUAGE_ORDER.map((lang) => (
<button
key={lang}
type="button"
className={`language-menu-option ${language === lang ? 'active' : ''}`}
onClick={() => handleLanguageSelect(lang)}
role="menuitemradio"
aria-checked={language === lang}
>
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
{language === lang ? <span className="language-menu-check"></span> : null}
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'

View File

@@ -376,6 +376,7 @@
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI",
"filter_kimi": "Kimi",
"filter_aistudio": "AIStudio",
"filter_claude": "Claude",
"filter_codex": "Codex",
@@ -387,6 +388,7 @@
"type_qwen": "Qwen",
"type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI",
"type_kimi": "Kimi",
"type_aistudio": "AIStudio",
"type_claude": "Claude",
"type_codex": "Codex",
@@ -640,6 +642,17 @@
"gemini_cli_oauth_status_error": "Ошибка аутентификации:",
"gemini_cli_oauth_start_error": "Не удалось запустить Gemini CLI OAuth:",
"gemini_cli_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "Начать вход Kimi",
"kimi_oauth_hint": "Выполните вход в сервис Kimi через поток авторизации устройства и автоматически получите/сохраните файлы авторизации.",
"kimi_oauth_url_label": "URL авторизации:",
"kimi_open_link": "Открыть ссылку",
"kimi_copy_link": "Скопировать ссылку",
"kimi_oauth_status_waiting": "Ожидание аутентификации...",
"kimi_oauth_status_success": "Аутентификация успешна!",
"kimi_oauth_status_error": "Ошибка аутентификации:",
"kimi_oauth_start_error": "Не удалось запустить Kimi OAuth:",
"kimi_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Начать вход Qwen",
"qwen_oauth_hint": "Выполните вход в сервис Qwen через поток авторизации устройства и автоматически получите/сохраните файлы авторизации.",

View File

@@ -190,38 +190,64 @@
gap: $spacing-xs;
flex-shrink: 0;
.language-select-wrapper {
.language-menu {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
&:hover {
color: var(--text-primary);
.language-menu-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: $z-dropdown;
min-width: 164px;
padding: $spacing-xs;
display: flex;
flex-direction: column;
gap: 2px;
}
}
.language-select-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.language-menu-option {
width: 100%;
border: none;
border-radius: $radius-sm;
background: transparent;
color: var(--text-primary);
cursor: pointer;
padding: 8px 10px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color $transition-fast, color $transition-fast;
.language-select {
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:hover {
background: var(--bg-secondary);
}
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
&:focus-visible {
outline: none;
background: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
&.active {
color: var(--primary-color);
font-weight: 600;
}
}
.language-menu-check {
font-size: 13px;
line-height: 1;
}
@media (max-width: $breakpoint-mobile) {
.language-menu-popover {
right: auto;
left: 0;
}
}
}