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

-106
View File
@@ -1,106 +0,0 @@
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
Colors,
} from "chart.js"
import { ChartType } from "utils/constants"
import type { Rank } from "utils/types"
// 仅注册柱状图所需的 Chart.js 组件
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, Colors)
const props = defineProps<{ rankData: Rank[]; type: ChartType }>()
const data = computed(() => {
const labels = props.rankData.map((rank) => rank.user.username)
const datasets: any[] = [
{
label: props.type === ChartType.Rank ? "已解决" : "做题数",
data: props.rankData.map((rank) => rank.acceptedNumber),
backgroundColor: [
"rgba(255, 99, 132, 0.2)",
"rgba(255, 159, 64, 0.2)",
"rgba(55, 66, 250, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(54, 162, 235, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(48, 51, 107, 0.2)",
"rgba(249, 202, 36, 0.2)",
"rgba(106, 176, 76, 0.2)",
"rgba(119, 139, 235, 0.2)",
],
borderColor: [
"rgba(255, 99, 132, 0.6)",
"rgba(255, 159, 64, 0.6)",
"rgba(55, 66, 250, 0.6)",
"rgba(75, 192, 192, 0.6)",
"rgba(54, 162, 235, 0.6)",
"rgba(153, 102, 255, 0.6)",
"rgba(48, 51, 107, 0.6)",
"rgba(249, 202, 36, 0.6)",
"rgba(106, 176, 76, 0.6)",
"rgba(119, 139, 235, 0.6)",
],
hoverBackgroundColor: [
"rgba(255, 99, 132, 0.8)",
"rgba(255, 159, 64, 0.8)",
"rgba(55, 66, 250, 0.8)",
"rgba(75, 192, 192, 0.8)",
"rgba(54, 162, 235, 0.8)",
"rgba(153, 102, 255, 0.8)",
"rgba(48, 51, 107, 0.8)",
"rgba(249, 202, 36, 0.8)",
"rgba(106, 176, 76, 0.8)",
"rgba(119, 139, 235, 0.8)",
],
hoverBorderColor: [
"rgba(255, 99, 132, 1)",
"rgba(255, 159, 64, 1)",
"rgba(55, 66, 250, 1)",
"rgba(75, 192, 192, 1)",
"rgba(54, 162, 235, 1)",
"rgba(153, 102, 255, 1)",
"rgba(48, 51, 107, 1)",
"rgba(249, 202, 36, 1)",
"rgba(106, 176, 76, 1)",
"rgba(119, 139, 235, 1)",
],
borderWidth: 2,
},
]
if (props.type === ChartType.Rank) {
datasets.push({
label: "总提交数",
data: props.rankData.map((rank) => rank.submissionNumber),
hidden: true,
})
}
return {
labels,
datasets,
}
})
const options = {
maintainAspectRatio: false,
}
</script>
<template>
<div class="chart">
<Bar :data="data" :options="options" />
</div>
</template>
<style scoped>
.chart {
height: 500px;
margin: 20px 0;
}
</style>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import type { ClassBattleItem } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { useRankPalette } from "../palette"
/**
* 班级对抗:全服各班这学期人均做对,绿字是这周人均涨了多少。按人均比,因为班级人数从
* 十来个到五十几个都有。列前 7 个班,自己的班不在里面就补在最后。
*/
const props = defineProps<{ items: ClassBattleItem[]; mine: string | null; teacher?: boolean }>()
const theme = useThemeVars()
const palette = useRankPalette()
const LIMIT = 7
const shown = computed(() => {
const head = props.items.slice(0, LIMIT)
const own = props.items.find((item) => item.className === props.mine)
return own && own.rank > LIMIT ? [...head, own] : head
})
const max = computed(() => Math.max(1, ...props.items.map((item) => item.perCapita)))
const summary = computed(() => {
const own = props.items.find((item) => item.className === props.mine)
if (!own) return ""
const who = props.teacher ? "这个班" : "你们班"
const faster = props.items.filter((item) => item.weekGain > own.weekGain).length + 1
const speed = own.weekGain > 0 ? ` · 这周涨得第 ${faster} 快` : ""
if (own.rank === 1) return `${who}第 1${speed}`
const prev = props.items[own.rank - 2]!
const need = Math.floor(prev.perCapita - own.perCapita) + 1
return `${who}第 ${own.rank} · 每人再做对 ${need} 道就超过${shortLabel(prev.className)}${speed}`
})
/** 同年级的班只说「3 班」,别的年级说全名 */
function shortLabel(className: string) {
if (props.mine && className.slice(0, 2) === props.mine.slice(0, 2))
return ` ${className.slice(2)} 班`
return ` ${classLabel(className)}`
}
</script>
<template>
<div class="battle">
<div class="title">
<b>班级对抗</b><span class="muted">人均做对 · 全服 · 绿字是这周涨的</span>
</div>
<div
v-for="item in shown"
:key="item.className"
class="row"
:class="{ mine: item.className === mine }"
>
<span class="rank">{{ item.rank }}</span>
<span class="name">{{ classLabel(item.className) }}</span>
<span class="bar-box">
<span
class="bar"
:style="{
width: `${(item.perCapita / max) * 100}%`,
background: item.className === mine ? palette.me : palette.bar,
}"
/>
</span>
<span class="value">{{ item.perCapita }}</span>
<span class="gain">{{ item.weekGain > 0 ? `+${item.weekGain}` : "" }}</span>
</div>
<span v-if="!items.length" class="muted">这学期还没有班做对题</span>
<span v-if="summary" class="summary">{{ summary }}</span>
</div>
</template>
<style scoped>
.battle {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 1px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.title {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
}
.muted {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.row {
height: 22px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.row.mine {
margin: 0 -8px;
padding: 0 8px;
border-radius: 4px;
background: rgba(24, 160, 88, 0.12);
}
.rank {
width: 14px;
font-size: 12px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.name {
width: 86px;
white-space: nowrap;
}
.mine .name {
font-weight: 700;
color: #18a058;
}
.bar-box {
flex-grow: 1;
height: 10px;
position: relative;
}
.bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px 5px 5px 2px;
}
.value {
width: 32px;
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.gain {
width: 34px;
font-size: 11px;
color: #18a058;
font-variant-numeric: tabular-nums;
}
.summary {
margin-top: 6px;
font-size: 12px;
color: #18a058;
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useDark } from "@vueuse/core"
import { rgba } from "oj/submission/composables/tone"
import { USERNAME_CLASS_RE } from "utils/constants"
/**
* 排名页的头像。九成以上的学生没换过头像(1797 个号是 default.png),一排一模一样的
* 默认图分不出人,所以默认头像画成「名字最后一个字 + 彩色圆」,颜色按用户名固定 ——
* 同一个人在领奖台、赛道、对手卡里是同一个颜色。自己是实心绿。
*/
const props = defineProps<{
username: string
avatar: string | null
size: number
me?: boolean
}>()
const isDark = useDark()
/** 头像文件丢了(备份恢复后没拷上传目录、被删)就退回名字那个字,别露出破图 */
const broken = ref(false)
watch(
() => props.avatar,
() => (broken.value = false),
)
const PALETTE = ["#2f6fd0", "#c76a12", "#6a4bd8", "#0f7a6c", "#b4315f", "#4d5967", "#8a6a00"]
const letter = computed(() => {
const name = props.username.replace(USERNAME_CLASS_RE, "") || props.username
return Array.from(name).at(-1) ?? "?"
})
const colors = computed(() => {
if (props.me) return { background: "#18a058", color: "#ffffff" }
let hash = 0
for (const char of props.username) hash = (hash * 31 + char.codePointAt(0)!) >>> 0
const base = PALETTE[hash % PALETTE.length]!
return { background: rgba(base, isDark.value ? 0.28 : 0.13), color: base }
})
</script>
<template>
<img
v-if="avatar && !broken"
class="avatar"
:src="avatar"
alt=""
@error="broken = true"
:style="{ width: `${size}px`, height: `${size}px` }"
/>
<span
v-else
class="avatar letter"
aria-hidden="true"
:style="{
width: `${size}px`,
height: `${size}px`,
fontSize: `${Math.round(size * 0.5)}px`,
...colors,
}"
>{{ letter }}</span
>
</template>
<style scoped>
.avatar {
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
}
.letter {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
line-height: 1;
}
</style>
+273
View File
@@ -0,0 +1,273 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { gapText, toPass } from "../utils"
import RankAvatar from "./RankAvatar.vue"
export interface MiniRank {
label: string
rank: number | null
total: number | null
go: () => void
}
/**
* 「你」那张卡:大字第几名、比周一升了几名,一句最能推人一把的话(离领奖台几道 / 升了几名),
* 再是两个对手 —— 前面那个要追的、后面那个在追你的,差几秒、差几道都写出来。
*/
const props = defineProps<{
me: RankRow
ahead: RankRow | null
behind: RankRow | null
/** 领奖台第 3 名(我不在台上时用来算「再做对几道上领奖台」) */
third: RankRow | null
total: number
label: string
minis: MiniRank[]
}>()
const theme = useThemeVars()
const headline = computed(() => {
const { me, ahead, third } = props
if (me.rank === 1) return "你是第一名,后面的人在追你"
if (me.rank <= 3 && ahead)
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
if (third) {
const need = toPass(me, third)
if (need <= 3) return `再做对 ${need} 道,你就上领奖台`
}
if ((me.change ?? 0) >= 3) return `比周一升了 ${me.change} 名`
if (ahead) return `再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
return ""
})
function chaseNote(me: RankRow, ahead: RankRow) {
if (ahead.solved === me.solved && ahead.reachedAt && me.reachedAt)
return `早你 ${gapText(ahead.reachedAt, me.reachedAt)}做到 ${me.solved} 道`
return `比你多 ${ahead.solved - me.solved} 道`
}
function threatNote(me: RankRow, behind: RankRow) {
if (behind.solved === me.solved && behind.reachedAt && me.reachedAt)
return `只比你晚 ${gapText(me.reachedAt, behind.reachedAt)},再做对 1 道就超过你`
if (!behind.solved) return "还没做对"
return `比你少 ${me.solved - behind.solved} 道`
}
</script>
<template>
<div class="me-card">
<div class="head">
<RankAvatar :username="me.user.username" :avatar="me.avatar" :size="46" me />
<div class="place">
<span class="label">{{ label }}</span>
<div class="big">
<span>第</span><b>{{ me.rank }}</b
><span>名</span>
<span class="total">/ {{ total }}</span>
<span v-if="(me.change ?? 0) > 0" class="up">↑{{ me.change }}</span>
<span v-else-if="(me.change ?? 0) < 0" class="down">↓{{ -me.change! }}</span>
</div>
</div>
</div>
<div v-if="headline" class="headline">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
<b>{{ headline }}</b>
</div>
<div v-if="ahead" class="rival">
<span class="tag chase">要追</span>
<RankAvatar :username="ahead.user.username" :avatar="ahead.avatar" :size="20" />
<UserName :username="ahead.user.username" class="rival-name" />
<span class="note">{{ chaseNote(me, ahead) }}</span>
</div>
<div v-if="behind" class="rival">
<span class="tag threat">追你</span>
<RankAvatar :username="behind.user.username" :avatar="behind.avatar" :size="20" />
<UserName :username="behind.user.username" class="rival-name" />
<span class="note">{{ threatNote(me, behind) }}</span>
</div>
<div v-if="minis.length" class="minis">
<button v-for="mini in minis" :key="mini.label" class="mini" @click="mini.go">
<span class="mini-label">{{ mini.label }}</span>
<b v-if="mini.rank"
>第 {{ mini.rank }}<small v-if="mini.total"> / {{ mini.total }}</small></b
>
<b v-else class="none">没上榜</b>
</button>
</div>
</div>
</template>
<style scoped>
.me-card {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid rgba(24, 160, 88, 0.45);
border-radius: 6px;
background: rgba(24, 160, 88, 0.07);
}
.head {
display: flex;
align-items: center;
gap: 12px;
}
.place {
display: flex;
flex-direction: column;
}
.label {
font-size: 12px;
color: v-bind("theme.textColor2");
}
.big {
display: flex;
align-items: baseline;
gap: 4px;
color: #18a058;
}
.big b {
font-size: 44px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.total {
font-size: 13px;
color: v-bind("theme.textColor3");
margin-left: 2px;
}
.up,
.down {
margin-left: 8px;
font-size: 15px;
font-weight: 600;
}
.down {
color: v-bind("theme.errorColor");
}
.headline {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
border: 1px dashed rgba(24, 160, 88, 0.6);
background: v-bind("theme.cardColor");
color: #18a058;
font-size: 14px;
}
.rival {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
min-width: 0;
}
.rival-name {
flex-shrink: 0;
max-width: 120px;
}
.note {
font-size: 12px;
color: v-bind("theme.textColor3");
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag {
font-size: 11px;
font-weight: 600;
border-radius: 3px;
padding: 0 5px;
line-height: 16px;
white-space: nowrap;
flex-shrink: 0;
}
.tag.chase {
color: #2f6fd0;
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: #c76a12;
background: rgba(199, 106, 18, 0.12);
}
.minis {
display: flex;
gap: 6px;
}
.mini {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
padding: 6px 10px;
border: 0;
border-radius: 4px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
font: inherit;
cursor: pointer;
}
.mini:hover {
color: v-bind("theme.primaryColor");
}
.mini-label {
font-size: 11px;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.mini b {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.mini small {
font-size: 11px;
font-weight: 400;
color: v-bind("theme.textColor3");
}
.mini .none {
font-weight: 400;
color: v-bind("theme.textColor3");
}
</style>
@@ -1,31 +0,0 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
interface Props {
page: number
limit: number
index: number
}
const props = defineProps<Props>()
const index = computed(() => props.index + (props.page - 1) * props.limit + 1)
const tooltip = computed(() => {
if (index.value === 1) return "🏅 金牌"
if (index.value === 2) return "🥈 银牌"
if (index.value === 3) return "🥉 铜牌"
return ""
})
</script>
<template>
<span v-if="index > 3">{{ index }}</span>
<n-tooltip v-else>
<template #trigger>
<n-icon :size="24">
<Icon v-if="index === 1" icon="fluent-emoji:1st-place-medal"></Icon>
<Icon v-if="index === 2" icon="fluent-emoji:2nd-place-medal"></Icon>
<Icon v-if="index === 3" icon="fluent-emoji:3rd-place-medal"></Icon>
</n-icon>
</template>
{{ tooltip }}
</n-tooltip>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { useRankPalette } from "../palette"
import RankAvatar from "./RankAvatar.vue"
/**
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
* 你要追的人站在台上时台阶描蓝边、挂「你要追的」,和赛道里的蓝条是一回事。
*/
const props = defineProps<{
rows: RankRow[]
meId?: number
chaseId?: number
threatId?: number
}>()
defineEmits<{ open: [username: string] }>()
const theme = useThemeVars()
const palette = useRankPalette()
/** 台阶从左到右是 2、1、3 */
const steps = computed(() =>
[2, 1, 3]
.map((place) => ({ place, row: props.rows[place - 1] }))
.filter((step): step is { place: number; row: RankRow } => !!step.row),
)
const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
</script>
<template>
<div class="podium">
<div v-for="{ place, row } in steps" :key="place" class="step" :class="`p${place}`">
<div class="top">
<svg
v-if="place === 1"
class="crown"
width="18"
height="18"
viewBox="0 0 24 24"
fill="#f2c94c"
stroke="#9a6700"
stroke-width="1.6"
stroke-linejoin="round"
role="img"
aria-label="第一名"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<span v-if="row.user.id === chaseId" class="tag chase">你要追的</span>
<span v-else-if="row.user.id === threatId" class="tag threat">在追你</span>
<span v-else-if="row.user.id === meId" class="tag me">你</span>
</div>
<RankAvatar
:username="row.user.username"
:avatar="row.avatar"
:size="place === 1 ? 34 : 28"
:me="row.user.id === meId"
/>
<button class="name" @click="$emit('open', row.user.username)">
<UserName :username="row.user.username" />
</button>
<div
class="block"
:style="{
height: `${HEIGHTS[place]}px`,
background: palette.medal[place - 1]!.background,
boxShadow:
row.user.id === chaseId
? `inset 0 0 0 2px ${palette.chase}`
: row.user.id === meId
? `inset 0 0 0 2px ${palette.me}`
: undefined,
}"
>
<span class="place" :style="{ color: palette.medal[place - 1]!.color }">{{ place }}</span>
<span class="solved"
><b>{{ row.solved }}</b
><small>道</small></span
>
</div>
</div>
<span v-if="!steps.length" class="empty">还没有人做对,现在做对 1 道就是第一名</span>
</div>
</template>
<style scoped>
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 6px;
margin: 0 16px;
border-bottom: 2px solid v-bind("theme.dividerColor");
min-height: 60px;
}
.step {
width: 170px;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.top {
height: 18px;
display: flex;
align-items: center;
gap: 4px;
}
.tag {
font-size: 11px;
font-weight: 600;
border-radius: 3px;
padding: 0 5px;
line-height: 16px;
white-space: nowrap;
}
.tag.chase {
color: v-bind("palette.chase");
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: v-bind("palette.threat");
background: rgba(199, 106, 18, 0.12);
}
.tag.me {
color: v-bind("palette.me");
background: rgba(24, 160, 88, 0.12);
}
.name {
max-width: 100%;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 13px;
cursor: pointer;
display: flex;
min-width: 0;
}
.block {
width: 100%;
box-sizing: border-box;
border-radius: 6px 6px 0 0;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 10px;
padding-top: 5px;
}
.place {
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.solved {
font-variant-numeric: tabular-nums;
}
.solved b {
font-size: 16px;
}
.solved small {
font-size: 11px;
color: v-bind("theme.textColor3");
margin-left: 1px;
}
.empty {
padding: 18px 0;
color: v-bind("theme.textColor3");
font-size: 13px;
}
</style>
@@ -0,0 +1,323 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { useThemeVars, type DropdownOption } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useRankPalette } from "../palette"
import RankAvatar from "./RankAvatar.vue"
/**
* 赛道:每人一根条,条越长做对越多,一样长的先做到的排前面(设计稿 G1 / G2)。
* 桌面两栏,机房 1366×768 一屏放得下一个班;本年级 / 全服只给前面一段 + 我附近一段,
* 名次断开的地方折起来,点了再把中间那段要回来。
*/
const props = defineProps<{
rows: RankRow[]
total: number
complete: boolean
/** 条的满格是几道(第 2 名的数,第 1 名常常一骑绝尘) */
scale: number
meId?: number
chaseId?: number
threatId?: number
hotId?: number
/** 本年级 / 全服里「你们班」的班号,这些人的条浅绿 */
mateClass?: string | null
teacher?: boolean
expanding?: boolean
}>()
const emit = defineEmits<{
open: [username: string]
expand: []
submissions: [username: string]
hide: [row: RankRow]
}>()
const theme = useThemeVars()
const palette = useRankPalette()
const { isDesktop } = useBreakpoints()
type Item =
| { kind: "row"; row: RankRow }
| { kind: "gap"; from: number; to: number; tail?: boolean; mine?: boolean }
const items = computed<Item[]>(() => {
const list: Item[] = []
let last = props.rows[0] ? props.rows[0].rank - 1 : 0
for (const row of props.rows) {
if (row.rank > last + 1) list.push({ kind: "gap", from: last + 1, to: row.rank - 1 })
list.push({ kind: "row", row })
last = row.rank
}
if (!props.complete && last < props.total)
list.push({ kind: "gap", from: last + 1, to: props.total, tail: true })
return list
})
/** 两栏:前一半、后一半。手机一栏 */
const columns = computed(() => {
if (!isDesktop.value) return [items.value]
const half = Math.ceil(items.value.length / 2)
return [items.value.slice(0, half), items.value.slice(half)]
})
function barColor(row: RankRow) {
const id = row.user.id
if (id === props.meId) return palette.value.me
if (id === props.chaseId) return palette.value.chase
if (id === props.threatId) return palette.value.threat
if (props.mateClass && row.className === props.mateClass) return palette.value.mate
return palette.value.bar
}
function width(row: RankRow) {
if (!row.solved) return "0"
return `${Math.max(1.5, Math.min(100, (row.solved / Math.max(1, props.scale)) * 100))}%`
}
const menu: DropdownOption[] = [
{ label: "看他的提交", key: "submissions" },
{ label: "不计入排名…", key: "hide" },
]
function onMenu(key: string, row: RankRow) {
if (key === "submissions") emit("submissions", row.user.username)
else emit("hide", row)
}
</script>
<template>
<div class="track" :class="{ single: !isDesktop }">
<div v-for="(column, index) in columns" :key="index" class="column">
<template
v-for="item in column"
:key="item.kind === 'row' ? item.row.user.id : `gap${item.from}`"
>
<button
v-if="item.kind === 'gap'"
class="gap"
:disabled="expanding"
@click="emit('expand')"
>
第 {{ item.from }}–{{ item.to }} 名 · {{ item.to - item.from + 1 }} 人 · 展开 ▾
</button>
<div v-else class="lane" :class="{ me: item.row.user.id === meId, zero: !item.row.solved }">
<span class="rank">{{ item.row.solved ? item.row.rank : "—" }}</span>
<RankAvatar
:username="item.row.user.username"
:avatar="item.row.avatar"
:size="16"
:me="item.row.user.id === meId"
/>
<button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" />
</button>
<span class="bar-box">
<span class="bar" :style="{ width: width(item.row), background: barColor(item.row) }" />
</span>
<span class="solved">{{ item.row.solved }}</span>
<span class="change">
<span v-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
<span v-else-if="(item.row.change ?? 0) < 0" class="down"
>↓{{ -item.row.change! }}</span
>
<span v-else-if="item.row.change === 0" class="flat">—</span>
</span>
<span class="tag-box">
<span v-if="item.row.user.id === chaseId" class="tag chase">要追</span>
<span v-else-if="item.row.user.id === threatId" class="tag threat">追你</span>
<span v-else-if="item.row.user.id === hotId" class="tag hot">冲得最猛</span>
</span>
<n-dropdown
v-if="teacher"
trigger="click"
:options="menu"
@select="(key: string) => onMenu(key, item.row)"
>
<button class="more" aria-label="更多">⋯</button>
</n-dropdown>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.track {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 36px;
padding: 10px 24px 14px;
}
.track.single {
grid-template-columns: 1fr;
padding: 10px 12px 14px;
}
.column {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.lane {
height: 18px;
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
}
.single .lane {
height: 26px;
}
.single .who {
width: 96px;
}
.single .tag-box {
width: auto;
}
.lane.me {
margin: 0 -8px;
padding: 0 8px;
height: 20px;
border-radius: 4px;
background: rgba(24, 160, 88, 0.12);
}
.lane.zero {
opacity: 0.55;
}
.rank {
width: 28px;
text-align: right;
font-size: 12px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.me .rank {
color: #18a058;
font-weight: 700;
}
.who {
width: 108px;
flex-shrink: 0;
padding: 0;
border: 0;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
display: flex;
min-width: 0;
}
.bar-box {
flex-grow: 1;
height: 11px;
position: relative;
min-width: 40px;
}
.bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px 6px 6px 2px;
}
.solved {
width: 26px;
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.change {
width: 34px;
font-size: 12px;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.up {
color: #18a058;
font-weight: 600;
}
.down {
color: v-bind("theme.errorColor");
}
.flat {
color: v-bind("theme.textColor3");
}
.tag-box {
width: 58px;
display: flex;
flex-shrink: 0;
}
.tag {
font-size: 11px;
font-weight: 600;
border-radius: 3px;
padding: 0 5px;
line-height: 16px;
white-space: nowrap;
}
.tag.chase {
color: v-bind("palette.chase");
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: v-bind("palette.threat");
background: rgba(199, 106, 18, 0.12);
}
.tag.hot {
color: #ffffff;
background: #c76a12;
}
.more {
width: 18px;
padding: 0;
border: 0;
background: none;
color: v-bind("theme.textColor2");
font-weight: 700;
cursor: pointer;
}
.gap {
height: 22px;
margin: 4px 0;
border: 0;
border-top: 1px dashed v-bind("theme.borderColor");
border-bottom: 1px dashed v-bind("theme.borderColor");
background: none;
font: inherit;
font-size: 12px;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.gap:hover {
color: v-bind("theme.primaryColor");
}
</style>
@@ -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>
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
import { useDark } from "@vueuse/core"
/**
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、你要追的蓝、
* 在追你的橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。
* 暗色下灰条和浅绿换深一档,不然在暗底上发白。
*/
export function useRankPalette() {
const isDark = useDark()
return computed(() => ({
me: "#18a058",
chase: "#2f6fd0",
threat: "#c76a12",
mate: isDark.value ? "#2f6b47" : "#a8dcbd",
bar: isDark.value ? "#4a525c" : "#c9d3dc",
medal: [
{
color: isDark.value ? "#e2b33b" : "#9a6700",
background: isDark.value ? "rgba(240,180,40,0.22)" : "#fbf1d9",
},
{
color: isDark.value ? "#a7b1bd" : "#5f6b7a",
background: isDark.value ? "rgba(120,135,155,0.22)" : "#eef1f4",
},
{
color: isDark.value ? "#c58b62" : "#8c5a3c",
background: isDark.value ? "rgba(170,110,70,0.22)" : "#f6ece5",
},
],
}))
}
export type RankPalette = ReturnType<typeof useRankPalette>["value"]
+63
View File
@@ -0,0 +1,63 @@
import type { RankPeriod, RankRow, RankScope } from "@oj2/contract"
import { classLabel } from "oj/submission/utils"
export const SCOPE_OPTIONS: { value: RankScope; label: string }[] = [
{ value: "class", label: "本班" },
{ value: "grade", label: "本年级" },
{ value: "all", label: "全服" },
]
export const PERIOD_OPTIONS: { value: RankPeriod; label: string }[] = [
{ value: "week", label: "这周" },
{ value: "term", label: "这学期" },
{ value: "all", label: "全部" },
]
export function scopeLabel(scope: RankScope) {
return SCOPE_OPTIONS.find((option) => option.value === scope)!.label
}
export function periodLabel(period: RankPeriod) {
return PERIOD_OPTIONS.find((option) => option.value === period)!.label
}
/** 榜的标题:本班写班名,本年级写「25 级」,全服就是「全服」 */
export function groupTitle(scope: RankScope, className: string | null) {
if (scope === "class" && className) return classLabel(className)
if (scope === "grade" && className) return `${className} 级`
return "全服"
}
/**
* 两个人做到同样多道的时间差,说成学生的话:「6 秒」「2 分 40 秒」「3 小时」「2 天」。
* 一节课的最后一道,全班常常差在几秒到几十秒 —— 这就是竞争感的来源,秒要写出来。
*/
export function gapText(earlier: string, later: string) {
const seconds = Math.max(0, Math.round((Date.parse(later) - Date.parse(earlier)) / 1000))
if (seconds === 0) return "同一秒"
if (seconds < 60) return `${seconds} 秒`
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`
}
if (seconds < 86_400) return `${Math.floor(seconds / 3600)} 小时`
return `${Math.floor(seconds / 86_400)} 天`
}
/**
* 再做对几道能超过 `target`:一样多时先做到的在前,后做到的人只能靠多做 1 道超过 ——
* 所以永远是「差几道 + 1」。
*/
export function toPass(me: RankRow, target: RankRow) {
return target.solved - me.solved + 1
}
/** 这张榜上升得最猛的人(至少升 3 名才算),给他挂「冲得最猛」 */
export function hottest(rows: RankRow[]) {
let best: RankRow | null = null
for (const row of rows) {
if ((row.change ?? 0) >= 3 && (!best || row.change! > best.change!)) best = row
}
return best
}