590 lines
22 KiB
HTML
590 lines
22 KiB
HTML
<!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; }
|
||
.tabs { display: flex; gap: 8px; margin: 14px 0 4px; }
|
||
.tab {
|
||
padding: 7px 18px; font-size: 13px; color: var(--muted); background: transparent;
|
||
border: 1px solid var(--border); border-radius: 999px; cursor: pointer; transition: all .15s;
|
||
}
|
||
.tab:hover { color: var(--text); }
|
||
.tab.active {
|
||
color: #06121f; font-weight: 700; border-color: transparent;
|
||
background: linear-gradient(90deg, var(--accent1), var(--accent2));
|
||
}
|
||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||
th {
|
||
text-align: left; color: var(--muted); font-size: 12px; font-weight: 500;
|
||
padding: 8px 10px; border-bottom: 1px solid var(--border);
|
||
}
|
||
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; }
|
||
.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; }
|
||
.speed-val { font-weight: 700; }
|
||
.ip { color: var(--muted); font-size: 13px; }
|
||
.time { color: var(--muted); font-size: 12px; }
|
||
.empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; }
|
||
|
||
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">点击下方按钮开始测速(约需 15~30 秒)</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>
|
||
<div class="tabs">
|
||
<button class="tab active" data-type="download">下载排行</button>
|
||
<button class="tab" data-type="upload">上传排行</button>
|
||
<button class="tab" data-type="latency">延迟排行</button>
|
||
</div>
|
||
<table id="rankTable">
|
||
<thead><tr><th style="width:64px">#</th><th>IP</th><th id="thSpeed">下载速度</th><th style="width:130px">时间</th></tr></thead>
|
||
<tbody id="rankBody"><tr><td colspan="4" class="empty">加载中…</td></tr></tbody>
|
||
</table>
|
||
</section>
|
||
</main>
|
||
|
||
<footer>
|
||
SpeedTest · Go + Gin + SQLite · 由 Caddy 反代提供 HTTPS<br>
|
||
每次测速结果自动入库参与排行 · 公网 IP 经外部服务探测(服务器位于 NAT 后)
|
||
</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');
|
||
const rankBody = document.getElementById('rankBody');
|
||
const rankTable = document.getElementById('rankTable');
|
||
const thSpeed = document.getElementById('thSpeed');
|
||
const totalCount = document.getElementById('totalCount');
|
||
|
||
let running = false;
|
||
let gaugeMode = 'speed'; // speed | latency
|
||
let rankType = 'download';
|
||
|
||
/* ================= 仪表盘 ================= */
|
||
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);
|
||
});
|
||
}
|
||
|
||
/* ================= 自适应测速阶段 =================
|
||
* 目标:每轮负载运行 ~2 秒(保证测量准确),负载逐轮翻倍;
|
||
* 阶段最短 5 秒,最多 6 轮。慢速连接 1-2 轮即达到单轮 2 秒,
|
||
* 快速连接自动加大负载拉长测试时间。
|
||
*/
|
||
const MB = 1048576;
|
||
const PHASE_MIN_MS = 5000;
|
||
const ROUND_TARGET_MS = 2000;
|
||
const MAX_ROUNDS = 6;
|
||
|
||
async function runPhase(runOnce, minBytes, maxBytes, label) {
|
||
const results = [];
|
||
const phaseStart = performance.now();
|
||
let size = minBytes;
|
||
for (let rounds = 0; rounds < MAX_ROUNDS; rounds++) {
|
||
setPhase(label + ' ' + (size / MB).toFixed(0) + ' MB …');
|
||
const r = await runOnce(size);
|
||
results.push(r.mbps);
|
||
const elapsed = performance.now() - phaseStart;
|
||
const dataEnough = r.ms >= ROUND_TARGET_MS || size >= maxBytes;
|
||
const timeEnough = elapsed >= PHASE_MIN_MS && rounds >= 1;
|
||
if (dataEnough || timeEnough) break;
|
||
size = Math.min(size * 2, maxBytes);
|
||
}
|
||
return Math.max(...results);
|
||
}
|
||
|
||
async function downloadTest() {
|
||
return runPhase(downloadOnce, 1 * MB, 400 * MB, '下载测速中');
|
||
}
|
||
|
||
async function uploadTest() {
|
||
return runPhase(uploadOnce, 1 * MB, 256 * MB, '上传测速中');
|
||
}
|
||
|
||
/* ================= 公网 IP 探测 =================
|
||
* 服务器在 NAT 后面(公网访问经路由器转发,服务器只能看到内网 IP),
|
||
* 因此通过外部服务探测用户真实的公网出口 IP,用于展示与排行榜。
|
||
*/
|
||
async function detectPublicIP() {
|
||
const sources = [
|
||
{ url: 'https://ipinfo.io/ip', type: 'text' },
|
||
{ url: 'https://api.ipify.org?format=json', type: 'json' },
|
||
];
|
||
for (const s of sources) {
|
||
try {
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 4000);
|
||
const resp = await fetch(s.url, { signal: ctrl.signal });
|
||
clearTimeout(timer);
|
||
if (!resp.ok) continue;
|
||
let ip;
|
||
if (s.type === 'json') ip = (await resp.json()).ip;
|
||
else ip = (await resp.text()).trim();
|
||
if (typeof ip === 'string' && ip && /^[\d.a-fA-F:]+$/.test(ip)) return ip;
|
||
} catch (e) { /* 尝试下一个源 */ }
|
||
}
|
||
return '';
|
||
}
|
||
|
||
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, fromServer) {
|
||
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';
|
||
}
|
||
if (fromServer) el.title = '由服务器识别(经 NAT 转发)';
|
||
}
|
||
|
||
/* ================= 结果提交 ================= */
|
||
async function submitResult(r, clientIP) {
|
||
const payload = {
|
||
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 (clientIP) payload.client_ip = clientIP;
|
||
const resp = await fetch('/api/result', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!resp.ok) throw new Error('结果提交失败');
|
||
return resp.json();
|
||
}
|
||
|
||
/* ================= 排行榜 ================= */
|
||
const SPEED_HEAD = { download: '下载速度', upload: '上传速度', latency: '延迟' };
|
||
|
||
async function loadRankings() {
|
||
try {
|
||
const resp = await fetch('/api/rankings', { cache: 'no-store' });
|
||
const data = await resp.json();
|
||
totalCount.textContent = '共 ' + data.total + ' 次测试';
|
||
renderRanking(data[rankType] || []);
|
||
} catch (e) {
|
||
totalCount.textContent = '排行榜加载失败';
|
||
}
|
||
}
|
||
|
||
function renderRanking(rows) {
|
||
thSpeed.textContent = SPEED_HEAD[rankType];
|
||
if (!rows.length) {
|
||
rankBody.innerHTML = '<tr><td colspan="4" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
|
||
return;
|
||
}
|
||
rankBody.innerHTML = rows.map((r, i) => {
|
||
const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : '';
|
||
let ipHtml;
|
||
if (r.is_private_ip) {
|
||
ipHtml = '<span class="ip" title="' + r.client_ip + '(经 NAT 转发,无法获取公网 IP)">内网 / NAT</span>';
|
||
} else {
|
||
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
|
||
}
|
||
let speed;
|
||
if (rankType === 'latency') {
|
||
speed = '<span class="speed-val" style="color:var(--green)">' + r.latency_ms.toFixed(1) + '</span> ms';
|
||
} else if (rankType === 'download') {
|
||
speed = '<span class="speed-val" style="color:var(--accent1)">' + r.download_mbps.toFixed(2) + '</span> Mbps';
|
||
} else {
|
||
speed = '<span class="speed-val" style="color:var(--accent2)">' + r.upload_mbps.toFixed(2) + '</span> Mbps';
|
||
}
|
||
return '<tr>' +
|
||
'<td><span class="rank-no ' + cls + '">' + (i + 1) + '</span></td>' +
|
||
'<td>' + ipHtml + '</td>' +
|
||
'<td>' + speed + '</td>' +
|
||
'<td class="time">' + r.created_at + '</td>' +
|
||
'</tr>';
|
||
}).join('');
|
||
}
|
||
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
rankType = tab.dataset.type;
|
||
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);
|
||
|
||
// 并行探测公网出口 IP(不阻塞测速流程)
|
||
let publicIP = '';
|
||
let serverIP = '';
|
||
detectPublicIP().then(ip => { if (ip && !isPrivateIP(ip)) publicIP = ip; });
|
||
|
||
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. 下载(自适应时长,阶段最短 5 秒)
|
||
setPhase('下载测速准备中…');
|
||
const download = await downloadTest();
|
||
statDownload.textContent = download.toFixed(2);
|
||
updateGaugeSpeed(download);
|
||
markDone(document.getElementById('cardDownload'));
|
||
|
||
// 3. 上传(自适应时长,阶段最短 5 秒)
|
||
setPhase('上传测速准备中…');
|
||
const upload = await uploadTest();
|
||
statUpload.textContent = upload.toFixed(2);
|
||
updateGaugeSpeed(upload);
|
||
markDone(document.getElementById('cardUpload'));
|
||
|
||
// 4. 展示 IP:优先公网探测结果,失败则用服务器识别结果(可能为内网)
|
||
try {
|
||
const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' });
|
||
serverIP = (await pr.json()).client_ip || '';
|
||
} catch (e) { /* ignore */ }
|
||
showMyIP(publicIP || serverIP, !publicIP);
|
||
|
||
// 5. 提交
|
||
setPhase('正在提交结果…');
|
||
await submitResult({ latency, jitter, download, upload }, publicIP);
|
||
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);
|
||
loadRankings();
|
||
</script>
|
||
</body>
|
||
</html>
|