feat(年度趋势): 后台年度趋势改成「这届和往届比」,比往届差的题排在最前面
Deploy / deploy (push) Canceled after 0s

- 选一届和往届(或另一届)比,两边都至少 20 人做过的题才比;分比往届差 / 差不多 / 比往届好三组,一题一根哑铃条
- 侧栏「历年概况」改回「年度趋势」,地址换成 /admin/problem/trend

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 13:10:18 +08:00
1 parent 318ffac90f
commit cd75d42a64
5 files changed
+393 -186

No files matched your search

+3 -3
View File
@@ -9,7 +9,7 @@ import type {
AdminAiReportList,
StuckProblem,
StuckProblemDetail,
YearlyOverview,
GradeTrendProblem,
LearnBoard,
LearnExerciseProgressList,
LearnExerciseAttempt,
@@ -624,8 +624,8 @@ export function getLearnExerciseAttempts(exerciseId: number, params: { className
})
}
export function getYearlyOverview() {
return api.get<YearlyOverview>("admin/problem-analytics/yearly")
export function getGradeTrend() {
return api.get<GradeTrendProblem[]>("admin/problem-analytics/grades")
}
// AI 学习分析报告
+384 -174
View File
@@ -1,126 +1,140 @@
<script setup lang="ts">
import type { YearlyOverview, YearlySummary } from "utils/types"
import { getYearlyOverview } from "admin/api"
import { parseTime } from "utils/functions"
import { useRouteQuery } from "@vueuse/router"
import type { GradeTrendProblem } from "utils/types"
import { getGradeTrend } from "admin/api"
import "oj/learn/learn.css"
/**
* 历年概况(原「年度趋势」,设计稿「卡点与趋势」页的定稿 TrendFinal):一个学年一列。
* 年度趋势(设计稿「卡点与趋势」页的 TrendB):选一届,和往届(或另一届)在两边都做过的题上
* 逐题比「一次就对」,比往届差的排前面。
*
* 学年从 9 月 1 日算起(原来按自然年切,一个学年被切成两半)。进行中的学年只有几个月,
* 拿它和整年比没有意义,所以旁边并排一列「去年同期」。单道题的历年变化挪进了卡点分析的右栏。
* 口径(人 × 题、一次就对、做对之前交了几次)见后端 routes/admin/analytics.ts。
* 按届(班号前两位)比而不是按自然年:一个学年里同时有两三届在做题,按年切会把它们搅在一起。
* 往届是把更早几届的人合在一起算(人多的届分量大),不是几个百分比再平均。
* 比输赢按显示出来的整数百分比,差 2 个点以内算差不多。
*/
const data = ref<YearlyOverview | null>(null)
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" })
interface Column {
key: string
title: string
sub: string
kind: "full" | "current" | "last"
row: YearlySummary
}
/** 有数据的届,新的在前 */
const grades = computed(() =>
[...new Set(problems.value.flatMap((row) => row.grades.map((cell) => cell.grade)))].sort((a, b) =>
b.localeCompare(a),
),
)
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 },
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 }),
{
label: "人均做对",
text: (row) => `${ratio(row.solvedPairs, row.users, 1)} 道`,
value: (row) => ratio(row.solvedPairs, row.users, 1),
users: 0,
firstTry: 0,
},
{ 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)
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 {
data.value = await getYearlyOverview()
problems.value = await getGradeTrend()
} finally {
loading.value = false
}
@@ -130,51 +144,126 @@ onMounted(async () => {
<template>
<div class="page">
<div class="top">
<span class="title">历年概况</span>
<span class="title">年度趋势</span>
<span class="grow" />
<n-button :disabled="!columns.length" @click="exportCsv">导出表格</n-button>
<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="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 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>
</td>
</tr>
</tbody>
</table>
<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: 12px;
gap: 10px;
}
.title {
font-size: 20px;
@@ -186,70 +275,191 @@ onMounted(async () => {
.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;
overflow: hidden;
padding: 8px 0 6px;
}
.scroll {
overflow-x: auto;
}
table {
border-collapse: collapse;
width: 100%;
min-width: 900px;
}
th,
td {
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 0 16px;
text-align: right;
white-space: nowrap;
}
th {
height: 56px;
font-size: 14px;
font-weight: 700;
vertical-align: bottom;
padding-bottom: 10px;
.axis {
height: 24px;
}
td {
height: 52px;
.item {
height: 38px;
border-top: 1px solid var(--learn-line);
}
.label {
text-align: left;
width: 130px;
.group-head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px 6px;
}
th.label {
font-size: 12px;
font-weight: 400;
.c-title {
width: 270px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: inherit;
text-decoration: none;
}
.sub {
font-size: 12px;
font-weight: 400;
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;
}
.current {
font-weight: 700;
border-left: 1px solid var(--learn-line);
.rail {
position: absolute;
left: 0;
right: 0;
top: 9px;
height: 2px;
background: var(--learn-line);
}
.last {
opacity: 0.7;
.span {
position: absolute;
top: 8px;
height: 4px;
border-radius: 2px;
opacity: 0.35;
background: var(--learn-line);
}
.track {
margin: 4px 0 0 auto;
width: 88px;
height: 5px;
border-radius: 3px;
background: var(--learn-todo);
.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;
}
.track > span {
display: block;
height: 100%;
background: var(--learn-green-line);
.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>
+3 -5
View File
@@ -310,13 +310,11 @@ export const admins: RouteRecordRaw = {
meta: { requiresTeacherAdmin: true },
},
{
path: "problem/yearly",
name: "admin yearly overview",
component: () => import("admin/problem/Yearly.vue"),
path: "problem/trend",
name: "admin grade trend",
component: () => import("admin/problem/Trend.vue"),
meta: { requiresTeacherAdmin: true },
},
// 「年度趋势」改名「历年概况」之前的地址,收藏夹里可能还有
{ path: "problem/top_ac_trend", redirect: "/admin/problem/yearly" },
// 题单管理路由
{
path: "problemset/list",
+2 -2
View File
@@ -85,9 +85,9 @@ const NAV: NavGroup[] = [
role: "teacher",
},
{
label: "历年概况",
label: "年度趋势",
icon: "ph:chart-line-up",
path: "/admin/problem/yearly",
path: "/admin/problem/trend",
role: "teacher",
},
{ label: "自学情况", icon: "ph:student", path: "/admin/learn", role: "teacher" },
+1 -2
View File
@@ -330,8 +330,7 @@ export type {
StuckProblem,
StuckProblemDetail,
StuckStudent,
YearlyOverview,
YearlySummary,
GradeTrendProblem,
LearnBoard,
LearnBoardStudent,
LearnExerciseProgress,