Files
speedtest/internal/web/templates/index.html
T

609 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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; }
.ip { color: var(--muted); font-size: 13px; }
.ip-src { color: #ffb45e; font-size: 12px; margin-left: 6px; }
.ip-tag {
display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 11px;
color: #ffb45e; border: 1px solid rgba(255, 180, 94, 0.4); border-radius: 999px; vertical-align: 1px;
}
.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>IP</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) {
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;
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';
// 我的成绩 IDlocalStorage 持久化,刷新后仍可高亮)
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' : '';
let ipHtml;
if (r.is_private_ip) {
// 纯内网记录:显示内网 IP 完整值,便于调试
ipHtml = '<span class="ip">' + r.client_ip + '</span><span class="ip-tag">内网</span>';
if (r.server_ip && r.server_ip !== r.client_ip) {
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
}
} else {
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
// 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备)
if (r.server_ip && isPrivateIP(r.server_ip)) {
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
}
}
return '<tr' + (r.is_mine ? ' class="mine"' : '') + '>' +
'<td><span class="rank-no ' + cls + '">' + r.rank + '</span></td>' +
'<td>' + ipHtml + (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' });
showMyIP((await pr.json()).client_ip || '');
} 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>