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
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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))
|
||||
},
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user