feat: 排行榜改为单表(延迟/下载/上传同列),点击列头动态排序,测速后自动高亮我的成绩(含真实名次)
This commit is contained in:
@@ -13,8 +13,9 @@
|
|||||||
- ⬇️ **下载测试**: 固定测速 10 秒——负载根据实测速率自适应(每轮约 2 秒),
|
- ⬇️ **下载测试**: 固定测速 10 秒——负载根据实测速率自适应(每轮约 2 秒),
|
||||||
快速连接自动加大负载填满 10 秒,慢速连接用小负载同样 10 秒内完成
|
快速连接自动加大负载填满 10 秒,慢速连接用小负载同样 10 秒内完成
|
||||||
- ⬆️ **上传测试**: 同样的固定 10 秒自适应策略
|
- ⬆️ **上传测试**: 同样的固定 10 秒自适应策略
|
||||||
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段);
|
- 🏆 **排行榜**: 单表同时展示延迟 / 下载 / 上传,**点击列头动态排序**(再点切换升降序,
|
||||||
内网来源 IP(NAT 场景)完整标注,方便站长调试区分同一出口下的不同设备
|
延迟默认升序、速度默认降序);测速完成后**自动高亮"我的成绩"**(含真实名次,
|
||||||
|
localStorage 持久化,刷新页面仍高亮);公网 IP 打码,内网来源 IP 完整标注方便调试
|
||||||
- 🌐 **公网 IP 识别**: 服务器直接识别客户端真实 IP(Caddy `X-Real-IP`),
|
- 🌐 **公网 IP 识别**: 服务器直接识别客户端真实 IP(Caddy `X-Real-IP`),
|
||||||
不依赖任何外部 IP 查询服务;路由器单向伪装/公网直连时即为真实公网 IP
|
不依赖任何外部 IP 查询服务;路由器单向伪装/公网直连时即为真实公网 IP
|
||||||
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
|
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
|
||||||
@@ -110,7 +111,7 @@ speedtest.lmve.net {
|
|||||||
| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 512MB) |
|
| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 512MB) |
|
||||||
| POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` |
|
| POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` |
|
||||||
| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps, client_ip?, server_ip?}` |
|
| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps, client_ip?, server_ip?}` |
|
||||||
| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_ip` 与来源 `server_ip`) |
|
| GET | `/api/rankings?sort=&order=&include_id=` | 排行榜单表:`sort` ∈ download/upload/latency,`order` ∈ asc/desc,`include_id` 附加指定记录(真实名次 + `is_mine`) |
|
||||||
|
|
||||||
客户端 IP 由服务器直接识别(Caddy 注入的 `X-Real-IP`),前端不调用任何外部 IP 服务;
|
客户端 IP 由服务器直接识别(Caddy 注入的 `X-Real-IP`),前端不调用任何外部 IP 服务;
|
||||||
`client_ip` / `server_ip` 字段为 API 兼容保留(可选)。公网 IP 打码保护隐私;
|
`client_ip` / `server_ip` 字段为 API 兼容保留(可选)。公网 IP 打码保护隐私;
|
||||||
|
|||||||
@@ -21,25 +21,50 @@ func (s *ResultStore) Create(r *db.SpeedTestResult) error {
|
|||||||
return s.db.Create(r).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 条记录
|
// TopDownload 返回下载速度最快的 n 条记录
|
||||||
func (s *ResultStore) TopDownload(n int) ([]db.SpeedTestResult, error) {
|
func (s *ResultStore) TopDownload(n int) ([]db.SpeedTestResult, error) {
|
||||||
var results []db.SpeedTestResult
|
return s.TopBy("download_mbps", false, n)
|
||||||
err := s.db.Order("download_mbps DESC").Limit(n).Find(&results).Error
|
|
||||||
return results, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopUpload 返回上传速度最快的 n 条记录
|
// TopUpload 返回上传速度最快的 n 条记录
|
||||||
func (s *ResultStore) TopUpload(n int) ([]db.SpeedTestResult, error) {
|
func (s *ResultStore) TopUpload(n int) ([]db.SpeedTestResult, error) {
|
||||||
var results []db.SpeedTestResult
|
return s.TopBy("upload_mbps", false, n)
|
||||||
err := s.db.Order("upload_mbps DESC").Limit(n).Find(&results).Error
|
|
||||||
return results, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopLatency 返回延迟最低的 n 条记录
|
// TopLatency 返回延迟最低的 n 条记录
|
||||||
func (s *ResultStore) TopLatency(n int) ([]db.SpeedTestResult, error) {
|
func (s *ResultStore) TopLatency(n int) ([]db.SpeedTestResult, error) {
|
||||||
var results []db.SpeedTestResult
|
return s.TopBy("latency_ms", true, n)
|
||||||
err := s.db.Order("latency_ms ASC").Limit(n).Find(&results).Error
|
|
||||||
return results, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count 返回测速总次数
|
// Count 返回测速总次数
|
||||||
|
|||||||
@@ -234,6 +234,8 @@ func (h *Handler) Result(c *gin.Context) {
|
|||||||
// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备)
|
// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备)
|
||||||
type RankItem struct {
|
type RankItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
|
Rank int `json:"rank"` // 名次(含附加记录的真实名次)
|
||||||
|
IsMine bool `json:"is_mine"` // 是否请求方指定的记录(前端高亮"我的成绩")
|
||||||
ClientIP string `json:"client_ip"`
|
ClientIP string `json:"client_ip"`
|
||||||
ServerIP string `json:"server_ip"`
|
ServerIP string `json:"server_ip"`
|
||||||
IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景)
|
IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景)
|
||||||
@@ -278,18 +280,68 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rankings 排行榜:下载榜 / 上传榜 / 延迟榜 + 总测试次数
|
// Rankings 排行榜:单表多指标,按 sort/order 动态排序;
|
||||||
|
// include_id 指定"我的记录"——不在榜内时附加到末尾(带真实名次与 is_mine 标记),供前端高亮
|
||||||
func (h *Handler) Rankings(c *gin.Context) {
|
func (h *Handler) Rankings(c *gin.Context) {
|
||||||
download, _ := h.stores.Results.TopDownload(rankLimit)
|
sortField := c.DefaultQuery("sort", "download")
|
||||||
upload, _ := h.stores.Results.TopUpload(rankLimit)
|
order := c.DefaultQuery("order", "desc")
|
||||||
latency, _ := h.stores.Results.TopLatency(rankLimit)
|
|
||||||
total, _ := h.stores.Results.Count()
|
|
||||||
|
|
||||||
|
// 列名白名单,防注入
|
||||||
|
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.Header("Cache-Control", "no-store")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"total": total,
|
"total": total,
|
||||||
"download": toRankItems(download),
|
"sort": sortField,
|
||||||
"upload": toRankItems(upload),
|
"order": order,
|
||||||
"latency": toRankItems(latency),
|
"items": items,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -79,23 +79,19 @@
|
|||||||
.rank-head { display: flex; align-items: baseline; justify-content: space-between; }
|
.rank-head { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
.rank-head h2 { font-size: 18px; }
|
.rank-head h2 { font-size: 18px; }
|
||||||
.total { color: var(--muted); font-size: 13px; }
|
.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 {
|
th {
|
||||||
text-align: left; color: var(--muted); font-size: 12px; font-weight: 500;
|
text-align: left; color: var(--muted); font-size: 12px; font-weight: 500;
|
||||||
padding: 8px 10px; border-bottom: 1px solid var(--border);
|
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; }
|
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: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 {
|
.rank-no {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
width: 24px; height: 24px; border-radius: 8px; font-size: 12px; font-weight: 700;
|
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.top1 { background: linear-gradient(135deg, #ffd76a, #ff9d3c); color: #3a2500; }
|
||||||
.rank-no.top2 { background: linear-gradient(135deg, #e8ecf6, #aab4cc); color: #232a3d; }
|
.rank-no.top2 { background: linear-gradient(135deg, #e8ecf6, #aab4cc); color: #232a3d; }
|
||||||
.rank-no.top3 { background: linear-gradient(135deg, #f0b08c, #c87a4a); color: #3d1d08; }
|
.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 { color: var(--muted); font-size: 13px; }
|
||||||
.ip-src { color: #ffb45e; font-size: 12px; margin-left: 6px; }
|
.ip-src { color: #ffb45e; font-size: 12px; margin-left: 6px; }
|
||||||
.ip-tag {
|
.ip-tag {
|
||||||
@@ -113,6 +117,7 @@
|
|||||||
}
|
}
|
||||||
.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; }
|
||||||
|
|
||||||
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; }
|
||||||
@@ -169,15 +174,20 @@
|
|||||||
<h2>🏆 测速排行</h2>
|
<h2>🏆 测速排行</h2>
|
||||||
<span class="total" id="totalCount"></span>
|
<span class="total" id="totalCount"></span>
|
||||||
</div>
|
</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">
|
<table id="rankTable">
|
||||||
<thead><tr><th style="width:64px">#</th><th>IP / 来源</th><th id="thSpeed">下载速度</th><th style="width:130px">时间</th></tr></thead>
|
<thead>
|
||||||
<tbody id="rankBody"><tr><td colspan="4" class="empty">加载中…</td></tr></tbody>
|
<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>
|
</table>
|
||||||
|
<div class="rank-hint">点击列头排序 · 你的成绩自动高亮</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -200,14 +210,9 @@ const statLatency = document.getElementById('statLatency');
|
|||||||
const statJitter = document.getElementById('statJitter');
|
const statJitter = document.getElementById('statJitter');
|
||||||
const statDownload = document.getElementById('statDownload');
|
const statDownload = document.getElementById('statDownload');
|
||||||
const statUpload = document.getElementById('statUpload');
|
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 running = false;
|
||||||
let gaugeMode = 'speed'; // speed | latency
|
let gaugeMode = 'speed'; // speed | latency
|
||||||
let rankType = 'download';
|
|
||||||
|
|
||||||
/* ================= 仪表盘 ================= */
|
/* ================= 仪表盘 ================= */
|
||||||
function drawGauge(frac) {
|
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() {
|
async function loadRankings() {
|
||||||
try {
|
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();
|
const data = await resp.json();
|
||||||
totalCount.textContent = '共 ' + data.total + ' 次测试';
|
totalCount.textContent = '共 ' + data.total + ' 次测试';
|
||||||
renderRanking(data[rankType] || []);
|
renderRanking(data.items || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
totalCount.textContent = '排行榜加载失败';
|
totalCount.textContent = '排行榜加载失败';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRanking(rows) {
|
function renderRanking(rows) {
|
||||||
thSpeed.textContent = SPEED_HEAD[rankType];
|
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
rankBody.innerHTML = '<tr><td colspan="4" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
|
rankBody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rankBody.innerHTML = rows.map((r, i) => {
|
rankBody.innerHTML = rows.map(r => {
|
||||||
const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : '';
|
const cls = r.rank === 1 ? 'top1' : r.rank === 2 ? 'top2' : r.rank === 3 ? 'top3' : '';
|
||||||
let ipHtml;
|
let ipHtml;
|
||||||
if (r.is_private_ip) {
|
if (r.is_private_ip) {
|
||||||
// 纯内网记录(无公网直连场景):显示内网 IP 完整值,便于调试
|
// 纯内网记录:显示内网 IP 完整值,便于调试
|
||||||
ipHtml = '<span class="ip">' + r.client_ip + '</span><span class="ip-tag">内网</span>';
|
ipHtml = '<span class="ip">' + r.client_ip + '</span><span class="ip-tag">内网</span>';
|
||||||
if (r.server_ip && r.server_ip !== r.client_ip) {
|
if (r.server_ip && r.server_ip !== r.client_ip) {
|
||||||
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
|
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
|
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
|
||||||
// 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备);
|
// 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备)
|
||||||
// 公网直连时 client_ip 与 server_ip 一致,无需重复展示
|
|
||||||
if (r.server_ip && isPrivateIP(r.server_ip)) {
|
if (r.server_ip && isPrivateIP(r.server_ip)) {
|
||||||
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
|
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let speed;
|
return '<tr' + (r.is_mine ? ' class="mine"' : '') + '>' +
|
||||||
if (rankType === 'latency') {
|
'<td><span class="rank-no ' + cls + '">' + r.rank + '</span></td>' +
|
||||||
speed = '<span class="speed-val" style="color:var(--green)">' + r.latency_ms.toFixed(1) + '</span> ms';
|
'<td>' + ipHtml + (r.is_mine ? '<span class="mine-tag">我的成绩</span>' : '') + '</td>' +
|
||||||
} else if (rankType === 'download') {
|
'<td class="val-la">' + r.latency_ms.toFixed(1) + ' <span class="unit-sm">ms</span></td>' +
|
||||||
speed = '<span class="speed-val" style="color:var(--accent1)">' + r.download_mbps.toFixed(2) + '</span> Mbps';
|
'<td class="val-dl">' + r.download_mbps.toFixed(2) + ' <span class="unit-sm">Mbps</span></td>' +
|
||||||
} else {
|
'<td class="val-up">' + r.upload_mbps.toFixed(2) + ' <span class="unit-sm">Mbps</span></td>' +
|
||||||
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>' +
|
|
||||||
'<td class="time">' + r.created_at + '</td>' +
|
'<td class="time">' + r.created_at + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.tab').forEach(tab => {
|
function updateSortHeaders() {
|
||||||
tab.addEventListener('click', () => {
|
document.querySelectorAll('th.sortable').forEach(th => {
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
const active = th.dataset.field === sortField;
|
||||||
tab.classList.add('active');
|
th.classList.toggle('active', active);
|
||||||
rankType = tab.dataset.type;
|
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();
|
loadRankings();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -560,11 +578,15 @@ async function runTest() {
|
|||||||
showMyIP((await pr.json()).client_ip || '');
|
showMyIP((await pr.json()).client_ip || '');
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
// 5. 提交
|
// 5. 提交(记录我的成绩 ID,用于排行榜高亮)
|
||||||
setPhase('正在提交结果…');
|
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();
|
await loadRankings();
|
||||||
setPhase('✅ 测试完成,结果已记录');
|
setPhase('✅ 测试完成,结果已记录(排行榜中已高亮你的成绩)');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setPhase('⚠ 测试中断:' + e.message);
|
setPhase('⚠ 测试中断:' + e.message);
|
||||||
@@ -579,6 +601,7 @@ startBtn.addEventListener('click', runTest);
|
|||||||
|
|
||||||
/* ================= 初始化 ================= */
|
/* ================= 初始化 ================= */
|
||||||
drawGauge(0);
|
drawGauge(0);
|
||||||
|
updateSortHeaders();
|
||||||
loadRankings();
|
loadRankings();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user