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 @@