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
|
return s.db.Create(r).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopBy 按指定列排序取前 n 条(col 为白名单列名,asc 控制方向)
|
// TopBy 按指定列排序取 [offset, offset+limit) 条(col 为白名单列名,asc 控制方向)
|
||||||
func (s *ResultStore) TopBy(col string, asc bool, n int) ([]db.SpeedTestResult, error) {
|
func (s *ResultStore) TopBy(col string, asc bool, limit, offset int) ([]db.SpeedTestResult, error) {
|
||||||
var results []db.SpeedTestResult
|
var results []db.SpeedTestResult
|
||||||
order := col + " DESC"
|
order := col + " DESC"
|
||||||
if asc {
|
if asc {
|
||||||
order = col + " 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
|
return results, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,21 +52,6 @@ func (s *ResultStore) CountBetter(col string, asc bool, value float64) (int64, e
|
|||||||
return n, err
|
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 返回测速总次数
|
// Count 返回测速总次数
|
||||||
func (s *ResultStore) Count() (int64, error) {
|
func (s *ResultStore) Count() (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
|
|||||||
@@ -306,8 +306,9 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rankings 排行榜:单表多指标,按 sort/order 动态排序;
|
// Rankings 排行榜:单表多指标,按 sort/order 动态排序,分页(每页 rankLimit 条);
|
||||||
// include_id 指定"我的记录"——不在榜内时附加到末尾(带真实名次与 is_mine 标记),供前端高亮
|
// include_id 指定"我的记录":返回其当前排序下的真实名次 my_rank(前端据此跳转所在页),
|
||||||
|
// 若记录恰在当前页榜内则标记 is_mine 高亮
|
||||||
func (h *Handler) Rankings(c *gin.Context) {
|
func (h *Handler) Rankings(c *gin.Context) {
|
||||||
sortField := c.DefaultQuery("sort", "download")
|
sortField := c.DefaultQuery("sort", "download")
|
||||||
order := c.DefaultQuery("order", "desc")
|
order := c.DefaultQuery("order", "desc")
|
||||||
@@ -326,50 +327,61 @@ func (h *Handler) Rankings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
asc := order == "asc"
|
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)
|
items := toRankItems(rows)
|
||||||
for i := range items {
|
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 idStr := strings.TrimSpace(c.Query("include_id")); idStr != "" {
|
||||||
if id, err := strconv.ParseUint(idStr, 10, 64); err == nil && id > 0 {
|
if id, err := strconv.ParseUint(idStr, 10, 64); err == nil && id > 0 {
|
||||||
inList := false
|
|
||||||
for i := range items {
|
for i := range items {
|
||||||
if uint64(items[i].ID) == id {
|
if uint64(items[i].ID) == id {
|
||||||
items[i].IsMine = true // 榜内:直接标记
|
items[i].IsMine = true
|
||||||
inList = true
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !inList {
|
if rec, err := h.stores.Results.GetByID(uint(id)); err == nil {
|
||||||
if rec, err := h.stores.Results.GetByID(uint(id)); err == nil {
|
var val float64
|
||||||
item := toRankItems([]db.SpeedTestResult{*rec})[0]
|
switch col {
|
||||||
var val float64
|
case "upload_mbps":
|
||||||
switch col {
|
val = rec.UploadMbps
|
||||||
case "upload_mbps":
|
case "latency_ms":
|
||||||
val = rec.UploadMbps
|
val = rec.LatencyMs
|
||||||
case "latency_ms":
|
case "created_at":
|
||||||
val = rec.LatencyMs
|
val = float64(rec.CreatedAt.Unix())
|
||||||
default:
|
default:
|
||||||
val = rec.DownloadMbps
|
val = rec.DownloadMbps
|
||||||
}
|
}
|
||||||
if better, err := h.stores.Results.CountBetter(col, asc, val); err == nil {
|
if better, err := h.stores.Results.CountBetter(col, asc, val); err == nil {
|
||||||
item.Rank = int(better) + 1 // 真实名次
|
myRank = int(better) + 1
|
||||||
}
|
|
||||||
item.IsMine = true
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
total, _ := h.stores.Results.Count()
|
total, _ := h.stores.Results.Count()
|
||||||
|
totalPages := (int(total) + rankLimit - 1) / rankLimit
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
c.Header("Cache-Control", "no-store")
|
c.Header("Cache-Control", "no-store")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"total": total,
|
"total": total,
|
||||||
"sort": sortField,
|
"total_pages": totalPages,
|
||||||
"order": order,
|
"page": page,
|
||||||
"items": items,
|
"sort": sortField,
|
||||||
|
"order": order,
|
||||||
|
"items": items,
|
||||||
|
"my_rank": myRank, // 0 = 未指定 include_id 或记录不存在
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,18 @@
|
|||||||
.time { color: var(--muted); font-size: 12px; }
|
.time { color: var(--muted); font-size: 12px; }
|
||||||
.empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; }
|
.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; }
|
.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 { text-align: center; color: var(--muted); font-size: 12px; margin-top: 26px; line-height: 1.8; }
|
||||||
footer a { color: var(--accent1); text-decoration: none; }
|
footer a { color: var(--accent1); text-decoration: none; }
|
||||||
@@ -183,7 +195,12 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="rankBody"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
|
<tbody id="rankBody"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
|
||||||
</table>
|
</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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -462,21 +479,69 @@ const totalCount = document.getElementById('totalCount');
|
|||||||
// 排序状态:默认按下载降序;点击列头切换字段,再点切换方向
|
// 排序状态:默认按下载降序;点击列头切换字段,再点切换方向
|
||||||
let sortField = 'download';
|
let sortField = 'download';
|
||||||
let sortOrder = 'desc';
|
let sortOrder = 'desc';
|
||||||
|
// 分页状态:每页 10 条
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
// 我的成绩 ID(localStorage 持久化,刷新后仍可高亮)
|
// 我的成绩 ID(localStorage 持久化,刷新后仍可高亮)
|
||||||
let myId = parseInt(localStorage.getItem('speedtest_my_id') || '0', 10) || 0;
|
let myId = parseInt(localStorage.getItem('speedtest_my_id') || '0', 10) || 0;
|
||||||
|
|
||||||
async function loadRankings() {
|
async function loadRankings(includeMine) {
|
||||||
try {
|
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 resp = await fetch(url, { cache: 'no-store' });
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
totalCount.textContent = '共 ' + data.total + ' 次测试';
|
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 || []);
|
renderRanking(data.items || []);
|
||||||
|
renderPager();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
totalCount.textContent = '排行榜加载失败';
|
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) {
|
function renderRanking(rows) {
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
rankBody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
|
rankBody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
|
||||||
@@ -515,8 +580,9 @@ document.querySelectorAll('th.sortable').forEach(th => {
|
|||||||
sortField = field;
|
sortField = field;
|
||||||
sortOrder = field === 'latency' ? 'asc' : 'desc'; // 延迟默认升序(低在前),其余默认降序(最新/最快在前)
|
sortOrder = field === 'latency' ? 'asc' : 'desc'; // 延迟默认升序(低在前),其余默认降序(最新/最快在前)
|
||||||
}
|
}
|
||||||
|
currentPage = 1; // 切换排序回到第一页
|
||||||
updateSortHeaders();
|
updateSortHeaders();
|
||||||
loadRankings();
|
loadRankings(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -557,15 +623,15 @@ async function runTest() {
|
|||||||
updateGaugeSpeed(upload);
|
updateGaugeSpeed(upload);
|
||||||
markDone(document.getElementById('cardUpload'));
|
markDone(document.getElementById('cardUpload'));
|
||||||
|
|
||||||
// 4. 提交(记录我的成绩 ID,用于排行榜高亮)
|
// 4. 提交(记录我的成绩 ID,排行榜自动定位到所在页并高亮)
|
||||||
setPhase('正在提交结果…');
|
setPhase('正在提交结果…');
|
||||||
const res = await submitResult({ latency, jitter, download, upload });
|
const res = await submitResult({ latency, jitter, download, upload });
|
||||||
if (res && res.id) {
|
if (res && res.id) {
|
||||||
myId = res.id;
|
myId = res.id;
|
||||||
localStorage.setItem('speedtest_my_id', String(myId));
|
localStorage.setItem('speedtest_my_id', String(myId));
|
||||||
}
|
}
|
||||||
await loadRankings();
|
await loadRankings(true);
|
||||||
setPhase('✅ 测试完成,结果已记录(排行榜中已高亮你的成绩)');
|
setPhase('✅ 测试完成,排行榜已定位到你的成绩(第 ' + currentPage + ' 页)');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setPhase('⚠ 测试中断:' + e.message);
|
setPhase('⚠ 测试中断:' + e.message);
|
||||||
@@ -581,7 +647,7 @@ startBtn.addEventListener('click', runTest);
|
|||||||
/* ================= 初始化 ================= */
|
/* ================= 初始化 ================= */
|
||||||
drawGauge(0);
|
drawGauge(0);
|
||||||
updateSortHeaders();
|
updateSortHeaders();
|
||||||
loadRankings();
|
loadRankings(false);
|
||||||
|
|
||||||
// 打开页面立即显示 IP 与地区(无需等待测速)
|
// 打开页面立即显示 IP 与地区(无需等待测速)
|
||||||
fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' })
|
fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' })
|
||||||
|
|||||||
Reference in New Issue
Block a user