Compare commits
2
Commits
0980ea858a
...
cd75d42a64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd75d42a64 | ||
|
|
318ffac90f |
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 学情分析里的「卡点分析」和「历年概况」。
|
* 学情分析里的「卡点分析」和「年度趋势」(这届和往届比)。
|
||||||
*
|
*
|
||||||
* 两边共用一个口径:**人 × 题**。原来的卡点数的是「错过一次的人」,全部时间、不分班 ——
|
* 两边共用一个口径:**人 × 题**。原来的卡点数的是「错过一次的人」,全部时间、不分班 ——
|
||||||
* 阿福讲白搭显示 325 人卡住,交过的 782 人里其实 753 人后来做对了,排在前面的只是做的人多。
|
* 阿福讲白搭显示 325 人卡住,交过的 782 人里其实 753 人后来做对了,排在前面的只是做的人多。
|
||||||
@@ -20,8 +20,7 @@ import {
|
|||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
type StuckProblem,
|
type StuckProblem,
|
||||||
type StuckProblemDetail,
|
type StuckProblemDetail,
|
||||||
type YearlyOverview,
|
type GradeTrendProblem,
|
||||||
type YearlySummary,
|
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, eq, inArray, sql, type SQL } from "drizzle-orm"
|
import { and, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -30,7 +29,7 @@ import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
|||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, success } from "../../http"
|
import { failure, success } from "../../http"
|
||||||
import { JudgeStatus } from "../../judge/status"
|
import { JudgeStatus } from "../../judge/status"
|
||||||
import { schoolYearOf, schoolYearStart, schoolYearStartOf, shiftMonthsByCalendar } from "../../time"
|
import { schoolYearOf } from "../../time"
|
||||||
import { queryInteger } from "../helpers"
|
import { queryInteger } from "../helpers"
|
||||||
|
|
||||||
export const adminAnalyticsRoutes = new Hono<AppEnv>()
|
export const adminAnalyticsRoutes = new Hono<AppEnv>()
|
||||||
@@ -242,126 +241,47 @@ adminAnalyticsRoutes.get("/problem-analytics/stuck/:id", requireTeacher, async (
|
|||||||
} satisfies StuckProblemDetail)
|
} satisfies StuckProblemDetail)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 历年概况
|
// ---------------------------------------------------------------- 年度趋势:这届和往届比
|
||||||
|
|
||||||
/** 「在用的年级」:这段时间里至少这么多人交过题的年级才列 */
|
/** 一个格子(题 × 届)至少这么多人做过才给,再少的前端也不会拿来比 */
|
||||||
const GRADE_MIN_USERS = 20
|
const GRADE_CELL_MIN_USERS = 5
|
||||||
|
|
||||||
adminAnalyticsRoutes.get("/problem-analytics/yearly", requireTeacher, async (c) => {
|
/**
|
||||||
const now = new Date()
|
* 每道题每一届的「一次就对」。届 = 班号前两位(253 → 25 级),没有班级或班号不是数字的不算。
|
||||||
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
|
adminAnalyticsRoutes.get("/problem-analytics/grades", requireTeacher, async (c) => {
|
||||||
`)
|
|
||||||
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<{
|
const rows = await db.execute<{
|
||||||
key: number
|
id: number
|
||||||
|
display_id: string
|
||||||
|
title: string
|
||||||
|
grade: string
|
||||||
users: number
|
users: number
|
||||||
submissions: number
|
first_try: 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`
|
}>(sql`
|
||||||
with ranges(key, f, t) as (values ${values}),
|
with s as (${studentSubmissions([sql`u.class_name ~ '^[0-9]{3,}$'`])}),
|
||||||
s as (${studentSubmissions([])}),
|
|
||||||
pairs as (
|
pairs as (
|
||||||
select min(create_time) as first_at, min(ac_at) as ac_at,
|
select problem_id, left(min(class_name), 2) as grade,
|
||||||
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try,
|
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
|
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,
|
select p.id, p._id as display_id, p.title, pairs.grade,
|
||||||
coalesce(sub.problems, 0) as problems, coalesce(sub.c_submissions, 0) as c_submissions,
|
count(*)::int as users, count(*) filter (where first_try)::int as first_try
|
||||||
coalesce(ps.solved_pairs, 0) as solved_pairs, coalesce(ps.started_pairs, 0) as started_pairs,
|
from pairs join ${schema.problem} p on p.id = pairs.problem_id
|
||||||
coalesce(ps.first_try_pairs, 0) as first_try_pairs,
|
group by p.id, pairs.grade
|
||||||
coalesce(ps.solved_started, 0) as solved_started,
|
having count(*) >= ${GRADE_CELL_MIN_USERS}
|
||||||
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 problems = new Map<number, GradeTrendProblem>()
|
||||||
const byKey = new Map(rows.map((row) => [row.key, row]))
|
for (const row of rows) {
|
||||||
const summary = (range: (typeof ranges)[number]): YearlySummary => {
|
const problem = problems.get(row.id) ?? {
|
||||||
const row = byKey.get(range.key)
|
id: row.id,
|
||||||
return {
|
displayId: row.display_id,
|
||||||
year: range.year,
|
title: row.title,
|
||||||
until: range.to,
|
grades: [],
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
problem.grades.push({ grade: row.grade, users: row.users, firstTry: row.first_try })
|
||||||
|
problems.set(row.id, problem)
|
||||||
}
|
}
|
||||||
const lastYear = ranges.find((range) => range.key === -1)
|
return success(c, [...problems.values()] satisfies GradeTrendProblem[])
|
||||||
return success(c, {
|
|
||||||
years: ranges.filter((range) => range.key !== -1).map(summary),
|
|
||||||
lastYearToDate: lastYear ? summary(lastYear) : null,
|
|
||||||
} satisfies YearlyOverview)
|
|
||||||
})
|
})
|
||||||
+1
-16
@@ -95,21 +95,6 @@ export function termStart(now: Date | number | string = new Date()): string {
|
|||||||
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
|
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 {
|
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
|
||||||
const wall = toWallClock(instant)
|
const wall = toWallClock(instant)
|
||||||
@@ -137,7 +122,7 @@ export function localTime(column: SQLWrapper) {
|
|||||||
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
|
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 时刻所在学年的起始年(9 月起算),和 `schoolYearStart()` 同一个口径 */
|
/** 时刻所在学年的起始年:9 月 1 日起算,2025 = 25–26 学年 */
|
||||||
export function schoolYearOf(column: SQLWrapper) {
|
export function schoolYearOf(column: SQLWrapper) {
|
||||||
return sql<number>`extract(year from ${localTime(column)} - interval '8 months')::int`
|
return sql<number>`extract(year from ${localTime(column)} - interval '8 months')::int`
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
AdminAiReportList,
|
AdminAiReportList,
|
||||||
StuckProblem,
|
StuckProblem,
|
||||||
StuckProblemDetail,
|
StuckProblemDetail,
|
||||||
YearlyOverview,
|
GradeTrendProblem,
|
||||||
LearnBoard,
|
LearnBoard,
|
||||||
LearnExerciseProgressList,
|
LearnExerciseProgressList,
|
||||||
LearnExerciseAttempt,
|
LearnExerciseAttempt,
|
||||||
@@ -624,8 +624,8 @@ export function getLearnExerciseAttempts(exerciseId: number, params: { className
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getYearlyOverview() {
|
export function getGradeTrend() {
|
||||||
return api.get<YearlyOverview>("admin/problem-analytics/yearly")
|
return api.get<GradeTrendProblem[]>("admin/problem-analytics/grades")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 学习分析报告
|
// AI 学习分析报告
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
|
import type { GradeTrendProblem } from "utils/types"
|
||||||
|
import { getGradeTrend } from "admin/api"
|
||||||
|
import "oj/learn/learn.css"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 年度趋势(设计稿「卡点与趋势」页的 TrendB):选一届,和往届(或另一届)在两边都做过的题上
|
||||||
|
* 逐题比「一次就对」,比往届差的排前面。
|
||||||
|
*
|
||||||
|
* 按届(班号前两位)比而不是按自然年:一个学年里同时有两三届在做题,按年切会把它们搅在一起。
|
||||||
|
* 往届是把更早几届的人合在一起算(人多的届分量大),不是几个百分比再平均。
|
||||||
|
* 比输赢按显示出来的整数百分比,差 2 个点以内算差不多。
|
||||||
|
*/
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** 每边至少这么多人做过,这道题才拿来比 */
|
||||||
|
const MIN_USERS = 20
|
||||||
|
const SAME = 2
|
||||||
|
const PAST = "past"
|
||||||
|
|
||||||
|
const problems = ref<GradeTrendProblem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const grade = useRouteQuery<string>("grade", "", { mode: "replace" })
|
||||||
|
const compare = useRouteQuery<string>("compare", PAST, { mode: "replace" })
|
||||||
|
|
||||||
|
/** 有数据的届,新的在前 */
|
||||||
|
const grades = computed(() =>
|
||||||
|
[...new Set(problems.value.flatMap((row) => row.grades.map((cell) => cell.grade)))].sort((a, b) =>
|
||||||
|
b.localeCompare(a),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function side(row: GradeTrendProblem, pick: (g: string) => boolean) {
|
||||||
|
return row.grades
|
||||||
|
.filter((cell) => pick(cell.grade))
|
||||||
|
.reduce(
|
||||||
|
(sum, cell) => ({ users: sum.users + cell.users, firstTry: sum.firstTry + cell.firstTry }),
|
||||||
|
{
|
||||||
|
users: 0,
|
||||||
|
firstTry: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rate(part: number, whole: number) {
|
||||||
|
return whole ? Math.round((part / whole) * 100) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRows(current: string, other: string) {
|
||||||
|
const isOther = other === PAST ? (g: string) => g < current : (g: string) => g === other
|
||||||
|
return problems.value
|
||||||
|
.map((row) => {
|
||||||
|
const mine = side(row, (g) => g === current)
|
||||||
|
const theirs = side(row, isOther)
|
||||||
|
const now = rate(mine.firstTry, mine.users)
|
||||||
|
const before = rate(theirs.firstTry, theirs.users)
|
||||||
|
return { row, mine, theirs, now, before, delta: now - before }
|
||||||
|
})
|
||||||
|
.filter((item) => item.mine.users >= MIN_USERS && item.theirs.users >= MIN_USERS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 没选就挑最新的一届里能和往届比上至少 5 道题的 */
|
||||||
|
const selected = computed(() => {
|
||||||
|
if (grades.value.includes(grade.value)) return grade.value
|
||||||
|
return grades.value.find((g) => compareRows(g, PAST).length >= 5) ?? grades.value[0] ?? ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const pastGrades = computed(() => grades.value.filter((g) => g < selected.value))
|
||||||
|
const pastLabel = computed(() => {
|
||||||
|
const list = pastGrades.value
|
||||||
|
if (!list.length) return "往届"
|
||||||
|
return list.length === 1 ? `往届(${list[0]} 级)` : `往届(${list.at(-1)}–${list[0]} 级)`
|
||||||
|
})
|
||||||
|
const otherLabel = computed(() =>
|
||||||
|
compare.value === PAST ? pastLabel.value : `${compare.value} 级`,
|
||||||
|
)
|
||||||
|
const shortOther = computed(() => (compare.value === PAST ? "往届" : `${compare.value} 级`))
|
||||||
|
|
||||||
|
const gradeOptions = computed(() => grades.value.map((g) => ({ label: `${g} 级`, value: g })))
|
||||||
|
const compareOptions = computed(() => [
|
||||||
|
{ label: pastLabel.value, value: PAST },
|
||||||
|
...grades.value.filter((g) => g !== selected.value).map((g) => ({ label: `${g} 级`, value: g })),
|
||||||
|
])
|
||||||
|
|
||||||
|
const rows = computed(() => (selected.value ? compareRows(selected.value, compare.value) : []))
|
||||||
|
const worse = computed(() =>
|
||||||
|
rows.value.filter((item) => item.delta < -SAME).sort((a, b) => a.delta - b.delta),
|
||||||
|
)
|
||||||
|
const same = computed(() =>
|
||||||
|
rows.value.filter((item) => Math.abs(item.delta) <= SAME).sort((a, b) => a.delta - b.delta),
|
||||||
|
)
|
||||||
|
const better = computed(() =>
|
||||||
|
rows.value.filter((item) => item.delta > SAME).sort((a, b) => b.delta - a.delta),
|
||||||
|
)
|
||||||
|
|
||||||
|
const summary = computed(() => {
|
||||||
|
const total = rows.value.reduce(
|
||||||
|
(sum, item) => ({
|
||||||
|
mineUsers: sum.mineUsers + item.mine.users,
|
||||||
|
mineFirst: sum.mineFirst + item.mine.firstTry,
|
||||||
|
theirUsers: sum.theirUsers + item.theirs.users,
|
||||||
|
theirFirst: sum.theirFirst + item.theirs.firstTry,
|
||||||
|
}),
|
||||||
|
{ mineUsers: 0, mineFirst: 0, theirUsers: 0, theirFirst: 0 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
now: rate(total.mineFirst, total.mineUsers),
|
||||||
|
before: rate(total.theirFirst, total.theirUsers),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = computed(() =>
|
||||||
|
[
|
||||||
|
{ key: "worse", label: `比${shortOther.value}差`, items: worse.value },
|
||||||
|
{ key: "same", label: "差不多", items: same.value },
|
||||||
|
{ key: "better", label: `比${shortOther.value}好`, items: better.value },
|
||||||
|
].filter((group) => group.items.length),
|
||||||
|
)
|
||||||
|
|
||||||
|
function tone(delta: number) {
|
||||||
|
return delta < -SAME ? "worse" : delta > SAME ? "better" : "same"
|
||||||
|
}
|
||||||
|
|
||||||
|
function problemHref(displayId: string) {
|
||||||
|
return router.resolve({ name: "problem", params: { problemID: displayId } }).href
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickGrade(value: string) {
|
||||||
|
grade.value = value
|
||||||
|
if (compare.value === value) compare.value = PAST
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
problems.value = await getGradeTrend()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="top">
|
||||||
|
<span class="title">年度趋势</span>
|
||||||
|
<span class="grow" />
|
||||||
|
<span class="muted">比</span>
|
||||||
|
<n-select
|
||||||
|
:value="selected || null"
|
||||||
|
:options="gradeOptions"
|
||||||
|
style="width: 100px"
|
||||||
|
@update:value="pickGrade"
|
||||||
|
/>
|
||||||
|
<span class="muted">和</span>
|
||||||
|
<n-select
|
||||||
|
:value="compare"
|
||||||
|
:options="compareOptions"
|
||||||
|
style="width: 170px"
|
||||||
|
@update:value="(value: string) => (compare = value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="label">两边都做过的题</div>
|
||||||
|
<div class="value num">{{ rows.length }}<span class="unit"> 道</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="label">一次就对</div>
|
||||||
|
<div class="value num">
|
||||||
|
{{ summary.now }}%<span class="unit"> / {{ shortOther }} {{ summary.before }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="label">比{{ shortOther }}差</div>
|
||||||
|
<div class="value num worse-text">{{ worse.length }}<span class="unit"> 道</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="label">比{{ shortOther }}好</div>
|
||||||
|
<div class="value num better-text">
|
||||||
|
{{ better.length }}<span class="unit"> 道</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="row axis">
|
||||||
|
<span class="c-title" />
|
||||||
|
<span class="c-track ticks num">
|
||||||
|
<span v-for="tick in [0, 25, 50, 75, 100]" :key="tick" :style="{ left: `${tick}%` }"
|
||||||
|
>{{ tick }}%</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="legend">
|
||||||
|
<span><i class="dot-now" />{{ selected }} 级</span>
|
||||||
|
<span><i class="dot-before" />{{ shortOther }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<template v-for="group in groups" :key="group.key">
|
||||||
|
<div class="group-head">
|
||||||
|
<span class="strong" :class="`${group.key}-text`">{{ group.label }}</span>
|
||||||
|
<span class="muted small num">{{ group.items.length }} 道</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="item in group.items" :key="item.row.id" class="row item">
|
||||||
|
<a :href="problemHref(item.row.displayId)" target="_blank" class="c-title">
|
||||||
|
<span class="muted num pid">{{ item.row.displayId }}</span>
|
||||||
|
<span class="ptitle">{{ item.row.title }}</span>
|
||||||
|
</a>
|
||||||
|
<span class="c-track" :class="tone(item.delta)">
|
||||||
|
<span class="rail" />
|
||||||
|
<span
|
||||||
|
class="span"
|
||||||
|
:style="{
|
||||||
|
left: `${Math.min(item.now, item.before)}%`,
|
||||||
|
width: `${Math.abs(item.delta)}%`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<span class="before" :style="{ left: `${item.before}%` }" />
|
||||||
|
<span class="now" :style="{ left: `${item.now}%` }" />
|
||||||
|
</span>
|
||||||
|
<span class="c-values num">
|
||||||
|
<b :class="`${tone(item.delta)}-text`">{{ item.now }}%</b>
|
||||||
|
<span class="muted"> / {{ item.before }}%</span>
|
||||||
|
</span>
|
||||||
|
<span class="c-delta num strong" :class="`${tone(item.delta)}-text`">
|
||||||
|
{{ item.delta > 0 ? "+" : "" }}{{ item.delta }}
|
||||||
|
</span>
|
||||||
|
<span class="c-users muted small num">
|
||||||
|
{{ selected }} 级 {{ item.mine.users }} 人 · {{ shortOther }}
|
||||||
|
{{ item.theirs.users }} 人
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<n-empty
|
||||||
|
v-else-if="!loading"
|
||||||
|
:description="`${selected ? `${selected} 级` : '这一届'}和${otherLabel}没有都做过(每边 ${MIN_USERS} 人以上)的题`"
|
||||||
|
style="margin: 60px 0"
|
||||||
|
/>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
--worse: #d9730d;
|
||||||
|
--worse-text: #b35c00;
|
||||||
|
--better: #2080f0;
|
||||||
|
--better-text: #1a66c2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
html.dark .page {
|
||||||
|
--worse-text: #f2a65a;
|
||||||
|
--better-text: #70b0ff;
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.worse-text {
|
||||||
|
color: var(--worse-text);
|
||||||
|
}
|
||||||
|
.better-text {
|
||||||
|
color: var(--better-text);
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
border: 1px solid var(--learn-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.unit {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--learn-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 0 6px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.axis {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
height: 38px;
|
||||||
|
border-top: 1px solid var(--learn-line);
|
||||||
|
}
|
||||||
|
.group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px 6px;
|
||||||
|
}
|
||||||
|
.c-title {
|
||||||
|
width: 270px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a.c-title:hover .ptitle {
|
||||||
|
color: var(--learn-green-text);
|
||||||
|
}
|
||||||
|
.pid {
|
||||||
|
width: 34px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ptitle {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.c-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 1 420px;
|
||||||
|
min-width: 200px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.ticks > span {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
width: 36px;
|
||||||
|
margin-left: -18px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.rail {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 9px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--learn-line);
|
||||||
|
}
|
||||||
|
.span {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0.35;
|
||||||
|
background: var(--learn-line);
|
||||||
|
}
|
||||||
|
.worse .span,
|
||||||
|
.worse .now {
|
||||||
|
background: var(--worse);
|
||||||
|
}
|
||||||
|
.better .span,
|
||||||
|
.better .now {
|
||||||
|
background: var(--better);
|
||||||
|
}
|
||||||
|
.before {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid rgba(128, 128, 128, 0.8);
|
||||||
|
background: var(--learn-pane);
|
||||||
|
}
|
||||||
|
.now {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-left: -7px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(128, 128, 128, 0.8);
|
||||||
|
}
|
||||||
|
.c-values {
|
||||||
|
width: 92px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.c-delta {
|
||||||
|
width: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.c-users {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
margin-left: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.legend > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.dot-now {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--worse);
|
||||||
|
}
|
||||||
|
.dot-before {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid rgba(128, 128, 128, 0.8);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
<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,13 +310,11 @@ export const admins: RouteRecordRaw = {
|
|||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "problem/yearly",
|
path: "problem/trend",
|
||||||
name: "admin yearly overview",
|
name: "admin grade trend",
|
||||||
component: () => import("admin/problem/Yearly.vue"),
|
component: () => import("admin/problem/Trend.vue"),
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
// 「年度趋势」改名「历年概况」之前的地址,收藏夹里可能还有
|
|
||||||
{ path: "problem/top_ac_trend", redirect: "/admin/problem/yearly" },
|
|
||||||
// 题单管理路由
|
// 题单管理路由
|
||||||
{
|
{
|
||||||
path: "problemset/list",
|
path: "problemset/list",
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ const NAV: NavGroup[] = [
|
|||||||
role: "teacher",
|
role: "teacher",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "历年概况",
|
label: "年度趋势",
|
||||||
icon: "ph:chart-line-up",
|
icon: "ph:chart-line-up",
|
||||||
path: "/admin/problem/yearly",
|
path: "/admin/problem/trend",
|
||||||
role: "teacher",
|
role: "teacher",
|
||||||
},
|
},
|
||||||
{ label: "自学情况", icon: "ph:student", path: "/admin/learn", role: "teacher" },
|
{ label: "自学情况", icon: "ph:student", path: "/admin/learn", role: "teacher" },
|
||||||
|
|||||||
@@ -330,8 +330,7 @@ export type {
|
|||||||
StuckProblem,
|
StuckProblem,
|
||||||
StuckProblemDetail,
|
StuckProblemDetail,
|
||||||
StuckStudent,
|
StuckStudent,
|
||||||
YearlyOverview,
|
GradeTrendProblem,
|
||||||
YearlySummary,
|
|
||||||
LearnBoard,
|
LearnBoard,
|
||||||
LearnBoardStudent,
|
LearnBoardStudent,
|
||||||
LearnExerciseProgress,
|
LearnExerciseProgress,
|
||||||
|
|||||||
@@ -584,31 +584,14 @@ export const stuckProblemDetailSchema = z.object({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const yearlySummarySchema = z.object({
|
/** 年度趋势:一道题在各届(班号前两位)的「一次就对」,只给至少 5 人做过的格子 */
|
||||||
/** 学年起始年,2025 = 25–26 学年 */
|
export const gradeTrendProblemSchema = z.object({
|
||||||
year: z.number().int(),
|
id: z.number().int(),
|
||||||
/** 统计到哪天为止(不含),进行中的学年和去年同期才有意义 */
|
displayId: z.string(),
|
||||||
until: z.string(),
|
title: z.string(),
|
||||||
grades: z.array(z.string()),
|
grades: z.array(
|
||||||
users: z.number().int(),
|
z.object({ grade: z.string(), users: z.number().int(), firstTry: 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 yearlyOverviewSchema = z.object({
|
|
||||||
/** 从最早的学年到现在这个学年(最后一个是进行中的,只算到今天) */
|
|
||||||
years: z.array(yearlySummarySchema),
|
|
||||||
/** 去年同期:上一个学年开学到「去年的今天」 */
|
|
||||||
lastYearToDate: yearlySummarySchema.nullable(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 自学情况
|
// ---------------------------------------------------------------- 自学情况
|
||||||
@@ -1000,8 +983,7 @@ export type AdminAiReportList = z.infer<typeof adminAiReportListSchema>
|
|||||||
export type StuckProblem = z.infer<typeof stuckProblemSchema>
|
export type StuckProblem = z.infer<typeof stuckProblemSchema>
|
||||||
export type StuckStudent = z.infer<typeof stuckStudentSchema>
|
export type StuckStudent = z.infer<typeof stuckStudentSchema>
|
||||||
export type StuckProblemDetail = z.infer<typeof stuckProblemDetailSchema>
|
export type StuckProblemDetail = z.infer<typeof stuckProblemDetailSchema>
|
||||||
export type YearlySummary = z.infer<typeof yearlySummarySchema>
|
export type GradeTrendProblem = z.infer<typeof gradeTrendProblemSchema>
|
||||||
export type YearlyOverview = z.infer<typeof yearlyOverviewSchema>
|
|
||||||
export type LearnBoard = z.infer<typeof learnBoardSchema>
|
export type LearnBoard = z.infer<typeof learnBoardSchema>
|
||||||
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
|
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
|
||||||
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
||||||
|
|||||||
Reference in new issue
Block a user