Files
OJ2/apps/web/src/oj/contest/pages/rank.vue
T
xuyueandClaude Opus 5.5 959c326bc9
Deploy / deploy (push) Canceled after 0s
style(名字): 名字列按去掉 ks 前缀后的长度收窄,不再留一大块空
原来的定宽是按 ks2312李鑫楠 定的(110~170px),现在名字最长 4 个字(在读的班 423 人里 5 人)。
只放名字的列收到 80px 左右;混着几个班、名字后面还有班号的(全部提交、全服 / 年级排名、
卡点分析没选班时)108px,最宽的「翁鑫叶炫 2312」95px 也放得下。改了提交列表、排名赛道和周榜、
比赛排名和成绩、题单全班情况、数据统计、后台自学情况和卡点分析、请假名单

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:51:28 +08:00

546 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 'shared/composables/tone'
import { useUserStore } from 'shared/store/user'
import { ContestStatus } from 'utils/constants'
import { secondsToDuration } from 'utils/functions'
import { classLabel } from 'utils/classes'
import UserName from 'shared/components/UserName.vue'
import { useVisibleInterval } from 'shared/composables/visibleInterval'
/**
* 比赛排名(设计稿「比赛重设计」排行榜两块)。竞争感是用户要的(「我需要竞争」):
* 名次变化箭头、这题最先做对的深绿格、按班筛、自己那行一直看得见;结束后前三名上领奖台。
* 原来的前十名排名曲线不要了(用户定的),学生的「导出数据」也拿掉了 —— 导出在老师的成绩页
*/
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 } = useVisibleInterval(() => contestStore.loadScoreboard(props.contestID), 10_000)
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 narrow = useMediaQuery('(max-width: 640px)')
const columns = computed(() => {
const move = running.value ? '36px ' : ''
const count = problems.value.length
// 名字列:名字最长 4 个字(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽)
return narrow.value ? `28px ${move}76px 36px 70px repeat(${count}, 52px)` : `40px ${move}96px 48px 80px repeat(${count}, 64px)`
})
</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" />
<span class="muted small podium-meta">
<span>{{ classLabel(podium[place - 1]!.className) }}</span
><span class="sep"> · </span><span>{{ podium[place - 1]!.solved }} 道 · {{ secondsToDuration(podium[place - 1]!.totalTime) }}</span>
</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" />
<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>
</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: var(--oj-fs-sec);
}
.spacer {
flex-grow: 1;
}
.podium-row {
padding: 20px var(--oj-pad-x) 0;
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 24px;
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: 16px;
max-width: 200px;
}
.p1 .podium-name {
font-size: 18px;
}
.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: 340px;
margin-bottom: 20px;
padding: 18px 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
border-radius: var(--oj-radius);
background: v-bind('success.background');
}
.mine-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.mine-big {
font-size: 30px;
font-weight: 800;
line-height: 1;
color: v-bind('success.color');
}
.tools {
min-height: 56px;
box-sizing: border-box;
padding: 10px var(--oj-pad-x);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
border-bottom: 1px solid v-bind('theme.dividerColor');
}
.chip {
height: 32px;
padding: 0 14px;
border-radius: 16px;
border: 1px solid v-bind('theme.borderColor');
background: transparent;
font: inherit;
font-size: var(--oj-fs-sec);
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 - 246px);
overflow: auto;
}
.tr {
display: grid;
align-items: center;
column-gap: 6px;
min-width: max-content;
height: 42px;
padding: 0 var(--oj-pad-x);
font-size: var(--oj-fs-body);
border-bottom: 1px solid v-bind('theme.dividerColor');
background: v-bind('theme.cardColor');
}
.th {
position: sticky;
top: 0;
z-index: 2;
height: 48px;
font-size: var(--oj-fs-meta);
color: v-bind('theme.textColor3');
background: v-bind('theme.actionColor');
}
.tr.me {
position: sticky;
bottom: 0;
top: 48px;
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: var(--oj-fs-meta);
color: v-bind('theme.textColor3');
padding-right: 8px;
}
.move {
font-size: 12px;
}
.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: var(--oj-fs-sec);
color: v-bind('theme.primaryColor');
}
.ph:hover b {
text-decoration: underline;
}
.ph span {
font-size: 12px;
}
.cell-wrap {
display: flex;
}
.cell {
width: 100%;
height: 30px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
font-size: var(--oj-fs-meta);
font-variant-numeric: tabular-nums;
}
.cell small {
font-size: 11px;
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: 32px var(--oj-pad-x);
}
/* 手机:三级台阶一共 646px 宽,原样摆第一名就被挤到屏幕外了。三块平分一行,
名字截断、班级和用时折成两行 */
@media (max-width: 640px) {
.podium-row {
gap: 16px;
}
.podium {
width: 100%;
gap: 6px;
}
.step {
flex: 1 1 0;
width: auto;
min-width: 0;
}
.podium-name,
.p1 .podium-name {
font-size: 15px;
max-width: 100%;
}
.podium-meta {
font-size: var(--oj-fs-meta);
text-align: center;
}
.podium-meta > span {
display: block;
white-space: nowrap;
}
.podium-meta > .sep {
display: none;
}
.mine {
width: 100%;
}
}
</style>