feat(I18n) 添加I18n相关功能,支持中文和英文
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||||
import FloatingHeader from './components/FloatingHeader'
|
import FloatingHeader from './components/FloatingHeader'
|
||||||
import IpInput from './components/IpInput'
|
import IpInput from './components/IpInput'
|
||||||
import LatencyMap from './components/LatencyMap'
|
import LatencyMap from './components/LatencyMap'
|
||||||
@@ -12,6 +13,7 @@ function AppContent() {
|
|||||||
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const handleTest = useCallback(async (target: string) => {
|
const handleTest = useCallback(async (target: string) => {
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
@@ -35,7 +37,7 @@ function AppContent() {
|
|||||||
|
|
||||||
<main className="app-main" style={{ paddingTop: '5rem' }}>
|
<main className="app-main" style={{ paddingTop: '5rem' }}>
|
||||||
<p className="app-description">
|
<p className="app-description">
|
||||||
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')}
|
||||||
</p>
|
</p>
|
||||||
<IpInput onTest={handleTest} testing={testing} />
|
<IpInput onTest={handleTest} testing={testing} />
|
||||||
<LatencyMap
|
<LatencyMap
|
||||||
@@ -51,7 +53,7 @@ function AppContent() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
<p>© 2024 Latency Test. Powered by GlobalPing.</p>
|
<p>{t('© 2024 延迟测试。由 GlobalPing 提供支持。', '© 2024 Latency Test. Powered by GlobalPing.')}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -60,7 +62,9 @@ function AppContent() {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<LanguageProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
|
</LanguageProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3rem;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1.5rem 0.75rem 2rem;
|
padding: 1.25rem 3rem;
|
||||||
|
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
backdrop-filter: var(--glass-blur);
|
backdrop-filter: var(--glass-blur);
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
|
|
||||||
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1);
|
||||||
transition: opacity var(--transition-smooth);
|
transition: opacity var(--transition-smooth);
|
||||||
width: max-content;
|
width: calc(100% - 2rem);
|
||||||
max-width: 90vw;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-header.scrolled {
|
.floating-header.scrolled {
|
||||||
@@ -45,6 +45,12 @@
|
|||||||
filter: drop-shadow(0 0 8px var(--accent-glow));
|
filter: drop-shadow(0 0 8px var(--accent-glow));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.floating-header .theme-switcher {
|
.floating-header .theme-switcher {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import ThemeSwitcher from './ThemeSwitcher'
|
import ThemeSwitcher from './ThemeSwitcher'
|
||||||
|
import LanguageSwitcher from './LanguageSwitcher'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import './FloatingHeader.css'
|
import './FloatingHeader.css'
|
||||||
|
|
||||||
export default function FloatingHeader() {
|
export default function FloatingHeader() {
|
||||||
const [isScrolled, setIsScrolled] = useState(false)
|
const [isScrolled, setIsScrolled] = useState(false)
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@@ -20,9 +23,12 @@ export default function FloatingHeader() {
|
|||||||
<header className={`floating-header ${isScrolled ? 'scrolled' : ''}`}>
|
<header className={`floating-header ${isScrolled ? 'scrolled' : ''}`}>
|
||||||
<div className="header-title">
|
<div className="header-title">
|
||||||
<span className="title-icon">🌐</span>
|
<span className="title-icon">🌐</span>
|
||||||
Latency Test
|
{t('延迟测试', 'Latency Test')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="header-controls">
|
||||||
|
<LanguageSwitcher />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { fetchUserIp } from '../api/latency'
|
import { fetchUserIp } from '../api/latency'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import './IpInput.css'
|
import './IpInput.css'
|
||||||
|
|
||||||
interface IpInputProps {
|
interface IpInputProps {
|
||||||
@@ -19,19 +20,20 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
|
|||||||
const [target, setTarget] = useState('')
|
const [target, setTarget] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserIp()
|
fetchUserIp()
|
||||||
.then(setTarget)
|
.then(setTarget)
|
||||||
.catch(() => setError('Failed to detect IP'))
|
.catch(() => setError(t('IP检测失败', 'Failed to detect IP')))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = target.trim()
|
const trimmed = target.trim()
|
||||||
if (!isValidTarget(trimmed)) {
|
if (!isValidTarget(trimmed)) {
|
||||||
setError('Invalid IP address or domain')
|
setError(t('无效的IP地址或域名', 'Invalid IP address or domain'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
@@ -48,7 +50,7 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
|
|||||||
setTarget(e.target.value)
|
setTarget(e.target.value)
|
||||||
setError('')
|
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' : ''}`}
|
className={`ip-input ${error ? 'ip-input-error' : ''}`}
|
||||||
disabled={testing || loading}
|
disabled={testing || loading}
|
||||||
/>
|
/>
|
||||||
@@ -58,10 +60,10 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
|
|||||||
{testing ? (
|
{testing ? (
|
||||||
<>
|
<>
|
||||||
<span className="spinner" />
|
<span className="spinner" />
|
||||||
Testing...
|
{t('测试中...', 'Testing...')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Test Latency'
|
t('开始测试', 'Test Latency')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
121
src/client/components/LanguageSwitcher.css
Normal file
121
src/client/components/LanguageSwitcher.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
62
src/client/components/LanguageSwitcher.tsx
Normal file
62
src/client/components/LanguageSwitcher.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="language-switcher-container" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
className={`language-btn ${isOpen ? 'active' : ''}`}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-label="Select language"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<span className="lang-icon">🌐</span>
|
||||||
|
<span className="lang-label">{currentLang.label}</span>
|
||||||
|
<span className="lang-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="language-dropdown">
|
||||||
|
{languages.map(lang => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
className={`language-option ${lang.code === language ? 'selected' : ''}`}
|
||||||
|
onClick={() => handleSelect(lang.code)}
|
||||||
|
>
|
||||||
|
<span>{lang.label}</span>
|
||||||
|
{lang.code === language && <span className="check-icon">✓</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useEffect, useRef } from 'react'
|
import { useMemo, useEffect, useRef } from 'react'
|
||||||
import Globe, { GlobeMethods } from 'react-globe.gl'
|
import Globe, { GlobeMethods } from 'react-globe.gl'
|
||||||
import { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types'
|
import { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import './LatencyMap.css'
|
import './LatencyMap.css'
|
||||||
|
|
||||||
interface LatencyMapProps {
|
interface LatencyMapProps {
|
||||||
@@ -12,6 +13,7 @@ interface LatencyMapProps {
|
|||||||
export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: LatencyMapProps) {
|
export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: LatencyMapProps) {
|
||||||
const globeEl = useRef<GlobeMethods | undefined>(undefined)
|
const globeEl = useRef<GlobeMethods | undefined>(undefined)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (globeEl.current) {
|
if (globeEl.current) {
|
||||||
@@ -115,23 +117,28 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
|
|||||||
</div>
|
</div>
|
||||||
<div className="popup-content">
|
<div className="popup-content">
|
||||||
<div className="popup-row">
|
<div className="popup-row">
|
||||||
<span>Region:</span>
|
<span>{t('地区', 'Region')}:</span>
|
||||||
<span>{selectedNodeData.region}</span>
|
<span>{selectedNodeData.region}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="popup-row">
|
<div className="popup-row">
|
||||||
<span>Country:</span>
|
<span>{t('国家', 'Country')}:</span>
|
||||||
<span>{selectedNodeData.country}</span>
|
<span>{selectedNodeData.country}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="popup-row highlight">
|
<div className="popup-row highlight">
|
||||||
<span>Latency:</span>
|
<span>{t('延迟', 'Latency')}:</span>
|
||||||
<span style={{ color: selectedNodeData.color }}>
|
<span style={{ color: selectedNodeData.color }}>
|
||||||
{selectedNodeData.latency ? `${selectedNodeData.latency}ms` : 'N/A'}
|
{selectedNodeData.latency ? `${selectedNodeData.latency}ms` : t('无', 'N/A')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="popup-row">
|
<div className="popup-row">
|
||||||
<span>Status:</span>
|
<span>{t('状态', 'Status')}:</span>
|
||||||
<span className={`status-badge ${selectedNodeData.status}`}>
|
<span className={`status-badge ${selectedNodeData.status}`}>
|
||||||
{selectedNodeData.status}
|
{t(
|
||||||
|
selectedNodeData.status === 'success' ? '成功' :
|
||||||
|
selectedNodeData.status === 'failed' ? '失败' :
|
||||||
|
selectedNodeData.status === 'testing' ? '测试中' : '等待中',
|
||||||
|
selectedNodeData.status
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +146,9 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="globe-instructions">
|
<div className="globe-instructions">
|
||||||
{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')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TEST_NODES, LatencyResult, getLatencyColor, getLatencyLevel } from '@shared/types'
|
import { TEST_NODES, LatencyResult, getLatencyColor, getLatencyLevel } from '@shared/types'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import './ResultsPanel.css'
|
import './ResultsPanel.css'
|
||||||
|
|
||||||
interface ResultsPanelProps {
|
interface ResultsPanelProps {
|
||||||
@@ -8,6 +9,8 @@ interface ResultsPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }: ResultsPanelProps) {
|
export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }: ResultsPanelProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
if (results.size === 0) return null
|
if (results.size === 0) return null
|
||||||
|
|
||||||
const sortedNodes = [...TEST_NODES].sort((a, b) => {
|
const sortedNodes = [...TEST_NODES].sort((a, b) => {
|
||||||
@@ -32,10 +35,20 @@ export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }:
|
|||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const regionMap: Record<string, string> = {
|
||||||
|
'North America': '北美',
|
||||||
|
'Europe': '欧洲',
|
||||||
|
'Asia': '亚洲',
|
||||||
|
'Middle East': '中东',
|
||||||
|
'South America': '南美',
|
||||||
|
'Africa': '非洲',
|
||||||
|
'Oceania': '大洋洲'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="results-panel">
|
<div className="results-panel">
|
||||||
<div className="results-header">
|
<div className="results-header">
|
||||||
<h2>Test Results</h2>
|
<h2>{t('测试结果', 'Test Results')}</h2>
|
||||||
<div className="results-header-right">
|
<div className="results-header-right">
|
||||||
<div className="latency-legend">
|
<div className="latency-legend">
|
||||||
<div className="legend-gradient"></div>
|
<div className="legend-gradient"></div>
|
||||||
@@ -50,7 +63,7 @@ export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }:
|
|||||||
</div>
|
</div>
|
||||||
{avgLatency !== null && (
|
{avgLatency !== null && (
|
||||||
<div className="avg-latency">
|
<div className="avg-latency">
|
||||||
Avg: <span style={{ color: getLatencyColor(avgLatency) }}>{avgLatency}ms</span>
|
{t('平均', 'Avg')}: <span style={{ color: getLatencyColor(avgLatency) }}>{avgLatency}ms</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -70,14 +83,14 @@ export default function ResultsPanel({ results, selectedNodeId, onNodeSelect }:
|
|||||||
className={`result-card ${hasResult ? getLatencyLevel(result?.latency ?? null) : ''} ${isSelected ? 'selected' : ''}`}
|
className={`result-card ${hasResult ? getLatencyLevel(result?.latency ?? null) : ''} ${isSelected ? 'selected' : ''}`}
|
||||||
onClick={() => onNodeSelect(isSelected ? null : node.id)}
|
onClick={() => onNodeSelect(isSelected ? null : node.id)}
|
||||||
>
|
>
|
||||||
<div className="result-region">{node.region}</div>
|
<div className="result-region">{t(regionMap[node.region] || node.region, node.region)}</div>
|
||||||
<div className="result-name">{node.name}</div>
|
<div className="result-name">{node.name}</div>
|
||||||
<div className="result-latency">
|
<div className="result-latency">
|
||||||
{isTesting ? (
|
{isTesting ? (
|
||||||
<span className="testing-indicator">Testing...</span>
|
<span className="testing-indicator">{t('测试中...', 'Testing...')}</span>
|
||||||
) : hasResult ? (
|
) : hasResult ? (
|
||||||
<span style={{ color: getLatencyColor(result?.latency ?? null) }}>
|
<span style={{ color: getLatencyColor(result?.latency ?? null) }}>
|
||||||
{result?.latency !== null ? `${result.latency}ms` : 'Timeout'}
|
{result?.latency !== null ? `${result.latency}ms` : t('超时', 'Timeout')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="pending">—</span>
|
<span className="pending">—</span>
|
||||||
|
|||||||
47
src/client/contexts/LanguageContext.tsx
Normal file
47
src/client/contexts/LanguageContext.tsx
Normal file
@@ -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<LanguageContextValue | null>(null)
|
||||||
|
|
||||||
|
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [language, setLanguageState] = useState<Language>(() => {
|
||||||
|
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 (
|
||||||
|
<LanguageContext.Provider value={{ language, setLanguage, toggleLanguage, t }}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLanguage() {
|
||||||
|
const ctx = useContext(LanguageContext)
|
||||||
|
if (!ctx) throw new Error('useLanguage must be used within LanguageProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user