feat(提交列表): 今日统计重做——老师看今天哪几个班上了课、几点到几点、错得最多的题;学生版从简
- 老师:各班上课时段画在一条 7–20 点的时间轴上(点班级名去数据统计),有班正在上课时引去课堂看板 - 错得最多的题带班级、没过的原因、做对 x/y 人;底部一根全站结果条,语言只有一种时缩成一句 - 学生:你今天交了几次做对几道 + 今天大家都在做(按人数,带去做 / 接着做) - 标题栏写「截至几点」、判题中几条;打开时把按钮上的数一起更新(原来只在进页面时拉一次) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99319fdcc5
commit
2fddf4a011
5 files changed
+977
-267
No files matched your search
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
|
|||||||
*/
|
*/
|
||||||
export type { Tag, TagCategory } from "@oj2/contract"
|
export type { Tag, TagCategory } from "@oj2/contract"
|
||||||
export type { ProblemStats, ProblemClassDetail } from "@oj2/contract"
|
export type { ProblemStats, ProblemClassDetail } from "@oj2/contract"
|
||||||
|
export type { TodaySubmissionStatistics } from "@oj2/contract"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AdminTag,
|
AdminTag,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in new issue
Block a user