Compare commits

...
3 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 db493cebe4 fix(题目页): 统计页签的老师版回到讲这道题本身,各班只列一张表,不选班、不列人名
Deploy / deploy (push) Canceled after 0s
按人看谁没做对是提交页「数据统计」的活;表里点一个班就去那边看。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:50:01 +08:00
xuyueandClaude Opus 5.5 2fddf4a011 feat(提交列表): 今日统计重做——老师看今天哪几个班上了课、几点到几点、错得最多的题;学生版从简
- 老师:各班上课时段画在一条 7–20 点的时间轴上(点班级名去数据统计),有班正在上课时引去课堂看板
- 错得最多的题带班级、没过的原因、做对 x/y 人;底部一根全站结果条,语言只有一种时缩成一句
- 学生:你今天交了几次做对几道 + 今天大家都在做(按人数,带去做 / 接着做)
- 标题栏写「截至几点」、判题中几条;打开时把按钮上的数一起更新(原来只在进页面时拉一次)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:47:51 +08:00
xuyueandClaude Opus 5.5 99319fdcc5 feat(题目页): 「统计」页签重做——学生看难不难、几次做对、常错在哪,老师按班看谁没做对
- 全部按人算:原来的「通过数」是 AC 条数、「通过率」按提交算;击败用户、饼图、历年 AC 率去掉
- 错得最多的那种给一句该怎么办(都指向「运行例子」),自己交了 3 次以上还没对再多一张卡
- 老师选一个班:做对 / 交了没对(次数、最后结果)/ 没交;班里错得和全站不一样时点出来
- 题库入口工具栏的「课堂统计」并进这里(题单没有页签,按钮留着)
- 新接口 /problems/:id/stats(按内部题号,比赛题不再查到同号公开题),删掉 beat-count、yearly-ac

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:39:57 +08:00
14 changed files with 1817 additions and 580 deletions

No files matched your search

+2
View File
@@ -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)
+224
View File
@@ -0,0 +1,224 @@
import { type ProblemStats } from "@oj2/contract"
import { and, count, eq, inArray, 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 } 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,
}
// 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
}
/**
* 题目页「统计」页签:讲的是**这道题**(难不难、几次做对、常错在哪),老师多一张各班的表。
* 看谁没做对是提交页「数据统计」的活,这里只到班级这一层。
* id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,拿 displayId 查会撞上
* 同号的公开题(原来的「历年 AC 率」就是这么查错的)。
*/
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
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
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,
)
// 花名册只数学生:老师、助教也可能挂着班级
const sizes = lessonClasses.length
? await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(
and(
inArray(
schema.user.className,
lessonClasses.map(([className]) => className),
),
eq(schema.user.adminType, "Regular User"),
eq(schema.user.isDisabled, false),
),
)
.groupBy(schema.user.className)
: []
classes = lessonClasses
.map(([className, rows]) => {
const top = failureRows
.filter((row) => row.class_name === className)
.sort((a, b) => b.n - a.n)[0]
return {
className,
day: peak.get(className)!.day,
tried: rows.length,
solved: rows.filter((row) => row.solved).length,
classSize: sizes.find((row) => row.className === className)?.value ?? 0,
topFailure: top ? { result: top.result, count: top.n } : null,
}
})
.sort((a, b) => b.day.localeCompare(a.day))
}
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,
} satisfies ProblemStats)
})
+1 -93
View File
@@ -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({
+193 -77
View File
@@ -7,6 +7,7 @@
*/ */
import { import {
type ProblemLanguage,
type SubmissionStatistics, type SubmissionStatistics,
type SubmissionStatisticsGrid, type SubmissionStatisticsGrid,
type TodaySubmissionStatistics, type TodaySubmissionStatistics,
@@ -17,111 +18,226 @@ import { Hono } from "hono"
import { optionalAuth, requireTeacher } from "../auth/middleware" import { optionalAuth, requireTeacher } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status" import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
import { type ContestEnv } from "../services/contest" import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { localTime, dayStart } from "../time" import { dayStart } from "../time"
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers" import {
isAdminRole,
isTeacherOrAbove,
parseDisplayIds,
rounded,
scopedUsers,
stripClassPrefix,
} from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>() export const submissionStatisticsRoutes = new Hono<ContestEnv>()
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] const ACCEPTED_RESULTS: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */ /** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
function judgedRate(accepted: number, judged: number) { function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0 return judged > 0 ? rounded((accepted / judged) * 100) : 0
} }
/** 同班这么多人做了同一道题,才算这个班今天上了课(和首页「班里在做」同一个门槛) */
const TODAY_LESSON_MIN_USERS = 5
/** 最后一条提交在这么多分钟内,算「正在上课」 */
const TODAY_LIVE_MINUTES = 15
type TodayRow = {
class_name: string | null
problem_id: number
display_id: string
title: string
visible: boolean
problemset_id: number | null
user_id: number
result: JudgeStatusValue
language: ProblemLanguage
create_time: string
}
/** /**
* 「今日提交数」标签点开的统计。**公开、只出聚合数**(没有用户名、没有代码, * 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师看:今天哪几个班上了课、几点到几点、
* 热门题只算公开可见的题),口径和那颗标签一致:东八区今天 + 非比赛提交。 * 错得最多的是哪几道;学生版从简。口径和那颗标签一致:东八区今天 + 非比赛提交。
* *
* 按钟点切用 `localTime()`,不能写 `extract(hour from create_time)` —— * 今天的提交一天也就几百到一千来条,整批拉回来在进程里分组,比写五六条各自 group by 的
* 后者按数据库会话时区算,容器是 UTC,整张分布图会整体左移 8 小时。 * SQL 好读,也只扫一遍 `create_time` 索引。
*/ */
submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, async (c) => { submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, async (c) => {
const user = c.get("user")
/** /**
* 「提交列表对学生全开」关掉时(考试那种场合)不给热门题这张表 —— 总数、正确率 * 「提交列表对学生全开」关掉时(考试那种场合)不给题目这张表 —— 总数、正确率
* 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近 * 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近
* 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。 * 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。
*/ */
const showProblems = const showProblems =
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(c.get("user")) (await getBooleanOption("submission_list_show_all", true)) || isAdminRole(user)
const where = and( const teacher = isTeacherOrAbove(user)
isNull(schema.submission.contestId), const since = dayStart()
sql`${schema.submission.createTime} >= ${dayStart()}`, const [rows, [flow]] = await Promise.all([
) db.execute<TodayRow>(sql`
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})` select u.class_name, s.problem_id, p._id as display_id, p.title, p.visible,
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})` s.problemset_id, s.user_id, s.result, s.language, s.create_time
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int` from ${schema.submission} s
join ${schema.problem} p on p.id = s.problem_id
const [[totals], hourRows, languageRows, resultRows, problemRows] = await Promise.all([ left join ${schema.user} u on u.id = s.user_id
where s.contest_id is null and s.create_time >= ${since}
`),
db db
.select({ .select({ value: count() })
total: count(), .from(schema.flowchartSubmission)
accepted: acceptedFilter.mapWith(Number), .where(sql`${schema.flowchartSubmission.createTime} >= ${since}`),
judging: judgingFilter.mapWith(Number),
userCount: sql<number>`count(distinct ${schema.submission.userId})`.mapWith(Number),
})
.from(schema.submission)
.where(where),
db.select({ hour, value: count() }).from(schema.submission).where(where).groupBy(hour),
db
.select({ language: schema.submission.language, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.language)
.orderBy(desc(count())),
db
.select({ result: schema.submission.result, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
showProblems
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
value: count(),
accepted: acceptedFilter.mapWith(Number),
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true)))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
.orderBy(desc(count()))
.limit(10)
: [],
]) ])
const total = totals?.total ?? 0 const isAccepted = (row: TodayRow) => ACCEPTED_RESULTS.includes(row.result)
const judging = totals?.judging ?? 0 const isJudging = (row: TodayRow) => UNJUDGED_RESULTS.includes(row.result)
const hours = Array.from({ length: 24 }, () => 0) const rate = (list: TodayRow[]) => {
for (const row of hourRows) hours[row.hour] = row.value const judging = list.filter(isJudging).length
return judgedRate(list.filter(isAccepted).length, list.length - judging)
}
const distinctUsers = (list: TodayRow[]) => new Set(list.map((row) => row.user_id)).size
const acceptedUsers = (list: TodayRow[]) =>
new Set(list.filter(isAccepted).map((row) => row.user_id)).size
const groupBy = <K>(list: TodayRow[], key: (row: TodayRow) => K) => {
const map = new Map<K, TodayRow[]>()
for (const row of list) map.set(key(row), [...(map.get(key(row)) ?? []), row])
return map
}
const countBy = <K>(list: TodayRow[], key: (row: TodayRow) => K) =>
[...groupBy(list, key)]
.map(([value, group]) => ({ value, count: group.length }))
.sort((a, b) => b.count - a.count)
const judging = rows.filter(isJudging).length
const byProblem = groupBy(rows, (row) => row.problem_id)
const mineRows = user ? rows.filter((row) => row.user_id === user.id) : []
// ---------- 学生:大家都在做 ----------
const problems = showProblems
? [...byProblem.values()]
.filter((list) => list[0]!.visible)
.map((list) => {
const mine = user ? list.filter((row) => row.user_id === user.id) : []
return {
problemDisplayId: list[0]!.display_id,
problemTitle: list[0]!.title,
userCount: distinctUsers(list),
acceptedUsers: acceptedUsers(list),
mine: !user
? null
: mine.some(isAccepted)
? ("accepted" as const)
: mine.length
? ("tried" as const)
: ("none" as const),
}
})
.sort((a, b) => b.userCount - a.userCount)
.slice(0, 8)
: []
// ---------- 老师:哪几个班上了课、错得最多的题 ----------
let classes: TodaySubmissionStatistics["classes"] = null
let scattered: TodaySubmissionStatistics["scattered"] = null
let hardProblems: TodaySubmissionStatistics["hardProblems"] = null
if (teacher) {
const byClass = groupBy(rows, (row) => row.class_name)
const lessonClasses: { className: string; list: TodayRow[]; problemCount: number }[] = []
scattered = []
for (const [className, list] of byClass) {
const problemCount = className
? [...groupBy(list, (row) => row.problem_id).values()].filter(
(group) => distinctUsers(group) >= TODAY_LESSON_MIN_USERS,
).length
: 0
if (className && problemCount) lessonClasses.push({ className, list, problemCount })
else scattered.push({ className, userCount: distinctUsers(list), total: list.length })
}
scattered.sort((a, b) =>
a.className === null ? 1 : b.className === null ? -1 : b.total - a.total,
)
const sizes = lessonClasses.length
? await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(
and(
inArray(
schema.user.className,
lessonClasses.map((item) => item.className),
),
eq(schema.user.adminType, "Regular User"),
eq(schema.user.isDisabled, false),
),
)
.groupBy(schema.user.className)
: []
const liveSince = new Date(Date.now() - TODAY_LIVE_MINUTES * 60_000).toISOString()
classes = lessonClasses
.map(({ className, list, problemCount }) => {
const times = list.map((row) => row.create_time).sort()
return {
className,
start: times[0]!,
end: times.at(-1)!,
userCount: distinctUsers(list),
classSize: sizes.find((row) => row.className === className)?.value ?? 0,
problemCount,
fromProblemSet: list.filter((row) => row.problemset_id !== null).length * 2 > list.length,
total: list.length,
correctRate: rate(list),
live: times.at(-1)! >= liveSince,
}
})
.sort((a, b) => a.start.localeCompare(b.start))
const failedOf = (list: TodayRow[]) => list.filter((row) => !isAccepted(row) && !isJudging(row))
hardProblems = [...byProblem.values()]
.filter((list) => failedOf(list).length > 0)
.sort((a, b) => failedOf(b).length - failedOf(a).length)
.slice(0, 6)
.map((list) => ({
problemDisplayId: list[0]!.display_id,
problemTitle: list[0]!.title,
className: countBy(list, (row) => row.class_name)[0]?.value ?? null,
total: list.length,
accepted: list.filter(isAccepted).length,
failures: countBy(failedOf(list), (row) => row.result).map(({ value, count }) => ({
result: value,
count,
})),
userCount: distinctUsers(list),
acceptedUsers: acceptedUsers(list),
}))
}
return success(c, { return success(c, {
total, asOf: new Date().toISOString(),
accepted: totals?.accepted ?? 0, total: rows.length,
accepted: rows.filter(isAccepted).length,
judging, judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging), correctRate: rate(rows),
userCount: totals?.userCount ?? 0, userCount: distinctUsers(rows),
hours, flowchartCount: flow?.value ?? 0,
languages: languageRows.map((row) => ({ languages: countBy(rows, (row) => row.language).map(({ value, count }) => ({
language: row.language, language: value,
count: row.value, count,
})), })),
results: resultRows.map((row) => ({ results: countBy(rows, (row) => row.result).map(({ value, count }) => ({
result: row.result, result: value,
count: row.value, count,
})),
problems: problemRows.map((row) => ({
problemDisplayId: row.displayId,
problemTitle: row.title,
count: row.value,
acceptedCount: row.accepted,
})), })),
me: user
? {
total: mineRows.length,
solved: new Set(mineRows.filter(isAccepted).map((row) => row.problem_id)).size,
}
: null,
problems,
classes,
scattered,
hardProblems,
} satisfies TodaySubmissionStatistics) } satisfies TodaySubmissionStatistics)
}) })
+4 -10
View File
@@ -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,9 @@ export async function getProblem(problemID: string, contestID: string) {
return problem return problem
} }
// 未登录返回 "0",登录后返回百分比字符串 /** 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查 */
export function getProblemBeatRate(problemID: number) { export function getProblemStats(problemID: number) {
return api.get<string>(`problems/${problemID}/beat-count`) return api.get<ProblemStats>(`problems/${problemID}/stats`)
} }
export async function getSubmission(id: string): Promise<Submission> { export async function getSubmission(id: string): Promise<Submission> {
@@ -405,12 +405,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 />
+557 -123
View File
@@ -1,164 +1,598 @@
<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)。讲的是**这道题**:难不难 → 大家几次
* 做对 → 常错在哪,全部按人算。学生多一行「你们班」、自己卡住了再多一张卡;老师多一张
* 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「数据统计」的活)。
* 原来的描述表、击败用户、饼图、历年 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 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> stats.value = await getProblemStats(problem.value.id)
const status = problem.value!.statisticInfo as Record<string, number> } catch {
const labels: string[] = [] failed.value = true
const values: number[] = []
for (const code in status) {
if (status[code] !== 0) {
labels.push(JUDGE_STATUS[Number(code) as SUBMISSION_RESULT].name)
values.push(status[code]!)
} }
}
onMounted(load)
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 }
} }
return { if (problem.value?.difficulty === "Low" && (rate < 0.9 || manyShare >= 0.15)) {
labels, return { kind: "warning" as const, text: "标的是「简单」,但不少人卡住了", detail }
datasets: [{ data: values, hoverOffset: 5, borderRadius: 10 }],
} }
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
})
// ---------- 老师:各班的表 ----------
/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */
function openStatistics(className?: string) {
const href = router.resolve({
name: "statistics",
query: { problem: problem.value!._id, period: "all", ...(className ? { className } : {}) },
}).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"
round
:bordered="false"
type="info"
>
{{ tag }} {{ tag }}
</n-tag> </n-tag>
</n-flex> <span class="spacer"></span>
</n-descriptions-item> <n-text depth="3" class="meta-author">{{ problem.createdBy.username }} 出题</n-text>
</n-descriptions> </div>
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="10" :y-gap="10" class="cards">
<n-gi v-for="item in numbers" :key="item.content"> <n-text v-if="failed" type="error">统计没拉下来,关掉再打开试试</n-text>
<n-card hoverable> <div v-else-if="!stats" class="center"><n-spin size="small" /></div>
<n-flex vertical align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="40" /> <!-- 比赛进行中:别人的情况等比赛结束再公布 -->
<n-h2 class="number"> <div v-else-if="stats.locked" class="empty">
<n-number-animation :to="item.title" :precision="item.int ? 0 : 2" /> <span class="empty-icon"><Icon icon="ph:lock-simple" :width="18" /></span>
<span v-if="item.suffix">{{ item.suffix }}</span> <b>比赛结束后才能看到大家的情况</b>
</n-h2> <n-text depth="3">
<n-h4 class="number-label">{{ item.content }}</n-h4> <template v-if="contestStore.contest">
</n-flex> 比赛 {{ parseTime(contestStore.contest.endTime, "HH:mm") }} 结束。
</n-card> </template>
</n-gi> 现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。
</n-grid> </n-text>
<div class="pie" v-if="problem && problem.submissionNumber > 0"> <router-link
<Pie :data="data" :options="options" /> :to="{ name: 'contest rank', params: { contestID: route.params.contestID } }"
class="link"
>
去看比赛排名 ›
</router-link>
</div>
<!-- ==================== 这道题(学生、老师都看) ==================== -->
<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.classes" class="section">
<div class="line">
<b>各班做得怎样</b>
<span class="spacer"></span>
<n-text depth="3" class="small">同一天 5 人以上交过才算这个班做过</n-text>
</div>
<table v-if="stats.classes.length" class="classes">
<thead>
<tr>
<th>班级</th>
<th>哪天做的</th>
<th>做对 / 交过</th>
<th>没交</th>
<th>错得最多</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in stats.classes"
:key="item.className"
title="去数据统计看这个班的每个人"
@click="openStatistics(item.className)"
>
<td>
<b>{{ classLabel(item.className) }}</b>
</td>
<td>
<n-text depth="2">{{ dayText(item.day) }}</n-text>
</td>
<td>
<span
:style="{
color: item.solved < item.tried ? tone('error').color : undefined,
fontWeight: 600,
}"
>{{ item.solved }}</span
>
/ {{ item.tried }}
</td>
<td>
<n-text depth="2">{{ Math.max(0, item.classSize - item.tried) }}</n-text>
</td>
<td>
<span v-if="item.topFailure" :style="{ color: barColor(item.topFailure.result) }">
{{ resultName(item.topFailure.result) }} {{ item.topFailure.count }}
</span>
<n-text v-else depth="3">—</n-text>
</td>
</tr>
</tbody>
</table>
<n-text v-else depth="3" class="small">还没有哪个班一起做过这道题</n-text>
<div class="foot">
<a href="#" class="link small" @click.prevent="openStatistics()">
按人看谁没做对,去数据统计 <Icon icon="ph:arrow-square-out" :width="13" />
</a>
</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,
.small {
font-size: 13px;
}
.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;
}
.foot {
display: flex;
justify-content: flex-end;
}
.classes {
width: 100%; width: 100%;
max-width: 500px; border-collapse: collapse;
margin: 24px auto; font-size: 13px;
}
.classes th {
text-align: left;
font-weight: normal;
color: v-bind("theme.textColor3");
padding: 4px 8px 6px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.classes td {
padding: 8px 8px 8px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
white-space: nowrap;
}
.classes tbody tr {
cursor: pointer;
}
.classes tbody tr:hover {
background: v-bind("theme.hoverColor");
} }
</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,
}, },
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案 // 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
@@ -1,240 +1,771 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getTodaySubmissionStatistics } from "oj/api" import { getTodaySubmissionStatistics } from "oj/api"
import { LANGUAGE_SHOW_VALUE } from "utils/constants" import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
import { zonedParts } from "utils/functions" import { parseTime, zonedParts } from "utils/functions"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import type { SUBMISSION_RESULT, TodaySubmissionStatistics } from "utils/types"
import type { TodaySubmissionStatistics } from "@oj2/contract" import { useTone, type Tone } from "../composables/tone"
import { classLabel } from "../utils"
const emit = defineEmits<{ openProblem: [problem: string] }>() /**
* 今日统计(设计稿「今日统计重设计」)。一般是老师在看:今天哪几个班上了课、几点到几点、
* 错得最多的是哪几道(老师 B)。学生版从简:自己今天怎样、大家在做哪几道。
* 盯人、实时刷新是课堂看板的活,这里不做;有班正在上课时直接引过去。
*/
const emit = defineEmits<{
close: []
openProblem: [problem: string]
/** 拉到数之后把总数报回去:按钮上的数只在进页面时拉过一次,停久了会对不上 */
loaded: [total: number]
}>()
const themeVars = useThemeVars() const router = useRouter()
const theme = useThemeVars()
const tone = useTone()
const stats = ref<TodaySubmissionStatistics | null>(null) const stats = ref<TodaySubmissionStatistics | null>(null)
const loading = ref(true) const failed = ref(false)
// 柱子高度用的像素上限。CSS 里 .hour-bars 的高度跟着它
const BAR_MAX_HEIGHT = 72
// 「现在是几点」按东八区取,不跟浏览器时区走 —— 高亮错一格比不高亮更糟
const currentHour = zonedParts(new Date())!.hour
const maxHour = computed(() => Math.max(1, ...(stats.value?.hours ?? [])))
const maxLanguage = computed(() =>
Math.max(1, ...(stats.value?.languages ?? []).map((row) => row.count)),
)
/** 0 条的钟点不画柱子,横轴那条基线本身就代表「这个钟点没人交」 */
function barHeight(value: number) {
if (value <= 0) return "0px"
return `${Math.max(4, Math.round((value / maxHour.value) * BAR_MAX_HEIGHT))}px`
}
onMounted(async () => { onMounted(async () => {
try { try {
stats.value = await getTodaySubmissionStatistics() stats.value = await getTodaySubmissionStatistics()
} finally { emit("loaded", stats.value.total)
loading.value = false } catch {
failed.value = true
} }
}) })
const teacher = computed(() => stats.value?.classes != null)
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
const headText = computed(() => {
const at = stats.value?.asOf ?? new Date().toISOString()
const p = zonedParts(at)!
const weekday = WEEKDAYS[new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay()]
return `${p.month}月${p.day}日 ${weekday} · 截至 ${parseTime(at, "HH:mm")}`
})
function resultName(result: number) {
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
}
/** 结果条的颜色:答案正确绿、运行时错误橙、答案错误红、编译失败和判题中灰 */
function resultColor(result: number) {
if (result === -2) return theme.value.textColor3
// 等待评分 / 判题中:还没有结果,不该和运行时错误一样是橙色
if (result === 6 || result === 7) return theme.value.borderColor
const kind: Tone = JUDGE_STATUS[result as SUBMISSION_RESULT]?.type ?? "default"
if (kind !== "success" && kind !== "error" && kind !== "warning") return theme.value.borderColor
return tone(kind).color
}
function percent(value: number, total: number) {
return total ? `${(value / total) * 100}%` : "0"
}
// ---------- 老师:各班时间轴 ----------
/** 东八区的「几点几分」换成小时的小数,给时间轴定位 */
function hourOf(value: string) {
const p = zonedParts(value)!
return p.hour + p.minute / 60
}
/** 横轴默认 7 点到 20 点(机房上课的时段),有更早更晚的提交就往外扩 */
const axis = computed(() => {
const list = stats.value?.classes ?? []
const start = Math.min(7, ...list.map((item) => Math.floor(hourOf(item.start))))
const end = Math.max(20, ...list.map((item) => Math.ceil(hourOf(item.end))))
return { start, end, ticks: Array.from({ length: end - start + 1 }, (_, i) => start + i) }
})
function axisLeft(hour: number) {
const { start, end } = axis.value
return `${((hour - start) / (end - start)) * 100}%`
}
function barStyle(item: { start: string; end: string; live: boolean }) {
const from = hourOf(item.start)
const to = hourOf(item.end)
const { start, end } = axis.value
return {
left: axisLeft(from),
width: `max(8px, ${((to - from) / (end - start)) * 100}%)`,
background: item.live ? theme.value.successColor : "#3f5f87",
}
}
/** 横条靠近右边时文字放到条的左边,不然「497 次 · 正确率 31%」被弹框右边裁掉 */
function textStyle(item: { start: string; end: string }) {
const { start, end } = axis.value
if ((hourOf(item.end) - start) / (end - start) > 0.72) {
return { right: `calc(100% - ${axisLeft(hourOf(item.start))} + 10px)` }
}
return { left: `calc(${axisLeft(hourOf(item.end))} + 14px)` }
}
const nowHour = computed(() => {
const at = stats.value?.asOf
if (!at) return null
const hour = hourOf(at)
return hour >= axis.value.start && hour <= axis.value.end ? hour : null
})
const liveClasses = computed(() => (stats.value?.classes ?? []).filter((item) => item.live))
/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */
function attendance(item: { userCount: number; classSize: number }) {
return item.classSize >= item.userCount
? `${item.userCount} / ${item.classSize} 人`
: `${item.userCount} 人`
}
const scatteredText = computed(() =>
(stats.value?.scattered ?? [])
.map(
(item) =>
`${item.className ? classLabel(item.className) : "没填班级的"} ${item.userCount} 人 ${item.total} 次`,
)
.join("、"),
)
function openClass(className: string) {
const href = router.resolve({
name: "statistics",
query: { className, period: "today" },
}).href
window.open(href, "_blank")
}
const judgedResults = computed(() => stats.value?.results ?? [])
const languageText = computed(() => {
const list = stats.value?.languages ?? []
if (!list.length) return ""
if (list.length === 1) return `语言:全部是 ${LANGUAGE_SHOW_VALUE[list[0]!.language]}`
return `语言:${list.map((item) => `${LANGUAGE_SHOW_VALUE[item.language]} ${item.count}`).join(" · ")}`
})
// ---------- 学生 ----------
const meText = computed(() => {
const me = stats.value?.me
if (!me) return ""
return me.total ? `交了 ${me.total} 次,做对 ${me.solved} 道` : "还没交过"
})
</script> </script>
<template> <template>
<n-spin :show="loading"> <div class="today" :class="{ wide: teacher }">
<n-empty <div class="head">
v-if="stats && stats.total === 0" <b class="title">今日统计</b>
description="今天还没有人提交" <n-text depth="3" class="small">{{ headText }}</n-text>
style="margin: 40px 0" <span v-if="stats?.judging" class="pill">判题中 {{ stats.judging }}</span>
/> <span class="spacer"></span>
<template v-else-if="stats"> <button type="button" class="close" aria-label="关闭" @click="emit('close')">
<n-flex justify="space-around"> <Icon icon="ph:x" :width="18" />
<div class="stat-item"> </button>
<n-text>总提交</n-text> </div>
<n-gradient-text type="info" font-size="28">
{{ stats.total }} <div class="body">
</n-gradient-text> <n-text v-if="failed" type="error">统计没拉下来,关掉再打开试试</n-text>
<div v-else-if="!stats" class="center"><n-spin size="small" /></div>
<div v-else-if="!stats.total" class="empty">
<span class="empty-icon"><Icon icon="ph:chart-bar" :width="20" /></span>
<b>今天还没有人交</b>
<n-text depth="3">第一条提交进来之后这里就有数了</n-text>
</div>
<!-- ==================== 老师 ==================== -->
<template v-else-if="teacher">
<div class="numbers">
<div>
<b>{{ stats.total }}</b
><span>次提交</span>
</div>
<div>
<b>{{ stats.userCount }}</b
><span>人</span>
</div>
<div>
<b>{{ stats.correctRate }}%</b><span>提交正确率</span>
</div>
<div>
<b>{{ stats.classes!.length }}</b
><span>个班上了课</span>
</div>
</div> </div>
<div class="stat-item">
<n-text>正确提交</n-text>
<n-gradient-text type="primary" font-size="28">
{{ stats.accepted }}
</n-gradient-text>
</div>
<div class="stat-item" v-if="stats.judging > 0">
<n-text>判题中</n-text>
<n-gradient-text type="info" font-size="28">
{{ stats.judging }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>正确率</n-text>
<n-gradient-text type="warning" font-size="28">
{{ stats.correctRate }}%
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>参与人数</n-text>
<n-gradient-text type="error" font-size="28">
{{ stats.userCount }}
</n-gradient-text>
</div>
</n-flex>
<n-divider style="margin: 16px 0">按小时</n-divider>
<div class="hours">
<div class="hour" v-for="(value, hour) in stats.hours" :key="hour">
<n-tooltip>
<template #trigger>
<!--
基线是每一格自己的下边框拼出来的(整条横轴一根 border 也行,但那样
「现在」这一格就没法单独加粗)。**现在这一格加粗成主色**,
原来是给整格垫一层底色,结果那块浅灰看着就像一根柱子。
-->
<div <div
class="hour-bars" v-if="liveClasses.length"
:style="{ class="live"
borderBottomColor: :style="{ background: tone('success').background, color: tone('success').color }"
hour === currentHour ? themeVars.primaryColor : themeVars.dividerColor,
}"
> >
<div <span>
class="bar" {{ liveClasses.map((item) => classLabel(item.className)).join("、") }}正在上课
:style="{ </span>
height: barHeight(value), <span class="spacer"></span>
backgroundColor: themeVars.primaryColor, <a href="#" class="live-link" @click.prevent="router.push('/classroom')">
}" <Icon icon="ph:monitor" :width="14" />去课堂看板
></div> </a>
</div>
</template>
{{ hour }}:00 - {{ hour }}:59 共 {{ value }} 条
</n-tooltip>
<!-- 每 3 小时标一个刻度。标签占位始终留着,柱子才对得齐 -->
<div class="hour-label">{{ hour % 3 === 0 ? hour : "" }}</div>
</div>
</div> </div>
<n-divider style="margin: 16px 0">按语言</n-divider> <template v-if="stats.classes!.length">
<div class="rows"> <div class="section-title">
<div class="row" v-for="row in stats.languages" :key="row.language"> <b>今天哪几个班上了课</b>
<n-text class="row-name">{{ LANGUAGE_SHOW_VALUE[row.language] }}</n-text> <n-text depth="3" class="small">点班级名去数据统计看今天</n-text>
<n-progress
class="row-bar"
type="line"
:percentage="(row.count / maxLanguage) * 100"
:show-indicator="false"
:height="10"
/>
<n-text class="row-count">{{ row.count }}</n-text>
</div> </div>
</div> <div class="timeline">
<div v-for="item in stats.classes" :key="item.className" class="lane">
<n-divider style="margin: 16px 0">按状态</n-divider> <div class="lane-label">
<n-flex align="center"> <a href="#" class="lane-name" @click.prevent="openClass(item.className)">
<n-flex align="center" :size="4" v-for="row in stats.results" :key="row.result"> {{ classLabel(item.className) }}
<SubmissionResultTag :result="row.result" /> </a>
<n-text>{{ row.count }}</n-text> <n-text depth="3" class="tiny">
</n-flex> {{ attendance(item) }} · {{ item.fromProblemSet ? "题单" : ""
</n-flex> }}{{ item.problemCount }} 道题
<template v-if="stats.problems.length">
<n-divider style="margin: 16px 0">今天最热的题</n-divider>
<div class="rows">
<div class="row" v-for="row in stats.problems" :key="row.problemDisplayId">
<n-button
class="problem"
text
type="info"
@click="emit('openProblem', row.problemDisplayId)"
>
{{ row.problemDisplayId }} {{ row.problemTitle }}
</n-button>
<n-text class="row-count" depth="3">
{{ row.count }} 条 / 正确 {{ row.acceptedCount }}
</n-text> </n-text>
</div> </div>
<div class="lane-track">
<span
v-for="tick in axis.ticks"
:key="tick"
class="grid"
:style="{ left: axisLeft(tick) }"
></span>
<span class="lane-bar" :style="barStyle(item)"></span>
<span class="lane-text" :style="textStyle(item)">
{{ item.total }} 次 ·
{{ item.live ? "上课中" : `正确率 ${Math.round(item.correctRate)}%` }}
</span>
<span
v-if="nowHour !== null"
class="now"
:style="{ left: axisLeft(nowHour) }"
></span>
</div>
</div>
<div class="lane axis-row">
<div class="lane-label"></div>
<div class="lane-track axis">
<span v-for="tick in axis.ticks" :key="tick" :style="{ left: axisLeft(tick) }">
{{ tick }}
</span>
<span
v-if="nowHour !== null"
class="now-label"
:style="{ left: axisLeft(nowHour) }"
>
现在
</span>
</div>
</div>
</div>
</template>
<n-text v-if="scatteredText" depth="3" class="small">
{{ stats.classes!.length ? "另有零散提交:" : "今天没有哪个班一起上课,零散提交:"
}}{{ scatteredText }}
</n-text>
<template v-if="stats.hardProblems?.length">
<div class="section-title">
<b>错得最多的题</b>
<n-text depth="3" class="small">按没过的次数</n-text>
</div>
<table class="hard">
<thead>
<tr>
<th>题目</th>
<th>哪个班</th>
<th>提交</th>
<th>没过的原因</th>
<th class="right">做对</th>
</tr>
</thead>
<tbody>
<tr v-for="item in stats.hardProblems" :key="item.problemDisplayId">
<td class="hard-title">
<a href="#" @click.prevent="emit('openProblem', item.problemDisplayId)">
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.problemTitle }}
</a>
</td>
<td>
<n-text depth="2">{{ item.className ? classLabel(item.className) : "—" }}</n-text>
</td>
<td>{{ item.total }} 次</td>
<td>
<span class="mini">
<span
:style="{
width: percent(item.accepted, item.total),
background: resultColor(0),
}"
></span>
<span
v-for="fail in item.failures"
:key="fail.result"
:style="{
width: percent(fail.count, item.total),
background: resultColor(fail.result),
}"
></span>
</span>
<n-text depth="2" class="small">
{{ resultName(item.failures[0]!.result) }} {{ item.failures[0]!.count }}
</n-text>
</td>
<td
class="right"
:style="{
color: item.acceptedUsers < item.userCount ? tone('error').color : undefined,
fontWeight: item.acceptedUsers < item.userCount ? 600 : undefined,
}"
>
{{ item.acceptedUsers }} / {{ item.userCount }} 人
</td>
</tr>
</tbody>
</table>
</template>
<div class="foot">
<div class="foot-bar">
<div class="results">
<span
v-for="item in judgedResults"
:key="item.result"
:style="{
width: percent(item.count, stats.total),
background: resultColor(item.result),
}"
></span>
</div>
<div class="legend">
<span v-for="item in judgedResults.slice(0, 5)" :key="item.result">
<i :style="{ background: resultColor(item.result) }"></i
>{{ resultName(item.result) }}
<b>{{ item.count }}</b>
</span>
</div>
</div>
<n-text depth="3" class="small foot-meta">
{{ languageText }} ·
{{ stats.flowchartCount ? `流程图 ${stats.flowchartCount} 张` : "流程图今天没人画" }}
</n-text>
</div>
</template>
<!-- ==================== 学生:从简 ==================== -->
<template v-else>
<div v-if="meText" class="me">
<b>你今天</b>
<n-text depth="2">{{ meText }}</n-text>
</div>
<div class="numbers">
<div>
<b>{{ stats.total }}</b
><span>次提交</span>
</div>
<div>
<b>{{ stats.userCount }}</b
><span>人交了题</span>
</div>
<div>
<b>{{ Math.round(stats.correctRate) }}%</b><span>提交做对了</span>
</div>
</div>
<template v-if="stats.problems.length">
<b class="section-title">今天大家都在做</b>
<div class="rows">
<a
v-for="item in stats.problems"
:key="item.problemDisplayId"
href="#"
class="row"
@click.prevent="emit('openProblem', item.problemDisplayId)"
>
<span class="row-title">
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.problemTitle }}
</span>
<n-text depth="3" class="small">
{{ item.userCount }} 人做 · {{ item.acceptedUsers }} 人对
</n-text>
<span v-if="item.mine === 'accepted'" class="mine done">
<Icon icon="ph:check-bold" :width="12" />做对了
</span>
<span v-else class="mine go">{{ item.mine === "tried" ? "接着做" : "去做" }} ›</span>
</a>
</div> </div>
</template> </template>
</template> </template>
<!-- 请求失败时 stats 还是 null,转圈停下来总得留句话 --> </div>
<n-empty v-else-if="!loading" description="统计拉取失败" style="margin: 40px 0" /> </div>
<div v-else style="height: 200px"></div>
</n-spin>
</template> </template>
<style scoped> <style scoped>
.stat-item { .today {
width: min(560px, calc(100vw - 32px));
max-height: 86vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 12px;
background: v-bind("theme.cardColor");
box-shadow: v-bind("theme.boxShadow3");
font-size: 14px;
overflow: hidden;
}
.today.wide {
width: min(880px, calc(100vw - 32px));
}
.head {
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px 14px 24px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.title {
font-size: 18px;
}
.pill {
font-size: 12px;
padding: 1px 8px;
border-radius: 10px;
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor2");
}
.spacer {
flex: 1 1 0;
}
.close {
width: 32px;
height: 32px;
border: 0;
border-radius: 4px;
background: transparent;
color: v-bind("theme.textColor2");
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.close:hover {
background: v-bind("theme.actionColor");
}
.body {
overflow: auto;
padding: 18px 24px 22px;
display: flex;
flex-direction: column;
gap: 14px;
}
.small {
font-size: 13px;
}
.tiny {
font-size: 12px;
}
.center {
display: flex;
justify-content: center;
padding: 40px 0;
}
.empty {
padding: 40px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.empty-icon {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor2");
}
.numbers {
display: flex;
gap: 36px;
flex-wrap: wrap;
}
.numbers > div {
display: flex;
flex-direction: column;
}
.numbers b {
font-size: 26px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.numbers span {
font-size: 13px;
color: v-bind("theme.textColor3");
}
.live {
display: flex;
align-items: center;
padding: 10px 14px;
border-radius: 8px;
}
.live-link {
color: inherit;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.hours { .section-title {
display: flex; display: flex;
align-items: flex-end; align-items: baseline;
gap: 2px; gap: 10px;
} }
.hour { .timeline {
display: flex;
flex-direction: column;
}
.lane {
display: flex;
min-height: 42px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.lane.axis-row {
min-height: 24px;
border-bottom: 0;
}
.lane-label {
width: 150px;
flex: none;
display: flex;
flex-direction: column;
justify-content: center;
}
.lane-name {
font-weight: 600;
color: v-bind("theme.textColor1");
text-decoration: none;
}
.lane-name:hover {
color: v-bind("theme.primaryColor");
}
.lane-track {
position: relative;
flex: 1; flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
} }
.hour-bars { .grid {
height: 72px; position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: v-bind("theme.dividerColor");
}
.lane-bar {
position: absolute;
top: 50%;
height: 16px;
margin-top: -8px;
border-radius: 4px;
}
.lane-text {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 13px;
white-space: nowrap;
color: v-bind("theme.textColor2");
}
.now {
position: absolute;
top: -4px;
bottom: -4px;
width: 2px;
background: v-bind("theme.successColor");
}
.axis span {
position: absolute;
top: 4px;
transform: translateX(-50%);
font-size: 11px;
color: v-bind("theme.textColor3");
}
.axis .now-label {
top: 4px;
font-weight: 600;
color: v-bind("theme.successColor");
background: v-bind("theme.cardColor");
padding: 0 2px;
}
.hard {
width: 100%; width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.hard th {
text-align: left;
font-weight: normal;
color: v-bind("theme.textColor3");
padding: 4px 8px 6px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.hard td {
padding: 8px 8px 8px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
white-space: nowrap;
}
.hard .right {
text-align: right;
padding-right: 0;
}
.hard-title a {
color: v-bind("theme.textColor1");
text-decoration: none;
}
.hard-title a:hover {
color: v-bind("theme.primaryColor");
}
.mini {
display: inline-flex;
width: 70px;
height: 8px;
border-radius: 2px;
overflow: hidden;
gap: 1px;
margin-right: 8px;
vertical-align: middle;
}
.foot {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
cursor: default; gap: 24px;
/* 每格一段,拼成整条横轴。「现在」那一格换主色,粗细不动 —— 变粗会让那一格的 flex-wrap: wrap;
柱子底比别人高 1px */
border-bottom: 2px solid;
} }
.bar { .foot-bar {
width: 100%; flex: 1;
border-radius: 2px 2px 0 0; min-width: 280px;
transition: height 0.2s; display: flex;
flex-direction: column;
gap: 8px;
} }
.hour-label { .results {
font-size: 11px; display: flex;
line-height: 14px; height: 10px;
opacity: 0.6; border-radius: 3px;
overflow: hidden;
gap: 1px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
font-size: 13px;
color: v-bind("theme.textColor2");
}
.legend i {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 2px;
margin-right: 5px;
}
.me {
display: flex;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
background: v-bind("theme.actionColor");
} }
.rows { .rows {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px;
} }
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 9px 2px;
border-bottom: 1px solid v-bind("theme.dividerColor");
color: v-bind("theme.textColor1");
text-decoration: none;
} }
.row-name { .row-title {
width: 72px;
flex-shrink: 0;
}
.row-bar {
flex: 1; flex: 1;
} min-width: 0;
.row-count {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.problem {
flex: 1;
justify-content: flex-start;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mine {
flex: none;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 3px;
}
.mine.done {
color: v-bind("theme.successColor");
}
.mine.go {
color: v-bind("theme.primaryColor");
} }
</style> </style>
+7 -8
View File
@@ -1068,14 +1068,13 @@ function dayBreak(index: number) {
</n-drawer-content> </n-drawer-content>
</n-drawer> </n-drawer>
<n-modal <!-- 标题栏在组件里:要写「截至几点」「判题中几条」,只有拉到数之后才知道 -->
v-model:show="todayPanel" <n-modal v-model:show="todayPanel">
preset="card" <TodayStatistics
:style="{ maxWidth: isDesktop && '700px', maxHeight: '80vh' }" @close="todayPanel = false"
:content-style="{ overflow: 'auto' }" @loaded="(total: number) => (todayCount = total)"
title="今日提交统计" @open-problem="(id: string) => openProblem({ problemDisplayId: id } as Row)"
> />
<TodayStatistics @open-problem="(id: string) => openProblem({ problemDisplayId: id } as Row)" />
</n-modal> </n-modal>
</template> </template>
+2
View File
@@ -81,6 +81,8 @@ 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 } from "@oj2/contract"
export type { TodaySubmissionStatistics } from "@oj2/contract"
export type { export type {
AdminTag, AdminTag,
+46 -6
View File
@@ -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,50 @@ 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(), * 题目页「统计」页签。全部**按人**算,不按提交条数 —— 原来的「通过数」是 AC 条数
acRate: z.number(), * (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(),
/** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天) */
day: z.string(),
tried: z.number().int(),
solved: z.number().int(),
/** 班里的学生数(学生角色、没禁用),没交的 = classSize - tried */
classSize: z.number().int(),
/** 这个班错得最多的那种,一次没错过为 null */
topFailure: failureCountSchema.nullable(),
}),
)
.nullable(),
}) })
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema> export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
@@ -468,4 +508,4 @@ 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>
+72 -9
View File
@@ -413,7 +413,16 @@ export const submissionStatisticsSchema = z.object({
* 口径跟着那颗标签走:**东八区今天、非比赛提交、不分语言**(语言分布就是 * 口径跟着那颗标签走:**东八区今天、非比赛提交、不分语言**(语言分布就是
* `languages` 这张表本身)。 * `languages` 这张表本身)。
*/ */
const todayFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
/**
* 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师在看(设计稿「今日统计重设计」老师 B):
* 今天哪几个班上了课、各在几点、错得最多的是哪几道。学生版从简:自己今天怎样、大家在做哪几道。
* 口径:东八区今天 + 非比赛提交。
*/
export const todaySubmissionStatisticsSchema = z.object({ export const todaySubmissionStatisticsSchema = z.object({
/** 这份数据是几点算的 —— 标题上的「截至 19:40」 */
asOf: z.string(),
total: z.number().int(), total: z.number().int(),
/** 通过的条数,含 AST_CHECK_FAILED(那也是答案对了) */ /** 通过的条数,含 AST_CHECK_FAILED(那也是答案对了) */
accepted: z.number().int(), accepted: z.number().int(),
@@ -425,24 +434,78 @@ export const todaySubmissionStatisticsSchema = z.object({
correctRate: z.number(), correctRate: z.number(),
/** 今天交过东西的人数,按 user_id 去重 */ /** 今天交过东西的人数,按 user_id 去重 */
userCount: z.number().int(), userCount: z.number().int(),
/** 按东八区钟点分的 24 个桶,**下标就是钟点**,没有提交的钟点是 0 */ /** 今天画了几张流程图,0 就不提 */
hours: z.array(z.number().int()).length(24), flowchartCount: z.number().int(),
/** 按语言,提交数倒序。零提交的语言不在表里 */ /** 按语言,提交数倒序。只有一种语言时界面上缩成一句话 */
languages: z.array(z.object({ language: problemLanguageSchema, count: z.number().int() })), languages: z.array(z.object({ language: problemLanguageSchema, count: z.number().int() })),
/** 按判题结果,条数倒序 */ /** 按判题结果,条数倒序 —— 画成底部那根结果条 */
results: z.array(z.object({ result: judgeStatusSchema, count: z.number().int() })), results: z.array(todayFailureSchema),
/** 我今天交了几次、做对几道。没登录为 null */
me: z.object({ total: z.number().int(), solved: z.number().int() }).nullable(),
/** /**
* 今天最热的几道题,提交数倒序,最多 10 道。 * 学生版「今天大家都在做」:按做的人数倒序,最多 8 道。**只含公开可见的题目** ——
* **只含公开可见的题目** —— 这个接口不需要登录,不能拿它探未发布题目的标题。 * 这个接口不需要登录,不能拿它探未发布题目的标题。「提交列表对学生全开」关掉时为空
*/ */
problems: z.array( problems: z.array(
z.object({ z.object({
problemDisplayId: z.string(), problemDisplayId: z.string(),
problemTitle: z.string(), problemTitle: z.string(),
count: z.number().int(), userCount: z.number().int(),
acceptedCount: z.number().int(), acceptedUsers: z.number().int(),
/** 我自己今天在这道题上:做对了 / 交了没对 / 没交;没登录为 null */
mine: z.enum(["accepted", "tried", "none"]).nullable(),
}), }),
), ),
/**
* 老师:今天上了课的班(同班 5 人以上做了同一道题),按开始时间排。学生为 null。
* 时段是这个班今天第一条到最后一条提交,`live` = 最后一条在 15 分钟内(正在上课,
* 界面上引去课堂看板 —— 盯人是看板的活)
*/
classes: z
.array(
z.object({
className: z.string(),
start: z.string(),
end: z.string(),
userCount: z.number().int(),
/** 班里的学生数 */
classSize: z.number().int(),
/** 这节课做的题数(班里 5 人以上做过的) */
problemCount: z.number().int(),
/** 多半是从题单里交的 —— 「题单 9 道」 */
fromProblemSet: z.boolean(),
total: z.number().int(),
correctRate: z.number(),
live: z.boolean(),
}),
)
.nullable(),
/** 老师:没上课的零星提交,按班(null = 没填班级的号) */
scattered: z
.array(
z.object({
className: z.string().nullable(),
userCount: z.number().int(),
total: z.number().int(),
}),
)
.nullable(),
/** 老师:错得最多的题,按没过的次数倒序,最多 6 道 */
hardProblems: z
.array(
z.object({
problemDisplayId: z.string(),
problemTitle: z.string(),
/** 这道题今天交得最多的那个班 */
className: z.string().nullable(),
total: z.number().int(),
accepted: z.number().int(),
failures: z.array(todayFailureSchema),
userCount: z.number().int(),
acceptedUsers: z.number().int(),
}),
)
.nullable(),
}) })
export const formatCodeRequestSchema = z.object({ export const formatCodeRequestSchema = z.object({