From 151bbdcb50a000b3ef9f06e313d304d7276567ad Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 9 Oct 2026 20:58:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=AF=94=E8=B5=9B):=20=E6=AF=94=E8=B5=9B?= =?UTF-8?q?=E9=A1=B5=E4=B8=80=E9=A1=B5=E7=9C=8B=E9=A2=98=E7=9B=AE=E5=92=8C?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E5=90=8D=E6=AC=A1=EF=BC=9B=E6=8E=92?= =?UTF-8?q?=E5=90=8D=E7=AB=9E=E4=BA=89=E6=84=9F=E5=81=9A=E8=B6=B3=E3=80=81?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E6=9C=89=E9=A2=86=E5=A5=96=E5=8F=B0=EF=BC=9B?= =?UTF-8?q?=E8=80=81=E5=B8=88=E8=80=83=E8=AF=95=E4=B8=AD=E7=9C=8B=E5=85=A8?= =?UTF-8?q?=E7=8F=AD=E3=80=81=E8=80=83=E5=AE=8C=E7=9C=8B=E6=88=90=E7=BB=A9?= =?UTF-8?q?=EF=BC=9B=E6=96=B0=E5=BB=BA=E6=AF=94=E8=B5=9B=E4=B8=80=E5=B1=8F?= =?UTF-8?q?=E5=81=9A=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 学生比赛页(设计稿 B):左边题目带几人做对、谁最先做对;右边我第几名、5 分钟里升了几名、再做对 1 道能到第几名、我前后的人 - 期中、期末考试中只有自己的进度,写明「排名考完公布」,题目页的排名入口也藏起来 - 排名页:按班筛、名次变化、最先做对的深绿格、自己那行钉住;结束后前三名领奖台 + 我的成绩卡;排名曲线和学生的「导出数据」去掉 - 老师的「全班情况」:卡在一道题、15 分钟没交的放最上面,没进来的人对上看板的请假;考完变「成绩」,点格子看代码、标看过了(代替后台审核页),导出成绩 - 比赛列表:进行中的一大块「接着比」,已结束的写我第几名,前三带奖杯 - 新建比赛:默认 10 分钟后开始,时长点按钮,搜题号把题加进来自动编号、拖动排顺序 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/admin/api.ts | 15 +- .../src/admin/contest/components/Actions.vue | 22 +- apps/web/src/admin/contest/detail.vue | 750 ++++++++++-- apps/web/src/oj/api.ts | 23 +- .../oj/contest/components/ContestBadge.vue | 65 + .../src/oj/contest/components/ContestInfo.vue | 8 +- .../src/oj/contest/components/RankMini.vue | 132 +++ .../src/oj/contest/components/UserName.vue | 46 + apps/web/src/oj/contest/detail.vue | 269 ++++- apps/web/src/oj/contest/list.vue | 572 ++++++--- apps/web/src/oj/contest/pages/class.vue | 1052 +++++++++++++++++ apps/web/src/oj/contest/pages/problems.vue | 587 ++++++++- apps/web/src/oj/contest/pages/rank.vue | 870 +++++++++----- .../src/oj/problem/components/ContextBar.vue | 9 +- .../src/oj/problem/components/ProblemInfo.vue | 1 + apps/web/src/oj/store/contest.ts | 50 +- apps/web/src/routes.ts | 15 +- apps/web/src/utils/functions.ts | 18 + apps/web/src/utils/types.ts | 7 - 19 files changed, 3768 insertions(+), 743 deletions(-) create mode 100644 apps/web/src/oj/contest/components/ContestBadge.vue create mode 100644 apps/web/src/oj/contest/components/RankMini.vue create mode 100644 apps/web/src/oj/contest/components/UserName.vue create mode 100644 apps/web/src/oj/contest/pages/class.vue diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index fdb7b18..d82c3fb 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -7,8 +7,6 @@ import type { GenerateSqlTestCaseResponse, RenameTagResponse, SqlTestCaseScript, - AcmHelperItem, - ContestSubmissionInfo, AdminAiReport, AdminAiReportList, StuckProblem, @@ -357,6 +355,11 @@ export function addProblemForContest(contestID: string, problemID: number, displ }) } +/** 比赛题按这个顺序重新编号 1、2、3…(要给全这场比赛的题) */ +export function reorderContestProblems(contestID: string, problemIds: number[]) { + return api.put(`admin/contests/${contestID}/problems/order`, { problemIds }) +} + export function getWebsite() { return api.get("admin/website") } @@ -478,14 +481,6 @@ export function makeProblemPublic(id: number, displayId: string) { } // 比赛辅助检查 -export function getACMHelperList(contestId: number) { - // acInfo 在契约里是 Record(acm_contest_rank 的 JSONB 原文), - // 组件侧按 ContestSubmissionInfo 读,收窄放在这里 - return api.get & { acInfo: ContestSubmissionInfo }>>( - `admin/contests/${contestId}/acm-helper`, - ) -} - export function updateACMHelperChecked( contest_id: number, rank_id: number, diff --git a/apps/web/src/admin/contest/components/Actions.vue b/apps/web/src/admin/contest/components/Actions.vue index dd3dfaf..8e16ed4 100644 --- a/apps/web/src/admin/contest/components/Actions.vue +++ b/apps/web/src/admin/contest/components/Actions.vue @@ -16,18 +16,9 @@ function goEdit() { }) } -function goEditProblems() { - router.push({ - name: "admin contest problem list", - params: { contestID: props.contest.id }, - }) -} - -function goACMHelper() { - router.push({ - name: "admin contest helper", - params: { contestID: props.contest.id }, - }) +// 原来的「审核」页并进了前台比赛页老师的「全班情况 / 成绩」:点格子看代码、标看过了 +function goClass() { + window.open(`/contest/${props.contest.id}/class`, "_blank") } async function clone() { @@ -45,9 +36,10 @@ async function clone() { diff --git a/apps/web/src/admin/contest/detail.vue b/apps/web/src/admin/contest/detail.vue index 093ffa9..9c5ec37 100644 --- a/apps/web/src/admin/contest/detail.vue +++ b/apps/web/src/admin/contest/detail.vue @@ -1,57 +1,40 @@ {{ contestBar.notStarted ? "还没开始" : "已结束" }} - 排名 + 排名 diff --git a/apps/web/src/oj/problem/components/ProblemInfo.vue b/apps/web/src/oj/problem/components/ProblemInfo.vue index 6c8d90e..627fc8e 100644 --- a/apps/web/src/oj/problem/components/ProblemInfo.vue +++ b/apps/web/src/oj/problem/components/ProblemInfo.vue @@ -197,6 +197,7 @@ function segmentWidth(value: number, total: number) { 现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。 diff --git a/apps/web/src/oj/store/contest.ts b/apps/web/src/oj/store/contest.ts index 1c700e6..8333207 100644 --- a/apps/web/src/oj/store/contest.ts +++ b/apps/web/src/oj/store/contest.ts @@ -3,8 +3,15 @@ import { formatISO, getTime, parseISO } from "date-fns" import { useUserStore } from "shared/store/user" import { ContestStatus, ContestType } from "utils/constants" import { duration, secondsToDuration } from "utils/functions" +import { isExamTag, type ContestScoreboard } from "@oj2/contract" import type { Contest, ProblemRow } from "utils/types" -import { checkContestPassword, getContest, getContestAccess, getContestProblems } from "../api" +import { + checkContestPassword, + getContest, + getContestAccess, + getContestProblems, + getContestScoreboard, +} from "../api" export const useContestStore = defineStore("contest", () => { const userStore = useUserStore() @@ -12,6 +19,10 @@ export const useContestStore = defineStore("contest", () => { const contest = ref(null) const problems = ref([]) const now = ref(0) + /** 整张榜(学生比赛页右边的名次、排名页共用),进行中由页面每 10 秒刷一次 */ + const scoreboard = ref(null) + /** 上次拉到榜的时刻(本机时间),「8 秒前更新」用 */ + const scoreboardAt = ref(0) let timer = 0 @@ -54,8 +65,37 @@ export const useContestStore = defineStore("contest", () => { (userStore.isAuthed && contest.value?.createdBy.id === userStore.user!.id), ) + /** 老师(出题人、超管、其他老师):看得到全班情况、真名,期中期末进行中也看得到排名 */ + const isTeacher = computed(() => isContestAdmin.value || userStore.isTeacherOrAbove) + const isPrivate = computed(() => contest.value!.contestType === ContestType.private) + /** 期中、期末进行中,学生看不到排名和每题做对人数(后端同样拦着,这里只管界面) */ + const rankHidden = computed( + () => + !!contest.value && + isExamTag(contest.value.tag) && + contestStatus.value === ContestStatus.underway && + !isTeacher.value, + ) + + /** 能不能看题目(也就能看榜):开始了、密码对了;老师 / 出题人随时 */ + const canEnter = computed(() => { + if (!contest.value) return false + if (isContestAdmin.value) return true + if (contestStatus.value === ContestStatus.not_started) return false + return !isPrivate.value || access.value + }) + + async function loadScoreboard(contestID: string) { + try { + scoreboard.value = await getContestScoreboard(contestID) + scoreboardAt.value = Date.now() + } catch { + // 没开始 / 没密码时拉不到,界面按 canEnter 自己会挡住 + } + } + async function init(contestID: string) { problems.value = [] const res = await getContest(contestID) @@ -81,6 +121,7 @@ export const useContestStore = defineStore("contest", () => { function clear() { contest.value = null + scoreboard.value = null problems.value = [] toggleAccess(false) now.value = 0 @@ -116,6 +157,13 @@ export const useContestStore = defineStore("contest", () => { contest, contestStatus, isContestAdmin, + isTeacher, + rankHidden, + canEnter, + scoreboard, + scoreboardAt, + loadScoreboard, + now, access, problems, isPrivate, diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index d87987c..0175a32 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -55,6 +55,14 @@ export const ojs: RouteRecordRaw = { meta: { requiresAuth: true }, name: "contest rank", }, + // 老师的「全班情况」,考完是「成绩」(代替原来后台的「审核」页) + { + path: "class", + component: () => import("oj/contest/pages/class.vue"), + props: true, + meta: { requiresAuth: true }, + name: "contest class", + }, ], }, { @@ -244,13 +252,6 @@ export const admins: RouteRecordRaw = { props: true, meta: { requiresTeacherAdmin: true }, }, - { - path: "contest/:contestID/helper", - name: "admin contest helper", - component: () => import("admin/contest/helper.vue"), - props: true, - meta: { requiresTeacherAdmin: true }, - }, // 只有super_admin可以访问的路由 { path: "announcement/list", diff --git a/apps/web/src/utils/functions.ts b/apps/web/src/utils/functions.ts index 9e164b0..afa9f26 100644 --- a/apps/web/src/utils/functions.ts +++ b/apps/web/src/utils/functions.ts @@ -212,6 +212,24 @@ export function readableDuration(seconds: number): string { return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时` } +/** + * 比赛有多长,给人看的:「40 分钟」「1 小时 30 分钟」「3 天」。 + * `duration()` 会拆出「1月6天59分钟」这种,比赛列表里读不下去;一天以上只说几天 + */ +export function contestLength(start: Date | string, end: Date | string): string { + const minutes = Math.round( + (normalizeDate(end).getTime() - normalizeDate(start).getTime()) / 60_000, + ) + if (!Number.isFinite(minutes) || minutes <= 0) return "" + if (minutes < 60) return `${minutes} 分钟` + if (minutes < 24 * 60) { + const h = Math.floor(minutes / 60) + const m = minutes % 60 + return m ? `${h} 小时 ${m} 分钟` : `${h} 小时` + } + return `${Math.round(minutes / (24 * 60))} 天` +} + export function durationToDays(start: Date | string, end: Date | string): string { const durationObj = getDurationObject(start, end) const units = [ diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 09e0667..0afff09 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -329,12 +329,6 @@ export type BlankContest = Omit< */ export type { ContestSubmissionInfo } from "@oj2/contract" -/** - * 榜单行。`submissionInfo` 的收窄(JSONB 原文的 snake_case 形状)已经搬进 - * `contestRankItemSchema`,这里不再需要 Omit + 覆盖。 - */ -export type ContestRank = import("@oj2/contract").ContestRankItem - export type { WebsiteConfig, OnlineCount } from "@oj2/contract" export type { @@ -344,7 +338,6 @@ export type { OrphanTestCase, AdminUser, AdminUserList, - AcmHelperItem, AdminContestList, AdminProblemSetProgress, AdminProblemSetProblem,