<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SpeedTest · 网速测试 - lmve.net</title>
<style>
  :root {
    --bg: #0b1020;
    --card: rgba(255, 255, 255, 0.045);
    --border: rgba(255, 255, 255, 0.08);
    --text: #e8ecf6;
    --muted: #8b93a7;
    --accent1: #00d4ff;
    --accent2: #7c5cff;
    --green: #2ee6a8;
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: radial-gradient(1200px 600px at 50% -100px, #17203f 0%, var(--bg) 60%) fixed, var(--bg);
    color: var(--text);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
    min-height: 100vh;
  }
  .wrap { max-width: 860px; margin: 0 auto; padding: 32px 16px 60px; }
  header { text-align: center; margin-bottom: 28px; }
  .logo {
    font-size: 30px; font-weight: 800; letter-spacing: 0.5px;
    background: linear-gradient(90deg, var(--accent1), var(--accent2));
    -webkit-background-clip: text; background-clip: text; color: transparent;
  }
  .sub { color: var(--muted); font-size: 13px; margin-top: 6px; }
  .myip {
    display: inline-block; margin-top: 10px; padding: 5px 14px; font-size: 13px;
    color: var(--accent1); background: rgba(0, 212, 255, 0.08);
    border: 1px solid rgba(0, 212, 255, 0.25); border-radius: 999px;
  }
  .myip.private { color: #ffb45e; background: rgba(255, 180, 94, 0.08); border-color: rgba(255, 180, 94, 0.3); }

  .card {
    background: var(--card);
    border: 1px solid var(--border);
    border-radius: 18px;
    backdrop-filter: blur(8px);
  }

  /* ---------- 仪表盘 ---------- */
  .gauge-card { position: relative; padding: 10px 20px 28px; text-align: center; }
  .gauge-box { position: relative; width: 360px; height: 300px; margin: 0 auto; }
  #gauge { width: 360px; height: 300px; }
  .gauge-center {
    position: absolute; left: 0; right: 0; top: 96px; text-align: center; pointer-events: none;
  }
  #gaugeValue { font-size: 44px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -1px; }
  #gaugeUnit { color: var(--muted); font-size: 14px; margin-top: 2px; }
  #phaseLabel { color: var(--muted); font-size: 14px; margin-top: 6px; min-height: 20px; }
  #startBtn {
    margin-top: 18px; padding: 13px 56px; font-size: 17px; font-weight: 700; color: #06121f;
    background: linear-gradient(90deg, var(--accent1), var(--accent2));
    border: none; border-radius: 999px; cursor: pointer; transition: transform .12s, filter .2s, opacity .2s;
    box-shadow: 0 8px 28px rgba(0, 212, 255, 0.25);
  }
  #startBtn:hover { transform: translateY(-1px); filter: brightness(1.08); }
  #startBtn:disabled { opacity: .55; cursor: not-allowed; transform: none; }

  /* ---------- 统计卡片 ---------- */
  .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 18px; }
  .stat {
    background: var(--card); border: 1px solid var(--border); border-radius: 14px;
    padding: 16px 10px; text-align: center;
  }
  .stat .label { color: var(--muted); font-size: 12px; }
  .stat .value { font-size: 26px; font-weight: 700; margin-top: 6px; font-variant-numeric: tabular-nums; }
  .stat .unit { color: var(--muted); font-size: 12px; margin-top: 2px; }
  .stat.done { border-color: rgba(46, 230, 168, 0.35); }

  /* ---------- 排行榜 ---------- */
  .rank-card { margin-top: 18px; padding: 22px 22px 10px; }
  .rank-head { display: flex; align-items: baseline; justify-content: space-between; }
  .rank-head h2 { font-size: 18px; }
  .total { color: var(--muted); font-size: 13px; }
  th {
    text-align: left; color: var(--muted); font-size: 12px; font-weight: 500;
    padding: 8px 10px; border-bottom: 1px solid var(--border);
  }
  th.sortable { cursor: pointer; user-select: none; transition: color .15s; white-space: nowrap; }
  th.sortable:hover { color: var(--text); }
  th.sortable.active { color: var(--accent1); }
  .arrow { font-size: 11px; }
  table { width: 100%; border-collapse: collapse; margin-top: 10px; }
  td { padding: 11px 10px; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.04); font-variant-numeric: tabular-nums; }
  tr:last-child td { border-bottom: none; }
  tr.mine { background: rgba(0, 212, 255, 0.10); box-shadow: inset 3px 0 0 var(--accent1); }
  tr.mine td { border-bottom-color: rgba(0, 212, 255, 0.12); }
  .rank-no {
    display: inline-flex; align-items: center; justify-content: center;
    width: 24px; height: 24px; border-radius: 8px; font-size: 12px; font-weight: 700;
    background: rgba(255,255,255,0.07); color: var(--muted);
  }
  .rank-no.top1 { background: linear-gradient(135deg, #ffd76a, #ff9d3c); color: #3a2500; }
  .rank-no.top2 { background: linear-gradient(135deg, #e8ecf6, #aab4cc); color: #232a3d; }
  .rank-no.top3 { background: linear-gradient(135deg, #f0b08c, #c87a4a); color: #3d1d08; }
  .mine-tag {
    display: inline-block; margin-left: 8px; padding: 1px 8px; font-size: 11px; font-weight: 700;
    color: #06121f; background: linear-gradient(90deg, var(--accent1), var(--accent2));
    border-radius: 999px; vertical-align: 1px;
  }
  .val-dl { font-weight: 700; color: var(--accent1); }
  .val-up { font-weight: 700; color: var(--accent2); }
  .val-la { font-weight: 700; color: var(--green); }
  .unit-sm { color: var(--muted); font-size: 11px; font-weight: 400; }
  .loc { font-size: 13px; }
  .ua { color: var(--muted); font-size: 12px; margin-top: 2px; }
  .time { color: var(--muted); font-size: 12px; }
  .empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; }
  .rank-hint { text-align: center; color: var(--muted); font-size: 12px; padding: 12px 0 8px; }

  footer { text-align: center; color: var(--muted); font-size: 12px; margin-top: 26px; line-height: 1.8; }
  footer a { color: var(--accent1); text-decoration: none; }

  @media (max-width: 640px) {
    .stats { grid-template-columns: repeat(2, 1fr); }
    .gauge-box { width: 320px; height: 270px; }
    #gauge { width: 320px; height: 270px; }
    .gauge-center { top: 84px; }
  }
</style>
</head>
<body>
<div class="wrap">
  <header>
    <div class="logo">⚡ SpeedTest</div>
    <div class="sub">speedtest.lmve.net · lmve.net 网速测试服务</div>
    <div id="myIP" class="myip" hidden></div>
  </header>

  <main>
    <!-- 仪表盘 -->
    <section class="card gauge-card">
      <div class="gauge-box">
        <canvas id="gauge" width="360" height="300"></canvas>
        <div class="gauge-center">
          <div id="gaugeValue">--</div>
          <div id="gaugeUnit">Mbps</div>
        </div>
      </div>
      <div id="phaseLabel">点击下方按钮开始测速（下载/上传各约 10 秒）</div>
      <button id="startBtn">开始测速</button>
    </section>

    <!-- 结果统计 -->
    <section class="stats">
      <div class="stat" id="cardLatency">
        <div class="label">延迟</div><div class="value" id="statLatency">--</div><div class="unit">ms</div>
      </div>
      <div class="stat" id="cardJitter">
        <div class="label">抖动</div><div class="value" id="statJitter">--</div><div class="unit">ms</div>
      </div>
      <div class="stat" id="cardDownload">
        <div class="label">下载速度</div><div class="value" id="statDownload">--</div><div class="unit">Mbps</div>
      </div>
      <div class="stat" id="cardUpload">
        <div class="label">上传速度</div><div class="value" id="statUpload">--</div><div class="unit">Mbps</div>
      </div>
    </section>

    <!-- 排行榜 -->
    <section class="card rank-card">
      <div class="rank-head">
        <h2>🏆 测速排行</h2>
        <span class="total" id="totalCount"></span>
      </div>
      <table id="rankTable">
        <thead>
          <tr>
            <th style="width:56px">#</th>
            <th>用户</th>
            <th class="sortable" data-field="latency">延迟 <span class="arrow"></span></th>
            <th class="sortable active" data-field="download">下载 <span class="arrow">↓</span></th>
            <th class="sortable" data-field="upload">上传 <span class="arrow"></span></th>
            <th class="sortable" data-field="created">时间 <span class="arrow"></span></th>
          </tr>
        </thead>
        <tbody id="rankBody"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
      </table>
      <div class="rank-hint">点击列头排序 · 你的成绩自动高亮</div>
    </section>
  </main>

  <footer>
    SpeedTest · Go + Gin + SQLite · 由 Caddy 反代提供 HTTPS<br>
    每次测速结果自动入库参与排行 · 下载/上传各固定测速 10 秒
  </footer>
</div>

<script>
"use strict";

/* ================= 元素引用 ================= */
const gauge = document.getElementById('gauge');
const gaugeValue = document.getElementById('gaugeValue');
const gaugeUnit = document.getElementById('gaugeUnit');
const phaseLabel = document.getElementById('phaseLabel');
const startBtn = document.getElementById('startBtn');
const statLatency = document.getElementById('statLatency');
const statJitter = document.getElementById('statJitter');
const statDownload = document.getElementById('statDownload');
const statUpload = document.getElementById('statUpload');

let running = false;
let gaugeMode = 'speed';   // speed | latency

/* ================= 仪表盘 ================= */
function drawGauge(frac) {
  const ctx = gauge.getContext('2d');
  const W = gauge.width, H = gauge.height;
  ctx.clearRect(0, 0, W, H);
  const cx = W / 2, cy = H / 2 + 18, r = 118;
  const start = Math.PI * 0.75, end = Math.PI * 2.25; // 270°
  const sweep = end - start;

  // 轨道
  ctx.lineWidth = 16; ctx.lineCap = 'round';
  ctx.strokeStyle = 'rgba(255,255,255,0.07)';
  ctx.beginPath(); ctx.arc(cx, cy, r, start, end); ctx.stroke();

  // 进度（渐变）
  const grad = ctx.createLinearGradient(0, 0, W, H);
  grad.addColorStop(0, '#00d4ff');
  grad.addColorStop(1, '#7c5cff');
  ctx.strokeStyle = grad;
  ctx.beginPath(); ctx.arc(cx, cy, r, start, start + sweep * Math.min(1, Math.max(0, frac))); ctx.stroke();

  // 刻度
  ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(255,255,255,0.25)';
  for (let i = 0; i <= 10; i++) {
    const a = start + sweep * i / 10;
    ctx.beginPath();
    ctx.moveTo(cx + Math.cos(a) * (r - 28), cy + Math.sin(a) * (r - 28));
    ctx.lineTo(cx + Math.cos(a) * (r - 34), cy + Math.sin(a) * (r - 34));
    ctx.stroke();
  }

  // 指针
  const a = start + sweep * Math.min(1, Math.max(0, frac));
  ctx.strokeStyle = '#fff'; ctx.lineWidth = 3;
  ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a) * (r - 40), cy + Math.sin(a) * (r - 40)); ctx.stroke();
  ctx.fillStyle = '#fff';
  ctx.beginPath(); ctx.arc(cx, cy, 7, 0, Math.PI * 2); ctx.fill();
}

function speedFrac(mbps) {
  // 对数刻度：1 ~ 1000 Mbps
  return Math.log10(1 + mbps) / Math.log10(1001);
}

function updateGaugeSpeed(mbps) {
  gaugeMode = 'speed';
  gaugeValue.textContent = (mbps >= 100 ? mbps.toFixed(0) : mbps.toFixed(1));
  gaugeUnit.textContent = 'Mbps';
  drawGauge(speedFrac(mbps));
}

function updateGaugeLatency(ms) {
  gaugeMode = 'latency';
  gaugeValue.textContent = ms.toFixed(0);
  gaugeUnit.textContent = 'ms';
  drawGauge(Math.min(1, 100 / Math.max(1, ms)));
}

function setPhase(text) {
  phaseLabel.textContent = text;
}

/* ================= 延迟测试 ================= */
async function pingOnce() {
  const t0 = performance.now();
  await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' });
  return performance.now() - t0;
}

async function pingTest(n) {
  const rtts = [];
  for (let i = 0; i < n; i++) {
    const t = await pingOnce();
    rtts.push(t);
    const mid = [...rtts].sort((a, b) => a - b)[Math.floor(rtts.length / 2)];
    updateGaugeLatency(mid);
    if (i < n - 1) await new Promise(r => setTimeout(r, 80));
  }
  rtts.sort((a, b) => a - b);
  const latency = rtts[Math.floor(rtts.length / 2)];          // 中位数
  const jitter = rtts.reduce((s, v) => s + Math.abs(v - latency), 0) / rtts.length; // 平均绝对偏差
  return { latency, jitter };
}

/* ================= 下载测试 ================= */
async function downloadOnce(sizeBytes) {
  const t0 = performance.now();
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 60000);
  let resp;
  try {
    resp = await fetch('/api/download?size=' + sizeBytes + '&t=' + Date.now(), { cache: 'no-store', signal: ctrl.signal });
  } finally {
    clearTimeout(timer);
  }
  if (!resp.ok || !resp.body) throw new Error('下载测试请求失败');
  const reader = resp.body.getReader();
  let received = 0, lastT = t0, lastBytes = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    received += value.length;
    const now = performance.now();
    if (now - lastT > 200) {
      updateGaugeSpeed(((received - lastBytes) * 8) / ((now - lastT) / 1000) / 1e6);
      lastT = now; lastBytes = received;
    }
  }
  const ms = performance.now() - t0;
  return { mbps: (received * 8) / (ms / 1000) / 1e6, ms };
}

/* ================= 上传测试 ================= */
let prngState = (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0;
function randomBytes(size) {
  const u8 = new Uint8Array(size);
  let x = prngState;
  for (let i = 0; i < size; i += 4) {
    x ^= x << 13; x >>>= 0; x ^= x >>> 17; x ^= x << 5; x >>>= 0;
    u8[i] = x & 0xff;
    if (i + 1 < size) u8[i + 1] = (x >>> 8) & 0xff;
    if (i + 2 < size) u8[i + 2] = (x >>> 16) & 0xff;
    if (i + 3 < size) u8[i + 3] = (x >>> 24) & 0xff;
  }
  prngState = x;
  return u8;
}

function uploadOnce(sizeBytes) {
  return new Promise((resolve, reject) => {
    const data = randomBytes(sizeBytes);
    const xhr = new XMLHttpRequest();
    const t0 = performance.now();
    let lastT = t0, lastBytes = 0;
    xhr.upload.onprogress = (e) => {
      if (!e.lengthComputable) return;
      const now = performance.now();
      if (now - lastT > 200) {
        updateGaugeSpeed(((e.loaded - lastBytes) * 8) / ((now - lastT) / 1000) / 1e6);
        lastT = now; lastBytes = e.loaded;
      }
    };
    xhr.onload = () => {
      const ms = performance.now() - t0;
      resolve({ mbps: (sizeBytes * 8) / (ms / 1000) / 1e6, ms });
    };
    xhr.onerror = () => reject(new Error('上传测试请求失败'));
    xhr.ontimeout = () => reject(new Error('上传测试超时'));
    xhr.timeout = 60000;
    xhr.open('POST', '/api/upload?t=' + Date.now());
    xhr.setRequestHeader('Content-Type', 'application/octet-stream');
    xhr.send(data);
  });
}

/* ================= 自适应测速阶段 =================
 * 每个阶段（下载/上传）固定测 10 秒：负载根据上一轮实测速率自适应，
 * 让每轮约 2 秒。快速连接自动加大负载填满 10 秒；慢速连接用小负载
 * 也能在 10 秒内完成，不会因为网速慢而拖长测试。
 */
const MB = 1048576;
const PHASE_DURATION_MS = 10000;
const ROUND_TARGET_MS = 2000;
const MIN_BYTES = 256 * 1024; // 0.25 MB，照顾慢速连接

async function runPhase(runOnce, maxBytes, label) {
  const results = [];
  const phaseStart = performance.now();
  let size = MIN_BYTES;
  while (performance.now() - phaseStart < PHASE_DURATION_MS) {
    setPhase(label + ' ' + (size / MB).toFixed(size < 10 * MB ? 1 : 0) + ' MB …');
    const r = await runOnce(size);
    results.push(r.mbps);

    const elapsed = performance.now() - phaseStart;
    if (elapsed >= PHASE_DURATION_MS) break;
    const remaining = PHASE_DURATION_MS - elapsed;

    // 下一轮负载：按实测速率取约 2 秒的数据量
    const rateBps = (r.mbps * 1e6) / 8; // bytes/s
    let next = Math.max(MIN_BYTES, Math.min(Math.round((rateBps * ROUND_TARGET_MS) / 1000), maxBytes));
    // 速率没让负载变大时翻倍推进，避免原地踏步
    if (next <= size && r.ms < ROUND_TARGET_MS) next = Math.min(size * 2, maxBytes);
    // 若下一轮按估算会跑完剩余时间，把负载裁剪到恰好填满剩余时间（收尾轮）
    const nextEstMs = (next * 1000) / Math.max(rateBps, 1);
    if (nextEstMs > remaining) {
      next = Math.round((rateBps * remaining) / 1000);
      if (next < 1) break;
    }
    size = next;
  }
  return results.length ? Math.max(...results) : 0;
}

async function downloadTest() {
  return runPhase(downloadOnce, 400 * MB, '下载测速中');
}

async function uploadTest() {
  return runPhase(uploadOnce, 256 * MB, '上传测速中');
}

/* ================= IP 展示 =================
 * 路由器已配置单向伪装，服务器直接看到真实公网 IP（X-Real-IP），
 * 无需前端探测外部服务。内网 IP 仅出现在未配置公网直连的 NAT 场景。
 */
function isPrivateIP(ip) {
  if (!ip) return false;
  if (ip.startsWith('127.') || ip.startsWith('10.') || ip.startsWith('192.168.')) return true;
  if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
  if (ip.startsWith('169.254.')) return true;
  const lower = ip.toLowerCase();
  if (lower === '::1' || lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return true;
  return false;
}

function showMyIP(ip, location) {
  const el = document.getElementById('myIP');
  if (!ip) { el.hidden = true; return; }
  el.hidden = false;
  if (isPrivateIP(ip)) {
    el.textContent = '你的 IP: ' + ip + '（内网 / NAT 环境）';
    el.className = 'myip private';
  } else {
    el.textContent = '你的公网 IP: ' + ip + (location ? ' · ' + location : '');
    el.className = 'myip';
  }
}

/* ================= 结果提交 ================= */
async function submitResult(r) {
  const resp = await fetch('/api/result', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      latency_ms: +r.latency.toFixed(2),
      jitter_ms: +r.jitter.toFixed(2),
      download_mbps: +r.download.toFixed(2),
      upload_mbps: +r.upload.toFixed(2),
    }),
  });
  if (!resp.ok) throw new Error('结果提交失败');
  return resp.json();
}

/* ================= 排行榜 ================= */
const rankBody = document.getElementById('rankBody');
const totalCount = document.getElementById('totalCount');

// 排序状态：默认按下载降序；点击列头切换字段，再点切换方向
let sortField = 'download';
let sortOrder = 'desc';
// 我的成绩 ID（localStorage 持久化，刷新后仍可高亮）
let myId = parseInt(localStorage.getItem('speedtest_my_id') || '0', 10) || 0;

async function loadRankings() {
  try {
    const url = '/api/rankings?sort=' + sortField + '&order=' + sortOrder + (myId ? '&include_id=' + myId : '');
    const resp = await fetch(url, { cache: 'no-store' });
    const data = await resp.json();
    totalCount.textContent = '共 ' + data.total + ' 次测试';
    renderRanking(data.items || []);
  } catch (e) {
    totalCount.textContent = '排行榜加载失败';
  }
}

function renderRanking(rows) {
  if (!rows.length) {
    rankBody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据，快来测速抢占榜首！</td></tr>';
    return;
  }
  rankBody.innerHTML = rows.map(r => {
    const cls = r.rank === 1 ? 'top1' : r.rank === 2 ? 'top2' : r.rank === 3 ? 'top3' : '';
    // 用户信息：地区 + 浏览器（不展示任何 IP，IP 仅后台保存）
    const user = '<div class="loc">' + (r.location || '未知地区') + '</div>' +
      '<div class="ua">' + (r.browser || '未知设备') + '</div>';
    return '<tr' + (r.is_mine ? ' class="mine"' : '') + '>' +
      '<td><span class="rank-no ' + cls + '">' + r.rank + '</span></td>' +
      '<td>' + user + (r.is_mine ? '<span class="mine-tag">我的成绩</span>' : '') + '</td>' +
      '<td class="val-la">' + r.latency_ms.toFixed(1) + ' <span class="unit-sm">ms</span></td>' +
      '<td class="val-dl">' + r.download_mbps.toFixed(2) + ' <span class="unit-sm">Mbps</span></td>' +
      '<td class="val-up">' + r.upload_mbps.toFixed(2) + ' <span class="unit-sm">Mbps</span></td>' +
      '<td class="time">' + r.created_at + '</td>' +
      '</tr>';
  }).join('');
}

function updateSortHeaders() {
  document.querySelectorAll('th.sortable').forEach(th => {
    const active = th.dataset.field === sortField;
    th.classList.toggle('active', active);
    th.querySelector('.arrow').textContent = active ? (sortOrder === 'desc' ? '↓' : '↑') : '';
  });
}

document.querySelectorAll('th.sortable').forEach(th => {
  th.addEventListener('click', () => {
    const field = th.dataset.field;
    if (sortField === field) {
      sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'; // 再点切换方向
    } else {
      sortField = field;
      sortOrder = field === 'latency' ? 'asc' : 'desc'; // 延迟默认升序（低在前），其余默认降序（最新/最快在前）
    }
    updateSortHeaders();
    loadRankings();
  });
});

/* ================= 主流程 ================= */
function markDone(el) {
  el.classList.add('done');
}

async function runTest() {
  if (running) return;
  running = true;
  startBtn.disabled = true;
  startBtn.textContent = '测速中…';
  ['cardLatency', 'cardJitter', 'cardDownload', 'cardUpload'].forEach(id => document.getElementById(id).classList.remove('done'));
  ['statLatency', 'statJitter', 'statDownload', 'statUpload'].forEach(id => document.getElementById(id).textContent = '--');
  drawGauge(0);

  try {
    // 1. 延迟
    setPhase('延迟测试中（10 次 ping）…');
    const { latency, jitter } = await pingTest(10);
    statLatency.textContent = latency.toFixed(1);
    statJitter.textContent = jitter.toFixed(1);
    markDone(document.getElementById('cardLatency'));
    markDone(document.getElementById('cardJitter'));

    // 2. 下载（固定 10 秒）
    setPhase('下载测速准备中…');
    const download = await downloadTest();
    statDownload.textContent = download.toFixed(2);
    updateGaugeSpeed(download);
    markDone(document.getElementById('cardDownload'));

    // 3. 上传（固定 10 秒）
    setPhase('上传测速准备中…');
    const upload = await uploadTest();
    statUpload.textContent = upload.toFixed(2);
    updateGaugeSpeed(upload);
    markDone(document.getElementById('cardUpload'));

    // 4. 展示 IP 与地区（页面上方徽标；排行榜不展示 IP）
    try {
      const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' });
      const pi = await pr.json();
      showMyIP(pi.client_ip || '', pi.location || '');
    } catch (e) { /* ignore */ }

    // 5. 提交（记录我的成绩 ID，用于排行榜高亮）
    setPhase('正在提交结果…');
    const res = await submitResult({ latency, jitter, download, upload });
    if (res && res.id) {
      myId = res.id;
      localStorage.setItem('speedtest_my_id', String(myId));
    }
    await loadRankings();
    setPhase('✅ 测试完成，结果已记录（排行榜中已高亮你的成绩）');
  } catch (e) {
    console.error(e);
    setPhase('⚠ 测试中断：' + e.message);
  } finally {
    running = false;
    startBtn.disabled = false;
    startBtn.textContent = '再次测速';
  }
}

startBtn.addEventListener('click', runTest);

/* ================= 初始化 ================= */
drawGauge(0);
updateSortHeaders();
loadRankings();
</script>
</body>
</html>
