Files
OJ2/apps/web/src/oj/contest/pages/rank.vue
T
xuyueandClaude Opus 5.5 151bbdcb50
Deploy / deploy (push) Canceled after 0s
feat(比赛): 比赛页一页看题目和自己的名次;排名竞争感做足、结束有领奖台;老师考试中看全班、考完看成绩;新建比赛一屏做完
- 学生比赛页(设计稿 B):左边题目带几人做对、谁最先做对;右边我第几名、5 分钟里升了几名、再做对 1 道能到第几名、我前后的人
- 期中、期末考试中只有自己的进度,写明「排名考完公布」,题目页的排名入口也藏起来
- 排名页:按班筛、名次变化、最先做对的深绿格、自己那行钉住;结束后前三名领奖台 + 我的成绩卡;排名曲线和学生的「导出数据」去掉
- 老师的「全班情况」:卡在一道题、15 分钟没交的放最上面,没进来的人对上看板的请假;考完变「成绩」,点格子看代码、标看过了(代替后台审核页),导出成绩
- 比赛列表:进行中的一大块「接着比」,已结束的写我第几名,前三带奖杯
- 新建比赛:默认 10 分钟后开始,时长点按钮,搜题号把题加进来自动编号、拖动排顺序

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:58:59 +08:00

593 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { ContestScoreCell, ContestScoreRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useContestStore } from "oj/store/contest"
import { useTone } from "oj/submission/composables/tone"
import { useUserStore } from "shared/store/user"
import { ContestStatus } from "utils/constants"
import { secondsToDuration } from "utils/functions"
import { classLabel } from "oj/submission/utils"
import UserName from "../components/UserName.vue"
/**
* 比赛排名(设计稿「比赛重设计」排行榜两块)。竞争感是用户要的(「我需要竞争」):
* 名次变化箭头、这题最先做对的深绿格、按班筛、自己那行一直看得见;结束后前三名上领奖台。
* 原来的前十名排名曲线不要了(用户定的),学生的「导出数据」也拿掉了 —— 导出在老师的成绩页
*/
const props = defineProps<{ contestID: string }>()
const router = useRouter()
const contestStore = useContestStore()
const userStore = useUserStore()
const theme = useThemeVars()
const tone = useTone()
const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
const autoRefresh = ref(true)
const { pause, resume } = useIntervalFn(
() => contestStore.loadScoreboard(props.contestID),
10_000,
{
immediate: false,
},
)
watch(
() => [contestStore.canEnter, running.value, autoRefresh.value] as const,
([enter, run, auto]) => {
if (!enter) return
if (!contestStore.scoreboard) contestStore.loadScoreboard(props.contestID)
if (run && auto) resume()
else pause()
},
{ immediate: true },
)
watch(autoRefresh, (on) => on && contestStore.loadScoreboard(props.contestID))
const board = computed(() => contestStore.scoreboard)
const allRows = computed(() => board.value?.rows ?? [])
const problems = computed(() => board.value?.problems ?? [])
const meId = computed(() => userStore.user?.id)
const me = computed(() => allRows.value.find((row) => row.userId === meId.value))
const classFilter = ref("")
const classes = computed(() => {
const count = new Map<string, number>()
for (const row of allRows.value) {
if (row.className) count.set(row.className, (count.get(row.className) ?? 0) + 1)
}
return [...count.entries()].sort(([a], [b]) => a.localeCompare(b))
})
const rows = computed(() =>
classFilter.value
? allRows.value.filter((row) => row.className === classFilter.value)
: allRows.value,
)
const updatedAgo = computed(() => {
if (!contestStore.scoreboardAt) return ""
const seconds = Math.max(0, Math.floor((contestStore.now - contestStore.scoreboardAt) / 1000))
return seconds < 5 ? "刚刚" : `${seconds} 秒前`
})
const podium = computed(() => (ended.value ? allRows.value.slice(0, 3) : []))
/** 我在自己班里第几 */
const classPlace = computed(() => {
const row = me.value
if (!row?.className) return null
return allRows.value.filter((r) => r.className === row.className && r.rank! <= row.rank!).length
})
function move(row: ContestScoreRow) {
return running.value && row.rank && row.prevRank ? row.prevRank - row.rank : 0
}
function cellOf(row: ContestScoreRow, problemId: number): ContestScoreCell | undefined {
return row.cells[String(problemId)]
}
function openProblem(displayId: string) {
router.push(`/contest/${props.contestID}/problem/${displayId}`)
}
const success = computed(() => tone("success"))
const meBackground = computed(
() =>
`linear-gradient(${success.value.background}, ${success.value.background}), ${theme.value.cardColor}`,
)
const danger = computed(() => tone("error"))
const columns = computed(
() =>
`34px ${running.value ? "34px " : ""}150px 40px 70px repeat(${problems.value.length}, 58px)`,
)
</script>
<template>
<div v-if="contestStore.rankHidden" class="locked">
<b>{{ contestStore.contest?.tag }}考试,排名考完公布</b>
<span class="muted">考完回来就能看到全部名次和每道题的做对情况</span>
</div>
<div v-else-if="board" class="rank-page">
<div v-if="podium.length" class="podium-row">
<div class="podium">
<div
v-for="place in [2, 1, 3]"
v-show="podium[place - 1]"
:key="place"
class="step"
:class="`p${place}`"
>
<svg
:width="place === 1 ? 26 : 22"
:height="place === 1 ? 26 : 22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="cup"
>
<path d="M8 4h8v5a4 4 0 0 1-8 0z" />
<path d="M16 6h3v1a3 3 0 0 1-3 3" />
<path d="M8 6H5v1a3 3 0 0 0 3 3" />
<path d="M12 13v4" />
<path d="M8 20h8" />
</svg>
<template v-if="podium[place - 1]">
<UserName
class="podium-name"
:username="podium[place - 1]!.username"
:class-name="podium[place - 1]!.className"
strong
/>
<span class="muted small">
{{ classLabel(podium[place - 1]!.className) }} · {{ podium[place - 1]!.solved }} 道 ·
{{ secondsToDuration(podium[place - 1]!.totalTime) }}
</span>
</template>
<div class="block">{{ place }}</div>
</div>
</div>
<div class="spacer"></div>
<div v-if="me" class="mine">
<span class="muted small">你的成绩</span>
<div class="mine-line">
<span class="mine-big">第 {{ me.rank }} 名</span
><span class="muted">/ {{ allRows.length }} 人</span>
</div>
<span class="small sec">
做对 {{ me.solved }} 道 · 用时 {{ secondsToDuration(me.totalTime) }}
<template v-if="classPlace && classes.length > 1">
· {{ classLabel(me.className) }}里第 {{ classPlace }}</template
>
</span>
</div>
</div>
<div class="tools">
<template v-if="classes.length > 1">
<button class="chip" :class="{ on: !classFilter }" @click="classFilter = ''">
全部 {{ allRows.length }}
</button>
<button
v-for="[name, n] in classes"
:key="name"
class="chip"
:class="{ on: classFilter === name }"
@click="classFilter = name"
>
{{ classLabel(name) }} {{ n }}
</button>
</template>
<span v-else class="muted small">{{ allRows.length }} 人</span>
<div class="spacer"></div>
<template v-if="running">
<n-switch v-model:value="autoRefresh" size="small" />
<span class="small">自动刷新</span>
<span v-if="autoRefresh" class="muted small">{{ updatedAgo }}</span>
</template>
</div>
<div class="table">
<div class="tr th" :style="{ gridTemplateColumns: columns }">
<span class="num">名次</span>
<span v-if="running"></span>
<span class="pad">学生</span>
<span class="num">做对</span>
<span class="num">用时</span>
<button
v-for="p in problems"
:key="p.id"
class="ph"
:title="p.title"
@click="openProblem(p._id)"
>
<b>{{ p._id }}</b>
<span>{{ p.solvedUsers }} 人</span>
</button>
</div>
<div
v-for="row in rows"
:key="row.userId"
class="tr"
:class="{ me: row.userId === meId }"
:style="{ gridTemplateColumns: columns }"
>
<span class="num rank">{{ row.rank }}</span>
<span v-if="running" class="move">
<span v-if="move(row) > 0" class="up">↑{{ move(row) }}</span>
<span v-else-if="move(row) < 0" class="down">↓{{ -move(row) }}</span>
</span>
<UserName
class="pad"
:username="row.username"
:class-name="row.className"
:strong="row.userId === meId"
:title="row.realName ?? row.username"
/>
<span class="num solved">{{ row.solved }}</span>
<span class="num time">{{ secondsToDuration(row.totalTime) }}</span>
<span v-for="p in problems" :key="p.id" class="cell-wrap">
<template v-if="cellOf(row, p.id)">
<span
v-if="cellOf(row, p.id)!.isAc"
class="cell ac"
:class="{ first: cellOf(row, p.id)!.firstAc }"
:title="cellOf(row, p.id)!.firstAc ? '这题最先做对' : undefined"
>
{{ Math.floor(cellOf(row, p.id)!.acTime / 60)
}}<small v-if="cellOf(row, p.id)!.errors">-{{ cellOf(row, p.id)!.errors }}</small>
</span>
<span v-else class="cell no">{{
cellOf(row, p.id)!.errors ? `-${cellOf(row, p.id)!.errors}` : "交过"
}}</span>
</template>
</span>
</div>
<div v-if="!rows.length" class="empty muted">还没有人交题</div>
</div>
<div class="legend">
<span><i class="sw first"></i>这题最先做对</span>
<span><i class="sw ac"></i>做对,格子里是第几分钟(-1 是错过 1 次)</span>
<span><i class="sw no"></i>还没对,错了几次</span>
<div class="spacer"></div>
<span>用时 = 每道做对题的分钟数相加,错一次多算 20 分钟</span>
</div>
</div>
</template>
<style scoped>
.locked {
padding: 80px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
font-size: 16px;
}
.rank-page {
display: flex;
flex-direction: column;
}
.muted {
color: v-bind("theme.textColor3");
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: 13px;
}
.spacer {
flex-grow: 1;
}
.podium-row {
padding: 12px 20px 0;
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 20px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.podium {
display: flex;
align-items: flex-end;
gap: 8px;
}
.step {
width: 210px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.podium-name {
font-size: 15px;
max-width: 200px;
}
.p1 .podium-name {
font-size: 17px;
}
.block {
width: 100%;
box-sizing: border-box;
padding-top: 6px;
border-radius: 6px 6px 0 0;
text-align: center;
font-size: 22px;
font-weight: 800;
}
.p1 .cup,
.p1 .block {
color: #9a6700;
}
.p1 .block {
height: 66px;
background: rgba(240, 180, 40, 0.2);
}
.p2 .cup,
.p2 .block {
color: #5f6b7a;
}
.p2 .block {
height: 46px;
background: rgba(120, 135, 155, 0.18);
}
.p3 .cup,
.p3 .block {
color: #8c5a3c;
}
.p3 .block {
height: 32px;
background: rgba(170, 110, 70, 0.16);
}
.mine {
width: 330px;
margin-bottom: 16px;
padding: 14px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 6px;
border-radius: 6px;
background: v-bind("success.background");
}
.mine-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.mine-big {
font-size: 28px;
font-weight: 800;
line-height: 1;
color: v-bind("success.color");
}
.tools {
min-height: 48px;
box-sizing: border-box;
padding: 8px 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.chip {
height: 26px;
padding: 0 10px;
border-radius: 13px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
white-space: nowrap;
}
.chip.on {
border-color: transparent;
background: v-bind("success.background");
color: v-bind("success.color");
font-weight: 600;
}
.table {
max-height: calc(100vh - 230px);
overflow: auto;
}
.tr {
display: grid;
align-items: center;
column-gap: 6px;
min-width: max-content;
height: 32px;
padding: 0 20px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.cardColor");
}
.th {
position: sticky;
top: 0;
z-index: 2;
height: 44px;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
}
.tr.me {
position: sticky;
bottom: 0;
top: 44px;
z-index: 1;
/* 浅绿是半透明的,钉住时底下的行会透上来,垫一层卡片底色 */
background: v-bind("meBackground");
box-shadow: inset 3px 0 0 v-bind("theme.successColor");
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pad {
padding-left: 6px;
min-width: 0;
}
.rank {
font-weight: 700;
}
.solved {
font-weight: 700;
}
.time {
font-size: 12px;
color: v-bind("theme.textColor3");
padding-right: 8px;
}
.move {
font-size: 11px;
}
.up {
color: v-bind("success.color");
}
.down {
color: v-bind("danger.color");
}
.ph {
border: 0;
padding: 0;
background: transparent;
font: inherit;
display: flex;
flex-direction: column;
align-items: center;
line-height: 16px;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.ph b {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.ph span {
font-size: 11px;
}
.cell-wrap {
display: flex;
}
.cell {
width: 100%;
height: 26px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.cell small {
font-size: 10px;
opacity: 0.8;
}
.cell.ac {
font-weight: 600;
background: v-bind("success.background");
color: v-bind("success.color");
}
.cell.ac.first {
background: v-bind("theme.successColor");
color: #ffffff;
}
.cell.no {
background: v-bind("danger.background");
color: v-bind("danger.color");
}
.empty {
padding: 24px 20px;
}
.legend {
min-height: 34px;
padding: 6px 20px;
box-sizing: border-box;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: v-bind("theme.textColor3");
border-top: 1px solid v-bind("theme.dividerColor");
}
.legend span {
display: flex;
align-items: center;
gap: 6px;
}
.sw {
width: 28px;
height: 16px;
border-radius: 3px;
}
.sw.first {
background: v-bind("theme.successColor");
}
.sw.ac {
background: v-bind("success.background");
}
.sw.no {
background: v-bind("danger.background");
}
</style>