feat: add network tools including DNS, Ping, TCPing, and Speed Test
- Introduced a new "Network Tools" category in src/components/tool/index.tsx. - Implemented DNS lookup functionality in src/components/tool/network/dns.tsx. - Added Ping, TCPing, and Speed Test components in their respective files. - Updated network index file to export new components for routing.
This commit is contained in:
		| @@ -1,9 +1,10 @@ | ||||
| import type { ReactNode } from 'react'; | ||||
| import { FileJson, Hash, Binary } from 'lucide-react' | ||||
| import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi } from 'lucide-react' | ||||
|  | ||||
| import UUID from './uuid' | ||||
| import JSON from './json' | ||||
| import Base64 from './base64' | ||||
| import { DNS, Ping, TCPing, SpeedTest } from './network' | ||||
|  | ||||
| export interface Tool { | ||||
|   path: string; | ||||
| @@ -35,5 +36,41 @@ export const tools: Tool[] = [ | ||||
|     description: "Encode and decode Base64", | ||||
|     icon: <Binary />, | ||||
|     component: <Base64 />, | ||||
|   } | ||||
|   }, | ||||
|   { | ||||
|     path: "network", | ||||
|     name: "Network Tools", | ||||
|     description: "Network testing tools", | ||||
|     icon: <Network />, | ||||
|     children: [ | ||||
|       { | ||||
|         path: "dns", | ||||
|         name: "DNS Lookup", | ||||
|         description: "DNS query tool", | ||||
|         icon: <Globe />, | ||||
|         component: <DNS />, | ||||
|       }, | ||||
|       { | ||||
|         path: "ping", | ||||
|         name: "Ping", | ||||
|         description: "Ping test tool", | ||||
|         icon: <Activity />, | ||||
|         component: <Ping />, | ||||
|       }, | ||||
|       { | ||||
|         path: "tcping", | ||||
|         name: "TCPing", | ||||
|         description: "TCP port connectivity test", | ||||
|         icon: <Wifi />, | ||||
|         component: <TCPing />, | ||||
|       }, | ||||
|       { | ||||
|         path: "speedtest", | ||||
|         name: "Speed Test", | ||||
|         description: "Website speed test", | ||||
|         icon: <Gauge />, | ||||
|         component: <SpeedTest />, | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
| ]; | ||||
							
								
								
									
										179
									
								
								src/components/tool/network/dns.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/components/tool/network/dns.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| import { useState, type FC } from "react"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { Input } from "@/components/ui/input"; | ||||
| import { toast } from "sonner"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
|  | ||||
| interface DNSRecord { | ||||
|   name: string; | ||||
|   type: number; | ||||
|   TTL: number; | ||||
|   data: string; | ||||
| } | ||||
|  | ||||
| interface DNSResponse { | ||||
|   Status: number; | ||||
|   Answer?: DNSRecord[]; | ||||
|   Question?: Array<{ name: string; type: number }>; | ||||
| } | ||||
|  | ||||
| const DNS_RECORD_TYPES = [ | ||||
|   { value: "1", label: "A", description: "IPv4 地址" }, | ||||
|   { value: "28", label: "AAAA", description: "IPv6 地址" }, | ||||
|   { value: "5", label: "CNAME", description: "规范名称" }, | ||||
|   { value: "15", label: "MX", description: "邮件交换" }, | ||||
|   { value: "2", label: "NS", description: "名称服务器" }, | ||||
|   { value: "16", label: "TXT", description: "文本记录" }, | ||||
|   { value: "6", label: "SOA", description: "授权起始" }, | ||||
|   { value: "257", label: "CAA", description: "证书颁发机构授权" }, | ||||
|   { value: "12", label: "PTR", description: "指针记录" }, | ||||
|   { value: "33", label: "SRV", description: "服务记录" }, | ||||
| ]; | ||||
|  | ||||
| const getRecordTypeName = (type: number): string => { | ||||
|   const record = DNS_RECORD_TYPES.find((r) => r.value === String(type)); | ||||
|   return record ? record.label : `TYPE${type}`; | ||||
| }; | ||||
|  | ||||
| const Tool: FC = () => { | ||||
|   const [domain, setDomain] = useState<string>(""); | ||||
|   const [loading, setLoading] = useState<boolean>(false); | ||||
|   const [results, setResults] = useState<DNSRecord[]>([]); | ||||
|   const [queryTime, setQueryTime] = useState<number>(0); | ||||
|  | ||||
|   const queryDNS = async () => { | ||||
|     if (!domain.trim()) { | ||||
|       toast.error("请输入域名"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setLoading(true); | ||||
|     setResults([]); | ||||
|     setQueryTime(0); | ||||
|  | ||||
|     const startTime = performance.now(); | ||||
|  | ||||
|     try { | ||||
|       // 并发查询所有记录类型 | ||||
|       const queries = DNS_RECORD_TYPES.map((recordType) => | ||||
|         fetch( | ||||
|           `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent( | ||||
|             domain | ||||
|           )}&type=${recordType.value}`, | ||||
|           { | ||||
|             headers: { | ||||
|               Accept: "application/dns-json", | ||||
|             }, | ||||
|           } | ||||
|         ) | ||||
|           .then((response) => response.json()) | ||||
|           .then((data: DNSResponse) => { | ||||
|             if (data.Status === 0 && data.Answer && data.Answer.length > 0) { | ||||
|               return data.Answer; | ||||
|             } | ||||
|             return []; | ||||
|           }) | ||||
|           .catch(() => []) | ||||
|       ); | ||||
|  | ||||
|       const allResults = await Promise.all(queries); | ||||
|       const endTime = performance.now(); | ||||
|       setQueryTime(endTime - startTime); | ||||
|  | ||||
|       // 合并所有结果并去重 | ||||
|       const combinedResults = allResults.flat(); | ||||
|        | ||||
|       if (combinedResults.length > 0) { | ||||
|         // 按记录类型分组并去重 | ||||
|         const uniqueResults = Array.from( | ||||
|           new Map( | ||||
|             combinedResults.map((record) => [ | ||||
|               `${record.name}-${record.type}-${record.data}`, | ||||
|               record, | ||||
|             ]) | ||||
|           ).values() | ||||
|         ); | ||||
|  | ||||
|         setResults(uniqueResults); | ||||
|         toast.success(`查询成功,找到 ${uniqueResults.length} 条记录`); | ||||
|       } else { | ||||
|         setResults([]); | ||||
|         toast.info("未找到记录"); | ||||
|       } | ||||
|     } catch (error: unknown) { | ||||
|       if (error instanceof Error) { | ||||
|         toast.error(`查询失败: ${error.message}`); | ||||
|       } else { | ||||
|         toast.error("查询失败"); | ||||
|       } | ||||
|       setResults([]); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { | ||||
|     if (e.key === "Enter" && !loading) { | ||||
|       queryDNS(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-4 h-full"> | ||||
|       <div className="flex flex-col gap-4"> | ||||
|         <div className="flex flex-col gap-2"> | ||||
|           <label className="text-sm font-medium">域名</label> | ||||
|           <Input | ||||
|             placeholder="例如: example.com" | ||||
|             value={domain} | ||||
|             onChange={(e) => setDomain(e.target.value)} | ||||
|             onKeyPress={handleKeyPress} | ||||
|             disabled={loading} | ||||
|           /> | ||||
|           <span className="text-xs text-muted-foreground"> | ||||
|             将自动查询所有类型的 DNS 记录 | ||||
|           </span> | ||||
|         </div> | ||||
|  | ||||
|         <Button onClick={queryDNS} disabled={loading} className="w-full"> | ||||
|           {loading && <Loader2 className="mr-2 size-4 animate-spin" />} | ||||
|           {loading ? "查询中..." : "查询所有记录"} | ||||
|         </Button> | ||||
|       </div> | ||||
|  | ||||
|       {queryTime > 0 && ( | ||||
|         <div className="text-sm text-muted-foreground"> | ||||
|           查询耗时: {queryTime.toFixed(2)} ms | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {results.length > 0 && ( | ||||
|         <div className="flex flex-col gap-3 flex-1 overflow-auto"> | ||||
|           <div className="text-sm font-medium">查询结果:</div> | ||||
|           <div className="space-y-2"> | ||||
|             {results.map((record, index) => ( | ||||
|               <div | ||||
|                 key={index} | ||||
|                 className="border rounded-md p-3 bg-card text-card-foreground" | ||||
|               > | ||||
|                 <div className="grid grid-cols-2 gap-2 text-sm"> | ||||
|                   <div className="text-muted-foreground">名称:</div> | ||||
|                   <div className="font-mono break-all">{record.name}</div> | ||||
|                   <div className="text-muted-foreground">类型:</div> | ||||
|                   <div>{getRecordTypeName(record.type)}</div> | ||||
|                   <div className="text-muted-foreground">TTL:</div> | ||||
|                   <div>{record.TTL} 秒</div> | ||||
|                   <div className="text-muted-foreground">数据:</div> | ||||
|                   <div className="font-mono break-all">{record.data}</div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             ))} | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Tool; | ||||
|  | ||||
							
								
								
									
										5
									
								
								src/components/tool/network/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/tool/network/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| export { default as DNS } from './dns'; | ||||
| export { default as Ping } from './ping'; | ||||
| export { default as TCPing } from './tcping'; | ||||
| export { default as SpeedTest } from './speedtest'; | ||||
|  | ||||
							
								
								
									
										261
									
								
								src/components/tool/network/ping.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										261
									
								
								src/components/tool/network/ping.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,261 @@ | ||||
| import { useState, useEffect, useRef, type FC } from "react"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { Input } from "@/components/ui/input"; | ||||
| import { toast } from "sonner"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
|  | ||||
| interface PingResult { | ||||
|   seq: number; | ||||
|   time: number; | ||||
|   success: boolean; | ||||
|   error?: string; | ||||
| } | ||||
|  | ||||
| interface PingStats { | ||||
|   sent: number; | ||||
|   received: number; | ||||
|   lost: number; | ||||
|   min: number; | ||||
|   max: number; | ||||
|   avg: number; | ||||
| } | ||||
|  | ||||
| const Tool: FC = () => { | ||||
|   const [url, setUrl] = useState<string>(""); | ||||
|   const [running, setRunning] = useState<boolean>(false); | ||||
|   const [results, setResults] = useState<PingResult[]>([]); | ||||
|   const [stats, setStats] = useState<PingStats>({ | ||||
|     sent: 0, | ||||
|     received: 0, | ||||
|     lost: 0, | ||||
|     min: 0, | ||||
|     max: 0, | ||||
|     avg: 0, | ||||
|   }); | ||||
|   const intervalRef = useRef<number | null>(null); | ||||
|   const seqRef = useRef<number>(0); | ||||
|   const resultsContainerRef = useRef<HTMLDivElement>(null); | ||||
|  | ||||
|   const ping = async () => { | ||||
|     if (!url.trim()) { | ||||
|       toast.error("请输入 URL"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const seq = ++seqRef.current; | ||||
|     let targetUrl = url.trim(); | ||||
|  | ||||
|     // 如果没有协议前缀,默认使用 https:// | ||||
|     if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) { | ||||
|       targetUrl = `https://${targetUrl}`; | ||||
|     } | ||||
|  | ||||
|     const startTime = performance.now(); | ||||
|  | ||||
|     try { | ||||
|       await fetch(targetUrl, { | ||||
|         method: "HEAD", | ||||
|         mode: "no-cors", | ||||
|         cache: "no-cache", | ||||
|       }); | ||||
|  | ||||
|       const endTime = performance.now(); | ||||
|       const time = endTime - startTime; | ||||
|  | ||||
|       const newResult: PingResult = { | ||||
|         seq, | ||||
|         time, | ||||
|         success: true, | ||||
|       }; | ||||
|  | ||||
|       setResults((prev) => [...prev, newResult]); | ||||
|       updateStats(newResult); | ||||
|     } catch (error: unknown) { | ||||
|       const endTime = performance.now(); | ||||
|       const time = endTime - startTime; | ||||
|  | ||||
|       const errorMessage = | ||||
|         error instanceof Error ? error.message : "请求失败"; | ||||
|  | ||||
|       const newResult: PingResult = { | ||||
|         seq, | ||||
|         time, | ||||
|         success: false, | ||||
|         error: errorMessage, | ||||
|       }; | ||||
|  | ||||
|       setResults((prev) => [...prev, newResult]); | ||||
|       updateStats(newResult); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const updateStats = (newResult: PingResult) => { | ||||
|     setStats((prev) => { | ||||
|       const sent = prev.sent + 1; | ||||
|       const received = newResult.success ? prev.received + 1 : prev.received; | ||||
|       const lost = sent - received; | ||||
|  | ||||
|       let min = prev.min; | ||||
|       let max = prev.max; | ||||
|       let avg = prev.avg; | ||||
|  | ||||
|       if (newResult.success) { | ||||
|         if (received === 1) { | ||||
|           min = newResult.time; | ||||
|           max = newResult.time; | ||||
|           avg = newResult.time; | ||||
|         } else { | ||||
|           min = Math.min(min, newResult.time); | ||||
|           max = Math.max(max, newResult.time); | ||||
|           avg = (prev.avg * (received - 1) + newResult.time) / received; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return { sent, received, lost, min, max, avg }; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const startPing = () => { | ||||
|     if (!url.trim()) { | ||||
|       toast.error("请输入 URL"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setRunning(true); | ||||
|     setResults([]); | ||||
|     setStats({ | ||||
|       sent: 0, | ||||
|       received: 0, | ||||
|       lost: 0, | ||||
|       min: 0, | ||||
|       max: 0, | ||||
|       avg: 0, | ||||
|     }); | ||||
|     seqRef.current = 0; | ||||
|  | ||||
|     // 立即执行第一次 ping | ||||
|     ping(); | ||||
|  | ||||
|     // 然后每秒执行一次 | ||||
|     intervalRef.current = window.setInterval(ping, 1000); | ||||
|   }; | ||||
|  | ||||
|   const stopPing = () => { | ||||
|     setRunning(false); | ||||
|     if (intervalRef.current) { | ||||
|       clearInterval(intervalRef.current); | ||||
|       intervalRef.current = null; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // 自动滚动到底部 | ||||
|     if (resultsContainerRef.current) { | ||||
|       resultsContainerRef.current.scrollTop = | ||||
|         resultsContainerRef.current.scrollHeight; | ||||
|     } | ||||
|   }, [results]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // 清理定时器 | ||||
|     return () => { | ||||
|       if (intervalRef.current) { | ||||
|         clearInterval(intervalRef.current); | ||||
|       } | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   const lossRate = | ||||
|     stats.sent > 0 ? ((stats.lost / stats.sent) * 100).toFixed(1) : "0.0"; | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-4 h-full"> | ||||
|       <div className="flex flex-col gap-4"> | ||||
|         <div className="flex flex-col gap-2"> | ||||
|           <label className="text-sm font-medium">目标 URL 或 IP</label> | ||||
|           <Input | ||||
|             placeholder="例如: example.com 或 https://example.com" | ||||
|             value={url} | ||||
|             onChange={(e) => setUrl(e.target.value)} | ||||
|             disabled={running} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div className="flex gap-2"> | ||||
|           {!running ? ( | ||||
|             <Button onClick={startPing} className="flex-1"> | ||||
|               开始 Ping | ||||
|             </Button> | ||||
|           ) : ( | ||||
|             <Button onClick={stopPing} variant="destructive" className="flex-1"> | ||||
|               停止 | ||||
|             </Button> | ||||
|           )} | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {stats.sent > 0 && ( | ||||
|         <div className="border rounded-md p-3 bg-card text-card-foreground"> | ||||
|           <div className="text-sm font-medium mb-2">统计信息</div> | ||||
|           <div className="grid grid-cols-2 gap-2 text-sm"> | ||||
|             <div className="text-muted-foreground">已发送:</div> | ||||
|             <div>{stats.sent} 包</div> | ||||
|             <div className="text-muted-foreground">已接收:</div> | ||||
|             <div>{stats.received} 包</div> | ||||
|             <div className="text-muted-foreground">丢失:</div> | ||||
|             <div> | ||||
|               {stats.lost} 包 ({lossRate}%) | ||||
|             </div> | ||||
|             {stats.received > 0 && ( | ||||
|               <> | ||||
|                 <div className="text-muted-foreground">最小延迟:</div> | ||||
|                 <div>{stats.min.toFixed(2)} ms</div> | ||||
|                 <div className="text-muted-foreground">最大延迟:</div> | ||||
|                 <div>{stats.max.toFixed(2)} ms</div> | ||||
|                 <div className="text-muted-foreground">平均延迟:</div> | ||||
|                 <div>{stats.avg.toFixed(2)} ms</div> | ||||
|               </> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {results.length > 0 && ( | ||||
|         <div className="flex flex-col gap-2 flex-1 overflow-hidden"> | ||||
|           <div className="text-sm font-medium">Ping 结果:</div> | ||||
|           <div | ||||
|             ref={resultsContainerRef} | ||||
|             className="flex-1 overflow-auto space-y-1 font-mono text-sm border rounded-md p-3 bg-card" | ||||
|           > | ||||
|             {results.map((result) => ( | ||||
|               <div | ||||
|                 key={result.seq} | ||||
|                 className={result.success ? "text-green-500" : "text-red-500"} | ||||
|               > | ||||
|                 {result.success ? ( | ||||
|                   <> | ||||
|                     seq={result.seq} time={result.time.toFixed(2)}ms | ||||
|                   </> | ||||
|                 ) : ( | ||||
|                   <> | ||||
|                     seq={result.seq} 请求超时 | ||||
|                     {result.error && ` (${result.error})`} | ||||
|                   </> | ||||
|                 )} | ||||
|               </div> | ||||
|             ))} | ||||
|             {running && ( | ||||
|               <div className="flex items-center gap-2 text-muted-foreground"> | ||||
|                 <Loader2 className="size-3 animate-spin" /> | ||||
|                 运行中... | ||||
|               </div> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Tool; | ||||
|  | ||||
							
								
								
									
										338
									
								
								src/components/tool/network/speedtest.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										338
									
								
								src/components/tool/network/speedtest.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,338 @@ | ||||
| import { useState, type FC } from "react"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { Input } from "@/components/ui/input"; | ||||
| import { toast } from "sonner"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
|  | ||||
| interface PerformanceMetrics { | ||||
|   dns: number; | ||||
|   tcp: number; | ||||
|   ssl: number; | ||||
|   ttfb: number; | ||||
|   download: number; | ||||
|   total: number; | ||||
| } | ||||
|  | ||||
| interface SpeedTestResult { | ||||
|   downloadSpeed?: number; | ||||
|   uploadSpeed?: number; | ||||
|   performance?: PerformanceMetrics; | ||||
| } | ||||
|  | ||||
| const Tool: FC = () => { | ||||
|   const [url, setUrl] = useState<string>(""); | ||||
|   const [testType, setTestType] = useState<"performance" | "download" | "upload">( | ||||
|     "performance" | ||||
|   ); | ||||
|   const [testing, setTesting] = useState<boolean>(false); | ||||
|   const [result, setResult] = useState<SpeedTestResult | null>(null); | ||||
|  | ||||
|   const testPerformance = async (targetUrl: string) => { | ||||
|     // 清除之前的性能数据 | ||||
|     performance.clearResourceTimings(); | ||||
|  | ||||
|     const startTime = performance.now(); | ||||
|  | ||||
|     try { | ||||
|       const response = await fetch(targetUrl, { | ||||
|         cache: "no-cache", | ||||
|       }); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`HTTP ${response.status}: ${response.statusText}`); | ||||
|       } | ||||
|  | ||||
|       // 等待内容加载完成 | ||||
|       await response.blob(); | ||||
|  | ||||
|       const endTime = performance.now(); | ||||
|  | ||||
|       // 获取性能数据 | ||||
|       const perfEntries = performance.getEntriesByType( | ||||
|         "resource" | ||||
|       ) as PerformanceResourceTiming[]; | ||||
|       const entry = perfEntries.find((e) => e.name === targetUrl); | ||||
|  | ||||
|       if (entry) { | ||||
|         const metrics: PerformanceMetrics = { | ||||
|           dns: entry.domainLookupEnd - entry.domainLookupStart, | ||||
|           tcp: entry.connectEnd - entry.connectStart, | ||||
|           ssl: | ||||
|             entry.secureConnectionStart > 0 | ||||
|               ? entry.connectEnd - entry.secureConnectionStart | ||||
|               : 0, | ||||
|           ttfb: entry.responseStart - entry.requestStart, | ||||
|           download: entry.responseEnd - entry.responseStart, | ||||
|           total: entry.responseEnd - entry.startTime, | ||||
|         }; | ||||
|  | ||||
|         return { performance: metrics }; | ||||
|       } else { | ||||
|         // 如果没有详细的性能数据,只返回总时间 | ||||
|         return { | ||||
|           performance: { | ||||
|             dns: 0, | ||||
|             tcp: 0, | ||||
|             ssl: 0, | ||||
|             ttfb: 0, | ||||
|             download: 0, | ||||
|             total: endTime - startTime, | ||||
|           }, | ||||
|         }; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       throw error; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const testDownloadSpeed = async (targetUrl: string) => { | ||||
|     const startTime = performance.now(); | ||||
|  | ||||
|     try { | ||||
|       const response = await fetch(targetUrl, { | ||||
|         cache: "no-cache", | ||||
|       }); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`HTTP ${response.status}: ${response.statusText}`); | ||||
|       } | ||||
|  | ||||
|       const blob = await response.blob(); | ||||
|       const endTime = performance.now(); | ||||
|  | ||||
|       const fileSizeBytes = blob.size; | ||||
|       const durationSeconds = (endTime - startTime) / 1000; | ||||
|       const speedMbps = (fileSizeBytes * 8) / (durationSeconds * 1000000); | ||||
|  | ||||
|       return { downloadSpeed: speedMbps }; | ||||
|     } catch (error) { | ||||
|       throw error; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const testUploadSpeed = async (targetUrl: string) => { | ||||
|     // 生成 1MB 的测试数据 | ||||
|     const testData = new Uint8Array(1024 * 1024); | ||||
|     for (let i = 0; i < testData.length; i++) { | ||||
|       testData[i] = Math.floor(Math.random() * 256); | ||||
|     } | ||||
|  | ||||
|     const startTime = performance.now(); | ||||
|  | ||||
|     try { | ||||
|       const response = await fetch(targetUrl, { | ||||
|         method: "POST", | ||||
|         body: testData, | ||||
|         cache: "no-cache", | ||||
|       }); | ||||
|  | ||||
|       const endTime = performance.now(); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`HTTP ${response.status}: ${response.statusText}`); | ||||
|       } | ||||
|  | ||||
|       const fileSizeBytes = testData.length; | ||||
|       const durationSeconds = (endTime - startTime) / 1000; | ||||
|       const speedMbps = (fileSizeBytes * 8) / (durationSeconds * 1000000); | ||||
|  | ||||
|       return { uploadSpeed: speedMbps }; | ||||
|     } catch (error) { | ||||
|       throw error; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const startTest = async () => { | ||||
|     if (!url.trim()) { | ||||
|       toast.error("请输入 URL"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     let targetUrl = url.trim(); | ||||
|  | ||||
|     // 如果没有协议前缀,默认使用 https:// | ||||
|     if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) { | ||||
|       targetUrl = `https://${targetUrl}`; | ||||
|     } | ||||
|  | ||||
|     setTesting(true); | ||||
|     setResult(null); | ||||
|  | ||||
|     try { | ||||
|       let testResult: SpeedTestResult = {}; | ||||
|  | ||||
|       switch (testType) { | ||||
|         case "performance": | ||||
|           testResult = await testPerformance(targetUrl); | ||||
|           toast.success("性能测试完成"); | ||||
|           break; | ||||
|         case "download": | ||||
|           testResult = await testDownloadSpeed(targetUrl); | ||||
|           toast.success("下载速度测试完成"); | ||||
|           break; | ||||
|         case "upload": | ||||
|           testResult = await testUploadSpeed(targetUrl); | ||||
|           toast.success("上传速度测试完成"); | ||||
|           break; | ||||
|       } | ||||
|  | ||||
|       setResult(testResult); | ||||
|     } catch (error: unknown) { | ||||
|       if (error instanceof Error) { | ||||
|         toast.error(`测试失败: ${error.message}`); | ||||
|       } else { | ||||
|         toast.error("测试失败"); | ||||
|       } | ||||
|     } finally { | ||||
|       setTesting(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { | ||||
|     if (e.key === "Enter" && !testing) { | ||||
|       startTest(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-4 h-full"> | ||||
|       <div className="border rounded-md p-3 bg-yellow-500/10 border-yellow-500/50"> | ||||
|         <div className="text-sm font-medium text-yellow-600 dark:text-yellow-400 mb-1"> | ||||
|           ⚠️ CORS 限制说明 | ||||
|         </div> | ||||
|         <div className="text-xs text-muted-foreground space-y-1"> | ||||
|           <p>由于浏览器的 CORS 安全策略,部分网站可能无法直接测试。</p> | ||||
|           <p>建议测试以下类型的网站:</p> | ||||
|           <ul className="list-disc list-inside ml-2"> | ||||
|             <li>支持 CORS 的公共 API</li> | ||||
|             <li>您自己控制的网站(可配置 CORS 头)</li> | ||||
|             <li>使用 CORS 代理服务</li> | ||||
|           </ul> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-4"> | ||||
|         <div className="flex flex-col gap-2"> | ||||
|           <label className="text-sm font-medium">目标 URL</label> | ||||
|           <Input | ||||
|             placeholder="例如: https://example.com" | ||||
|             value={url} | ||||
|             onChange={(e) => setUrl(e.target.value)} | ||||
|             onKeyPress={handleKeyPress} | ||||
|             disabled={testing} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div className="flex flex-col gap-2"> | ||||
|           <label className="text-sm font-medium">测试类型</label> | ||||
|           <select | ||||
|             className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" | ||||
|             value={testType} | ||||
|             onChange={(e) => | ||||
|               setTestType(e.target.value as "performance" | "download" | "upload") | ||||
|             } | ||||
|             disabled={testing} | ||||
|           > | ||||
|             <option value="performance">页面加载性能</option> | ||||
|             <option value="download">下载速度</option> | ||||
|             <option value="upload">上传速度</option> | ||||
|           </select> | ||||
|         </div> | ||||
|  | ||||
|         <Button onClick={startTest} disabled={testing} className="w-full"> | ||||
|           {testing && <Loader2 className="mr-2 size-4 animate-spin" />} | ||||
|           {testing ? "测试中..." : "开始测试"} | ||||
|         </Button> | ||||
|       </div> | ||||
|  | ||||
|       {result && ( | ||||
|         <div className="flex flex-col gap-3 flex-1 overflow-auto"> | ||||
|           <div className="text-sm font-medium">测试结果:</div> | ||||
|  | ||||
|           {result.performance && ( | ||||
|             <div className="border rounded-md p-3 bg-card text-card-foreground"> | ||||
|               <div className="text-sm font-medium mb-3">页面加载性能</div> | ||||
|               <div className="space-y-2"> | ||||
|                 {result.performance.dns > 0 && ( | ||||
|                   <div className="grid grid-cols-2 gap-2 text-sm"> | ||||
|                     <div className="text-muted-foreground">DNS 查询:</div> | ||||
|                     <div>{result.performance.dns.toFixed(2)} ms</div> | ||||
|                   </div> | ||||
|                 )} | ||||
|                 {result.performance.tcp > 0 && ( | ||||
|                   <div className="grid grid-cols-2 gap-2 text-sm"> | ||||
|                     <div className="text-muted-foreground">TCP 连接:</div> | ||||
|                     <div>{result.performance.tcp.toFixed(2)} ms</div> | ||||
|                   </div> | ||||
|                 )} | ||||
|                 {result.performance.ssl > 0 && ( | ||||
|                   <div className="grid grid-cols-2 gap-2 text-sm"> | ||||
|                     <div className="text-muted-foreground">SSL 握手:</div> | ||||
|                     <div>{result.performance.ssl.toFixed(2)} ms</div> | ||||
|                   </div> | ||||
|                 )} | ||||
|                 {result.performance.ttfb > 0 && ( | ||||
|                   <div className="grid grid-cols-2 gap-2 text-sm"> | ||||
|                     <div className="text-muted-foreground">首字节时间 (TTFB):</div> | ||||
|                     <div>{result.performance.ttfb.toFixed(2)} ms</div> | ||||
|                   </div> | ||||
|                 )} | ||||
|                 {result.performance.download > 0 && ( | ||||
|                   <div className="grid grid-cols-2 gap-2 text-sm"> | ||||
|                     <div className="text-muted-foreground">内容下载:</div> | ||||
|                     <div>{result.performance.download.toFixed(2)} ms</div> | ||||
|                   </div> | ||||
|                 )} | ||||
|                 <div className="grid grid-cols-2 gap-2 text-sm border-t pt-2 mt-2"> | ||||
|                   <div className="text-muted-foreground font-medium">总时间:</div> | ||||
|                   <div className="font-medium"> | ||||
|                     {result.performance.total.toFixed(2)} ms | ||||
|                   </div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|           )} | ||||
|  | ||||
|           {result.downloadSpeed !== undefined && ( | ||||
|             <div className="border rounded-md p-3 bg-card text-card-foreground"> | ||||
|               <div className="text-sm font-medium mb-3">下载速度</div> | ||||
|               <div className="text-2xl font-bold text-green-500"> | ||||
|                 {result.downloadSpeed.toFixed(2)} Mbps | ||||
|               </div> | ||||
|               <div className="text-sm text-muted-foreground mt-1"> | ||||
|                 {(result.downloadSpeed / 8).toFixed(2)} MB/s | ||||
|               </div> | ||||
|             </div> | ||||
|           )} | ||||
|  | ||||
|           {result.uploadSpeed !== undefined && ( | ||||
|             <div className="border rounded-md p-3 bg-card text-card-foreground"> | ||||
|               <div className="text-sm font-medium mb-3">上传速度</div> | ||||
|               <div className="text-2xl font-bold text-blue-500"> | ||||
|                 {result.uploadSpeed.toFixed(2)} Mbps | ||||
|               </div> | ||||
|               <div className="text-sm text-muted-foreground mt-1"> | ||||
|                 {(result.uploadSpeed / 8).toFixed(2)} MB/s | ||||
|               </div> | ||||
|             </div> | ||||
|           )} | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {testType === "download" && ( | ||||
|         <div className="text-xs text-muted-foreground"> | ||||
|           提示: 下载速度测试会下载目标 URL 的内容并计算速度 | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {testType === "upload" && ( | ||||
|         <div className="text-xs text-muted-foreground"> | ||||
|           提示: 上传速度测试会向目标 URL 发送 1MB 测试数据 | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Tool; | ||||
|  | ||||
							
								
								
									
										285
									
								
								src/components/tool/network/tcping.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										285
									
								
								src/components/tool/network/tcping.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,285 @@ | ||||
| import { useState, useEffect, useRef, type FC } from "react"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { Input } from "@/components/ui/input"; | ||||
| import { toast } from "sonner"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
|  | ||||
| interface TCPingResult { | ||||
|   seq: number; | ||||
|   time: number; | ||||
|   success: boolean; | ||||
|   error?: string; | ||||
| } | ||||
|  | ||||
| interface TCPingStats { | ||||
|   sent: number; | ||||
|   received: number; | ||||
|   lost: number; | ||||
|   min: number; | ||||
|   max: number; | ||||
|   avg: number; | ||||
| } | ||||
|  | ||||
| const Tool: FC = () => { | ||||
|   const [host, setHost] = useState<string>(""); | ||||
|   const [port, setPort] = useState<string>("443"); | ||||
|   const [running, setRunning] = useState<boolean>(false); | ||||
|   const [results, setResults] = useState<TCPingResult[]>([]); | ||||
|   const [stats, setStats] = useState<TCPingStats>({ | ||||
|     sent: 0, | ||||
|     received: 0, | ||||
|     lost: 0, | ||||
|     min: 0, | ||||
|     max: 0, | ||||
|     avg: 0, | ||||
|   }); | ||||
|   const intervalRef = useRef<number | null>(null); | ||||
|   const seqRef = useRef<number>(0); | ||||
|   const resultsContainerRef = useRef<HTMLDivElement>(null); | ||||
|  | ||||
|   const tcping = async () => { | ||||
|     if (!host.trim()) { | ||||
|       toast.error("请输入主机名或 IP"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const seq = ++seqRef.current; | ||||
|     const portNum = parseInt(port) || 443; | ||||
|     let targetUrl = host.trim(); | ||||
|  | ||||
|     // 移除协议前缀 | ||||
|     targetUrl = targetUrl.replace(/^https?:\/\//, ""); | ||||
|  | ||||
|     // 构建测试 URL | ||||
|     const protocol = portNum === 443 ? "https" : "http"; | ||||
|     const url = `${protocol}://${targetUrl}:${portNum}`; | ||||
|  | ||||
|     const startTime = performance.now(); | ||||
|  | ||||
|     try { | ||||
|       // 使用 fetch 测试连接 | ||||
|       await fetch(url, { | ||||
|         method: "HEAD", | ||||
|         mode: "no-cors", | ||||
|         cache: "no-cache", | ||||
|       }); | ||||
|  | ||||
|       const endTime = performance.now(); | ||||
|       const time = endTime - startTime; | ||||
|  | ||||
|       const newResult: TCPingResult = { | ||||
|         seq, | ||||
|         time, | ||||
|         success: true, | ||||
|       }; | ||||
|  | ||||
|       setResults((prev) => [...prev, newResult]); | ||||
|       updateStats(newResult); | ||||
|     } catch (error: unknown) { | ||||
|       const endTime = performance.now(); | ||||
|       const time = endTime - startTime; | ||||
|  | ||||
|       const errorMessage = | ||||
|         error instanceof Error ? error.message : "连接失败"; | ||||
|  | ||||
|       const newResult: TCPingResult = { | ||||
|         seq, | ||||
|         time, | ||||
|         success: false, | ||||
|         error: errorMessage, | ||||
|       }; | ||||
|  | ||||
|       setResults((prev) => [...prev, newResult]); | ||||
|       updateStats(newResult); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const updateStats = (newResult: TCPingResult) => { | ||||
|     setStats((prev) => { | ||||
|       const sent = prev.sent + 1; | ||||
|       const received = newResult.success ? prev.received + 1 : prev.received; | ||||
|       const lost = sent - received; | ||||
|  | ||||
|       let min = prev.min; | ||||
|       let max = prev.max; | ||||
|       let avg = prev.avg; | ||||
|  | ||||
|       if (newResult.success) { | ||||
|         if (received === 1) { | ||||
|           min = newResult.time; | ||||
|           max = newResult.time; | ||||
|           avg = newResult.time; | ||||
|         } else { | ||||
|           min = Math.min(min, newResult.time); | ||||
|           max = Math.max(max, newResult.time); | ||||
|           avg = (prev.avg * (received - 1) + newResult.time) / received; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return { sent, received, lost, min, max, avg }; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const startTCPing = () => { | ||||
|     if (!host.trim()) { | ||||
|       toast.error("请输入主机名或 IP"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setRunning(true); | ||||
|     setResults([]); | ||||
|     setStats({ | ||||
|       sent: 0, | ||||
|       received: 0, | ||||
|       lost: 0, | ||||
|       min: 0, | ||||
|       max: 0, | ||||
|       avg: 0, | ||||
|     }); | ||||
|     seqRef.current = 0; | ||||
|  | ||||
|     // 立即执行第一次 tcping | ||||
|     tcping(); | ||||
|  | ||||
|     // 然后每秒执行一次 | ||||
|     intervalRef.current = window.setInterval(tcping, 1000); | ||||
|   }; | ||||
|  | ||||
|   const stopTCPing = () => { | ||||
|     setRunning(false); | ||||
|     if (intervalRef.current) { | ||||
|       clearInterval(intervalRef.current); | ||||
|       intervalRef.current = null; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // 自动滚动到底部 | ||||
|     if (resultsContainerRef.current) { | ||||
|       resultsContainerRef.current.scrollTop = | ||||
|         resultsContainerRef.current.scrollHeight; | ||||
|     } | ||||
|   }, [results]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // 清理定时器 | ||||
|     return () => { | ||||
|       if (intervalRef.current) { | ||||
|         clearInterval(intervalRef.current); | ||||
|       } | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   const lossRate = | ||||
|     stats.sent > 0 ? ((stats.lost / stats.sent) * 100).toFixed(1) : "0.0"; | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-4 h-full"> | ||||
|       <div className="flex flex-col gap-4"> | ||||
|         <div className="flex flex-col gap-2"> | ||||
|           <label className="text-sm font-medium">主机名或 IP</label> | ||||
|           <Input | ||||
|             placeholder="例如: example.com 或 192.168.1.1" | ||||
|             value={host} | ||||
|             onChange={(e) => setHost(e.target.value)} | ||||
|             disabled={running} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div className="flex flex-col gap-2"> | ||||
|           <label className="text-sm font-medium">端口</label> | ||||
|           <Input | ||||
|             type="number" | ||||
|             placeholder="例如: 443" | ||||
|             value={port} | ||||
|             onChange={(e) => setPort(e.target.value)} | ||||
|             disabled={running} | ||||
|             min="1" | ||||
|             max="65535" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div className="flex gap-2"> | ||||
|           {!running ? ( | ||||
|             <Button onClick={startTCPing} className="flex-1"> | ||||
|               开始测试 | ||||
|             </Button> | ||||
|           ) : ( | ||||
|             <Button | ||||
|               onClick={stopTCPing} | ||||
|               variant="destructive" | ||||
|               className="flex-1" | ||||
|             > | ||||
|               停止 | ||||
|             </Button> | ||||
|           )} | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {stats.sent > 0 && ( | ||||
|         <div className="border rounded-md p-3 bg-card text-card-foreground"> | ||||
|           <div className="text-sm font-medium mb-2">统计信息</div> | ||||
|           <div className="grid grid-cols-2 gap-2 text-sm"> | ||||
|             <div className="text-muted-foreground">已发送:</div> | ||||
|             <div>{stats.sent} 次</div> | ||||
|             <div className="text-muted-foreground">成功:</div> | ||||
|             <div>{stats.received} 次</div> | ||||
|             <div className="text-muted-foreground">失败:</div> | ||||
|             <div> | ||||
|               {stats.lost} 次 ({lossRate}%) | ||||
|             </div> | ||||
|             {stats.received > 0 && ( | ||||
|               <> | ||||
|                 <div className="text-muted-foreground">最小延迟:</div> | ||||
|                 <div>{stats.min.toFixed(2)} ms</div> | ||||
|                 <div className="text-muted-foreground">最大延迟:</div> | ||||
|                 <div>{stats.max.toFixed(2)} ms</div> | ||||
|                 <div className="text-muted-foreground">平均延迟:</div> | ||||
|                 <div>{stats.avg.toFixed(2)} ms</div> | ||||
|                 <div className="text-muted-foreground">端口状态:</div> | ||||
|                 <div className="text-green-500 font-medium">开放</div> | ||||
|               </> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {results.length > 0 && ( | ||||
|         <div className="flex flex-col gap-2 flex-1 overflow-hidden"> | ||||
|           <div className="text-sm font-medium">TCPing 结果:</div> | ||||
|           <div | ||||
|             ref={resultsContainerRef} | ||||
|             className="flex-1 overflow-auto space-y-1 font-mono text-sm border rounded-md p-3 bg-card" | ||||
|           > | ||||
|             {results.map((result) => ( | ||||
|               <div | ||||
|                 key={result.seq} | ||||
|                 className={result.success ? "text-green-500" : "text-red-500"} | ||||
|               > | ||||
|                 {result.success ? ( | ||||
|                   <> | ||||
|                     seq={result.seq} port={port} time={result.time.toFixed(2)}ms | ||||
|                   </> | ||||
|                 ) : ( | ||||
|                   <> | ||||
|                     seq={result.seq} port={port} 连接失败 | ||||
|                     {result.error && ` (${result.error})`} | ||||
|                   </> | ||||
|                 )} | ||||
|               </div> | ||||
|             ))} | ||||
|             {running && ( | ||||
|               <div className="flex items-center gap-2 text-muted-foreground"> | ||||
|                 <Loader2 className="size-3 animate-spin" /> | ||||
|                 运行中... | ||||
|               </div> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Tool; | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 typist
					typist