feat(提交列表): 今日统计重做——老师看今天哪几个班上了课、几点到几点、错得最多的题;学生版从简
- 老师:各班上课时段画在一条 7–20 点的时间轴上(点班级名去数据统计),有班正在上课时引去课堂看板 - 错得最多的题带班级、没过的原因、做对 x/y 人;底部一根全站结果条,语言只有一种时缩成一句 - 学生:你今天交了几次做对几道 + 今天大家都在做(按人数,带去做 / 接着做) - 标题栏写「截至几点」、判题中几条;打开时把按钮上的数一起更新(原来只在进页面时拉一次) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99319fdcc5
commit
2fddf4a011
5 files changed
+983
-273
No files matched your search
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
type ProblemLanguage,
|
||||
type SubmissionStatistics,
|
||||
type SubmissionStatisticsGrid,
|
||||
type TodaySubmissionStatistics,
|
||||
@@ -17,111 +18,226 @@ import { Hono } from "hono"
|
||||
import { optionalAuth, requireTeacher } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status"
|
||||
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
|
||||
import { type ContestEnv } from "../services/contest"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { localTime, dayStart } from "../time"
|
||||
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers"
|
||||
import { dayStart } from "../time"
|
||||
import {
|
||||
isAdminRole,
|
||||
isTeacherOrAbove,
|
||||
parseDisplayIds,
|
||||
rounded,
|
||||
scopedUsers,
|
||||
stripClassPrefix,
|
||||
} from "./helpers"
|
||||
|
||||
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||
|
||||
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
const ACCEPTED_RESULTS: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
|
||||
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
|
||||
function judgedRate(accepted: number, judged: number) {
|
||||
return judged > 0 ? rounded((accepted / judged) * 100) : 0
|
||||
}
|
||||
|
||||
/** 同班这么多人做了同一道题,才算这个班今天上了课(和首页「班里在做」同一个门槛) */
|
||||
const TODAY_LESSON_MIN_USERS = 5
|
||||
/** 最后一条提交在这么多分钟内,算「正在上课」 */
|
||||
const TODAY_LIVE_MINUTES = 15
|
||||
|
||||
type TodayRow = {
|
||||
class_name: string | null
|
||||
problem_id: number
|
||||
display_id: string
|
||||
title: string
|
||||
visible: boolean
|
||||
problemset_id: number | null
|
||||
user_id: number
|
||||
result: JudgeStatusValue
|
||||
language: ProblemLanguage
|
||||
create_time: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 「今日提交数」标签点开的统计。**公开、只出聚合数**(没有用户名、没有代码,
|
||||
* 热门题只算公开可见的题),口径和那颗标签一致:东八区今天 + 非比赛提交。
|
||||
* 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师看:今天哪几个班上了课、几点到几点、
|
||||
* 错得最多的是哪几道;学生版从简。口径和那颗标签一致:东八区今天 + 非比赛提交。
|
||||
*
|
||||
* 按钟点切用 `localTime()`,不能写 `extract(hour from create_time)` ——
|
||||
* 后者按数据库会话时区算,容器是 UTC,整张分布图会整体左移 8 小时。
|
||||
* 今天的提交一天也就几百到一千来条,整批拉回来在进程里分组,比写五六条各自 group by 的
|
||||
* SQL 好读,也只扫一遍 `create_time` 索引。
|
||||
*/
|
||||
submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
/**
|
||||
* 「提交列表对学生全开」关掉时(考试那种场合)不给热门题这张表 —— 总数、正确率
|
||||
* 「提交列表对学生全开」关掉时(考试那种场合)不给题目这张表 —— 总数、正确率
|
||||
* 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近
|
||||
* 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。
|
||||
*/
|
||||
const showProblems =
|
||||
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(c.get("user"))
|
||||
const where = and(
|
||||
isNull(schema.submission.contestId),
|
||||
sql`${schema.submission.createTime} >= ${dayStart()}`,
|
||||
)
|
||||
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
|
||||
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
|
||||
|
||||
const [[totals], hourRows, languageRows, resultRows, problemRows] = await Promise.all([
|
||||
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(user)
|
||||
const teacher = isTeacherOrAbove(user)
|
||||
const since = dayStart()
|
||||
const [rows, [flow]] = await Promise.all([
|
||||
db.execute<TodayRow>(sql`
|
||||
select u.class_name, s.problem_id, p._id as display_id, p.title, p.visible,
|
||||
s.problemset_id, s.user_id, s.result, s.language, s.create_time
|
||||
from ${schema.submission} s
|
||||
join ${schema.problem} p on p.id = s.problem_id
|
||||
left join ${schema.user} u on u.id = s.user_id
|
||||
where s.contest_id is null and s.create_time >= ${since}
|
||||
`),
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
accepted: acceptedFilter.mapWith(Number),
|
||||
judging: judgingFilter.mapWith(Number),
|
||||
userCount: sql<number>`count(distinct ${schema.submission.userId})`.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(where),
|
||||
db.select({ hour, value: count() }).from(schema.submission).where(where).groupBy(hour),
|
||||
db
|
||||
.select({ language: schema.submission.language, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.groupBy(schema.submission.language)
|
||||
.orderBy(desc(count())),
|
||||
db
|
||||
.select({ result: schema.submission.result, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.groupBy(schema.submission.result)
|
||||
.orderBy(desc(count())),
|
||||
showProblems
|
||||
? db
|
||||
.select({
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
value: count(),
|
||||
accepted: acceptedFilter.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
|
||||
.where(and(where, eq(schema.problem.visible, true)))
|
||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
|
||||
.orderBy(desc(count()))
|
||||
.limit(10)
|
||||
: [],
|
||||
.select({ value: count() })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(sql`${schema.flowchartSubmission.createTime} >= ${since}`),
|
||||
])
|
||||
|
||||
const total = totals?.total ?? 0
|
||||
const judging = totals?.judging ?? 0
|
||||
const hours = Array.from({ length: 24 }, () => 0)
|
||||
for (const row of hourRows) hours[row.hour] = row.value
|
||||
const isAccepted = (row: TodayRow) => ACCEPTED_RESULTS.includes(row.result)
|
||||
const isJudging = (row: TodayRow) => UNJUDGED_RESULTS.includes(row.result)
|
||||
const rate = (list: TodayRow[]) => {
|
||||
const judging = list.filter(isJudging).length
|
||||
return judgedRate(list.filter(isAccepted).length, list.length - judging)
|
||||
}
|
||||
const distinctUsers = (list: TodayRow[]) => new Set(list.map((row) => row.user_id)).size
|
||||
const acceptedUsers = (list: TodayRow[]) =>
|
||||
new Set(list.filter(isAccepted).map((row) => row.user_id)).size
|
||||
const groupBy = <K>(list: TodayRow[], key: (row: TodayRow) => K) => {
|
||||
const map = new Map<K, TodayRow[]>()
|
||||
for (const row of list) map.set(key(row), [...(map.get(key(row)) ?? []), row])
|
||||
return map
|
||||
}
|
||||
const countBy = <K>(list: TodayRow[], key: (row: TodayRow) => K) =>
|
||||
[...groupBy(list, key)]
|
||||
.map(([value, group]) => ({ value, count: group.length }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
const judging = rows.filter(isJudging).length
|
||||
const byProblem = groupBy(rows, (row) => row.problem_id)
|
||||
const mineRows = user ? rows.filter((row) => row.user_id === user.id) : []
|
||||
|
||||
// ---------- 学生:大家都在做 ----------
|
||||
const problems = showProblems
|
||||
? [...byProblem.values()]
|
||||
.filter((list) => list[0]!.visible)
|
||||
.map((list) => {
|
||||
const mine = user ? list.filter((row) => row.user_id === user.id) : []
|
||||
return {
|
||||
problemDisplayId: list[0]!.display_id,
|
||||
problemTitle: list[0]!.title,
|
||||
userCount: distinctUsers(list),
|
||||
acceptedUsers: acceptedUsers(list),
|
||||
mine: !user
|
||||
? null
|
||||
: mine.some(isAccepted)
|
||||
? ("accepted" as const)
|
||||
: mine.length
|
||||
? ("tried" as const)
|
||||
: ("none" as const),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.userCount - a.userCount)
|
||||
.slice(0, 8)
|
||||
: []
|
||||
|
||||
// ---------- 老师:哪几个班上了课、错得最多的题 ----------
|
||||
let classes: TodaySubmissionStatistics["classes"] = null
|
||||
let scattered: TodaySubmissionStatistics["scattered"] = null
|
||||
let hardProblems: TodaySubmissionStatistics["hardProblems"] = null
|
||||
if (teacher) {
|
||||
const byClass = groupBy(rows, (row) => row.class_name)
|
||||
const lessonClasses: { className: string; list: TodayRow[]; problemCount: number }[] = []
|
||||
scattered = []
|
||||
for (const [className, list] of byClass) {
|
||||
const problemCount = className
|
||||
? [...groupBy(list, (row) => row.problem_id).values()].filter(
|
||||
(group) => distinctUsers(group) >= TODAY_LESSON_MIN_USERS,
|
||||
).length
|
||||
: 0
|
||||
if (className && problemCount) lessonClasses.push({ className, list, problemCount })
|
||||
else scattered.push({ className, userCount: distinctUsers(list), total: list.length })
|
||||
}
|
||||
scattered.sort((a, b) =>
|
||||
a.className === null ? 1 : b.className === null ? -1 : b.total - a.total,
|
||||
)
|
||||
const sizes = lessonClasses.length
|
||||
? await db
|
||||
.select({ className: schema.user.className, value: count() })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
schema.user.className,
|
||||
lessonClasses.map((item) => item.className),
|
||||
),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.user.className)
|
||||
: []
|
||||
const liveSince = new Date(Date.now() - TODAY_LIVE_MINUTES * 60_000).toISOString()
|
||||
classes = lessonClasses
|
||||
.map(({ className, list, problemCount }) => {
|
||||
const times = list.map((row) => row.create_time).sort()
|
||||
return {
|
||||
className,
|
||||
start: times[0]!,
|
||||
end: times.at(-1)!,
|
||||
userCount: distinctUsers(list),
|
||||
classSize: sizes.find((row) => row.className === className)?.value ?? 0,
|
||||
problemCount,
|
||||
fromProblemSet: list.filter((row) => row.problemset_id !== null).length * 2 > list.length,
|
||||
total: list.length,
|
||||
correctRate: rate(list),
|
||||
live: times.at(-1)! >= liveSince,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.start.localeCompare(b.start))
|
||||
const failedOf = (list: TodayRow[]) => list.filter((row) => !isAccepted(row) && !isJudging(row))
|
||||
hardProblems = [...byProblem.values()]
|
||||
.filter((list) => failedOf(list).length > 0)
|
||||
.sort((a, b) => failedOf(b).length - failedOf(a).length)
|
||||
.slice(0, 6)
|
||||
.map((list) => ({
|
||||
problemDisplayId: list[0]!.display_id,
|
||||
problemTitle: list[0]!.title,
|
||||
className: countBy(list, (row) => row.class_name)[0]?.value ?? null,
|
||||
total: list.length,
|
||||
accepted: list.filter(isAccepted).length,
|
||||
failures: countBy(failedOf(list), (row) => row.result).map(({ value, count }) => ({
|
||||
result: value,
|
||||
count,
|
||||
})),
|
||||
userCount: distinctUsers(list),
|
||||
acceptedUsers: acceptedUsers(list),
|
||||
}))
|
||||
}
|
||||
|
||||
return success(c, {
|
||||
total,
|
||||
accepted: totals?.accepted ?? 0,
|
||||
asOf: new Date().toISOString(),
|
||||
total: rows.length,
|
||||
accepted: rows.filter(isAccepted).length,
|
||||
judging,
|
||||
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
|
||||
userCount: totals?.userCount ?? 0,
|
||||
hours,
|
||||
languages: languageRows.map((row) => ({
|
||||
language: row.language,
|
||||
count: row.value,
|
||||
correctRate: rate(rows),
|
||||
userCount: distinctUsers(rows),
|
||||
flowchartCount: flow?.value ?? 0,
|
||||
languages: countBy(rows, (row) => row.language).map(({ value, count }) => ({
|
||||
language: value,
|
||||
count,
|
||||
})),
|
||||
results: resultRows.map((row) => ({
|
||||
result: row.result,
|
||||
count: row.value,
|
||||
})),
|
||||
problems: problemRows.map((row) => ({
|
||||
problemDisplayId: row.displayId,
|
||||
problemTitle: row.title,
|
||||
count: row.value,
|
||||
acceptedCount: row.accepted,
|
||||
results: countBy(rows, (row) => row.result).map(({ value, count }) => ({
|
||||
result: value,
|
||||
count,
|
||||
})),
|
||||
me: user
|
||||
? {
|
||||
total: mineRows.length,
|
||||
solved: new Set(mineRows.filter(isAccepted).map((row) => row.problem_id)).size,
|
||||
}
|
||||
: null,
|
||||
problems,
|
||||
classes,
|
||||
scattered,
|
||||
hardProblems,
|
||||
} satisfies TodaySubmissionStatistics)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user