diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index 59ec6bc..05df784 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -114,7 +114,7 @@ contestRoutes.get("/contests/:id", optionalAuth, async (c) => { c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }), ) - if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") + if (!contest) return failure(c, 404, "contest-not-found", "比赛不存在") const byId = await creators([contest.createdById]) return success( c, @@ -133,9 +133,8 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => { c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }), ) - if (!contest || !contest.password) - return failure(c, 404, "contest-not-found", "Contest does not exist") - const parsed = await parseBody(c, contestPasswordRequestSchema, "Password is required") + if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "比赛不存在") + const parsed = await parseBody(c, contestPasswordRequestSchema, "请输入比赛密码") if (!parsed.success) return parsed.response // 比赛密码往往就是几位数字,不限的话一个脚本几分钟就能扫完。 // 按「人 × 比赛」计失败次数:猜错的是自己,锁的也只是自己进这一场。 @@ -151,7 +150,7 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => { } if (!checkContestPassword(parsed.data.password, contest.password)) { await countAttempt(attemptKey, CONTEST_PASSWORD_RULE) - return failure(c, 403, "wrong-password", "Wrong password or password expired") + return failure(c, 403, "wrong-password", "比赛密码不对,或者已经过期了,请重新输入") } await setContestPassword(c, contest.id, parsed.data.password) return success(c, true) @@ -162,8 +161,7 @@ contestRoutes.get("/contests/:id/access", requireAuth, async (c) => { c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }), ) - if (!contest || !contest.password) - return failure(c, 404, "contest-not-found", "Contest does not exist") + if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "比赛不存在") const access = await canAccessContest(c, contest, "details") return success(c, { access: access.ok } satisfies ContestAccess) }) @@ -276,7 +274,7 @@ contestRoutes.get( ), ) .limit(1) - if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist") + if (!row) return failure(c, 404, "problem-not-found", "题目不存在") const tags = await contestProblemTags([row.problem.id]) const allowed = contestDetailsAllowed(c.get("user"), contest) const statuses = await contestProblemStatuses(c.get("user")?.id) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 2af4452..3b4f9ea 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -618,16 +618,23 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => { /** * 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。 * current / history / scores 三个接口都从这一份出。 + * + * 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次 + * 的学生每切一次题就要搬几百 KB,而结果页签只要分数。要整行的 history 自己按 id 再取。 + * userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。 */ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) { const [rows, joinTimes] = await Promise.all([ db .select({ - flowchart: schema.flowchartSubmission, - username: schema.user.username, + id: schema.flowchartSubmission.id, + userId: schema.flowchartSubmission.userId, + problemId: schema.flowchartSubmission.problemId, + createTime: schema.flowchartSubmission.createTime, + aiScore: schema.flowchartSubmission.aiScore, + aiGrade: schema.flowchartSubmission.aiGrade, }) .from(schema.flowchartSubmission) - .innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id)) .where( and( eq(schema.flowchartSubmission.userId, user.id), @@ -638,7 +645,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) { .orderBy(asc(schema.flowchartSubmission.createTime)), joinTimesFor(user, [{ userId: user.id, problemId }]), ]) - const visible = rows.filter((row) => !hiddenByProblemSet(user, row.flowchart, joinTimes)) + const visible = rows.filter((row) => !hiddenByProblemSet(user, row, joinTimes)) return { visible, hidden: rows.length - visible.length } } @@ -650,7 +657,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) { flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => { const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) const { visible } = await myEvaluatedFlowcharts(c.get("user")!, problemId) - const latest = visible.at(-1)?.flowchart + const latest = visible.at(-1) return success(c, { count: visible.length, score: latest?.aiScore ?? 0, @@ -661,11 +668,20 @@ flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => { const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) const page = queryInteger(c.req.query("page"), 0, { min: 0 }) - const { visible: rows } = await myEvaluatedFlowcharts(c.get("user")!, problemId) + const user = c.get("user")! + const { visible: rows } = await myEvaluatedFlowcharts(user, problemId) const selected = page === 0 ? rows.at(-1) : rows[page - 1] if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range") + // 列表只带了分数,选中的那一条再按 id 取整行。都是本人的提交,用户名就是自己的 + const [full] = selected + ? await db + .select() + .from(schema.flowchartSubmission) + .where(eq(schema.flowchartSubmission.id, selected.id)) + .limit(1) + : [] return success(c, { - submission: selected ? flowchartData(selected.flowchart, selected.username) : null, + submission: full ? flowchartData(full, user.username) : null, count: rows.length, } satisfies FlowchartDetail) }) @@ -678,7 +694,7 @@ flowchartRoutes.get("/problems/:id/flowchart/scores", requireAuth, async (c) => const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) const { visible, hidden } = await myEvaluatedFlowcharts(c.get("user")!, problemId) return success(c, { - scores: visible.map(({ flowchart }) => ({ + scores: visible.map((flowchart) => ({ id: flowchart.id, score: flowchart.aiScore ?? 0, grade: flowchart.aiGrade ?? "", diff --git a/apps/api/src/services/contest.ts b/apps/api/src/services/contest.ts index a7a3379..e6d61d4 100644 --- a/apps/api/src/services/contest.ts +++ b/apps/api/src/services/contest.ts @@ -86,7 +86,7 @@ export async function canAccessContest( return { ok: false as const, code: "wrong-password", - message: "Wrong password or password expired", + message: "比赛密码不对,或者已经过期了,请重新输入", } } } @@ -94,7 +94,7 @@ export async function canAccessContest( return { ok: false as const, code: "contest-not-started", - message: "Contest has not started yet.", + message: "比赛还没有开始", } } return { ok: true as const } @@ -119,7 +119,7 @@ export function requireContestAccess( const id = Number(c.req.param(paramName)) const contest = Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null - if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") + if (!contest) return failure(c, 404, "contest-not-found", "比赛不存在") const access = await canAccessContest(c, contest, checkType) if (!access.ok) { return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) diff --git a/apps/web/src/oj/problem/components/CollabBar.vue b/apps/web/src/oj/problem/components/CollabBar.vue index 9df01fb..221f2c9 100644 --- a/apps/web/src/oj/problem/components/CollabBar.vue +++ b/apps/web/src/oj/problem/components/CollabBar.vue @@ -23,7 +23,8 @@ function openHisSubmissions() { if (!room || !problem.value) return const target = router.resolve({ name: "submissions", - query: { problem: problem.value._id, username: room.peerName }, + // exactUsername:整名匹配,不然学号相近的同学(ks24a1 / ks24a10…)会混进来 + query: { problem: problem.value._id, username: room.peerName, exactUsername: "1" }, }) window.open(target.href, "_blank") } diff --git a/apps/web/src/oj/problem/components/ContextBar.vue b/apps/web/src/oj/problem/components/ContextBar.vue index 0fc1193..fdf989f 100644 --- a/apps/web/src/oj/problem/components/ContextBar.vue +++ b/apps/web/src/oj/problem/components/ContextBar.vue @@ -9,6 +9,7 @@ import { useProblemStore } from "oj/store/problem" import { useUserStore } from "shared/store/user" import { useBreakpoints } from "shared/composables/breakpoints" import { useThemeVars } from "naive-ui" +import { ContestStatus } from "utils/constants" import type { ProblemSet, ProblemSetProblem } from "utils/types" import { useProblemPageContext } from "../composables/problemPageContext" import ProblemChips, { type ChipItem } from "./ProblemChips.vue" @@ -213,6 +214,8 @@ const contestBar = computed(() => { title: contest.title, chips, remaining: contestStore.remaining, + // 不在进行中的两种:管理员赛前看题是「还没开始」,不能一律写「已结束」 + notStarted: contestStore.contestStatus === ContestStatus.not_started, home: { name: "contest problems", params: { contestID: ctx.value.contestId } }, rank: { name: "contest rank", params: { contestID: ctx.value.contestId } }, } @@ -352,7 +355,7 @@ onSolvedHere(() => { 还剩 {{ contestBar.remaining }} - 已结束 + {{ contestBar.notStarted ? "还没开始" : "已结束" }} 排名 diff --git a/apps/web/src/oj/problem/components/EditorToolbar.vue b/apps/web/src/oj/problem/components/EditorToolbar.vue index d7294df..56f8ddd 100644 --- a/apps/web/src/oj/problem/components/EditorToolbar.vue +++ b/apps/web/src/oj/problem/components/EditorToolbar.vue @@ -226,7 +226,7 @@ const changeLanguage = (v: LANGUAGE) => { diff --git a/apps/web/src/oj/problem/components/FlowchartResult.vue b/apps/web/src/oj/problem/components/FlowchartResult.vue index aa27033..2ef5174 100644 --- a/apps/web/src/oj/problem/components/FlowchartResult.vue +++ b/apps/web/src/oj/problem/components/FlowchartResult.vue @@ -4,6 +4,7 @@ import { storeToRefs } from "pinia" import { useCodeStore } from "oj/store/code" import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart" import { useProblemStore } from "oj/store/problem" +import { useUserStore } from "shared/store/user" import { sortFlowchartCriteria } from "utils/constants" import { decompressFromBase64, parseTime } from "utils/functions" @@ -12,13 +13,29 @@ import { decompressFromBase64, parseTime } from "utils/functions" * * 原来评分详情是提交按钮旁一个 1000px 的弹框:盖住画布,学生没法一边看改进建议一边改图; * 翻历次评分要一页一页点分页器。现在摆在左栏,画布整列都在右边(设计文档第 6 节)。 + * + * viewOnly:手机上只能看、不能画(设计文档 5.6),不给「照着它写代码」「载回画布」。 */ +const { viewOnly = false } = defineProps<{ viewOnly?: boolean }>() + +const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue")) const flowchartStore = useFlowchartStore() -const { phase, scores, hiddenCount, selectedId, details, detailLoading, editor } = +const { phase, scores, hiddenCount, selectedId, details, detailLoading, scoresFailed, editor } = storeToRefs(flowchartStore) const problemStore = useProblemStore() const codeStore = useCodeStore() +const userStore = useUserStore() + +// 历次分数原来只由题面(ProblemContent)去读。手机上题面是另一个页签、不切过去就不挂载, +// 直接打开 ?tab=result 的话「流程图点评」一条都没有。这里自己也读一次(同一道题只读一次) +watch( + () => [problemStore.problem?.id, userStore.isAuthed] as const, + ([id, authed]) => { + if (id && authed) flowchartStore.ensureLoaded() + }, + { immediate: true }, +) const message = useMessage() const theme = useThemeVars() // 暗色主题的成功色是浅薄荷绿,上面的白字看不清 @@ -33,6 +50,23 @@ const current = computed(() => { const detail = computed(() => (current.value ? details.value[current.value.id] : undefined)) const viewingLatest = computed(() => selectedId.value === null) +// ==================== 这一版的图 ==================== +/** + * 点历史分数条上的哪一次,就摆哪一次交上去的图(只读的 Mermaid 渲染,不动画布)。 + * 原来的评分弹框左边就是这张图;挪进结果页签时丢了,学生翻到「第 2 次 C 42」 + * 只看得到评语,想不起来那一版画成什么样。图从评语同一个详情接口来,随评语一起现拉。 + */ +const versionIndex = computed(() => + current.value ? scores.value.findIndex((row) => row.id === current.value!.id) : -1, +) +const versionTitle = computed(() => { + if (!current.value) return "" + return `第 ${versionIndex.value + 1} 次画的图 · ${parseTime(current.value.createTime, "MM-DD HH:mm")}` +}) +/** 收起来之后翻别的次也保持收着:左栏在机房屏上只有六百来像素高 */ +const versionOpen = ref(true) +const versionZoom = ref(false) + // 评完的那次、点到的那次,评语都要现拉(历次分数里只有分数) watch( () => current.value?.id, @@ -162,10 +196,18 @@ function goCode() {