feat(题目列表): 学生自己挑题时只看得到一堆标签和提交数,不知道自己练到哪了——改成左栏知识点进度、顶上先说做到了什么
Deploy / deploy (push) Canceled after 0s

- 左栏是知识点(可切到主题)和自己的进度:做了一部分写 37/96 加进度条,全做对写「通关」,
  一道没做对过的只写题数,不再是一整列 0/108
- 顶上一行:做对几道 · 这周 +n(或连续几周都有做对)· 离哪个成就最近 ·「有 n 道差一点」
  (点开才列出来);新生引去「输出入门」;一言照旧在右边
- 每题最后一列是做对 / 做过的人数,代替提交数和按提交算的通过率(和题目页统计同一个口径)
- 「画流程图 / 语法要求 / 有参考图」挂在题目名后面,「全部类型」下拉单独筛这三类,
  不再混在排序里;题目页的「代码要求」统一叫「语法要求」
- 「只看没做完」开关(默认关)、「随便来一道」(当前知识点里先挑没做对的简单 / 中等题)
- 画流程图的题,流程图评到 A / S 在状态列也打勾,和题单、课堂条一个口径
- 老师看不到状态列和个人进度;出题者筛选保留

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 12:31:49 +08:00
1 parent 1ba8928d41
commit ed7752afbb
14 files changed
+2083 -339

No files matched your search

+3
View File
@@ -245,6 +245,9 @@ contestRoutes.get(
showFlowchart: problem.showFlowchart,
hasAstRules: problem.astRules !== null,
myStatus: myStatusOf(statuses, problem.id),
// 按人数是公开题库列表的列;比赛题表看的是榜单,不下发
solvedUsers: 0,
triedUsers: 0,
}) satisfies ProblemListItem,
),
)
+116 -35
View File
@@ -1,4 +1,13 @@
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag } from "@oj2/contract"
import {
problemListSortSchema,
problemTypeFilterSchema,
type ProblemAuthor,
type ProblemDetail,
type ProblemList,
type ProblemListItem,
type ProblemProgress,
type Tag,
} from "@oj2/contract"
import {
and,
asc,
@@ -21,6 +30,11 @@ import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import {
buildProblemProgress,
problemStates,
problemUserCounts,
} from "../services/problem-progress"
import {
asFilterValue,
countFailedSubmissions,
@@ -67,6 +81,9 @@ async function getProblemTags(problemIds: number[]) {
return result
}
type ProblemStates = Awaited<ReturnType<typeof problemStates>> | null
type UserCounts = Awaited<ReturnType<typeof problemUserCounts>>
function listItem(
row: {
problem: typeof schema.problem.$inferSelect
@@ -75,26 +92,43 @@ function listItem(
},
tags: Map<number, string[]>,
statuses: Record<string, unknown>,
states: ProblemStates,
counts: UserCounts,
) {
const status = asRecord(statuses[String(row.problem.id)]).status
const id = row.problem.id
return {
id: row.problem.id,
id,
_id: row.problem.displayId,
title: row.problem.title,
submissionNumber: row.problem.submissionNumber,
acceptedNumber: row.problem.acceptedNumber,
difficulty: row.problem.difficulty,
createdBy: sampleUser(row.user, row.realName),
tags: tags.get(row.problem.id) ?? [],
tags: tags.get(id) ?? [],
contestId: row.problem.contestId,
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
hasAstRules: row.problem.astRules !== null,
myStatus: typeof status === "number" ? status : null,
// 流程图评到 A / S 也打勾:problemStates 已经把它算进 solved
myStatus: states?.solved.has(id)
? JudgeStatus.ACCEPTED
: typeof status === "number"
? status
: null,
solvedUsers: counts.get(id)?.solved ?? 0,
triedUsers: counts.get(id)?.tried ?? 0,
} satisfies ProblemListItem
}
/** 题号排序:先按长度再按字面,1001 < 1100 < P026 < SQL09,不会排成 1001、1100、11、2 */
const displayIdOrder = [
asc(sql`length(${schema.problem.displayId})`),
asc(schema.problem.displayId),
]
problemRoutes.get("/problems", optionalAuth, async (c) => {
const user = c.get("user")
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)]
@@ -102,6 +136,9 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim()
const tag = c.req.query("tag")?.trim()
const type = problemTypeFilterSchema.safeParse(c.req.query("type")).data
// 没登录时「只看没做完」在前端是灰的;直接拼地址来的就当没开
const undone = c.req.query("undone") === "1" && !!user
if (author) filters.push(eq(schema.user.username, author))
if (keyword)
filters.push(
@@ -123,31 +160,32 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
),
)
}
if (type === "reference") filters.push(eq(schema.problem.showFlowchart, true))
if (type === "flowchart") filters.push(eq(schema.problem.allowFlowchart, true))
if (type === "ast") filters.push(sql`${schema.problem.astRules} is not null`)
const states = user ? await problemStates(user.id) : null
if (undone && states && states.solved.size > 0)
filters.push(notInArray(schema.problem.id, [...states.solved]))
const where = and(...filters)
const sort = c.req.query("sort")
// 默认:选了知识点按题号(同一系列的题挨在一起),全部题目按最新创建。
// 旧的排序值(最多提交、画流程图……)落到默认 —— 类型已经拆成了单独的筛选
const sort = problemListSortSchema.safeParse(c.req.query("sort") ?? "").data ?? ""
const effective = sort || (tag ? "id" : "new")
const allCounts = effective === "popular" ? await problemUserCounts() : null
const popularity = allCounts
? sql`case ${schema.problem.id} ${sql.join(
[...allCounts].map(([id, n]) => sql`when ${id} then ${n.solved}`),
sql` `,
)} else 0 end`
: null
const order =
sort === "flowchart"
? [
desc(schema.problem.allowFlowchart),
desc(schema.problem.showFlowchart),
desc(schema.problem.createTime),
]
: sort === "ast"
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)]
: sort === "-accepted_number"
? [desc(schema.problem.acceptedNumber)]
: sort === "accepted_number"
? [asc(schema.problem.acceptedNumber)]
: sort === "-submission_number"
? [desc(schema.problem.submissionNumber)]
: sort === "submission_number"
? [asc(schema.problem.submissionNumber)]
: sort === "difficulty"
? [asc(schema.problem.difficulty)]
: sort === "create_time"
? [asc(schema.problem.createTime)]
: [desc(schema.problem.createTime)]
effective === "id"
? displayIdOrder
: popularity
? [desc(popularity), ...displayIdOrder]
: [desc(schema.problem.createTime)]
const [totalRow] = await db
.select({ value: countDistinct(schema.problem.id) })
.from(schema.problem)
@@ -166,16 +204,28 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
.orderBy(...order)
.limit(limit)
.offset(offset)
const [tags, statuses] = await Promise.all([
getProblemTags(rows.map((row) => row.problem.id)),
getProblemStatuses(c.get("user")?.id),
const ids = rows.map((row) => row.problem.id)
const [tags, statuses, counts] = await Promise.all([
getProblemTags(ids),
getProblemStatuses(user?.id),
allCounts ?? problemUserCounts(ids),
])
return success(c, {
results: rows.map((row) => listItem(row, tags, statuses)),
results: rows.map((row) => listItem(row, tags, statuses, states, counts)),
total: totalRow?.value ?? 0,
} satisfies ProblemList)
})
/**
* 题目列表顶上那行和左栏的进度。没登录给 null(前端那行换成「登录以后……」)。
* 注册在 /problems/:displayId 前面,不然被它吃掉。
*/
problemRoutes.get("/problems/progress", optionalAuth, async (c) => {
const user = c.get("user")
if (!user) return success(c, null)
return success(c, (await buildProblemProgress(user.id)) satisfies ProblemProgress)
})
problemRoutes.get("/problem-tags", async (c) => {
const keyword = c.req.query("keyword")?.trim()
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
@@ -204,12 +254,42 @@ problemRoutes.get("/problem-tags", async (c) => {
return success(c, rows satisfies Tag[])
})
problemRoutes.get("/problems/random", async (c) => {
/**
* 「随便来一道」:范围是当前知识点(不传就是全部),先挑自己还没做对的简单 / 中等题,
* 没有就放宽到没做对的困难题,再没有就随便一道(全做对了也给一道,不让按钮落空)。
*/
problemRoutes.get("/problems/random", optionalAuth, async (c) => {
const user = c.get("user")
const tag = c.req.query("tag")?.trim()
const states = user ? await problemStates(user.id) : null
const solved = states ? [...states.solved] : []
const [row] = await db
.select({ displayId: schema.problem.displayId })
.from(schema.problem)
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
.orderBy(sql`random()`)
.where(
and(
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
tag
? inArray(
schema.problem.id,
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(eq(schema.problemTag.name, tag)),
)
: undefined,
),
)
.orderBy(
solved.length ? asc(inArray(schema.problem.id, solved)) : sql`1`,
asc(sql`${schema.problem.difficulty} = 'High'`),
sql`random()`,
)
.limit(1)
if (!row) return failure(c, 404, "no-problems", "No problem to pick")
return success(c, row.displayId)
@@ -303,10 +383,11 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
asc(schema.problem.id),
)
.limit(5)
const tags = await getProblemTags(rows.map((row) => row.problem.id))
const ids = rows.map((row) => row.problem.id)
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
return success(
c,
rows.map((row) => listItem(row, tags, statuses)),
rows.map((row) => listItem(row, tags, statuses, null, counts)),
)
})
+210
View File
@@ -0,0 +1,210 @@
import { FLOWCHART_PASS_GRADES, type ProblemProgress } from "@oj2/contract"
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { isAccepted, UNJUDGED_RESULTS } from "../judge/status"
import { asRecord } from "../routes/helpers"
import { calendarDay, dayNumber, dayText, weekStart } from "../time"
import { accepted } from "./learning-stats"
/**
* 一个人在公开题库里的做题状态:做对了哪些、交过哪些。
*
* 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到
* A / S 也算做对、交过流程图就算交过 —— 和题单、课堂条一个口径(题目页设计文档
* 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。
*/
export async function problemStates(userId: number) {
const [[profile], flowRows] = await Promise.all([
db
.select({ value: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.limit(1),
db
.select({
problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`bool_or(${schema.flowchartSubmission.status} = 2 and ${inArray(
schema.flowchartSubmission.aiGrade,
[...FLOWCHART_PASS_GRADES],
)})`,
})
.from(schema.flowchartSubmission)
.where(eq(schema.flowchartSubmission.userId, userId))
.groupBy(schema.flowchartSubmission.problemId),
])
const solved = new Set<number>()
const tried = new Set<number>()
for (const [key, raw] of Object.entries(asRecord(asRecord(profile?.value).problems))) {
const id = Number(key)
const status = asRecord(raw).status
if (!Number.isInteger(id) || typeof status !== "number") continue
tried.add(id)
if (isAccepted(status)) solved.add(id)
}
for (const row of flowRows) {
tried.add(row.problemId)
if (row.passed) solved.add(row.problemId)
}
return { solved, tried }
}
/**
* 每道题做对 / 交过的人数,和题目页「统计」页签(`problem-stats.ts`)同一个口径:
* 代码提交,不算还在判的,AST_CHECK_FAILED 也是做对。不传 ids 就是全部题(排序用,全表约 85ms)。
*/
export async function problemUserCounts(problemIds?: number[]) {
const result = new Map<number, { solved: number; tried: number }>()
if (problemIds && problemIds.length === 0) return result
const rows = await db
.select({
problemId: schema.submission.problemId,
solved: sql<number>`count(distinct ${schema.submission.userId}) filter (where ${inArray(
schema.submission.result,
accepted,
)})`.mapWith(Number),
tried: sql<number>`count(distinct ${schema.submission.userId})`.mapWith(Number),
})
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
sql`${schema.submission.result} not in (${sql.join(
UNJUDGED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)})`,
problemIds ? inArray(schema.submission.problemId, problemIds) : undefined,
),
)
.groupBy(schema.submission.problemId)
for (const row of rows) result.set(row.problemId, { solved: row.solved, tried: row.tried })
return result
}
/**
* 顶行提示哪个成就。只挑做题能推动的几项(提交次数、编译错误这类不该鼓励),
* 隐藏成就不提示(会泄露门槛),完成比例最高的那个;一样高按下面的先后挑 ——
* 新生所有成就都是 0,不按先后的话会挑中「做对 1 道困难题」,把刚来的人往最难的题上引。
*/
const HINT_METRICS = [
"accepted_count",
"mid_ac_count",
"hard_ac_count",
"active_days",
"max_ac_week_streak",
]
function numberOf(metrics: Record<string, unknown>, key: string) {
const value = metrics[key]
return typeof value === "number" ? value : 0
}
export async function buildProblemProgress(userId: number): Promise<ProblemProgress> {
const [states, statRows, achievements, unlockedRows] = await Promise.all([
problemStates(userId),
db
.select({ metrics: schema.userStat.metrics })
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId))
.limit(1),
db
.select()
.from(schema.achievement)
.where(
and(
eq(schema.achievement.visible, true),
eq(schema.achievement.hidden, false),
eq(schema.achievement.operator, "gte"),
inArray(schema.achievement.metric, HINT_METRICS),
),
),
db
.select({ id: schema.userAchievement.achievementId })
.from(schema.userAchievement)
.where(eq(schema.userAchievement.userId, userId)),
])
const metrics = asRecord(statRows[0]?.metrics)
// 只数公开可见的题:隐藏题、比赛题在左栏里本来就不出现
const visibleRows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
.orderBy(asc(sql`length(${schema.problem.displayId})`), asc(schema.problem.displayId))
const visible = new Set(visibleRows.map((row) => row.id))
const solvedVisible = [...states.solved].filter((id) => visible.has(id))
const byTag: Record<string, number> = {}
if (solvedVisible.length > 0) {
const rows = await db
.select({
tagId: schema.problemTags.problemtagId,
n: sql<number>`count(*)`.mapWith(Number),
})
.from(schema.problemTags)
.where(inArray(schema.problemTags.problemId, solvedVisible))
.groupBy(schema.problemTags.problemtagId)
for (const row of rows) byTag[String(row.tagId)] = row.n
}
const almost = visibleRows
.filter((row) => states.tried.has(row.id) && !states.solved.has(row.id))
.map((row) => ({ _id: row.displayId, title: row.title }))
// 这周:_ac_per_day 的键是东八区日历日,周一起算(和周榜、连续周数同一个口径)
const monday = calendarDay(weekStart())
let weekSolved = 0
for (const [day, n] of Object.entries(asRecord(metrics._ac_per_day))) {
if (day >= monday && typeof n === "number") weekSolved += n
}
// 当前连续周数只在最近一次新通过落在本周或上周时还作数,再早就已经断了
const lastWeek = typeof metrics._last_ac_week === "string" ? metrics._last_ac_week : ""
const previousMonday = dayText(dayNumber(monday) - 7)
const weekStreak =
lastWeek === monday || lastWeek === previousMonday
? numberOf(metrics, "_current_ac_week_streak")
: 0
const unlocked = new Set(unlockedRows.map((row) => row.id))
let next: ProblemProgress["next"] = null
let best = -1
for (const achievement of achievements) {
if (unlocked.has(achievement.id) || achievement.threshold <= 0) continue
// 连续周数看的是「现在连着几周」,不是历史最长:断过的人从头数
const progress =
achievement.metric === "max_ac_week_streak"
? weekStreak
: numberOf(metrics, achievement.metric)
if (progress >= achievement.threshold) continue
const ratio = progress / achievement.threshold
// 一样高:先按指标先后,同一个指标挑门槛低的(「初出茅庐」而不是「渐入佳境」)
const order = HINT_METRICS.indexOf(achievement.metric)
const tie =
next !== null &&
ratio === best &&
(order < HINT_METRICS.indexOf(next.metric) ||
(achievement.metric === next.metric && achievement.threshold < next.threshold))
if (ratio > best || tie) {
best = ratio
next = {
name: achievement.name,
icon: achievement.icon,
rarity: achievement.rarity,
metric: achievement.metric,
threshold: achievement.threshold,
progress,
}
}
}
return {
solved: numberOf(metrics, "accepted_count"),
weekSolved,
weekStreak,
almost,
byTag,
next,
}
}