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:
xuyueandClaude Opus 5.5 committed 2026-10-09 20:58:59 +08:00
1 parent 4ef2794552
commit b94e028ff4
10 files changed
+528 -740

No files matched your search

+3 -55
View File
@@ -2,7 +2,6 @@ import {
createContestRequestSchema,
updateAcmHelperRequestSchema,
updateContestRequestSchema,
type AcmHelperItem,
type AdminContest,
type AdminContestList,
} from "@oj2/contract"
@@ -268,60 +267,9 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
return success(c, await serialize(row!), 201)
})
// ---------------------------------------------------------------- ACM 赛后核查
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, id)).limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const [problems, ranks] = await Promise.all([
db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(eq(schema.problem.contestId, id)),
db
.select({
id: schema.acmContestRank.id,
username: schema.user.username,
realName: schema.userProfile.realName,
submissionInfo: schema.acmContestRank.submissionInfo,
acceptedNumber: schema.acmContestRank.acceptedNumber,
})
.from(schema.acmContestRank)
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.acmContestRank.contestId, id)),
])
const displayIds = new Map(problems.map((problem) => [String(problem.id), problem.displayId]))
const results = []
for (const rank of ranks) {
if (rank.acceptedNumber <= 0) continue
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
if (info.is_ac !== true) continue
results.push({
id: rank.id,
username: rank.username,
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
// 接口已由 requireTeacher + ownedBy 双重把关。
realName: rank.realName,
problemId,
problemDisplayId: displayIds.get(problemId) ?? problemId,
acInfo: info,
checked: info.checked === true,
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
})
}
}
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
results.sort((left, right) => right._acTime - left._acTime)
return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[])
})
// ---------------------------------------------------------------- 赛后核查
// 「看过了」的标记。列表原来是后台单独一页(审核),2026-10 并进前台比赛页老师的「成绩」,
// 数据从 GET /contests/:id/class-view 来,这里只剩写标记
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
+56
View File
@@ -1,5 +1,6 @@
import {
addContestProblemRequestSchema,
reorderContestProblemsRequestSchema,
astCheckRequestSchema,
generateTestInputsRequestSchema,
isSqlProblem,
@@ -797,6 +798,61 @@ adminProblemRoutes.post(
},
)
/**
* 比赛题按给定顺序重新编号 1、2、3…(新建比赛页拖动排序用)。
* 榜单、提交、草稿都按题目 id 记,改编号不影响已经交的东西;比赛结束后就不让动了,
* 免得赛后对着「第 3 题」讲评时题号变了。
* 唯一约束是 (_id, contest_id),直接改会和还没改的那道撞,所以先统一挪到临时编号再写回来
*/
adminProblemRoutes.put(
"/contests/:contestId/problems/order",
requireProblemPermission,
async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const parsed = await parseBody(c, reorderContestProblemsRequestSchema)
if (!parsed.success) return parsed.response
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
if (contestStatus(contest) === "-1")
return failure(c, 409, "contest-ended", "比赛已经结束,不能再改题号")
const existing = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(eq(schema.problem.contestId, contestId))
const ids = parsed.data.problemIds
const all = new Set(existing.map((row) => row.id))
if (
new Set(ids).size !== ids.length ||
ids.length !== all.size ||
ids.some((id) => !all.has(id))
) {
return failure(c, 400, "problem-list-mismatch", "题目列表和比赛里的对不上,请刷新后再排")
}
await db.transaction(async (tx) => {
for (const id of ids) {
await tx
.update(schema.problem)
.set({ displayId: `__${id}` })
.where(eq(schema.problem.id, id))
}
for (const [index, id] of ids.entries()) {
await tx
.update(schema.problem)
.set({ displayId: String(index + 1) })
.where(eq(schema.problem.id, id))
}
})
return success(c, null)
},
)
// ---------------------------------------------------------------- 测试用例
adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
+116 -68
View File
@@ -1,12 +1,11 @@
import {
EXAM_TAGS,
isSqlProblem,
contestPasswordRequestSchema,
STUDENT_ROLES,
type Contest,
type ContestAccess,
type ContestList,
type ContestRank,
type ContestRankItem,
type ProblemDetail,
type ProblemListItem,
} from "@oj2/contract"
@@ -23,8 +22,11 @@ import {
checkContestPassword,
contestDetailsAllowed,
contestStatus,
buildClassView,
buildScoreboard,
findAccessibleContest,
isContestAdmin,
isContestTeacher,
rankHiddenFor,
requireContestAccess,
type ContestEnv,
} from "../services/contest"
@@ -71,7 +73,59 @@ function serializeContest(
} satisfies Contest
}
contestRoutes.get("/contests", async (c) => {
/**
* 列表上要的几个数:每场几道题、几个学生交过,以及「我」在每场的名次和做对数。
* 名次和榜单同一个口径(学生、没禁用、做对数 ↓ 罚时 ↑ id ↑),期中期末进行中不给名次
*/
async function listExtras(
rows: (typeof schema.contest.$inferSelect)[],
userId: number | undefined,
) {
const ids = rows.map((row) => row.id)
if (ids.length === 0) return { problems: new Map(), people: new Map(), mine: new Map() }
const idList = sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)
const studentList = sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
sql`, `,
)
const [problemRows, peopleRows, mineRows] = await Promise.all([
db.execute<{ contest_id: number; n: number }>(sql`
select contest_id, count(*)::int as n from ${schema.problem}
where contest_id in (${idList}) and visible group by contest_id
`),
db.execute<{ contest_id: number; n: number }>(sql`
select r.contest_id, count(*)::int as n from ${schema.acmContestRank} r
join ${schema.user} u on u.id = r.user_id
where r.contest_id in (${idList}) and u.admin_type in (${studentList}) and not u.is_disabled
group by r.contest_id
`),
userId
? db.execute<{ contest_id: number; solved: number; rank: number }>(sql`
select r.contest_id, r.accepted_number as solved,
1 + (select count(*)::int from ${schema.acmContestRank} o
join ${schema.user} ou on ou.id = o.user_id
where o.contest_id = r.contest_id and ou.admin_type in (${studentList})
and not ou.is_disabled and o.user_id <> r.user_id
and (o.accepted_number > r.accepted_number
or (o.accepted_number = r.accepted_number and o.total_time < r.total_time)
or (o.accepted_number = r.accepted_number and o.total_time = r.total_time
and o.id < r.id))) as rank
from ${schema.acmContestRank} r
where r.user_id = ${userId} and r.contest_id in (${idList})
`)
: Promise.resolve([]),
])
return {
problems: new Map(problemRows.map((row) => [row.contest_id, row.n])),
people: new Map(peopleRows.map((row) => [row.contest_id, row.n])),
mine: new Map(mineRows.map((row) => [row.contest_id, row])),
}
}
contestRoutes.get("/contests", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const keyword = c.req.query("keyword")?.trim()
@@ -80,7 +134,21 @@ contestRoutes.get("/contests", async (c) => {
const now = new Date().toISOString()
const filters = [eq(schema.contest.visible, true)]
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
if (tag) filters.push(eq(schema.contest.tag, tag))
if (tag === "考试") filters.push(inArray(schema.contest.tag, [...EXAM_TAGS]))
else if (tag) filters.push(eq(schema.contest.tag, tag))
const user = c.get("user")
// 「我参加过的」:交过题的才算(榜单行是第一次交题时建的)
if (c.req.query("joined") === "1" && user) {
filters.push(
inArray(
schema.contest.id,
db
.select({ id: schema.acmContestRank.contestId })
.from(schema.acmContestRank)
.where(eq(schema.acmContestRank.userId, user.id)),
),
)
}
if (status === "1") filters.push(gte(schema.contest.startTime, now))
else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
else if (status === "0")
@@ -96,14 +164,28 @@ contestRoutes.get("/contests", async (c) => {
.limit(limit)
.offset(offset),
])
const byId = await creators([...new Set(rows.map((row) => row.createdById))])
const [byId, extras] = await Promise.all([
creators([...new Set(rows.map((row) => row.createdById))]),
listExtras(rows, user?.id),
])
return success(c, {
results: rows.map((row) =>
serializeContest(
row,
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
),
),
results: rows.map((row) => {
const mine = extras.mine.get(row.id)
const total = extras.people.get(row.id) ?? 0
return {
...serializeContest(
row,
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
),
problemCount: extras.problems.get(row.id) ?? 0,
participantCount: total,
mine: !user
? undefined
: mine
? { rank: rankHiddenFor(user, row) ? null : mine.rank, total, solved: mine.solved }
: null,
} satisfies Contest
}),
total: totalRow[0]?.value ?? 0,
} satisfies ContestList)
})
@@ -224,7 +306,8 @@ contestRoutes.get(
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true)))
.orderBy(asc(schema.problem.displayId))
// 先按长度再按字面:编号是文本,直接排会变成 1、10、11…、2
.orderBy(sql`length(${schema.problem.displayId})`, asc(schema.problem.displayId))
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id)
@@ -323,58 +406,23 @@ contestRoutes.get(
},
)
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const where = and(
eq(schema.acmContestRank.contestId, contest.id),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
)
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.from(schema.acmContestRank)
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.where(where),
db
.select({
rank: schema.acmContestRank,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.acmContestRank)
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
// 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
.orderBy(
desc(schema.acmContestRank.acceptedNumber),
asc(schema.acmContestRank.totalTime),
asc(schema.acmContestRank.id),
)
.limit(limit)
.offset(offset),
])
const admin = isContestAdmin(c.get("user"), contest)
return success(c, {
results: rows.map(
({ rank, user, realName }) =>
({
id: rank.id,
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
user: sampleUser(user, realName, { includeRealName: admin }),
submissionNumber: rank.submissionNumber,
acceptedNumber: rank.acceptedNumber,
totalTime: rank.totalTime,
submissionInfo: rank.submissionInfo,
contestId: rank.contestId,
}) satisfies ContestRankItem,
),
total: totalRows[0]?.value ?? 0,
} satisfies ContestRank)
})
contestRoutes.get(
"/contests/:id/scoreboard",
optionalAuth,
requireContestAccess("ranks"),
async (c) => success(c, await buildScoreboard(c.get("contest")!, c.get("user"))),
)
contestRoutes.get(
"/contests/:id/class-view",
optionalAuth,
requireContestAccess("ranks"),
async (c) => {
const contest = c.get("contest")!
const user = c.get("user")
if (!user || !isContestTeacher(user, contest)) {
return failure(c, 403, "teacher-only", "只有老师能看全班情况")
}
return success(c, await buildClassView(contest, user))
},
)
+254 -1
View File
@@ -1,6 +1,15 @@
import { createHash } from "node:crypto"
import { eq } from "drizzle-orm"
import {
isExamTag,
STUDENT_ROLES,
TEACHER_ROLES,
type ContestClassView,
type ContestScoreboard,
type ContestScoreRow,
type ContestSubmissionInfo,
} from "@oj2/contract"
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"
import type { Context, MiddlewareHandler } from "hono"
import type { AppEnv } from "../auth/middleware"
@@ -128,3 +137,247 @@ export function requireContestAccess(
await next()
}
}
/** 老师(含出题人、超管):看得到真名、全班情况,期中期末进行中也看得到排名 */
export function isContestTeacher(user: AuthUser | null | undefined, contest: ContestRow) {
return Boolean(user && (isContestAdmin(user, contest) || TEACHER_ROLES.includes(user.adminType)))
}
/** 期中、期末进行中,学生看不到排名和每题做对人数(见契约 isExamTag) */
export function rankHiddenFor(user: AuthUser | null | undefined, contest: ContestRow) {
return (
isExamTag(contest.tag) && contestStatus(contest) === "0" && !isContestTeacher(user, contest)
)
}
const PENALTY_SECONDS = 20 * 60
/** 「5 分钟里上升 3 名」的 5 分钟 */
const RANK_MOVE_WINDOW_SECONDS = 5 * 60
type RankRow = {
rankId: number
userId: number
username: string
realName: string | null
className: string | null
solved: number
totalTime: number
info: Record<string, ContestSubmissionInfo>
}
/**
* 只算 `cutoff` 秒之前做对的题,得到那一刻的名次(用户 id → 名次)。
* 做对前的错误次数都发生在做对之前,所以「那时做对的题 + 它们的罚时」就是那一刻的榜;
* 那时还没做对的题当时错了几次不影响名次(ACM 只罚做对的题)
*/
function ranksAt(rows: RankRow[], cutoff: number) {
const scored = rows.map((row) => {
let solved = 0
let time = 0
for (const info of Object.values(row.info)) {
if (info.is_ac && info.ac_time <= cutoff) {
solved += 1
time += info.ac_time + info.error_number * PENALTY_SECONDS
}
}
return { row, solved, time }
})
scored.sort((a, b) => b.solved - a.solved || a.time - b.time || a.row.rankId - b.row.rankId)
return new Map(scored.map((item, index) => [item.row.userId, index + 1]))
}
/**
* 整张榜。名次按 做对数 ↓、罚时 ↑ 排,同分的按 rank 行 id 定个稳定顺序(不并列名次,和原来一样)。
* 只算学生、不算禁用的号;老师赛中试做的提交不上榜,也不占「最先做对」。
*/
export async function buildScoreboard(
contest: ContestRow,
viewer: AuthUser | null | undefined,
): Promise<ContestScoreboard> {
const teacher = isContestTeacher(viewer, contest)
const hidden = rankHiddenFor(viewer, contest)
const [problems, rankRows] = await Promise.all([
db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true)))
.orderBy(sql`length(${schema.problem.displayId})`, asc(schema.problem.displayId)),
db
.select({
rankId: schema.acmContestRank.id,
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
solved: schema.acmContestRank.acceptedNumber,
totalTime: schema.acmContestRank.totalTime,
info: schema.acmContestRank.submissionInfo,
})
.from(schema.acmContestRank)
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
eq(schema.acmContestRank.contestId, contest.id),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
),
)
.orderBy(
desc(schema.acmContestRank.acceptedNumber),
asc(schema.acmContestRank.totalTime),
asc(schema.acmContestRank.id),
),
])
// 每题学生里最早做对的那一秒
const firstAcTime = new Map<string, number>()
for (const row of rankRows) {
for (const [problemId, info] of Object.entries(row.info)) {
if (!info.is_ac) continue
const best = firstAcTime.get(problemId)
if (best === undefined || info.ac_time < best) firstAcTime.set(problemId, info.ac_time)
}
}
const elapsed = Math.floor((Date.now() - Date.parse(contest.startTime)) / 1000)
const prev =
contestStatus(contest) === "0" ? ranksAt(rankRows, elapsed - RANK_MOVE_WINDOW_SECONDS) : null
const rows: ContestScoreRow[] = rankRows.map((row, index) => ({
rankId: row.rankId,
userId: row.userId,
username: row.username,
realName: teacher ? row.realName : null,
className: row.className,
rank: index + 1,
prevRank: prev?.get(row.userId) ?? null,
solved: row.solved,
totalTime: row.totalTime,
cells: Object.fromEntries(
Object.entries(row.info).map(([problemId, info]) => [
problemId,
{
isAc: info.is_ac,
acTime: info.ac_time,
errors: info.error_number,
firstAc: info.is_ac && info.ac_time === firstAcTime.get(problemId),
checked: info.checked === true,
},
]),
),
}))
const problemRows = problems.map((problem) => {
const key = String(problem.id)
let solvedUsers = 0
let triedUsers = 0
let firstSolver: { username: string; acTime: number } | null = null
for (const row of rows) {
const cell = row.cells[key]
if (!cell) continue
triedUsers += 1
if (!cell.isAc) continue
solvedUsers += 1
if (cell.firstAc && !firstSolver)
firstSolver = { username: row.username, acTime: cell.acTime }
}
return hidden
? {
id: problem.id,
_id: problem.displayId,
title: problem.title,
solvedUsers: 0,
triedUsers: 0,
firstSolver: null,
}
: {
id: problem.id,
_id: problem.displayId,
title: problem.title,
solvedUsers,
triedUsers,
firstSolver,
}
})
if (hidden) {
const mine = rows.filter((row) => row.userId === viewer?.id)
return {
hidden,
problems: problemRows,
// 「全班最先做对」也是别人的信息,考完再说
rows: mine.map((row) => ({
...row,
rank: null,
prevRank: null,
cells: Object.fromEntries(
Object.entries(row.cells).map(([key, cell]) => [key, { ...cell, firstAc: false }]),
),
})),
}
}
return { hidden, problems: problemRows, rows }
}
/**
* 老师的「全班情况 / 成绩」:榜单 + 每人最后一次交 + 没进来的人。
* 比赛不绑班(2026-10 用户定的「都能进」),名单按参赛的人推:一个班来了一半以上,
* 就把这个班算进来,班里没交过题的人列成「没进来」。技能周那种七个班各来几个人的,一个班也不算
*/
export async function buildClassView(
contest: ContestRow,
viewer: AuthUser,
): Promise<ContestClassView> {
const board = await buildScoreboard(contest, viewer)
const last = await db.execute<{ user_id: number; create_time: string; problem_id: number }>(sql`
select distinct on (user_id) user_id, create_time, problem_id
from submission
where contest_id = ${contest.id}
order by user_id, create_time desc
`)
const lastSubmit: ContestClassView["lastSubmit"] = {}
for (const row of last) {
lastSubmit[String(row.user_id)] = { time: row.create_time, problemId: row.problem_id }
}
const came = new Map<string, number>()
for (const row of board.rows) {
if (row.className) came.set(row.className, (came.get(row.className) ?? 0) + 1)
}
const candidates = [...came.keys()]
const roster =
candidates.length === 0
? []
: await db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
inArray(schema.user.className, candidates),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
),
)
.orderBy(asc(schema.user.username))
const size = new Map<string, number>()
for (const user of roster) size.set(user.className!, (size.get(user.className!) ?? 0) + 1)
const classes = candidates
.filter((name) => (came.get(name) ?? 0) * 2 >= (size.get(name) ?? 0))
.sort()
const joined = new Set(board.rows.map((row) => row.userId))
const absent = roster.filter(
(user) => classes.includes(user.className!) && !joined.has(user.userId),
)
return { ...board, lastSubmit, classes, absent }
}
-297
View File
@@ -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>
+5 -11
View File
@@ -350,16 +350,6 @@ export const createContestRequestSchema = z.object({
export const updateContestRequestSchema = createContestRequestSchema
export const acmHelperItemSchema = z.object({
id: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
problemId: z.string(),
problemDisplayId: z.string(),
acInfo: z.record(z.string(), z.unknown()),
checked: z.boolean(),
})
export const updateAcmHelperRequestSchema = z.object({
rankId: z.number().int().positive(),
problemId: z.string().min(1),
@@ -788,6 +778,11 @@ export const addContestProblemRequestSchema = z.object({
displayId: z.string().trim().min(1).max(32),
})
/** 比赛题排顺序:给出这场比赛**全部**题目的 id,按这个顺序把编号改成 1、2、3… */
export const reorderContestProblemsRequestSchema = z.object({
problemIds: z.array(z.number().int().positive()).min(1).max(100),
})
export const testCaseEntrySchema = z.object({
stripped_output_md5: z.string(),
input_size: z.number().int(),
@@ -901,7 +896,6 @@ export type CreateProblemRequest = z.infer<typeof createProblemRequestSchema>
export type AdminContest = z.infer<typeof adminContestSchema>
export type AdminContestList = z.infer<typeof adminContestListSchema>
export type AcmHelperItem = z.infer<typeof acmHelperItemSchema>
export type AdminUser = z.infer<typeof adminUserSchema>
export type AdminUserList = z.infer<typeof adminUserListSchema>
export type JudgeServer = z.infer<typeof judgeServerSchema>
+94 -11
View File
@@ -18,6 +18,21 @@ export const contestSchema = z.object({
status: contestStatusSchema,
contestType: z.enum(["Public", "Password Protected"]),
now: z.string().optional(),
/** 列表才带:几道题(可见的)、几个学生交过题 */
problemCount: z.number().int().optional(),
participantCount: z.number().int().optional(),
/**
* 列表才带,登录了才有:我在这场里的成绩,没参加是 null。
* `rank` 在期中 / 期末进行中是 null —— 那时排名不公布(见 {@link isExamTag})
*/
mine: z
.object({
rank: z.number().int().nullable(),
total: z.number().int(),
solved: z.number().int(),
})
.nullable()
.optional(),
})
export const contestListSchema = paginatedSchema(contestSchema)
@@ -49,25 +64,93 @@ export const contestSubmissionInfoSchema = z.object({
checked: z.boolean().optional(),
})
export const contestRankItemSchema = z.object({
id: z.number().int(),
user: sampleUserSchema,
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
totalTime: z.number().int(),
submissionInfo: z.record(z.string(), contestSubmissionInfoSchema),
contestId: z.number().int(),
/**
* 期中、期末:进行中学生看不到排名、每题做对人数、谁最先做对,考完一起公布(2026-10 用户定的)。
* 练习照常全看得到 —— 「我需要竞争」。标签只有 练习 / 期中 / 期末 三个值
*/
export const EXAM_TAGS = ["期中", "期末"] as const
export function isExamTag(tag: string) {
return (EXAM_TAGS as readonly string[]).includes(tag)
}
/** 榜单上一个人一道题的格子。时间都是秒,从比赛开始算 */
export const contestScoreCellSchema = z.object({
isAc: z.boolean(),
acTime: z.number(),
/** 做对的题:做对前错了几次(编译失败不算);没做对的题:到现在错了几次 */
errors: z.number().int(),
/** 学生里最先做对这题的(同一秒并列都算)。不用库里的 is_first_ac:老师赛中试做也会占掉它 */
firstAc: z.boolean(),
/** 老师在成绩页标的「看过了」 */
checked: z.boolean(),
})
export const contestRankSchema = paginatedSchema(contestRankItemSchema)
export const contestScoreRowSchema = z.object({
/** acm_contest_rank.id,标「看过了」要用 */
rankId: z.number().int(),
userId: z.number().int(),
username: z.string(),
/** 只给老师 */
realName: z.string().nullable(),
className: z.string().nullable(),
/** 名次(1 起)。排名不公布时自己那一行是 null */
rank: z.number().int().nullable(),
/** 5 分钟前的名次,比赛结束后是 null(名次不再变) */
prevRank: z.number().int().nullable(),
solved: z.number().int(),
/** 罚时,秒:每道做对的题做对时是第几秒,加起来,错一次多 20 分钟 */
totalTime: z.number().int(),
/** 题目 id → 格子,只有交过的题有 */
cells: z.record(z.string(), contestScoreCellSchema),
})
export const contestScoreProblemSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
solvedUsers: z.number().int(),
triedUsers: z.number().int(),
firstSolver: z.object({ username: z.string(), acTime: z.number() }).nullable(),
})
/**
* 比赛的整张榜,一次全给(一场最多百来人):学生比赛页右边的名次、排名页、老师的全班情况都用它。
* `hidden` = 期中期末进行中、看的人是学生:problems 的人数清零,rows 只有自己那一行
*/
export const contestScoreboardSchema = z.object({
hidden: z.boolean(),
problems: z.array(contestScoreProblemSchema),
rows: z.array(contestScoreRowSchema),
})
/**
* 老师看的「全班情况 / 成绩」:在榜单之外,多每个人最后一次交的时间,和没进来的人。
* 比赛不绑班:参赛的人里,一个班来了一半以上就算这场是这个班的,没进来的从这些班的名单里找
*/
export const contestClassViewSchema = contestScoreboardSchema.extend({
lastSubmit: z.record(z.string(), z.object({ time: z.string(), problemId: z.number().int() })),
classes: z.array(z.string()),
absent: z.array(
z.object({
userId: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
className: z.string().nullable(),
}),
),
})
export type Contest = z.infer<typeof contestSchema>
export type ContestList = z.infer<typeof contestListSchema>
export type ContestRankItem = z.infer<typeof contestRankItemSchema>
export type ContestRank = z.infer<typeof contestRankSchema>
export type ContestAccess = z.infer<typeof contestAccessSchema>
export type ContestSubmissionInfo = z.infer<typeof contestSubmissionInfoSchema>
export type ContestScoreCell = z.infer<typeof contestScoreCellSchema>
export type ContestScoreRow = z.infer<typeof contestScoreRowSchema>
export type ContestScoreProblem = z.infer<typeof contestScoreProblemSchema>
export type ContestScoreboard = z.infer<typeof contestScoreboardSchema>
export type ContestClassView = z.infer<typeof contestClassViewSchema>
export type ContestStatus = z.infer<typeof contestStatusSchema>
export type ContestPasswordRequest = z.infer<typeof contestPasswordRequestSchema>
export type ContestProblems = z.infer<typeof contestProblemsSchema>