diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index 05df784..d64462f 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -245,6 +245,9 @@ contestRoutes.get( showFlowchart: problem.showFlowchart, hasAstRules: problem.astRules !== null, myStatus: myStatusOf(statuses, problem.id), + // 按人数是公开题库列表的列;比赛题表看的是榜单,不下发 + solvedUsers: 0, + triedUsers: 0, }) satisfies ProblemListItem, ), ) diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index de0cf70..974477a 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -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> | null +type UserCounts = Awaited> + function listItem( row: { problem: typeof schema.problem.$inferSelect @@ -75,26 +92,43 @@ function listItem( }, tags: Map, statuses: Record, + 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)), ) }) diff --git a/apps/api/src/services/problem-progress.ts b/apps/api/src/services/problem-progress.ts new file mode 100644 index 0000000..0a78f81 --- /dev/null +++ b/apps/api/src/services/problem-progress.ts @@ -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`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() + const tried = new Set() + 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() + if (problemIds && problemIds.length === 0) return result + const rows = await db + .select({ + problemId: schema.submission.problemId, + solved: sql`count(distinct ${schema.submission.userId}) filter (where ${inArray( + schema.submission.result, + accepted, + )})`.mapWith(Number), + tried: sql`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, key: string) { + const value = metrics[key] + return typeof value === "number" ? value : 0 +} + +export async function buildProblemProgress(userId: number): Promise { + 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 = {} + if (solvedVisible.length > 0) { + const rows = await db + .select({ + tagId: schema.problemTags.problemtagId, + n: sql`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, + } +} diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 1befaf8..e69968f 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -24,6 +24,7 @@ import { type ProblemListItem, type ProblemStats, type ProblemList, + type ProblemProgress, type CreateFlowchartResponse, type FlowchartScores, type FlowchartList, @@ -94,6 +95,16 @@ export async function getProblemList( } } +/** 题目列表顶上那行和左栏的进度;没登录是 null */ +export function getProblemProgress() { + return api.get("problems/progress") +} + +/** 「随便来一道」:当前知识点里先挑自己没做对的简单 / 中等题,返回题号 */ +export function getRandomProblem(tag: string) { + return api.get("problems/random", { params: tag ? { tag } : {} }) +} + export function getAuthors(all = false) { return api.get("problem-authors", { params: { all: all ? "1" : "0" }, diff --git a/apps/web/src/oj/problem/components/ProblemContent.vue b/apps/web/src/oj/problem/components/ProblemContent.vue index 5335988..4ff4666 100644 --- a/apps/web/src/oj/problem/components/ProblemContent.vue +++ b/apps/web/src/oj/problem/components/ProblemContent.vue @@ -164,9 +164,9 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem - +
- 代码要求 + 语法要求