From 9a07c3e1ce2fee76a1c5ce21aa2c566330d9ac9d Mon Sep 17 00:00:00 2001 From: dsh Date: Mon, 17 Aug 2026 23:19:28 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=92=E8=A1=8C=E6=A6=9C=E5=88=86?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E6=AF=8F=E9=A1=B510=E6=9D=A1=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=B5=8B=E9=80=9F=E5=AE=8C=E6=88=90=E5=90=8E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=B7=B3=E8=BD=AC=E5=88=B0=E6=88=90=E7=BB=A9=E6=89=80?= =?UTF-8?q?=E5=9C=A8=E9=A1=B5=E5=B9=B6=E9=AB=98=E4=BA=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/store/result_store.go | 21 ++------ internal/web/handlers/speedtest.go | 70 ++++++++++++++----------- internal/web/templates/index.html | 82 +++++++++++++++++++++++++++--- 3 files changed, 118 insertions(+), 55 deletions(-) diff --git a/internal/store/result_store.go b/internal/store/result_store.go index 394a783..aa8733c 100644 --- a/internal/store/result_store.go +++ b/internal/store/result_store.go @@ -21,14 +21,14 @@ 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) { +// TopBy 按指定列排序取 [offset, offset+limit) 条(col 为白名单列名,asc 控制方向) +func (s *ResultStore) TopBy(col string, asc bool, limit, offset 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 + err := s.db.Order(order).Limit(limit).Offset(offset).Find(&results).Error return results, err } @@ -52,21 +52,6 @@ func (s *ResultStore) CountBetter(col string, asc bool, value float64) (int64, e return n, err } -// TopDownload 返回下载速度最快的 n 条记录 -func (s *ResultStore) TopDownload(n int) ([]db.SpeedTestResult, error) { - return s.TopBy("download_mbps", false, n) -} - -// TopUpload 返回上传速度最快的 n 条记录 -func (s *ResultStore) TopUpload(n int) ([]db.SpeedTestResult, error) { - return s.TopBy("upload_mbps", false, n) -} - -// TopLatency 返回延迟最低的 n 条记录 -func (s *ResultStore) TopLatency(n int) ([]db.SpeedTestResult, error) { - return s.TopBy("latency_ms", true, n) -} - // Count 返回测速总次数 func (s *ResultStore) Count() (int64, error) { var total int64 diff --git a/internal/web/handlers/speedtest.go b/internal/web/handlers/speedtest.go index 0341762..53f4765 100644 --- a/internal/web/handlers/speedtest.go +++ b/internal/web/handlers/speedtest.go @@ -306,8 +306,9 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem { return items } -// Rankings 排行榜:单表多指标,按 sort/order 动态排序; -// include_id 指定"我的记录"——不在榜内时附加到末尾(带真实名次与 is_mine 标记),供前端高亮 +// Rankings 排行榜:单表多指标,按 sort/order 动态排序,分页(每页 rankLimit 条); +// include_id 指定"我的记录":返回其当前排序下的真实名次 my_rank(前端据此跳转所在页), +// 若记录恰在当前页榜内则标记 is_mine 高亮 func (h *Handler) Rankings(c *gin.Context) { sortField := c.DefaultQuery("sort", "download") order := c.DefaultQuery("order", "desc") @@ -326,50 +327,61 @@ func (h *Handler) Rankings(c *gin.Context) { } asc := order == "asc" - rows, _ := h.stores.Results.TopBy(col, asc, rankLimit) + // 分页 + page := 1 + if p, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && p > 0 { + page = p + } + offset := (page - 1) * rankLimit + + rows, _ := h.stores.Results.TopBy(col, asc, rankLimit, offset) items := toRankItems(rows) for i := range items { - items[i].Rank = i + 1 + items[i].Rank = offset + i + 1 } - // 附加"我的记录"并标记 is_mine(无论是否在榜内,前端均高亮) + // "我的记录":标记 + 计算真实名次 + myRank := 0 if idStr := strings.TrimSpace(c.Query("include_id")); idStr != "" { if id, err := strconv.ParseUint(idStr, 10, 64); err == nil && id > 0 { - inList := false for i := range items { if uint64(items[i].ID) == id { - items[i].IsMine = true // 榜内:直接标记 - inList = true + items[i].IsMine = 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) + if rec, err := h.stores.Results.GetByID(uint(id)); err == nil { + var val float64 + switch col { + case "upload_mbps": + val = rec.UploadMbps + case "latency_ms": + val = rec.LatencyMs + case "created_at": + val = float64(rec.CreatedAt.Unix()) + default: + val = rec.DownloadMbps + } + if better, err := h.stores.Results.CountBetter(col, asc, val); err == nil { + myRank = int(better) + 1 } } } } total, _ := h.stores.Results.Count() + totalPages := (int(total) + rankLimit - 1) / rankLimit + if totalPages < 1 { + totalPages = 1 + } c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, gin.H{ - "total": total, - "sort": sortField, - "order": order, - "items": items, + "total": total, + "total_pages": totalPages, + "page": page, + "sort": sortField, + "order": order, + "items": items, + "my_rank": myRank, // 0 = 未指定 include_id 或记录不存在 }) } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 9cc7951..9913b99 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -114,6 +114,18 @@ .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; } + .pager { display: flex; justify-content: center; align-items: center; gap: 6px; padding: 14px 0 0; } + .page-btn { + min-width: 30px; height: 30px; padding: 0 8px; font-size: 13px; color: var(--muted); + background: transparent; border: 1px solid var(--border); border-radius: 8px; + cursor: pointer; transition: all .15s; font-variant-numeric: tabular-nums; + } + .page-btn:hover:not(:disabled) { color: var(--text); border-color: var(--muted); } + .page-btn.active { + color: #06121f; font-weight: 700; border-color: transparent; + background: linear-gradient(90deg, var(--accent1), var(--accent2)); + } + .page-btn:disabled { opacity: .35; cursor: not-allowed; } 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; } @@ -183,7 +195,12 @@ 加载中… -
点击列头排序 · 你的成绩自动高亮
+
+ + + +
+
点击列头排序 · 测速后自动定位你的成绩
@@ -462,21 +479,69 @@ const totalCount = document.getElementById('totalCount'); // 排序状态:默认按下载降序;点击列头切换字段,再点切换方向 let sortField = 'download'; let sortOrder = 'desc'; +// 分页状态:每页 10 条 +let currentPage = 1; +let totalPages = 1; +const PAGE_SIZE = 10; // 我的成绩 ID(localStorage 持久化,刷新后仍可高亮) let myId = parseInt(localStorage.getItem('speedtest_my_id') || '0', 10) || 0; -async function loadRankings() { +async function loadRankings(includeMine) { try { - const url = '/api/rankings?sort=' + sortField + '&order=' + sortOrder + (myId ? '&include_id=' + myId : ''); + const url = '/api/rankings?sort=' + sortField + '&order=' + sortOrder + '&page=' + currentPage + + (includeMine && myId ? '&include_id=' + myId : ''); const resp = await fetch(url, { cache: 'no-store' }); const data = await resp.json(); totalCount.textContent = '共 ' + data.total + ' 次测试'; + totalPages = Math.max(1, data.total_pages || 1); + + // 测速完成后:若我的成绩不在当前页,自动跳转到所在页 + if (includeMine && data.my_rank > 0) { + const myPage = Math.ceil(data.my_rank / PAGE_SIZE); + if (myPage !== currentPage) { + currentPage = myPage; + return loadRankings(true); // 跳页后重新加载(此时 myPage == currentPage,不会死循环) + } + } + renderRanking(data.items || []); + renderPager(); } catch (e) { totalCount.textContent = '排行榜加载失败'; } } +function renderPager() { + const pager = document.getElementById('pager'); + pager.style.display = totalPages <= 1 ? 'none' : ''; + document.getElementById('prevPage').disabled = currentPage <= 1; + document.getElementById('nextPage').disabled = currentPage >= totalPages; + + // 最多显示 7 个页码,当前页居中 + let start = Math.max(1, currentPage - 3); + let end = Math.min(totalPages, start + 6); + start = Math.max(1, end - 6); + let html = ''; + for (let i = start; i <= end; i++) { + html += ''; + } + const btns = document.getElementById('pageBtns'); + btns.innerHTML = html; + btns.querySelectorAll('.page-btn').forEach(b => { + b.addEventListener('click', () => { + currentPage = parseInt(b.dataset.page, 10); + loadRankings(false); + }); + }); +} + +document.getElementById('prevPage').addEventListener('click', () => { + if (currentPage > 1) { currentPage--; loadRankings(false); } +}); +document.getElementById('nextPage').addEventListener('click', () => { + if (currentPage < totalPages) { currentPage++; loadRankings(false); } +}); + function renderRanking(rows) { if (!rows.length) { rankBody.innerHTML = '暂无数据,快来测速抢占榜首!'; @@ -515,8 +580,9 @@ document.querySelectorAll('th.sortable').forEach(th => { sortField = field; sortOrder = field === 'latency' ? 'asc' : 'desc'; // 延迟默认升序(低在前),其余默认降序(最新/最快在前) } + currentPage = 1; // 切换排序回到第一页 updateSortHeaders(); - loadRankings(); + loadRankings(false); }); }); @@ -557,15 +623,15 @@ async function runTest() { updateGaugeSpeed(upload); markDone(document.getElementById('cardUpload')); - // 4. 提交(记录我的成绩 ID,用于排行榜高亮) + // 4. 提交(记录我的成绩 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('✅ 测试完成,结果已记录(排行榜中已高亮你的成绩)'); + await loadRankings(true); + setPhase('✅ 测试完成,排行榜已定位到你的成绩(第 ' + currentPage + ' 页)'); } catch (e) { console.error(e); setPhase('⚠ 测试中断:' + e.message); @@ -581,7 +647,7 @@ startBtn.addEventListener('click', runTest); /* ================= 初始化 ================= */ drawGauge(0); updateSortHeaders(); -loadRankings(); +loadRankings(false); // 打开页面立即显示 IP 与地区(无需等待测速) fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' })