feat(排名): 排名页重做成领奖台加全班赛道,一眼看出自己第几、该追谁、谁在追自己
Deploy / deploy (push) Canceled after 0s

- 一张榜两个开关:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部,默认本班 · 这学期。
  原来最大那张全服 Top100 里,这学期在用的 25 级学生一个都进不去
- 做对一样多的不再按「提交少的在前」排,改成先做到的在前;名次旁边写比周一升降了几名
- 「你」那张卡:第几名、再做对几道上领奖台,要追的人早你几秒、追你的人晚你几秒
- 本年级 / 全服只列前面一段和你附近一段,中间折起来,点开再看
- 班级对抗按人均做对比,另有我和对手的名次走势、这周本班、每周冠军
- 老师不上榜,选班看;每行「⋯」能把怀疑抄代码的学生设成不计入排名(新增 user.rank_hidden_at,
  首页周榜同样不算他),随时能恢复
- 去掉全服 Top10、日活两张图、在线人数和班级详情弹框里的统计词,以及它们背后的旧接口

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 07:26:15 +08:00
1 parent 0cee4ef779
commit 1c6e2b9d82
31 files changed
+6633 -1392

No files matched your search

@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { RankTrend } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { parseTime } from "utils/functions"
/**
* 我和两个对手每周日晚上的名次。名次越小越好,所以纵轴倒过来(第 1 在最上面)。
* 线的末端直接写名字,不要图例 —— 三条线,看名字比对颜色快。
*/
const props = defineProps<{
trend: RankTrend
lines: { userId: number; label: string; color: string; strong?: boolean }[]
}>()
const theme = useThemeVars()
const W = 400
const H = 190
const PAD = { left: 40, right: 64, top: 12, bottom: 26 }
const series = computed(() =>
props.lines
.map((line) => ({
...line,
ranks: props.trend.series.find((s) => s.userId === line.userId)?.ranks,
}))
.filter((line): line is typeof line & { ranks: (number | null)[] } => !!line.ranks),
)
const range = computed(() => {
const values = series.value.flatMap((line) => line.ranks.filter((v): v is number => v !== null))
const lo = Math.max(1, Math.min(...values, 1e9) - 1)
const hi = Math.max(lo + 2, Math.max(...values, 0) + 1)
return { lo, hi }
})
const ticks = computed(() => {
const { lo, hi } = range.value
const step = Math.max(1, Math.ceil((hi - lo) / 5))
const list: number[] = []
for (let v = lo; v <= hi; v += step) list.push(v)
return list
})
const n = computed(() => props.trend.points.length)
function x(i: number) {
return PAD.left + ((W - PAD.left - PAD.right) * i) / Math.max(1, n.value - 1)
}
function y(rank: number) {
const { lo, hi } = range.value
return PAD.top + ((rank - lo) / (hi - lo)) * (H - PAD.top - PAD.bottom)
}
/** 点是周一零点,说成前一天(周日晚上);最后一个点是现在 */
const labels = computed(() =>
props.trend.points.map((point, i) =>
i === n.value - 1 ? "现在" : parseTime(new Date(Date.parse(point) - 1), "M月D日"),
),
)
function path(ranks: (number | null)[]) {
let d = ""
ranks.forEach((rank, i) => {
if (rank === null) return
d += `${d && ranks[i - 1] !== null ? "L" : "M"}${x(i).toFixed(1)},${y(rank).toFixed(1)} `
})
return d
}
/** 线尾的名字按纵坐标排开,挨得太近往下错开 */
const ends = computed(() => {
const list = series.value
.map((line) => {
const last = line.ranks.at(-1)
return last == null
? null
: { y: y(last), text: `${line.label} 第${last}`, color: line.color }
})
.filter((end): end is { y: number; text: string; color: string } => !!end)
.sort((a, b) => a.y - b.y)
for (let i = 1; i < list.length; i++) {
if (list[i]!.y - list[i - 1]!.y < 14) list[i]!.y = list[i - 1]!.y + 14
}
return list
})
</script>
<template>
<svg :viewBox="`0 0 ${W} ${H}`" class="chart" role="img" aria-label="我和对手每周的名次">
<g v-for="tick in ticks" :key="tick">
<line :x1="PAD.left" :x2="W - PAD.right" :y1="y(tick)" :y2="y(tick)" class="grid" />
<text :x="PAD.left - 6" :y="y(tick) + 4" text-anchor="end" class="axis">第 {{ tick }}</text>
</g>
<text
v-for="(label, i) in labels"
:key="label"
:x="x(i)"
:y="H - 8"
text-anchor="middle"
class="axis"
>
{{ label }}
</text>
<g v-for="line in series" :key="line.userId">
<path
:d="path(line.ranks)"
fill="none"
:stroke="line.color"
:stroke-width="line.strong ? 3 : 1.8"
stroke-linejoin="round"
stroke-linecap="round"
/>
<template v-for="(rank, i) in line.ranks" :key="i">
<circle
v-if="rank !== null"
:cx="x(i)"
:cy="y(rank)"
:r="line.strong ? 3.5 : 2.5"
class="dot"
:stroke="line.color"
/>
</template>
</g>
<text
v-for="end in ends"
:key="end.text"
:x="W - PAD.right + 8"
:y="end.y + 4"
:fill="end.color"
class="end"
>
{{ end.text }}
</text>
</svg>
</template>
<style scoped>
.chart {
width: 100%;
height: auto;
display: block;
}
.grid {
stroke: v-bind("theme.dividerColor");
stroke-width: 1;
}
.axis {
font-size: 11px;
fill: v-bind("theme.textColor3");
}
.dot {
fill: v-bind("theme.cardColor");
stroke-width: 2;
}
.end {
font-size: 12px;
font-weight: 600;
}
</style>