Files
OJ2/apps/web/src/oj/contest/pages/rank.vue
yuetsh 5222e012e1 fix(比赛): 修四处 —— 倒计时两倍速、排名不自动刷新、题目状态恒空、比赛隐藏后审核页取不到代码
查比赛功能时实跑出来的四个问题,都在这一条里修掉:

**倒计时两倍速**(store/contest.ts)。init() 里 setInterval 之前不清旧表,而
detail.vue 在「未开始 → 进行中」那一刻会再 init 一次(为了捞开赛后才拿得到的题),
于是两个 interval 一起给 now 加 1000。学生赛前挂着页面就会中招:一场 60 分钟的
比赛,真过了 30 分钟页面就显示「已结束」、倒计时归零,而服务端还在正常收提交。
ojnext 里就有,是原样搬过来的。

**排名页「开启自动刷新」开着但不刷新**(contest/pages/rank.vue)。useIntervalFn
传的是 immediate: false,而 watch(autoRefresh) 只在开关变化时才 resume ——
开关初值就是 true、进页面不产生变化,表从没启动过,得手动关一次再开。改成
watchEffect,由「开关 + 比赛进行中」共同驱动,顺带不再在赛后空转轮询。同样来自
ojnext。

**比赛题的 myStatus 恒为 null**(routes/contest.ts)。判题其实把状态记进了
user_profile 的 acm_problems_status.contest_problems,只是这两条路由硬编码下发
空值,于是题目页的「状态」列永远是「未做」,赛后也不恢复。旧后端在赛后/管理员
视角是给的,这是回归。不按赛中赛后分档:这是学生自己的判题结果,不泄露别人任何
信息(旧后端赛中不给,只是因为它整条路换了个 serializer)。

**比赛一隐藏,审核页的「查看代码」必 404**(services/contest.ts)。acm-helper
故意不卡 visible(赛后核查恰恰发生在比赛收起来之后),它调的比赛提交列表却卡着,
两边对不上。findVisibleContest 换成 findAccessibleContest:公开的谁都取得到,
隐藏的只有比赛管理员取得到,学生看隐藏比赛照旧 404。

实跑验证(dev 全栈,判题走临时 worker 绕开本机 token 不一致):

- 浏览器跨过开赛时刻挂着不动 —— 墙钟 20.0 秒,倒计时正好减 20 秒(原来会减 40)。
- 排名页停在「无数据」,另一账号提交一发 AC,5 秒内表格自己长出
  `1 student2 1/3 0:00:42`,没刷新页面。
- 学生 AC 后列表和详情都回 myStatus: 0,没做过的另一个学生仍是 null,匿名照旧 401。
- 比赛隐藏 + 已结束:出题人 detail / problems / rank / submissions / acm-helper
  全 200,学生这 5 条全 404,提交也 404,隐藏比赛不进公开列表。
- 排名记账口径未受影响:10 次提交(8 编译失败 + 2 AC)落库 submission_number=9、
  accepted_number=1、total_time=231=ac_time、is_first_ac=true。

tsc / vue-tsc / check:routes(175 条无遮蔽)均干净,测试数据已清库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
2026-09-07 19:19:29 -06:00

361 lines
10 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { Icon } from "@iconify/vue"
import { NButton, useThemeVars } from "naive-ui"
import { getContestProblems, getContestRank } from "oj/api"
import { secondsToDuration } from "utils/functions"
import { useContestStore } from "oj/store/contest"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { ContestStatus } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import type { ContestRank, ProblemFiltered } from "utils/types"
import AcAndSubmission from "../components/AcAndSubmission.vue"
import LineChart from "../components/LineChart.vue"
interface Props {
contestID: string
}
const props = defineProps<Props>()
const route = useRoute()
const router = useRouter()
const theme = useThemeVars()
const contestStore = useContestStore()
const total = ref(0)
const data = ref<ContestRank[]>([])
const chart = ref<ContestRank[]>([])
const problems = ref<ProblemFiltered[]>([])
const [autoRefresh] = useToggle(true)
const { resume, pause } = useIntervalFn(
() => {
query.page = 1
listRanks()
},
10000,
{
immediate: false,
},
)
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 50 })
const columns = ref<DataTableColumn<ContestRank>[]>([
{
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "id",
width: 80,
fixed: "left",
align: "center",
render: (_, index) => index + (query.page - 1) * query.limit + 1,
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
width: 120,
fixed: "left",
align: "center",
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
},
{
title: renderTableTitle(
"正确数/总提交",
"streamline-ultimate-color:color-palette",
),
key: "submission",
width: 140,
align: "center",
render: (row) => h(AcAndSubmission, { rank: row }),
},
{
title: "总时间",
key: "total_time",
width: 120,
align: "center",
render: (row) => secondsToDuration(row.totalTime),
},
])
async function listRanks() {
const res = await getContestRank(props.contestID, {
limit: query.limit,
offset: query.limit * (query.page - 1),
})
total.value = res.total
data.value = res.results
if (query.page === 1) {
chart.value = data.value
}
}
async function addColumns() {
try {
problems.value = await getContestProblems(props.contestID)
problems.value.map((problem) => {
columns.value.push({
align: "center",
title: () =>
h(
NButton,
{
text: true,
type: "primary",
onClick: () => {
const data = router.resolve({
name: "contest problem",
params: {
contestID: route.params.contestID,
problemID: problem._id,
},
})
window.open(data.href, "_blank")
},
},
() => problem.title,
),
render: (row) => {
if (row.submissionInfo[problem.id]) {
const status = row.submissionInfo[problem.id]
let acTime
let errorNumber
if (status.is_ac) {
acTime = h("span", secondsToDuration(status.ac_time))
}
if (status.is_first_ac) {
acTime = [
h(Icon, {
icon: "fluent-emoji:1st-place-medal",
height: 20,
width: 20,
}),
h("span", secondsToDuration(status.ac_time)),
]
}
if (status.error_number) {
errorNumber = h(
"span",
{ style: "margin: 0" },
`(-${status.error_number})`,
)
}
return h("div", { class: "oj-time-with-modal" }, [
acTime,
errorNumber,
])
}
},
cellProps: (row) => {
let backgroundColor = ""
let color = theme.value.textColorBase
if (row.submissionInfo[problem.id]) {
const status = row.submissionInfo[problem.id]
if (status.is_first_ac) {
backgroundColor = theme.value.primaryColor
color = theme.value.baseColor
} else if (status.is_ac) {
const success = theme.value.successColor
backgroundColor = success + "50"
color = theme.value.textColorBase
} else {
const error = theme.value.errorColor
backgroundColor = error + "50"
color = theme.value.textColorBase
}
}
return { style: { backgroundColor, color } }
},
key: problem.id,
width: 150,
ellipsis: true,
})
})
} catch (err) {
problems.value = []
}
}
// 导出弹窗
const showExportModal = ref(false)
const exportLoading = ref(false)
const exportForm = reactive({
first: 0,
second: 0,
third: 0,
})
watch(
() => total.value,
(val) => {
if (val > 0) {
exportForm.first = Math.round(val * 0.1)
exportForm.second = Math.round(val * 0.2)
exportForm.third = Math.round(val * 0.3)
}
},
)
function openExportModal() {
if (total.value > 0) {
exportForm.first = Math.round(total.value * 0.1)
exportForm.second = Math.round(total.value * 0.2)
exportForm.third = Math.round(total.value * 0.3)
}
showExportModal.value = true
}
async function downloadExcel() {
exportLoading.value = true
try {
// 自己翻页凑齐全量:后端 limit 上限 250而**超出上限不是截断、是静默回落到
// 默认的 10**routes/helpers.ts 的 queryInteger。原来这里传 total或 10000
// 想一次拉完,参赛超过 250 人时只会拿回 10 行,而下面的等级分档仍按真实总人数算 ——
// 老师拿到的是一份 10 个人、等级全错的名单,还不报错
const PAGE = 250
const allRanks: ContestRank[] = []
for (;;) {
const res = await getContestRank(props.contestID, {
limit: PAGE,
offset: allRanks.length,
})
allRanks.push(...res.results)
// 两个出口都要留:拿不满一页说明到底了;比对 total 是防着最后一页正好整除
if (res.results.length < PAGE || allRanks.length >= res.total) break
}
const rows = allRanks.map((rank, index) => {
const rank1 = index + 1
let level = ""
if (rank1 <= exportForm.first) {
level = "一等奖"
} else if (rank1 <= exportForm.first + exportForm.second) {
level = "二等奖"
} else if (
rank1 <=
exportForm.first + exportForm.second + exportForm.third
) {
level = "三等奖"
} else {
level = "参与奖"
}
return { 用户名: rank.user.username, 等级: level }
})
const csv =
"用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${contestStore.contest?.title ?? "contest"}获奖情况.csv`
a.click()
URL.revokeObjectURL(url)
showExportModal.value = false
} finally {
exportLoading.value = false
}
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], listRanks)
// 自动刷新只在比赛进行中有意义(开关本身也只在这一档渲染),所以由「开关 + 比赛状态」
// 一起驱动。原来只 watch(autoRefresh):开关初值就是 true、进页面不产生变化
// useIntervalFn 建的时候又传了 immediate: false于是表从没启动过 —— 开关明明是开着的,
// 排名却一直不刷新,得手动关一次再开。
watchEffect(() => {
const running = contestStore.contestStatus === ContestStatus.underway
if (autoRefresh.value && running) resume()
else pause()
})
onMounted(() => {
listRanks()
addColumns()
})
</script>
<template>
<!-- 排名变化图表 -->
<LineChart :ranks="chart" :problems="problems" v-if="chart.length > 0" />
<!-- 排名表格 -->
<n-data-table
striped
:single-line="false"
:scroll-x="1200"
:columns="columns"
:data="data"
/>
<n-space justify="end" align="center">
<n-form
label-placement="left"
inline
:show-feedback="false"
v-if="contestStore.contestStatus === ContestStatus.underway"
>
<n-form-item label="开启自动刷新">
<n-switch v-model:value="autoRefresh" />
</n-form-item>
</n-form>
<n-button
v-if="contestStore.contestStatus === ContestStatus.finished"
type="primary"
@click="openExportModal"
>
导出数据
</n-button>
<Pagination
:total="total"
:limit="query.limit"
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</n-space>
<n-modal v-model:show="showExportModal" preset="dialog" title="导出获奖数据">
<n-form
label-placement="left"
label-width="auto"
:show-feedback="false"
style="margin-top: 16px"
>
<n-form-item label="一等奖人数" style="margin-bottom: 12px">
<n-input-number v-model:value="exportForm.first" :min="0" />
</n-form-item>
<n-form-item label="二等奖人数" style="margin-bottom: 12px">
<n-input-number v-model:value="exportForm.second" :min="0" />
</n-form-item>
<n-form-item label="三等奖人数">
<n-input-number v-model:value="exportForm.third" :min="0" />
</n-form-item>
</n-form>
<template #action>
<n-button @click="showExportModal = false">取消</n-button>
<n-button type="primary" :loading="exportLoading" @click="downloadExcel">
下载 CSV
</n-button>
</template>
</n-modal>
</template>
<style>
.oj-time-with-modal {
display: flex;
}
</style>