fix(题目页): 统计页签的各班表和数据统计「按班级汇总」对不上,改成同一个口径
原来题目页写 13/26(做对/交过)、数据统计写 13/58(做完/班级人数),列的班也不一样, 老师点过去看到的数「变了」。现在两边都是:只算学生,做完/班级人数、交了没对、没交、 正确率、最近一次,按最近一次排。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db493cebe4
commit
50d8e072ac
3 files changed
+148
-126
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import { type ProblemStats } from "@oj2/contract"
|
import { type ProblemStats } from "@oj2/contract"
|
||||||
import { and, count, eq, inArray, isNotNull, sql } from "drizzle-orm"
|
import { and, count, eq, inArray, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
||||||
@@ -8,17 +8,10 @@ import { failure, success } from "../http"
|
|||||||
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
|
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
|
||||||
import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest"
|
import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest"
|
||||||
import { accepted } from "../services/learning-stats"
|
import { accepted } from "../services/learning-stats"
|
||||||
import { localTime } from "../time"
|
|
||||||
import { isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
|
import { isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
|
||||||
|
|
||||||
export const problemStatsRoutes = new Hono<AppEnv>()
|
export const problemStatsRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
/**
|
|
||||||
* 同一天班里至少这么多人交过,才算「这个班一起做过这题」。只看总人数不行:
|
|
||||||
* 2079 有个班 2 月里零星补做了 6 个人,比 12 月全班 26 人做的那次还「近」,默认就选成了它
|
|
||||||
*/
|
|
||||||
const CLASS_MIN_TRIED = 5
|
|
||||||
|
|
||||||
const EMPTY: ProblemStats = {
|
const EMPTY: ProblemStats = {
|
||||||
locked: false,
|
locked: false,
|
||||||
tried: 0,
|
tried: 0,
|
||||||
@@ -85,8 +78,7 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
|||||||
sql`, `,
|
sql`, `,
|
||||||
)
|
)
|
||||||
// 每人一行:窗口函数先给每条提交标上这个人第一次做对的时刻,再按人数到那一刻为止交了几次
|
// 每人一行:窗口函数先给每条提交标上这个人第一次做对的时刻,再按人数到那一刻为止交了几次
|
||||||
const day = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
|
const [users, failureRows, firstCase] = await Promise.all([
|
||||||
const [users, failureRows, firstCase, classDays] = await Promise.all([
|
|
||||||
db.execute<UserRow>(sql`
|
db.execute<UserRow>(sql`
|
||||||
select s.user_id, u.username, u.class_name,
|
select s.user_id, u.username, u.class_name,
|
||||||
count(*)::int as attempts,
|
count(*)::int as attempts,
|
||||||
@@ -121,17 +113,6 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
|||||||
where e->>'test_case' = '1' and (e->>'result')::int <> 0
|
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 solvedUsers = users.filter((row) => row.solved)
|
||||||
@@ -153,56 +134,7 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
|||||||
const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : []
|
const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : []
|
||||||
|
|
||||||
let classes: ProblemStats["classes"] = null
|
let classes: ProblemStats["classes"] = null
|
||||||
if (!inContest && isTeacherOrAbove(user)) {
|
if (!inContest && isTeacherOrAbove(user)) classes = await classRows(problem.id)
|
||||||
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,
|
|
||||||
)
|
|
||||||
// 花名册只数学生:老师、助教也可能挂着班级
|
|
||||||
const sizes = lessonClasses.length
|
|
||||||
? await db
|
|
||||||
.select({ className: schema.user.className, value: count() })
|
|
||||||
.from(schema.user)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(
|
|
||||||
schema.user.className,
|
|
||||||
lessonClasses.map(([className]) => className),
|
|
||||||
),
|
|
||||||
eq(schema.user.adminType, "Regular User"),
|
|
||||||
eq(schema.user.isDisabled, false),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(schema.user.className)
|
|
||||||
: []
|
|
||||||
classes = lessonClasses
|
|
||||||
.map(([className, rows]) => {
|
|
||||||
const top = failureRows
|
|
||||||
.filter((row) => row.class_name === className)
|
|
||||||
.sort((a, b) => b.n - a.n)[0]
|
|
||||||
return {
|
|
||||||
className,
|
|
||||||
day: peak.get(className)!.day,
|
|
||||||
tried: rows.length,
|
|
||||||
solved: rows.filter((row) => row.solved).length,
|
|
||||||
classSize: sizes.find((row) => row.className === className)?.value ?? 0,
|
|
||||||
topFailure: top ? { result: top.result, count: top.n } : null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.day.localeCompare(a.day))
|
|
||||||
}
|
|
||||||
|
|
||||||
return success(c, {
|
return success(c, {
|
||||||
locked: false,
|
locked: false,
|
||||||
@@ -222,3 +154,69 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
|||||||
classes,
|
classes,
|
||||||
} satisfies ProblemStats)
|
} satisfies ProblemStats)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各班一行,和数据统计「按班级汇总」(statistics/components/ByClass.vue)同一个口径:
|
||||||
|
* 只算学生角色、没禁用的号,班级人数也这么数。那边是前端拿逐条提交现算的(最多 5000 条),
|
||||||
|
* 这里在库里直接聚合,数是一样的。
|
||||||
|
*/
|
||||||
|
async function classRows(problemId: number): Promise<NonNullable<ProblemStats["classes"]>> {
|
||||||
|
const acceptedList = sql.join(
|
||||||
|
accepted.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const unjudgedList = sql.join(
|
||||||
|
UNJUDGED_RESULTS.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const rows = await db.execute<{
|
||||||
|
class_name: string | null
|
||||||
|
tried: number
|
||||||
|
solved: number
|
||||||
|
accepted: number
|
||||||
|
judged: number
|
||||||
|
last_time: string
|
||||||
|
}>(sql`
|
||||||
|
select u.class_name,
|
||||||
|
count(distinct s.user_id)::int as tried,
|
||||||
|
count(distinct s.user_id) filter (where s.result in (${acceptedList}))::int as solved,
|
||||||
|
count(*) filter (where s.result in (${acceptedList}))::int as accepted,
|
||||||
|
count(*) filter (where s.result not in (${unjudgedList}))::int as judged,
|
||||||
|
max(s.create_time) as last_time
|
||||||
|
from ${schema.submission} s
|
||||||
|
join ${schema.user} u on u.id = s.user_id
|
||||||
|
where s.problem_id = ${problemId} and s.contest_id is null
|
||||||
|
and u.admin_type = 'Regular User' and u.is_disabled = false
|
||||||
|
group by u.class_name
|
||||||
|
`)
|
||||||
|
const names = rows.map((row) => row.class_name).filter((name) => name !== null)
|
||||||
|
const sizes = names.length
|
||||||
|
? await db
|
||||||
|
.select({ className: schema.user.className, value: count() })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.user.className, names),
|
||||||
|
eq(schema.user.adminType, "Regular User"),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.user.className)
|
||||||
|
: []
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const classSize =
|
||||||
|
(row.class_name && sizes.find((item) => item.className === row.class_name)?.value) ||
|
||||||
|
row.tried
|
||||||
|
return {
|
||||||
|
className: row.class_name,
|
||||||
|
classSize,
|
||||||
|
solved: row.solved,
|
||||||
|
unsolved: row.tried - row.solved,
|
||||||
|
untouched: Math.max(0, classSize - row.tried),
|
||||||
|
correctRate: row.judged ? Math.round((row.accepted / row.judged) * 100) : null,
|
||||||
|
lastTime: row.last_time,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { getProblemStats } from "oj/api"
|
|||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useTone, type Tone } from "oj/submission/composables/tone"
|
import { useTone, type Tone } from "oj/submission/composables/tone"
|
||||||
import { classLabel } from "oj/submission/utils"
|
import { classLabel, submissionDayText } from "oj/submission/utils"
|
||||||
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
||||||
import { getTagColor, parseTime } from "utils/functions"
|
import { getTagColor, parseTime } from "utils/functions"
|
||||||
import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
|
import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
|
||||||
@@ -130,6 +130,15 @@ const stuck = computed(() => {
|
|||||||
|
|
||||||
// ---------- 老师:各班的表 ----------
|
// ---------- 老师:各班的表 ----------
|
||||||
|
|
||||||
|
/** 班多的老题(1001 这种十几个班都做过)先给 6 个,最近做的在前 */
|
||||||
|
const allClasses = ref(false)
|
||||||
|
const shownClasses = computed(() =>
|
||||||
|
allClasses.value ? (stats.value?.classes ?? []) : (stats.value?.classes ?? []).slice(0, 6),
|
||||||
|
)
|
||||||
|
const hiddenClasses = computed(
|
||||||
|
() => (stats.value?.classes?.length ?? 0) - shownClasses.value.length,
|
||||||
|
)
|
||||||
|
|
||||||
/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */
|
/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */
|
||||||
function openStatistics(className?: string) {
|
function openStatistics(className?: string) {
|
||||||
const href = router.resolve({
|
const href = router.resolve({
|
||||||
@@ -139,12 +148,6 @@ function openStatistics(className?: string) {
|
|||||||
window.open(href, "_blank")
|
window.open(href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 「2025-12-05」→「2025年12月5日」。后端给的已经是东八区的日子,不用再换算 */
|
|
||||||
function dayText(day: string) {
|
|
||||||
const [year, month, date] = day.split("-").map(Number)
|
|
||||||
return `${year}年${month}月${date}日`
|
|
||||||
}
|
|
||||||
|
|
||||||
function segmentWidth(value: number, total: number) {
|
function segmentWidth(value: number, total: number) {
|
||||||
return total ? `${(value / total) * 100}%` : "0"
|
return total ? `${(value / total) * 100}%` : "0"
|
||||||
}
|
}
|
||||||
@@ -270,60 +273,64 @@ function segmentWidth(value: number, total: number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 老师:各班一行,只到班级这一层;按人看去数据统计 -->
|
<!-- 老师:各班一行,和数据统计「按班级汇总」同一份数;按人看去那边 -->
|
||||||
<div v-if="stats.classes" class="section">
|
<div v-if="stats.classes?.length" class="section">
|
||||||
<div class="line">
|
<b>各班做得怎样</b>
|
||||||
<b>各班做得怎样</b>
|
<table class="classes">
|
||||||
<span class="spacer"></span>
|
|
||||||
<n-text depth="3" class="small">同一天 5 人以上交过才算这个班做过</n-text>
|
|
||||||
</div>
|
|
||||||
<table v-if="stats.classes.length" class="classes">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>班级</th>
|
<th>班级</th>
|
||||||
<th>哪天做的</th>
|
<th>做完</th>
|
||||||
<th>做对 / 交过</th>
|
<th>交了没对</th>
|
||||||
<th>没交</th>
|
<th>没交</th>
|
||||||
<th>错得最多</th>
|
<th>正确率</th>
|
||||||
|
<th>最近一次</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="item in stats.classes"
|
v-for="item in shownClasses"
|
||||||
:key="item.className"
|
:key="item.className ?? ''"
|
||||||
title="去数据统计看这个班的每个人"
|
:class="{ pickable: item.className }"
|
||||||
@click="openStatistics(item.className)"
|
:title="item.className ? '去数据统计看这个班的每个人' : undefined"
|
||||||
|
@click="item.className && openStatistics(item.className)"
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<b>{{ classLabel(item.className) }}</b>
|
<b>{{ item.className ? classLabel(item.className) : "没有班级" }}</b>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<n-text depth="2">{{ dayText(item.day) }}</n-text>
|
<span class="done-bar">
|
||||||
</td>
|
<span
|
||||||
<td>
|
:style="{
|
||||||
<span
|
width: segmentWidth(item.solved, item.classSize),
|
||||||
:style="{
|
background: theme.successColor,
|
||||||
color: item.solved < item.tried ? tone('error').color : undefined,
|
}"
|
||||||
fontWeight: 600,
|
></span>
|
||||||
}"
|
|
||||||
>{{ item.solved }}</span
|
|
||||||
>
|
|
||||||
/ {{ item.tried }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<n-text depth="2">{{ Math.max(0, item.classSize - item.tried) }}</n-text>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span v-if="item.topFailure" :style="{ color: barColor(item.topFailure.result) }">
|
|
||||||
{{ resultName(item.topFailure.result) }} {{ item.topFailure.count }}
|
|
||||||
</span>
|
</span>
|
||||||
<n-text v-else depth="3">—</n-text>
|
{{ item.solved }}/{{ item.classSize }}
|
||||||
|
</td>
|
||||||
|
<td :style="{ color: item.unsolved ? tone('warning').color : undefined }">
|
||||||
|
{{ item.unsolved }}
|
||||||
|
</td>
|
||||||
|
<td :style="{ color: item.untouched ? tone('error').color : undefined }">
|
||||||
|
{{ item.untouched }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<n-text depth="2">{{
|
||||||
|
item.correctRate === null ? "—" : `${item.correctRate}%`
|
||||||
|
}}</n-text>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<n-text depth="2">{{ submissionDayText(item.lastTime) }}</n-text>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<n-text v-else depth="3" class="small">还没有哪个班一起做过这道题</n-text>
|
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
|
<a v-if="hiddenClasses" href="#" class="link small" @click.prevent="allClasses = true">
|
||||||
|
还有 {{ hiddenClasses }} 个班 ›
|
||||||
|
</a>
|
||||||
|
<span class="spacer"></span>
|
||||||
<a href="#" class="link small" @click.prevent="openStatistics()">
|
<a href="#" class="link small" @click.prevent="openStatistics()">
|
||||||
按人看谁没做对,去数据统计 <Icon icon="ph:arrow-square-out" :width="13" />
|
按人看谁没做对,去数据统计 <Icon icon="ph:arrow-square-out" :width="13" />
|
||||||
</a>
|
</a>
|
||||||
@@ -575,6 +582,7 @@ function segmentWidth(value: number, total: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.classes th {
|
.classes th {
|
||||||
|
white-space: nowrap;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: v-bind("theme.textColor3");
|
color: v-bind("theme.textColor3");
|
||||||
@@ -588,11 +596,22 @@ function segmentWidth(value: number, total: number) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classes tbody tr {
|
.classes tbody tr.pickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classes tbody tr:hover {
|
.done-bar {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 60px;
|
||||||
|
height: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.classes tbody tr.pickable:hover {
|
||||||
background: v-bind("theme.hoverColor");
|
background: v-bind("theme.hoverColor");
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -473,21 +473,26 @@ export const problemStatsSchema = z.object({
|
|||||||
.object({ className: z.string(), tried: z.number().int(), solved: z.number().int() })
|
.object({ className: z.string(), tried: z.number().int(), solved: z.number().int() })
|
||||||
.nullable(),
|
.nullable(),
|
||||||
/**
|
/**
|
||||||
* 老师:一起做过这题的班(同一天至少 5 个人交过 —— 零星几个人自己刷到、补做的不算),
|
* 老师:各班做这道题的情况,和数据统计「按班级汇总」**同一个口径**(只算学生的提交;
|
||||||
* 按那天倒序。只到班级这一层:看谁没做对是提交页「数据统计」的活。学生、比赛里为 null
|
* 做完 / 班级人数、交了没对、没交、正确率、最近一次),按最近一次倒序。
|
||||||
|
* 两边数字要一模一样 —— 老师从这里点过去,看到的数「变了」就不会再信任何一边。
|
||||||
|
* 学生、比赛里为 null
|
||||||
*/
|
*/
|
||||||
classes: z
|
classes: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
className: z.string(),
|
/** null = 没有班级的号(数据统计那边写「没有班级」) */
|
||||||
/** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天) */
|
className: z.string().nullable(),
|
||||||
day: z.string(),
|
/** 班里的学生数(学生角色、没禁用);没有班级的那一行就是交过的人数 */
|
||||||
tried: z.number().int(),
|
|
||||||
solved: z.number().int(),
|
|
||||||
/** 班里的学生数(学生角色、没禁用),没交的 = classSize - tried */
|
|
||||||
classSize: z.number().int(),
|
classSize: z.number().int(),
|
||||||
/** 这个班错得最多的那种,一次没错过为 null */
|
solved: z.number().int(),
|
||||||
topFailure: failureCountSchema.nullable(),
|
/** 交了但没做对的人 */
|
||||||
|
unsolved: z.number().int(),
|
||||||
|
/** 班里还没交过的人 */
|
||||||
|
untouched: z.number().int(),
|
||||||
|
/** 提交正确率,分母不算还在判的;一条都没判完为 null */
|
||||||
|
correctRate: z.number().nullable(),
|
||||||
|
lastTime: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.nullable(),
|
.nullable(),
|
||||||
|
|||||||
Reference in new issue
Block a user