feat(Compare): 增加延迟对比功能

This commit is contained in:
2025-12-23 10:29:31 +08:00
parent 1c7837d50c
commit d5a44d2862
23 changed files with 1124 additions and 182 deletions

View File

@@ -1,14 +0,0 @@
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.a6.wiki",
"ANTHROPIC_AUTH_TOKEN": "sk-ahdoawhfo",
"ANTHROPIC_MODEL": "claude-opus-4-5-thinking"
},
"permissions": {
"allow": [
"mcp__codex__codex",
"mcp__gemini__gemini"
],
"deny": []
}
}

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/LatencyTest.iml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,68 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AliAccessStaticViaInstance" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliArrayNamingShouldHaveBracket" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliControlFlowStatementWithoutBraces" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliDeprecation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliEqualsAvoidNull" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliLongLiteralsEndingWithLowercaseL" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliMissingOverrideAnnotation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliWrapperTypeEquality" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAbstractClassShouldStartWithAbstractNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAbstractMethodOrInterfaceMethodMustUseJavadoc" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidApacheBeanUtilsCopy" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidCallStaticSimpleDateFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidCommentBehindStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidComplexCondition" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidConcurrentCompetitionRandom" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidDoubleOrFloatEqualCompare" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidManuallyCreateThread" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidMissUseOfMathRandom" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidNegationOperator" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidNewDateGetTime" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidPatternCompileInMethod" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidReturnInFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidStartWithDollarAndUnderLineNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidUseTimer" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaBigDecimalAvoidDoubleConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaBooleanPropertyShouldNotStartWithIs" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassCastExceptionWithSubListToArrayList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassCastExceptionWithToArray" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassMustHaveAuthor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassNamingShouldBeCamel" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCollectionInitShouldAssignCapacity" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCommentsMustBeJavadocFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaConcurrentExceptionWithModifyOriginSubList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaConstantFieldShouldBeUpperCase" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCountDownShouldInFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaDontModifyInForeachCircle" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaEnumConstantsMustHaveComment" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaExceptionClassShouldEndWithException" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaIbatisMethodQueryForList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaLockShouldWithTryFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaLowerCamelCaseVariableNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaMethodReturnWrapperType" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaMethodTooLong" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPackageNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoMustOverrideToString" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoMustUsePrimitiveField" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoNoDefaultValue" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaRemoveCommentedCode" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaServiceOrDaoClassShouldEndWithImpl" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSneakyThrowsWithoutExceptionType" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaStringConcat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSwitchExpression" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSwitchStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaTestClassShouldEndWithTestNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadLocalShouldRemove" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadPoolCreation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadShouldSetName" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaTransactionMustHaveRollback" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUndefineMagicConstant" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUnsupportedExceptionWithModifyAsList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUseQuietReferenceNotation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUseRightCaseForDateFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="MapOrSetKeyShouldOverrideHashCodeEquals" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
.idea/misc.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/LatencyTest-CF.iml" filepath="$PROJECT_DIR$/.idea/LatencyTest-CF.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

58
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-globe.gl": "^2.37.0",
"react-router-dom": "^7.11.0",
"react-simple-maps": "^3.0.0",
"three": "^0.182.0"
},
@@ -3882,6 +3883,57 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"license": "MIT",
"dependencies": {
"react-router": "7.11.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/react-simple-maps": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
@@ -4109,6 +4161,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",

View File

@@ -24,6 +24,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-globe.gl": "^2.37.0",
"react-router-dom": "^7.11.0",
"react-simple-maps": "^3.0.0",
"three": "^0.182.0"
},

View File

@@ -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>
)

View 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;
}
}

View 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>
)
}

View File

@@ -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 {

View File

@@ -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 />

View 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}
/>
</>
)
}

View File

@@ -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}
/>

View 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;
}
}

View 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>
)
}

View File

@@ -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' },

View File

@@ -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 })
}
}

View File

@@ -1,14 +1,65 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
// SPA fallback middleware - runs after Vite's built-in middleware
function spaFallback() {
return {
name: 'spa-fallback',
configureServer: function (server) {
// Return a function to execute after Vite's internal middleware
return function () {
server.middlewares.use(function (req, res, next) {
var _a, _b;
// Skip API routes and file requests
if (((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith('/api')) || ((_b = req.url) === null || _b === void 0 ? void 0 : _b.includes('.'))) {
return next();
}
// Serve index.html for SPA routes
var indexPath = path.resolve(__dirname, 'index.html');
if (fs.existsSync(indexPath)) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
var html = fs.readFileSync(indexPath, 'utf-8');
server.transformIndexHtml(req.url || '/', html).then(function (transformed) {
res.end(transformed);
}).catch(next);
}
else {
next();
}
});
};
},
configurePreviewServer: function (server) {
// Same logic for preview server
server.middlewares.use(function (req, res, next) {
var _a, _b;
if (((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith('/api')) || ((_b = req.url) === null || _b === void 0 ? void 0 : _b.includes('.'))) {
return next();
}
var indexPath = path.resolve(__dirname, 'dist/client/index.html');
if (fs.existsSync(indexPath)) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
res.end(fs.readFileSync(indexPath, 'utf-8'));
}
else {
next();
}
});
}
};
}
export default defineConfig({
plugins: [react()],
plugins: [react(), spaFallback()],
root: '.',
publicDir: 'public',
build: {
outDir: 'dist/client',
},
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3000',
@@ -16,6 +67,9 @@ export default defineConfig({
},
},
},
preview: {
host: '0.0.0.0',
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/client'),

View File

@@ -1,15 +1,65 @@
import { defineConfig } from 'vite'
import { defineConfig, type PluginOption } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import fs from 'fs'
// SPA fallback middleware - runs after Vite's built-in middleware
function spaFallback(): PluginOption {
return {
name: 'spa-fallback',
configureServer(server) {
// Return a function to execute after Vite's internal middleware
return () => {
server.middlewares.use((req, res, next) => {
// Skip API routes and file requests
if (req.url?.startsWith('/api') || req.url?.includes('.')) {
return next()
}
// Serve index.html for SPA routes
const indexPath = path.resolve(__dirname, 'index.html')
if (fs.existsSync(indexPath)) {
res.statusCode = 200
res.setHeader('Content-Type', 'text/html')
let html = fs.readFileSync(indexPath, 'utf-8')
server.transformIndexHtml(req.url || '/', html).then(transformed => {
res.end(transformed)
}).catch(next)
} else {
next()
}
})
}
},
configurePreviewServer(server) {
// Same logic for preview server
server.middlewares.use((req, res, next) => {
if (req.url?.startsWith('/api') || req.url?.includes('.')) {
return next()
}
const indexPath = path.resolve(__dirname, 'dist/client/index.html')
if (fs.existsSync(indexPath)) {
res.statusCode = 200
res.setHeader('Content-Type', 'text/html')
res.end(fs.readFileSync(indexPath, 'utf-8'))
} else {
next()
}
})
}
}
}
export default defineConfig({
plugins: [react()],
plugins: [react(), spaFallback()],
root: '.',
publicDir: 'public',
build: {
outDir: 'dist/client',
},
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3000',
@@ -17,6 +67,9 @@ export default defineConfig({
},
},
},
preview: {
host: '0.0.0.0',
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/client'),

View File

@@ -1,9 +1,12 @@
name = "latency-test"
main = "dist/worker/index.js"
compatibility_date = "2025-12-22"
compatibility_date = "2025-12-23"
compatibility_flags = ["nodejs_compat"]
assets = { directory = "./dist/client" }
[assets]
directory = "./dist/client"
html_handling = "auto-trailing-slash"
not_found_handling = "single-page-application"
[vars]
GLOBALPING_API = "https://api.globalping.io/v1"