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
+523
-735
No files matched your search
@@ -2,7 +2,6 @@ import {
|
|||||||
createContestRequestSchema,
|
createContestRequestSchema,
|
||||||
updateAcmHelperRequestSchema,
|
updateAcmHelperRequestSchema,
|
||||||
updateContestRequestSchema,
|
updateContestRequestSchema,
|
||||||
type AcmHelperItem,
|
|
||||||
type AdminContest,
|
type AdminContest,
|
||||||
type AdminContestList,
|
type AdminContestList,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
@@ -268,60 +267,9 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
|||||||
return success(c, await serialize(row!), 201)
|
return success(c, await serialize(row!), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- ACM 赛后核查
|
// ---------------------------------------------------------------- 赛后核查
|
||||||
|
// 「看过了」的标记。列表原来是后台单独一页(审核),2026-10 并进前台比赛页老师的「成绩」,
|
||||||
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
// 数据从 GET /contests/:id/class-view 来,这里只剩写标记
|
||||||
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[])
|
|
||||||
})
|
|
||||||
|
|
||||||
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
||||||
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
addContestProblemRequestSchema,
|
addContestProblemRequestSchema,
|
||||||
|
reorderContestProblemsRequestSchema,
|
||||||
astCheckRequestSchema,
|
astCheckRequestSchema,
|
||||||
generateTestInputsRequestSchema,
|
generateTestInputsRequestSchema,
|
||||||
isSqlProblem,
|
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) => {
|
adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
|
||||||
|
|||||||
+111
-63
@@ -1,12 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
EXAM_TAGS,
|
||||||
isSqlProblem,
|
isSqlProblem,
|
||||||
contestPasswordRequestSchema,
|
contestPasswordRequestSchema,
|
||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
type Contest,
|
type Contest,
|
||||||
type ContestAccess,
|
type ContestAccess,
|
||||||
type ContestList,
|
type ContestList,
|
||||||
type ContestRank,
|
|
||||||
type ContestRankItem,
|
|
||||||
type ProblemDetail,
|
type ProblemDetail,
|
||||||
type ProblemListItem,
|
type ProblemListItem,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
@@ -23,8 +22,11 @@ import {
|
|||||||
checkContestPassword,
|
checkContestPassword,
|
||||||
contestDetailsAllowed,
|
contestDetailsAllowed,
|
||||||
contestStatus,
|
contestStatus,
|
||||||
|
buildClassView,
|
||||||
|
buildScoreboard,
|
||||||
findAccessibleContest,
|
findAccessibleContest,
|
||||||
isContestAdmin,
|
isContestTeacher,
|
||||||
|
rankHiddenFor,
|
||||||
requireContestAccess,
|
requireContestAccess,
|
||||||
type ContestEnv,
|
type ContestEnv,
|
||||||
} from "../services/contest"
|
} from "../services/contest"
|
||||||
@@ -71,7 +73,59 @@ function serializeContest(
|
|||||||
} satisfies Contest
|
} 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 limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
@@ -80,7 +134,21 @@ contestRoutes.get("/contests", async (c) => {
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const filters = [eq(schema.contest.visible, true)]
|
const filters = [eq(schema.contest.visible, true)]
|
||||||
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
|
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))
|
if (status === "1") filters.push(gte(schema.contest.startTime, now))
|
||||||
else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
|
else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
|
||||||
else if (status === "0")
|
else if (status === "0")
|
||||||
@@ -96,14 +164,28 @@ contestRoutes.get("/contests", async (c) => {
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset),
|
.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, {
|
return success(c, {
|
||||||
results: rows.map((row) =>
|
results: rows.map((row) => {
|
||||||
serializeContest(
|
const mine = extras.mine.get(row.id)
|
||||||
|
const total = extras.people.get(row.id) ?? 0
|
||||||
|
return {
|
||||||
|
...serializeContest(
|
||||||
row,
|
row,
|
||||||
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
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,
|
total: totalRow[0]?.value ?? 0,
|
||||||
} satisfies ContestList)
|
} satisfies ContestList)
|
||||||
})
|
})
|
||||||
@@ -224,7 +306,8 @@ contestRoutes.get(
|
|||||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, 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)))
|
.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 tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
||||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||||
@@ -323,58 +406,23 @@ contestRoutes.get(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
|
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 contest = c.get("contest")!
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const user = c.get("user")
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
if (!user || !isContestTeacher(user, contest)) {
|
||||||
const where = and(
|
return failure(c, 403, "teacher-only", "只有老师能看全班情况")
|
||||||
eq(schema.acmContestRank.contestId, contest.id),
|
}
|
||||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
return success(c, await buildClassView(contest, user))
|
||||||
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)
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { createHash } from "node:crypto"
|
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 { Context, MiddlewareHandler } from "hono"
|
||||||
|
|
||||||
import type { AppEnv } from "../auth/middleware"
|
import type { AppEnv } from "../auth/middleware"
|
||||||
@@ -128,3 +137,247 @@ export function requireContestAccess(
|
|||||||
await next()
|
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 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({
|
export const updateAcmHelperRequestSchema = z.object({
|
||||||
rankId: z.number().int().positive(),
|
rankId: z.number().int().positive(),
|
||||||
problemId: z.string().min(1),
|
problemId: z.string().min(1),
|
||||||
@@ -788,6 +778,11 @@ export const addContestProblemRequestSchema = z.object({
|
|||||||
displayId: z.string().trim().min(1).max(32),
|
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({
|
export const testCaseEntrySchema = z.object({
|
||||||
stripped_output_md5: z.string(),
|
stripped_output_md5: z.string(),
|
||||||
input_size: z.number().int(),
|
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 AdminContest = z.infer<typeof adminContestSchema>
|
||||||
export type AdminContestList = z.infer<typeof adminContestListSchema>
|
export type AdminContestList = z.infer<typeof adminContestListSchema>
|
||||||
export type AcmHelperItem = z.infer<typeof acmHelperItemSchema>
|
|
||||||
export type AdminUser = z.infer<typeof adminUserSchema>
|
export type AdminUser = z.infer<typeof adminUserSchema>
|
||||||
export type AdminUserList = z.infer<typeof adminUserListSchema>
|
export type AdminUserList = z.infer<typeof adminUserListSchema>
|
||||||
export type JudgeServer = z.infer<typeof judgeServerSchema>
|
export type JudgeServer = z.infer<typeof judgeServerSchema>
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ export const contestSchema = z.object({
|
|||||||
status: contestStatusSchema,
|
status: contestStatusSchema,
|
||||||
contestType: z.enum(["Public", "Password Protected"]),
|
contestType: z.enum(["Public", "Password Protected"]),
|
||||||
now: z.string().optional(),
|
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)
|
export const contestListSchema = paginatedSchema(contestSchema)
|
||||||
@@ -49,25 +64,93 @@ export const contestSubmissionInfoSchema = z.object({
|
|||||||
checked: z.boolean().optional(),
|
checked: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const contestRankItemSchema = z.object({
|
/**
|
||||||
id: z.number().int(),
|
* 期中、期末:进行中学生看不到排名、每题做对人数、谁最先做对,考完一起公布(2026-10 用户定的)。
|
||||||
user: sampleUserSchema,
|
* 练习照常全看得到 —— 「我需要竞争」。标签只有 练习 / 期中 / 期末 三个值
|
||||||
submissionNumber: z.number().int(),
|
*/
|
||||||
acceptedNumber: z.number().int(),
|
export const EXAM_TAGS = ["期中", "期末"] as const
|
||||||
totalTime: z.number().int(),
|
export function isExamTag(tag: string) {
|
||||||
submissionInfo: z.record(z.string(), contestSubmissionInfoSchema),
|
return (EXAM_TAGS as readonly string[]).includes(tag)
|
||||||
contestId: z.number().int(),
|
}
|
||||||
|
|
||||||
|
/** 榜单上一个人一道题的格子。时间都是秒,从比赛开始算 */
|
||||||
|
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 Contest = z.infer<typeof contestSchema>
|
||||||
export type ContestList = z.infer<typeof contestListSchema>
|
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 ContestAccess = z.infer<typeof contestAccessSchema>
|
||||||
export type ContestSubmissionInfo = z.infer<typeof contestSubmissionInfoSchema>
|
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 ContestStatus = z.infer<typeof contestStatusSchema>
|
||||||
export type ContestPasswordRequest = z.infer<typeof contestPasswordRequestSchema>
|
export type ContestPasswordRequest = z.infer<typeof contestPasswordRequestSchema>
|
||||||
export type ContestProblems = z.infer<typeof contestProblemsSchema>
|
export type ContestProblems = z.infer<typeof contestProblemsSchema>
|
||||||
Reference in new issue
Block a user