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:
typist
2025-10-29 05:37:37 +08:00
parent 27d230e501
commit e9cf714da9
6 changed files with 1107 additions and 2 deletions

View 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;