feat(比赛): 榜单一次给全,带名次变化和每题谁最先做对;期中、期末考试中学生看不到排名;老师能看全班情况
- GET /contests/:id/scoreboard 代替 /rank:整张榜、5 分钟前的名次、每题做对 / 做过人数和学生里最先做对的人(不再用 is_first_ac,老师赛中试做会占掉它) - 期中、期末进行中:学生只拿到自己那一行,人数清零、「全班最先」也不给,考完一起公布 - GET /contests/:id/class-view(老师):真名、每人最后一次交、没进来的人 —— 比赛不绑班,来了一半以上的班才算这场的班 - 比赛列表带题数、参赛人数、我的名次和做对数,能筛「我参加过的」「期中 · 期末」 - PUT /admin/contests/:id/problems/order:比赛题按顺序重编 1、2、3,比赛结束后不让动 - 后台「审核」列表接口去掉,并进前台老师的成绩页;「看过了」的写接口留着 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ef2794552
commit
b94e028ff4
10 files changed
+528
-740
No files matched your search
@@ -1,297 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { AcmHelperItem, ContestSubmissionInfo } from "utils/types"
|
||||
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
||||
import { getSubmission, getSubmissions } from "oj/api"
|
||||
import SubmissionDetail from "oj/submission/detail.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
|
||||
interface Props {
|
||||
contestID: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文,
|
||||
* 键名保持 snake_case —— 判题写进去的就是这套键名,见 utils/types.ts 的 ContestSubmissionInfo。
|
||||
*/
|
||||
type HelperItem = Omit<AcmHelperItem, "acInfo"> & {
|
||||
acInfo: ContestSubmissionInfo
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const message = useMessage()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const submissions = ref<HelperItem[]>([])
|
||||
const contestStartTime = ref<Date | null>(null)
|
||||
const query = reactive({
|
||||
username: "",
|
||||
problemId: "",
|
||||
checked: "all",
|
||||
})
|
||||
|
||||
// 检查状态选项
|
||||
const checkedOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "已检查", value: "checked" },
|
||||
{ label: "未检查", value: "unchecked" },
|
||||
]
|
||||
|
||||
// 代码查看模态框
|
||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||
const currentSubmission = ref<any>(null)
|
||||
|
||||
// 格式化 AC 时间(ac_time 是相对于比赛开始的秒数)
|
||||
function formatACTime(relativeSeconds: number) {
|
||||
if (!contestStartTime.value) return "-"
|
||||
const acTime = new Date(contestStartTime.value.getTime() + relativeSeconds * 1000)
|
||||
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
|
||||
}
|
||||
|
||||
// 切换检查状态
|
||||
async function toggleChecked(item: HelperItem) {
|
||||
const newChecked = !item.checked
|
||||
try {
|
||||
await updateACMHelperChecked(Number(props.contestID), item.id, item.problemId, newChecked)
|
||||
// 更新本地状态
|
||||
item.checked = newChecked
|
||||
item.acInfo.checked = newChecked
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err, "操作失败"))
|
||||
}
|
||||
}
|
||||
|
||||
// 批量标记为已检查
|
||||
async function markAllAsChecked() {
|
||||
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
|
||||
if (unchecked.length === 0) {
|
||||
message.info("没有需要标记的提交")
|
||||
return
|
||||
}
|
||||
|
||||
const loadingMsg = message.loading("正在标记...", { duration: 0 })
|
||||
try {
|
||||
for (const item of unchecked) {
|
||||
await updateACMHelperChecked(Number(props.contestID), item.id, item.problemId, true)
|
||||
item.checked = true
|
||||
item.acInfo.checked = true
|
||||
}
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
loadingMsg.destroy()
|
||||
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
||||
} catch (err) {
|
||||
loadingMsg.destroy()
|
||||
message.error(errorMessage(err, "批量操作失败"))
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤后的提交列表
|
||||
const filteredSubmissions = computed(() => {
|
||||
return submissions.value.filter((item) => {
|
||||
if (query.username && !item.username.includes(query.username)) return false
|
||||
if (query.problemId && !item.problemDisplayId.includes(query.problemId)) return false
|
||||
if (query.checked === "checked" && !item.checked) return false
|
||||
if (query.checked === "unchecked" && item.checked) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 统计信息
|
||||
const stats = computed(() => {
|
||||
const total = submissions.value.length
|
||||
const checked = submissions.value.filter((item) => item.checked).length
|
||||
const unchecked = total - checked
|
||||
return { total, checked, unchecked }
|
||||
})
|
||||
|
||||
// 查看代码 - 获取该用户在该题目的 AC 提交
|
||||
async function viewSubmission(item: HelperItem) {
|
||||
try {
|
||||
// 查询该用户在该竞赛该题目的 AC 提交
|
||||
const res = await getSubmissions({
|
||||
username: item.username,
|
||||
problemDisplayId: item.problemDisplayId,
|
||||
contestId: props.contestID,
|
||||
result: "0", // ACCEPTED
|
||||
language: "",
|
||||
page: 1,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (res.results.length === 0) {
|
||||
message.warning("未找到该用户的 AC 提交")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取提交详情
|
||||
const submissionListItem = res.results[0]
|
||||
const detailRes = await getSubmission(submissionListItem.id)
|
||||
|
||||
// 手动添加 contest 字段(ACM模式下后端不返回此字段)
|
||||
currentSubmission.value = {
|
||||
...detailRes,
|
||||
contest: Number(props.contestID),
|
||||
problem_display_id: item.problemDisplayId,
|
||||
}
|
||||
|
||||
toggleCodePanel(true)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err, "加载提交失败"))
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
try {
|
||||
// 先获取比赛信息,获取开始时间
|
||||
const contestRes = await getContest(props.contestID)
|
||||
contestStartTime.value = new Date(contestRes.startTime)
|
||||
|
||||
// 再获取 AC 提交列表
|
||||
const data = await getACMHelperList(Number(props.contestID))
|
||||
submissions.value = data
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err, "加载失败"))
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<HelperItem>[] = [
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "题目",
|
||||
key: "problem_display_id",
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { type: "info" }, () => row.problemDisplayId),
|
||||
},
|
||||
{
|
||||
title: "AC时间",
|
||||
key: "ac_time",
|
||||
width: 180,
|
||||
render: (row) => formatACTime(row.acInfo.ac_time),
|
||||
},
|
||||
{
|
||||
title: "错误次数",
|
||||
key: "error_number",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: row.acInfo.error_number > 0 ? "warning" : "success",
|
||||
size: "small",
|
||||
},
|
||||
() => row.acInfo.error_number,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "已检查",
|
||||
key: "checked",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NCheckbox, {
|
||||
checked: row.checked,
|
||||
onUpdateChecked: () => toggleChecked(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "primary",
|
||||
secondary: true,
|
||||
onClick: () => viewSubmission(row),
|
||||
},
|
||||
() => "查看代码",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<PageHeader
|
||||
title="比赛辅助检查"
|
||||
description="赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选「已检查」。"
|
||||
:back="{ name: 'admin contest list' }"
|
||||
>
|
||||
<template #extra>
|
||||
<n-tag type="info" round>总计 {{ stats.total }}</n-tag>
|
||||
<n-tag type="success" round>已检查 {{ stats.checked }}</n-tag>
|
||||
<n-tag type="warning" round>未检查 {{ stats.unchecked }}</n-tag>
|
||||
</template>
|
||||
<template #actions>
|
||||
<n-button type="primary" :disabled="stats.unchecked === 0" @click="markAllAsChecked">
|
||||
标记全部为已检查
|
||||
</n-button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<n-input
|
||||
v-model:value="query.username"
|
||||
placeholder="筛选用户名"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="query.problemId"
|
||||
placeholder="筛选题目"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-select v-model:value="query.checked" :options="checkedOptions" style="width: 120px" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="filteredSubmissions"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
<n-modal
|
||||
v-model:show="codePanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="代码详情"
|
||||
>
|
||||
<SubmissionDetail
|
||||
v-if="currentSubmission"
|
||||
:submission="currentSubmission"
|
||||
:submissionID="currentSubmission.id"
|
||||
hideList
|
||||
@copied="toggleCodePanel(false)"
|
||||
/>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-data-table) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContestRank } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
rank: ContestRank
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
|
||||
function goto() {
|
||||
router.push({
|
||||
name: "contest submissions",
|
||||
query: { username: props.rank.user.username },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
{{ rank.acceptedNumber }} /
|
||||
<n-button text type="primary" @click="goto">
|
||||
{{ rank.submissionNumber }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const contestStore = useContestStore()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const contestMenuVisible = computed(() => {
|
||||
if (contestStore.isContestAdmin) return true
|
||||
if (!contestStore.isPrivate) {
|
||||
return contestStore.contestStatus !== ContestStatus.not_started
|
||||
}
|
||||
return contestStore.access
|
||||
})
|
||||
|
||||
function goto(name: string) {
|
||||
router.push({ name: "contest " + name })
|
||||
}
|
||||
|
||||
function getCurrentType(name: string): "primary" | "default" {
|
||||
if (route.name === "contest " + name) return "primary"
|
||||
return "default"
|
||||
}
|
||||
|
||||
const options: DropdownOption[] = [
|
||||
{ label: "比赛题目", key: "problems" },
|
||||
{ label: "提交信息", key: "submissions" },
|
||||
{ label: "比赛排名", key: "rank" },
|
||||
]
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="contestMenuVisible">
|
||||
<n-flex v-if="isDesktop">
|
||||
<n-button :type="getCurrentType('problems')" @click="goto('problems')"> 比赛题目 </n-button>
|
||||
<n-button :type="getCurrentType('submissions')" @click="goto('submissions')">
|
||||
提交信息
|
||||
</n-button>
|
||||
<n-button :type="getCurrentType('rank')" @click="goto('rank')"> 比赛排名 </n-button>
|
||||
</n-flex>
|
||||
<n-dropdown v-else :options="options" @select="goto">
|
||||
<n-button>菜单</n-button>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
@@ -1,223 +0,0 @@
|
||||
<template>
|
||||
<div class="chart" v-if="showChart">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Line } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import type { ContestRank } from "utils/types"
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend)
|
||||
|
||||
interface Props {
|
||||
ranks: ContestRank[]
|
||||
problems: Array<{ id: number; title: string }>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const PENALTY_SECONDS = 20 * 60
|
||||
|
||||
const showChart = computed(() => {
|
||||
const hasRanks = props.ranks.length > 0
|
||||
const hasProblems = props.problems.length >= 3
|
||||
return hasProblems && hasRanks
|
||||
})
|
||||
|
||||
const colorPalette = [
|
||||
"#3B82F6",
|
||||
"#EF4444",
|
||||
"#10B981",
|
||||
"#F59E0B",
|
||||
"#8B5CF6",
|
||||
"#EC4899",
|
||||
"#06B6D4",
|
||||
"#84CC16",
|
||||
"#F97316",
|
||||
"#6366F1",
|
||||
]
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return `${h}h${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
interface AcEvent {
|
||||
time: number
|
||||
userIndex: number
|
||||
problemId: string
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.ranks || props.ranks.length === 0) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
const topUsers = props.ranks.slice(0, 10)
|
||||
|
||||
// 收集所有AC事件并按时间排序
|
||||
const events: AcEvent[] = []
|
||||
topUsers.forEach((rank, userIndex) => {
|
||||
Object.entries(rank.submissionInfo).forEach(([problemId, info]) => {
|
||||
if (info.is_ac) {
|
||||
events.push({ time: info.ac_time, userIndex, problemId })
|
||||
}
|
||||
})
|
||||
})
|
||||
events.sort((a, b) => a.time - b.time)
|
||||
|
||||
if (events.length === 0) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
// 在每个时间点计算所有人的排名
|
||||
// 状态: 每个用户当前已AC题数和罚时
|
||||
const userState = topUsers.map(() => ({
|
||||
solved: 0,
|
||||
penalty: 0,
|
||||
}))
|
||||
|
||||
// 用于记录每个用户每道题的错误次数
|
||||
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
|
||||
topUsers.forEach((rank, i) => {
|
||||
Object.entries(rank.submissionInfo).forEach(([problemId, info]) => {
|
||||
if (info.error_number > 0) {
|
||||
userErrors[i].set(problemId, info.error_number)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function calcRanks(): number[] {
|
||||
const indexed = userState.map((s, i) => ({ ...s, i }))
|
||||
indexed.sort((a, b) => {
|
||||
if (b.solved !== a.solved) return b.solved - a.solved
|
||||
return a.penalty - b.penalty
|
||||
})
|
||||
const ranks = Array.from({ length: topUsers.length }, () => 0)
|
||||
indexed.forEach((item, pos) => {
|
||||
ranks[item.i] = pos + 1
|
||||
})
|
||||
return ranks
|
||||
}
|
||||
|
||||
// 时间轴上的数据点: [时间标签, 各用户排名]
|
||||
const timePoints: number[] = [0]
|
||||
const rankSnapshots: number[][] = [calcRanks()]
|
||||
|
||||
// 按时间处理事件(合并同一时刻的事件)
|
||||
let i = 0
|
||||
while (i < events.length) {
|
||||
const currentTime = events[i].time
|
||||
// 处理同一时刻的所有事件
|
||||
while (i < events.length && events[i].time === currentTime) {
|
||||
const ev = events[i]
|
||||
userState[ev.userIndex].solved++
|
||||
const errors = userErrors[ev.userIndex].get(ev.problemId) || 0
|
||||
userState[ev.userIndex].penalty =
|
||||
userState[ev.userIndex].penalty + ev.time + errors * PENALTY_SECONDS
|
||||
i++
|
||||
}
|
||||
timePoints.push(currentTime)
|
||||
rankSnapshots.push(calcRanks())
|
||||
}
|
||||
|
||||
const labels = timePoints.map((t) => formatTime(t))
|
||||
|
||||
const datasets = topUsers.map((rank, userIndex) => {
|
||||
const color = colorPalette[userIndex % colorPalette.length]
|
||||
const finalRank = rankSnapshots[rankSnapshots.length - 1][userIndex]
|
||||
return {
|
||||
label: `#${finalRank} ${rank.user.username}`,
|
||||
data: rankSnapshots.map((snapshot) => snapshot[userIndex]),
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 1,
|
||||
borderWidth: 2.5,
|
||||
}
|
||||
})
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "top" as const,
|
||||
maxHeight: 80,
|
||||
labels: {
|
||||
boxWidth: 14,
|
||||
boxHeight: 3,
|
||||
padding: 10,
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
|
||||
callbacks: {
|
||||
title: (context: any) => `比赛进行: ${context[0].label}`,
|
||||
label: (context: any) => {
|
||||
const rank = context.parsed.y
|
||||
const name = context.dataset.label
|
||||
return ` 第${rank}名 — ${name}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "比赛时间",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "排名",
|
||||
},
|
||||
reverse: true,
|
||||
min: 1,
|
||||
max: 10,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (value: any) => `第${value}名`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 420px;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user