
用 AI 从零搓一个网页版 PING 工具
从「我需要这个东西」到「能用了」,中间只隔了几轮对话。
起因
前阵子在排查线上服务间歇性超时的问题,需要在多个环境反复监测某个接口的响应状态。试了一圈现有工具——Postman 太重,curl 要开终端,各种桌面端 PING 软件要么装不上要么界面丑。我真正需要的很简单:用浏览器打开文件,每秒刷一次,看到流水日志和状态码就行。
没有现成的好用,那就自己搓一个。既然浏览器里 fetch 就能发请求,为什么不能直接做一个纯前端的 PING 工具?
开工
把需求扔给 AI:输入一个 URL,每秒自动请求一次,流水日志显示响应速度、状态码、时间,支持状态码筛选,能导出 CSV。
没有框架,不用打包,一个 HTML 文件搞定。
AI 几分钟就输出了完整的 index.html——HTML + CSS + JS 全塞一个文件里,深色主题,布局干净:
- 顶部输入框 + 开始/停止按钮
- 高级设置折叠面板(Method、间隔、超时、Headers、Body)
- 统计栏(总计/成功/失败/平均耗时)
- 筛选按钮组(2xx/3xx/4xx/5xx/失败 + 自定义状态码)
- 流水日志区,每条一行,状态码按类别着色
打开浏览器一试,输入 https://www.baidu.com,每秒刷新,日志哗哗出来,完美,期间也出现了不管网址是否正确,都是返回成功,随手把问题丢给AI,一会就修复完毕。
代码如下,文本文档另存为.html格式打开即可,其他功能自测:
HTML代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网页 PING</title>
<style>
:root {
--bg: #1a1a2e;
--card: #16213e;
--accent: #0f3460;
--highlight: #e94560;
--green: #4ecca3;
--yellow: #ffd369;
--red: #ff6b6b;
--text: #e0e0e0;
--muted: #888;
--border: #2a2a4a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
}
.container {
width: 100%;
max-width: 860px;
display: flex;
flex-direction: column;
gap: 16px;
}
.header { text-align: center; margin-bottom: 8px; }
.header h1 { font-size: 1.6rem; font-weight: 700; color: var(--highlight); letter-spacing: 2px; }
.header p { font-size: 0.8rem; color: var(--muted); margin-top: 4px; }
.input-panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.row { display: flex; gap: 10px; align-items: center; }
.url-wrap { flex: 1; position: relative; }
.url-wrap input {
width: 100%;
padding: 10px 14px 10px 36px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
color: var(--text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.url-wrap input:focus { border-color: var(--highlight); }
.url-wrap input::placeholder { color: var(--muted); }
.url-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 0.9rem; opacity: 0.5; }
.btn {
padding: 10px 22px;
border: none;
border-radius: 7px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
white-space: nowrap;
}
.btn:active { transform: scale(0.96); }
.btn-start { background: var(--highlight); color: #fff; }
.btn-start:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-stop { background: var(--accent); color: var(--text); border: 1px solid var(--border); }
.advanced-toggle {
font-size: 0.82rem;
color: var(--muted);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 4px;
}
.advanced-toggle:hover { color: var(--text); }
.advanced-panel { display: none; gap: 10px; flex-wrap: wrap; }
.advanced-panel.open { display: flex; }
.field { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
.field label { font-size: 0.75rem; color: var(--muted); }
.field input, .field select {
padding: 7px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.85rem;
outline: none;
}
.field input:focus, .field select:focus { border-color: var(--highlight); }
.stats-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.stat-chip { background: var(--bg); border: 1px solid var(--border); border-radius: 20px; padding: 4px 14px; font-size: 0.8rem; color: var(--muted); }
.stat-chip strong { color: var(--text); font-weight: 600; }
.stat-chip .ok { color: var(--green); }
.stat-chip .err { color: var(--red); }
.stat-chip .slow { color: var(--yellow); }
.btn-save { background: var(--green); color: #1a1a2e; }
.btn-clear { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.filter-bar { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.filter-btn {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--card);
color: var(--muted);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover { border-color: var(--highlight); color: var(--text); }
.filter-btn.active { background: var(--highlight); border-color: var(--highlight); color: #fff; }
.filter-sep { width: 1px; height: 20px; background: var(--border); margin: 0 2px; }
.custom-filter { display: flex; align-items: center; gap: 6px; }
.custom-filter input {
width: 60px; padding: 4px 8px;
background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 0.8rem;
text-align: center; outline: none;
}
.custom-filter input:focus { border-color: var(--highlight); }
.custom-filter label { font-size: 0.78rem; color: var(--muted); }
.custom-filter button {
padding: 4px 10px; background: var(--accent);
border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-size: 0.78rem; cursor: pointer;
}
.log-container { background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.log-header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px; border-bottom: 1px solid var(--border);
font-size: 0.8rem; color: var(--muted);
}
.log-header .count { color: var(--text); }
.log-list { max-height: 480px; overflow-y: auto; scroll-behavior: smooth; }
.log-list::-webkit-scrollbar { width: 5px; }
.log-list::-webkit-scrollbar-track { background: transparent; }
.log-list::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 3px; }
.log-item {
display: grid;
grid-template-columns: 70px 100px 1fr 70px 70px 44px;
padding: 9px 16px;
border-bottom: 1px solid rgba(255,255,255,0.03);
font-size: 0.82rem;
align-items: center;
transition: background 0.1s;
}
.log-item:hover { background: rgba(255,255,255,0.03); }
.log-item.hidden { display: none; }
.log-item .time { color: var(--muted); font-family: 'Consolas', monospace; font-size: 0.78rem; }
.log-item .url { color: var(--muted); font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.log-item .method { font-weight: 700; font-size: 0.75rem; padding: 2px 7px; border-radius: 4px; text-align: center; width: fit-content; }
.method-GET { background: #1a4d2e; color: var(--green); }
.method-POST { background: #2d3a1a; color: var(--yellow); }
.method-HEAD { background: #1a2a3a; color: #7ec8e3; }
.method-OPTIONS { background: #3a1a2d; color: #e3a17e; }
.method-DELETE { background: #2d1a1a; color: var(--red); }
.method-PUT { background: #1a2d3a; color: #7ec8e3; }
.method-PATCH { background: #2d2a1a; color: var(--yellow); }
.method-default { background: var(--accent); color: var(--text); }
.log-item .status { font-weight: 700; font-size: 0.82rem; text-align: center; }
.status-2xx { color: var(--green); }
.status-3xx { color: #7ec8e3; }
.status-4xx { color: var(--yellow); }
.status-5xx { color: var(--red); }
.status-unknown { color: var(--muted); }
.status-error { color: var(--red); }
.log-item .duration { text-align: right; font-family: 'Consolas', monospace; font-size: 0.78rem; }
.dur-fast { color: var(--green); }
.dur-medium { color: var(--yellow); }
.dur-slow { color: var(--red); }
.log-item .ok-badge { display: inline-block; padding: 1px 5px; border-radius: 4px; font-size: 0.68rem; font-weight: 700; }
.ok-badge-true { background: #1a4d2e; color: var(--green); }
.ok-badge-false { background: #2d1a1a; color: var(--red); }
.empty { text-align: center; padding: 60px 20px; color: var(--muted); font-size: 0.9rem; }
.empty-icon { font-size: 2.5rem; margin-bottom: 10px; opacity: 0.3; }
@media (max-width: 600px) {
.log-item { grid-template-columns: 60px 70px 1fr 60px 50px; }
.log-item .url { display: none; }
.row { flex-wrap: wrap; }
.btn { flex: 1; text-align: center; }
}
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.pulse-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--highlight); animation: pulse-dot 1s ease-in-out infinite; vertical-align: middle; margin-right: 4px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌐 网页 PING</h1>
<p id="statusText">输入地址开始监测</p>
</div>
<div class="input-panel">
<div class="row">
<div class="url-wrap">
<span class="url-icon">🔗</span>
<input type="text" id="urlInput" placeholder="https://www.example.com" value="">
</div>
<button class="btn btn-start" id="startBtn" onclick="startPing()">▶ 开始</button>
<button class="btn btn-stop" id="stopBtn" onclick="stopPing()" disabled>■ 停止</button>
</div>
<div class="advanced-toggle" onclick="toggleAdvanced()">⚙ 高级设置 <span id="advArrow">▼</span></div>
<div class="advanced-panel" id="advancedPanel">
<div class="field">
<label>Method</label>
<select id="methodSelect">
<option value="GET">GET</option>
<option value="HEAD">HEAD</option>
<option value="OPTIONS">OPTIONS</option>
<option value="POST">POST</option>
</select>
</div>
<div class="field">
<label>间隔 (ms)</label>
<input type="number" id="intervalInput" value="1000" min="200" max="60000">
</div>
<div class="field">
<label>超时 (ms)</label>
<input type="number" id="timeoutInput" value="10000" min="500" max="60000">
</div>
<div class="field" style="flex:1; min-width:200px;">
<label>Headers (JSON)</label>
<input type="text" id="headersInput" placeholder='{"Authorization":"Bearer ..."}'>
</div>
<div class="field" style="flex:1; min-width:200px;">
<label>POST Body</label>
<input type="text" id="bodyInput" placeholder='{"key":"value"}'>
</div>
</div>
<div class="row" style="justify-content:space-between;">
<div class="stats-bar">
<span class="stat-chip">总计: <strong id="totalCount">0</strong></span>
<span class="stat-chip">成功: <strong class="ok" id="okCount">0</strong></span>
<span class="stat-chip">失败: <strong class="err" id="errCount">0</strong></span>
<span class="stat-chip">均耗时: <strong id="avgDuration">—</strong></span>
</div>
<div style="display:flex; gap:8px;">
<button class="btn btn-save" onclick="exportCSV()">💾 导出 CSV</button>
<button class="btn btn-clear" onclick="clearLog()">🗑 清空</button>
</div>
</div>
</div>
<div class="filter-bar">
<span style="font-size:0.78rem; color:var(--muted); margin-right:4px;">筛选:</span>
<button class="filter-btn active" data-filter="all" onclick="setFilter('all', this)">全部</button>
<button class="filter-btn" data-filter="2xx" onclick="setFilter('2xx', this)">2xx</button>
<button class="filter-btn" data-filter="3xx" onclick="setFilter('3xx', this)">3xx</button>
<button class="filter-btn" data-filter="4xx" onclick="setFilter('4xx', this)">4xx</button>
<button class="filter-btn" data-filter="5xx" onclick="setFilter('5xx', this)">5xx</button>
<button class="filter-btn" data-filter="error" onclick="setFilter('error', this)">失败</button>
<button class="filter-btn" data-filter="unreachable" onclick="setFilter('unreachable', this)">不可达</button>
<div class="filter-sep"></div>
<div class="custom-filter">
<label>精确:</label>
<input type="text" id="customCodeInput" placeholder="200" maxlength="8" onkeydown="if(event.key==='Enter')applyCustomCode()">
<button onclick="applyCustomCode()">应用</button>
</div>
</div>
<div class="log-container">
<div class="log-header">
<span>💧 流水日志</span>
<span>显示 <span class="count" id="visibleCount">0</span> / <span class="count" id="totalLogCount">0</span> 条</span>
</div>
<div class="log-list" id="logList">
<div class="empty" id="emptyState">
<div class="empty-icon">📡</div>
<div>输入网址后点击开始,将在此显示访问日志</div>
</div>
</div>
</div>
</div>
<script>
'use strict';
// ========== 状态 ==========
let logs = [];
let isRunning = false;
let timer = null;
let filterMode = 'all';
let customCode = null;
let totalReq = 0, okReq = 0, errReq = 0;
let durations = [];
// ========== DOM refs ==========
const urlInput = document.getElementById('urlInput');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const statusText = document.getElementById('statusText');
const logList = document.getElementById('logList');
const emptyState = document.getElementById('emptyState');
const methodSelect = document.getElementById('methodSelect');
const intervalInput = document.getElementById('intervalInput');
const timeoutInput = document.getElementById('timeoutInput');
const headersInput = document.getElementById('headersInput');
const bodyInput = document.getElementById('bodyInput');
// ========== 工具函数 ==========
function formatTime() {
const d = new Date();
return d.toTimeString().slice(0, 8);
}
function getHost(url) {
try { return new URL(url).host; }
catch { return url; }
}
function statusClassFor(code) {
if (code === null || code === undefined) return 'status-unknown';
if (code === 'UNREACHABLE' || code === 'URL_ERR' || code === 'ERROR') return 'status-error';
if (code === 'TIMEOUT') return 'status-error';
const n = typeof code === 'number' ? code : parseInt(code);
if (isNaN(n)) return 'status-unknown';
if (n >= 200 && n < 300) return 'status-2xx';
if (n >= 300 && n < 400) return 'status-3xx';
if (n >= 400 && n < 500) return 'status-4xx';
if (n >= 500) return 'status-5xx';
return 'status-unknown';
}
function durClass(ms) {
if (ms === null || ms === undefined) return '';
if (ms < 300) return 'dur-fast';
if (ms < 1000) return 'dur-medium';
return 'dur-slow';
}
function methodClass(m) {
const map = {
'GET': 'method-GET', 'POST': 'method-POST', 'HEAD': 'method-HEAD',
'OPTIONS': 'method-OPTIONS', 'DELETE': 'method-DELETE',
'PUT': 'method-PUT', 'PATCH': 'method-PATCH'
};
return map[m] || 'method-default';
}
// ========== 高级设置 ==========
function toggleAdvanced() {
const panel = document.getElementById('advancedPanel');
const arrow = document.getElementById('advArrow');
panel.classList.toggle('open');
arrow.textContent = panel.classList.contains('open') ? '▲' : '▼';
}
// ========== 核心:探测 + 请求 ==========
function doPing() {
const rawUrl = urlInput.value.trim();
if (!rawUrl) return;
let targetUrl = rawUrl;
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = 'https://' + targetUrl;
let parsedUrl;
try { parsedUrl = new URL(targetUrl); }
catch {
onResult({ status: 'URL_ERR', duration: 0, ok: false });
return;
}
const method = methodSelect.value;
const timeout = parseInt(timeoutInput.value) || 10000;
let headers = {};
try {
if (headersInput.value.trim()) headers = JSON.parse(headersInput.value.trim());
} catch (e) { /* ignore */ }
const body = bodyInput.value.trim() || undefined;
const startTime = performance.now();
totalReq++;
// Step 1: 用 <img> 探测域名可达性(DNS 失败 / 主机不可达在 onerror 立即触发)
const img = new Image();
let step2Done = false;
const imgTimeout = setTimeout(() => {
if (step2Done) return;
img.src = '';
// 2s 内图片未触发 onload → 域名不可达
img.onload = img.onerror = null;
doFetch(null); // domain unreachable
}, 2000);
img.onload = function () {
if (step2Done) return;
clearTimeout(imgTimeout);
img.onload = img.onerror = null;
doFetch(true);
};
img.onerror = function () {
if (step2Done) return;
clearTimeout(imgTimeout);
img.onload = img.onerror = null;
// 域名不可达/DNS 失败/连接拒绝
doFetch(null);
};
// 用 favicon 做轻量探测(即使 404 也算可达)
img.src = parsedUrl.origin + '/favicon.ico?_=' + Date.now();
// Step 2: fetch
function doFetch(domainOk) {
if (step2Done) return;
step2Done = true;
if (!domainOk) {
// 域名不可达
const ms = Math.round(performance.now() - startTime);
onResult({ status: 'UNREACHABLE', duration: ms, ok: false });
return;
}
// 域名可达,发送真实请求
const controller = new AbortController();
const fetchTimeout = setTimeout(() => controller.abort(), timeout);
const fetchOpts = {
method,
headers,
mode: 'no-cors',
signal: controller.signal,
cache: 'no-store',
};
if (method !== 'GET' && method !== 'HEAD') fetchOpts.body = body;
fetch(targetUrl, fetchOpts)
.then(function () {
clearTimeout(fetchTimeout);
const ms = Math.round(performance.now() - startTime);
// no-cors: 域名已验可达,fetch 成功即成功
onResult({ status: null, duration: ms, ok: true });
})
.catch(function (err) {
clearTimeout(fetchTimeout);
const ms = Math.round(performance.now() - startTime);
if (err.name === 'AbortError') {
onResult({ status: 'TIMEOUT', duration: ms, ok: false });
} else {
onResult({ status: 'ERROR', duration: ms, ok: false });
}
});
}
}
// ========== 记录结果 ==========
function onResult(data) {
const { status, duration, ok } = data;
if (ok) okReq++;
else errReq++;
if (ok && duration !== null) durations.push(duration);
const rawUrl = urlInput.value.trim();
let targetUrl = rawUrl;
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = 'https://' + targetUrl;
const displayStatus = (status === 'URL_ERR') ? 'URL错误'
: (status === 'UNREACHABLE') ? '不可达'
: (status === 'TIMEOUT') ? '超时'
: (status === 'ERROR') ? '请求失败'
: (status !== null ? String(status) : '???');
const entry = {
time: formatTime(),
host: getHost(targetUrl),
method: methodSelect.value,
status: displayStatus,
rawStatus: status, // null | 'UNREACHABLE' | 'TIMEOUT' | 'ERROR' | 'URL_ERR' | number
duration,
ok,
id: Date.now() + Math.random(),
};
logs.unshift(entry);
if (logs.length > 1000) logs = logs.slice(0, 1000);
renderItem(entry);
updateStats();
updateVisibleCount();
// 最新条目在最上,自动滚到顶部
if (logList.scrollHeight - logList.scrollTop - logList.clientHeight < 80) {
logList.scrollTop = 0;
}
}
// ========== 渲染单条 ==========
function renderItem(entry) {
emptyState.style.display = 'none';
const div = document.createElement('div');
div.className = 'log-item';
div.dataset.id = entry.id;
// 状态颜色
const sc = statusClassFor(entry.rawStatus);
// 筛选用的代码:不可达类/超时/ERROR → 字符串;真实码 → 数字字符串
const filterCode = (entry.rawStatus === 'UNREACHABLE' || entry.rawStatus === 'URL_ERR' || entry.rawStatus === 'ERROR')
? entry.status
: (entry.rawStatus === 'TIMEOUT' ? 'TIMEOUT' : String(entry.rawStatus ?? '???'));
div.dataset.code = filterCode;
div.dataset.ok = entry.ok;
div.innerHTML =
'<span class="time">' + entry.time + '</span>' +
'<span class="url" title="'+ entry.host + '">' + entry.host + '</span>' +
'<span class="method ' + methodClass(entry.method) + '">' + entry.method + '</span>' +
'<span class="status ' + sc + '">' + entry.status + '</span>' +
'<span class="duration '+ durClass(entry.duration) + '">' + (entry.duration !== null ? entry.duration + 'ms' : '—') + '</span>' +
'<span class="ok-badge ok-badge-' + entry.ok + '">' + (entry.ok ? 'OK' : 'ERR') + '</span>';
logList.insertBefore(div, logList.firstChild);
applyFilterToItem(div);
}
// ========== 统计 ==========
function updateStats() {
document.getElementById('totalCount').textContent = totalReq;
document.getElementById('okCount').textContent = okReq;
document.getElementById('errCount').textContent = errReq;
if (durations.length > 0) {
const avg = Math.round(durations.reduce(function(a,b){return a+b;}, 0) / durations.length);
document.getElementById('avgDuration').textContent = avg + 'ms';
}
}
function updateVisibleCount() {
const visible = logList.querySelectorAll('.log-item:not(.hidden)').length;
document.getElementById('visibleCount').textContent = visible;
document.getElementById('totalLogCount').textContent = logs.length;
}
// ========== 筛选 ==========
function setFilter(mode, btn) {
filterMode = mode;
customCode = null;
document.getElementById('customCodeInput').value = '';
document.querySelectorAll('.filter-btn').forEach(function(b){ b.classList.remove('active'); });
if (btn) btn.classList.add('active');
applyFilter();
}
function applyCustomCode() {
const val = document.getElementById('customCodeInput').value.trim();
if (!val) { setFilter('all', document.querySelector('[data-filter="all"]')); return; }
customCode = val;
filterMode = 'custom';
document.querySelectorAll('.filter-btn').forEach(function(b){ b.classList.remove('active'); });
applyFilter();
}
function applyFilter() {
logList.querySelectorAll('.log-item').forEach(applyFilterToItem);
updateVisibleCount();
}
function applyFilterToItem(div) {
const code = div.dataset.code; // 'UNREACHABLE'|'TIMEOUT'|number string|'???'
const ok = div.dataset.ok === 'true';
let show = false;
if (filterMode === 'all') {
show = true;
} else if (filterMode === 'error') {
show = !ok;
} else if (filterMode === 'unreachable') {
show = (code === '不可达' || code === 'URL错误' || code === '请求失败');
} else if (filterMode === '2xx') {
const n = parseInt(code);
show = !isNaN(n) && n >= 200 && n < 300;
} else if (filterMode === '3xx') {
const n = parseInt(code);
show = !isNaN(n) && n >= 300 && n < 400;
} else if (filterMode === '4xx') {
const n = parseInt(code);
show = !isNaN(n) && n >= 400 && n < 500;
} else if (filterMode === '5xx') {
const n = parseInt(code);
show = !isNaN(n) && n >= 500 && n < 600;
} else if (filterMode === 'custom' && customCode !== null) {
show = (code === customCode || code === '不可达' && customCode === 'UNREACHABLE');
}
div.classList.toggle('hidden', !show);
}
// ========== 开始/停止 ==========
function startPing() {
const raw = urlInput.value.trim();
if (!raw) { urlInput.focus(); return; }
let target = raw;
if (!/^https?:\/\//i.test(target)) target = 'https://' + target;
try { new URL(target); }
catch { alert('URL 格式不正确'); return; }
isRunning = true;
startBtn.disabled = true;
stopBtn.disabled = false;
statusText.innerHTML = '<span class="pulse-dot"></span>监测中…';
doPing();
const interval = parseInt(intervalInput.value) || 1000;
timer = setInterval(doPing, interval);
}
function stopPing() {
isRunning = false;
clearInterval(timer);
timer = null;
startBtn.disabled = false;
stopBtn.disabled = true;
statusText.textContent = '已停止 — 累计 ' + logs.length + ' 条记录';
}
function clearLog() {
if (isRunning) return;
logs = [];
totalReq = okReq = errReq = 0;
durations = [];
logList.innerHTML = '';
logList.appendChild(emptyState);
emptyState.style.display = '';
updateStats();
updateVisibleCount();
statusText.textContent = '输入地址开始监测';
}
// ========== 导出 CSV ==========
function exportCSV() {
if (logs.length === 0) { alert('没有日志可导出'); return; }
const rows = logs.map(function(l) {
var desc = '';
if (l.rawStatus === 'UNREACHABLE') desc = '域名不可达/DNS失败';
else if (l.rawStatus === 'URL_ERR') desc = 'URL格式错误';
else if (l.rawStatus === 'TIMEOUT') desc = '请求超时';
else if (l.rawStatus === 'ERROR') desc = '网络错误';
else if (l.rawStatus === null) desc = 'no-cors无状态码';
return [l.time, l.host, l.method, l.status, l.duration !== null ? l.duration : '—', l.ok ? '是' : '否', desc].join(',');
});
var csv = '\uFEFF时间,域名,方法,状态,耗时,成功,说明\n' + rows.join('\n');
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'ping_' + new Date().toISOString().slice(0,19).replace(/:/g,'-') + '.csv';
a.click();
URL.revokeObjectURL(a.href);
}
// ========== URL 参数预填 ==========
(function() {
var params = new URLSearchParams(location.search);
var u = params.get('url');
if (u) urlInput.value = decodeURIComponent(u);
})();
</script>
</body>
</html>
关于状态码:网页直接 fetch 受跨域限制,真实状态码只有目标服务器配置了 CORS 才能读到。未配置 CORS 时统一显示 ???,以耗时判断成功/失败。当前代码默认用 no-cors 模式以支持任意 URL,如需读真实状态码可切换到 mode:'cors' 并在高级设置里填入对应 Header。