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
.idea
.wrangler
.kiro

View File

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

View File

@@ -1,18 +1,38 @@
.compare-page {
max-width: 1000px;
max-width: 1440px;
margin: 0 auto;
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 {
text-align: center;
margin-bottom: 2.5rem;
}
.compare-title {
font-size: 1.75rem;
font-size: 2rem;
font-weight: 800;
margin-bottom: 0.75rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
@@ -24,24 +44,50 @@
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));
.compare-inputs-wrapper {
display: flex;
align-items: flex-end;
gap: 2rem;
width: 100%;
max-width: 900px;
}
.input-group {
display: flex;
flex-direction: column;
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 {
@@ -90,19 +136,14 @@
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;
gap: 0.75rem;
padding: 1rem 3rem;
margin-top: 1rem;
border-radius: 1rem;
border: none;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: #fff;
@@ -161,7 +202,7 @@
/* Results Table */
.results-container {
background: var(--card-bg);
border-radius: 1rem;
border-radius: 1.5rem;
border: 1px solid var(--border-color);
overflow: hidden;
backdrop-filter: var(--glass-blur);
@@ -176,7 +217,7 @@
.results-table th,
.results-table td {
padding: 0.875rem 1.25rem;
padding: 1rem 1.5rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
@@ -379,12 +420,37 @@
flex: 1;
}
/* Share link card container */
.compare-share-container {
margin-top: -0.5rem;
margin-bottom: 2rem;
/* Comparison Stats Grid - two column layout for A vs B */
.comparison-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
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 {
@@ -399,11 +465,28 @@
}
@media (max-width: 768px) {
.compare-inputs {
grid-template-columns: 1fr;
.hero-card {
padding: 1.5rem;
border-radius: 1rem;
}
.compare-inputs-wrapper {
flex-direction: column;
align-items: stretch;
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 {
flex-direction: column;
gap: 1.5rem;
@@ -415,6 +498,10 @@
margin: -0.5rem 0;
}
.comparison-stats-grid {
grid-template-columns: 1fr;
}
.compare-ip-info-cards,
.compare-score-cards {
grid-template-columns: 1fr;

View File

@@ -8,14 +8,31 @@ import ExpirationBanner from './ExpirationBanner'
import ShareLinkCard from './ShareLinkCard'
import IpInfoCard from './IpInfoCard'
import ScoreCard from './ScoreCard'
import TestProgressBar, { TestPhase } from './TestProgressBar'
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,}$/
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 {
const trimmed = value.trim().toLowerCase()
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
const normalized = normalizeTarget(value)
return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
}
export default function ComparePage() {
@@ -39,6 +56,7 @@ export default function ComparePage() {
const [ipInfoB, setIpInfoB] = useState<IpInfo | null>(null)
const [tracerouteA, setTracerouteA] = useState<TracerouteStats | null>(null)
const [tracerouteB, setTracerouteB] = useState<TracerouteStats | null>(null)
const [phase, setPhase] = useState<TestPhase>('idle')
useEffect(() => {
if (!resultId) {
@@ -126,10 +144,10 @@ export default function ComparePage() {
const handleCompare = async () => {
if (isReadOnly) return
const trimmedA = targetA.trim()
const trimmedB = targetB.trim()
const aValid = isValidTarget(trimmedA)
const bValid = isValidTarget(trimmedB)
const normalizedA = normalizeTarget(targetA)
const normalizedB = normalizeTarget(targetB)
const aValid = isValidTarget(targetA)
const bValid = isValidTarget(targetB)
setErrors({
a: aValid ? '' : t('无效的目标', 'Invalid target'),
@@ -138,7 +156,12 @@ export default function ComparePage() {
if (!aValid || !bValid) return
// Update display to show normalized values
setTargetA(normalizedA)
setTargetB(normalizedB)
setTesting(true)
setPhase('init')
setResultsA(new Map())
setResultsB(new Map())
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 } }>()
try {
const [resA, resB] = await Promise.all([
testAllNodes(trimmedA, (res) => {
// Test targets sequentially to avoid GlobalPing API rate limits
const resA = await testAllNodes(
normalizedA,
(res) => {
setResultsA(prev => new Map(prev).set(res.nodeId, res))
if (res.status === 'success' || res.status === 'failed') {
leftResults.set(res.nodeId, {
@@ -164,8 +189,16 @@ export default function ComparePage() {
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))
if (res.status === 'success' || res.status === 'failed') {
rightResults.set(res.nodeId, {
@@ -175,8 +208,8 @@ export default function ComparePage() {
stats: res.stats
})
}
})
])
}
)
if (resA.resolvedAddress) {
setResolvedIpA(resA.resolvedAddress)
@@ -206,22 +239,29 @@ export default function ComparePage() {
})
try {
setPhase('saving')
const { shareUrl: url } = await saveResult({
type: 'compare',
input: { leftTarget: trimmedA, rightTarget: trimmedB },
input: { leftTarget: normalizedA, rightTarget: normalizedB },
results: { left, right },
ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null },
traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null }
})
setShareUrl(url)
setShowShareModal(true)
setPhase('complete')
} catch (e) {
console.error('Failed to save result:', e)
setPhase('complete')
}
} catch (e) {
console.error('Comparison test failed:', e)
setPhase('complete')
} finally {
setTimeout(() => {
setTesting(false)
setPhase('idle')
}, 1500)
}
}
@@ -282,6 +322,33 @@ export default function ComparePage() {
return (
<div className="compare-page">
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
{isReadOnly ? (
<>
<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="readonly-badge" style={{ textAlign: 'center', marginBottom: '1.5rem' }}>{t('只读模式', 'View Only')}</div>
<div className="comparison-stats-grid">
<div className="comparison-column">
<div className="comparison-column-header">{targetA}</div>
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} 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 className="hero-card">
<div className="compare-header">
<h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
<p className="compare-subtitle">
@@ -289,28 +356,7 @@ export default function ComparePage() {
</p>
</div>
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
{isReadOnly ? (
<div className="compare-inputs readonly">
<div className="readonly-input-group">
<span className="readonly-label">{t('目标 A', 'Target A')}</span>
<span className="readonly-value">{targetA}</span>
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
</div>
<div className="readonly-vs">VS</div>
<div className="readonly-input-group">
<span className="readonly-label">{t('目标 B', 'Target B')}</span>
<span className="readonly-value">{targetB}</span>
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
</div>
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
</div>
) : (
<>
<div className="compare-inputs">
<div className="compare-inputs-wrapper">
<div className="input-group">
<label className="input-label">{t('目标 A', 'Target A')}</label>
<input
@@ -322,6 +368,9 @@ export default function ComparePage() {
/>
{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
@@ -335,7 +384,6 @@ export default function ComparePage() {
</div>
</div>
<div className="compare-actions">
<button
className="compare-button"
onClick={handleCompare}
@@ -350,25 +398,35 @@ export default function ComparePage() {
t('开始对比', 'Start Comparison')
)}
</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>
{(shareUrl || resultsA.size > 0 || resultsB.size > 0) && !testing && (
<div className="compare-share-container">
{(ipInfoA || ipInfoB) && (
<div className="compare-ip-info-cards">
{ipInfoA && <IpInfoCard info={ipInfoA} compact className="compare-ip-card" />}
{ipInfoB && <IpInfoCard info={ipInfoB} compact className="compare-ip-card" />}
</div>
)}
{(resultsA.size > 0 || resultsB.size > 0) && (
<div className="compare-score-cards">
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact className="compare-score-card" />}
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact className="compare-score-card" />}
</div>
)}
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
{(resultsA.size > 0 || resultsB.size > 0) && !testing && (
<div className="comparison-stats-grid">
<div className="comparison-column">
<div className="comparison-column-header">{targetA || 'Target A'}</div>
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
</div>
<div className="comparison-column">
<div className="comparison-column-header">{targetB || 'Target B'}</div>
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
</div>
</div>
)}
{shareUrl && !testing && <ShareLinkCard shareUrl={shareUrl} />}
</>
)}

View File

@@ -8,8 +8,9 @@ import ExpirationBanner from './ExpirationBanner'
import ShareLinkCard from './ShareLinkCard'
import IpInfoCard from './IpInfoCard'
import ScoreCard from './ScoreCard'
import TestProgressBar, { TestPhase } from './TestProgressBar'
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'
export default function HomePage() {
@@ -27,6 +28,7 @@ export default function HomePage() {
const [resolvedIp, setResolvedIp] = useState<string | null>(null)
const [ipInfo, setIpInfo] = useState<IpInfo | null>(null)
const [traceroute, setTraceroute] = useState<TracerouteStats | null>(null)
const [phase, setPhase] = useState<TestPhase>('idle')
const { t } = useLanguage()
// Load saved result if viewing shared link
@@ -77,6 +79,7 @@ export default function HomePage() {
const handleTest = useCallback(async (testTarget: string) => {
setTesting(true)
setPhase('init')
setResults(new Map())
setSelectedNodeId(null)
setShareUrl(null)
@@ -87,7 +90,9 @@ export default function HomePage() {
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(
testTarget,
(result) => {
setResults((prev) => new Map(prev).set(result.nodeId, result))
if (result.status === 'success' || result.status === 'failed') {
finalResults.push({
@@ -97,7 +102,9 @@ export default function HomePage() {
stats: result.stats
})
}
})
},
(newPhase) => setPhase(newPhase)
)
if (resolvedAddress) {
setResolvedIp(resolvedAddress)
@@ -113,6 +120,7 @@ export default function HomePage() {
// Save result and get share URL
try {
setPhase('saving')
const { shareUrl: url } = await saveResult({
type: 'single',
input: { target: testTarget },
@@ -122,11 +130,16 @@ export default function HomePage() {
})
setShareUrl(url)
setShowShareModal(true)
setPhase('complete')
} catch (e) {
console.error('Failed to save result:', e)
setPhase('complete')
}
setTimeout(() => {
setTesting(false)
setPhase('idle')
}, 1500)
}, [])
const handleNodeSelect = useCallback((nodeId: string | null) => {
@@ -152,54 +165,90 @@ export default function HomePage() {
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 (
<>
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
<div className="hero-card">
<p className="app-description">
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
</p>
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
{isReadOnly ? (
<div className="readonly-input-container">
<div className="readonly-input">
<span className="readonly-label">{t('测试目标', 'Test Target')}</span>
<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>
)}
<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
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 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 {
const trimmed = value.trim().toLowerCase()
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
const normalized = normalizeTarget(value)
return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
}
export default function IpInput({ onTest, testing }: IpInputProps) {
@@ -31,13 +47,14 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = target.trim()
if (!isValidTarget(trimmed)) {
if (!isValidTarget(target)) {
setError(t('无效的IP地址或域名', 'Invalid IP address or domain'))
return
}
const normalized = normalizeTarget(target)
setTarget(normalized) // Update display to show normalized value
setError('')
onTest(trimmed)
onTest(normalized)
}
return (

View File

@@ -1,9 +1,8 @@
.map-container {
position: relative;
width: 100%;
max-width: 1000px;
height: 500px;
margin: 0 auto 3rem;
height: 100%;
min-height: 500px;
border-radius: 16px;
overflow: hidden;
background: #000;
@@ -152,6 +151,7 @@
@media (max-width: 768px) {
.map-container {
height: 400px;
min-height: 400px;
}
.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 { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types'
import { useLanguage } from '../contexts/LanguageContext'
@@ -14,6 +14,27 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
const globeEl = useRef<GlobeMethods | undefined>(undefined)
const containerRef = useRef<HTMLDivElement>(null)
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(() => {
if (globeEl.current) {
@@ -73,6 +94,8 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
<div className="map-container" ref={containerRef}>
<Globe
ref={globeEl}
width={dimensions.width}
height={dimensions.height}
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
backgroundImageUrl="//unpkg.com/three-globe/example/img/night-sky.png"

View File

@@ -1,6 +1,5 @@
.results-panel {
max-width: 1000px;
margin: 0 auto;
width: 100%;
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 {
flex: 1;
padding: 3rem 2rem;
max-width: 1200px;
padding: 2rem;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
@@ -206,6 +206,41 @@ body {
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-container {
display: flex;
@@ -330,3 +365,149 @@ body {
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 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 {
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 {
@@ -536,16 +553,17 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
return jsonResponse({ error: 'target is required' }, 400)
}
const trimmedTarget = target.trim().toLowerCase()
if (!isValidTarget(trimmedTarget)) {
if (!isValidTarget(target)) {
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)
let tracerouteTarget = trimmedTarget
if (!isIPAddress(trimmedTarget)) {
const resolvedIP = await resolveDomainToIP(trimmedTarget)
let tracerouteTarget = normalizedTarget
if (!isIPAddress(normalizedTarget)) {
const resolvedIP = await resolveDomainToIP(normalizedTarget)
if (resolvedIP) {
tracerouteTarget = resolvedIP
}
@@ -553,7 +571,7 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
// Create both ping and traceroute measurements in parallel
const [measurementId, tracerouteId] = await Promise.all([
createBatchMeasurement(trimmedTarget, env),
createBatchMeasurement(normalizedTarget, env),
createMtrMeasurement(tracerouteTarget, env)
])

View File

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