diff --git a/package.json b/package.json index 36c62b2..fba790e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "release:major": "npm version major && git push --follow-tags" }, "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-separator": "^1.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ada229..a67820a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -323,6 +326,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1921,6 +1937,22 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx index a6dde77..a43c742 100644 --- a/src/components/sidebar/index.tsx +++ b/src/components/sidebar/index.tsx @@ -1,40 +1,98 @@ -import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; -import { tools } from "@/components/tool"; +import type { ReactNode } from "react"; +import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem, SidebarMenu, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton } from "@/components/ui/sidebar"; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; +import { tools, type Tool } from "@/components/tool"; import { Link } from "react-router-dom"; import { ModeToggle } from "@/components/theme/toggle"; import { Button } from "../ui/button"; +import { ChevronRight } from "lucide-react"; -export const AppSidebar = () => ( - - - Lite Kit - - - - - Tools - - - { - tools.map((tool) => ( - - - - {tool.icon} - {tool.name} - - - - )) - } - - - - - - - - -) \ No newline at end of file +export const AppSidebar = () => { + // 递归构建完整路径 + const buildFullPath = (pathSegments: string[]): string => { + return `/tool/${pathSegments.join("/")}`; + }; + + // 递归渲染菜单项 + const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => { + const currentPaths = [...parentPaths, tool.path]; + + if (tool.children) { + // 有子菜单的项目 + return ( + + + + + {tool.icon} + {tool.name} + + + + + + {tool.children.map((child) => ( + + {child.children ? ( + renderMenuItem(child, currentPaths) + ) : ( + + + {child.icon} + {child.name} + + + )} + + ))} + + + + + ); + } + + // 没有子菜单的项目 + return ( + + + + {tool.icon} + {tool.name} + + + + ); + }; + + return ( + + + Lite Kit + + + + Tools + + + {tools.map((tool) => renderMenuItem(tool))} + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/tool/index.tsx b/src/components/tool/index.tsx index 735a1d8..ead16c2 100644 --- a/src/components/tool/index.tsx +++ b/src/components/tool/index.tsx @@ -1,16 +1,18 @@ 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; name: string; icon: ReactNode; description: string; - component: ReactNode; + component?: ReactNode; + children?: Tool[]; } export const tools: Tool[] = [ @@ -34,5 +36,41 @@ export const tools: Tool[] = [ description: "Encode and decode Base64", icon: , component: , - } + }, + { + path: "network", + name: "Network Tools", + description: "Network testing tools", + icon: , + children: [ + { + path: "dns", + name: "DNS Lookup", + description: "DNS query tool", + icon: , + component: , + }, + { + path: "ping", + name: "Ping", + description: "Ping test tool", + icon: , + component: , + }, + { + path: "tcping", + name: "TCPing", + description: "TCP port connectivity test", + icon: , + component: , + }, + { + path: "speedtest", + name: "Speed Test", + description: "Website speed test", + icon: , + component: , + }, + ], + }, ]; \ No newline at end of file diff --git a/src/components/tool/network/dns.tsx b/src/components/tool/network/dns.tsx new file mode 100644 index 0000000..cae5ca1 --- /dev/null +++ b/src/components/tool/network/dns.tsx @@ -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 Address" }, + { value: "28", label: "AAAA", description: "IPv6 Address" }, + { value: "5", label: "CNAME", description: "Canonical Name" }, + { value: "15", label: "MX", description: "Mail Exchange" }, + { value: "2", label: "NS", description: "Name Server" }, + { value: "16", label: "TXT", description: "Text Record" }, + { value: "6", label: "SOA", description: "Start of Authority" }, + { value: "257", label: "CAA", description: "Certification Authority Authorization" }, + { value: "12", label: "PTR", description: "Pointer Record" }, + { value: "33", label: "SRV", description: "Service Record" }, +]; + +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(""); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState([]); + const [queryTime, setQueryTime] = useState(0); + + const queryDNS = async () => { + if (!domain.trim()) { + toast.error("Please enter a domain name"); + return; + } + + setLoading(true); + setResults([]); + setQueryTime(0); + + const startTime = performance.now(); + + try { + // Query all record types concurrently + 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); + + // Merge and deduplicate results + const combinedResults = allResults.flat(); + + if (combinedResults.length > 0) { + // Group by record type and deduplicate + const uniqueResults = Array.from( + new Map( + combinedResults.map((record) => [ + `${record.name}-${record.type}-${record.data}`, + record, + ]) + ).values() + ); + + setResults(uniqueResults); + toast.success(`Query successful, found ${uniqueResults.length} record(s)`); + } else { + setResults([]); + toast.info("No records found"); + } + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(`Query failed: ${error.message}`); + } else { + toast.error("Query failed"); + } + setResults([]); + } finally { + setLoading(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !loading) { + queryDNS(); + } + }; + + return ( +
+
+
+ + setDomain(e.target.value)} + onKeyPress={handleKeyPress} + disabled={loading} + /> + + Will automatically query all DNS record types + +
+ + +
+ + {queryTime > 0 && ( +
+ Query time: {queryTime.toFixed(2)} ms +
+ )} + + {results.length > 0 && ( +
+
Query Results:
+
+ {results.map((record, index) => ( +
+
+
Name:
+
{record.name}
+
Type:
+
{getRecordTypeName(record.type)}
+
TTL:
+
{record.TTL} seconds
+
Data:
+
{record.data}
+
+
+ ))} +
+
+ )} +
+ ); +}; + +export default Tool; + diff --git a/src/components/tool/network/index.tsx b/src/components/tool/network/index.tsx new file mode 100644 index 0000000..380f532 --- /dev/null +++ b/src/components/tool/network/index.tsx @@ -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'; + diff --git a/src/components/tool/network/ping.tsx b/src/components/tool/network/ping.tsx new file mode 100644 index 0000000..378226e --- /dev/null +++ b/src/components/tool/network/ping.tsx @@ -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(""); + const [running, setRunning] = useState(false); + const [results, setResults] = useState([]); + const [stats, setStats] = useState({ + sent: 0, + received: 0, + lost: 0, + min: 0, + max: 0, + avg: 0, + }); + const intervalRef = useRef(null); + const seqRef = useRef(0); + const resultsContainerRef = useRef(null); + + const ping = async () => { + if (!url.trim()) { + toast.error("Please enter a URL"); + return; + } + + const seq = ++seqRef.current; + let targetUrl = url.trim(); + + // If no protocol prefix, default to 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 : "Request failed"; + + 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("Please enter a URL"); + return; + } + + setRunning(true); + setResults([]); + setStats({ + sent: 0, + received: 0, + lost: 0, + min: 0, + max: 0, + avg: 0, + }); + seqRef.current = 0; + + // Execute first ping immediately + ping(); + + // Then execute every second + intervalRef.current = window.setInterval(ping, 1000); + }; + + const stopPing = () => { + setRunning(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + + useEffect(() => { + // Auto-scroll to bottom + if (resultsContainerRef.current) { + resultsContainerRef.current.scrollTop = + resultsContainerRef.current.scrollHeight; + } + }, [results]); + + useEffect(() => { + // Cleanup timer + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, []); + + const lossRate = + stats.sent > 0 ? ((stats.lost / stats.sent) * 100).toFixed(1) : "0.0"; + + return ( +
+
+
+ + setUrl(e.target.value)} + disabled={running} + /> +
+ +
+ {!running ? ( + + ) : ( + + )} +
+
+ + {stats.sent > 0 && ( +
+
Statistics
+
+
Sent:
+
{stats.sent} packets
+
Received:
+
{stats.received} packets
+
Lost:
+
+ {stats.lost} packets ({lossRate}%) +
+ {stats.received > 0 && ( + <> +
Min Latency:
+
{stats.min.toFixed(2)} ms
+
Max Latency:
+
{stats.max.toFixed(2)} ms
+
Avg Latency:
+
{stats.avg.toFixed(2)} ms
+ + )} +
+
+ )} + + {results.length > 0 && ( +
+
Ping Results:
+
+ {results.map((result) => ( +
+ {result.success ? ( + <> + seq={result.seq} time={result.time.toFixed(2)}ms + + ) : ( + <> + seq={result.seq} Request timeout + {result.error && ` (${result.error})`} + + )} +
+ ))} + {running && ( +
+ + Running... +
+ )} +
+
+ )} +
+ ); +}; + +export default Tool; + diff --git a/src/components/tool/network/speedtest.tsx b/src/components/tool/network/speedtest.tsx new file mode 100644 index 0000000..ea1d453 --- /dev/null +++ b/src/components/tool/network/speedtest.tsx @@ -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(""); + const [testType, setTestType] = useState<"performance" | "download" | "upload">( + "performance" + ); + const [testing, setTesting] = useState(false); + const [result, setResult] = useState(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 { + // If no detailed performance data, only return total time + 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("Please enter a URL"); + return; + } + + let targetUrl = url.trim(); + + // If no protocol prefix, default to 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("Performance test completed"); + break; + case "download": + testResult = await testDownloadSpeed(targetUrl); + toast.success("Download speed test completed"); + break; + case "upload": + testResult = await testUploadSpeed(targetUrl); + toast.success("Upload speed test completed"); + break; + } + + setResult(testResult); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(`Test failed: ${error.message}`); + } else { + toast.error("Test failed"); + } + } finally { + setTesting(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !testing) { + startTest(); + } + }; + + return ( +
+
+
+ ⚠️ CORS Restrictions +
+
+

Due to browser CORS security policies, some websites cannot be tested directly.

+

Recommended websites to test:

+
    +
  • Public APIs with CORS support
  • +
  • Your own websites (with configured CORS headers)
  • +
  • Using CORS proxy services
  • +
+
+
+ +
+
+ + setUrl(e.target.value)} + onKeyPress={handleKeyPress} + disabled={testing} + /> +
+ +
+ + +
+ + +
+ + {result && ( +
+
Test Results:
+ + {result.performance && ( +
+
Page Load Performance
+
+ {result.performance.dns > 0 && ( +
+
DNS Lookup:
+
{result.performance.dns.toFixed(2)} ms
+
+ )} + {result.performance.tcp > 0 && ( +
+
TCP Connection:
+
{result.performance.tcp.toFixed(2)} ms
+
+ )} + {result.performance.ssl > 0 && ( +
+
SSL Handshake:
+
{result.performance.ssl.toFixed(2)} ms
+
+ )} + {result.performance.ttfb > 0 && ( +
+
Time to First Byte (TTFB):
+
{result.performance.ttfb.toFixed(2)} ms
+
+ )} + {result.performance.download > 0 && ( +
+
Content Download:
+
{result.performance.download.toFixed(2)} ms
+
+ )} +
+
Total Time:
+
+ {result.performance.total.toFixed(2)} ms +
+
+
+
+ )} + + {result.downloadSpeed !== undefined && ( +
+
Download Speed
+
+ {result.downloadSpeed.toFixed(2)} Mbps +
+
+ {(result.downloadSpeed / 8).toFixed(2)} MB/s +
+
+ )} + + {result.uploadSpeed !== undefined && ( +
+
Upload Speed
+
+ {result.uploadSpeed.toFixed(2)} Mbps +
+
+ {(result.uploadSpeed / 8).toFixed(2)} MB/s +
+
+ )} +
+ )} + + {testType === "download" && ( +
+ Note: Download speed test will download content from the target URL and calculate speed +
+ )} + + {testType === "upload" && ( +
+ Note: Upload speed test will send 1MB of test data to the target URL +
+ )} +
+ ); +}; + +export default Tool; + diff --git a/src/components/tool/network/tcping.tsx b/src/components/tool/network/tcping.tsx new file mode 100644 index 0000000..f94338b --- /dev/null +++ b/src/components/tool/network/tcping.tsx @@ -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(""); + const [port, setPort] = useState("443"); + const [running, setRunning] = useState(false); + const [results, setResults] = useState([]); + const [stats, setStats] = useState({ + sent: 0, + received: 0, + lost: 0, + min: 0, + max: 0, + avg: 0, + }); + const intervalRef = useRef(null); + const seqRef = useRef(0); + const resultsContainerRef = useRef(null); + + const tcping = async () => { + if (!host.trim()) { + toast.error("Please enter a hostname or 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 : "Connection failed"; + + 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("Please enter a hostname or 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 ( +
+
+
+ + setHost(e.target.value)} + disabled={running} + /> +
+ +
+ + setPort(e.target.value)} + disabled={running} + min="1" + max="65535" + /> +
+ +
+ {!running ? ( + + ) : ( + + )} +
+
+ + {stats.sent > 0 && ( +
+
Statistics
+
+
Sent:
+
{stats.sent} times
+
Success:
+
{stats.received} times
+
Failed:
+
+ {stats.lost} times ({lossRate}%) +
+ {stats.received > 0 && ( + <> +
Min Latency:
+
{stats.min.toFixed(2)} ms
+
Max Latency:
+
{stats.max.toFixed(2)} ms
+
Avg Latency:
+
{stats.avg.toFixed(2)} ms
+
Port Status:
+
Open
+ + )} +
+
+ )} + + {results.length > 0 && ( +
+
TCPing Results:
+
+ {results.map((result) => ( +
+ {result.success ? ( + <> + seq={result.seq} port={port} time={result.time.toFixed(2)}ms + + ) : ( + <> + seq={result.seq} port={port} Connection failed + {result.error && ` (${result.error})`} + + )} +
+ ))} + {running && ( +
+ + Running... +
+ )} +
+
+ )} +
+ ); +}; + +export default Tool; + diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..ef6b9f9 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,12 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } + diff --git a/src/index.css b/src/index.css index 3830205..9f52dc8 100644 --- a/src/index.css +++ b/src/index.css @@ -117,4 +117,51 @@ body { @apply bg-background text-foreground; } +} + +/* 自定义滚动条样式 */ +* { + scrollbar-width: thin; + scrollbar-color: oklch(0.551 0.027 264.364 / 0.3) transparent; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: transparent; + border-radius: 4px; +} + +*::-webkit-scrollbar-thumb { + background: oklch(0.551 0.027 264.364 / 0.3); + border-radius: 4px; + transition: background 0.2s ease; +} + +*::-webkit-scrollbar-thumb:hover { + background: oklch(0.551 0.027 264.364 / 0.5); +} + +*::-webkit-scrollbar-thumb:active { + background: oklch(0.551 0.027 264.364 / 0.6); +} + +/* 深色模式下的滚动条 */ +.dark *::-webkit-scrollbar-thumb { + background: oklch(0.707 0.022 261.325 / 0.4); +} + +.dark *::-webkit-scrollbar-thumb:hover { + background: oklch(0.707 0.022 261.325 / 0.6); +} + +.dark *::-webkit-scrollbar-thumb:active { + background: oklch(0.707 0.022 261.325 / 0.7); +} + +.dark * { + scrollbar-color: oklch(0.707 0.022 261.325 / 0.4) transparent; } \ No newline at end of file diff --git a/src/router.tsx b/src/router.tsx index c73043d..0760224 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -2,11 +2,30 @@ import { createBrowserRouter, redirect, RouterProvider, + type RouteObject, } from "react-router-dom"; -import { tools } from "@/components/tool"; +import { tools, type Tool } from "@/components/tool"; import { Layout } from "./layout"; +const buildToolRoutes = (tools: Tool[]): RouteObject[] => { + return tools.map((tool) => { + const route: RouteObject = { + path: tool.path, + }; + + if (tool.component) { + route.element = tool.component; + } + + if (tool.children && tool.children.length > 0) { + route.children = buildToolRoutes(tool.children); + } + + return route; + }); +}; + // 路由配置 const router = createBrowserRouter([ { @@ -16,19 +35,14 @@ const router = createBrowserRouter([ { path: "tool", children: [ - ...tools.map((tool) => ( - { - path: tool.path, - element: tool.component, - } - )), + ...buildToolRoutes(tools), { index: true, loader: () => redirect("/tool/uuid"), }, - ] + ], }, - ] + ], }, { index: true,