Compare commits
2
Commits
9e23d28f62
...
0980ea858a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0980ea858a | ||
|
|
b2c802174c |
No files matched your search
@@ -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)
|
||||
})
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
+11
-10
@@ -1,7 +1,6 @@
|
||||
import api from "utils/api"
|
||||
import { toAdminProblemRow } from "admin/transforms"
|
||||
import type {
|
||||
AcTrend,
|
||||
BatchProblemTagResponse,
|
||||
GenerateSqlTestCaseResponse,
|
||||
RenameTagResponse,
|
||||
@@ -9,6 +8,8 @@ import type {
|
||||
AdminAiReport,
|
||||
AdminAiReportList,
|
||||
StuckProblem,
|
||||
StuckProblemDetail,
|
||||
YearlyOverview,
|
||||
LearnBoard,
|
||||
LearnExerciseProgressList,
|
||||
LearnExerciseAttempt,
|
||||
@@ -598,9 +599,13 @@ export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||
return api.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`)
|
||||
}
|
||||
|
||||
// 学生卡点分析
|
||||
export function getStuckProblems() {
|
||||
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
|
||||
// 卡点分析:start 是时间段的起点(ISO),不给就是全部时间
|
||||
export function getStuckProblems(params: { className?: string; start?: string }) {
|
||||
return api.get<StuckProblem[]>("admin/problem-analytics/stuck", { params })
|
||||
}
|
||||
|
||||
export function getStuckProblemDetail(id: number, params: { className?: string; start?: string }) {
|
||||
return api.get<StuckProblemDetail>(`admin/problem-analytics/stuck/${id}`, { params })
|
||||
}
|
||||
|
||||
export function getLearnBoard(params: { type: "python" | "c"; className?: string }) {
|
||||
@@ -619,12 +624,8 @@ export function getLearnExerciseAttempts(exerciseId: number, params: { className
|
||||
})
|
||||
}
|
||||
|
||||
export function getTopACTrend(params: {
|
||||
sinceYear: number
|
||||
untilYear: number
|
||||
minPerYear: number
|
||||
}) {
|
||||
return api.get<AcTrend[]>("admin/problem-analytics/ac-trend", { params })
|
||||
export function getYearlyOverview() {
|
||||
return api.get<YearlyOverview>("admin/problem-analytics/yearly")
|
||||
}
|
||||
|
||||
// AI 学习分析报告
|
||||
|
||||
@@ -1,49 +1,863 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { getStuckProblems } from "admin/api"
|
||||
import type { StuckProblem } from "utils/types"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { zonedParts } from "utils/functions"
|
||||
import type { StuckProblem, StuckProblemDetail, StuckStudent } from "utils/types"
|
||||
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { classLabel, groupClassOptions } from "oj/submission/utils"
|
||||
import { periodRange, type Period } from "oj/statistics/period"
|
||||
import { resultName, useResultColor } from "oj/statistics/results"
|
||||
import "oj/learn/learn.css"
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<StuckProblem[]>([])
|
||||
/**
|
||||
* 卡点分析(设计稿「卡点与趋势」页的定稿 StuckFinalAll / StuckFinalClass):
|
||||
* 知识点 → 这个知识点下的题 → 一道题。
|
||||
*
|
||||
* 口径见后端 routes/admin/analytics.ts:人 × 题,「一次就对」= 第一次交就对的人占比,
|
||||
* 「平均交几次」只数做对那次及之前的。门槛(至少几人做过)在前端卡,知识点的汇总跟着门槛走 ——
|
||||
* 选了一个班时默认 10 人,相当于「课上布置过的」,不然会混进一两个人自己做的题。
|
||||
*/
|
||||
const router = useRouter()
|
||||
const configStore = useConfigStore()
|
||||
const resultColor = useResultColor()
|
||||
|
||||
const columns: DataTableColumn<StuckProblem>[] = [
|
||||
{ title: "题目 ID", key: "problemId", width: 100 },
|
||||
{ title: "题目名称", key: "problemTitle", minWidth: 200 },
|
||||
{ title: "总提交", key: "total", width: 100, sorter: "default" },
|
||||
{ title: "失败次数", key: "failed", width: 100, sorter: "default" },
|
||||
{
|
||||
title: "卡住学生数",
|
||||
key: "failedUsers",
|
||||
width: 120,
|
||||
sorter: "default",
|
||||
defaultSortOrder: "descend",
|
||||
},
|
||||
{
|
||||
title: "AC 率",
|
||||
key: "acRate",
|
||||
width: 100,
|
||||
sorter: "default",
|
||||
render: (row) => `${row.acRate}%`,
|
||||
},
|
||||
const PERIODS: { label: string; value: Period }[] = [
|
||||
{ label: "这学期", value: "term" },
|
||||
{ label: "最近 30 天", value: "30d" },
|
||||
{ label: "最近 7 天", value: "7d" },
|
||||
{ label: "全部", value: "all" },
|
||||
]
|
||||
const THRESHOLDS = [5, 10, 20, 50]
|
||||
|
||||
onMounted(async () => {
|
||||
const className = useRouteQuery<string>("className", "", { mode: "replace" })
|
||||
const period = useRouteQuery<string>("period", "term", { mode: "replace" })
|
||||
const tag = useRouteQuery<string>("tag", "", { mode: "replace" })
|
||||
const picked = useRouteQuery<string>("problem", "", { mode: "replace" })
|
||||
const min = useRouteQuery<string>("min", "", { mode: "replace" })
|
||||
|
||||
const problems = ref<StuckProblem[]>([])
|
||||
const detail = ref<StuckProblemDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
||||
const threshold = computed(() => Number(min.value) || (className.value ? 10 : 20))
|
||||
const thresholdOptions = computed(() =>
|
||||
THRESHOLDS.map((n) => ({ label: `至少 ${n} 人做过`, value: n })),
|
||||
)
|
||||
|
||||
function rate(part: number, whole: number) {
|
||||
return whole ? Math.round((part / whole) * 100) : 0
|
||||
}
|
||||
|
||||
function firstRate(row: StuckProblem) {
|
||||
return rate(row.firstTry, row.users)
|
||||
}
|
||||
|
||||
function avgTries(row: { solved: number; solvedTries: number }) {
|
||||
return row.solved ? (row.solvedTries / row.solved).toFixed(1) : "-"
|
||||
}
|
||||
|
||||
function range() {
|
||||
return periodRange(period.value as Period, null).start
|
||||
}
|
||||
|
||||
const visible = computed(() => problems.value.filter((row) => row.users >= threshold.value))
|
||||
|
||||
/** 知识点:按门槛过滤后的题,人数加权;「全部」在最前,其余一次就对从低到高 */
|
||||
const knowledge = computed(() => {
|
||||
const groups = new Map<string, { problems: number; users: number; firstTry: number }>()
|
||||
for (const row of visible.value) {
|
||||
for (const name of row.tags) {
|
||||
const group = groups.get(name) ?? { problems: 0, users: 0, firstTry: 0 }
|
||||
group.problems += 1
|
||||
group.users += row.users
|
||||
group.firstTry += row.firstTry
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
const all = visible.value.reduce(
|
||||
(sum, row) => ({ users: sum.users + row.users, firstTry: sum.firstTry + row.firstTry }),
|
||||
{ users: 0, firstTry: 0 },
|
||||
)
|
||||
return [
|
||||
{
|
||||
name: "",
|
||||
label: "全部",
|
||||
problems: visible.value.length,
|
||||
rate: rate(all.firstTry, all.users),
|
||||
},
|
||||
...[...groups]
|
||||
.map(([name, group]) => ({
|
||||
name,
|
||||
label: name,
|
||||
problems: group.problems,
|
||||
rate: rate(group.firstTry, group.users),
|
||||
}))
|
||||
.sort((a, b) => a.rate - b.rate || b.problems - a.problems),
|
||||
]
|
||||
})
|
||||
|
||||
const activeTag = computed(() =>
|
||||
knowledge.value.some((row) => row.name === tag.value) ? tag.value : "",
|
||||
)
|
||||
|
||||
const list = computed(() =>
|
||||
visible.value
|
||||
.filter((row) => !activeTag.value || row.tags.includes(activeTag.value))
|
||||
.sort((a, b) => firstRate(a) - firstRate(b) || b.users - a.users),
|
||||
)
|
||||
|
||||
const selected = computed(
|
||||
() => list.value.find((row) => String(row.id) === picked.value) ?? list.value[0] ?? null,
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getStuckProblems()
|
||||
data.value = res
|
||||
problems.value = await getStuckProblems({
|
||||
className: className.value || undefined,
|
||||
start: range(),
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let detailToken = 0
|
||||
async function loadDetail() {
|
||||
const row = selected.value
|
||||
const token = ++detailToken
|
||||
if (!row) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const data = await getStuckProblemDetail(row.id, {
|
||||
className: className.value || undefined,
|
||||
start: range(),
|
||||
})
|
||||
if (token === detailToken) detail.value = data
|
||||
} finally {
|
||||
if (token === detailToken) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([className, period], load)
|
||||
watch(() => [selected.value?.id, problems.value] as const, loadDetail)
|
||||
onMounted(load)
|
||||
|
||||
// ---------------------------------------------------------------- 一道题
|
||||
|
||||
const students = computed(() => detail.value?.students ?? [])
|
||||
const summary = computed(() => {
|
||||
const rows = students.value
|
||||
const solved = rows.filter((row) => row.solved)
|
||||
return {
|
||||
users: rows.length,
|
||||
firstRate: rate(rows.filter((row) => row.firstTry).length, rows.length),
|
||||
avgTries: avgTries({
|
||||
solved: solved.length,
|
||||
solvedTries: solved.reduce((sum, row) => sum + row.tries, 0),
|
||||
}),
|
||||
stuck: rows.length - solved.length,
|
||||
}
|
||||
})
|
||||
|
||||
const errorTotal = computed(() =>
|
||||
(detail.value?.errors ?? []).reduce((sum, row) => sum + row.count, 0),
|
||||
)
|
||||
|
||||
const byClass = computed(() => {
|
||||
const groups = new Map<string, StuckStudent[]>()
|
||||
for (const row of students.value) {
|
||||
const key = row.className ?? ""
|
||||
groups.set(key, [...(groups.get(key) ?? []), row])
|
||||
}
|
||||
return [...groups]
|
||||
.map(([name, rows]) => {
|
||||
const solved = rows.filter((row) => row.solved)
|
||||
return {
|
||||
name,
|
||||
users: rows.length,
|
||||
rate: rate(rows.filter((row) => row.firstTry).length, rows.length),
|
||||
avgTries: avgTries({
|
||||
solved: solved.length,
|
||||
solvedTries: solved.reduce((sum, row) => sum + row.tries, 0),
|
||||
}),
|
||||
stuck: rows.length - solved.length,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.users - a.users)
|
||||
})
|
||||
|
||||
const BUCKETS = [
|
||||
{ label: "1 次", min: 1, max: 1 },
|
||||
{ label: "2 次", min: 2, max: 2 },
|
||||
{ label: "3 次", min: 3, max: 3 },
|
||||
{ label: "4–5 次", min: 4, max: 5 },
|
||||
{ label: "6 次以上", min: 6, max: Infinity },
|
||||
]
|
||||
const distribution = computed(() => {
|
||||
const solved = students.value.filter((row) => row.solved)
|
||||
const rows = BUCKETS.map((bucket) => ({
|
||||
...bucket,
|
||||
count: solved.filter((row) => row.tries >= bucket.min && row.tries <= bucket.max).length,
|
||||
}))
|
||||
const max = Math.max(1, ...rows.map((row) => row.count))
|
||||
return rows.map((row) => ({ ...row, height: Math.max(2, (row.count / max) * 64) }))
|
||||
})
|
||||
|
||||
const mostTries = computed(() =>
|
||||
students.value
|
||||
.filter((row) => row.solved && row.tries >= 3)
|
||||
.sort((a, b) => b.tries - a.tries)
|
||||
.slice(0, 6),
|
||||
)
|
||||
|
||||
const stuckStudents = computed(() =>
|
||||
students.value.filter((row) => !row.solved).sort((a, b) => b.tries - a.tries),
|
||||
)
|
||||
|
||||
const currentYear = (() => {
|
||||
const now = zonedParts(new Date())!
|
||||
return now.month >= 9 ? now.year : now.year - 1
|
||||
})()
|
||||
const history = computed(() =>
|
||||
(detail.value?.history ?? []).map((row) => ({
|
||||
label: `${String(row.year).slice(2)}–${String(row.year + 1).slice(2)}`,
|
||||
current: row.year === currentYear,
|
||||
users: row.users,
|
||||
rate: rate(row.firstTry, row.users),
|
||||
})),
|
||||
)
|
||||
|
||||
function gap(minutes: number | null) {
|
||||
if (minutes === null) return ""
|
||||
if (minutes < 1) return "不到 1 分钟"
|
||||
if (minutes < 60) return `${minutes} 分钟`
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} 小时`
|
||||
return `隔了 ${Math.round(minutes / 1440)} 天`
|
||||
}
|
||||
|
||||
function href(to: Parameters<typeof router.resolve>[0]) {
|
||||
return router.resolve(to).href
|
||||
}
|
||||
|
||||
const links = computed(() => {
|
||||
const row = detail.value?.problem
|
||||
if (!row) return null
|
||||
const scope = className.value ? { className: className.value } : {}
|
||||
return {
|
||||
problem: href({ name: "problem", params: { problemID: row.displayId } }),
|
||||
statistics: href({
|
||||
name: "statistics",
|
||||
query: { problem: row.displayId, period: period.value, ...scope },
|
||||
}),
|
||||
submissions: href({ name: "submissions", query: { problem: row.displayId, ...scope } }),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="卡点分析" description="只分析前 40 道题目" />
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
striped
|
||||
:pagination="{ pageSize: 20 }"
|
||||
/>
|
||||
<div class="page">
|
||||
<div class="top">
|
||||
<span class="title">卡点分析</span>
|
||||
<span class="grow" />
|
||||
<n-select
|
||||
:value="className || null"
|
||||
:options="classOptions"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="全部班级"
|
||||
style="width: 170px"
|
||||
@update:value="(value: string | null) => (className = value ?? '')"
|
||||
/>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="option in PERIODS"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:class="{ on: period === option.value }"
|
||||
@click="period = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<div class="main">
|
||||
<section class="card know">
|
||||
<div class="card-head">
|
||||
<span class="strong">知识点</span>
|
||||
<span class="grow" />
|
||||
<span class="muted small">一次就对</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="row in knowledge"
|
||||
:key="row.name"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: row.name === activeTag }"
|
||||
@click="tag = row.name"
|
||||
>
|
||||
<span class="line">
|
||||
<span class="name">{{ row.label }}</span>
|
||||
<span class="grow" />
|
||||
<span class="muted small num">{{ row.problems }} 道</span>
|
||||
</span>
|
||||
<span class="line">
|
||||
<span class="track"><span :style="{ width: `${row.rate}%` }" /></span>
|
||||
<span class="pct num">{{ row.rate }}%</span>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card problems">
|
||||
<div class="card-head">
|
||||
<span class="strong">{{ activeTag || "全部" }}</span>
|
||||
<span class="muted small num">{{ list.length }} 道</span>
|
||||
<span class="grow" />
|
||||
<n-select
|
||||
:value="threshold"
|
||||
:options="thresholdOptions"
|
||||
size="small"
|
||||
style="width: 130px"
|
||||
@update:value="(value: number) => (min = String(value))"
|
||||
/>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div v-if="!list.length && !loading" class="none muted">
|
||||
这段时间还没有 {{ threshold }} 人以上做过的题
|
||||
</div>
|
||||
<button
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: row.id === selected?.id }"
|
||||
@click="picked = String(row.id)"
|
||||
>
|
||||
<span class="line">
|
||||
<span class="pid muted num">{{ row.displayId }}</span>
|
||||
<span class="ptitle">{{ row.title }}</span>
|
||||
<span v-if="row.users > row.solved" class="stuck num">
|
||||
{{ row.users - row.solved }} 人没对
|
||||
</span>
|
||||
</span>
|
||||
<span class="line indent">
|
||||
<span class="track short"><span :style="{ width: `${firstRate(row)}%` }" /></span>
|
||||
<span class="pct num">{{ firstRate(row) }}%</span>
|
||||
<span class="muted small num nowrap">
|
||||
{{ row.users }} 人 · 平均 {{ avgTries(row) }} 次
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="list.length" class="card detail">
|
||||
<n-spin :show="detailLoading">
|
||||
<div v-if="detail && links" class="detail-body">
|
||||
<div class="line dhead">
|
||||
<span class="muted num">{{ detail.problem.displayId }}</span>
|
||||
<span class="dtitle">{{ detail.problem.title }}</span>
|
||||
<span v-for="name in detail.problem.tags" :key="name" class="tag">{{ name }}</span>
|
||||
<span class="links">
|
||||
<a :href="links.problem" target="_blank" class="link">去题目 ›</a>
|
||||
<a :href="links.statistics" target="_blank" class="link">数据统计 ›</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div>
|
||||
<div class="muted small">做过</div>
|
||||
<div class="big num">{{ summary.users }}<span class="unit"> 人</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">一次就对</div>
|
||||
<div class="big num">{{ summary.firstRate }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">做对平均交</div>
|
||||
<div class="big num">{{ summary.avgTries }}<span class="unit"> 次</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">还没做对</div>
|
||||
<div class="big num" :class="{ red: summary.stuck }">
|
||||
{{ summary.stuck }}<span class="unit"> 人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorTotal" class="block">
|
||||
<div class="muted small">错在哪 · {{ errorTotal }} 次</div>
|
||||
<div class="errbar">
|
||||
<span
|
||||
v-for="row in detail.errors"
|
||||
:key="row.result"
|
||||
:style="{
|
||||
width: `${(row.count / errorTotal) * 100}%`,
|
||||
background: resultColor(row.result),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="line small num errtext">
|
||||
<span v-for="row in detail.errors" :key="row.result" class="legend">
|
||||
<i :style="{ background: resultColor(row.result) }" />{{
|
||||
resultName(row.result)
|
||||
}}
|
||||
{{ row.count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!className" class="block">
|
||||
<div class="muted small">各班</div>
|
||||
<div v-for="row in byClass" :key="row.name" class="crow">
|
||||
<span class="cname">{{ classLabel(row.name || null) }}</span>
|
||||
<span class="num cusers">{{ row.users }} 人</span>
|
||||
<span class="track mid"><span :style="{ width: `${row.rate}%` }" /></span>
|
||||
<span class="pct num">{{ row.rate }}%</span>
|
||||
<span class="muted num cavg">平均 {{ row.avgTries }} 次</span>
|
||||
<span class="grow" />
|
||||
<span v-if="row.stuck" class="stuck num">{{ row.stuck }} 人没对</span>
|
||||
<span v-else class="muted">都对了</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="block">
|
||||
<div class="muted small">交了几次才对</div>
|
||||
<div class="dist">
|
||||
<div v-for="row in distribution" :key="row.label" class="dcol">
|
||||
<span class="small num strong">{{ row.count }} 人</span>
|
||||
<span
|
||||
class="dbar"
|
||||
:class="{ first: row.min === 1, many: row.min === 6 }"
|
||||
:style="{ height: `${row.height}px` }"
|
||||
/>
|
||||
<span class="muted small">{{ row.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mostTries.length" class="block">
|
||||
<div class="muted small">交得最多的</div>
|
||||
<div class="names">
|
||||
<div v-for="row in mostTries" :key="row.username" class="nrow">
|
||||
<UserName :username="row.username" class="uname" />
|
||||
<span class="num strong ntries">交了 {{ row.tries }} 次</span>
|
||||
<span class="muted num">{{ gap(row.minutes) }}</span>
|
||||
<span class="grow" />
|
||||
<a
|
||||
:href="
|
||||
href({
|
||||
name: 'submissions',
|
||||
query: { problem: detail.problem.displayId, username: row.username },
|
||||
})
|
||||
"
|
||||
target="_blank"
|
||||
class="link small"
|
||||
>看代码</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="history.length" class="block">
|
||||
<div class="muted small">历年一次就对{{ className ? "(全站)" : "" }}</div>
|
||||
<div v-for="row in history" :key="row.label" class="hrow num">
|
||||
<span class="hlabel" :class="{ muted: !row.current }">
|
||||
{{ row.label }}{{ row.current ? " 这学年" : "" }}
|
||||
</span>
|
||||
<span class="track mid" :class="{ past: !row.current }"
|
||||
><span :style="{ width: `${row.rate}%` }"
|
||||
/></span>
|
||||
<span class="pct" :class="{ strong: row.current }">{{ row.rate }}%</span>
|
||||
<span class="muted small">{{ row.users }} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stuckStudents.length" class="block">
|
||||
<div class="line">
|
||||
<span class="muted small">还没做对的 {{ stuckStudents.length }} 人</span>
|
||||
<span class="grow" />
|
||||
<a :href="links.submissions" target="_blank" class="link small"
|
||||
>在提交列表里看 ›</a
|
||||
>
|
||||
</div>
|
||||
<div class="names">
|
||||
<div v-for="row in stuckStudents" :key="row.username" class="nrow">
|
||||
<UserName :username="row.username" class="uname" />
|
||||
<span class="muted num ntries">交了 {{ row.tries }} 次</span>
|
||||
<span class="legend" :style="{ color: resultColor(row.lastResult) }">
|
||||
<i :style="{ background: resultColor(row.lastResult) }" />{{
|
||||
resultName(row.lastResult)
|
||||
}}
|
||||
</span>
|
||||
<span class="grow" />
|
||||
<a
|
||||
:href="href({ path: `/submission/${row.lastSubmissionId}` })"
|
||||
target="_blank"
|
||||
class="link small"
|
||||
>看代码</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</section>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
.muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
}
|
||||
.seg button {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
margin-left: -1px;
|
||||
}
|
||||
.seg button:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
margin-left: 0;
|
||||
}
|
||||
.seg button:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.seg button.on {
|
||||
border-color: var(--learn-green);
|
||||
color: var(--learn-green-text);
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--learn-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.know {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.problems {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scroll {
|
||||
max-height: calc(100vh - 210px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
padding: 9px 16px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--learn-line);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.item:hover {
|
||||
background: var(--learn-soft);
|
||||
}
|
||||
.item.on {
|
||||
background: var(--learn-green-bg);
|
||||
box-shadow: inset 3px 0 0 var(--learn-green);
|
||||
}
|
||||
.item.on .name,
|
||||
.item.on .ptitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.indent {
|
||||
padding-left: 42px;
|
||||
gap: 8px;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.pid {
|
||||
width: 34px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.stuck {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--learn-red-text);
|
||||
}
|
||||
.track {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 170px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--learn-todo);
|
||||
overflow: hidden;
|
||||
}
|
||||
.track > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--learn-green);
|
||||
}
|
||||
.know .track {
|
||||
width: 130px;
|
||||
}
|
||||
.track.short {
|
||||
width: 90px;
|
||||
}
|
||||
.track.mid {
|
||||
width: 90px;
|
||||
}
|
||||
.track.past > span {
|
||||
background: var(--learn-green-line);
|
||||
}
|
||||
.pct {
|
||||
width: 38px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.none {
|
||||
padding: 24px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-body {
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.dhead {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
.dtitle {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tag {
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
background: var(--learn-soft);
|
||||
}
|
||||
.link {
|
||||
flex-shrink: 0;
|
||||
color: var(--learn-green-text);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.link.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.big {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.big.red {
|
||||
color: var(--learn-red-text);
|
||||
}
|
||||
.unit {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.errbar {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
max-width: 440px;
|
||||
height: 10px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.errtext {
|
||||
gap: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.legend i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.crow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--learn-line);
|
||||
font-size: 13px;
|
||||
}
|
||||
.cname {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cusers {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
.cavg {
|
||||
width: 72px;
|
||||
}
|
||||
.dist {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.dcol {
|
||||
width: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.dbar {
|
||||
width: 40px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: var(--learn-green-line);
|
||||
}
|
||||
.dbar.first {
|
||||
background: var(--learn-green);
|
||||
}
|
||||
.dbar.many {
|
||||
background: var(--learn-orange);
|
||||
}
|
||||
.names {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||
column-gap: 24px;
|
||||
}
|
||||
.nrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
}
|
||||
.uname {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ntries {
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.hlabel {
|
||||
width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import type { YearlyOverview, YearlySummary } from "utils/types"
|
||||
import { getYearlyOverview } from "admin/api"
|
||||
import { parseTime } from "utils/functions"
|
||||
import "oj/learn/learn.css"
|
||||
|
||||
/**
|
||||
* 历年概况(原「年度趋势」,设计稿「卡点与趋势」页的定稿 TrendFinal):一个学年一列。
|
||||
*
|
||||
* 学年从 9 月 1 日算起(原来按自然年切,一个学年被切成两半)。进行中的学年只有几个月,
|
||||
* 拿它和整年比没有意义,所以旁边并排一列「去年同期」。单道题的历年变化挪进了卡点分析的右栏。
|
||||
* 口径(人 × 题、一次就对、做对之前交了几次)见后端 routes/admin/analytics.ts。
|
||||
*/
|
||||
const data = ref<YearlyOverview | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
interface Column {
|
||||
key: string
|
||||
title: string
|
||||
sub: string
|
||||
kind: "full" | "current" | "last"
|
||||
row: YearlySummary
|
||||
}
|
||||
|
||||
function yearLabel(year: number) {
|
||||
return `${String(year).slice(2)}–${String(year + 1).slice(2)}`
|
||||
}
|
||||
|
||||
/** `until` 是开区间的终点,显示成「到 X 月 X 日」得往前退一毫秒 */
|
||||
function untilText(until: string) {
|
||||
return `到 ${parseTime(new Date(Date.parse(until) - 1), "M月D日")}`
|
||||
}
|
||||
|
||||
const columns = computed<Column[]>(() => {
|
||||
if (!data.value) return []
|
||||
const years = data.value.years
|
||||
const out: Column[] = years.map((row, index) => {
|
||||
const current = index === years.length - 1
|
||||
return {
|
||||
key: String(row.year),
|
||||
title: yearLabel(row.year),
|
||||
sub: current ? untilText(row.until) : "",
|
||||
kind: current ? "current" : "full",
|
||||
row,
|
||||
}
|
||||
})
|
||||
const last = data.value.lastYearToDate
|
||||
if (last)
|
||||
out.push({
|
||||
key: "last",
|
||||
title: "去年同期",
|
||||
sub: `${yearLabel(last.year)} ${untilText(last.until)}`,
|
||||
kind: "last",
|
||||
row: last,
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
function ratio(part: number, whole: number, digits = 0) {
|
||||
return whole ? Number(((part / whole) * (digits ? 1 : 100)).toFixed(digits)) : 0
|
||||
}
|
||||
|
||||
interface Line {
|
||||
label: string
|
||||
/** 给人看的 */
|
||||
text: (row: YearlySummary) => string
|
||||
/** 画小横条用的值,只给数量类的行 */
|
||||
value?: (row: YearlySummary) => number
|
||||
}
|
||||
|
||||
const LINES: Line[] = [
|
||||
{
|
||||
label: "在用的年级",
|
||||
text: (row) => (row.grades.length ? row.grades.map((g) => `${g}`).join(" · ") + " 级" : "-"),
|
||||
},
|
||||
{ label: "做题的学生", text: (row) => `${row.users} 人`, value: (row) => row.users },
|
||||
{
|
||||
label: "提交",
|
||||
text: (row) => `${row.submissions.toLocaleString("en-US")} 次`,
|
||||
value: (row) => row.submissions,
|
||||
},
|
||||
{ label: "做过的题", text: (row) => `${row.problems} 道`, value: (row) => row.problems },
|
||||
{
|
||||
label: "人均做对",
|
||||
text: (row) => `${ratio(row.solvedPairs, row.users, 1)} 道`,
|
||||
value: (row) => ratio(row.solvedPairs, row.users, 1),
|
||||
},
|
||||
{ label: "一次就对", text: (row) => `${ratio(row.firstTryPairs, row.startedPairs)}%` },
|
||||
{
|
||||
label: "做对平均交",
|
||||
text: (row) => (row.solvedStarted ? `${ratio(row.solvedTries, row.solvedStarted, 1)} 次` : "-"),
|
||||
},
|
||||
{ label: "C / C++ 提交", text: (row) => `${ratio(row.cSubmissions, row.submissions)}%` },
|
||||
]
|
||||
|
||||
/** 小横条只在完整学年之间比,进行中的和去年同期都只有几个月 */
|
||||
function barWidth(line: Line, column: Column) {
|
||||
if (!line.value || column.kind !== "full") return null
|
||||
const max = Math.max(
|
||||
1,
|
||||
...columns.value.filter((c) => c.kind === "full").map((c) => line.value!(c.row)),
|
||||
)
|
||||
return `${(line.value(column.row) / max) * 100}%`
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const quote = (text: string) => `"${text.replaceAll('"', '""')}"`
|
||||
const head = ["", ...columns.value.map((c) => [c.title, c.sub].filter(Boolean).join(" "))]
|
||||
const rows = LINES.map((line) => [line.label, ...columns.value.map((c) => line.text(c.row))])
|
||||
const csv = [head, ...rows].map((row) => row.map(quote).join(",")).join("\r\n")
|
||||
// 带 BOM,Excel 才按 UTF-8 打开,不然中文是乱码
|
||||
const url = URL.createObjectURL(new Blob(["" + csv], { type: "text/csv;charset=utf-8" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = "历年概况.csv"
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await getYearlyOverview()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="top">
|
||||
<span class="title">历年概况</span>
|
||||
<span class="grow" />
|
||||
<n-button :disabled="!columns.length" @click="exportCsv">导出表格</n-button>
|
||||
</div>
|
||||
<n-spin :show="loading">
|
||||
<section class="card">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="label muted">学年(9 月起)</th>
|
||||
<th v-for="column in columns" :key="column.key" :class="column.kind">
|
||||
<div>{{ column.title }}</div>
|
||||
<div v-if="column.sub" class="sub">{{ column.sub }}</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="line in LINES" :key="line.label">
|
||||
<td class="label">{{ line.label }}</td>
|
||||
<td v-for="column in columns" :key="column.key" :class="column.kind" class="num">
|
||||
<div>{{ line.text(column.row) }}</div>
|
||||
<div v-if="barWidth(line, column)" class="track">
|
||||
<span :style="{ width: barWidth(line, column)! }" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
.muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--learn-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
min-width: 900px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0 16px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
height: 56px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
vertical-align: bottom;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
td {
|
||||
height: 52px;
|
||||
border-top: 1px solid var(--learn-line);
|
||||
}
|
||||
.label {
|
||||
text-align: left;
|
||||
width: 130px;
|
||||
}
|
||||
th.label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.current {
|
||||
font-weight: 700;
|
||||
border-left: 1px solid var(--learn-line);
|
||||
}
|
||||
.last {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.track {
|
||||
margin: 4px 0 0 auto;
|
||||
width: 88px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--learn-todo);
|
||||
overflow: hidden;
|
||||
}
|
||||
.track > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--learn-green-line);
|
||||
}
|
||||
</style>
|
||||
@@ -310,11 +310,13 @@ export const admins: RouteRecordRaw = {
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "problem/top_ac_trend",
|
||||
name: "admin top ac trend",
|
||||
component: () => import("admin/problem/TopACTrend.vue"),
|
||||
path: "problem/yearly",
|
||||
name: "admin yearly overview",
|
||||
component: () => import("admin/problem/Yearly.vue"),
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
// 「年度趋势」改名「历年概况」之前的地址,收藏夹里可能还有
|
||||
{ path: "problem/top_ac_trend", redirect: "/admin/problem/yearly" },
|
||||
// 题单管理路由
|
||||
{
|
||||
path: "problemset/list",
|
||||
|
||||
@@ -85,9 +85,9 @@ const NAV: NavGroup[] = [
|
||||
role: "teacher",
|
||||
},
|
||||
{
|
||||
label: "年度趋势",
|
||||
label: "历年概况",
|
||||
icon: "ph:chart-line-up",
|
||||
path: "/admin/problem/top_ac_trend",
|
||||
path: "/admin/problem/yearly",
|
||||
role: "teacher",
|
||||
},
|
||||
{ label: "自学情况", icon: "ph:student", path: "/admin/learn", role: "teacher" },
|
||||
|
||||
@@ -328,7 +328,10 @@ export type {
|
||||
AdminAiReportListItem,
|
||||
AdminAiReportList,
|
||||
StuckProblem,
|
||||
AcTrend,
|
||||
StuckProblemDetail,
|
||||
StuckStudent,
|
||||
YearlyOverview,
|
||||
YearlySummary,
|
||||
LearnBoard,
|
||||
LearnBoardStudent,
|
||||
LearnExerciseProgress,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user