feat(统计): 数据统计从弹框改成独立页面,左边题目、右边学生,一道题看全班每人的每一次

- 课堂看板管这节课,统计管回头看:时间段默认「两节课」,还有这节课、今天、7 天、30 天、这学期、全部、自己选日子;不再自动刷新
- 选一道题看每人在这道题上的方块串,鼠标停上去看那次的代码;选「全部」每人一行、展开按题看
- 没选班级(从题目页进来)先按班级汇总;流程图统计有等级分布、四项雷达、各项平均分、词云和每人最好的一次
- 提交列表、题目页的「数据统计」改成新标签打开这个页面
- 「请假隐藏」挪到课堂看板:点名字旁的 × 藏两个小时,做完人数的分母一起减掉

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 18:28:27 +08:00
1 parent f09da88ff7
commit ed55ee5af6
16 files changed
+2256 -76

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
import { TIME_ZONE_OFFSET_MINUTES } from "@oj2/contract"
import { parseTime, zonedParts } from "utils/functions"
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const HOUR = 3_600_000
const DAY = 86_400_000
/**
* 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、
* 默认「两节课」;后面几档是回头看。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的,
* 那件事现在归课堂看板。
*/
export const PERIOD_OPTIONS = [
{ label: "这节课(1 小时内)", value: "1h" },
{ label: "两节课(2 小时内)", value: "2h" },
{ label: "今天", value: "today" },
{ label: "最近 7 天", value: "7d" },
{ label: "最近 30 天", value: "30d" },
{ label: "这学期", value: "term" },
{ label: "全部", value: "all" },
{ label: "自己选日子", value: "custom" },
] as const
export type Period = (typeof PERIOD_OPTIONS)[number]["value"]
export const DEFAULT_PERIOD: Period = "2h"
/** 东八区某天 0 点对应的 UTC 时刻 */
function dayStartOf(year: number, month: number, day: number) {
return Date.UTC(year, month - 1, day) - OFFSET_MS
}
/** 东八区「现在」这一天的 0 点 */
function todayStart(now: number) {
const p = zonedParts(new Date(now))!
return dayStartOf(p.year, p.month, p.day)
}
/** 这学期从哪天起:9 月到次年 2 月算秋季学期(9 月 1 日起),3 到 8 月算春季(3 月 1 日起) */
function termStart(now: number) {
const p = zonedParts(new Date(now))!
if (p.month >= 9) return dayStartOf(p.year, 9, 1)
if (p.month >= 3) return dayStartOf(p.year, 3, 1)
return dayStartOf(p.year - 1, 9, 1)
}
/**
* 时间段换成接口要的 start / end(ISO),外加一句给人看的范围。
* `custom` 用的是 [开始日 0 点, 结束日 24 点),两个都是 date picker 取回来的 UTC 时刻。
*/
export function periodRange(period: Period, custom: [number, number] | null, now = Date.now()) {
let start: number | null
let end = now
switch (period) {
case "1h":
start = now - HOUR
break
case "2h":
start = now - 2 * HOUR
break
case "today":
start = todayStart(now)
break
case "7d":
start = todayStart(now) - 6 * DAY
break
case "30d":
start = todayStart(now) - 29 * DAY
break
case "term":
start = termStart(now)
break
case "custom":
if (custom) {
start = custom[0]
end = custom[1] + DAY
} else {
start = todayStart(now)
}
break
default:
start = null
}
const text = describe(start, end, period)
return {
start: start === null ? undefined : new Date(start).toISOString(),
end: new Date(end).toISOString(),
text,
/** 范围落在今天以内:提交列表那边能用「今天」接上 */
withinToday: start !== null && start >= todayStart(now),
}
}
function describe(start: number | null, end: number, period: Period) {
if (start === null) return ""
if (period === "1h" || period === "2h") {
return `${parseTime(new Date(start), "HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
}
const from = parseTime(new Date(start), "M月D日")
const to = parseTime(new Date(end - 1), "M月D日")
return from === to ? from : `${from}–${to}`
}