feat(Compare): 增加延迟对比功能
This commit is contained in:
@@ -1,69 +1,45 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
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'
|
||||
import ResultsPanel from './components/ResultsPanel'
|
||||
import { testAllNodes } from './api/latency'
|
||||
import { LatencyResult } from '@shared/types'
|
||||
import HomePage from './components/HomePage'
|
||||
import ComparePage from './components/ComparePage'
|
||||
import './styles/index.css'
|
||||
|
||||
function AppContent() {
|
||||
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleTest = useCallback(async (target: string) => {
|
||||
setTesting(true)
|
||||
setResults(new Map())
|
||||
setSelectedNodeId(null)
|
||||
|
||||
await testAllNodes(target, (result) => {
|
||||
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
||||
})
|
||||
|
||||
setTesting(false)
|
||||
}, [])
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<FloatingHeader />
|
||||
|
||||
<main className="app-main" style={{ paddingTop: '8rem' }}>
|
||||
<p className="app-description">
|
||||
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
||||
</p>
|
||||
<IpInput onTest={handleTest} testing={testing} />
|
||||
<LatencyMap
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
<ResultsPanel
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>{t('© 2025 全球延迟测试。由 GlobalPing 提供服务支持。', '© 2025 Global Latency Test. Powered by GlobalPing.')}</p>
|
||||
<p>{t('Copyright © by ahdoawhfo All Rights Reserved. ', 'Copyright © by ahdoawhfo All Rights Reserved. ')}</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/compare" element={<ComparePage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<AppContent />
|
||||
<AppRoutes />
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
310
src/client/components/ComparePage.css
Normal file
310
src/client/components/ComparePage.css
Normal file
@@ -0,0 +1,310 @@
|
||||
.compare-page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compare-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.compare-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.75rem;
|
||||
background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.compare-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.compare-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--card-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
filter: drop-shadow(var(--shadow-glow));
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.compare-input {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 1rem;
|
||||
transition: all var(--transition-fast);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.compare-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.compare-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: inset 0 0 20px rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.compare-input.error {
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.compare-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--error-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compare-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.compare-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 48px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compare-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(rgba(255,255,255,0.15), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.compare-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.compare-button:hover:not(:disabled)::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compare-button:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.compare-button .spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Results Table */
|
||||
.results-container {
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
filter: drop-shadow(var(--shadow-glow));
|
||||
}
|
||||
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: 0.875rem 1.25rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
background: var(--hover-bg);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.results-table tbody tr:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.results-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.node-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.node-city {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.node-country {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.latency-cell {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.latency-cell.better {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.latency-cell.worse {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.latency-cell.testing {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-cell {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diff-positive {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.diff-negative {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.diff-neutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Summary */
|
||||
.compare-summary {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.summary-value.winner-a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.summary-value.winner-b {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compare-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.compare-summary {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
226
src/client/components/ComparePage.tsx
Normal file
226
src/client/components/ComparePage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState } from 'react'
|
||||
import { testAllNodes } from '../api/latency'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { LatencyResult, TEST_NODES } from '@shared/types'
|
||||
import './ComparePage.css'
|
||||
|
||||
const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/
|
||||
const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/
|
||||
|
||||
function isValidTarget(value: string): boolean {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
|
||||
}
|
||||
|
||||
export default function ComparePage() {
|
||||
const { t } = useLanguage()
|
||||
const [targetA, setTargetA] = useState('')
|
||||
const [targetB, setTargetB] = useState('')
|
||||
const [resultsA, setResultsA] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [resultsB, setResultsB] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [errors, setErrors] = useState({ a: '', b: '' })
|
||||
|
||||
const handleCompare = async () => {
|
||||
const aValid = isValidTarget(targetA)
|
||||
const bValid = isValidTarget(targetB)
|
||||
|
||||
setErrors({
|
||||
a: aValid ? '' : t('无效的目标', 'Invalid target'),
|
||||
b: bValid ? '' : t('无效的目标', 'Invalid target')
|
||||
})
|
||||
|
||||
if (!aValid || !bValid) return
|
||||
|
||||
setTesting(true)
|
||||
setResultsA(new Map())
|
||||
setResultsB(new Map())
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
testAllNodes(targetA.trim(), (res) => {
|
||||
setResultsA(prev => new Map(prev).set(res.nodeId, res))
|
||||
}),
|
||||
testAllNodes(targetB.trim(), (res) => {
|
||||
setResultsB(prev => new Map(prev).set(res.nodeId, res))
|
||||
})
|
||||
])
|
||||
} catch (e) {
|
||||
console.error('Comparison test failed:', e)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getDiff = (a: number | null, b: number | null) => {
|
||||
if (a === null || b === null) return null
|
||||
return b - a
|
||||
}
|
||||
|
||||
const getDiffDisplay = (diff: number | null) => {
|
||||
if (diff === null) return <span className="diff-neutral">-</span>
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
const className = diff > 0 ? 'diff-positive' : diff < 0 ? 'diff-negative' : 'diff-neutral'
|
||||
return <span className={className}>{sign}{diff.toFixed(0)} ms</span>
|
||||
}
|
||||
|
||||
// Calculate summary stats
|
||||
const calculateSummary = () => {
|
||||
let winsA = 0, winsB = 0, totalDiff = 0, count = 0
|
||||
|
||||
for (const node of TEST_NODES) {
|
||||
const rA = resultsA.get(node.id)
|
||||
const rB = resultsB.get(node.id)
|
||||
const lA = rA?.status === 'success' ? rA.latency : null
|
||||
const lB = rB?.status === 'success' ? rB.latency : null
|
||||
|
||||
if (lA !== null && lB !== null) {
|
||||
if (lA < lB) winsA++
|
||||
else if (lB < lA) winsB++
|
||||
totalDiff += lB - lA
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return { winsA, winsB, avgDiff: count > 0 ? totalDiff / count : 0 }
|
||||
}
|
||||
|
||||
const summary = calculateSummary()
|
||||
const hasResults = resultsA.size > 0 || resultsB.size > 0
|
||||
|
||||
return (
|
||||
<div className="compare-page">
|
||||
<div className="compare-header">
|
||||
<h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
|
||||
<p className="compare-subtitle">
|
||||
{t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="compare-inputs">
|
||||
<div className="input-group">
|
||||
<label className="input-label">{t('目标 A', 'Target A')}</label>
|
||||
<input
|
||||
className={`compare-input ${errors.a ? 'error' : ''}`}
|
||||
value={targetA}
|
||||
onChange={(e) => { setTargetA(e.target.value); setErrors(prev => ({ ...prev, a: '' })) }}
|
||||
placeholder={t('输入IP或域名', 'Enter IP or domain')}
|
||||
disabled={testing}
|
||||
/>
|
||||
{errors.a && <span className="input-error">{errors.a}</span>}
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">{t('目标 B', 'Target B')}</label>
|
||||
<input
|
||||
className={`compare-input ${errors.b ? 'error' : ''}`}
|
||||
value={targetB}
|
||||
onChange={(e) => { setTargetB(e.target.value); setErrors(prev => ({ ...prev, b: '' })) }}
|
||||
placeholder={t('输入IP或域名', 'Enter IP or domain')}
|
||||
disabled={testing}
|
||||
/>
|
||||
{errors.b && <span className="input-error">{errors.b}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compare-actions">
|
||||
<button
|
||||
className="compare-button"
|
||||
onClick={handleCompare}
|
||||
disabled={testing || !targetA.trim() || !targetB.trim()}
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<span className="spinner" />
|
||||
{t('测试中...', 'Testing...')}
|
||||
</>
|
||||
) : (
|
||||
t('开始对比', 'Start Comparison')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasResults && (
|
||||
<>
|
||||
<div className="results-container">
|
||||
<table className="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('节点', 'Node')}</th>
|
||||
<th>{targetA || 'A'}</th>
|
||||
<th>{targetB || 'B'}</th>
|
||||
<th>{t('差值', 'Diff')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{TEST_NODES.map(node => {
|
||||
const rA = resultsA.get(node.id)
|
||||
const rB = resultsB.get(node.id)
|
||||
const lA = rA?.status === 'success' ? rA.latency : null
|
||||
const lB = rB?.status === 'success' ? rB.latency : null
|
||||
const diff = getDiff(lA, lB)
|
||||
|
||||
const getLatencyClass = (mine: number | null, other: number | null, status?: string) => {
|
||||
if (status === 'testing') return 'latency-cell testing'
|
||||
if (mine === null || other === null) return 'latency-cell'
|
||||
if (mine < other) return 'latency-cell better'
|
||||
if (mine > other) return 'latency-cell worse'
|
||||
return 'latency-cell'
|
||||
}
|
||||
|
||||
const formatLatency = (latency: number | null, status?: string) => {
|
||||
if (status === 'testing' || status === 'pending') return '...'
|
||||
if (latency === null) return '-'
|
||||
return `${latency.toFixed(0)} ms`
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={node.id}>
|
||||
<td>
|
||||
<div className="node-cell">
|
||||
<span className="node-city">{node.city || node.name}</span>
|
||||
<span className="node-country">{node.country}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={getLatencyClass(lA, lB, rA?.status)}>
|
||||
{formatLatency(lA, rA?.status)}
|
||||
</td>
|
||||
<td className={getLatencyClass(lB, lA, rB?.status)}>
|
||||
{formatLatency(lB, rB?.status)}
|
||||
</td>
|
||||
<td className="diff-cell">
|
||||
{getDiffDisplay(diff)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!testing && (summary.winsA > 0 || summary.winsB > 0) && (
|
||||
<div className="compare-summary">
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('A 胜出节点', 'A Wins')}</span>
|
||||
<span className={`summary-value ${summary.winsA > summary.winsB ? 'winner-a' : ''}`}>
|
||||
{summary.winsA}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('B 胜出节点', 'B Wins')}</span>
|
||||
<span className={`summary-value ${summary.winsB > summary.winsA ? 'winner-b' : ''}`}>
|
||||
{summary.winsB}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('平均差值', 'Avg Diff')}</span>
|
||||
<span className="summary-value">
|
||||
{summary.avgDiff > 0 ? '+' : ''}{summary.avgDiff.toFixed(0)} ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,19 +30,43 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
/* Brand Section - Premium Style */
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.header-title .title-icon {
|
||||
.header-brand:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.header-brand .title-icon {
|
||||
font-size: 1.4rem;
|
||||
filter: drop-shadow(0 0 8px var(--accent-glow));
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 0 10px rgba(60, 130, 250, 0.35));
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.header-brand:hover .title-icon {
|
||||
transform: rotate(8deg) scale(1.08);
|
||||
filter: drop-shadow(0 0 16px rgba(60, 130, 250, 0.5));
|
||||
}
|
||||
|
||||
.header-brand .brand-text {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
margin-left: 1.5rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import ThemeSwitcher from './ThemeSwitcher'
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import LiquidGlassMenu from './LiquidGlassMenu'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import './FloatingHeader.css'
|
||||
|
||||
export default function FloatingHeader() {
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', label: t('首页', 'Home') },
|
||||
{ key: '/compare', label: t('延迟对比', 'Compare') }
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -21,10 +30,18 @@ export default function FloatingHeader() {
|
||||
|
||||
return (
|
||||
<header className={`floating-header ${isScrolled ? 'scrolled' : ''}`}>
|
||||
<div className="header-title">
|
||||
<Link to="/" className="header-brand">
|
||||
<span className="title-icon">🌐</span>
|
||||
{t('延迟测试', 'Latency Test')}
|
||||
</div>
|
||||
<span className="brand-text">{t('延迟测试', 'Latency Test')}</span>
|
||||
</Link>
|
||||
|
||||
<LiquidGlassMenu
|
||||
items={menuItems}
|
||||
selectedKey={location.pathname}
|
||||
onSelect={(key) => navigate(key)}
|
||||
className="header-nav"
|
||||
/>
|
||||
|
||||
<div className="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ThemeSwitcher />
|
||||
|
||||
49
src/client/components/HomePage.tsx
Normal file
49
src/client/components/HomePage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import IpInput from './IpInput'
|
||||
import LatencyMap from './LatencyMap'
|
||||
import ResultsPanel from './ResultsPanel'
|
||||
import { testAllNodes } from '../api/latency'
|
||||
import { LatencyResult } from '@shared/types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
export default function HomePage() {
|
||||
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleTest = useCallback(async (target: string) => {
|
||||
setTesting(true)
|
||||
setResults(new Map())
|
||||
setSelectedNodeId(null)
|
||||
|
||||
await testAllNodes(target, (result) => {
|
||||
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
||||
})
|
||||
|
||||
setTesting(false)
|
||||
}, [])
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="app-description">
|
||||
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
||||
</p>
|
||||
<IpInput onTest={handleTest} testing={testing} />
|
||||
<LatencyMap
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
<ResultsPanel
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
|
||||
setTarget(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
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)')}
|
||||
placeholder={loading ? t('正在检测IP...', 'Detecting IP...') : t('输入IP或域名', 'Enter IP or domain')}
|
||||
className={`ip-input ${error ? 'ip-input-error' : ''}`}
|
||||
disabled={testing || loading}
|
||||
/>
|
||||
|
||||
176
src/client/components/LiquidGlassMenu.css
Normal file
176
src/client/components/LiquidGlassMenu.css
Normal file
@@ -0,0 +1,176 @@
|
||||
/* Liquid Glass Menu - Premium iOS 26 Redesign */
|
||||
|
||||
.liquid-glass-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Premium Glass Indicator - Refined depth and refraction */
|
||||
.liquid-glass-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
/* Multi-layer frosted glass effect */
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
rgba(255, 255, 255, 0.03) 40%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
);
|
||||
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border-radius: 999px;
|
||||
|
||||
/* Refined layered shadows for premium depth */
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
/* Inner top highlight - simulates rim light */
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
/* Inner bottom edge - subtle thickness */
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
|
||||
/* Inner side highlights */
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.06),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.06),
|
||||
/* Soft ambient glow */
|
||||
0 8px 32px -8px rgba(0, 0, 0, 0.35),
|
||||
/* Contact shadow */
|
||||
0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transform-origin: center center;
|
||||
will-change: transform, width;
|
||||
}
|
||||
|
||||
/* Top specular highlight - elegant gloss effect */
|
||||
.liquid-glass-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 38%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
mask-image: linear-gradient(to right, transparent, black 15%, black 85%, transparent);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 15%, black 85%, transparent);
|
||||
}
|
||||
|
||||
/* Bottom subtle reflection */
|
||||
.liquid-glass-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 20%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.06) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Light Theme - Brighter, more solid glass */
|
||||
[data-theme="light"] .liquid-glass-indicator {
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.75) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.55) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.04),
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.4),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.4),
|
||||
0 8px 24px -6px rgba(0, 0, 0, 0.15),
|
||||
0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-indicator::before {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.9) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-indicator::after {
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Menu items container */
|
||||
.liquid-glass-items {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Individual menu item */
|
||||
.liquid-glass-item {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
transition: color 0.28s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
border-radius: 999px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.liquid-glass-item:hover {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.liquid-glass-item.active {
|
||||
color: #fff;
|
||||
text-shadow: 0 0 20px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Light Theme Text */
|
||||
[data-theme="light"] .liquid-glass-item {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-item:hover {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-item.active {
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.liquid-glass-item {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
112
src/client/components/LiquidGlassMenu.tsx
Normal file
112
src/client/components/LiquidGlassMenu.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import './LiquidGlassMenu.css'
|
||||
|
||||
interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface LiquidGlassMenuProps {
|
||||
items: MenuItem[]
|
||||
selectedKey: string
|
||||
onSelect: (key: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LiquidGlassMenu({
|
||||
items,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
className = ''
|
||||
}: LiquidGlassMenuProps) {
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({
|
||||
transform: 'translateX(0)',
|
||||
width: 0,
|
||||
opacity: 0,
|
||||
transition: 'none'
|
||||
})
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const isInitialRender = useRef(true)
|
||||
|
||||
const selectedIndex = items.findIndex(item => item.key === selectedKey)
|
||||
|
||||
const updateIndicator = useCallback(() => {
|
||||
if (!containerRef.current || selectedIndex < 0) return
|
||||
|
||||
const targetItem = itemRefs.current[selectedIndex]
|
||||
if (!targetItem) return
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const itemRect = targetItem.getBoundingClientRect()
|
||||
const relativeLeft = itemRect.left - containerRect.left
|
||||
|
||||
setIndicatorStyle(prev => ({
|
||||
transform: `translateX(${relativeLeft}px)`,
|
||||
width: itemRect.width,
|
||||
opacity: 1,
|
||||
transition: isInitialRender.current
|
||||
? 'none'
|
||||
: prev.transition === 'none'
|
||||
? 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), width 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
: prev.transition
|
||||
}))
|
||||
|
||||
isInitialRender.current = false
|
||||
}, [selectedIndex])
|
||||
|
||||
// Update on mount, resize, and selection change
|
||||
useEffect(() => {
|
||||
updateIndicator()
|
||||
window.addEventListener('resize', updateIndicator)
|
||||
return () => window.removeEventListener('resize', updateIndicator)
|
||||
}, [updateIndicator])
|
||||
|
||||
// Watch for size changes in menu items (e.g., language switch)
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateIndicator()
|
||||
})
|
||||
|
||||
itemRefs.current.forEach(item => {
|
||||
if (item) observer.observe(item)
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [updateIndicator, items.length])
|
||||
|
||||
// Re-calculate when items labels change (language switch)
|
||||
useEffect(() => {
|
||||
// Small delay to ensure DOM has updated
|
||||
const timer = setTimeout(updateIndicator, 10)
|
||||
return () => clearTimeout(timer)
|
||||
}, [items.map(i => i.label).join(','), updateIndicator])
|
||||
|
||||
return (
|
||||
<div className={`liquid-glass-menu ${className}`} ref={containerRef}>
|
||||
<div
|
||||
className="liquid-glass-indicator"
|
||||
style={{
|
||||
transform: indicatorStyle.transform,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
opacity: indicatorStyle.opacity,
|
||||
transition: indicatorStyle.transition
|
||||
}}
|
||||
/>
|
||||
<div className="liquid-glass-items">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={el => { itemRefs.current[index] = el }}
|
||||
className={`liquid-glass-item ${item.key === selectedKey ? 'active' : ''}`}
|
||||
onClick={() => onSelect(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export const TEST_NODES: TestNode[] = [
|
||||
{ id: 'us-east', name: 'New York', region: 'North America', coords: [-74.006, 40.7128], country: 'US', city: 'New York' },
|
||||
{ id: 'us-central', name: 'Dallas', region: 'North America', coords: [-96.797, 32.7767], country: 'US', city: 'Dallas' },
|
||||
{ id: 'canada', name: 'Toronto', region: 'North America', coords: [-79.3832, 43.6532], country: 'CA', city: 'Toronto' },
|
||||
{ id: 'mexico', name: 'Mexico City', region: 'North America', coords: [-99.1332, 19.4326], country: 'MX', city: 'Mexico City' },
|
||||
{ id: 'mexico', name: 'Queretaro', region: 'North America', coords: [-100.39, 20.59], country: 'MX', city: 'Queretaro' },
|
||||
|
||||
// Europe - Western
|
||||
{ id: 'eu-west', name: 'London', region: 'Europe', coords: [-0.1276, 51.5074], country: 'GB', city: 'London' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TEST_NODES } from '../shared/types'
|
||||
|
||||
interface Env {
|
||||
GLOBALPING_API: string
|
||||
ASSETS?: { fetch: (request: Request) => Promise<Response> }
|
||||
}
|
||||
|
||||
interface MeasurementResponse {
|
||||
@@ -244,7 +245,8 @@ export default {
|
||||
return handleGetMeasurement(batchMatch[1], env)
|
||||
}
|
||||
|
||||
// Serve static files for other routes (handled by Cloudflare Pages/Workers Sites)
|
||||
return new Response('Not Found', { status: 404 })
|
||||
// For non-API routes, let Cloudflare Assets handle static files
|
||||
// This will be caught by the assets handler configured in wrangler.toml
|
||||
return env.ASSETS ? env.ASSETS.fetch(request) : new Response('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user