diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index c2397c1..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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": [] - } -} diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index e741503..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 默认忽略的文件 -/shelf/ -/workspace.xml -# 基于编辑器的 HTTP 客户端请求 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/LatencyTest.iml b/.idea/LatencyTest.iml deleted file mode 100644 index af3e685..0000000 --- a/.idea/LatencyTest.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index a21da9a..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index f44a18d..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index f65d9a4..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 0faa797..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 05fa5a4..6e8c8d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b6373cd..bd8a690 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/client/App.tsx b/src/client/App.tsx index 6bc8f0c..e357d29 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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>(new Map()) - const [testing, setTesting] = useState(false) - const [selectedNodeId, setSelectedNodeId] = useState(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 (
-
-

- {t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')} -

- - - + {children}
-
-

{t('© 2025 全球延迟测试。由 GlobalPing 提供服务支持。', '© 2025 Global Latency Test. Powered by GlobalPing.')}

+

{t('Copyright © by ahdoawhfo All Rights Reserved. ', 'Copyright © by ahdoawhfo All Rights Reserved. ')}

) } +function AppRoutes() { + return ( + + + + } /> + } /> + + + + ) +} + export default function App() { return ( - + ) diff --git a/src/client/components/ComparePage.css b/src/client/components/ComparePage.css new file mode 100644 index 0000000..c9e50c5 --- /dev/null +++ b/src/client/components/ComparePage.css @@ -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; + } +} diff --git a/src/client/components/ComparePage.tsx b/src/client/components/ComparePage.tsx new file mode 100644 index 0000000..d289a86 --- /dev/null +++ b/src/client/components/ComparePage.tsx @@ -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>(new Map()) + const [resultsB, setResultsB] = useState>(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 - + const sign = diff > 0 ? '+' : '' + const className = diff > 0 ? 'diff-positive' : diff < 0 ? 'diff-negative' : 'diff-neutral' + return {sign}{diff.toFixed(0)} ms + } + + // 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 ( +
+
+

{t('延迟对比', 'Latency Comparison')}

+

+ {t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')} +

+
+ +
+
+ + { setTargetA(e.target.value); setErrors(prev => ({ ...prev, a: '' })) }} + placeholder={t('输入IP或域名', 'Enter IP or domain')} + disabled={testing} + /> + {errors.a && {errors.a}} +
+
+ + { setTargetB(e.target.value); setErrors(prev => ({ ...prev, b: '' })) }} + placeholder={t('输入IP或域名', 'Enter IP or domain')} + disabled={testing} + /> + {errors.b && {errors.b}} +
+
+ +
+ +
+ + {hasResults && ( + <> +
+ + + + + + + + + + + {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 ( + + + + + + + ) + })} + +
{t('节点', 'Node')}{targetA || 'A'}{targetB || 'B'}{t('差值', 'Diff')}
+
+ {node.city || node.name} + {node.country} +
+
+ {formatLatency(lA, rA?.status)} + + {formatLatency(lB, rB?.status)} + + {getDiffDisplay(diff)} +
+
+ + {!testing && (summary.winsA > 0 || summary.winsB > 0) && ( +
+
+ {t('A 胜出节点', 'A Wins')} + summary.winsB ? 'winner-a' : ''}`}> + {summary.winsA} + +
+
+ {t('B 胜出节点', 'B Wins')} + summary.winsA ? 'winner-b' : ''}`}> + {summary.winsB} + +
+
+ {t('平均差值', 'Avg Diff')} + + {summary.avgDiff > 0 ? '+' : ''}{summary.avgDiff.toFixed(0)} ms + +
+
+ )} + + )} +
+ ) +} diff --git a/src/client/components/FloatingHeader.css b/src/client/components/FloatingHeader.css index 80dde68..9057b4b 100644 --- a/src/client/components/FloatingHeader.css +++ b/src/client/components/FloatingHeader.css @@ -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 { diff --git a/src/client/components/FloatingHeader.tsx b/src/client/components/FloatingHeader.tsx index 7fdea83..0e48410 100644 --- a/src/client/components/FloatingHeader.tsx +++ b/src/client/components/FloatingHeader.tsx @@ -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 (
-
+ 🌐 - {t('延迟测试', 'Latency Test')} -
+ {t('延迟测试', 'Latency Test')} + + + navigate(key)} + className="header-nav" + /> +
diff --git a/src/client/components/HomePage.tsx b/src/client/components/HomePage.tsx new file mode 100644 index 0000000..3efbc20 --- /dev/null +++ b/src/client/components/HomePage.tsx @@ -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>(new Map()) + const [testing, setTesting] = useState(false) + const [selectedNodeId, setSelectedNodeId] = useState(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 ( + <> +

+ {t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')} +

+ + + + + ) +} diff --git a/src/client/components/IpInput.tsx b/src/client/components/IpInput.tsx index c0abec8..524db18 100644 --- a/src/client/components/IpInput.tsx +++ b/src/client/components/IpInput.tsx @@ -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} /> diff --git a/src/client/components/LiquidGlassMenu.css b/src/client/components/LiquidGlassMenu.css new file mode 100644 index 0000000..4c5f924 --- /dev/null +++ b/src/client/components/LiquidGlassMenu.css @@ -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; + } +} diff --git a/src/client/components/LiquidGlassMenu.tsx b/src/client/components/LiquidGlassMenu.tsx new file mode 100644 index 0000000..d8a3192 --- /dev/null +++ b/src/client/components/LiquidGlassMenu.tsx @@ -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(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 ( +
+
+
+ {items.map((item, index) => ( + + ))} +
+
+ ) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index fbe96ec..dc79a29 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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' }, diff --git a/src/worker/index.ts b/src/worker/index.ts index d919c1b..b6d8210 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -2,6 +2,7 @@ import { TEST_NODES } from '../shared/types' interface Env { GLOBALPING_API: string + ASSETS?: { fetch: (request: Request) => Promise } } 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 }) } } diff --git a/vite.config.js b/vite.config.js index 2ffcd0a..4a41a5b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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'), diff --git a/vite.config.ts b/vite.config.ts index e0abfdc..d59e62a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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'), diff --git a/wrangler.toml b/wrangler.toml index 2d93751..4231d02 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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"