From 6f6dea9f03da661fa8c43d3530f64faab6cb1f43031b5cc64adf79c420890db1 Mon Sep 17 00:00:00 2001 From: ahdoawhfo Date: Fri, 19 Dec 2025 17:53:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(I18n)=20=E6=B7=BB=E5=8A=A0I18n=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=92=8C=E8=8B=B1=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/App.tsx | 10 +- src/client/components/FloatingHeader.css | 14 ++- src/client/components/FloatingHeader.tsx | 10 +- src/client/components/IpInput.tsx | 14 ++- src/client/components/LanguageSwitcher.css | 121 +++++++++++++++++++++ src/client/components/LanguageSwitcher.tsx | 62 +++++++++++ src/client/components/LatencyMap.tsx | 23 ++-- src/client/components/ResultsPanel.tsx | 23 +++- src/client/contexts/LanguageContext.tsx | 47 ++++++++ 9 files changed, 297 insertions(+), 27 deletions(-) create mode 100644 src/client/components/LanguageSwitcher.css create mode 100644 src/client/components/LanguageSwitcher.tsx create mode 100644 src/client/contexts/LanguageContext.tsx diff --git a/src/client/App.tsx b/src/client/App.tsx index 533d7c4..6da7ee8 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react' import { ThemeProvider } from './contexts/ThemeContext' +import { LanguageProvider, useLanguage } from './contexts/LanguageContext' import FloatingHeader from './components/FloatingHeader' import IpInput from './components/IpInput' import LatencyMap from './components/LatencyMap' @@ -12,6 +13,7 @@ function AppContent() { const [results, setResults] = useState>(new Map()) const [testing, setTesting] = useState(false) const [selectedNodeId, setSelectedNodeId] = useState(null) + const { t } = useLanguage() const handleTest = useCallback(async (target: string) => { setTesting(true) @@ -35,7 +37,7 @@ function AppContent() {

- Test network latency from global locations to any IP address or domain + {t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}

-

© 2024 Latency Test. Powered by GlobalPing.

+

{t('© 2024 延迟测试。由 GlobalPing 提供支持。', '© 2024 Latency Test. Powered by GlobalPing.')}

) @@ -60,7 +62,9 @@ function AppContent() { export default function App() { return ( - + + + ) } diff --git a/src/client/components/FloatingHeader.css b/src/client/components/FloatingHeader.css index 1ff02a7..873d315 100644 --- a/src/client/components/FloatingHeader.css +++ b/src/client/components/FloatingHeader.css @@ -7,8 +7,8 @@ display: flex; align-items: center; - gap: 3rem; - padding: 0.75rem 1.5rem 0.75rem 2rem; + justify-content: space-between; + padding: 1.25rem 3rem; background: var(--card-bg); backdrop-filter: var(--glass-blur); @@ -18,8 +18,8 @@ box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1); transition: opacity var(--transition-smooth); - width: max-content; - max-width: 90vw; + width: calc(100% - 2rem); + max-width: 1100px; } .floating-header.scrolled { @@ -45,6 +45,12 @@ filter: drop-shadow(0 0 8px var(--accent-glow)); } +.header-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + .floating-header .theme-switcher { font-size: 1.1rem; padding: 6px 10px; diff --git a/src/client/components/FloatingHeader.tsx b/src/client/components/FloatingHeader.tsx index cebb296..7fdea83 100644 --- a/src/client/components/FloatingHeader.tsx +++ b/src/client/components/FloatingHeader.tsx @@ -1,9 +1,12 @@ import { useState, useEffect } from 'react' import ThemeSwitcher from './ThemeSwitcher' +import LanguageSwitcher from './LanguageSwitcher' +import { useLanguage } from '../contexts/LanguageContext' import './FloatingHeader.css' export default function FloatingHeader() { const [isScrolled, setIsScrolled] = useState(false) + const { t } = useLanguage() useEffect(() => { const handleScroll = () => { @@ -20,9 +23,12 @@ export default function FloatingHeader() {
🌐 - Latency Test + {t('延迟测试', 'Latency Test')} +
+
+ +
-
) } diff --git a/src/client/components/IpInput.tsx b/src/client/components/IpInput.tsx index 2531257..c0abec8 100644 --- a/src/client/components/IpInput.tsx +++ b/src/client/components/IpInput.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { fetchUserIp } from '../api/latency' +import { useLanguage } from '../contexts/LanguageContext' import './IpInput.css' interface IpInputProps { @@ -19,19 +20,20 @@ export default function IpInput({ onTest, testing }: IpInputProps) { const [target, setTarget] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const { t } = useLanguage() useEffect(() => { fetchUserIp() .then(setTarget) - .catch(() => setError('Failed to detect IP')) + .catch(() => setError(t('IP检测失败', 'Failed to detect IP'))) .finally(() => setLoading(false)) - }, []) + }, [t]) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() const trimmed = target.trim() if (!isValidTarget(trimmed)) { - setError('Invalid IP address or domain') + setError(t('无效的IP地址或域名', 'Invalid IP address or domain')) return } setError('') @@ -48,7 +50,7 @@ export default function IpInput({ onTest, testing }: IpInputProps) { setTarget(e.target.value) setError('') }} - placeholder={loading ? 'Detecting IP...' : 'Enter IP or domain (e.g., 8.8.8.8 or google.com)'} + placeholder={loading ? t('正在检测IP...', 'Detecting IP...') : t('输入IP或域名(如 8.8.8.8 或 google.com)', 'Enter IP or domain (e.g., 8.8.8.8 or google.com)')} className={`ip-input ${error ? 'ip-input-error' : ''}`} disabled={testing || loading} /> @@ -58,10 +60,10 @@ export default function IpInput({ onTest, testing }: IpInputProps) { {testing ? ( <> - Testing... + {t('测试中...', 'Testing...')} ) : ( - 'Test Latency' + t('开始测试', 'Test Latency') )} diff --git a/src/client/components/LanguageSwitcher.css b/src/client/components/LanguageSwitcher.css new file mode 100644 index 0000000..6e8340b --- /dev/null +++ b/src/client/components/LanguageSwitcher.css @@ -0,0 +1,121 @@ +.language-switcher-container { + position: relative; + display: flex; + align-items: center; +} + +.language-btn { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + border: 1px solid transparent; + border-radius: 99px; + padding: 6px 10px; + + font-family: inherit; + font-size: 0.9rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.language-btn:hover, +.language-btn.active { + color: var(--text-color); + background-color: var(--hover-bg); +} + +.lang-icon { + font-size: 1.1rem; + opacity: 0.8; +} + +.lang-label { + line-height: 1; +} + +.lang-arrow { + font-size: 0.6rem; + opacity: 0.6; + margin-left: 2px; + transition: transform 0.2s ease; +} + +.language-btn.active .lang-arrow { + transform: rotate(180deg); +} + +.language-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 120px; + + background: var(--card-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + + animation: dropdown-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; + transform-origin: top right; + z-index: 101; +} + +@keyframes dropdown-enter { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.96); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.language-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 12px; + + background: transparent; + border: none; + border-radius: 8px; + + font-family: inherit; + font-size: 0.9rem; + color: var(--text-secondary); + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.language-option:hover { + background-color: var(--hover-bg); + color: var(--text-color); +} + +.language-option.selected { + color: var(--primary-color); + background-color: rgba(59, 130, 246, 0.08); + font-weight: 600; +} + +[data-theme='dark'] .language-option.selected { + background-color: rgba(56, 189, 248, 0.15); +} + +.check-icon { + font-size: 0.85rem; + margin-left: 8px; +} diff --git a/src/client/components/LanguageSwitcher.tsx b/src/client/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..3286300 --- /dev/null +++ b/src/client/components/LanguageSwitcher.tsx @@ -0,0 +1,62 @@ +import { useState, useRef, useEffect } from 'react' +import { useLanguage, Language } from '../contexts/LanguageContext' +import './LanguageSwitcher.css' + +const languages: { code: Language; label: string }[] = [ + { code: 'zh', label: '中文' }, + { code: 'en', label: 'English' }, +] + +export default function LanguageSwitcher() { + const { language, setLanguage } = useLanguage() + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + const currentLang = languages.find(l => l.code === language) || languages[0] + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen]) + + const handleSelect = (code: Language) => { + setLanguage(code) + setIsOpen(false) + } + + return ( +
+ + {isOpen && ( +
+ {languages.map(lang => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/client/components/LatencyMap.tsx b/src/client/components/LatencyMap.tsx index ccb5ce8..eec1f00 100644 --- a/src/client/components/LatencyMap.tsx +++ b/src/client/components/LatencyMap.tsx @@ -1,6 +1,7 @@ import { useMemo, useEffect, useRef } from 'react' import Globe, { GlobeMethods } from 'react-globe.gl' import { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types' +import { useLanguage } from '../contexts/LanguageContext' import './LatencyMap.css' interface LatencyMapProps { @@ -12,6 +13,7 @@ interface LatencyMapProps { export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: LatencyMapProps) { const globeEl = useRef(undefined) const containerRef = useRef(null) + const { t } = useLanguage() useEffect(() => { if (globeEl.current) { @@ -115,23 +117,28 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
- Region: + {t('地区', 'Region')}: {selectedNodeData.region}
- Country: + {t('国家', 'Country')}: {selectedNodeData.country}
- Latency: + {t('延迟', 'Latency')}: - {selectedNodeData.latency ? `${selectedNodeData.latency}ms` : 'N/A'} + {selectedNodeData.latency ? `${selectedNodeData.latency}ms` : t('无', 'N/A')}
- Status: + {t('状态', 'Status')}: - {selectedNodeData.status} + {t( + selectedNodeData.status === 'success' ? '成功' : + selectedNodeData.status === 'failed' ? '失败' : + selectedNodeData.status === 'testing' ? '测试中' : '等待中', + selectedNodeData.status + )}
@@ -139,7 +146,9 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La )}
- {hasTesting ? 'Drag to rotate · Scroll to zoom · Click nodes for details' : 'Drag to rotate · Scroll to zoom'} + {hasTesting + ? t('拖动旋转 · 滚动缩放 · 点击节点查看详情', 'Drag to rotate · Scroll to zoom · Click nodes for details') + : t('拖动旋转 · 滚动缩放', 'Drag to rotate · Scroll to zoom')}
) diff --git a/src/client/components/ResultsPanel.tsx b/src/client/components/ResultsPanel.tsx index 6d1ca10..d41bfe5 100644 --- a/src/client/components/ResultsPanel.tsx +++ b/src/client/components/ResultsPanel.tsx @@ -1,4 +1,5 @@ import { TEST_NODES, LatencyResult, getLatencyColor, getLatencyLevel } from '@shared/types' +import { useLanguage } from '../contexts/LanguageContext' import './ResultsPanel.css' interface ResultsPanelProps { @@ -8,6 +9,8 @@ interface ResultsPanelProps { } export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }: ResultsPanelProps) { + const { t } = useLanguage() + if (results.size === 0) return null const sortedNodes = [...TEST_NODES].sort((a, b) => { @@ -32,10 +35,20 @@ export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }: ) : null + const regionMap: Record = { + 'North America': '北美', + 'Europe': '欧洲', + 'Asia': '亚洲', + 'Middle East': '中东', + 'South America': '南美', + 'Africa': '非洲', + 'Oceania': '大洋洲' + } + return (
-

Test Results

+

{t('测试结果', 'Test Results')}

@@ -50,7 +63,7 @@ export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }:
{avgLatency !== null && (
- Avg: {avgLatency}ms + {t('平均', 'Avg')}: {avgLatency}ms
)}
@@ -70,14 +83,14 @@ export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }: className={`result-card ${hasResult ? getLatencyLevel(result?.latency ?? null) : ''} ${isSelected ? 'selected' : ''}`} onClick={() => onNodeSelect(isSelected ? null : node.id)} > -
{node.region}
+
{t(regionMap[node.region] || node.region, node.region)}
{node.name}
{isTesting ? ( - Testing... + {t('测试中...', 'Testing...')} ) : hasResult ? ( - {result?.latency !== null ? `${result.latency}ms` : 'Timeout'} + {result?.latency !== null ? `${result.latency}ms` : t('超时', 'Timeout')} ) : ( diff --git a/src/client/contexts/LanguageContext.tsx b/src/client/contexts/LanguageContext.tsx new file mode 100644 index 0000000..2376711 --- /dev/null +++ b/src/client/contexts/LanguageContext.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useEffect, useState, ReactNode } from 'react' + +export type Language = 'zh' | 'en' + +interface LanguageContextValue { + language: Language + setLanguage: (lang: Language) => void + toggleLanguage: () => void + t: (zh: string, en: string) => string +} + +const LanguageContext = createContext(null) + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [language, setLanguageState] = useState(() => { + const saved = localStorage.getItem('language') as Language + if (saved === 'zh' || saved === 'en') return saved + return 'zh' + }) + + useEffect(() => { + localStorage.setItem('language', language) + document.documentElement.setAttribute('lang', language) + }, [language]) + + const toggleLanguage = () => { + setLanguageState(prev => prev === 'zh' ? 'en' : 'zh') + } + + const setLanguage = (lang: Language) => { + setLanguageState(lang) + } + + const t = (zh: string, en: string) => (language === 'zh' ? zh : en) + + return ( + + {children} + + ) +} + +export function useLanguage() { + const ctx = useContext(LanguageContext) + if (!ctx) throw new Error('useLanguage must be used within LanguageProvider') + return ctx +}