feat: 排行榜分页(每页10条),测速完成后自动跳转到成绩所在页并高亮
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 或记录不存在
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
// 我的成绩 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 += '<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' })
|
||||
|
||||
Reference in New Issue
Block a user