feat: 排行榜改为单表(延迟/下载/上传同列),点击列头动态排序,测速后自动高亮我的成绩(含真实名次)
This commit is contained in:
@@ -21,25 +21,50 @@ 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) {
|
||||
var results []db.SpeedTestResult
|
||||
order := col + " DESC"
|
||||
if asc {
|
||||
order = col + " ASC"
|
||||
}
|
||||
err := s.db.Order(order).Limit(n).Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetByID 按 ID 查询单条记录
|
||||
func (s *ResultStore) GetByID(id uint) (*db.SpeedTestResult, error) {
|
||||
var r db.SpeedTestResult
|
||||
if err := s.db.First(&r, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// CountBetter 统计比指定值更优的记录数(用于计算记录的真实排名)
|
||||
func (s *ResultStore) CountBetter(col string, asc bool, value float64) (int64, error) {
|
||||
op := ">"
|
||||
if asc {
|
||||
op = "<"
|
||||
}
|
||||
var n int64
|
||||
err := s.db.Model(&db.SpeedTestResult{}).Where(col+" "+op+" ?", value).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// TopDownload 返回下载速度最快的 n 条记录
|
||||
func (s *ResultStore) TopDownload(n int) ([]db.SpeedTestResult, error) {
|
||||
var results []db.SpeedTestResult
|
||||
err := s.db.Order("download_mbps DESC").Limit(n).Find(&results).Error
|
||||
return results, err
|
||||
return s.TopBy("download_mbps", false, n)
|
||||
}
|
||||
|
||||
// TopUpload 返回上传速度最快的 n 条记录
|
||||
func (s *ResultStore) TopUpload(n int) ([]db.SpeedTestResult, error) {
|
||||
var results []db.SpeedTestResult
|
||||
err := s.db.Order("upload_mbps DESC").Limit(n).Find(&results).Error
|
||||
return results, err
|
||||
return s.TopBy("upload_mbps", false, n)
|
||||
}
|
||||
|
||||
// TopLatency 返回延迟最低的 n 条记录
|
||||
func (s *ResultStore) TopLatency(n int) ([]db.SpeedTestResult, error) {
|
||||
var results []db.SpeedTestResult
|
||||
err := s.db.Order("latency_ms ASC").Limit(n).Find(&results).Error
|
||||
return results, err
|
||||
return s.TopBy("latency_ms", true, n)
|
||||
}
|
||||
|
||||
// Count 返回测速总次数
|
||||
|
||||
@@ -234,6 +234,8 @@ func (h *Handler) Result(c *gin.Context) {
|
||||
// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备)
|
||||
type RankItem struct {
|
||||
ID uint `json:"id"`
|
||||
Rank int `json:"rank"` // 名次(含附加记录的真实名次)
|
||||
IsMine bool `json:"is_mine"` // 是否请求方指定的记录(前端高亮"我的成绩")
|
||||
ClientIP string `json:"client_ip"`
|
||||
ServerIP string `json:"server_ip"`
|
||||
IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景)
|
||||
@@ -278,18 +280,68 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem {
|
||||
return items
|
||||
}
|
||||
|
||||
// Rankings 排行榜:下载榜 / 上传榜 / 延迟榜 + 总测试次数
|
||||
// Rankings 排行榜:单表多指标,按 sort/order 动态排序;
|
||||
// include_id 指定"我的记录"——不在榜内时附加到末尾(带真实名次与 is_mine 标记),供前端高亮
|
||||
func (h *Handler) Rankings(c *gin.Context) {
|
||||
download, _ := h.stores.Results.TopDownload(rankLimit)
|
||||
upload, _ := h.stores.Results.TopUpload(rankLimit)
|
||||
latency, _ := h.stores.Results.TopLatency(rankLimit)
|
||||
total, _ := h.stores.Results.Count()
|
||||
sortField := c.DefaultQuery("sort", "download")
|
||||
order := c.DefaultQuery("order", "desc")
|
||||
|
||||
// 列名白名单,防注入
|
||||
var col string
|
||||
switch sortField {
|
||||
case "upload":
|
||||
col = "upload_mbps"
|
||||
case "latency":
|
||||
col = "latency_ms"
|
||||
default:
|
||||
sortField, col = "download", "download_mbps"
|
||||
}
|
||||
asc := order == "asc"
|
||||
|
||||
rows, _ := h.stores.Results.TopBy(col, asc, rankLimit)
|
||||
items := toRankItems(rows)
|
||||
for i := range items {
|
||||
items[i].Rank = i + 1
|
||||
}
|
||||
|
||||
// 附加"我的记录"(不在榜内时)
|
||||
if idStr := strings.TrimSpace(c.Query("include_id")); idStr != "" {
|
||||
if id, err := strconv.ParseUint(idStr, 10, 64); err == nil && id > 0 {
|
||||
inList := false
|
||||
for _, it := range items {
|
||||
if uint64(it.ID) == id {
|
||||
inList = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total, _ := h.stores.Results.Count()
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": total,
|
||||
"download": toRankItems(download),
|
||||
"upload": toRankItems(upload),
|
||||
"latency": toRankItems(latency),
|
||||
"total": total,
|
||||
"sort": sortField,
|
||||
"order": order,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
@@ -79,23 +79,19 @@
|
||||
.rank-head { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.rank-head h2 { font-size: 18px; }
|
||||
.total { color: var(--muted); font-size: 13px; }
|
||||
.tabs { display: flex; gap: 8px; margin: 14px 0 4px; }
|
||||
.tab {
|
||||
padding: 7px 18px; font-size: 13px; color: var(--muted); background: transparent;
|
||||
border: 1px solid var(--border); border-radius: 999px; cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
color: #06121f; font-weight: 700; border-color: transparent;
|
||||
background: linear-gradient(90deg, var(--accent1), var(--accent2));
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th {
|
||||
text-align: left; color: var(--muted); font-size: 12px; font-weight: 500;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th.sortable { cursor: pointer; user-select: none; transition: color .15s; white-space: nowrap; }
|
||||
th.sortable:hover { color: var(--text); }
|
||||
th.sortable.active { color: var(--accent1); }
|
||||
.arrow { font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
td { padding: 11px 10px; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.04); font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr.mine { background: rgba(0, 212, 255, 0.10); box-shadow: inset 3px 0 0 var(--accent1); }
|
||||
tr.mine td { border-bottom-color: rgba(0, 212, 255, 0.12); }
|
||||
.rank-no {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 8px; font-size: 12px; font-weight: 700;
|
||||
@@ -104,7 +100,15 @@
|
||||
.rank-no.top1 { background: linear-gradient(135deg, #ffd76a, #ff9d3c); color: #3a2500; }
|
||||
.rank-no.top2 { background: linear-gradient(135deg, #e8ecf6, #aab4cc); color: #232a3d; }
|
||||
.rank-no.top3 { background: linear-gradient(135deg, #f0b08c, #c87a4a); color: #3d1d08; }
|
||||
.speed-val { font-weight: 700; }
|
||||
.mine-tag {
|
||||
display: inline-block; margin-left: 8px; padding: 1px 8px; font-size: 11px; font-weight: 700;
|
||||
color: #06121f; background: linear-gradient(90deg, var(--accent1), var(--accent2));
|
||||
border-radius: 999px; vertical-align: 1px;
|
||||
}
|
||||
.val-dl { font-weight: 700; color: var(--accent1); }
|
||||
.val-up { font-weight: 700; color: var(--accent2); }
|
||||
.val-la { font-weight: 700; color: var(--green); }
|
||||
.unit-sm { color: var(--muted); font-size: 11px; font-weight: 400; }
|
||||
.ip { color: var(--muted); font-size: 13px; }
|
||||
.ip-src { color: #ffb45e; font-size: 12px; margin-left: 6px; }
|
||||
.ip-tag {
|
||||
@@ -113,6 +117,7 @@
|
||||
}
|
||||
.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; }
|
||||
|
||||
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; }
|
||||
@@ -169,15 +174,20 @@
|
||||
<h2>🏆 测速排行</h2>
|
||||
<span class="total" id="totalCount"></span>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-type="download">下载排行</button>
|
||||
<button class="tab" data-type="upload">上传排行</button>
|
||||
<button class="tab" data-type="latency">延迟排行</button>
|
||||
</div>
|
||||
<table id="rankTable">
|
||||
<thead><tr><th style="width:64px">#</th><th>IP / 来源</th><th id="thSpeed">下载速度</th><th style="width:130px">时间</th></tr></thead>
|
||||
<tbody id="rankBody"><tr><td colspan="4" class="empty">加载中…</td></tr></tbody>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:56px">#</th>
|
||||
<th>IP</th>
|
||||
<th class="sortable" data-field="latency">延迟 <span class="arrow"></span></th>
|
||||
<th class="sortable active" data-field="download">下载 <span class="arrow">↓</span></th>
|
||||
<th class="sortable" data-field="upload">上传 <span class="arrow"></span></th>
|
||||
<th style="width:120px">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rankBody"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
<div class="rank-hint">点击列头排序 · 你的成绩自动高亮</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -200,14 +210,9 @@ const statLatency = document.getElementById('statLatency');
|
||||
const statJitter = document.getElementById('statJitter');
|
||||
const statDownload = document.getElementById('statDownload');
|
||||
const statUpload = document.getElementById('statUpload');
|
||||
const rankBody = document.getElementById('rankBody');
|
||||
const rankTable = document.getElementById('rankTable');
|
||||
const thSpeed = document.getElementById('thSpeed');
|
||||
const totalCount = document.getElementById('totalCount');
|
||||
|
||||
let running = false;
|
||||
let gaugeMode = 'speed'; // speed | latency
|
||||
let rankType = 'download';
|
||||
|
||||
/* ================= 仪表盘 ================= */
|
||||
function drawGauge(frac) {
|
||||
@@ -455,64 +460,77 @@ async function submitResult(r) {
|
||||
}
|
||||
|
||||
/* ================= 排行榜 ================= */
|
||||
const SPEED_HEAD = { download: '下载速度', upload: '上传速度', latency: '延迟' };
|
||||
const rankBody = document.getElementById('rankBody');
|
||||
const totalCount = document.getElementById('totalCount');
|
||||
|
||||
// 排序状态:默认按下载降序;点击列头切换字段,再点切换方向
|
||||
let sortField = 'download';
|
||||
let sortOrder = 'desc';
|
||||
// 我的成绩 ID(localStorage 持久化,刷新后仍可高亮)
|
||||
let myId = parseInt(localStorage.getItem('speedtest_my_id') || '0', 10) || 0;
|
||||
|
||||
async function loadRankings() {
|
||||
try {
|
||||
const resp = await fetch('/api/rankings', { cache: 'no-store' });
|
||||
const url = '/api/rankings?sort=' + sortField + '&order=' + sortOrder + (myId ? '&include_id=' + myId : '');
|
||||
const resp = await fetch(url, { cache: 'no-store' });
|
||||
const data = await resp.json();
|
||||
totalCount.textContent = '共 ' + data.total + ' 次测试';
|
||||
renderRanking(data[rankType] || []);
|
||||
renderRanking(data.items || []);
|
||||
} catch (e) {
|
||||
totalCount.textContent = '排行榜加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
function renderRanking(rows) {
|
||||
thSpeed.textContent = SPEED_HEAD[rankType];
|
||||
if (!rows.length) {
|
||||
rankBody.innerHTML = '<tr><td colspan="4" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
|
||||
rankBody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
|
||||
return;
|
||||
}
|
||||
rankBody.innerHTML = rows.map((r, i) => {
|
||||
const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : '';
|
||||
rankBody.innerHTML = rows.map(r => {
|
||||
const cls = r.rank === 1 ? 'top1' : r.rank === 2 ? 'top2' : r.rank === 3 ? 'top3' : '';
|
||||
let ipHtml;
|
||||
if (r.is_private_ip) {
|
||||
// 纯内网记录(无公网直连场景):显示内网 IP 完整值,便于调试
|
||||
// 纯内网记录:显示内网 IP 完整值,便于调试
|
||||
ipHtml = '<span class="ip">' + r.client_ip + '</span><span class="ip-tag">内网</span>';
|
||||
if (r.server_ip && r.server_ip !== r.client_ip) {
|
||||
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
|
||||
}
|
||||
} else {
|
||||
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
|
||||
// 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备);
|
||||
// 公网直连时 client_ip 与 server_ip 一致,无需重复展示
|
||||
// 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备)
|
||||
if (r.server_ip && isPrivateIP(r.server_ip)) {
|
||||
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
|
||||
}
|
||||
}
|
||||
let speed;
|
||||
if (rankType === 'latency') {
|
||||
speed = '<span class="speed-val" style="color:var(--green)">' + r.latency_ms.toFixed(1) + '</span> ms';
|
||||
} else if (rankType === 'download') {
|
||||
speed = '<span class="speed-val" style="color:var(--accent1)">' + r.download_mbps.toFixed(2) + '</span> Mbps';
|
||||
} else {
|
||||
speed = '<span class="speed-val" style="color:var(--accent2)">' + r.upload_mbps.toFixed(2) + '</span> Mbps';
|
||||
}
|
||||
return '<tr>' +
|
||||
'<td><span class="rank-no ' + cls + '">' + (i + 1) + '</span></td>' +
|
||||
'<td>' + ipHtml + '</td>' +
|
||||
'<td>' + speed + '</td>' +
|
||||
return '<tr' + (r.is_mine ? ' class="mine"' : '') + '>' +
|
||||
'<td><span class="rank-no ' + cls + '">' + r.rank + '</span></td>' +
|
||||
'<td>' + ipHtml + (r.is_mine ? '<span class="mine-tag">我的成绩</span>' : '') + '</td>' +
|
||||
'<td class="val-la">' + r.latency_ms.toFixed(1) + ' <span class="unit-sm">ms</span></td>' +
|
||||
'<td class="val-dl">' + r.download_mbps.toFixed(2) + ' <span class="unit-sm">Mbps</span></td>' +
|
||||
'<td class="val-up">' + r.upload_mbps.toFixed(2) + ' <span class="unit-sm">Mbps</span></td>' +
|
||||
'<td class="time">' + r.created_at + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
rankType = tab.dataset.type;
|
||||
function updateSortHeaders() {
|
||||
document.querySelectorAll('th.sortable').forEach(th => {
|
||||
const active = th.dataset.field === sortField;
|
||||
th.classList.toggle('active', active);
|
||||
th.querySelector('.arrow').textContent = active ? (sortOrder === 'desc' ? '↓' : '↑') : '';
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const field = th.dataset.field;
|
||||
if (sortField === field) {
|
||||
sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'; // 再点切换方向
|
||||
} else {
|
||||
sortField = field;
|
||||
sortOrder = field === 'latency' ? 'asc' : 'desc'; // 延迟默认升序(低在前),速度默认降序
|
||||
}
|
||||
updateSortHeaders();
|
||||
loadRankings();
|
||||
});
|
||||
});
|
||||
@@ -560,11 +578,15 @@ async function runTest() {
|
||||
showMyIP((await pr.json()).client_ip || '');
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 5. 提交
|
||||
// 5. 提交(记录我的成绩 ID,用于排行榜高亮)
|
||||
setPhase('正在提交结果…');
|
||||
await submitResult({ latency, jitter, download, upload });
|
||||
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('✅ 测试完成,结果已记录');
|
||||
setPhase('✅ 测试完成,结果已记录(排行榜中已高亮你的成绩)');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setPhase('⚠ 测试中断:' + e.message);
|
||||
@@ -579,6 +601,7 @@ startBtn.addEventListener('click', runTest);
|
||||
|
||||
/* ================= 初始化 ================= */
|
||||
drawGauge(0);
|
||||
updateSortHeaders();
|
||||
loadRankings();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user