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) => {