diff --git a/README.md b/README.md index 701de07..13a35b0 100644 --- a/README.md +++ b/README.md @@ -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 打码保护隐私; diff --git a/internal/store/result_store.go b/internal/store/result_store.go index 10bcee3..394a783 100644 --- a/internal/store/result_store.go +++ b/internal/store/result_store.go @@ -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 返回测速总次数 diff --git a/internal/web/handlers/speedtest.go b/internal/web/handlers/speedtest.go index d3e0527..686eec6 100644 --- a/internal/web/handlers/speedtest.go +++ b/internal/web/handlers/speedtest.go @@ -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), + "total": total, + "sort": sortField, + "order": order, + "items": items, }) } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 7303fc2..3b6a793 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -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 @@

🏆 测速排行

-
- - - -
- - + + + + + + + + + + +
#IP / 来源下载速度时间
加载中…
#IP延迟 下载 上传 时间
加载中…
+
点击列头排序 · 你的成绩自动高亮
@@ -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'; +// 我的成绩 ID(localStorage 持久化,刷新后仍可高亮) +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 = '暂无数据,快来测速抢占榜首!'; + rankBody.innerHTML = '暂无数据,快来测速抢占榜首!'; 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 = '' + r.client_ip + '内网'; if (r.server_ip && r.server_ip !== r.client_ip) { ipHtml += '← ' + r.server_ip + ''; } } else { ipHtml = '' + r.client_ip + ''; - // 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备); - // 公网直连时 client_ip 与 server_ip 一致,无需重复展示 + // 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备) if (r.server_ip && isPrivateIP(r.server_ip)) { ipHtml += '← ' + r.server_ip + ''; } } - let speed; - if (rankType === 'latency') { - speed = '' + r.latency_ms.toFixed(1) + ' ms'; - } else if (rankType === 'download') { - speed = '' + r.download_mbps.toFixed(2) + ' Mbps'; - } else { - speed = '' + r.upload_mbps.toFixed(2) + ' Mbps'; - } - return '' + - '' + (i + 1) + '' + - '' + ipHtml + '' + - '' + speed + '' + + return '' + + '' + r.rank + '' + + '' + ipHtml + (r.is_mine ? '我的成绩' : '') + '' + + '' + r.latency_ms.toFixed(1) + ' ms' + + '' + r.download_mbps.toFixed(2) + ' Mbps' + + '' + r.upload_mbps.toFixed(2) + ' Mbps' + '' + r.created_at + '' + ''; }).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();