feat(前台): 顶栏可以输入题号直达;首页列出班里最近一次课一起做的题和自己的完成情况
Deploy / deploy (push) Canceled after 0s

课上老师报题号、全班去找,是 2025 秋七成非比赛提交的来路,界面上原本没有它的入口。

- 顶栏(桌面端)加题号框,首页「找题」同一套逻辑:题号对上直接进题,否则去列表搜
- 新接口 GET /me/class-activity:同班同一天 ≥5 人做过的题,取最近 7 天里最近的一天,
  附带我自己的状态与班里通过人数;首页据此显示「今天班里在做」卡片,没有就不占位

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 20:58:15 +08:00
1 parent d07459928d
commit 039992dc86
7 files changed
+279 -8

No files matched your search

+125 -1
View File
@@ -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<AppEnv>()
@@ -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<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
const userCount = sql<number>`count(distinct ${schema.submission.userId})::int`
const groups = await db
.select({
day,
problemId: schema.submission.problemId,
firstAt: sql<string>`min(${schema.submission.createTime})`,
userCount,
acceptedCount: sql<number>`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<string | null>(
(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<boolean>`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