1 parent
23844ae301
commit
d25b5dbe6d
268 files changed
+3684
-8861
No files matched your search
@@ -10,18 +10,7 @@ import {
|
||||
type SolvedList,
|
||||
type SolvedProblem,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
countDistinct,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
lte,
|
||||
min,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { and, asc, count, countDistinct, eq, gte, inArray, lte, min, sql } from "drizzle-orm"
|
||||
|
||||
import type { AuthUser } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
@@ -29,10 +18,7 @@ import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
|
||||
import { rounded } from "../routes/helpers"
|
||||
import { localTime, shiftMonthsByCalendar } from "../time"
|
||||
|
||||
export const accepted: JudgeStatusValue[] = [
|
||||
JudgeStatus.ACCEPTED,
|
||||
JudgeStatus.AST_CHECK_FAILED,
|
||||
]
|
||||
export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
const difficultyNames: Record<string, string> = {
|
||||
Low: "简单",
|
||||
Mid: "中等",
|
||||
@@ -42,8 +28,7 @@ const difficultyNames: Record<string, string> = {
|
||||
function grade(rank: number | null, count: number, reference = count): Grade {
|
||||
if (!rank || count <= 0) return "C"
|
||||
const percentile = ((rank - 1) / count) * 100
|
||||
let value: Grade =
|
||||
percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
|
||||
let value: Grade = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
|
||||
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
|
||||
return value
|
||||
}
|
||||
@@ -53,25 +38,13 @@ function averageGrade(grades: Grade[]): Grade {
|
||||
const values = grades.flatMap((item) => weights[item] ?? [])
|
||||
if (!values.length) return ""
|
||||
const average = values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
return average >= 3.5
|
||||
? "S"
|
||||
: average >= 2.5
|
||||
? "A"
|
||||
: average >= 1.5
|
||||
? "B"
|
||||
: "C"
|
||||
return average >= 3.5 ? "S" : average >= 2.5 ? "A" : average >= 1.5 ? "B" : "C"
|
||||
}
|
||||
|
||||
type FirstAcRow = { problemId: number; first: string | null }
|
||||
|
||||
/** 区间内首次 AC 的题,按通过时间升序。limit/offset 给分页用,不传就是全部 */
|
||||
function firstAcQuery(
|
||||
user: AuthUser,
|
||||
start: string,
|
||||
end: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) {
|
||||
function firstAcQuery(user: AuthUser, start: string, end: string, limit?: number, offset?: number) {
|
||||
const first = min(schema.submission.createTime)
|
||||
const query = db
|
||||
.select({ problemId: schema.submission.problemId, first })
|
||||
@@ -115,8 +88,7 @@ export async function buildSolved(
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, user.className))
|
||||
: []
|
||||
const scopeIds =
|
||||
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const [problems, rankRows, periodRows, attemptRows] = await Promise.all([
|
||||
db
|
||||
.select({ problem: schema.problem, contestTitle: schema.contest.title })
|
||||
@@ -181,19 +153,12 @@ export async function buildSolved(
|
||||
for (const row of attemptRows) {
|
||||
const deadline = firstAcTime.get(row.problemId)
|
||||
if (deadline === undefined || Date.parse(row.time) > deadline) continue
|
||||
attemptsByProblem.set(
|
||||
row.problemId,
|
||||
(attemptsByProblem.get(row.problemId) ?? 0) + 1,
|
||||
)
|
||||
attemptsByProblem.set(row.problemId, (attemptsByProblem.get(row.problemId) ?? 0) + 1)
|
||||
}
|
||||
function ranks(rows: typeof rankRows, problemId: number) {
|
||||
return rows
|
||||
.filter((item) => item.problemId === problemId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Date.parse(a.first ?? "") - Date.parse(b.first ?? "") ||
|
||||
a.userId - b.userId,
|
||||
)
|
||||
.sort((a, b) => Date.parse(a.first ?? "") - Date.parse(b.first ?? "") || a.userId - b.userId)
|
||||
}
|
||||
const solved = firstAc
|
||||
.flatMap((item) => {
|
||||
@@ -202,8 +167,7 @@ export async function buildSolved(
|
||||
const all = ranks(rankRows, item.problemId)
|
||||
const period = ranks(periodRows, item.problemId)
|
||||
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
const periodRank =
|
||||
period.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
return {
|
||||
problem: {
|
||||
title: problem.problem.title,
|
||||
@@ -259,9 +223,7 @@ export async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
|
||||
// 星期和小时都按东八区取,和热力图同口径
|
||||
const weekday =
|
||||
sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(
|
||||
Number,
|
||||
)
|
||||
sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(Number)
|
||||
const period =
|
||||
sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(
|
||||
Number,
|
||||
@@ -299,9 +261,7 @@ export async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
),
|
||||
)
|
||||
const settledFail = (result: JudgeStatusValue) =>
|
||||
!accepted.includes(result) &&
|
||||
result !== JudgeStatus.PENDING &&
|
||||
result !== JudgeStatus.JUDGING
|
||||
!accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING
|
||||
const errorCounts = new Map<number, number>()
|
||||
for (const row of submissions) {
|
||||
if (!settledFail(row.result)) continue
|
||||
@@ -329,41 +289,33 @@ export async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
errors,
|
||||
rankScope: "global",
|
||||
} satisfies AiDetail
|
||||
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all(
|
||||
[
|
||||
buildSolved(user, start, end, firstAc),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.problemTags.problemId,
|
||||
name: schema.problemTag.name,
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(inArray(schema.problemTags.problemId, problemIds)),
|
||||
db
|
||||
.select({
|
||||
flow: schema.flowchartSubmission,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.flowchartSubmission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, user.id),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
gte(schema.flowchartSubmission.createTime, start),
|
||||
lte(schema.flowchartSubmission.createTime, end),
|
||||
),
|
||||
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([
|
||||
buildSolved(user, start, end, firstAc),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.problemTags.problemId,
|
||||
name: schema.problemTag.name,
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(inArray(schema.problemTags.problemId, problemIds)),
|
||||
db
|
||||
.select({
|
||||
flow: schema.flowchartSubmission,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, user.id),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
gte(schema.flowchartSubmission.createTime, start),
|
||||
lte(schema.flowchartSubmission.createTime, end),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
])
|
||||
const tags: Record<string, number> = {}
|
||||
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
|
||||
const topTags = Object.fromEntries(
|
||||
@@ -378,18 +330,14 @@ export async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
}
|
||||
const flowGroups = new Map<string, typeof flowRows>()
|
||||
for (const flow of flowRows)
|
||||
flowGroups.set(flow.displayId, [
|
||||
...(flowGroups.get(flow.displayId) ?? []),
|
||||
flow,
|
||||
])
|
||||
flowGroups.set(flow.displayId, [...(flowGroups.get(flow.displayId) ?? []), flow])
|
||||
const flowcharts = [...flowGroups]
|
||||
.map(([displayId, rows]) => {
|
||||
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
|
||||
// 直接留住得分最高的那一次,等级读它。原来是拿 max 回头 find 分数相等的行 ——
|
||||
// ai_score 是 double,相等比较本就不可靠;全是 null 时 max 退成 0,更是谁都匹配不上
|
||||
const top = rows.reduce(
|
||||
(best, row) =>
|
||||
(row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best,
|
||||
(best, row) => ((row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best),
|
||||
rows[0]!,
|
||||
)
|
||||
return {
|
||||
@@ -404,16 +352,12 @@ export async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
.sort()
|
||||
.at(-1) ?? start,
|
||||
avgScore: rounded(
|
||||
scores.length
|
||||
? scores.reduce((sum, value) => sum + value, 0) / scores.length
|
||||
: 0,
|
||||
scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0,
|
||||
0,
|
||||
),
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
b.latestSubmissionTime.localeCompare(a.latestSubmissionTime),
|
||||
)
|
||||
.sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
|
||||
return {
|
||||
username: user.username,
|
||||
className: user.className,
|
||||
@@ -425,20 +369,14 @@ export async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
grade: averageGrade(solved.map((item) => item.grade)),
|
||||
tags: topTags,
|
||||
difficulty,
|
||||
contestCount: new Set(
|
||||
solved.flatMap((item) => item.problem.contestId ?? []),
|
||||
).size,
|
||||
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
||||
activity,
|
||||
errors,
|
||||
rankScope: scopeIds ? "class" : "global",
|
||||
} satisfies AiDetail
|
||||
}
|
||||
|
||||
export async function buildDuration(
|
||||
user: AuthUser,
|
||||
endText: string,
|
||||
duration: string,
|
||||
) {
|
||||
export async function buildDuration(user: AuthUser, endText: string, duration: string) {
|
||||
const config =
|
||||
duration === "months:2"
|
||||
? {
|
||||
@@ -482,9 +420,7 @@ export async function buildDuration(
|
||||
// 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响
|
||||
const rows = await db
|
||||
.select({
|
||||
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
|
||||
Number,
|
||||
),
|
||||
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(Number),
|
||||
problemId: schema.submission.problemId,
|
||||
result: schema.submission.result,
|
||||
})
|
||||
@@ -501,11 +437,7 @@ export async function buildDuration(
|
||||
// 不传 reference(不打小规模折扣)—— 那个折扣只在 /ai/detail 那支用。
|
||||
// 迁移时这里被写死成 `solved ? "B" : ""`,DurationChart 上那条等级折线因此恒定在 B。
|
||||
const solvedIds = [
|
||||
...new Set(
|
||||
rows
|
||||
.filter((row) => accepted.includes(row.result))
|
||||
.map((row) => row.problemId),
|
||||
),
|
||||
...new Set(rows.filter((row) => accepted.includes(row.result)).map((row) => row.problemId)),
|
||||
]
|
||||
const classUsers = user.className
|
||||
? await db
|
||||
@@ -513,8 +445,7 @@ export async function buildDuration(
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, user.className))
|
||||
: []
|
||||
const scopeIds =
|
||||
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const peers = solvedIds.length
|
||||
? await db
|
||||
.select({
|
||||
@@ -530,10 +461,7 @@ export async function buildDuration(
|
||||
inArray(schema.submission.result, accepted),
|
||||
inArray(schema.submission.problemId, solvedIds),
|
||||
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
|
||||
lte(
|
||||
schema.submission.createTime,
|
||||
buckets.at(-1)!.end.toISOString(),
|
||||
),
|
||||
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
|
||||
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
|
||||
),
|
||||
)
|
||||
@@ -541,10 +469,7 @@ export async function buildDuration(
|
||||
// 一次查回来在内存里按题分组再按桶切,别在循环里发查询:一年 12 个桶 × 几十道题
|
||||
const peersByProblem = new Map<number, typeof peers>()
|
||||
for (const row of peers)
|
||||
peersByProblem.set(row.problemId, [
|
||||
...(peersByProblem.get(row.problemId) ?? []),
|
||||
row,
|
||||
])
|
||||
peersByProblem.set(row.problemId, [...(peersByProblem.get(row.problemId) ?? []), row])
|
||||
|
||||
function bucketGrade(problemIds: number[], from: number, to: number) {
|
||||
return averageGrade(
|
||||
@@ -553,8 +478,7 @@ export async function buildDuration(
|
||||
for (const row of peersByProblem.get(problemId) ?? []) {
|
||||
if (row.time < from || row.time > to) continue
|
||||
const seen = firstAc.get(row.userId)
|
||||
if (seen === undefined || row.time < seen)
|
||||
firstAc.set(row.userId, row.time)
|
||||
if (seen === undefined || row.time < seen) firstAc.set(row.userId, row.time)
|
||||
}
|
||||
const ordered = [...firstAc].sort((a, b) => a[1] - b[1] || a[0] - b[0])
|
||||
const rank = ordered.findIndex(([id]) => id === user.id) + 1 || null
|
||||
|
||||
Reference in new issue
Block a user