fix(all): 修复了一些bug

This commit is contained in:
2026-02-24 17:11:25 +08:00
parent 75b4341330
commit c2f041b03b
14 changed files with 816 additions and 191 deletions

1
.gitignore vendored
View File

@@ -176,3 +176,4 @@ dist
.claude .claude
.idea .idea
.wrangler .wrangler
.kiro

View File

@@ -86,8 +86,10 @@ export interface TestResult {
export async function testAllNodes( export async function testAllNodes(
target: string, target: string,
onProgress: (result: LatencyResult) => void onProgress: (result: LatencyResult) => void,
onPhase?: (phase: 'init' | 'testing' | 'traceroute') => void
): Promise<TestResult> { ): Promise<TestResult> {
onPhase?.('init')
for (const node of TEST_NODES) { for (const node of TEST_NODES) {
onProgress({ nodeId: node.id, latency: null, status: 'pending' }) onProgress({ nodeId: node.id, latency: null, status: 'pending' })
} }
@@ -107,6 +109,7 @@ export async function testAllNodes(
const { measurementId, tracerouteId }: BatchMeasurementResponse = await res.json() const { measurementId, tracerouteId }: BatchMeasurementResponse = await res.json()
onPhase?.('testing')
for (const node of TEST_NODES) { for (const node of TEST_NODES) {
onProgress({ nodeId: node.id, latency: null, status: 'testing' }) onProgress({ nodeId: node.id, latency: null, status: 'testing' })
} }
@@ -165,6 +168,7 @@ export async function testAllNodes(
// Fetch traceroute results if available // Fetch traceroute results if available
let traceroute: TracerouteStats | null = null let traceroute: TracerouteStats | null = null
if (tracerouteId) { if (tracerouteId) {
onPhase?.('traceroute')
try { try {
const trRes = await fetch(`${API_BASE}/latency/traceroute/${tracerouteId}`) const trRes = await fetch(`${API_BASE}/latency/traceroute/${tracerouteId}`)
if (trRes.ok) { if (trRes.ok) {

View File

@@ -1,18 +1,38 @@
.compare-page { .compare-page {
max-width: 1000px; max-width: 1440px;
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
/* Hero Card - unified container for header, inputs, and progress */
.compare-page .hero-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
width: 100%;
max-width: 1200px;
padding: 3rem;
background: var(--card-bg);
border-radius: 2rem;
border: 1px solid var(--border-color);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
} }
.compare-header { .compare-header {
text-align: center; text-align: center;
margin-bottom: 2.5rem;
} }
.compare-title { .compare-title {
font-size: 1.75rem; font-size: 2rem;
font-weight: 800; font-weight: 800;
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%); background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
@@ -24,24 +44,50 @@
font-size: 1rem; font-size: 1rem;
} }
.compare-inputs { .compare-inputs-wrapper {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; align-items: flex-end;
gap: 1.5rem; gap: 2rem;
margin-bottom: 1.5rem; width: 100%;
background: var(--card-bg); max-width: 900px;
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 { .input-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
}
.input-vs-divider {
font-weight: 800;
font-size: 1.5rem;
color: var(--primary-color);
padding-bottom: 0.75rem;
text-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
position: relative;
}
.input-vs-divider::before,
.input-vs-divider::after {
content: '';
position: absolute;
top: 50%;
width: 2rem;
height: 2px;
background: linear-gradient(90deg, transparent, var(--primary-color));
opacity: 0.3;
}
.input-vs-divider::before {
right: 100%;
margin-right: 0.75rem;
}
.input-vs-divider::after {
left: 100%;
margin-left: 0.75rem;
background: linear-gradient(90deg, var(--primary-color), transparent);
} }
.input-label { .input-label {
@@ -90,19 +136,14 @@
font-weight: 500; font-weight: 500;
} }
.compare-actions {
display: flex;
justify-content: center;
margin-bottom: 2.5rem;
}
.compare-button { .compare-button {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 0.75rem;
padding: 14px 48px; padding: 1rem 3rem;
border-radius: 12px; margin-top: 1rem;
border-radius: 1rem;
border: none; border: none;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: #fff; color: #fff;
@@ -161,7 +202,7 @@
/* Results Table */ /* Results Table */
.results-container { .results-container {
background: var(--card-bg); background: var(--card-bg);
border-radius: 1rem; border-radius: 1.5rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
overflow: hidden; overflow: hidden;
backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
@@ -176,7 +217,7 @@
.results-table th, .results-table th,
.results-table td { .results-table td {
padding: 0.875rem 1.25rem; padding: 1rem 1.5rem;
text-align: left; text-align: left;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
@@ -379,12 +420,37 @@
flex: 1; flex: 1;
} }
/* Share link card container */ /* Comparison Stats Grid - two column layout for A vs B */
.compare-share-container { .comparison-stats-grid {
margin-top: -0.5rem; display: grid;
margin-bottom: 2rem; grid-template-columns: 1fr 1fr;
gap: 1.5rem;
width: 100%; width: 100%;
animation: slideUp 0.3s ease-out; }
.comparison-column {
display: flex;
flex-direction: column;
gap: 1rem;
}
.comparison-column-header {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-color);
text-align: center;
padding: 0.75rem 1rem;
background: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--border-color);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
word-break: break-all;
}
/* Share link card - match hero-card width */
.compare-page .share-link-card {
width: 100%;
max-width: 1200px;
} }
@keyframes slideUp { @keyframes slideUp {
@@ -399,11 +465,28 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.compare-inputs { .hero-card {
grid-template-columns: 1fr; padding: 1.5rem;
border-radius: 1rem;
}
.compare-inputs-wrapper {
flex-direction: column;
align-items: stretch;
gap: 1rem; gap: 1rem;
} }
.input-vs-divider {
align-self: center;
padding-bottom: 0;
margin: 0.5rem 0;
}
.input-vs-divider::before,
.input-vs-divider::after {
display: none;
}
.compare-inputs.readonly { .compare-inputs.readonly {
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
@@ -415,6 +498,10 @@
margin: -0.5rem 0; margin: -0.5rem 0;
} }
.comparison-stats-grid {
grid-template-columns: 1fr;
}
.compare-ip-info-cards, .compare-ip-info-cards,
.compare-score-cards { .compare-score-cards {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -8,14 +8,31 @@ import ExpirationBanner from './ExpirationBanner'
import ShareLinkCard from './ShareLinkCard' import ShareLinkCard from './ShareLinkCard'
import IpInfoCard from './IpInfoCard' import IpInfoCard from './IpInfoCard'
import ScoreCard from './ScoreCard' import ScoreCard from './ScoreCard'
import TestProgressBar, { TestPhase } from './TestProgressBar'
import './ComparePage.css' 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 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,}$/ const DOMAIN_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
function normalizeTarget(value: string): string {
let target = value.trim().toLowerCase()
// Remove protocol prefix if present
target = target.replace(/^https?:\/\//, '')
// Strip userinfo if a full URL with credentials was pasted
const atIndex = target.lastIndexOf('@')
if (atIndex !== -1) {
target = target.slice(atIndex + 1)
}
// Remove path, query, and fragment
target = target.split(/[/?#]/)[0]
// Remove port if present
target = target.split(':')[0]
return target
}
function isValidTarget(value: string): boolean { function isValidTarget(value: string): boolean {
const trimmed = value.trim().toLowerCase() const normalized = normalizeTarget(value)
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed) return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
} }
export default function ComparePage() { export default function ComparePage() {
@@ -39,6 +56,7 @@ export default function ComparePage() {
const [ipInfoB, setIpInfoB] = useState<IpInfo | null>(null) const [ipInfoB, setIpInfoB] = useState<IpInfo | null>(null)
const [tracerouteA, setTracerouteA] = useState<TracerouteStats | null>(null) const [tracerouteA, setTracerouteA] = useState<TracerouteStats | null>(null)
const [tracerouteB, setTracerouteB] = useState<TracerouteStats | null>(null) const [tracerouteB, setTracerouteB] = useState<TracerouteStats | null>(null)
const [phase, setPhase] = useState<TestPhase>('idle')
useEffect(() => { useEffect(() => {
if (!resultId) { if (!resultId) {
@@ -126,10 +144,10 @@ export default function ComparePage() {
const handleCompare = async () => { const handleCompare = async () => {
if (isReadOnly) return if (isReadOnly) return
const trimmedA = targetA.trim() const normalizedA = normalizeTarget(targetA)
const trimmedB = targetB.trim() const normalizedB = normalizeTarget(targetB)
const aValid = isValidTarget(trimmedA) const aValid = isValidTarget(targetA)
const bValid = isValidTarget(trimmedB) const bValid = isValidTarget(targetB)
setErrors({ setErrors({
a: aValid ? '' : t('无效的目标', 'Invalid target'), a: aValid ? '' : t('无效的目标', 'Invalid target'),
@@ -138,7 +156,12 @@ export default function ComparePage() {
if (!aValid || !bValid) return if (!aValid || !bValid) return
// Update display to show normalized values
setTargetA(normalizedA)
setTargetB(normalizedB)
setTesting(true) setTesting(true)
setPhase('init')
setResultsA(new Map()) setResultsA(new Map())
setResultsB(new Map()) setResultsB(new Map())
setShareUrl(null) setShareUrl(null)
@@ -153,8 +176,10 @@ export default function ComparePage() {
const rightResults = new Map<string, { nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>() const rightResults = new Map<string, { nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>()
try { try {
const [resA, resB] = await Promise.all([ // Test targets sequentially to avoid GlobalPing API rate limits
testAllNodes(trimmedA, (res) => { const resA = await testAllNodes(
normalizedA,
(res) => {
setResultsA(prev => new Map(prev).set(res.nodeId, res)) setResultsA(prev => new Map(prev).set(res.nodeId, res))
if (res.status === 'success' || res.status === 'failed') { if (res.status === 'success' || res.status === 'failed') {
leftResults.set(res.nodeId, { leftResults.set(res.nodeId, {
@@ -164,8 +189,16 @@ export default function ComparePage() {
stats: res.stats stats: res.stats
}) })
} }
}), },
testAllNodes(trimmedB, (res) => { (newPhase) => {
if (newPhase === 'testing') setPhase('testing')
else if (newPhase === 'traceroute') setPhase('traceroute')
}
)
const resB = await testAllNodes(
normalizedB,
(res) => {
setResultsB(prev => new Map(prev).set(res.nodeId, res)) setResultsB(prev => new Map(prev).set(res.nodeId, res))
if (res.status === 'success' || res.status === 'failed') { if (res.status === 'success' || res.status === 'failed') {
rightResults.set(res.nodeId, { rightResults.set(res.nodeId, {
@@ -175,8 +208,8 @@ export default function ComparePage() {
stats: res.stats stats: res.stats
}) })
} }
}) }
]) )
if (resA.resolvedAddress) { if (resA.resolvedAddress) {
setResolvedIpA(resA.resolvedAddress) setResolvedIpA(resA.resolvedAddress)
@@ -206,22 +239,29 @@ export default function ComparePage() {
}) })
try { try {
setPhase('saving')
const { shareUrl: url } = await saveResult({ const { shareUrl: url } = await saveResult({
type: 'compare', type: 'compare',
input: { leftTarget: trimmedA, rightTarget: trimmedB }, input: { leftTarget: normalizedA, rightTarget: normalizedB },
results: { left, right }, results: { left, right },
ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null }, ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null },
traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null } traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null }
}) })
setShareUrl(url) setShareUrl(url)
setShowShareModal(true) setShowShareModal(true)
setPhase('complete')
} catch (e) { } catch (e) {
console.error('Failed to save result:', e) console.error('Failed to save result:', e)
setPhase('complete')
} }
} catch (e) { } catch (e) {
console.error('Comparison test failed:', e) console.error('Comparison test failed:', e)
setPhase('complete')
} finally { } finally {
setTesting(false) setTimeout(() => {
setTesting(false)
setPhase('idle')
}, 1500)
} }
} }
@@ -282,60 +322,68 @@ export default function ComparePage() {
return ( return (
<div className="compare-page"> <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>
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />} {isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
{isReadOnly ? ( {isReadOnly ? (
<div className="compare-inputs readonly"> <>
<div className="readonly-input-group"> <div className="compare-header">
<span className="readonly-label">{t('目标 A', 'Target A')}</span> <h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
<span className="readonly-value">{targetA}</span> <p className="compare-subtitle">
{ipInfoA && <IpInfoCard info={ipInfoA} compact />} {t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')}
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />} </p>
</div> </div>
<div className="readonly-vs">VS</div> <div className="readonly-badge" style={{ textAlign: 'center', marginBottom: '1.5rem' }}>{t('只读模式', 'View Only')}</div>
<div className="readonly-input-group"> <div className="comparison-stats-grid">
<span className="readonly-label">{t('目标 B', 'Target B')}</span> <div className="comparison-column">
<span className="readonly-value">{targetB}</span> <div className="comparison-column-header">{targetA}</div>
{ipInfoB && <IpInfoCard info={ipInfoB} compact />} {resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />} {ipInfoA && <IpInfoCard info={ipInfoA} compact />}
</div>
<div className="comparison-column">
<div className="comparison-column-header">{targetB}</div>
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
</div>
</div> </div>
<div className="readonly-badge">{t('只读模式', 'View Only')}</div> </>
</div>
) : ( ) : (
<> <>
<div className="compare-inputs"> <div className="hero-card">
<div className="input-group"> <div className="compare-header">
<label className="input-label">{t('目标 A', 'Target A')}</label> <h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
<input <p className="compare-subtitle">
className={`compare-input ${errors.a ? 'error' : ''}`} {t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')}
value={targetA} </p>
onChange={(e) => { setTargetA(e.target.value); setErrors(prev => ({ ...prev, a: '' })) }} </div>
placeholder={t('输入IP或域名', 'Enter IP or domain')}
disabled={testing} <div className="compare-inputs-wrapper">
/> <div className="input-group">
{errors.a && <span className="input-error">{errors.a}</span>} <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-vs-divider">VS</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>
<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 <button
className="compare-button" className="compare-button"
onClick={handleCompare} onClick={handleCompare}
@@ -350,25 +398,35 @@ export default function ComparePage() {
t('开始对比', 'Start Comparison') t('开始对比', 'Start Comparison')
)} )}
</button> </button>
<div style={{ width: '100%' }}>
<TestProgressBar
current={
Array.from(resultsA.values()).filter(r => r.status === 'success' || r.status === 'failed').length +
Array.from(resultsB.values()).filter(r => r.status === 'success' || r.status === 'failed').length
}
total={TEST_NODES.length * 2}
phase={phase}
/>
</div>
</div> </div>
{(shareUrl || resultsA.size > 0 || resultsB.size > 0) && !testing && ( {(resultsA.size > 0 || resultsB.size > 0) && !testing && (
<div className="compare-share-container"> <div className="comparison-stats-grid">
{(ipInfoA || ipInfoB) && ( <div className="comparison-column">
<div className="compare-ip-info-cards"> <div className="comparison-column-header">{targetA || 'Target A'}</div>
{ipInfoA && <IpInfoCard info={ipInfoA} compact className="compare-ip-card" />} {resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
{ipInfoB && <IpInfoCard info={ipInfoB} compact className="compare-ip-card" />} {ipInfoA && <IpInfoCard info={ipInfoA} compact />}
</div> </div>
)} <div className="comparison-column">
{(resultsA.size > 0 || resultsB.size > 0) && ( <div className="comparison-column-header">{targetB || 'Target B'}</div>
<div className="compare-score-cards"> {resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact className="compare-score-card" />} {ipInfoB && <IpInfoCard info={ipInfoB} compact />}
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact className="compare-score-card" />} </div>
</div>
)}
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
</div> </div>
)} )}
{shareUrl && !testing && <ShareLinkCard shareUrl={shareUrl} />}
</> </>
)} )}

View File

@@ -8,8 +8,9 @@ import ExpirationBanner from './ExpirationBanner'
import ShareLinkCard from './ShareLinkCard' import ShareLinkCard from './ShareLinkCard'
import IpInfoCard from './IpInfoCard' import IpInfoCard from './IpInfoCard'
import ScoreCard from './ScoreCard' import ScoreCard from './ScoreCard'
import TestProgressBar, { TestPhase } from './TestProgressBar'
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency' import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
import { LatencyResult, IpInfo, TracerouteStats } from '@shared/types' import { LatencyResult, IpInfo, TracerouteStats, TEST_NODES } from '@shared/types'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
export default function HomePage() { export default function HomePage() {
@@ -27,6 +28,7 @@ export default function HomePage() {
const [resolvedIp, setResolvedIp] = useState<string | null>(null) const [resolvedIp, setResolvedIp] = useState<string | null>(null)
const [ipInfo, setIpInfo] = useState<IpInfo | null>(null) const [ipInfo, setIpInfo] = useState<IpInfo | null>(null)
const [traceroute, setTraceroute] = useState<TracerouteStats | null>(null) const [traceroute, setTraceroute] = useState<TracerouteStats | null>(null)
const [phase, setPhase] = useState<TestPhase>('idle')
const { t } = useLanguage() const { t } = useLanguage()
// Load saved result if viewing shared link // Load saved result if viewing shared link
@@ -77,6 +79,7 @@ export default function HomePage() {
const handleTest = useCallback(async (testTarget: string) => { const handleTest = useCallback(async (testTarget: string) => {
setTesting(true) setTesting(true)
setPhase('init')
setResults(new Map()) setResults(new Map())
setSelectedNodeId(null) setSelectedNodeId(null)
setShareUrl(null) setShareUrl(null)
@@ -87,17 +90,21 @@ export default function HomePage() {
const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }> = [] const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }> = []
const { resolvedAddress, ipInfo: fetchedIpInfo, traceroute: fetchedTraceroute } = await testAllNodes(testTarget, (result) => { const { resolvedAddress, ipInfo: fetchedIpInfo, traceroute: fetchedTraceroute } = await testAllNodes(
setResults((prev) => new Map(prev).set(result.nodeId, result)) testTarget,
if (result.status === 'success' || result.status === 'failed') { (result) => {
finalResults.push({ setResults((prev) => new Map(prev).set(result.nodeId, result))
nodeId: result.nodeId, if (result.status === 'success' || result.status === 'failed') {
latency: result.latency, finalResults.push({
success: result.status === 'success', nodeId: result.nodeId,
stats: result.stats latency: result.latency,
}) success: result.status === 'success',
} stats: result.stats
}) })
}
},
(newPhase) => setPhase(newPhase)
)
if (resolvedAddress) { if (resolvedAddress) {
setResolvedIp(resolvedAddress) setResolvedIp(resolvedAddress)
@@ -113,6 +120,7 @@ export default function HomePage() {
// Save result and get share URL // Save result and get share URL
try { try {
setPhase('saving')
const { shareUrl: url } = await saveResult({ const { shareUrl: url } = await saveResult({
type: 'single', type: 'single',
input: { target: testTarget }, input: { target: testTarget },
@@ -122,11 +130,16 @@ export default function HomePage() {
}) })
setShareUrl(url) setShareUrl(url)
setShowShareModal(true) setShowShareModal(true)
setPhase('complete')
} catch (e) { } catch (e) {
console.error('Failed to save result:', e) console.error('Failed to save result:', e)
setPhase('complete')
} }
setTesting(false) setTimeout(() => {
setTesting(false)
setPhase('idle')
}, 1500)
}, []) }, [])
const handleNodeSelect = useCallback((nodeId: string | null) => { const handleNodeSelect = useCallback((nodeId: string | null) => {
@@ -152,54 +165,90 @@ export default function HomePage() {
const currentUrl = isReadOnly ? window.location.href : undefined const currentUrl = isReadOnly ? window.location.href : undefined
const hasResults = results.size > 0 || ipInfo || shareUrl
const hasScoreCard = results.size > 0
const hasInfoCard = !!ipInfo || (!!resolvedIp && target !== resolvedIp)
const hasTopRow = hasScoreCard || hasInfoCard
return ( return (
<> <>
<p className="app-description">
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
</p>
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />} {isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
{isReadOnly ? ( <div className="hero-card">
<div className="readonly-input-container"> <p className="app-description">
<div className="readonly-input"> {t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
<span className="readonly-label">{t('测试目标', 'Test Target')}</span> </p>
<span className="readonly-value">{target}</span>
</div>
{ipInfo && <IpInfoCard info={ipInfo} compact />}
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact />}
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
</div>
) : (
<>
<IpInput onTest={handleTest} testing={testing} />
{(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl || results.size > 0) && (
<div className="test-result-info">
{ipInfo ? (
<IpInfoCard info={ipInfo} compact />
) : resolvedIp && target !== resolvedIp ? (
<div className="resolved-ip-info">
<span className="resolved-ip-label">{t('解析IP', 'Resolved IP')}</span>
<span className="resolved-ip-value">{resolvedIp}</span>
</div>
) : null}
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact testing={testing} />}
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
</div>
)}
</>
)}
<LatencyMap {isReadOnly ? (
results={results} <div className="readonly-input-container">
selectedNodeId={selectedNodeId} <div className="readonly-input">
onNodeSelect={handleNodeSelect} <span className="readonly-label">{t('测试目标', 'Test Target')}</span>
/> <span className="readonly-value">{target}</span>
<ResultsPanel </div>
results={results} <div className="readonly-badge">{t('只读模式', 'View Only')}</div>
selectedNodeId={selectedNodeId} </div>
onNodeSelect={handleNodeSelect} ) : (
/> <>
<IpInput onTest={handleTest} testing={testing} />
<TestProgressBar
current={Array.from(results.values()).filter(r => r.status === 'success' || r.status === 'failed').length}
total={TEST_NODES.length}
phase={phase}
/>
</>
)}
</div>
<div className="layout-container">
{/* Map Section - The Visual Hero */}
<div className="map-section">
<LatencyMap
results={results}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
/>
</div>
{/* Stats Grid - Cards below map */}
{hasResults && (
<div className="stats-grid">
{hasTopRow && (
<div className="stats-top-row">
{hasScoreCard && (
<div className="stats-score-col">
<ScoreCard
results={Array.from(results.values())}
ipInfo={ipInfo}
traceroute={traceroute}
testing={testing}
/>
</div>
)}
{hasInfoCard && (
<div className="stats-info-col">
{ipInfo ? (
<IpInfoCard info={ipInfo} />
) : (
<div className="resolved-ip-info">
<span className="resolved-ip-label">{t('解析IP', 'Resolved IP')}</span>
<span className="resolved-ip-value">{resolvedIp}</span>
</div>
)}
</div>
)}
</div>
)}
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
</div>
)}
{/* Results Table */}
<ResultsPanel
results={results}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
/>
</div>
<ShareModal <ShareModal
isOpen={showShareModal} isOpen={showShareModal}

View File

@@ -9,11 +9,27 @@ interface IpInputProps {
} }
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 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,}$/ const DOMAIN_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
function normalizeTarget(value: string): string {
let target = value.trim().toLowerCase()
// Remove protocol prefix if present
target = target.replace(/^https?:\/\//, '')
// Strip userinfo if a full URL with credentials was pasted
const atIndex = target.lastIndexOf('@')
if (atIndex !== -1) {
target = target.slice(atIndex + 1)
}
// Remove path, query, and fragment
target = target.split(/[/?#]/)[0]
// Remove port if present
target = target.split(':')[0]
return target
}
function isValidTarget(value: string): boolean { function isValidTarget(value: string): boolean {
const trimmed = value.trim().toLowerCase() const normalized = normalizeTarget(value)
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed) return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
} }
export default function IpInput({ onTest, testing }: IpInputProps) { export default function IpInput({ onTest, testing }: IpInputProps) {
@@ -31,13 +47,14 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
const trimmed = target.trim() if (!isValidTarget(target)) {
if (!isValidTarget(trimmed)) {
setError(t('无效的IP地址或域名', 'Invalid IP address or domain')) setError(t('无效的IP地址或域名', 'Invalid IP address or domain'))
return return
} }
const normalized = normalizeTarget(target)
setTarget(normalized) // Update display to show normalized value
setError('') setError('')
onTest(trimmed) onTest(normalized)
} }
return ( return (

View File

@@ -1,9 +1,8 @@
.map-container { .map-container {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: 1000px; height: 100%;
height: 500px; min-height: 500px;
margin: 0 auto 3rem;
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
background: #000; background: #000;
@@ -152,6 +151,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.map-container { .map-container {
height: 400px; height: 400px;
min-height: 400px;
} }
.globe-popup { .globe-popup {

View File

@@ -1,4 +1,4 @@
import { useMemo, useEffect, useRef } from 'react' import { useMemo, useEffect, useRef, useState } 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 { useLanguage } from '../contexts/LanguageContext'
@@ -14,6 +14,27 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
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() const { t } = useLanguage()
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight
})
}
}
updateDimensions()
const ro = new ResizeObserver(updateDimensions)
if (containerRef.current) {
ro.observe(containerRef.current)
}
return () => ro.disconnect()
}, [])
useEffect(() => { useEffect(() => {
if (globeEl.current) { if (globeEl.current) {
@@ -73,6 +94,8 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
<div className="map-container" ref={containerRef}> <div className="map-container" ref={containerRef}>
<Globe <Globe
ref={globeEl} ref={globeEl}
width={dimensions.width}
height={dimensions.height}
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg" globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
backgroundImageUrl="//unpkg.com/three-globe/example/img/night-sky.png" backgroundImageUrl="//unpkg.com/three-globe/example/img/night-sky.png"

View File

@@ -1,6 +1,5 @@
.results-panel { .results-panel {
max-width: 1000px; width: 100%;
margin: 0 auto;
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }

View File

@@ -0,0 +1,111 @@
.test-progress-container {
width: 100%;
margin: 1.5rem auto 0;
padding: 0;
opacity: 0;
transform: translateY(10px);
transition: all var(--transition-smooth);
pointer-events: none;
}
.test-progress-container.active {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.test-progress-info {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 0.75rem;
}
.test-progress-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
}
.test-progress-percentage {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: var(--primary-color);
}
.test-progress-track {
position: relative;
height: 12px;
margin-bottom: 0.75rem;
}
.test-progress-bar-bg {
height: 100%;
background: var(--background-color);
border-radius: 999px;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--border-color);
}
.test-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
border-radius: 999px;
transition: width 0.3s ease-out;
box-shadow: 0 0 10px var(--accent-glow);
}
.phase-markers {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.phase-marker {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: rgba(255, 255, 255, 0.5);
z-index: 2;
transform: translateX(-50%);
opacity: 0.3;
}
.phase-marker.active {
opacity: 0;
}
.phase-labels {
display: flex;
justify-content: space-between;
padding: 0 2%;
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.phase-label-item {
transition: color 0.3s;
}
.phase-label-item.active {
color: var(--primary-color);
font-weight: 700;
}
@media (max-width: 640px) {
.test-progress-label,
.test-progress-percentage {
font-size: 0.8rem;
}
.phase-labels {
font-size: 0.65rem;
}
}

View File

@@ -0,0 +1,77 @@
import { useLanguage } from '../contexts/LanguageContext'
import './TestProgressBar.css'
export type TestPhase = 'idle' | 'init' | 'testing' | 'traceroute' | 'saving' | 'complete'
interface TestProgressBarProps {
current: number
total: number
phase: TestPhase
}
export default function TestProgressBar({ current, total, phase }: TestProgressBarProps) {
const { t } = useLanguage()
let percentage = 0
let label = ''
switch (phase) {
case 'idle':
percentage = 0
break
case 'init':
percentage = 5
label = t('正在初始化...', 'Initializing...')
break
case 'testing':
// Map 0-100% of testing to 5-85% of total progress
const testingProgress = total > 0 ? current / total : 0
percentage = 5 + testingProgress * 80
label = t(`正在测试节点 (${current}/${total})`, `Testing nodes (${current}/${total})`)
break
case 'traceroute':
percentage = 90
label = t('分析路由路径...', 'Analyzing routes...')
break
case 'saving':
percentage = 95
label = t('保存结果...', 'Saving results...')
break
case 'complete':
percentage = 100
label = t('测试完成', 'Test Complete')
break
}
percentage = Math.min(Math.round(percentage), 100)
if (phase === 'idle') return null
return (
<div className="test-progress-container active">
<div className="test-progress-info">
<span className="test-progress-label">{label}</span>
<span className="test-progress-percentage">{percentage}%</span>
</div>
<div className="test-progress-track">
<div className="test-progress-bar-bg">
<div className="test-progress-fill" style={{ width: `${percentage}%` }} />
</div>
<div className="phase-markers">
<div className={`phase-marker ${percentage >= 5 ? 'active' : ''}`} style={{ left: '5%' }} />
<div className={`phase-marker ${percentage >= 85 ? 'active' : ''}`} style={{ left: '85%' }} />
<div className={`phase-marker ${percentage >= 95 ? 'active' : ''}`} style={{ left: '95%' }} />
</div>
</div>
<div className="phase-labels">
<span className={`phase-label-item ${phase === 'init' ? 'active' : ''}`}>{t('初始化', 'Init')}</span>
<span className={`phase-label-item ${phase === 'testing' ? 'active' : ''}`}>{t('测试', 'Test')}</span>
<span className={`phase-label-item ${phase === 'traceroute' ? 'active' : ''}`}>{t('路由', 'Trace')}</span>
<span className={`phase-label-item ${phase === 'saving' || phase === 'complete' ? 'active' : ''}`}>{t('完成', 'Done')}</span>
</div>
</div>
)
}

View File

@@ -117,8 +117,8 @@ body {
.app-main { .app-main {
flex: 1; flex: 1;
padding: 3rem 2rem; padding: 2rem;
max-width: 1200px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
} }
@@ -206,6 +206,41 @@ body {
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* Hero Card Section */
.hero-card {
background: var(--card-bg);
border-radius: 2rem;
padding: 3rem 2rem;
margin-bottom: 2rem;
border: 1px solid var(--border-color);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
width: 100%;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
}
.hero-card .app-description {
margin-bottom: 0;
}
.hero-card .ip-input-form {
margin-bottom: 0;
}
@media (max-width: 768px) {
.hero-card {
padding: 1.5rem 1rem;
gap: 1rem;
}
}
/* Loading and Error States */ /* Loading and Error States */
.loading-container { .loading-container {
display: flex; display: flex;
@@ -330,3 +365,149 @@ body {
flex-direction: column; flex-direction: column;
} }
} }
/* --- New Layout System --- */
/* Main layout container - vertical stack for breathing room */
.layout-container {
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
}
/* Map Section - The Visual Hero/Focus */
.map-section {
width: 100%;
height: 55vh;
min-height: 450px;
max-height: 650px;
background: var(--card-bg);
border-radius: 1.5rem;
border: 1px solid var(--border-color);
box-shadow: 0 8px 32px -4px rgba(0, 0, 0, 0.08);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
/* Stats Grid - Cards arranged below map */
.stats-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
/* Top row: ScoreCard + IpInfoCard side by side */
.stats-top-row {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.stats-grid > * {
margin-bottom: 0 !important;
}
.stats-score-col > *,
.stats-info-col > * {
margin-bottom: 0 !important;
}
/* Desktop: side by side layout */
@media (min-width: 850px) {
.stats-top-row {
flex-direction: row;
align-items: flex-start;
}
.stats-score-col {
flex: 1.6;
width: 0;
min-width: 0;
}
.stats-info-col {
flex: 1;
min-width: 300px;
}
}
/* ShareLinkCard full width */
.stats-grid .share-link-card {
width: 100%;
margin-bottom: 0;
}
/* Refined Resolved IP Card */
.stats-grid .resolved-ip-info {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 1.5rem;
background: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--border-color);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
margin-bottom: 0;
width: 100%;
}
/* Mobile Adjustments */
@media (max-width: 768px) {
.map-section {
height: 45vh;
min-height: 350px;
max-height: 450px;
border-radius: 1rem;
}
.layout-container {
gap: 1.25rem;
}
.stats-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}
/* --- Comparison Grid Layout --- */
.comparison-stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
margin-bottom: 2rem;
align-items: start;
}
@media (min-width: 768px) {
.comparison-stats-grid {
grid-template-columns: 1fr 1fr;
}
}
.comparison-column {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.comparison-column-header {
text-align: center;
font-weight: 700;
font-size: 1.1rem;
color: var(--primary-color);
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--border-color);
font-family: 'JetBrains Mono', monospace;
word-break: break-all;
}

View File

@@ -148,10 +148,27 @@ function generateId(): string {
} }
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 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,}$/ const DOMAIN_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
function normalizeTarget(value: string): string {
let target = value.trim().toLowerCase()
// Remove protocol prefix if present
target = target.replace(/^https?:\/\//, '')
// Strip userinfo if a full URL with credentials was pasted
const atIndex = target.lastIndexOf('@')
if (atIndex !== -1) {
target = target.slice(atIndex + 1)
}
// Remove path, query, and fragment
target = target.split(/[/?#]/)[0]
// Remove port if present
target = target.split(':')[0]
return target
}
function isValidTarget(target: string): boolean { function isValidTarget(target: string): boolean {
return IP_REGEX.test(target) || DOMAIN_REGEX.test(target) const normalized = normalizeTarget(target)
return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
} }
function isIPAddress(target: string): boolean { function isIPAddress(target: string): boolean {
@@ -536,16 +553,17 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
return jsonResponse({ error: 'target is required' }, 400) return jsonResponse({ error: 'target is required' }, 400)
} }
const trimmedTarget = target.trim().toLowerCase() if (!isValidTarget(target)) {
if (!isValidTarget(trimmedTarget)) {
return jsonResponse({ error: 'Invalid target. Please enter a valid IP address or domain name.' }, 400) return jsonResponse({ error: 'Invalid target. Please enter a valid IP address or domain name.' }, 400)
} }
// Use normalized target for API calls
const normalizedTarget = normalizeTarget(target)
// Resolve domain to IP for traceroute (GlobalPing requires IP) // Resolve domain to IP for traceroute (GlobalPing requires IP)
let tracerouteTarget = trimmedTarget let tracerouteTarget = normalizedTarget
if (!isIPAddress(trimmedTarget)) { if (!isIPAddress(normalizedTarget)) {
const resolvedIP = await resolveDomainToIP(trimmedTarget) const resolvedIP = await resolveDomainToIP(normalizedTarget)
if (resolvedIP) { if (resolvedIP) {
tracerouteTarget = resolvedIP tracerouteTarget = resolvedIP
} }
@@ -553,7 +571,7 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
// Create both ping and traceroute measurements in parallel // Create both ping and traceroute measurements in parallel
const [measurementId, tracerouteId] = await Promise.all([ const [measurementId, tracerouteId] = await Promise.all([
createBatchMeasurement(trimmedTarget, env), createBatchMeasurement(normalizedTarget, env),
createMtrMeasurement(tracerouteTarget, env) createMtrMeasurement(tracerouteTarget, env)
]) ])

View File

@@ -1,6 +1,6 @@
name = "latency-test" name = "latency-test"
main = "dist/worker/index.js" main = "dist/worker/index.js"
compatibility_date = "2025-12-25" compatibility_date = "2025-12-28"
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
[assets] [assets]