From 039992dc86c7c680249f4115b2eb51d578ac1058 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 20:58:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=89=8D=E5=8F=B0):=20=E9=A1=B6=E6=A0=8F?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E8=BE=93=E5=85=A5=E9=A2=98=E5=8F=B7=E7=9B=B4?= =?UTF-8?q?=E8=BE=BE=EF=BC=9B=E9=A6=96=E9=A1=B5=E5=88=97=E5=87=BA=E7=8F=AD?= =?UTF-8?q?=E9=87=8C=E6=9C=80=E8=BF=91=E4=B8=80=E6=AC=A1=E8=AF=BE=E4=B8=80?= =?UTF-8?q?=E8=B5=B7=E5=81=9A=E7=9A=84=E9=A2=98=E5=92=8C=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E7=9A=84=E5=AE=8C=E6=88=90=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 课上老师报题号、全班去找,是 2025 秋七成非比赛提交的来路,界面上原本没有它的入口。 - 顶栏(桌面端)加题号框,首页「找题」同一套逻辑:题号对上直接进题,否则去列表搜 - 新接口 GET /me/class-activity:同班同一天 ≥5 人做过的题,取最近 7 天里最近的一天, 附带我自己的状态与班里通过人数;首页据此显示「今天班里在做」卡片,没有就不占位 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/classroom.ts | 126 +++++++++++++++++- apps/web/src/oj/api.ts | 5 + apps/web/src/oj/home/dashboard.vue | 73 +++++++++- apps/web/src/shared/components/Header.vue | 29 ++++ .../web/src/shared/composables/problemJump.ts | 27 ++++ apps/web/src/utils/types.ts | 3 + packages/contract/src/classroom.ts | 24 ++++ 7 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/shared/composables/problemJump.ts diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index d1fc604..4f92c44 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -1,18 +1,21 @@ import { classComparisonRequestSchema, STUDENT_ROLES, + type ClassActivity, + type ClassActivityProblem, type ClassComparison, type ClassComparisonResponse, type ClassRankItem, type ClassUserRank, } from "@oj2/contract" -import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm" +import { and, eq, gte, inArray, isNull, like, lte, sql } from "drizzle-orm" import { Hono } from "hono" import { requireAuth, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { JudgeStatus } from "../judge/status" +import { dayStart, localTime } from "../time" import { queryInteger, rounded } from "./helpers" export const classroomRoutes = new Hono() @@ -141,6 +144,127 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => { } satisfies ClassUserRank) }) +/** + * 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的: + * (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), + * 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。 + */ +const CLASS_ACTIVITY_MIN_USERS = 5 +/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */ +const CLASS_ACTIVITY_LOOKBACK_DAYS = 7 +const CLASS_ACTIVITY_LIMIT = 8 +/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */ +const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] + +/** + * 班里最近一次「一起做」的那几道题。课上老师报题号、全班去找 —— 这是 2025 秋 + * 七成非比赛提交的来路,但界面上原本没有它的入口,没听清题号的只能问同桌。 + * + * 只取最近的**一天**,不把 7 天摊平:摊平了就是一张越来越长的旧题单, + * 「现在该做哪道」反而看不出来。题按那天第一次有人提交的时间排,大致就是老师点题的顺序。 + */ +classroomRoutes.get("/me/class-activity", requireAuth, async (c) => { + const user = c.get("user")! + if (!user.className) { + return success(c, { className: null, day: null, problems: [] } satisfies ClassActivity) + } + + const since = dayStart(Date.now() - (CLASS_ACTIVITY_LOOKBACK_DAYS - 1) * 86_400_000) + const day = sql`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')` + const userCount = sql`count(distinct ${schema.submission.userId})::int` + const groups = await db + .select({ + day, + problemId: schema.submission.problemId, + firstAt: sql`min(${schema.submission.createTime})`, + userCount, + acceptedCount: sql`count(distinct ${schema.submission.userId}) filter (where ${inArray(schema.submission.result, SOLVED_RESULTS)})::int`, + }) + .from(schema.submission) + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .where( + and( + isNull(schema.submission.contestId), + gte(schema.submission.createTime, since), + eq(schema.user.className, user.className), + eq(schema.user.isDisabled, false), + inArray(schema.user.adminType, [...STUDENT_ROLES]), + ), + ) + .groupBy(day, schema.submission.problemId) + .having(sql`${userCount} >= ${CLASS_ACTIVITY_MIN_USERS}`) + + const latest = groups.reduce( + (acc, row) => (!acc || row.day > acc ? row.day : acc), + null, + ) + const picked = groups + .filter((row) => row.day === latest) + .sort((a, b) => a.firstAt.localeCompare(b.firstAt)) + .slice(0, CLASS_ACTIVITY_LIMIT) + if (!picked.length) { + return success(c, { + className: user.className, + day: null, + problems: [], + } satisfies ClassActivity) + } + + const ids = picked.map((row) => row.problemId) + const [problems, mine] = await Promise.all([ + // 题目后来被藏起来的就不列了,点进去也是 404 + db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + }) + .from(schema.problem) + .where( + and( + inArray(schema.problem.id, ids), + eq(schema.problem.visible, true), + isNull(schema.problem.contestId), + ), + ), + db + .select({ + problemId: schema.submission.problemId, + accepted: sql`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`, + }) + .from(schema.submission) + .where( + and( + eq(schema.submission.userId, user.id), + inArray(schema.submission.problemId, ids), + isNull(schema.submission.contestId), + ), + ) + .groupBy(schema.submission.problemId), + ]) + const problemById = new Map(problems.map((row) => [row.id, row])) + const mineById = new Map(mine.map((row) => [row.problemId, row.accepted])) + + return success(c, { + className: user.className, + day: latest, + problems: picked.flatMap((row) => { + const problem = problemById.get(row.problemId) + if (!problem) return [] + const accepted = mineById.get(row.problemId) + return [ + { + problemDisplayId: problem.displayId, + title: problem.title, + userCount: row.userCount, + acceptedCount: row.acceptedCount, + myStatus: accepted === undefined ? "none" : accepted ? "accepted" : "tried", + } satisfies ClassActivityProblem, + ] + }), + } satisfies ClassActivity) +}) + classroomRoutes.post("/classes/comparison", async (c) => { const parsed = await parseBody(c, classComparisonRequestSchema, "At least one class is required") if (!parsed.success) return parsed.response diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index bbd6815..b5474ad 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -8,6 +8,7 @@ import { type FormatCodeResponse, type Metrics, type TutorialSummary, + type ClassActivity, type ClassComparisonResponse, type ClassRankItem, type ClassUserRank, @@ -216,6 +217,10 @@ export function getWeeklyRank(scope: "global" | "class") { return api.get("rankings/weekly", { params: { scope } }) } +export function getClassActivity() { + return api.get("me/class-activity") +} + export function getClassRank(grade?: number | null) { return api.get("rankings/classes", { params: { grade } }) } diff --git a/apps/web/src/oj/home/dashboard.vue b/apps/web/src/oj/home/dashboard.vue index 6f545d5..ddf7082 100644 --- a/apps/web/src/oj/home/dashboard.vue +++ b/apps/web/src/oj/home/dashboard.vue @@ -3,6 +3,7 @@ import { Icon } from "@iconify/vue" import { getAnnouncement, getAnnouncementList, + getClassActivity, getContestList, getLearnProgress, getSubmissions, @@ -11,10 +12,18 @@ import { } from "oj/api" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import { useBreakpoints } from "shared/composables/breakpoints" +import { useProblemJump } from "shared/composables/problemJump" import { useUserStore } from "shared/store/user" import { ContestStatus, CONTEST_STATUS } from "utils/constants" import { duration, parseTime, zonedParts } from "utils/functions" -import type { AnnouncementListItem, Contest, SubmissionListItem, WeeklyRank } from "utils/types" +import type { + AnnouncementListItem, + ClassActivity, + ClassActivityProblem, + Contest, + SubmissionListItem, + WeeklyRank, +} from "utils/types" type TutorialType = "python" | "c" @@ -39,6 +48,7 @@ const contests = ref([]) const submissions = ref([]) const announcements = ref([]) const weekly = ref(null) +const classActivity = ref(null) const loaded = ref(false) const keyword = ref("") @@ -111,6 +121,30 @@ async function loadAnnouncements() { announcements.value = res.results } +async function loadClassActivity() { + classActivity.value = await getClassActivity() +} + +/** 今天的就叫「今天」,往前找到的那天标出日期 —— 周一早上看到的是上周五的课 */ +const classActivityTitle = computed(() => { + const day = classActivity.value?.day + if (!day) return "" + const now = zonedParts(new Date())! + const today = `${now.year}-${String(now.month).padStart(2, "0")}-${String(now.day).padStart(2, "0")}` + if (day === today) return "今天班里在做" + const [, month, date] = day.split("-").map(Number) + return `${month}月${date}日班里做了` +}) + +const MY_STATUS: Record< + ClassActivityProblem["myStatus"], + { label: string; type: "success" | "warning" | "default" } +> = { + accepted: { label: "已通过", type: "success" }, + tried: { label: "未通过", type: "warning" }, + none: { label: "没做", type: "default" }, +} + async function loadWeekly() { weekly.value = await getWeeklyRank(userStore.user?.className ? "class" : "global") } @@ -123,6 +157,7 @@ async function load() { loadSubmissions(), loadAnnouncements(), loadWeekly(), + loadClassActivity(), ]) loaded.value = true } @@ -137,10 +172,7 @@ function learnLink(track: Track) { return `/learn/${track.type}/${track.step.toString().padStart(2, "0")}` } -function searchProblem() { - const value = keyword.value.trim() - router.push({ path: "/problem", query: value ? { keyword: value } : {} }) -} +const { jump, jumping } = useProblemJump() function openSubmission(row: SubmissionListItem) { if (row.showLink) router.push(`/submission/${row.id}`) @@ -198,14 +230,41 @@ async function openAnnouncement(item: AnnouncementListItem) { v-model:value="keyword" placeholder="题号或题目名" clearable - @keyup.enter="searchProblem" + @keyup.enter="jump(keyword)" /> - 找题 + 找题
+ + + + + + {{ MY_STATUS[item.myStatus].label }} + + {{ item.problemDisplayId }} {{ item.title }} + + {{ item.acceptedCount }}/{{ item.userCount }} 人通过 + + + + { if (import.meta.env.PUBLIC_ENV === "test") { return "测试版" @@ -263,6 +276,18 @@ function goHome() { 菜单 + + + diff --git a/apps/web/src/shared/composables/problemJump.ts b/apps/web/src/shared/composables/problemJump.ts new file mode 100644 index 0000000..3d9bbcb --- /dev/null +++ b/apps/web/src/shared/composables/problemJump.ts @@ -0,0 +1,27 @@ +import { getProblemList } from "oj/api" + +/** + * 按题号直达。课上老师报一个题号、全班去找,是非比赛提交最主要的来路, + * 所以敲完题号回车就该进题,而不是先落到列表再点一下。 + * + * 题号对上了(不分大小写)才直达;对不上、或者敲的是题目名,就去列表搜 —— + * 列表那边的 keyword 是题号和标题一起模糊匹配,这里只是在它前面加了一步。 + */ +export function useProblemJump() { + const router = useRouter() + const jumping = ref(false) + + async function jump(input: string) { + const keyword = input.trim() + if (!keyword) return router.push("/problem") + jumping.value = true + // 查询失败就当没对上,照样去列表搜,别让一次网络抖动把回车吞掉 + const res = await getProblemList(0, 20, { keyword }).catch(() => null) + jumping.value = false + const exact = res?.results.find((row) => row._id.toLowerCase() === keyword.toLowerCase()) + if (exact) return router.push(`/problem/${exact._id}`) + return router.push({ path: "/problem", query: { keyword } }) + } + + return { jump, jumping } +} diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 0f9ebce..4cc9438 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -287,6 +287,9 @@ export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract" export type { ClassComparison, ClassRankItem, ClassUserRank } from "@oj2/contract" +/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */ +export type { ClassActivity, ClassActivityProblem } from "@oj2/contract" + /** * 学生侧的比赛:不含 password / visible。后台用 `AdminContest` —— * oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生) diff --git a/packages/contract/src/classroom.ts b/packages/contract/src/classroom.ts index 5a01a10..9fc3a19 100644 --- a/packages/contract/src/classroom.ts +++ b/packages/contract/src/classroom.ts @@ -63,10 +63,34 @@ export const classComparisonResponseSchema = z.object({ hasTimeRange: z.boolean(), }) +/** + * 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。 + * `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。 + */ +export const classActivityProblemSchema = z.object({ + problemDisplayId: z.string(), + title: z.string(), + /** 那天做过这道题的同班人数(含自己) */ + userCount: z.number().int(), + /** 其中那天通过了的人数 */ + acceptedCount: z.number().int(), + /** 我自己在题库里(不含比赛)做这道题的状态,不限那一天 */ + myStatus: z.enum(["accepted", "tried", "none"]), +}) + +export const classActivitySchema = z.object({ + className: z.string().nullable(), + day: z.string().nullable(), + problems: z.array(classActivityProblemSchema), +}) + export type ClassRankItem = z.infer export type ClassUserRank = z.infer export type ClassComparison = z.infer export type ClassComparisonResponse = z.infer +export type ClassActivity = z.infer +export type ClassActivityProblem = z.infer + export type ClassUserRankItem = z.infer export type ClassComparisonRequest = z.infer