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 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 UUID from './uuid' | ||||||
| import JSON from './json' | import JSON from './json' | ||||||
| import Base64 from './base64' | import Base64 from './base64' | ||||||
|  | import { DNS, Ping, TCPing, SpeedTest } from './network' | ||||||
|  |  | ||||||
| export interface Tool { | export interface Tool { | ||||||
|   path: string; |   path: string; | ||||||
| @@ -35,5 +36,41 @@ export const tools: Tool[] = [ | |||||||
|     description: "Encode and decode Base64", |     description: "Encode and decode Base64", | ||||||
|     icon: <Binary />, |     icon: <Binary />, | ||||||
|     component: <Base64 />, |     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