feat(dashboard): add i18n support with zh/en/ja/ko locales

- Add react-i18next + i18next + i18next-browser-languagedetector
- Create i18n config (singleton import) with zh/en/ja/ko JSON locale files
- Add language switcher Globe dropdown in Header topbar
- Replace all hardcoded Chinese strings in:
  - Layout (Header, Sidebar, NavItem, Layout, constants)
  - Settings (index, AppearanceTab, SecurityTab, OtherTab, AboutTab)
  - Auth page (auth.tsx)
  - Search dialog (searchItems via useMemo + t())
  - Restart overlay (getStatusConfig accepts t param)
  - Error boundary (ErrorFallback, ErrorDetails function components)
  - HTTP warning banner
- localStorage key: maibot-locale
- Compatible with Electron
This commit is contained in:
DrSmoothl
2026-03-03 20:50:06 +08:00
parent 5cc34f24c0
commit a65a40f85f
23 changed files with 7271 additions and 473 deletions

View File

@@ -1,17 +1,26 @@
import { BookOpen, ChevronLeft, LogOut, Menu, Moon, PieChart, Search, Server, Sun } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { BookOpen, ChevronLeft, Globe, LogOut, Menu, Moon, PieChart, Search, Server, Sun } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BackgroundLayer } from '@/components/background-layer'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import { SearchDialog } from '@/components/search-dialog'
import { useEffect, useState } from 'react'
import { BackendManager } from '@/components/electron/BackendManager'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import { SearchDialog } from '@/components/search-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Kbd } from '@/components/ui/kbd'
import { toggleThemeWithTransition } from '@/components/use-theme'
import { useBackground } from '@/hooks/use-background'
import { logout } from '@/lib/fetch-with-auth'
import { toggleThemeWithTransition } from '@/components/use-theme'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
const LANGUAGE_CODES = ['zh', 'en', 'ja', 'ko'] as const
interface HeaderProps {
sidebarOpen: boolean
@@ -26,7 +35,7 @@ interface HeaderProps {
export function Header({
sidebarOpen,
// mobileMenuOpen, // unused - kept in props for API compatibility
searchOpen,
actualTheme,
onSidebarToggle,
@@ -34,6 +43,8 @@ export function Header({
onSearchOpenChange,
onThemeChange,
}: HeaderProps) {
const { t, i18n: i18nInstance } = useTranslation()
const currentLang = i18nInstance.language || 'zh'
const headerBg = useBackground('header')
const [backendManagerOpen, setBackendManagerOpen] = useState(false)
const [activeBackendName, setActiveBackendName] = useState<string>('')
@@ -41,7 +52,7 @@ export function Header({
useEffect(() => {
if (!isElectron()) return
window.electronAPI!.getActiveBackend().then((b) => {
setActiveBackendName(b?.name ?? '未连接')
setActiveBackendName(b?.name ?? t('header.notConnected'))
})
}, [])
@@ -65,7 +76,7 @@ export function Header({
<button
onClick={onSidebarToggle}
className="hidden rounded-lg p-2 hover:bg-accent lg:block"
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}
title={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
>
<ChevronLeft
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
@@ -82,7 +93,7 @@ export function Header({
size="sm"
className="gap-2"
onClick={() => setBackendManagerOpen(true)}
title="切换后端连接"
title={t('header.toggleConnection')}
>
<Server className="h-4 w-4" />
<span className="hidden sm:inline text-xs text-muted-foreground truncate max-w-[100px]">
@@ -99,10 +110,10 @@ export function Header({
variant="ghost"
size="sm"
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
title="查看年度总结"
title={t('header.viewAnnualSummary')}
>
<PieChart className="h-4 w-4 text-pink-500" />
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">2025 </span>
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">{t('header.annualSummary')}</span>
</Button>
</Link>
@@ -112,7 +123,7 @@ export function Header({
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<span className="text-sm text-muted-foreground">...</span>
<span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span>
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
<span className="text-xs"></span>K
</Kbd>
@@ -127,12 +138,41 @@ export function Header({
size="sm"
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
className="gap-2"
title="查看麦麦文档"
title={t('header.viewDocs')}
>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="hidden sm:inline">{t('header.docs')}</span>
</Button>
{/* 语言切换 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" />
<span className="hidden sm:inline text-xs">
{t(`language.${currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'}`) ?? currentLang}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LANGUAGE_CODES.map((code) => (
<DropdownMenuItem
key={code}
onClick={() => i18nInstance.changeLanguage(code)}
className={cn(
'cursor-pointer',
currentLang.split('-')[0] === code && 'font-semibold text-primary'
)}
>
{currentLang.split('-')[0] === code && (
<span className="mr-2"></span>
)}
{t(`language.${code}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 主题切换按钮 */}
<button
onClick={(e) => {
@@ -140,7 +180,7 @@ export function Header({
toggleThemeWithTransition(newTheme, onThemeChange, e)
}}
className="rounded-lg p-2 hover:bg-accent"
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
title={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
>
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
@@ -154,10 +194,10 @@ export function Header({
size="sm"
onClick={handleLogout}
className="gap-2"
title="登出系统"
title={t('header.logout')}
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="hidden sm:inline">{t('header.logoutLabel')}</span>
</Button>
</div>
</header>