feat: 排行榜改为单表(延迟/下载/上传同列),点击列头动态排序,测速后自动高亮我的成绩(含真实名次)

This commit is contained in:
dsh
2026-08-17 11:42:41 -04:00
parent 3c691c6a84
commit 56f918f33d
4 changed files with 176 additions and 75 deletions
+4 -3
View File
@@ -13,8 +13,9 @@
- ⬇️ **下载测试**: 固定测速 10 秒——负载根据实测速率自适应(每轮约 2 秒),
快速连接自动加大负载填满 10 秒,慢速连接用小负载同样 10 秒内完成
- ⬆️ **上传测试**: 同样的固定 10 秒自适应策略
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段);
内网来源 IP(NAT 场景)完整标注,方便站长调试区分同一出口下的不同设备
- 🏆 **排行榜**: 单表同时展示延迟 / 下载 / 上传,**点击列头动态排序**(再点切换升降序,
延迟默认升序、速度默认降序);测速完成后**自动高亮"我的成绩"**(含真实名次,
localStorage 持久化,刷新页面仍高亮);公网 IP 打码,内网来源 IP 完整标注方便调试
- 🌐 **公网 IP 识别**: 服务器直接识别客户端真实 IP(Caddy `X-Real-IP`),
不依赖任何外部 IP 查询服务;路由器单向伪装/公网直连时即为真实公网 IP
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
@@ -110,7 +111,7 @@ speedtest.lmve.net {
| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 512MB |
| POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` |
| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps, client_ip?, server_ip?}` |
| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_ip` 与来源 `server_ip` |
| GET | `/api/rankings?sort=&order=&include_id=` | 排行榜单表:`sort` ∈ download/upload/latency`order` ∈ asc/desc`include_id` 附加指定记录(真实名次 + `is_mine` |
客户端 IP 由服务器直接识别(Caddy 注入的 `X-Real-IP`),前端不调用任何外部 IP 服务;
`client_ip` / `server_ip` 字段为 API 兼容保留(可选)。公网 IP 打码保护隐私;
+34 -9
View File
@@ -21,25 +21,50 @@ func (s *ResultStore) Create(r *db.SpeedTestResult) error {
return s.db.Create(r).Error
}
// TopBy 按指定列排序取前 n 条(col 为白名单列名,asc 控制方向)
func (s *ResultStore) TopBy(col string, asc bool, n int) ([]db.SpeedTestResult, error) {
var results []db.SpeedTestResult
order := col + " DESC"
if asc {
order = col + " ASC"
}
err := s.db.Order(order).Limit(n).Find(&results).Error
return results, err
}
// GetByID 按 ID 查询单条记录
func (s *ResultStore) GetByID(id uint) (*db.SpeedTestResult, error) {
var r db.SpeedTestResult
if err := s.db.First(&r, id).Error; err != nil {
return nil, err
}
return &r, nil
}
// CountBetter 统计比指定值更优的记录数(用于计算记录的真实排名)
func (s *ResultStore) CountBetter(col string, asc bool, value float64) (int64, error) {
op := ">"
if asc {
op = "<"
}
var n int64
err := s.db.Model(&db.SpeedTestResult{}).Where(col+" "+op+" ?", value).Count(&n).Error
return n, err
}
// TopDownload 返回下载速度最快的 n 条记录
func (s *ResultStore) TopDownload(n int) ([]db.SpeedTestResult, error) {
var results []db.SpeedTestResult
err := s.db.Order("download_mbps DESC").Limit(n).Find(&results).Error
return results, err
return s.TopBy("download_mbps", false, n)
}
// TopUpload 返回上传速度最快的 n 条记录
func (s *ResultStore) TopUpload(n int) ([]db.SpeedTestResult, error) {
var results []db.SpeedTestResult
err := s.db.Order("upload_mbps DESC").Limit(n).Find(&results).Error
return results, err
return s.TopBy("upload_mbps", false, n)
}
// TopLatency 返回延迟最低的 n 条记录
func (s *ResultStore) TopLatency(n int) ([]db.SpeedTestResult, error) {
var results []db.SpeedTestResult
err := s.db.Order("latency_ms ASC").Limit(n).Find(&results).Error
return results, err
return s.TopBy("latency_ms", true, n)
}
// Count 返回测速总次数
+60 -8
View File
@@ -234,6 +234,8 @@ func (h *Handler) Result(c *gin.Context) {
// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备)
type RankItem struct {
ID uint `json:"id"`
Rank int `json:"rank"` // 名次(含附加记录的真实名次)
IsMine bool `json:"is_mine"` // 是否请求方指定的记录(前端高亮"我的成绩")
ClientIP string `json:"client_ip"`
ServerIP string `json:"server_ip"`
IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景)
@@ -278,18 +280,68 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem {
return items
}
// Rankings 排行榜:下载榜 / 上传榜 / 延迟榜 + 总测试次数
// Rankings 排行榜:单表多指标,按 sort/order 动态排序;
// include_id 指定"我的记录"——不在榜内时附加到末尾(带真实名次与 is_mine 标记),供前端高亮
func (h *Handler) Rankings(c *gin.Context) {
download, _ := h.stores.Results.TopDownload(rankLimit)
upload, _ := h.stores.Results.TopUpload(rankLimit)
latency, _ := h.stores.Results.TopLatency(rankLimit)
total, _ := h.stores.Results.Count()
sortField := c.DefaultQuery("sort", "download")
order := c.DefaultQuery("order", "desc")
// 列名白名单,防注入
var col string
switch sortField {
case "upload":
col = "upload_mbps"
case "latency":
col = "latency_ms"
default:
sortField, col = "download", "download_mbps"
}
asc := order == "asc"
rows, _ := h.stores.Results.TopBy(col, asc, rankLimit)
items := toRankItems(rows)
for i := range items {
items[i].Rank = i + 1
}
// 附加"我的记录"(不在榜内时)
if idStr := strings.TrimSpace(c.Query("include_id")); idStr != "" {
if id, err := strconv.ParseUint(idStr, 10, 64); err == nil && id > 0 {
inList := false
for _, it := range items {
if uint64(it.ID) == id {
inList = true
break
}
}
if !inList {
if rec, err := h.stores.Results.GetByID(uint(id)); err == nil {
item := toRankItems([]db.SpeedTestResult{*rec})[0]
var val float64
switch col {
case "upload_mbps":
val = rec.UploadMbps
case "latency_ms":
val = rec.LatencyMs
default:
val = rec.DownloadMbps
}
if better, err := h.stores.Results.CountBetter(col, asc, val); err == nil {
item.Rank = int(better) + 1 // 真实名次
}
item.IsMine = true
items = append(items, item)
}
}
}
}
total, _ := h.stores.Results.Count()
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{
"total": total,
"download": toRankItems(download),
"upload": toRankItems(upload),
"latency": toRankItems(latency),
"sort": sortField,
"order": order,
"items": items,
})
}
+77 -54
View File
@@ -79,23 +79,19 @@
.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);
}
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;
@@ -104,7 +100,15 @@
.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; }
.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 {
@@ -113,6 +117,7 @@
}
.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; }
@@ -169,15 +174,20 @@
<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>
<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 style="width:120px">时间</th>
</tr>
</thead>
<tbody id="rankBody"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
</table>
<div class="rank-hint">点击列头排序 · 你的成绩自动高亮</div>
</section>
</main>
@@ -200,14 +210,9 @@ 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) {
@@ -455,64 +460,77 @@ async function submitResult(r) {
}
/* ================= 排行榜 ================= */
const SPEED_HEAD = { download: '下载速度', upload: '上传速度', latency: '延迟' };
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 resp = await fetch('/api/rankings', { cache: 'no-store' });
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[rankType] || []);
renderRanking(data.items || []);
} 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>';
rankBody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
return;
}
rankBody.innerHTML = rows.map((r, i) => {
const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : '';
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 完整值,便于调试
// 纯内网记录:显示内网 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 场景区分同一出口下的不同设备)
// 公网直连时 client_ip 与 server_ip 一致,无需重复展示
// 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备)
if (r.server_ip && isPrivateIP(r.server_ip)) {
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_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>' +
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('');
}
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;
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();
});
});
@@ -560,11 +578,15 @@ async function runTest() {
showMyIP((await pr.json()).client_ip || '');
} catch (e) { /* ignore */ }
// 5. 提交
// 5. 提交(记录我的成绩 ID,用于排行榜高亮)
setPhase('正在提交结果…');
await submitResult({ latency, jitter, download, upload });
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('✅ 测试完成,结果已记录');
setPhase('✅ 测试完成,结果已记录(排行榜中已高亮你的成绩)');
} catch (e) {
console.error(e);
setPhase('⚠ 测试中断:' + e.message);
@@ -579,6 +601,7 @@ startBtn.addEventListener('click', runTest);
/* ================= 初始化 ================= */
drawGauge(0);
updateSortHeaders();
loadRankings();
</script>
</body>