feat(卡点分析): 「卡住」原来数的是错过一次的人,做的人多的题全排在前面;改成看一次就对、交几次才对,能按班级和时间段筛

- 卡点按「人 × 题」算:一次就对、做对之前交了几次、还没做对几人,带知识点;一道题给每个人的情况、错在哪、全站历年一次就对
- 年度趋势换成历年概况:学年按 9 月切(原来按自然年),进行中的学年和去年同期并排
- 删掉旧的 /problem-analytics/stuck 全站前 40 和 /ac-trend

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 12:55:49 +08:00
1 parent 9e23d28f62
commit b2c802174c
6 files changed
+465 -355

No files matched your search

+367
View File
@@ -0,0 +1,367 @@
/**
* 学情分析里的「卡点分析」和「历年概况」。
*
* 两边共用一个口径:**人 × 题**。原来的卡点数的是「错过一次的人」,全部时间、不分班 ——
* 阿福讲白搭显示 325 人卡住,交过的 782 人里其实 753 人后来做对了,排在前面的只是做的人多。
* 课堂驱动的题最后几乎都会做对(这学期「交过还没做对」最多的一道也就 10 人),真正卡人的是
* 「要交好几次才对」,所以这里给的是:第一次交就对的人、做对的人、做对之前交了几次。
*
* - 只算学生(STUDENT_ROLES),只算非比赛提交;没判完的、判题机自己崩了的(SYSTEM_ERROR)
* 不算一次「交」。
* - 选了时间段时,只算**第一次交**落在这段时间里的人题对,指标按这对的全部历史算 ——
* 否则「最近 7 天」会把上周错、这周对的人算成一次就对。
* - 「做对平均交几次」只数做对那次及之前的,做对以后又交的不算。
*
* 路径挂在 /problem-analytics 下,不放进 /problems:Hono 按注册顺序匹配,
* problem.ts 先注册的 `/problems/:id` 会把它整个吃掉(以前真发生过,见 git 历史)。
*/
import {
STUDENT_ROLES,
type StuckProblem,
type StuckProblemDetail,
type YearlyOverview,
type YearlySummary,
} from "@oj2/contract"
import { and, eq, inArray, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { JudgeStatus } from "../../judge/status"
import { schoolYearOf, schoolYearStart, schoolYearStartOf, shiftMonthsByCalendar } from "../../time"
import { queryInteger } from "../helpers"
export const adminAnalyticsRoutes = new Hono<AppEnv>()
const ACCEPTED = sql.join([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED], sql`, `)
const NOT_COUNTED = sql.join(
[JudgeStatus.PENDING, JudgeStatus.JUDGING, JudgeStatus.SYSTEM_ERROR],
sql`, `,
)
const STUDENTS = sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
sql`, `,
)
/** `start` 是 ISO 时刻,解析不了就当没给(= 全部时间) */
function parseStart(value: string | undefined) {
if (!value) return null
const time = Date.parse(value)
return Number.isNaN(time) ? null : new Date(time).toISOString()
}
/**
* 学生的非比赛提交,每条带上「这对人题」第一次做对的时刻和序号。
* 后面的查询都从这张表聚合成人题对。
*/
function studentSubmissions(where: SQL[]) {
return sql`
select s.id, s.user_id, s.problem_id, s.create_time, s.result, s.language, u.class_name,
min(s.create_time) filter (where s.result in (${ACCEPTED}))
over (partition by s.user_id, s.problem_id) as ac_at,
row_number() over (partition by s.user_id, s.problem_id order by s.create_time, s.id) as rn
from ${schema.submission} s
join ${schema.user} u on u.id = s.user_id
where s.contest_id is null
and s.result not in (${NOT_COUNTED})
and u.admin_type in (${STUDENTS})
${sql.join(
where.map((condition) => sql`and ${condition}`),
sql` `,
)}
`
}
/** 班级精确匹配、时间段只留「在这段时间里交过」的人题对(第一次交在不在段里由 having 再卡) */
function scope(className: string | undefined, start: string | null, problemId?: number) {
const where: SQL[] = []
if (problemId) where.push(sql`s.problem_id = ${problemId}`)
if (className) where.push(sql`u.class_name = ${className}`)
if (start)
where.push(sql`(s.user_id, s.problem_id) in (
select user_id, problem_id from ${schema.submission}
where contest_id is null and create_time >= ${start}
${problemId ? sql`and problem_id = ${problemId}` : sql``}
)`)
return where
}
async function knowledgeTags(problemIds: number[]) {
const tags = new Map<number, string[]>()
if (!problemIds.length) return tags
const rows = await db
.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTag.id, schema.problemTags.problemtagId))
.where(
and(
inArray(schema.problemTags.problemId, problemIds),
eq(schema.problemTag.category, "knowledge"),
),
)
for (const row of rows) tags.set(row.problemId, [...(tags.get(row.problemId) ?? []), row.name])
return tags
}
// ---------------------------------------------------------------- 卡点:每道题一行
adminAnalyticsRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
const className = c.req.query("className")?.trim() || undefined
const start = parseStart(c.req.query("start"))
const rows = await db.execute<{
id: number
display_id: string
title: string
users: number
first_try: number
solved: number
solved_tries: number
}>(sql`
with s as (${studentSubmissions(scope(className, start))}),
pairs as (
select problem_id,
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try,
min(ac_at) as ac_at,
count(*) filter (where ac_at is null or create_time <= ac_at) as tries
from s
group by user_id, problem_id
${start ? sql`having min(create_time) >= ${start}` : sql``}
)
select p.id, p._id as display_id, p.title,
count(*)::int as users,
count(*) filter (where first_try)::int as first_try,
count(*) filter (where ac_at is not null)::int as solved,
coalesce(sum(tries) filter (where ac_at is not null), 0)::int as solved_tries
from pairs
join ${schema.problem} p on p.id = pairs.problem_id
group by p.id
`)
const tags = await knowledgeTags(rows.map((row) => row.id))
return success(
c,
rows.map(
(row) =>
({
id: row.id,
displayId: row.display_id,
title: row.title,
tags: tags.get(row.id) ?? [],
users: row.users,
firstTry: row.first_try,
solved: row.solved,
solvedTries: row.solved_tries,
}) satisfies StuckProblem,
),
)
})
// ---------------------------------------------------------------- 卡点:一道题
adminAnalyticsRoutes.get("/problem-analytics/stuck/:id", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problem] = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(eq(schema.problem.id, id))
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
const className = c.req.query("className")?.trim() || undefined
const start = parseStart(c.req.query("start"))
const pairs = sql`
with s as (${studentSubmissions(scope(className, start, id))}),
pairs as (
select user_id, min(create_time) as first_at, min(ac_at) as ac_at,
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try,
count(*) filter (where ac_at is null or create_time <= ac_at) as tries,
(array_agg(result order by create_time desc, id desc))[1] as last_result,
(array_agg(id order by create_time desc, id desc))[1] as last_id
from s
group by user_id
${start ? sql`having min(create_time) >= ${start}` : sql``}
)
`
const [students, errors, history, tags] = await Promise.all([
db.execute<{
username: string
class_name: string | null
first_try: boolean
solved: boolean
tries: number
minutes: number | null
last_result: number
last_id: string
}>(sql`
${pairs}
select u.username, u.class_name, pairs.first_try, pairs.ac_at is not null as solved,
pairs.tries::int as tries,
round(extract(epoch from pairs.ac_at - pairs.first_at) / 60)::int as minutes,
pairs.last_result, pairs.last_id
from pairs join ${schema.user} u on u.id = pairs.user_id
`),
db.execute<{ result: number; count: number }>(sql`
${pairs}
select s.result, count(*)::int as count
from s join pairs using (user_id)
where s.result not in (${ACCEPTED})
group by s.result
order by count desc
`),
// 历年:全站、全部时间,按第一次交落在哪个学年分
db.execute<{ year: number; users: number; first_try: number }>(sql`
with s as (${studentSubmissions([sql`s.problem_id = ${id}`])}),
pairs as (
select min(create_time) as first_at, bool_or(rn = 1 and result in (${ACCEPTED})) as first_try
from s group by user_id
)
select ${schoolYearOf(sql`first_at`)} as year, count(*)::int as users,
count(*) filter (where first_try)::int as first_try
from pairs group by 1 order by 1
`),
knowledgeTags([id]),
])
return success(c, {
problem: { ...problem, tags: tags.get(id) ?? [] },
students: students.map((row) => ({
username: row.username,
className: row.class_name,
firstTry: row.first_try,
solved: row.solved,
tries: row.tries,
minutes: row.minutes,
lastResult: row.last_result,
lastSubmissionId: row.last_id,
})),
errors: errors.map((row) => ({ result: row.result, count: row.count })),
history: history.map((row) => ({ year: row.year, users: row.users, firstTry: row.first_try })),
} satisfies StuckProblemDetail)
})
// ---------------------------------------------------------------- 历年概况
/** 「在用的年级」:这段时间里至少这么多人交过题的年级才列 */
const GRADE_MIN_USERS = 20
adminAnalyticsRoutes.get("/problem-analytics/yearly", requireTeacher, async (c) => {
const now = new Date()
const current = schoolYearStart(now)
const [first] = await db.execute<{ year: number | null }>(sql`
select ${schoolYearOf(sql`min(create_time)`)} as year
from ${schema.submission} where contest_id is null
`)
const firstYear = Math.min(first?.year ?? current.year, current.year)
// 每个完整学年、进行中的学年(到现在),外加去年同期(上一学年开学到去年的今天)
const ranges: { key: number; year: number; from: string; to: string }[] = []
for (let year = firstYear; year <= current.year; year += 1) {
const to = year === current.year ? now.toISOString() : schoolYearStartOf(year + 1)
ranges.push({ key: year, year, from: schoolYearStartOf(year), to })
}
const hasLastYear = current.year - 1 >= firstYear
if (hasLastYear)
ranges.push({
key: -1,
year: current.year - 1,
from: schoolYearStartOf(current.year - 1),
to: shiftMonthsByCalendar(now, -12).toISOString(),
})
const values = sql.join(
ranges.map(
(range) => sql`(${range.key}::int, ${range.from}::timestamptz, ${range.to}::timestamptz)`,
),
sql`, `,
)
const rows = await db.execute<{
key: number
users: number
submissions: number
problems: number
c_submissions: number
solved_pairs: number
started_pairs: number
first_try_pairs: number
solved_started: number
solved_tries: number
grades: string[] | null
}>(sql`
with ranges(key, f, t) as (values ${values}),
s as (${studentSubmissions([])}),
pairs as (
select min(create_time) as first_at, min(ac_at) as ac_at,
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try,
count(*) filter (where ac_at is null or create_time <= ac_at) as tries
from s group by user_id, problem_id
),
sub_stats as (
select r.key, count(distinct s.user_id)::int as users, count(*)::int as submissions,
count(distinct s.problem_id)::int as problems,
count(*) filter (where s.language in ('C', 'C++'))::int as c_submissions
from s join ranges r on s.create_time >= r.f and s.create_time < r.t
group by r.key
),
pair_stats as (
select r.key,
count(*) filter (where p.ac_at >= r.f and p.ac_at < r.t)::int as solved_pairs,
count(*) filter (where p.first_at >= r.f and p.first_at < r.t)::int as started_pairs,
count(*) filter (where p.first_at >= r.f and p.first_at < r.t and p.first_try)::int
as first_try_pairs,
count(*) filter (where p.first_at >= r.f and p.first_at < r.t and p.ac_at is not null)::int
as solved_started,
coalesce(sum(p.tries) filter (
where p.first_at >= r.f and p.first_at < r.t and p.ac_at is not null
), 0)::int as solved_tries
from pairs p join ranges r
on (p.first_at >= r.f and p.first_at < r.t) or (p.ac_at >= r.f and p.ac_at < r.t)
group by r.key
),
grade_stats as (
select key, json_agg(grade order by grade) as grades from (
select r.key, left(s.class_name, 2) as grade
from s join ranges r on s.create_time >= r.f and s.create_time < r.t
where s.class_name ~ '^[0-9]{3,}$'
group by r.key, left(s.class_name, 2)
having count(distinct s.user_id) >= ${GRADE_MIN_USERS}
) g group by key
)
select r.key, coalesce(sub.users, 0) as users, coalesce(sub.submissions, 0) as submissions,
coalesce(sub.problems, 0) as problems, coalesce(sub.c_submissions, 0) as c_submissions,
coalesce(ps.solved_pairs, 0) as solved_pairs, coalesce(ps.started_pairs, 0) as started_pairs,
coalesce(ps.first_try_pairs, 0) as first_try_pairs,
coalesce(ps.solved_started, 0) as solved_started,
coalesce(ps.solved_tries, 0) as solved_tries, gs.grades
from ranges r
left join sub_stats sub on sub.key = r.key
left join pair_stats ps on ps.key = r.key
left join grade_stats gs on gs.key = r.key
`)
const byKey = new Map(rows.map((row) => [row.key, row]))
const summary = (range: (typeof ranges)[number]): YearlySummary => {
const row = byKey.get(range.key)
return {
year: range.year,
until: range.to,
grades: row?.grades ?? [],
users: row?.users ?? 0,
submissions: row?.submissions ?? 0,
problems: row?.problems ?? 0,
solvedPairs: row?.solved_pairs ?? 0,
startedPairs: row?.started_pairs ?? 0,
firstTryPairs: row?.first_try_pairs ?? 0,
solvedStarted: row?.solved_started ?? 0,
solvedTries: row?.solved_tries ?? 0,
cSubmissions: row?.c_submissions ?? 0,
}
}
const lastYear = ranges.find((range) => range.key === -1)
return success(c, {
years: ranges.filter((range) => range.key !== -1).map(summary),
lastYearToDate: lastYear ? summary(lastYear) : null,
} satisfies YearlyOverview)
})
+2
View File
@@ -4,6 +4,7 @@ import type { AppEnv } from "../../auth/middleware"
import { adminAccountRoutes } from "./account"
import { adminAchievementRoutes } from "./achievement"
import { adminAiRoutes } from "./ai"
import { adminAnalyticsRoutes } from "./analytics"
import { adminConfRoutes } from "./conf"
import { adminContestRoutes } from "./contest"
import { adminLearnRoutes } from "./learn"
@@ -26,6 +27,7 @@ export const adminRoutes = new Hono<AppEnv>()
adminRoutes.route("/", adminAccountRoutes)
adminRoutes.route("/", adminAchievementRoutes)
adminRoutes.route("/", adminAiRoutes)
adminRoutes.route("/", adminAnalyticsRoutes)
adminRoutes.route("/", adminConfRoutes)
adminRoutes.route("/", adminContestRoutes)
adminRoutes.route("/", adminLearnRoutes)
+3 -150
View File
@@ -3,45 +3,24 @@ import {
generateFlowchartRequestSchema,
renameTagRequestSchema,
setTagCategoryRequestSchema,
type AcTrend,
type AdminTag,
type BatchProblemTagResponse,
type GenerateFlowchartResponse,
type RenameTagResponse,
type StuckProblem,
} from "@oj2/contract"
import {
and,
asc,
countDistinct,
count,
desc,
eq,
gte,
ilike,
inArray,
isNull,
lte,
ne,
sql,
} from "drizzle-orm"
import { and, asc, countDistinct, desc, eq, ilike, inArray, isNull, ne, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireProblemPermission, requireTeacher, type AppEnv } from "../../auth/middleware"
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http"
import { JudgeStatus } from "../../judge/status"
import { completeChat } from "../../services/ai"
import { localTime, localYear } from "../../time"
import { queryInteger, rounded } from "../helpers"
import { queryInteger } from "../helpers"
import { canEdit, findTagsByName, normalizeTagNames } from "./problem"
export const adminTagRoutes = new Hono<AppEnv>()
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
const FAILED = [JudgeStatus.WRONG_ANSWER, JudgeStatus.COMPILE_ERROR, JudgeStatus.RUNTIME_ERROR]
/** 能管所有题目:超管,或 problemPermission 为 All */
function canManageAllProblems(user: AuthUser) {
return user.adminType === "Super Admin" || user.problemPermission === "All"
@@ -273,132 +252,6 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
return success(c, { visible: !problem.visible })
})
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
// 路径特意不放在 /problems 下:Hono 按**注册顺序**匹配(不是静态优先),
// 而 problem.ts 的 `GET /problems/:id` 先注册,会把 `/problems/stuck` 整个吃掉 ——
// 这两个 handler 曾经从未执行过,生效的还是那边的 requireProblemPermission 而非这里的
// requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
const rows = await db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
total: count(),
accepted:
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
Number,
),
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(
Number,
),
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
/**
* 只看公共题,和隔壁 ac-trend 同一个口径。原来这里一个 where 都没有,比赛题
* 也进榜 —— 而比赛题的题号是每场比赛各自从 1 开始编的(快照里 61 道不同的题
* 都叫「1」),一旦挤进前 40,那一行显示的题号会指向一道根本不存在的公共题。
*
* 眼下还没发生:前 40 的门槛是 97 人卡住,比赛题最多的一道是 59 人。但两个班
* 一起考的场次有 95 人,撞上一道难题就够得着了。
*
* 加了这条对公共题的数字**没有任何影响**:比赛提交挂的是比赛自己的 problem 行
* (快照实测两个方向的交叉都是 0 条),公共题那一行本来就只统计自己的提交。
* 顺带让这条查询能用上 0013 的 submission_public_metrics_idx,173ms → 82ms。
*/
.where(isNull(schema.submission.contestId))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
.limit(40)
return success(
c,
rows.map(
(row) =>
({
problemId: row.displayId,
problemTitle: row.title,
total: row.total,
failed: row.failed,
failedUsers: row.failedUsers,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
}) satisfies StuckProblem,
),
)
})
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
const currentYear = localYear()
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
let untilYear = queryInteger(c.req.query("untilYear"), currentYear)
if (untilYear < sinceYear || untilYear > currentYear) untilYear = currentYear - 1
let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
// 年份按东八区切,和上面 `currentYear` 的夹逼同口径
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(
Number,
)
const rows = await db
.select({
problemId: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
year,
total: count(),
accepted:
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
Number,
),
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(and(isNull(schema.submission.contestId), gte(year, sinceYear), lte(year, untilYear)))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
.orderBy(asc(schema.problem.id), asc(year))
const required = new Set<number>()
for (let y = sinceYear; y <= untilYear; y += 1) required.add(y)
const grouped = new Map<number, { displayId: string; title: string; yearly: typeof rows }>()
for (const row of rows) {
const bucket = grouped.get(row.problemId)
if (bucket) bucket.yearly.push(row)
else
grouped.set(row.problemId, {
displayId: row.displayId,
title: row.title,
yearly: [row],
})
}
const result = []
for (const entry of grouped.values()) {
const years = new Set(entry.yearly.map((row) => row.year))
// 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性
if (![...required].every((y) => years.has(y))) continue
if (!entry.yearly.every((row) => row.total > minPerYear)) continue
result.push({
problemId: entry.displayId,
problemTitle: entry.title,
yearly: entry.yearly
.map((row) => ({
year: row.year,
total: row.total,
accepted: row.accepted,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
}))
.sort((left, right) => left.year - right.year),
} satisfies AcTrend)
}
return success(c, result)
})
// ---------------------------------------------------------------- Python → 流程图
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => {
+20
View File
@@ -95,6 +95,21 @@ export function termStart(now: Date | number | string = new Date()): string {
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
}
/**
* 学年从哪天零点(东八区)算起:9 月 1 日。返回 `{ year, start }`,`year` 是起始年
* (2025 = 25–26 学年),`start` 是 ISO 字符串。SQL 里按学年分组用 `schoolYearOf()`,两边同一个口径。
*/
export function schoolYearStart(now: Date | number | string = new Date()) {
const wall = toWallClock(now)
const year = wall.getUTCMonth() + 1 >= 9 ? wall.getUTCFullYear() : wall.getUTCFullYear() - 1
return { year, start: schoolYearStartOf(year) }
}
/** 某个学年起始年的 9 月 1 日零点(东八区),ISO 字符串 */
export function schoolYearStartOf(year: number): string {
return fromWallClock(new Date(Date.UTC(year, 8, 1))).toISOString()
}
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant)
@@ -121,3 +136,8 @@ const TIME_ZONE_SQL = sql.raw(`'${TIME_ZONE}'`)
export function localTime(column: SQLWrapper) {
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
}
/** 时刻所在学年的起始年(9 月起算),和 `schoolYearStart()` 同一个口径 */
export function schoolYearOf(column: SQLWrapper) {
return sql<number>`extract(year from ${localTime(column)} - interval '8 months')::int`
}
-189
View File
@@ -1,189 +0,0 @@
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import type { AcTrend } from "utils/types"
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js"
import { getTopACTrend } from "admin/api"
import { zonedYear } from "utils/functions"
ChartJS.register(CategoryScale, Filler, LinearScale, LineElement, PointElement, Title, Tooltip)
type ProblemTrend = AcTrend
// 年份按东八区取,和后端 ac-trend 的夹逼口径(`localYear()`)对齐。
// 用 `new Date().getFullYear()` 的话,跨年那几个小时里浏览器年份可能比后端认定的
// 年份晚一年,默认的 untilYear 会被后端夹掉、图表悄悄变成另一个区间。
const currentYear = zonedYear()
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
label: String(2022 + i),
value: 2022 + i,
}))
const minPerYearOptions = [
{ label: "50", value: 50 },
{ label: "100", value: 100 },
{ label: "200", value: 200 },
]
const sinceYear = ref(2023)
const untilYear = ref(currentYear - 1)
const minPerYear = ref(100)
const loading = ref(false)
const data = ref<ProblemTrend[]>([])
const acLabelPlugin = {
id: "acLabel",
afterDatasetsDraw(chart: any) {
const ctx = chart.ctx
chart.data.datasets.forEach((_: any, i: number) => {
const meta = chart.getDatasetMeta(i)
meta.data.forEach((point: any, j: number) => {
const value = chart.data.datasets[i].data[j]
if (value === null || value === undefined) return
ctx.save()
ctx.font = "bold 11px sans-serif"
ctx.fillStyle = "rgba(99, 179, 237, 1)"
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillText(`${value}%`, point.x, point.y - 6)
ctx.restore()
})
})
},
}
function getChartData(problem: ProblemTrend) {
return {
labels: problem.yearly.map((y) => String(y.year)),
datasets: [
{
label: "AC 率",
data: problem.yearly.map((y) => y.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)",
pointRadius: 4,
},
],
}
}
function getChartOptions(problem: ProblemTrend) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `${problem.problemId} · ${problem.problemTitle}`,
font: { size: 14 },
},
tooltip: {
callbacks: {
label: (ctx: any) => {
const entry = problem.yearly[ctx.dataIndex]
return `AC 率: ${entry.acRate}% (${entry.accepted}/${entry.total})`
},
},
},
},
scales: {
y: {
min: 0,
max: 100,
ticks: { callback: (v: any) => `${v}%` },
},
x: {
title: { display: true, text: "年份" },
},
},
}
}
async function fetchData() {
loading.value = true
try {
const res = await getTopACTrend({
sinceYear: sinceYear.value,
untilYear: untilYear.value,
minPerYear: minPerYear.value,
})
data.value = res
} finally {
loading.value = false
}
}
onMounted(fetchData)
</script>
<template>
<PageHeader title="年度趋势">
<template #extra>
<n-tag type="info" size="small" round>共 {{ data.length }} 题</n-tag>
</template>
<template #filters>
<span>年份范围</span>
<n-select
v-model:value="sinceYear"
:options="yearOptions"
style="width: 100px"
@update:value="fetchData"
/>
<span>—</span>
<n-select
v-model:value="untilYear"
:options="yearOptions"
style="width: 100px"
@update:value="fetchData"
/>
<span>年提交下限</span>
<n-select
v-model:value="minPerYear"
:options="minPerYearOptions"
style="width: 90px"
@update:value="fetchData"
/>
</template>
</PageHeader>
<n-spin :show="loading">
<div v-if="!loading && data.length === 0" style="text-align: center; padding: 40px">
暂无数据
</div>
<div v-else class="grid">
<div v-for="problem in data" :key="problem.problemId" class="chart-card">
<Line
:data="getChartData(problem)"
:options="getChartOptions(problem)"
:plugins="[acLabelPlugin]"
/>
</div>
</div>
</n-spin>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
padding: 8px 0;
}
.chart-card {
height: 260px;
border-radius: 8px;
padding: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
</style>
+73 -16
View File
@@ -534,26 +534,81 @@ export const batchProblemTagResponseSchema = z.object({
tagCount: z.number().int(),
})
/**
* 卡点分析的一道题(一行)。口径是「人 × 题」:只算**第一次交**落在所选时间段里的那些
* 人题对,指标按这对的全部历史算(做没做对、交了几次才对)。比率由前端按人数算,
* 这样知识点的汇总能按所选门槛过滤后再加权。
*/
export const stuckProblemSchema = z.object({
problemId: z.string(),
problemTitle: z.string(),
total: z.number().int(),
failed: z.number().int(),
failedUsers: z.number().int(),
acRate: z.number(),
id: z.number().int(),
displayId: z.string(),
title: z.string(),
/** 知识点(category = knowledge 的标签) */
tags: z.array(z.string()),
/** 做过的人 */
users: z.number().int(),
/** 第一次交就对的人 */
firstTry: z.number().int(),
/** 做对了的人 */
solved: z.number().int(),
/** 做对的人「做对那次及之前」一共交了几次,加总 —— 除以 solved 就是平均 */
solvedTries: z.number().int(),
})
export const acTrendYearSchema = z.object({
export const stuckStudentSchema = z.object({
username: z.string(),
className: z.string().nullable(),
firstTry: z.boolean(),
solved: z.boolean(),
/** 做对了:做对那次及之前交了几次;没做对:一共交了几次 */
tries: z.number().int(),
/** 第一次交到做对隔了几分钟,没做对是 null */
minutes: z.number().int().nullable(),
lastResult: z.number().int(),
lastSubmissionId: z.string(),
})
export const stuckProblemDetailSchema = z.object({
problem: z.object({
id: z.number().int(),
displayId: z.string(),
title: z.string(),
tags: z.array(z.string()),
}),
students: z.array(stuckStudentSchema),
/** 这些人错的那些次,按判题结果分 */
errors: z.array(z.object({ result: z.number().int(), count: z.number().int() })),
/** 全站历年(学年按 9 月起算,`year` 是起始年),不受班级和时间段影响 */
history: z.array(
z.object({ year: z.number().int(), users: z.number().int(), firstTry: z.number().int() }),
),
})
export const yearlySummarySchema = z.object({
/** 学年起始年,2025 = 25–26 学年 */
year: z.number().int(),
total: z.number().int(),
accepted: z.number().int(),
acRate: z.number(),
/** 统计到哪天为止(不含),进行中的学年和去年同期才有意义 */
until: z.string(),
grades: z.array(z.string()),
users: z.number().int(),
submissions: z.number().int(),
problems: z.number().int(),
/** 这段时间里第一次做对的人题对 */
solvedPairs: z.number().int(),
/** 第一次交在这段时间里的人题对,和其中第一次就对的 */
startedPairs: z.number().int(),
firstTryPairs: z.number().int(),
/** 上面那批里做对了的,以及它们做对那次及之前一共交了几次 */
solvedStarted: z.number().int(),
solvedTries: z.number().int(),
cSubmissions: z.number().int(),
})
export const acTrendSchema = z.object({
problemId: z.string(),
problemTitle: z.string(),
yearly: z.array(acTrendYearSchema),
export const yearlyOverviewSchema = z.object({
/** 从最早的学年到现在这个学年(最后一个是进行中的,只算到今天) */
years: z.array(yearlySummarySchema),
/** 去年同期:上一个学年开学到「去年的今天」 */
lastYearToDate: yearlySummarySchema.nullable(),
})
// ---------------------------------------------------------------- 自学情况
@@ -943,12 +998,15 @@ export type AdminAiReportListItem = z.infer<typeof adminAiReportListItemSchema>
export type AdminAiReport = z.infer<typeof adminAiReportSchema>
export type AdminAiReportList = z.infer<typeof adminAiReportListSchema>
export type StuckProblem = z.infer<typeof stuckProblemSchema>
export type StuckStudent = z.infer<typeof stuckStudentSchema>
export type StuckProblemDetail = z.infer<typeof stuckProblemDetailSchema>
export type YearlySummary = z.infer<typeof yearlySummarySchema>
export type YearlyOverview = z.infer<typeof yearlyOverviewSchema>
export type LearnBoard = z.infer<typeof learnBoardSchema>
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
export type LearnExerciseProgressList = z.infer<typeof learnExerciseProgressListSchema>
export type LearnExerciseAttempt = z.infer<typeof learnExerciseAttemptSchema>
export type AcTrend = z.infer<typeof acTrendSchema>
export type AdminTag = z.infer<typeof adminTagSchema>
export type RenameTagResponse = z.infer<typeof renameTagResponseSchema>
@@ -1010,7 +1068,6 @@ export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeR
export type UpdateProblemSetBadgeRequest = z.infer<typeof updateProblemSetBadgeRequestSchema>
export type RenameTagRequest = z.infer<typeof renameTagRequestSchema>
export type BatchProblemTagRequest = z.infer<typeof batchProblemTagRequestSchema>
export type AcTrendYear = z.infer<typeof acTrendYearSchema>
export type GenerateFlowchartRequest = z.infer<typeof generateFlowchartRequestSchema>
export type GenerateFlowchartResponse = z.infer<typeof generateFlowchartResponseSchema>
export type UpdateProblemRequest = z.infer<typeof updateProblemRequestSchema>