feat(题目页): 「统计」页签重做——学生看难不难、几次做对、常错在哪,老师按班看谁没做对
- 全部按人算:原来的「通过数」是 AC 条数、「通过率」按提交算;击败用户、饼图、历年 AC 率去掉 - 错得最多的那种给一句该怎么办(都指向「运行例子」),自己交了 3 次以上还没对再多一张卡 - 老师选一个班:做对 / 交了没对(次数、最后结果)/ 没交;班里错得和全站不一样时点出来 - 题库入口工具栏的「课堂统计」并进这里(题单没有页签,按钮留着) - 新接口 /problems/:id/stats(按内部题号,比赛题不再查到同号公开题),删掉 beat-count、yearly-ac Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91d9c9c6d7
commit
99319fdcc5
10 files changed
+1017
-317
No files matched your search
@@ -15,6 +15,7 @@ import { achievementRoutes } from "./routes/achievement"
|
|||||||
import { aiRoutes } from "./routes/ai"
|
import { aiRoutes } from "./routes/ai"
|
||||||
import { flowchartRoutes } from "./routes/flowchart"
|
import { flowchartRoutes } from "./routes/flowchart"
|
||||||
import { problemRoutes } from "./routes/problem"
|
import { problemRoutes } from "./routes/problem"
|
||||||
|
import { problemStatsRoutes } from "./routes/problem-stats"
|
||||||
import { submissionRoutes } from "./routes/submission"
|
import { submissionRoutes } from "./routes/submission"
|
||||||
import { trialRunRoutes } from "./routes/trial-run"
|
import { trialRunRoutes } from "./routes/trial-run"
|
||||||
import { siteRoutes } from "./routes/site"
|
import { siteRoutes } from "./routes/site"
|
||||||
@@ -40,6 +41,7 @@ app.route("/api", achievementRoutes)
|
|||||||
app.route("/api", aiRoutes)
|
app.route("/api", aiRoutes)
|
||||||
app.route("/api", flowchartRoutes)
|
app.route("/api", flowchartRoutes)
|
||||||
app.route("/api", problemRoutes)
|
app.route("/api", problemRoutes)
|
||||||
|
app.route("/api", problemStatsRoutes)
|
||||||
app.route("/api", submissionRoutes)
|
app.route("/api", submissionRoutes)
|
||||||
app.route("/api", trialRunRoutes)
|
app.route("/api", trialRunRoutes)
|
||||||
app.route("/api", judgeServerRoutes)
|
app.route("/api", judgeServerRoutes)
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { type ProblemClassDetail, type ProblemStats } from "@oj2/contract"
|
||||||
|
import { and, eq, isNotNull, sql } from "drizzle-orm"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
||||||
|
import { db, schema } from "../db"
|
||||||
|
import { failure, success } from "../http"
|
||||||
|
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
|
||||||
|
import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest"
|
||||||
|
import { accepted } from "../services/learning-stats"
|
||||||
|
import { localTime } from "../time"
|
||||||
|
import { isAdminRole, isTeacherOrAbove, queryInteger, stripClassPrefix } from "./helpers"
|
||||||
|
|
||||||
|
export const problemStatsRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一天班里至少这么多人交过,才算「这个班一起做过这题」。只看总人数不行:
|
||||||
|
* 2079 有个班 2 月里零星补做了 6 个人,比 12 月全班 26 人做的那次还「近」,默认就选成了它
|
||||||
|
*/
|
||||||
|
const CLASS_MIN_TRIED = 5
|
||||||
|
|
||||||
|
const EMPTY: ProblemStats = {
|
||||||
|
locked: false,
|
||||||
|
tried: 0,
|
||||||
|
solved: 0,
|
||||||
|
tries: { one: 0, few: 0, many: 0 },
|
||||||
|
failures: [],
|
||||||
|
wrongAnswerFirstCase: 0,
|
||||||
|
me: null,
|
||||||
|
myClass: null,
|
||||||
|
classes: null,
|
||||||
|
classDetail: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
// type 而不是 interface:db.execute 的泛型约束是 Record<string, unknown>,interface 不带索引签名
|
||||||
|
type UserRow = {
|
||||||
|
user_id: number
|
||||||
|
username: string | null
|
||||||
|
class_name: string | null
|
||||||
|
attempts: number
|
||||||
|
/** 做对之前(含做对那次)交了几次;没做对就是全部次数 */
|
||||||
|
tries: number
|
||||||
|
solved: boolean
|
||||||
|
last_result: JudgeStatusValue
|
||||||
|
last_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目页「统计」页签。id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,
|
||||||
|
* 拿 displayId 查会撞上同号的公开题(原来的「历年 AC 率」就是这么查错的)。
|
||||||
|
* `className` 只有老师用:选中哪个班看明细,不填就是最近做过这题的那个班。
|
||||||
|
*/
|
||||||
|
problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
||||||
|
const user = c.get("user")
|
||||||
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
const [problem] = await db
|
||||||
|
.select({
|
||||||
|
id: schema.problem.id,
|
||||||
|
contestId: schema.problem.contestId,
|
||||||
|
visible: schema.problem.visible,
|
||||||
|
})
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(eq(schema.problem.id, id))
|
||||||
|
.limit(1)
|
||||||
|
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||||
|
|
||||||
|
const inContest = problem.contestId !== null
|
||||||
|
if (inContest) {
|
||||||
|
const contest = await findAccessibleContest(user, problem.contestId!)
|
||||||
|
if (!contest) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||||
|
const access = await canAccessContest(c, contest, "problems")
|
||||||
|
if (!access.ok) return failure(c, 403, access.code, access.message)
|
||||||
|
// 比赛没结束:别人几次做对、错在哪一律不给,和题目详情里计数归零同一个口径
|
||||||
|
if (!contestDetailsAllowed(user, contest)) {
|
||||||
|
return success(c, { ...EMPTY, locked: true } satisfies ProblemStats)
|
||||||
|
}
|
||||||
|
} else if (!problem.visible && !isAdminRole(user)) {
|
||||||
|
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptedList = sql.join(
|
||||||
|
accepted.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const unjudgedList = sql.join(
|
||||||
|
UNJUDGED_RESULTS.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
// 每人一行:窗口函数先给每条提交标上这个人第一次做对的时刻,再按人数到那一刻为止交了几次
|
||||||
|
const day = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
|
||||||
|
const [users, failureRows, firstCase, classDays] = await Promise.all([
|
||||||
|
db.execute<UserRow>(sql`
|
||||||
|
select s.user_id, u.username, u.class_name,
|
||||||
|
count(*)::int as attempts,
|
||||||
|
count(*) filter (where s.first_ac is null or s.create_time <= s.first_ac)::int as tries,
|
||||||
|
(min(s.first_ac) is not null) as solved,
|
||||||
|
(array_agg(s.result order by s.create_time desc))[1] as last_result,
|
||||||
|
max(s.create_time) as last_time
|
||||||
|
from (
|
||||||
|
select user_id, result, create_time,
|
||||||
|
min(create_time) filter (where result in (${acceptedList}))
|
||||||
|
over (partition by user_id) as first_ac
|
||||||
|
from ${schema.submission}
|
||||||
|
where problem_id = ${problem.id} and result not in (${unjudgedList})
|
||||||
|
) s
|
||||||
|
left join ${schema.user} u on u.id = s.user_id
|
||||||
|
group by s.user_id, u.username, u.class_name
|
||||||
|
`),
|
||||||
|
db.execute<{ class_name: string | null; result: JudgeStatusValue; n: number }>(sql`
|
||||||
|
select u.class_name, s.result, count(*)::int as n
|
||||||
|
from ${schema.submission} s
|
||||||
|
left join ${schema.user} u on u.id = s.user_id
|
||||||
|
where s.problem_id = ${problem.id}
|
||||||
|
and s.result not in (${acceptedList})
|
||||||
|
and s.result not in (${unjudgedList})
|
||||||
|
group by u.class_name, s.result
|
||||||
|
`),
|
||||||
|
// 判题机按测试点回一个数组,test_case 是 "1"、"2"……
|
||||||
|
db.execute<{ n: number }>(sql`
|
||||||
|
select count(*)::int as n from ${schema.submission}
|
||||||
|
where problem_id = ${problem.id} and result = ${JudgeStatus.WRONG_ANSWER}
|
||||||
|
and jsonb_typeof(info->'data') = 'array'
|
||||||
|
and exists (
|
||||||
|
select 1 from jsonb_array_elements(info->'data') e
|
||||||
|
where e->>'test_case' = '1' and (e->>'result')::int <> 0
|
||||||
|
)
|
||||||
|
`),
|
||||||
|
// 每个班每天几个人交过,取人最多的那天当「这个班做这题的那天」
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
className: schema.user.className,
|
||||||
|
day,
|
||||||
|
users: sql<number>`count(distinct ${schema.submission.userId})::int`,
|
||||||
|
})
|
||||||
|
.from(schema.submission)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||||
|
.where(and(eq(schema.submission.problemId, problem.id), isNotNull(schema.user.className)))
|
||||||
|
.groupBy(schema.user.className, day),
|
||||||
|
])
|
||||||
|
|
||||||
|
const solvedUsers = users.filter((row) => row.solved)
|
||||||
|
const tries = { one: 0, few: 0, many: 0 }
|
||||||
|
for (const row of solvedUsers) {
|
||||||
|
if (row.tries <= 1) tries.one++
|
||||||
|
else if (row.tries <= 3) tries.few++
|
||||||
|
else tries.many++
|
||||||
|
}
|
||||||
|
const sortFailures = (map: Map<JudgeStatusValue, number>) =>
|
||||||
|
[...map].map(([result, count]) => ({ result, count })).sort((a, b) => b.count - a.count)
|
||||||
|
const siteFailures = new Map<JudgeStatusValue, number>()
|
||||||
|
for (const row of failureRows) {
|
||||||
|
siteFailures.set(row.result, (siteFailures.get(row.result) ?? 0) + row.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mine = user ? users.find((row) => row.user_id === user.id) : undefined
|
||||||
|
const myClassName = !inContest && user?.className ? user.className : null
|
||||||
|
const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : []
|
||||||
|
|
||||||
|
let classes: ProblemStats["classes"] = null
|
||||||
|
let classDetail: ProblemClassDetail | null = null
|
||||||
|
if (!inContest && isTeacherOrAbove(user)) {
|
||||||
|
const byClass = new Map<string, UserRow[]>()
|
||||||
|
for (const row of users) {
|
||||||
|
if (!row.class_name) continue
|
||||||
|
byClass.set(row.class_name, [...(byClass.get(row.class_name) ?? []), row])
|
||||||
|
}
|
||||||
|
const peak = new Map<string, { day: string; users: number }>()
|
||||||
|
for (const row of classDays) {
|
||||||
|
const current = peak.get(row.className!)
|
||||||
|
const later = current && row.users === current.users && row.day > current.day
|
||||||
|
if (!current || row.users > current.users || later) {
|
||||||
|
peak.set(row.className!, { day: row.day, users: row.users })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lessonClasses = [...byClass]
|
||||||
|
.filter(([className]) => (peak.get(className)?.users ?? 0) >= CLASS_MIN_TRIED)
|
||||||
|
.map(([className, rows]) => ({
|
||||||
|
className,
|
||||||
|
tried: rows.length,
|
||||||
|
solved: rows.filter((row) => row.solved).length,
|
||||||
|
day: peak.get(className)!.day,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.day.localeCompare(a.day))
|
||||||
|
classes = lessonClasses
|
||||||
|
// 地址里指定的班不在列表里(零星几个人做过)也照样给明细
|
||||||
|
const wanted = c.req.query("className")?.trim()
|
||||||
|
const selected = wanted && byClass.has(wanted) ? wanted : lessonClasses[0]?.className
|
||||||
|
if (selected) {
|
||||||
|
classDetail = await buildClassDetail(
|
||||||
|
selected,
|
||||||
|
peak.get(selected)!.day,
|
||||||
|
byClass.get(selected)!,
|
||||||
|
failureRows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
locked: false,
|
||||||
|
tried: users.length,
|
||||||
|
solved: solvedUsers.length,
|
||||||
|
tries,
|
||||||
|
failures: sortFailures(siteFailures),
|
||||||
|
wrongAnswerFirstCase: firstCase[0]?.n ?? 0,
|
||||||
|
me: user ? { attempts: mine?.attempts ?? 0, solved: mine?.solved ?? false } : null,
|
||||||
|
myClass: myClassName
|
||||||
|
? {
|
||||||
|
className: myClassName,
|
||||||
|
tried: classmates.length,
|
||||||
|
solved: classmates.filter((row) => row.solved).length,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
classes,
|
||||||
|
classDetail,
|
||||||
|
} satisfies ProblemStats)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function buildClassDetail(
|
||||||
|
className: string,
|
||||||
|
day: string,
|
||||||
|
rows: UserRow[],
|
||||||
|
failureRows: { class_name: string | null; result: JudgeStatusValue; n: number }[],
|
||||||
|
): Promise<ProblemClassDetail> {
|
||||||
|
// 花名册只数学生:老师、助教也可能挂着班级
|
||||||
|
const roster = await db
|
||||||
|
.select({ id: schema.user.id, username: schema.user.username })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.user.className, className),
|
||||||
|
eq(schema.user.adminType, "Regular User"),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const triedIds = new Set(rows.map((row) => row.user_id))
|
||||||
|
const name = (username: string | null) => stripClassPrefix(username ?? "", className)
|
||||||
|
return {
|
||||||
|
className,
|
||||||
|
roster: roster.length,
|
||||||
|
day,
|
||||||
|
failures: failureRows
|
||||||
|
.filter((row) => row.class_name === className)
|
||||||
|
.map((row) => ({ result: row.result, count: row.n }))
|
||||||
|
.sort((a, b) => b.count - a.count),
|
||||||
|
unsolved: rows
|
||||||
|
.filter((row) => !row.solved)
|
||||||
|
.sort((a, b) => b.attempts - a.attempts)
|
||||||
|
.map((row) => ({
|
||||||
|
realName: name(row.username),
|
||||||
|
attempts: row.attempts,
|
||||||
|
lastResult: row.last_result,
|
||||||
|
})),
|
||||||
|
solved: rows
|
||||||
|
.filter((row) => row.solved)
|
||||||
|
.sort((a, b) => a.last_time.localeCompare(b.last_time))
|
||||||
|
.map((row) => name(row.username)),
|
||||||
|
untouched: roster.filter((row) => !triedIds.has(row.id)).map((row) => name(row.username)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import type {
|
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag } from "@oj2/contract"
|
||||||
ProblemAuthor,
|
|
||||||
ProblemDetail,
|
|
||||||
ProblemList,
|
|
||||||
ProblemListItem,
|
|
||||||
Tag,
|
|
||||||
YearlyAc,
|
|
||||||
} from "@oj2/contract"
|
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
asc,
|
asc,
|
||||||
@@ -13,7 +6,6 @@ import {
|
|||||||
countDistinct,
|
countDistinct,
|
||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
gte,
|
|
||||||
ilike,
|
ilike,
|
||||||
inArray,
|
inArray,
|
||||||
isNull,
|
isNull,
|
||||||
@@ -29,7 +21,6 @@ import { db, schema } from "../db"
|
|||||||
import { astRequirements } from "../judge/ast"
|
import { astRequirements } from "../judge/ast"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { dayStart, localTime, shiftMonthsByCalendar } from "../time"
|
|
||||||
import {
|
import {
|
||||||
asFilterValue,
|
asFilterValue,
|
||||||
countFailedSubmissions,
|
countFailedSubmissions,
|
||||||
@@ -245,47 +236,6 @@ problemRoutes.get("/problem-authors", async (c) => {
|
|||||||
return success(c, rows satisfies ProblemAuthor[])
|
return success(c, rows satisfies ProblemAuthor[])
|
||||||
})
|
})
|
||||||
|
|
||||||
problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
|
|
||||||
const user = c.get("user")
|
|
||||||
if (!user) return success(c, "0")
|
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
|
||||||
const [mine] = await db
|
|
||||||
.select({ value: count() })
|
|
||||||
.from(schema.submission)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.submission.userId, user.id),
|
|
||||||
eq(schema.submission.problemId, id),
|
|
||||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!mine?.value) return success(c, "0")
|
|
||||||
// 「近两年」按东八区日历算到当天零点
|
|
||||||
const since = dayStart(shiftMonthsByCalendar(new Date(), -24))
|
|
||||||
const [active, accepted] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({ value: count() })
|
|
||||||
.from(schema.user)
|
|
||||||
.where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since))),
|
|
||||||
db
|
|
||||||
.select({ value: countDistinct(schema.submission.userId) })
|
|
||||||
.from(schema.submission)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.submission.problemId, id),
|
|
||||||
inArray(schema.submission.result, [0, 10]),
|
|
||||||
gte(schema.submission.createTime, since),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
const total = active[0]?.value ?? 0
|
|
||||||
const solved = accepted[0]?.value ?? 0
|
|
||||||
return success(
|
|
||||||
c,
|
|
||||||
total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
||||||
const [target] = await db
|
const [target] = await db
|
||||||
.select({ id: schema.problem.id })
|
.select({ id: schema.problem.id })
|
||||||
@@ -360,48 +310,6 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
|
|
||||||
const [problem] = await db
|
|
||||||
.select({ id: schema.problem.id })
|
|
||||||
.from(schema.problem)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
|
||||||
isNull(schema.problem.contestId),
|
|
||||||
eq(schema.problem.visible, true),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
|
||||||
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
year,
|
|
||||||
total: count(),
|
|
||||||
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
|
|
||||||
})
|
|
||||||
.from(schema.submission)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.submission.problemId, problem.id),
|
|
||||||
isNull(schema.submission.contestId),
|
|
||||||
notInArray(schema.submission.result, [6, 7]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(year)
|
|
||||||
.orderBy(year)
|
|
||||||
return success(
|
|
||||||
c,
|
|
||||||
rows.map(
|
|
||||||
(row) =>
|
|
||||||
({
|
|
||||||
...row,
|
|
||||||
acRate: row.total > 0 ? Math.round((row.accepted / row.total) * 10_000) / 100 : 0,
|
|
||||||
}) satisfies YearlyAc,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
+9
-10
@@ -22,7 +22,7 @@ import {
|
|||||||
type CreateSubmissionResponse,
|
type CreateSubmissionResponse,
|
||||||
type ProblemAuthor,
|
type ProblemAuthor,
|
||||||
type ProblemListItem,
|
type ProblemListItem,
|
||||||
type YearlyAc,
|
type ProblemStats,
|
||||||
type ProblemList,
|
type ProblemList,
|
||||||
type CreateFlowchartResponse,
|
type CreateFlowchartResponse,
|
||||||
type FlowchartScores,
|
type FlowchartScores,
|
||||||
@@ -115,9 +115,14 @@ export async function getProblem(problemID: string, contestID: string) {
|
|||||||
return problem
|
return problem
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未登录返回 "0",登录后返回百分比字符串
|
/**
|
||||||
export function getProblemBeatRate(problemID: number) {
|
* 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查。
|
||||||
return api.get<string>(`problems/${problemID}/beat-count`)
|
* className 只有老师用:看哪个班的明细,不填就是最近做过这题的班
|
||||||
|
*/
|
||||||
|
export function getProblemStats(problemID: number, className?: string) {
|
||||||
|
return api.get<ProblemStats>(`problems/${problemID}/stats`, {
|
||||||
|
params: className ? { className } : {},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSubmission(id: string): Promise<Submission> {
|
export async function getSubmission(id: string): Promise<Submission> {
|
||||||
@@ -405,12 +410,6 @@ export function getSimilarProblems(problemId: string) {
|
|||||||
.then((response) => response.map(toProblemRow))
|
.then((response) => response.map(toProblemRow))
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { YearlyAc as YearlyACData } from "@oj2/contract"
|
|
||||||
|
|
||||||
export function getProblemYearlyAC(problemId: string) {
|
|
||||||
return api.get<YearlyAc[]>(`problems/${encodeURIComponent(problemId)}/yearly-ac`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 流程图相关API ====================
|
// ==================== 流程图相关API ====================
|
||||||
|
|
||||||
export function submitFlowchart(data: {
|
export function submitFlowchart(data: {
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ function goAllSubmissions() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<ProblemInfo v-if="lastOpened === 'info'" />
|
<ProblemInfo v-if="lastOpened === 'info'" @open-submissions="open = 'submission'" />
|
||||||
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
|
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ProblemSubmission />
|
<ProblemSubmission />
|
||||||
|
|||||||
@@ -1,164 +1,716 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
|
import { getProblemStats } from "oj/api"
|
||||||
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
import { useTone, type Tone } from "oj/submission/composables/tone"
|
||||||
|
import { classLabel } from "oj/submission/utils"
|
||||||
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
||||||
import type { SUBMISSION_RESULT } from "utils/types"
|
import { getTagColor, parseTime } from "utils/functions"
|
||||||
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
|
import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
|
||||||
import { Pie } from "vue-chartjs"
|
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend, Colors } from "chart.js"
|
|
||||||
import { getProblemBeatRate } from "oj/api"
|
|
||||||
import { getProblemYearlyAC, type YearlyACData } from "oj/api"
|
|
||||||
import ProblemYearlyChart from "./ProblemYearlyChart.vue"
|
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
||||||
|
|
||||||
// 仅注册饼图所需的 Chart.js 组件
|
/**
|
||||||
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
|
* 「统计」页签(设计稿「题目页统计重设计」:学生 A、老师 B)。
|
||||||
|
*
|
||||||
|
* 学生看的是「这题难不难 → 大家几次做对 → 常错在哪 → 你们班」,全部按人算;自己卡住了
|
||||||
|
* 再多一张卡。老师看的是一个班:谁做对了、谁交了没对(卡在哪)、谁还没交 —— 原来工具栏上
|
||||||
|
* 的「课堂统计」并进了这里。原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
|
||||||
|
*/
|
||||||
|
const emit = defineEmits<{ openSubmissions: [] }>()
|
||||||
|
|
||||||
const problemStore = useProblemStore()
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
const { problem } = storeToRefs(problemStore)
|
const contestStore = useContestStore()
|
||||||
|
const ctx = useProblemPageContext()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const stats = ref<ProblemStats | null>(null)
|
||||||
|
const failed = ref(false)
|
||||||
|
const className = ref<string | null>(null)
|
||||||
|
|
||||||
const beatRate = ref("0")
|
async function load() {
|
||||||
const yearlyACData = ref<YearlyACData[]>([])
|
if (!problem.value) return
|
||||||
|
failed.value = false
|
||||||
const data = computed(() => {
|
try {
|
||||||
// statisticInfo 是 { 判题状态码: 次数 } 的 JSONB,契约里是 Record<string, unknown>
|
const res = await getProblemStats(problem.value.id, className.value ?? undefined)
|
||||||
const status = problem.value!.statisticInfo as Record<string, number>
|
stats.value = res
|
||||||
const labels: string[] = []
|
if (res.classDetail) className.value = res.classDetail.className
|
||||||
const values: number[] = []
|
} catch {
|
||||||
for (const code in status) {
|
failed.value = true
|
||||||
if (status[code] !== 0) {
|
|
||||||
labels.push(JUDGE_STATUS[Number(code) as SUBMISSION_RESULT].name)
|
|
||||||
values.push(status[code]!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
}
|
||||||
labels,
|
|
||||||
datasets: [{ data: values, hoverOffset: 5, borderRadius: 10 }],
|
onMounted(load)
|
||||||
|
|
||||||
|
function changeClass(value: string) {
|
||||||
|
className.value = value
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const teacherView = computed(() => stats.value?.classes != null)
|
||||||
|
|
||||||
|
function resultName(result: number) {
|
||||||
|
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultTone(result: number): Tone {
|
||||||
|
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.type ?? "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 条的颜色:运行时错误橙、答案错误红、编译失败灰 —— 和提交列表的胶囊一个配色 */
|
||||||
|
function barColor(result: number) {
|
||||||
|
const kind = resultTone(result)
|
||||||
|
// 编译失败用灰:它和运行时错误都是 warning,都画成橙色就分不开了
|
||||||
|
if (result === -2 || kind === "default" || kind === "info") return theme.value.textColor3
|
||||||
|
return tone(kind).color
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 学生 ----------
|
||||||
|
|
||||||
|
const verdict = computed(() => {
|
||||||
|
const s = stats.value
|
||||||
|
if (!s || !s.tried) return null
|
||||||
|
const rate = s.solved / s.tried
|
||||||
|
const firstShare = s.solved ? s.tries.one / s.solved : 0
|
||||||
|
const manyShare = s.solved ? s.tries.many / s.solved : 0
|
||||||
|
const detail = `${s.tried} 人做过,${s.solved} 人做对了`
|
||||||
|
if (rate >= 0.9 && firstShare >= 0.6) {
|
||||||
|
return { kind: "success" as const, text: "这题不难:大多数人第一次交就对了", detail }
|
||||||
}
|
}
|
||||||
|
if (problem.value?.difficulty === "Low" && (rate < 0.9 || manyShare >= 0.15)) {
|
||||||
|
return { kind: "warning" as const, text: "标的是「简单」,但不少人卡住了", detail }
|
||||||
|
}
|
||||||
|
if (rate >= 0.75) {
|
||||||
|
return { kind: "info" as const, text: "多数人做对了,不少人要多试几次", detail }
|
||||||
|
}
|
||||||
|
return { kind: "warning" as const, text: "这题有点难:不少人还没做对", detail }
|
||||||
})
|
})
|
||||||
|
|
||||||
const numbers = computed(() => {
|
const triesSegments = computed(() => {
|
||||||
|
const s = stats.value
|
||||||
|
if (!s) return []
|
||||||
|
const green = theme.value.successColor
|
||||||
return [
|
return [
|
||||||
|
{ key: "one", label: "1 次", value: s.tries.one, color: green, opacity: 1 },
|
||||||
|
{ key: "few", label: "2–3 次", value: s.tries.few, color: green, opacity: 0.6 },
|
||||||
|
{ key: "many", label: "4 次以上", value: s.tries.many, color: green, opacity: 0.3 },
|
||||||
{
|
{
|
||||||
icon: "streamline-ultimate-color:checklist",
|
key: "none",
|
||||||
title: problem.value?.submissionNumber ?? 0,
|
label: "没做对",
|
||||||
content: "总提交",
|
value: s.tried - s.solved,
|
||||||
int: true,
|
color: theme.value.dividerColor,
|
||||||
suffix: "",
|
opacity: 1,
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "streamline-emojis:woman-raising-hand-2",
|
|
||||||
title: problem.value?.acceptedNumber ?? 0,
|
|
||||||
content: "通过数",
|
|
||||||
int: true,
|
|
||||||
suffix: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "fluent-emoji:chart-increasing",
|
|
||||||
title: getACRateNumber(
|
|
||||||
problem.value?.acceptedNumber ?? 0,
|
|
||||||
problem.value?.submissionNumber ?? 0,
|
|
||||||
),
|
|
||||||
content: "通过率",
|
|
||||||
int: false,
|
|
||||||
suffix: "%",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "streamline-emojis:sparkles",
|
|
||||||
title: parseFloat(beatRate.value),
|
|
||||||
content: "击败用户",
|
|
||||||
int: false,
|
|
||||||
suffix: "%",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const options = {
|
const failedTotal = computed(() =>
|
||||||
plugins: {
|
(stats.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0),
|
||||||
title: { text: "提交结果的比例", display: true, font: { size: 20 } },
|
)
|
||||||
},
|
const topFailures = computed(() => (stats.value?.failures ?? []).slice(0, 3))
|
||||||
}
|
|
||||||
|
|
||||||
async function getBeatRate() {
|
/** 错得最多的那一种,给一句该怎么办。都指向「运行例子」:提交前自己就能看出来 */
|
||||||
const res = await getProblemBeatRate(problem.value!.id)
|
const tip = computed(() => {
|
||||||
beatRate.value = res
|
const s = stats.value
|
||||||
}
|
const top = s?.failures[0]
|
||||||
|
if (!s || !top) return ""
|
||||||
async function getYearlyAC() {
|
if (top.result === -1) {
|
||||||
const res = await getProblemYearlyAC(problem.value!._id)
|
return s.wrongAnswerFirstCase >= top.count * 0.6
|
||||||
yearlyACData.value = res
|
? "答案错误几乎都卡在第 1 个测试点:多半是例子都没对上。先点「运行例子」,把你的输出和例子一行一行对一对。"
|
||||||
}
|
: "答案错误最多:输出和标准答案对不上。留意空格、换行和标点,交之前先点「运行例子」对一对。"
|
||||||
|
}
|
||||||
onMounted(() => {
|
if (top.result === 4) {
|
||||||
getBeatRate()
|
return "运行时错误最多:程序跑到一半出错了。交之前先点「运行例子」,看看有没有红色的报错。"
|
||||||
getYearlyAC()
|
}
|
||||||
|
if (top.result === -2) {
|
||||||
|
return "编译失败最多:代码有语法错误,程序根本没跑起来。交之前先点「运行例子」检查一下。"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 自己交了好几次还没对:比赛里没有举手,也不提「看别人」 */
|
||||||
|
const stuck = computed(() => {
|
||||||
|
const me = stats.value?.me
|
||||||
|
return me && !me.solved && me.attempts >= 3 ? me.attempts : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- 老师 ----------
|
||||||
|
|
||||||
|
const detail = computed(() => stats.value?.classDetail ?? null)
|
||||||
|
|
||||||
|
const classOptions = computed(() =>
|
||||||
|
(stats.value?.classes ?? []).map((item) => ({
|
||||||
|
label: `${classLabel(item.className)} · ${item.solved}/${item.tried}`,
|
||||||
|
value: item.className,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const classSegments = computed(() => {
|
||||||
|
const d = detail.value
|
||||||
|
if (!d) return []
|
||||||
|
return [
|
||||||
|
{ key: "solved", label: "做对", value: d.solved.length, color: theme.value.successColor },
|
||||||
|
{ key: "unsolved", label: "交了没对", value: d.unsolved.length, color: theme.value.errorColor },
|
||||||
|
{ key: "untouched", label: "没交", value: d.untouched.length, color: theme.value.dividerColor },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const classFailedTotal = computed(() =>
|
||||||
|
(detail.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 这个班错得最多的和全站不一样时点出来:老师该看的就是这种差别 */
|
||||||
|
const siteDiffers = computed(() => {
|
||||||
|
const site = stats.value?.failures[0]
|
||||||
|
const mine = detail.value?.failures[0]
|
||||||
|
return site && mine && site.result !== mine.result ? resultName(site.result) : ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSolved = ref(false)
|
||||||
|
const showUntouched = ref(false)
|
||||||
|
|
||||||
|
function openStatistics() {
|
||||||
|
const href = router.resolve({
|
||||||
|
name: "statistics",
|
||||||
|
query: { problem: problem.value!._id, className: className.value ?? "", period: "all" },
|
||||||
|
}).href
|
||||||
|
window.open(href, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
function openClassSubmissions() {
|
||||||
|
const href = router.resolve({
|
||||||
|
name: "submissions",
|
||||||
|
query: { problem: problem.value!._id, className: className.value ?? "" },
|
||||||
|
}).href
|
||||||
|
window.open(href, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「2025-12-05」→「2025年12月5日」。后端给的已经是东八区的日子,不用再换算 */
|
||||||
|
function dayText(day: string) {
|
||||||
|
const [year, month, date] = day.split("-").map(Number)
|
||||||
|
return `${year}年${month}月${date}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentWidth(value: number, total: number) {
|
||||||
|
return total ? `${(value / total) * 100}%` : "0"
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-descriptions bordered label-placement="left" :column="isDesktop ? 3 : 1" v-if="problem">
|
<div v-if="problem" class="info">
|
||||||
<n-descriptions-item label="编号">
|
<!-- 原来占一大块的描述表收成一行 -->
|
||||||
{{ problem._id }}
|
<div class="meta">
|
||||||
</n-descriptions-item>
|
<b class="meta-title">{{ problem._id }} {{ problem.title }}</b>
|
||||||
<n-descriptions-item label="出题人">
|
<!-- 比赛进行中难度不下发(null),整个不显示 -->
|
||||||
{{ problem.createdBy.username }}
|
<n-tag
|
||||||
</n-descriptions-item>
|
v-if="problem.difficulty"
|
||||||
<n-descriptions-item label="创建时间">
|
size="small"
|
||||||
{{ parseTime(problem.createTime) }}
|
round
|
||||||
</n-descriptions-item>
|
:bordered="false"
|
||||||
<!-- 比赛进行中,难度不下发(difficulty 为 null),这一项整个不显示 -->
|
:type="getTagColor(problem.difficulty)"
|
||||||
<n-descriptions-item v-if="problem.difficulty" label="难度">
|
>
|
||||||
<n-tag :type="getTagColor(problem.difficulty)">
|
|
||||||
{{ DIFFICULTY[problem.difficulty] }}
|
{{ DIFFICULTY[problem.difficulty] }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</n-descriptions-item>
|
<n-tag
|
||||||
<n-descriptions-item :span="2" label="标签">
|
v-for="tag in problem.tags"
|
||||||
<n-flex>
|
:key="tag"
|
||||||
<n-tag type="info" v-for="tag in problem.tags" :key="tag">
|
size="small"
|
||||||
{{ tag }}
|
round
|
||||||
</n-tag>
|
:bordered="false"
|
||||||
</n-flex>
|
type="info"
|
||||||
</n-descriptions-item>
|
>
|
||||||
</n-descriptions>
|
{{ tag }}
|
||||||
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="10" :y-gap="10" class="cards">
|
</n-tag>
|
||||||
<n-gi v-for="item in numbers" :key="item.content">
|
<span class="spacer"></span>
|
||||||
<n-card hoverable>
|
<n-text depth="3" class="meta-author">{{ problem.createdBy.username }} 出题</n-text>
|
||||||
<n-flex vertical align="center">
|
</div>
|
||||||
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
|
|
||||||
<n-h2 class="number">
|
<n-text v-if="failed" type="error">统计没拉下来,关掉再打开试试</n-text>
|
||||||
<n-number-animation :to="item.title" :precision="item.int ? 0 : 2" />
|
<div v-else-if="!stats" class="center"><n-spin size="small" /></div>
|
||||||
<span v-if="item.suffix">{{ item.suffix }}</span>
|
|
||||||
</n-h2>
|
<!-- 比赛进行中:别人的情况等比赛结束再公布 -->
|
||||||
<n-h4 class="number-label">{{ item.content }}</n-h4>
|
<div v-else-if="stats.locked" class="empty">
|
||||||
</n-flex>
|
<span class="empty-icon"><Icon icon="ph:lock-simple" :width="18" /></span>
|
||||||
</n-card>
|
<b>比赛结束后才能看到大家的情况</b>
|
||||||
</n-gi>
|
<n-text depth="3">
|
||||||
</n-grid>
|
<template v-if="contestStore.contest">
|
||||||
<div class="pie" v-if="problem && problem.submissionNumber > 0">
|
比赛 {{ parseTime(contestStore.contest.endTime, "HH:mm") }} 结束。
|
||||||
<Pie :data="data" :options="options" />
|
</template>
|
||||||
|
现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。
|
||||||
|
</n-text>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'contest rank', params: { contestID: route.params.contestID } }"
|
||||||
|
class="link"
|
||||||
|
>
|
||||||
|
去看比赛排名 ›
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== 老师:看一个班 ==================== -->
|
||||||
|
<template v-else-if="teacherView">
|
||||||
|
<div v-if="!detail" class="empty">
|
||||||
|
<span class="empty-icon"><Icon icon="ph:users" :width="18" /></span>
|
||||||
|
<b>还没有班级做过这道题</b>
|
||||||
|
<n-text depth="3">布置到这节课之后,这里会按班显示谁做对了、谁还没交。</n-text>
|
||||||
|
<n-button type="primary" secondary @click="router.push('/classroom')">
|
||||||
|
去课堂看板布置 →
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="class-head">
|
||||||
|
<n-select
|
||||||
|
:value="className"
|
||||||
|
:options="classOptions"
|
||||||
|
size="small"
|
||||||
|
class="class-select"
|
||||||
|
:consistent-menu-width="false"
|
||||||
|
@update:value="changeClass"
|
||||||
|
/>
|
||||||
|
<n-text depth="3" class="small">{{ dayText(detail.day) }}做的</n-text>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a href="#" class="link small" @click.prevent="openStatistics">
|
||||||
|
在数据统计里看 <Icon icon="ph:arrow-square-out" :width="13" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<span
|
||||||
|
v-for="seg in classSegments"
|
||||||
|
:key="seg.key"
|
||||||
|
:style="{ width: segmentWidth(seg.value, detail.roster), background: seg.color }"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span v-for="seg in classSegments" :key="seg.key">
|
||||||
|
<i :style="{ background: seg.color }"></i>{{ seg.label }} <b>{{ seg.value }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="classFailedTotal" class="line">
|
||||||
|
<b>没通过的 {{ classFailedTotal }} 次:</b>
|
||||||
|
<n-text depth="2">
|
||||||
|
{{
|
||||||
|
detail.failures
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => `${resultName(item.result)} ${item.count}`)
|
||||||
|
.join(" · ")
|
||||||
|
}}
|
||||||
|
</n-text>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<n-text v-if="siteDiffers" depth="3" class="small">全站是{{ siteDiffers }}最多</n-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="detail.unsolved.length">
|
||||||
|
<div class="line">
|
||||||
|
<b>交了没对({{ detail.unsolved.length }} 人)</b>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<n-text depth="3" class="small">次数多的在前</n-text>
|
||||||
|
</div>
|
||||||
|
<div class="people">
|
||||||
|
<div v-for="(item, index) in detail.unsolved" :key="index" class="person">
|
||||||
|
<b>{{ item.realName }}</b>
|
||||||
|
<n-text depth="3">{{ item.attempts }} 次</n-text>
|
||||||
|
<span :style="{ color: barColor(item.lastResult) }">
|
||||||
|
{{ resultName(item.lastResult) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="detail.solved.length" class="fold">
|
||||||
|
<div class="fold-head" @click="showSolved = !showSolved">
|
||||||
|
<b>做对 {{ detail.solved.length }}</b>
|
||||||
|
<span class="fold-names">
|
||||||
|
{{ showSolved ? "" : detail.solved.join("、") }}
|
||||||
|
</span>
|
||||||
|
<span class="link small">{{ showSolved ? "收起" : "展开" }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showSolved" class="fold-body">{{ detail.solved.join("、") }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.untouched.length" class="fold">
|
||||||
|
<div class="fold-head" @click="showUntouched = !showUntouched">
|
||||||
|
<b>没交 {{ detail.untouched.length }}</b>
|
||||||
|
<span class="fold-names">{{ showUntouched ? "" : "展开看名单" }}</span>
|
||||||
|
<span class="link small">{{ showUntouched ? "收起" : "展开" }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showUntouched" class="fold-body">{{ detail.untouched.join("、") }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
<a href="#" class="link small" @click.prevent="openClassSubmissions">
|
||||||
|
看这个班这道题的提交 ›
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ==================== 学生 ==================== -->
|
||||||
|
<div v-else-if="!stats.tried" class="empty">
|
||||||
|
<span class="empty-icon"><Icon icon="ph:sparkle" :width="18" /></span>
|
||||||
|
<b>还没有人交过这道题</b>
|
||||||
|
<n-text depth="3">你可以做第一个。</n-text>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-if="verdict"
|
||||||
|
class="verdict"
|
||||||
|
:style="{
|
||||||
|
background: tone(verdict.kind).background,
|
||||||
|
borderColor: tone(verdict.kind).background,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<b :style="{ color: tone(verdict.kind).color }">
|
||||||
|
<Icon icon="ph:gauge" :width="16" />{{ verdict.text }}
|
||||||
|
</b>
|
||||||
|
<n-text depth="2" class="small">{{ verdict.detail }}</n-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stats.solved" class="section">
|
||||||
|
<div class="line">
|
||||||
|
<b>做对用了几次</b>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<n-text depth="3" class="small">按人算</n-text>
|
||||||
|
</div>
|
||||||
|
<div class="bar">
|
||||||
|
<span
|
||||||
|
v-for="seg in triesSegments"
|
||||||
|
:key="seg.key"
|
||||||
|
:style="{
|
||||||
|
width: segmentWidth(seg.value, stats.tried),
|
||||||
|
background: seg.color,
|
||||||
|
opacity: seg.opacity,
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span v-for="seg in triesSegments" :key="seg.key">
|
||||||
|
<i :style="{ background: seg.color, opacity: seg.opacity }"></i>{{ seg.label }}
|
||||||
|
<b>{{ seg.value }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="topFailures.length" class="section">
|
||||||
|
<b>没通过的 {{ failedTotal }} 次,错在哪</b>
|
||||||
|
<div v-for="item in topFailures" :key="item.result" class="fail">
|
||||||
|
<span class="fail-name">{{ resultName(item.result) }}</span>
|
||||||
|
<span class="fail-track">
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
width: segmentWidth(item.count, topFailures[0]!.count),
|
||||||
|
background: barColor(item.result),
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
<span class="fail-count">{{ item.count }} 次</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="tip"
|
||||||
|
class="tip"
|
||||||
|
:style="{ background: tone('warning').background, color: tone('warning').color }"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:lightbulb" :width="15" class="tip-icon" />
|
||||||
|
<span>{{ tip }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stats.myClass?.tried" class="class-box">
|
||||||
|
<div class="line">
|
||||||
|
<b>你们班 · {{ classLabel(stats.myClass.className) }}</b>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span>
|
||||||
|
<b class="big">{{ stats.myClass.solved }}</b>
|
||||||
|
<n-text depth="3"> / {{ stats.myClass.tried }} 人做对</n-text>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar thin">
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
width: segmentWidth(stats.myClass.solved, stats.myClass.tried),
|
||||||
|
background: theme.successColor,
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="stuck && ctx.entry !== 'contest'"
|
||||||
|
class="stuck"
|
||||||
|
:style="{ borderColor: tone('warning').background, background: tone('warning').background }"
|
||||||
|
>
|
||||||
|
<b>你交了 {{ stuck }} 次,还没对上</b>
|
||||||
|
<n-text depth="2" class="small">
|
||||||
|
卡了这么久,可以点右上角「举手求助」问老师;也可以去「我的提交」看看每一次错在哪。
|
||||||
|
</n-text>
|
||||||
|
<div>
|
||||||
|
<n-button size="small" @click="emit('openSubmissions')">看我的提交</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<ProblemYearlyChart :data="yearlyACData" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cards {
|
.info {
|
||||||
margin-top: 24px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number {
|
.meta {
|
||||||
margin: 0;
|
display: flex;
|
||||||
font-weight: bold;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-label {
|
.meta-title {
|
||||||
margin: 0;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pie {
|
.meta-author,
|
||||||
width: 100%;
|
.small {
|
||||||
max-width: 500px;
|
font-size: 13px;
|
||||||
margin: 24px auto;
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 60px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict b {
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.thin {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar > span {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-name {
|
||||||
|
width: 72px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-track > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-count {
|
||||||
|
width: 48px;
|
||||||
|
flex: none;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon {
|
||||||
|
flex: none;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-box {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid v-bind("theme.dividerColor");
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
font-size: 18px;
|
||||||
|
color: v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.stuck {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-select {
|
||||||
|
width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid v-bind("theme.dividerColor");
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold {
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-names {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-body {
|
||||||
|
padding: 0 12px 10px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="yearly-chart" v-if="props.data.length > 1">
|
|
||||||
<Line :data="chartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Line } from "vue-chartjs"
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
CategoryScale,
|
|
||||||
Filler,
|
|
||||||
LinearScale,
|
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
} from "chart.js"
|
|
||||||
import type { YearlyACData } from "oj/api"
|
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, Filler, LinearScale, LineElement, PointElement, Title, Tooltip)
|
|
||||||
|
|
||||||
const props = defineProps<{ data: YearlyACData[] }>()
|
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
|
||||||
labels: props.data.map((d) => String(d.year)),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "AC 率",
|
|
||||||
data: props.data.map((d) => d.acRate),
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
backgroundColor: "rgba(99, 179, 237, 0.2)",
|
|
||||||
borderColor: "rgba(99, 179, 237, 1)",
|
|
||||||
pointBackgroundColor: "rgba(99, 179, 237, 1)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "历年 AC 率",
|
|
||||||
font: { size: 20 },
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (context: any) => {
|
|
||||||
const d = props.data[context.dataIndex]
|
|
||||||
return [`AC 率: ${d.acRate}%`, `通过: ${d.accepted} / ${d.total}`]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
ticks: {
|
|
||||||
callback: (value: any) => `${value}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.yearly-chart {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
height: 250px;
|
|
||||||
margin: 24px auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -39,8 +39,9 @@ interface EntryRules {
|
|||||||
*/
|
*/
|
||||||
collab: boolean
|
collab: boolean
|
||||||
/**
|
/**
|
||||||
* 教师的「课堂统计」。统计接口按 displayId 在公开题库里找题(`submission-statistics.ts`),
|
* 工具栏上教师的「课堂统计」(新标签开数据统计页)。题库入口已经并进「统计」页签的老师
|
||||||
* 比赛题号拿过去查到的是另一道公开题,或者 404
|
* 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的题单入口还要这个按钮。
|
||||||
|
* 比赛不给:统计接口按 displayId 在公开题库里找题,比赛题号查到的是另一道公开题,或者 404
|
||||||
*/
|
*/
|
||||||
classStats: boolean
|
classStats: boolean
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +61,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
|
|||||||
backToProblemSet: false,
|
backToProblemSet: false,
|
||||||
help: true,
|
help: true,
|
||||||
collab: true,
|
collab: true,
|
||||||
classStats: true,
|
classStats: false,
|
||||||
allSubmissionsInMenu: false,
|
allSubmissionsInMenu: false,
|
||||||
},
|
},
|
||||||
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
|
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
|
|||||||
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
|
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
|
||||||
*/
|
*/
|
||||||
export type { Tag, TagCategory } from "@oj2/contract"
|
export type { Tag, TagCategory } from "@oj2/contract"
|
||||||
|
export type { ProblemStats, ProblemClassDetail } from "@oj2/contract"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AdminTag,
|
AdminTag,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
|
import { judgeStatusSchema } from "./judge-status"
|
||||||
import { problemLanguageSchema } from "./language"
|
import { problemLanguageSchema } from "./language"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,11 +447,64 @@ export const problemAuthorSchema = z.object({
|
|||||||
problemCount: z.number().int().nonnegative(),
|
problemCount: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const yearlyAcSchema = z.object({
|
const failureCountSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
|
||||||
year: z.number().int(),
|
|
||||||
total: z.number().int().nonnegative(),
|
/** 老师选中一个班时的明细:谁做对了、谁交了没对(卡在哪)、谁还没交 */
|
||||||
accepted: z.number().int().nonnegative(),
|
export const problemClassDetailSchema = z.object({
|
||||||
acRate: z.number(),
|
className: z.string(),
|
||||||
|
/** 班里的学生数(学生角色、没禁用) */
|
||||||
|
roster: z.number().int(),
|
||||||
|
/** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天)—— 「2025年12月5日做的」 */
|
||||||
|
day: z.string(),
|
||||||
|
failures: z.array(failureCountSchema),
|
||||||
|
/** 交了没对,次数多的在前。名字是剥掉 ks<班级号> 前缀的那一段 */
|
||||||
|
unsolved: z.array(
|
||||||
|
z.object({ realName: z.string(), attempts: z.number().int(), lastResult: judgeStatusSchema }),
|
||||||
|
),
|
||||||
|
solved: z.array(z.string()),
|
||||||
|
/** 班里还没交过的 */
|
||||||
|
untouched: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目页「统计」页签。全部**按人**算,不按提交条数 —— 原来的「通过数」是 AC 条数
|
||||||
|
* (3092 是 219 条、202 个人),「通过率」是按提交算的,学生看了不知道这题难不难。
|
||||||
|
* 原来的「击败用户」(分母是近两年所有登录过的人)、饼图、历年 AC 率一起去掉了。
|
||||||
|
*/
|
||||||
|
export const problemStatsSchema = z.object({
|
||||||
|
/** 比赛还没结束(又不是比赛管理员):别人的情况一律不给,下面的数字都是 0 / 空 */
|
||||||
|
locked: z.boolean(),
|
||||||
|
/** 交过的人 */
|
||||||
|
tried: z.number().int(),
|
||||||
|
solved: z.number().int(),
|
||||||
|
/** 做对的人用了几次:1 次 / 2–3 次 / 4 次以上 */
|
||||||
|
tries: z.object({ one: z.number().int(), few: z.number().int(), many: z.number().int() }),
|
||||||
|
/** 没通过的提交按结果分,多的在前(不含判题中) */
|
||||||
|
failures: z.array(failureCountSchema),
|
||||||
|
/** 答案错误里第 1 个测试点就没过的条数 —— 多半是例子都没对上,提示先点「运行例子」 */
|
||||||
|
wrongAnswerFirstCase: z.number().int(),
|
||||||
|
/** 我自己:交了几次、做对没有。没登录为 null */
|
||||||
|
me: z.object({ attempts: z.number().int(), solved: z.boolean() }).nullable(),
|
||||||
|
/** 学生自己班这题 x / y 人做对(y 是班里交过的人)。没班级、比赛里为 null */
|
||||||
|
myClass: z
|
||||||
|
.object({ className: z.string(), tried: z.number().int(), solved: z.number().int() })
|
||||||
|
.nullable(),
|
||||||
|
/**
|
||||||
|
* 老师:一起做过这题的班(同一天至少 5 个人交过 —— 零星几个人自己刷到、补做的不算),
|
||||||
|
* 按那天倒序。原来工具栏上的「课堂统计」并进了这里。学生、比赛里为 null
|
||||||
|
*/
|
||||||
|
classes: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
className: z.string(),
|
||||||
|
tried: z.number().int(),
|
||||||
|
solved: z.number().int(),
|
||||||
|
day: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.nullable(),
|
||||||
|
/** 老师选中的那个班(默认 classes 第一个)的明细 */
|
||||||
|
classDetail: problemClassDetailSchema.nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
|
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
|
||||||
@@ -468,4 +522,5 @@ export type SqlConfig = z.infer<typeof sqlConfigSchema>
|
|||||||
export type SqlDisplay = z.infer<typeof sqlDisplaySchema>
|
export type SqlDisplay = z.infer<typeof sqlDisplaySchema>
|
||||||
export type SqlDisplayTable = z.infer<typeof sqlDisplayTableSchema>
|
export type SqlDisplayTable = z.infer<typeof sqlDisplayTableSchema>
|
||||||
export type SqlDisplayColumn = z.infer<typeof sqlDisplayColumnSchema>
|
export type SqlDisplayColumn = z.infer<typeof sqlDisplayColumnSchema>
|
||||||
export type YearlyAc = z.infer<typeof yearlyAcSchema>
|
export type ProblemStats = z.infer<typeof problemStatsSchema>
|
||||||
|
export type ProblemClassDetail = z.infer<typeof problemClassDetailSchema>
|
||||||
Reference in new issue
Block a user