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:
xuyueandClaude Opus 5.5 committed 2026-09-30 00:39:57 +08:00
1 parent 91d9c9c6d7
commit 99319fdcc5
10 files changed
+1017 -317

No files matched your search

+2
View File
@@ -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)
+259
View File
@@ -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 -93
View File
@@ -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({
+9 -10
View File
@@ -22,7 +22,7 @@ import {
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemStats,
type ProblemList,
type CreateFlowchartResponse,
type FlowchartScores,
@@ -115,9 +115,14 @@ export async function getProblem(problemID: string, contestID: string) {
return problem
}
// 未登录返回 "0",登录后返回百分比字符串
export function getProblemBeatRate(problemID: number) {
return api.get<string>(`problems/${problemID}/beat-count`)
/**
* 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查。
* className 只有老师用:看哪个班的明细,不填就是最近做过这题的班
*/
export function getProblemStats(problemID: number, className?: string) {
return api.get<ProblemStats>(`problems/${problemID}/stats`, {
params: className ? { className } : {},
})
}
export async function getSubmission(id: string): Promise<Submission> {
@@ -405,12 +410,6 @@ export function getSimilarProblems(problemId: string) {
.then((response) => response.map(toProblemRow))
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
export function getProblemYearlyAC(problemId: string) {
return api.get<YearlyAc[]>(`problems/${encodeURIComponent(problemId)}/yearly-ac`)
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
@@ -142,7 +142,7 @@ function goAllSubmissions() {
</button>
</div>
</template>
<ProblemInfo v-if="lastOpened === 'info'" />
<ProblemInfo v-if="lastOpened === 'info'" @open-submissions="open = 'submission'" />
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
<template v-else>
<ProblemSubmission />
+679 -127
View File
@@ -1,164 +1,716 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import { getProblemStats } from "oj/api"
import { useContestStore } from "oj/store/contest"
import { useProblemStore } from "oj/store/problem"
import { useTone, type Tone } from "oj/submission/composables/tone"
import { classLabel } from "oj/submission/utils"
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
import { Pie } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend, Colors } from "chart.js"
import { getProblemBeatRate } from "oj/api"
import { getProblemYearlyAC, type YearlyACData } from "oj/api"
import ProblemYearlyChart from "./ProblemYearlyChart.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { getTagColor, parseTime } from "utils/functions"
import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext"
// 仅注册饼图所需的 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
/**
* 「统计」页签(设计稿「题目页统计重设计」:学生 A、老师 B)。
*
* 学生看的是「这题难不难 → 大家几次做对 → 常错在哪 → 你们班」,全部按人算;自己卡住了
* 再多一张卡。老师看的是一个班:谁做对了、谁交了没对(卡在哪)、谁还没交 —— 原来工具栏上
* 的「课堂统计」并进了这里。原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
*/
const emit = defineEmits<{ openSubmissions: [] }>()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { problem } = storeToRefs(useProblemStore())
const contestStore = useContestStore()
const ctx = useProblemPageContext()
const route = useRoute()
const router = useRouter()
const theme = useThemeVars()
const tone = useTone()
const { isDesktop } = useBreakpoints()
const stats = ref<ProblemStats | null>(null)
const failed = ref(false)
const className = ref<string | null>(null)
const beatRate = ref("0")
const yearlyACData = ref<YearlyACData[]>([])
const data = computed(() => {
// statisticInfo 是 { 判题状态码: 次数 } 的 JSONB,契约里是 Record<string, unknown>
const status = problem.value!.statisticInfo as Record<string, number>
const labels: string[] = []
const values: number[] = []
for (const code in status) {
if (status[code] !== 0) {
labels.push(JUDGE_STATUS[Number(code) as SUBMISSION_RESULT].name)
values.push(status[code]!)
}
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
} catch {
failed.value = true
}
return {
labels,
datasets: [{ data: values, hoverOffset: 5, borderRadius: 10 }],
}
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 ?? "其他"
}
function resultTone(result: number): Tone {
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.type ?? "default"
}
/** 条的颜色:运行时错误橙、答案错误红、编译失败灰 —— 和提交列表的胶囊一个配色 */
function barColor(result: number) {
const kind = resultTone(result)
// 编译失败用灰:它和运行时错误都是 warning,都画成橙色就分不开了
if (result === -2 || kind === "default" || kind === "info") return theme.value.textColor3
return tone(kind).color
}
// ---------- 学生 ----------
const verdict = computed(() => {
const s = stats.value
if (!s || !s.tried) return null
const rate = s.solved / s.tried
const firstShare = s.solved ? s.tries.one / s.solved : 0
const manyShare = s.solved ? s.tries.many / s.solved : 0
const detail = `${s.tried} 人做过,${s.solved} 人做对了`
if (rate >= 0.9 && firstShare >= 0.6) {
return { kind: "success" as const, text: "这题不难:大多数人第一次交就对了", detail }
}
if (problem.value?.difficulty === "Low" && (rate < 0.9 || manyShare >= 0.15)) {
return { kind: "warning" as const, text: "标的是「简单」,但不少人卡住了", detail }
}
if (rate >= 0.75) {
return { kind: "info" as const, text: "多数人做对了,不少人要多试几次", detail }
}
return { kind: "warning" as const, text: "这题有点难:不少人还没做对", detail }
})
const numbers = computed(() => {
const triesSegments = computed(() => {
const s = stats.value
if (!s) return []
const green = theme.value.successColor
return [
{ key: "one", label: "1 次", value: s.tries.one, color: green, opacity: 1 },
{ key: "few", label: "2–3 次", value: s.tries.few, color: green, opacity: 0.6 },
{ key: "many", label: "4 次以上", value: s.tries.many, color: green, opacity: 0.3 },
{
icon: "streamline-ultimate-color:checklist",
title: problem.value?.submissionNumber ?? 0,
content: "总提交",
int: true,
suffix: "",
},
{
icon: "streamline-emojis:woman-raising-hand-2",
title: problem.value?.acceptedNumber ?? 0,
content: "通过数",
int: true,
suffix: "",
},
{
icon: "fluent-emoji:chart-increasing",
title: getACRateNumber(
problem.value?.acceptedNumber ?? 0,
problem.value?.submissionNumber ?? 0,
),
content: "通过率",
int: false,
suffix: "%",
},
{
icon: "streamline-emojis:sparkles",
title: parseFloat(beatRate.value),
content: "击败用户",
int: false,
suffix: "%",
key: "none",
label: "没做对",
value: s.tried - s.solved,
color: theme.value.dividerColor,
opacity: 1,
},
]
})
const options = {
plugins: {
title: { text: "提交结果的比例", display: true, font: { size: 20 } },
},
}
const failedTotal = computed(() =>
(stats.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0),
)
const topFailures = computed(() => (stats.value?.failures ?? []).slice(0, 3))
async function getBeatRate() {
const res = await getProblemBeatRate(problem.value!.id)
beatRate.value = res
}
async function getYearlyAC() {
const res = await getProblemYearlyAC(problem.value!._id)
yearlyACData.value = res
}
onMounted(() => {
getBeatRate()
getYearlyAC()
/** 错得最多的那一种,给一句该怎么办。都指向「运行例子」:提交前自己就能看出来 */
const tip = computed(() => {
const s = stats.value
const top = s?.failures[0]
if (!s || !top) return ""
if (top.result === -1) {
return s.wrongAnswerFirstCase >= top.count * 0.6
? "答案错误几乎都卡在第 1 个测试点:多半是例子都没对上。先点「运行例子」,把你的输出和例子一行一行对一对。"
: "答案错误最多:输出和标准答案对不上。留意空格、换行和标点,交之前先点「运行例子」对一对。"
}
if (top.result === 4) {
return "运行时错误最多:程序跑到一半出错了。交之前先点「运行例子」,看看有没有红色的报错。"
}
if (top.result === -2) {
return "编译失败最多:代码有语法错误,程序根本没跑起来。交之前先点「运行例子」检查一下。"
}
return ""
})
/** 自己交了好几次还没对:比赛里没有举手,也不提「看别人」 */
const stuck = computed(() => {
const me = stats.value?.me
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() {
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 ?? "" },
}).href
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) {
return total ? `${(value / total) * 100}%` : "0"
}
</script>
<template>
<n-descriptions bordered label-placement="left" :column="isDesktop ? 3 : 1" v-if="problem">
<n-descriptions-item label="编号">
{{ problem._id }}
</n-descriptions-item>
<n-descriptions-item label="出题人">
{{ problem.createdBy.username }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problem.createTime) }}
</n-descriptions-item>
<!-- 比赛进行中,难度不下发(difficulty 为 null),这一项整个不显示 -->
<n-descriptions-item v-if="problem.difficulty" label="难度">
<n-tag :type="getTagColor(problem.difficulty)">
<div v-if="problem" class="info">
<!-- 原来占一大块的描述表收成一行 -->
<div class="meta">
<b class="meta-title">{{ problem._id }} {{ problem.title }}</b>
<!-- 比赛进行中难度不下发(null),整个不显示 -->
<n-tag
v-if="problem.difficulty"
size="small"
round
:bordered="false"
:type="getTagColor(problem.difficulty)"
>
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item :span="2" label="标签">
<n-flex>
<n-tag type="info" v-for="tag in problem.tags" :key="tag">
{{ tag }}
</n-tag>
</n-flex>
</n-descriptions-item>
</n-descriptions>
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="10" :y-gap="10" class="cards">
<n-gi v-for="item in numbers" :key="item.content">
<n-card hoverable>
<n-flex vertical align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
<n-h2 class="number">
<n-number-animation :to="item.title" :precision="item.int ? 0 : 2" />
<span v-if="item.suffix">{{ item.suffix }}</span>
</n-h2>
<n-h4 class="number-label">{{ item.content }}</n-h4>
</n-flex>
</n-card>
</n-gi>
</n-grid>
<div class="pie" v-if="problem && problem.submissionNumber > 0">
<Pie :data="data" :options="options" />
<n-tag
v-for="tag in problem.tags"
:key="tag"
size="small"
round
:bordered="false"
type="info"
>
{{ tag }}
</n-tag>
<span class="spacer"></span>
<n-text depth="3" class="meta-author">{{ problem.createdBy.username }} 出题</n-text>
</div>
<n-text v-if="failed" type="error">统计没拉下来,关掉再打开试试</n-text>
<div v-else-if="!stats" class="center"><n-spin size="small" /></div>
<!-- 比赛进行中:别人的情况等比赛结束再公布 -->
<div v-else-if="stats.locked" class="empty">
<span class="empty-icon"><Icon icon="ph:lock-simple" :width="18" /></span>
<b>比赛结束后才能看到大家的情况</b>
<n-text depth="3">
<template v-if="contestStore.contest">
比赛 {{ parseTime(contestStore.contest.endTime, "HH:mm") }} 结束。
</template>
现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。
</n-text>
<router-link
:to="{ name: 'contest rank', params: { contestID: route.params.contestID } }"
class="link"
>
去看比赛排名 ›
</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>
<n-text depth="3">你可以做第一个。</n-text>
</div>
<template v-else>
<div
v-if="verdict"
class="verdict"
:style="{
background: tone(verdict.kind).background,
borderColor: tone(verdict.kind).background,
}"
>
<b :style="{ color: tone(verdict.kind).color }">
<Icon icon="ph:gauge" :width="16" />{{ verdict.text }}
</b>
<n-text depth="2" class="small">{{ verdict.detail }}</n-text>
</div>
<div v-if="stats.solved" class="section">
<div class="line">
<b>做对用了几次</b>
<span class="spacer"></span>
<n-text depth="3" class="small">按人算</n-text>
</div>
<div class="bar">
<span
v-for="seg in triesSegments"
:key="seg.key"
:style="{
width: segmentWidth(seg.value, stats.tried),
background: seg.color,
opacity: seg.opacity,
}"
></span>
</div>
<div class="legend">
<span v-for="seg in triesSegments" :key="seg.key">
<i :style="{ background: seg.color, opacity: seg.opacity }"></i>{{ seg.label }}
<b>{{ seg.value }}</b>
</span>
</div>
</div>
<div v-if="topFailures.length" class="section">
<b>没通过的 {{ failedTotal }} 次,错在哪</b>
<div v-for="item in topFailures" :key="item.result" class="fail">
<span class="fail-name">{{ resultName(item.result) }}</span>
<span class="fail-track">
<span
:style="{
width: segmentWidth(item.count, topFailures[0]!.count),
background: barColor(item.result),
}"
></span>
</span>
<span class="fail-count">{{ item.count }} 次</span>
</div>
<div
v-if="tip"
class="tip"
:style="{ background: tone('warning').background, color: tone('warning').color }"
>
<Icon icon="ph:lightbulb" :width="15" class="tip-icon" />
<span>{{ tip }}</span>
</div>
</div>
<div v-if="stats.myClass?.tried" class="class-box">
<div class="line">
<b>你们班 · {{ classLabel(stats.myClass.className) }}</b>
<span class="spacer"></span>
<span>
<b class="big">{{ stats.myClass.solved }}</b>
<n-text depth="3"> / {{ stats.myClass.tried }} 人做对</n-text>
</span>
</div>
<div class="bar thin">
<span
:style="{
width: segmentWidth(stats.myClass.solved, stats.myClass.tried),
background: theme.successColor,
}"
></span>
</div>
</div>
<div
v-if="stuck && ctx.entry !== 'contest'"
class="stuck"
:style="{ borderColor: tone('warning').background, background: tone('warning').background }"
>
<b>你交了 {{ stuck }} 次,还没对上</b>
<n-text depth="2" class="small">
卡了这么久,可以点右上角「举手求助」问老师;也可以去「我的提交」看看每一次错在哪。
</n-text>
<div>
<n-button size="small" @click="emit('openSubmissions')">看我的提交</n-button>
</div>
</div>
</template>
</div>
<ProblemYearlyChart :data="yearlyACData" />
</template>
<style scoped>
.cards {
margin-top: 24px;
.info {
display: flex;
flex-direction: column;
gap: 14px;
font-size: 14px;
}
.number {
margin: 0;
font-weight: bold;
.meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.number-label {
margin: 0;
.meta-title {
margin-right: 4px;
}
.pie {
width: 100%;
max-width: 500px;
margin: 24px auto;
.meta-author,
.small {
font-size: 13px;
}
.spacer {
flex: 1 1 0;
}
.center {
display: flex;
justify-content: center;
padding: 40px 0;
}
.empty {
padding: 60px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.empty-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor2");
margin-bottom: 4px;
}
.link {
color: v-bind("theme.primaryColor");
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
}
.verdict {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid;
display: flex;
flex-direction: column;
gap: 4px;
}
.verdict b {
font-size: 16px;
display: flex;
align-items: center;
gap: 6px;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.line {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.bar {
height: 12px;
border-radius: 3px;
overflow: hidden;
display: flex;
gap: 2px;
background: v-bind("theme.dividerColor");
}
.bar.thin {
height: 8px;
}
.bar > span {
height: 100%;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 13px;
color: v-bind("theme.textColor2");
}
.legend i {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 5px;
vertical-align: -1px;
}
.fail {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.fail-name {
width: 72px;
flex: none;
}
.fail-track {
flex: 1;
height: 10px;
border-radius: 5px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fail-track > span {
display: block;
height: 100%;
border-radius: 5px;
}
.fail-count {
width: 48px;
flex: none;
text-align: right;
font-variant-numeric: tabular-nums;
}
.tip {
display: flex;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
}
.tip-icon {
flex: none;
margin-top: 3px;
}
.class-box {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid v-bind("theme.dividerColor");
display: flex;
flex-direction: column;
gap: 8px;
}
.big {
font-size: 18px;
color: v-bind("theme.successColor");
}
.stuck {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid;
display: flex;
flex-direction: column;
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;
}
</style>
@@ -1,77 +0,0 @@
<template>
<div class="yearly-chart" v-if="props.data.length > 1">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js"
import type { YearlyACData } from "oj/api"
ChartJS.register(CategoryScale, Filler, LinearScale, LineElement, PointElement, Title, Tooltip)
const props = defineProps<{ data: YearlyACData[] }>()
const chartData = computed(() => ({
labels: props.data.map((d) => String(d.year)),
datasets: [
{
label: "AC 率",
data: props.data.map((d) => d.acRate),
fill: true,
tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)",
borderColor: "rgba(99, 179, 237, 1)",
pointBackgroundColor: "rgba(99, 179, 237, 1)",
},
],
}))
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: "历年 AC 率",
font: { size: 20 },
},
tooltip: {
callbacks: {
label: (context: any) => {
const d = props.data[context.dataIndex]
return [`AC 率: ${d.acRate}%`, `通过: ${d.accepted} / ${d.total}`]
},
},
},
},
scales: {
y: {
min: 0,
max: 100,
ticks: {
callback: (value: any) => `${value}%`,
},
},
},
}))
</script>
<style scoped>
.yearly-chart {
width: 100%;
max-width: 500px;
height: 250px;
margin: 24px auto;
}
</style>
@@ -39,8 +39,9 @@ interface EntryRules {
*/
collab: boolean
/**
* 教师的「课堂统计」。统计接口按 displayId 在公开题库里找题(`submission-statistics.ts`),
* 比赛题号拿过去查到的是另一道公开题,或者 404
* 工具栏上教师的「课堂统计」(新标签开数据统计页)。题库入口已经并进「统计」页签的老师
* 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的题单入口还要这个按钮。
* 比赛不给:统计接口按 displayId 在公开题库里找题,比赛题号查到的是另一道公开题,或者 404
*/
classStats: boolean
/**
@@ -60,7 +61,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
backToProblemSet: false,
help: true,
collab: true,
classStats: true,
classStats: false,
allSubmissionsInMenu: false,
},
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
+1
View File
@@ -81,6 +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 {
AdminTag,
+61 -6
View File
@@ -1,6 +1,7 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { judgeStatusSchema } from "./judge-status"
import { problemLanguageSchema } from "./language"
/**
@@ -446,11 +447,64 @@ export const problemAuthorSchema = z.object({
problemCount: z.number().int().nonnegative(),
})
export const yearlyAcSchema = z.object({
year: z.number().int(),
total: z.number().int().nonnegative(),
accepted: z.number().int().nonnegative(),
acRate: z.number(),
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 个人),「通过率」是按提交算的,学生看了不知道这题难不难。
* 原来的「击败用户」(分母是近两年所有登录过的人)、饼图、历年 AC 率一起去掉了。
*/
export const problemStatsSchema = z.object({
/** 比赛还没结束(又不是比赛管理员):别人的情况一律不给,下面的数字都是 0 / 空 */
locked: z.boolean(),
/** 交过的人 */
tried: z.number().int(),
solved: z.number().int(),
/** 做对的人用了几次:1 次 / 2–3 次 / 4 次以上 */
tries: z.object({ one: z.number().int(), few: z.number().int(), many: z.number().int() }),
/** 没通过的提交按结果分,多的在前(不含判题中) */
failures: z.array(failureCountSchema),
/** 答案错误里第 1 个测试点就没过的条数 —— 多半是例子都没对上,提示先点「运行例子」 */
wrongAnswerFirstCase: z.number().int(),
/** 我自己:交了几次、做对没有。没登录为 null */
me: z.object({ attempts: z.number().int(), solved: z.boolean() }).nullable(),
/** 学生自己班这题 x / y 人做对(y 是班里交过的人)。没班级、比赛里为 null */
myClass: z
.object({ className: z.string(), tried: z.number().int(), solved: z.number().int() })
.nullable(),
/**
* 老师:一起做过这题的班(同一天至少 5 个人交过 —— 零星几个人自己刷到、补做的不算),
* 按那天倒序。原来工具栏上的「课堂统计」并进了这里。学生、比赛里为 null
*/
classes: z
.array(
z.object({
className: z.string(),
tried: z.number().int(),
solved: z.number().int(),
day: z.string(),
}),
)
.nullable(),
/** 老师选中的那个班(默认 classes 第一个)的明细 */
classDetail: problemClassDetailSchema.nullable(),
})
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
@@ -468,4 +522,5 @@ export type SqlConfig = z.infer<typeof sqlConfigSchema>
export type SqlDisplay = z.infer<typeof sqlDisplaySchema>
export type SqlDisplayTable = z.infer<typeof sqlDisplayTableSchema>
export type SqlDisplayColumn = z.infer<typeof sqlDisplayColumnSchema>
export type YearlyAc = z.infer<typeof yearlyAcSchema>
export type ProblemStats = z.infer<typeof problemStatsSchema>
export type ProblemClassDetail = z.infer<typeof problemClassDetailSchema>