fix(题目页): 统计页签的老师版回到讲这道题本身,各班只列一张表,不选班、不列人名
Deploy / deploy (push) Canceled after 0s

按人看谁没做对是提交页「数据统计」的活;表里点一个班就去那边看。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-30 00:50:01 +08:00
1 parent 2fddf4a011
commit db493cebe4
5 files changed
+151 -324

No files matched your search

+42 -77
View File
@@ -1,5 +1,5 @@
import { type ProblemClassDetail, type ProblemStats } from "@oj2/contract"
import { and, eq, isNotNull, sql } from "drizzle-orm"
import { type ProblemStats } from "@oj2/contract"
import { and, count, eq, inArray, isNotNull, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
@@ -9,7 +9,7 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s
import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest"
import { accepted } from "../services/learning-stats"
import { localTime } from "../time"
import { isAdminRole, isTeacherOrAbove, queryInteger, stripClassPrefix } from "./helpers"
import { isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
export const problemStatsRoutes = new Hono<AppEnv>()
@@ -29,7 +29,6 @@ const EMPTY: ProblemStats = {
me: null,
myClass: null,
classes: null,
classDetail: null,
}
// type 而不是 interface:db.execute 的泛型约束是 Record<string, unknown>,interface 不带索引签名
@@ -41,14 +40,13 @@ type UserRow = {
/** 做对之前(含做对那次)交了几次;没做对就是全部次数 */
tries: number
solved: boolean
last_result: JudgeStatusValue
last_time: string
}
/**
* 题目页「统计」页签。id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,
* 拿 displayId 查会撞上同号的公开题(原来的「历年 AC 率」就是这么查错的)。
* `className` 只有老师用:选中哪个班看明细,不填就是最近做过这题的那个班。
* 题目页「统计」页签:讲的是**这道题**(难不难、几次做对、常错在哪),老师多一张各班的表。
* 看谁没做对是提交页「数据统计」的活,这里只到班级这一层。
* id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,拿 displayId 查会撞上
* 同号的公开题(原来的「历年 AC 率」就是这么查错的)。
*/
problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
const user = c.get("user")
@@ -93,9 +91,7 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
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
(min(s.first_ac) is not null) as solved
from (
select user_id, result, create_time,
min(create_time) filter (where result in (${acceptedList}))
@@ -157,7 +153,6 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
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) {
@@ -172,27 +167,41 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
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))
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
// 地址里指定的班不在列表里(零星几个人做过)也照样给明细
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,
)
}
.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, {
@@ -211,49 +220,5 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
}
: 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)),
}
}
+3 -8
View File
@@ -115,14 +115,9 @@ export async function getProblem(problemID: string, contestID: string) {
return problem
}
/**
* 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查。
* className 只有老师用:看哪个班的明细,不填就是最近做过这题的班
*/
export function getProblemStats(problemID: number, className?: string) {
return api.get<ProblemStats>(`problems/${problemID}/stats`, {
params: className ? { className } : {},
})
/** 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查 */
export function getProblemStats(problemID: number) {
return api.get<ProblemStats>(`problems/${problemID}/stats`)
}
export async function getSubmission(id: string): Promise<Submission> {
@@ -13,11 +13,10 @@ import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext"
/**
* 「统计」页签(设计稿「题目页统计重设计」:学生 A、老师 B)。
*
* 学生看的是「这题难不难 → 大家几次做对 → 常错在哪 → 你们班」,全部按人算;自己卡住了
* 再多一张卡。老师看的是一个班:谁做对了、谁交了没对(卡在哪)、谁还没交 —— 原来工具栏上
* 的「课堂统计」并进了这里。原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
* 「统计」页签(设计稿「题目页统计重设计」学生 A)。讲的是**这道题**:难不难 → 大家几次
* 做对 → 常错在哪,全部按人算。学生多一行「你们班」、自己卡住了再多一张卡;老师多一张
* 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「数据统计」的活)。
* 原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
*/
const emit = defineEmits<{ openSubmissions: [] }>()
@@ -31,15 +30,12 @@ const tone = useTone()
const stats = ref<ProblemStats | null>(null)
const failed = ref(false)
const className = ref<string | null>(null)
async function load() {
if (!problem.value) return
failed.value = false
try {
const res = await getProblemStats(problem.value.id, className.value ?? undefined)
stats.value = res
if (res.classDetail) className.value = res.classDetail.className
stats.value = await getProblemStats(problem.value.id)
} catch {
failed.value = true
}
@@ -47,13 +43,6 @@ async function load() {
onMounted(load)
function changeClass(value: string) {
className.value = value
load()
}
const teacherView = computed(() => stats.value?.classes != null)
function resultName(result: number) {
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
}
@@ -139,53 +128,13 @@ const stuck = computed(() => {
return me && !me.solved && me.attempts >= 3 ? me.attempts : 0
})
// ---------- 老师 ----------
// ---------- 老师:各班的表 ----------
const detail = computed(() => stats.value?.classDetail ?? null)
const classOptions = computed(() =>
(stats.value?.classes ?? []).map((item) => ({
label: `${classLabel(item.className)} · ${item.solved}/${item.tried}`,
value: item.className,
})),
)
const classSegments = computed(() => {
const d = detail.value
if (!d) return []
return [
{ key: "solved", label: "做对", value: d.solved.length, color: theme.value.successColor },
{ key: "unsolved", label: "交了没对", value: d.unsolved.length, color: theme.value.errorColor },
{ key: "untouched", label: "没交", value: d.untouched.length, color: theme.value.dividerColor },
]
})
const classFailedTotal = computed(() =>
(detail.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0),
)
/** 这个班错得最多的和全站不一样时点出来:老师该看的就是这种差别 */
const siteDiffers = computed(() => {
const site = stats.value?.failures[0]
const mine = detail.value?.failures[0]
return site && mine && site.result !== mine.result ? resultName(site.result) : ""
})
const showSolved = ref(false)
const showUntouched = ref(false)
function openStatistics() {
/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */
function openStatistics(className?: string) {
const href = router.resolve({
name: "statistics",
query: { problem: problem.value!._id, className: className.value ?? "", period: "all" },
}).href
window.open(href, "_blank")
}
function openClassSubmissions() {
const href = router.resolve({
name: "submissions",
query: { problem: problem.value!._id, className: className.value ?? "" },
query: { problem: problem.value!._id, period: "all", ...(className ? { className } : {}) },
}).href
window.open(href, "_blank")
}
@@ -251,105 +200,7 @@ function segmentWidth(value: number, total: number) {
</router-link>
</div>
<!-- ==================== 老师:看一个班 ==================== -->
<template v-else-if="teacherView">
<div v-if="!detail" class="empty">
<span class="empty-icon"><Icon icon="ph:users" :width="18" /></span>
<b>还没有班级做过这道题</b>
<n-text depth="3">布置到这节课之后,这里会按班显示谁做对了、谁还没交。</n-text>
<n-button type="primary" secondary @click="router.push('/classroom')">
去课堂看板布置 →
</n-button>
</div>
<template v-else>
<div class="class-head">
<n-select
:value="className"
:options="classOptions"
size="small"
class="class-select"
:consistent-menu-width="false"
@update:value="changeClass"
/>
<n-text depth="3" class="small">{{ dayText(detail.day) }}做的</n-text>
<span class="spacer"></span>
<a href="#" class="link small" @click.prevent="openStatistics">
在数据统计里看 <Icon icon="ph:arrow-square-out" :width="13" />
</a>
</div>
<div class="bar">
<span
v-for="seg in classSegments"
:key="seg.key"
:style="{ width: segmentWidth(seg.value, detail.roster), background: seg.color }"
></span>
</div>
<div class="legend">
<span v-for="seg in classSegments" :key="seg.key">
<i :style="{ background: seg.color }"></i>{{ seg.label }} <b>{{ seg.value }}</b>
</span>
</div>
<div v-if="classFailedTotal" class="line">
<b>没通过的 {{ classFailedTotal }} 次:</b>
<n-text depth="2">
{{
detail.failures
.slice(0, 3)
.map((item) => `${resultName(item.result)} ${item.count}`)
.join(" · ")
}}
</n-text>
<span class="spacer"></span>
<n-text v-if="siteDiffers" depth="3" class="small">全站是{{ siteDiffers }}最多</n-text>
</div>
<template v-if="detail.unsolved.length">
<div class="line">
<b>交了没对({{ detail.unsolved.length }} 人)</b>
<span class="spacer"></span>
<n-text depth="3" class="small">次数多的在前</n-text>
</div>
<div class="people">
<div v-for="(item, index) in detail.unsolved" :key="index" class="person">
<b>{{ item.realName }}</b>
<n-text depth="3">{{ item.attempts }} 次</n-text>
<span :style="{ color: barColor(item.lastResult) }">
{{ resultName(item.lastResult) }}
</span>
</div>
</div>
</template>
<div v-if="detail.solved.length" class="fold">
<div class="fold-head" @click="showSolved = !showSolved">
<b>做对 {{ detail.solved.length }}</b>
<span class="fold-names">
{{ showSolved ? "" : detail.solved.join("、") }}
</span>
<span class="link small">{{ showSolved ? "收起" : "展开" }}</span>
</div>
<div v-if="showSolved" class="fold-body">{{ detail.solved.join("、") }}</div>
</div>
<div v-if="detail.untouched.length" class="fold">
<div class="fold-head" @click="showUntouched = !showUntouched">
<b>没交 {{ detail.untouched.length }}</b>
<span class="fold-names">{{ showUntouched ? "" : "展开看名单" }}</span>
<span class="link small">{{ showUntouched ? "收起" : "展开" }}</span>
</div>
<div v-if="showUntouched" class="fold-body">{{ detail.untouched.join("、") }}</div>
</div>
<div class="foot">
<a href="#" class="link small" @click.prevent="openClassSubmissions">
看这个班这道题的提交 ›
</a>
</div>
</template>
</template>
<!-- ==================== 学生 ==================== -->
<!-- ==================== 这道题(学生、老师都看) ==================== -->
<div v-else-if="!stats.tried" class="empty">
<span class="empty-icon"><Icon icon="ph:sparkle" :width="18" /></span>
<b>还没有人交过这道题</b>
@@ -419,6 +270,66 @@ function segmentWidth(value: number, total: number) {
</div>
</div>
<!-- 老师:各班一行,只到班级这一层;按人看去数据统计 -->
<div v-if="stats.classes" class="section">
<div class="line">
<b>各班做得怎样</b>
<span class="spacer"></span>
<n-text depth="3" class="small">同一天 5 人以上交过才算这个班做过</n-text>
</div>
<table v-if="stats.classes.length" class="classes">
<thead>
<tr>
<th>班级</th>
<th>哪天做的</th>
<th>做对 / 交过</th>
<th>没交</th>
<th>错得最多</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in stats.classes"
:key="item.className"
title="去数据统计看这个班的每个人"
@click="openStatistics(item.className)"
>
<td>
<b>{{ classLabel(item.className) }}</b>
</td>
<td>
<n-text depth="2">{{ dayText(item.day) }}</n-text>
</td>
<td>
<span
:style="{
color: item.solved < item.tried ? tone('error').color : undefined,
fontWeight: 600,
}"
>{{ 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>
<n-text v-else depth="3">—</n-text>
</td>
</tr>
</tbody>
</table>
<n-text v-else depth="3" class="small">还没有哪个班一起做过这道题</n-text>
<div class="foot">
<a href="#" class="link small" @click.prevent="openStatistics()">
按人看谁没做对,去数据统计 <Icon icon="ph:arrow-square-out" :width="13" />
</a>
</div>
</div>
<div v-if="stats.myClass?.tried" class="class-box">
<div class="line">
<b>你们班 · {{ classLabel(stats.myClass.className) }}</b>
@@ -652,65 +563,36 @@ function segmentWidth(value: number, total: number) {
gap: 6px;
}
.class-head {
display: flex;
align-items: center;
gap: 10px;
}
.class-select {
width: 190px;
}
.people {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 6px;
}
.person {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 6px;
border: 1px solid v-bind("theme.dividerColor");
font-size: 13px;
white-space: nowrap;
overflow: hidden;
}
.fold {
border-radius: 6px;
background: v-bind("theme.actionColor");
font-size: 13px;
}
.fold-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
}
.fold-names {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: v-bind("theme.textColor2");
}
.fold-body {
padding: 0 12px 10px;
line-height: 1.8;
color: v-bind("theme.textColor2");
}
.foot {
display: flex;
justify-content: flex-end;
}
.classes {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.classes th {
text-align: left;
font-weight: normal;
color: v-bind("theme.textColor3");
padding: 4px 8px 6px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.classes td {
padding: 8px 8px 8px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
white-space: nowrap;
}
.classes tbody tr {
cursor: pointer;
}
.classes tbody tr:hover {
background: v-bind("theme.hoverColor");
}
</style>
+1 -1
View File
@@ -81,7 +81,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
*/
export type { Tag, TagCategory } from "@oj2/contract"
export type { ProblemStats, ProblemClassDetail } from "@oj2/contract"
export type { ProblemStats } from "@oj2/contract"
export type { TodaySubmissionStatistics } from "@oj2/contract"
export type {
+7 -22
View File
@@ -449,23 +449,6 @@ export const problemAuthorSchema = z.object({
const failureCountSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
/** 老师选中一个班时的明细:谁做对了、谁交了没对(卡在哪)、谁还没交 */
export const problemClassDetailSchema = z.object({
className: z.string(),
/** 班里的学生数(学生角色、没禁用) */
roster: z.number().int(),
/** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天)—— 「2025年12月5日做的」 */
day: z.string(),
failures: z.array(failureCountSchema),
/** 交了没对,次数多的在前。名字是剥掉 ks<班级号> 前缀的那一段 */
unsolved: z.array(
z.object({ realName: z.string(), attempts: z.number().int(), lastResult: judgeStatusSchema }),
),
solved: z.array(z.string()),
/** 班里还没交过的 */
untouched: z.array(z.string()),
})
/**
* 题目页「统计」页签。全部**按人**算,不按提交条数 —— 原来的「通过数」是 AC 条数
* (3092 是 219 条、202 个人),「通过率」是按提交算的,学生看了不知道这题难不难。
@@ -491,20 +474,23 @@ export const problemStatsSchema = z.object({
.nullable(),
/**
* 老师:一起做过这题的班(同一天至少 5 个人交过 —— 零星几个人自己刷到、补做的不算),
* 按那天倒序。原来工具栏上的「课堂统计」并进了这里。学生、比赛里为 null
* 按那天倒序。只到班级这一层:看谁没做对是提交页「数据统计」的活。学生、比赛里为 null
*/
classes: z
.array(
z.object({
className: z.string(),
/** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天) */
day: z.string(),
tried: z.number().int(),
solved: z.number().int(),
day: z.string(),
/** 班里的学生数(学生角色、没禁用),没交的 = classSize - tried */
classSize: z.number().int(),
/** 这个班错得最多的那种,一次没错过为 null */
topFailure: failureCountSchema.nullable(),
}),
)
.nullable(),
/** 老师选中的那个班(默认 classes 第一个)的明细 */
classDetail: problemClassDetailSchema.nullable(),
})
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
@@ -523,4 +509,3 @@ export type SqlDisplay = z.infer<typeof sqlDisplaySchema>
export type SqlDisplayTable = z.infer<typeof sqlDisplayTableSchema>
export type SqlDisplayColumn = z.infer<typeof sqlDisplayColumnSchema>
export type ProblemStats = z.infer<typeof problemStatsSchema>
export type ProblemClassDetail = z.infer<typeof problemClassDetailSchema>