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

590 lines
22 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; }
.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>