diff --git a/apps/api/src/routes/admin/contest.ts b/apps/api/src/routes/admin/contest.ts index 6cfd8ca..b945afe 100644 --- a/apps/api/src/routes/admin/contest.ts +++ b/apps/api/src/routes/admin/contest.ts @@ -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 }) diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 452421e..59bc617 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -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) => { diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index 300fffc..f5486be 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -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)) + }, +) diff --git a/apps/api/src/services/contest.ts b/apps/api/src/services/contest.ts index e6d61d4..e568404 100644 --- a/apps/api/src/services/contest.ts +++ b/apps/api/src/services/contest.ts @@ -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 +} + +/** + * 只算 `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 { + 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() + 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 { + 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() + 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() + 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 } +} diff --git a/apps/web/src/admin/contest/helper.vue b/apps/web/src/admin/contest/helper.vue deleted file mode 100644 index cfce653..0000000 --- a/apps/web/src/admin/contest/helper.vue +++ /dev/null @@ -1,297 +0,0 @@ - - - - - diff --git a/apps/web/src/oj/contest/components/AcAndSubmission.vue b/apps/web/src/oj/contest/components/AcAndSubmission.vue deleted file mode 100644 index 4014e93..0000000 --- a/apps/web/src/oj/contest/components/AcAndSubmission.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/apps/web/src/oj/contest/components/ContestMenu.vue b/apps/web/src/oj/contest/components/ContestMenu.vue deleted file mode 100644 index 030be05..0000000 --- a/apps/web/src/oj/contest/components/ContestMenu.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/apps/web/src/oj/contest/components/LineChart.vue b/apps/web/src/oj/contest/components/LineChart.vue deleted file mode 100644 index 792f9cc..0000000 --- a/apps/web/src/oj/contest/components/LineChart.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - - diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index f7a68c4..a5ef449 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -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 export type AdminContest = z.infer export type AdminContestList = z.infer -export type AcmHelperItem = z.infer export type AdminUser = z.infer export type AdminUserList = z.infer export type JudgeServer = z.infer diff --git a/packages/contract/src/contest.ts b/packages/contract/src/contest.ts index 3f63a9f..325db06 100644 --- a/packages/contract/src/contest.ts +++ b/packages/contract/src/contest.ts @@ -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 export type ContestList = z.infer -export type ContestRankItem = z.infer -export type ContestRank = z.infer export type ContestAccess = z.infer export type ContestSubmissionInfo = z.infer +export type ContestScoreCell = z.infer +export type ContestScoreRow = z.infer +export type ContestScoreProblem = z.infer +export type ContestScoreboard = z.infer +export type ContestClassView = z.infer + export type ContestStatus = z.infer export type ContestPasswordRequest = z.infer export type ContestProblems = z.infer