feat(题目页): 「统计」页签重做——学生看难不难、几次做对、常错在哪,老师按班看谁没做对
- 全部按人算:原来的「通过数」是 AC 条数、「通过率」按提交算;击败用户、饼图、历年 AC 率去掉 - 错得最多的那种给一句该怎么办(都指向「运行例子」),自己交了 3 次以上还没对再多一张卡 - 老师选一个班:做对 / 交了没对(次数、最后结果)/ 没交;班里错得和全站不一样时点出来 - 题库入口工具栏的「课堂统计」并进这里(题单没有页签,按钮留着) - 新接口 /problems/:id/stats(按内部题号,比赛题不再查到同号公开题),删掉 beat-count、yearly-ac Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91d9c9c6d7
commit
99319fdcc5
10 files changed
+1017
-317
No files matched your search
@@ -15,6 +15,7 @@ import { achievementRoutes } from "./routes/achievement"
|
||||
import { aiRoutes } from "./routes/ai"
|
||||
import { flowchartRoutes } from "./routes/flowchart"
|
||||
import { problemRoutes } from "./routes/problem"
|
||||
import { problemStatsRoutes } from "./routes/problem-stats"
|
||||
import { submissionRoutes } from "./routes/submission"
|
||||
import { trialRunRoutes } from "./routes/trial-run"
|
||||
import { siteRoutes } from "./routes/site"
|
||||
@@ -40,6 +41,7 @@ app.route("/api", achievementRoutes)
|
||||
app.route("/api", aiRoutes)
|
||||
app.route("/api", flowchartRoutes)
|
||||
app.route("/api", problemRoutes)
|
||||
app.route("/api", problemStatsRoutes)
|
||||
app.route("/api", submissionRoutes)
|
||||
app.route("/api", trialRunRoutes)
|
||||
app.route("/api", judgeServerRoutes)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { type ProblemClassDetail, type ProblemStats } from "@oj2/contract"
|
||||
import { and, eq, isNotNull, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
|
||||
import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest"
|
||||
import { accepted } from "../services/learning-stats"
|
||||
import { localTime } from "../time"
|
||||
import { isAdminRole, isTeacherOrAbove, queryInteger, stripClassPrefix } from "./helpers"
|
||||
|
||||
export const problemStatsRoutes = new Hono<AppEnv>()
|
||||
|
||||
/**
|
||||
* 同一天班里至少这么多人交过,才算「这个班一起做过这题」。只看总人数不行:
|
||||
* 2079 有个班 2 月里零星补做了 6 个人,比 12 月全班 26 人做的那次还「近」,默认就选成了它
|
||||
*/
|
||||
const CLASS_MIN_TRIED = 5
|
||||
|
||||
const EMPTY: ProblemStats = {
|
||||
locked: false,
|
||||
tried: 0,
|
||||
solved: 0,
|
||||
tries: { one: 0, few: 0, many: 0 },
|
||||
failures: [],
|
||||
wrongAnswerFirstCase: 0,
|
||||
me: null,
|
||||
myClass: null,
|
||||
classes: null,
|
||||
classDetail: null,
|
||||
}
|
||||
|
||||
// type 而不是 interface:db.execute 的泛型约束是 Record<string, unknown>,interface 不带索引签名
|
||||
type UserRow = {
|
||||
user_id: number
|
||||
username: string | null
|
||||
class_name: string | null
|
||||
attempts: number
|
||||
/** 做对之前(含做对那次)交了几次;没做对就是全部次数 */
|
||||
tries: number
|
||||
solved: boolean
|
||||
last_result: JudgeStatusValue
|
||||
last_time: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目页「统计」页签。id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,
|
||||
* 拿 displayId 查会撞上同号的公开题(原来的「历年 AC 率」就是这么查错的)。
|
||||
* `className` 只有老师用:选中哪个班看明细,不填就是最近做过这题的那个班。
|
||||
*/
|
||||
problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problem] = await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
contestId: schema.problem.contestId,
|
||||
visible: schema.problem.visible,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(eq(schema.problem.id, id))
|
||||
.limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
|
||||
const inContest = problem.contestId !== null
|
||||
if (inContest) {
|
||||
const contest = await findAccessibleContest(user, problem.contestId!)
|
||||
if (!contest) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const access = await canAccessContest(c, contest, "problems")
|
||||
if (!access.ok) return failure(c, 403, access.code, access.message)
|
||||
// 比赛没结束:别人几次做对、错在哪一律不给,和题目详情里计数归零同一个口径
|
||||
if (!contestDetailsAllowed(user, contest)) {
|
||||
return success(c, { ...EMPTY, locked: true } satisfies ProblemStats)
|
||||
}
|
||||
} else if (!problem.visible && !isAdminRole(user)) {
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
}
|
||||
|
||||
const acceptedList = sql.join(
|
||||
accepted.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)
|
||||
const unjudgedList = sql.join(
|
||||
UNJUDGED_RESULTS.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)
|
||||
// 每人一行:窗口函数先给每条提交标上这个人第一次做对的时刻,再按人数到那一刻为止交了几次
|
||||
const day = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
|
||||
const [users, failureRows, firstCase, classDays] = await Promise.all([
|
||||
db.execute<UserRow>(sql`
|
||||
select s.user_id, u.username, u.class_name,
|
||||
count(*)::int as attempts,
|
||||
count(*) filter (where s.first_ac is null or s.create_time <= s.first_ac)::int as tries,
|
||||
(min(s.first_ac) is not null) as solved,
|
||||
(array_agg(s.result order by s.create_time desc))[1] as last_result,
|
||||
max(s.create_time) as last_time
|
||||
from (
|
||||
select user_id, result, create_time,
|
||||
min(create_time) filter (where result in (${acceptedList}))
|
||||
over (partition by user_id) as first_ac
|
||||
from ${schema.submission}
|
||||
where problem_id = ${problem.id} and result not in (${unjudgedList})
|
||||
) s
|
||||
left join ${schema.user} u on u.id = s.user_id
|
||||
group by s.user_id, u.username, u.class_name
|
||||
`),
|
||||
db.execute<{ class_name: string | null; result: JudgeStatusValue; n: number }>(sql`
|
||||
select u.class_name, s.result, count(*)::int as n
|
||||
from ${schema.submission} s
|
||||
left join ${schema.user} u on u.id = s.user_id
|
||||
where s.problem_id = ${problem.id}
|
||||
and s.result not in (${acceptedList})
|
||||
and s.result not in (${unjudgedList})
|
||||
group by u.class_name, s.result
|
||||
`),
|
||||
// 判题机按测试点回一个数组,test_case 是 "1"、"2"……
|
||||
db.execute<{ n: number }>(sql`
|
||||
select count(*)::int as n from ${schema.submission}
|
||||
where problem_id = ${problem.id} and result = ${JudgeStatus.WRONG_ANSWER}
|
||||
and jsonb_typeof(info->'data') = 'array'
|
||||
and exists (
|
||||
select 1 from jsonb_array_elements(info->'data') e
|
||||
where e->>'test_case' = '1' and (e->>'result')::int <> 0
|
||||
)
|
||||
`),
|
||||
// 每个班每天几个人交过,取人最多的那天当「这个班做这题的那天」
|
||||
db
|
||||
.select({
|
||||
className: schema.user.className,
|
||||
day,
|
||||
users: sql<number>`count(distinct ${schema.submission.userId})::int`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(and(eq(schema.submission.problemId, problem.id), isNotNull(schema.user.className)))
|
||||
.groupBy(schema.user.className, day),
|
||||
])
|
||||
|
||||
const solvedUsers = users.filter((row) => row.solved)
|
||||
const tries = { one: 0, few: 0, many: 0 }
|
||||
for (const row of solvedUsers) {
|
||||
if (row.tries <= 1) tries.one++
|
||||
else if (row.tries <= 3) tries.few++
|
||||
else tries.many++
|
||||
}
|
||||
const sortFailures = (map: Map<JudgeStatusValue, number>) =>
|
||||
[...map].map(([result, count]) => ({ result, count })).sort((a, b) => b.count - a.count)
|
||||
const siteFailures = new Map<JudgeStatusValue, number>()
|
||||
for (const row of failureRows) {
|
||||
siteFailures.set(row.result, (siteFailures.get(row.result) ?? 0) + row.n)
|
||||
}
|
||||
|
||||
const mine = user ? users.find((row) => row.user_id === user.id) : undefined
|
||||
const myClassName = !inContest && user?.className ? user.className : null
|
||||
const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : []
|
||||
|
||||
let classes: ProblemStats["classes"] = null
|
||||
let classDetail: ProblemClassDetail | null = null
|
||||
if (!inContest && isTeacherOrAbove(user)) {
|
||||
const byClass = new Map<string, UserRow[]>()
|
||||
for (const row of users) {
|
||||
if (!row.class_name) continue
|
||||
byClass.set(row.class_name, [...(byClass.get(row.class_name) ?? []), row])
|
||||
}
|
||||
const peak = new Map<string, { day: string; users: number }>()
|
||||
for (const row of classDays) {
|
||||
const current = peak.get(row.className!)
|
||||
const later = current && row.users === current.users && row.day > current.day
|
||||
if (!current || row.users > current.users || later) {
|
||||
peak.set(row.className!, { day: row.day, users: row.users })
|
||||
}
|
||||
}
|
||||
const lessonClasses = [...byClass]
|
||||
.filter(([className]) => (peak.get(className)?.users ?? 0) >= CLASS_MIN_TRIED)
|
||||
.map(([className, rows]) => ({
|
||||
className,
|
||||
tried: rows.length,
|
||||
solved: rows.filter((row) => row.solved).length,
|
||||
day: peak.get(className)!.day,
|
||||
}))
|
||||
.sort((a, b) => b.day.localeCompare(a.day))
|
||||
classes = lessonClasses
|
||||
// 地址里指定的班不在列表里(零星几个人做过)也照样给明细
|
||||
const wanted = c.req.query("className")?.trim()
|
||||
const selected = wanted && byClass.has(wanted) ? wanted : lessonClasses[0]?.className
|
||||
if (selected) {
|
||||
classDetail = await buildClassDetail(
|
||||
selected,
|
||||
peak.get(selected)!.day,
|
||||
byClass.get(selected)!,
|
||||
failureRows,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return success(c, {
|
||||
locked: false,
|
||||
tried: users.length,
|
||||
solved: solvedUsers.length,
|
||||
tries,
|
||||
failures: sortFailures(siteFailures),
|
||||
wrongAnswerFirstCase: firstCase[0]?.n ?? 0,
|
||||
me: user ? { attempts: mine?.attempts ?? 0, solved: mine?.solved ?? false } : null,
|
||||
myClass: myClassName
|
||||
? {
|
||||
className: myClassName,
|
||||
tried: classmates.length,
|
||||
solved: classmates.filter((row) => row.solved).length,
|
||||
}
|
||||
: null,
|
||||
classes,
|
||||
classDetail,
|
||||
} satisfies ProblemStats)
|
||||
})
|
||||
|
||||
async function buildClassDetail(
|
||||
className: string,
|
||||
day: string,
|
||||
rows: UserRow[],
|
||||
failureRows: { class_name: string | null; result: JudgeStatusValue; n: number }[],
|
||||
): Promise<ProblemClassDetail> {
|
||||
// 花名册只数学生:老师、助教也可能挂着班级
|
||||
const roster = await db
|
||||
.select({ id: schema.user.id, username: schema.user.username })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.className, className),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
const triedIds = new Set(rows.map((row) => row.user_id))
|
||||
const name = (username: string | null) => stripClassPrefix(username ?? "", className)
|
||||
return {
|
||||
className,
|
||||
roster: roster.length,
|
||||
day,
|
||||
failures: failureRows
|
||||
.filter((row) => row.class_name === className)
|
||||
.map((row) => ({ result: row.result, count: row.n }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
unsolved: rows
|
||||
.filter((row) => !row.solved)
|
||||
.sort((a, b) => b.attempts - a.attempts)
|
||||
.map((row) => ({
|
||||
realName: name(row.username),
|
||||
attempts: row.attempts,
|
||||
lastResult: row.last_result,
|
||||
})),
|
||||
solved: rows
|
||||
.filter((row) => row.solved)
|
||||
.sort((a, b) => a.last_time.localeCompare(b.last_time))
|
||||
.map((row) => name(row.username)),
|
||||
untouched: roster.filter((row) => !triedIds.has(row.id)).map((row) => name(row.username)),
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
ProblemAuthor,
|
||||
ProblemDetail,
|
||||
ProblemList,
|
||||
ProblemListItem,
|
||||
Tag,
|
||||
YearlyAc,
|
||||
} from "@oj2/contract"
|
||||
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag } from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
@@ -13,7 +6,6 @@ import {
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
@@ -29,7 +21,6 @@ import { db, schema } from "../db"
|
||||
import { astRequirements } from "../judge/ast"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { dayStart, localTime, shiftMonthsByCalendar } from "../time"
|
||||
import {
|
||||
asFilterValue,
|
||||
countFailedSubmissions,
|
||||
@@ -245,47 +236,6 @@ problemRoutes.get("/problem-authors", async (c) => {
|
||||
return success(c, rows satisfies ProblemAuthor[])
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
if (!user) return success(c, "0")
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [mine] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
eq(schema.submission.problemId, id),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
),
|
||||
)
|
||||
if (!mine?.value) return success(c, "0")
|
||||
// 「近两年」按东八区日历算到当天零点
|
||||
const since = dayStart(shiftMonthsByCalendar(new Date(), -24))
|
||||
const [active, accepted] = await Promise.all([
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.user)
|
||||
.where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since))),
|
||||
db
|
||||
.select({ value: countDistinct(schema.submission.userId) })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.problemId, id),
|
||||
inArray(schema.submission.result, [0, 10]),
|
||||
gte(schema.submission.createTime, since),
|
||||
),
|
||||
),
|
||||
])
|
||||
const total = active[0]?.value ?? 0
|
||||
const solved = accepted[0]?.value ?? 0
|
||||
return success(
|
||||
c,
|
||||
total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0",
|
||||
)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
||||
const [target] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
@@ -360,48 +310,6 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
||||
)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
|
||||
const rows = await db
|
||||
.select({
|
||||
year,
|
||||
total: count(),
|
||||
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.problemId, problem.id),
|
||||
isNull(schema.submission.contestId),
|
||||
notInArray(schema.submission.result, [6, 7]),
|
||||
),
|
||||
)
|
||||
.groupBy(year)
|
||||
.orderBy(year)
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
...row,
|
||||
acRate: row.total > 0 ? Math.round((row.accepted / row.total) * 10_000) / 100 : 0,
|
||||
}) satisfies YearlyAc,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
|
||||
Reference in new issue
Block a user