用 AI 从零搓一个网页版 PING 工具

用 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。

上一篇 微信小程序开发实战「瞬译码」:二维码工具设计与实现
2026年4月
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
加载中...