feat: 排行榜分页(每页10条),测速完成后自动跳转到成绩所在页并高亮

This commit is contained in:
dsh
2026-08-17 23:19:28 -04:00
parent 9cccbba3bd
commit 9a07c3e1ce
3 changed files with 118 additions and 55 deletions
+3 -18
View File
@@ -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
+41 -29
View File
@@ -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 或记录不存在
})
}
+74 -8
View File
@@ -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 @@
</thead>
<tbody id="rankBody"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
</table>
<div class="rank-hint">点击列头排序 · 你的成绩自动高亮</div>
<div class="pager" id="pager">
<button class="page-btn" id="prevPage"></button>
<span id="pageBtns"></span>
<button class="page-btn" id="nextPage"></button>
</div>
<div class="rank-hint">点击列头排序 · 测速后自动定位你的成绩</div>
</section>
</main>
@@ -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;
// 我的成绩 IDlocalStorage 持久化,刷新后仍可高亮)
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 += '<button class="page-btn' + (i === currentPage ? ' active' : '') + '" data-page="' + i + '">' + i + '</button>';
}
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 = '<tr><td colspan="6" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
@@ -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' })