diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index bd6fda6..bcfb611 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -40,6 +40,7 @@ import { type FlowchartStatistics, type SubmissionStatistics, type SubmissionStatisticsItems, + type SubmissionStatisticsGrid, type TodaySubmissionStatistics, } from "@oj2/contract" import api from "utils/api" @@ -197,6 +198,17 @@ export function getSubmissionStatistics( }) } +/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */ +export function getSubmissionStatisticsGrid( + duration: { start?: string; end: string }, + problemDisplayId?: string, + username?: string, +) { + return api.get("submissions/statistics/grid", { + params: { ...duration, problemDisplayId, username }, + }) +} + /** * 全服榜单。上限(100 名)由服务端定,调用方只管翻页 —— * 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。 diff --git a/apps/web/src/oj/classroom/board.vue b/apps/web/src/oj/classroom/board.vue index 919e7bc..de2b1e5 100644 --- a/apps/web/src/oj/classroom/board.vue +++ b/apps/web/src/oj/classroom/board.vue @@ -1,5 +1,6 @@ + + + + diff --git a/apps/web/src/oj/statistics/components/RingChart.vue b/apps/web/src/oj/statistics/components/RingChart.vue new file mode 100644 index 0000000..464f62a --- /dev/null +++ b/apps/web/src/oj/statistics/components/RingChart.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/apps/web/src/oj/statistics/components/StatItem.vue b/apps/web/src/oj/statistics/components/StatItem.vue new file mode 100644 index 0000000..1460847 --- /dev/null +++ b/apps/web/src/oj/statistics/components/StatItem.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/apps/web/src/oj/statistics/index.vue b/apps/web/src/oj/statistics/index.vue new file mode 100644 index 0000000..097d9ab --- /dev/null +++ b/apps/web/src/oj/statistics/index.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/apps/web/src/oj/statistics/period.ts b/apps/web/src/oj/statistics/period.ts new file mode 100644 index 0000000..e1e7964 --- /dev/null +++ b/apps/web/src/oj/statistics/period.ts @@ -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}` +} diff --git a/apps/web/src/oj/submission/list.vue b/apps/web/src/oj/submission/list.vue index 7bf9f03..250c6ae 100644 --- a/apps/web/src/oj/submission/list.vue +++ b/apps/web/src/oj/submission/list.vue @@ -44,10 +44,6 @@ import { classLabel, submissionClockText, submissionDayText } from "./utils" // (光 chart.js 就 197KB) const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue")) const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue")) -const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue")) -const FlowchartStatisticsPanel = defineAsyncComponent( - () => import("shared/components/FlowchartStatisticsPanel.vue"), -) const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue")) interface SubmissionQuery { @@ -272,11 +268,10 @@ function move(step: 1 | -1) { query.page = page } -const [statisticPanel, toggleStatisticPanel] = useToggle(false) const [todayPanel, toggleTodayPanel] = useToggle(false) onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => { - if (!isDesktop.value || statisticPanel.value || todayPanel.value) return + if (!isDesktop.value || todayPanel.value) return // 焦点在输入框、下拉框里时不抢方向键 const target = e.target as HTMLElement | null if ( @@ -504,6 +499,24 @@ function filterUser(username: string) { query.username = username } +/** + * 「数据统计」:新标签打开统计页,带上现在的班级 / 学生 / 题号 / 代码或流程图 + * (原来是这一页上的弹框)。「今天」带过去就是统计页的「今天」 + */ +function openStatistics() { + const href = router.resolve({ + name: "statistics", + query: { + ...(flowMode.value ? { tab: "flow" } : {}), + ...(query.className ? { className: query.className } : {}), + ...(query.username ? { username: query.username } : {}), + ...(query.problem ? { problem: query.problem } : {}), + ...(query.today === "1" ? { period: "today" } : {}), + }, + }).href + window.open(href, "_blank") +} + function filterProblem(displayId: string) { query.problem = displayId } @@ -692,11 +705,7 @@ function dayBreak(index: number) { {{ todayCount }} - + 数据统计 @@ -929,25 +938,6 @@ function dayBreak(index: number) { - - - - import("oj/classroom/board.vue"), meta: { requiresAuth: true, requiresTeacherAdmin: true }, }, + { + path: "statistics", + name: "statistics", + component: () => import("oj/statistics/index.vue"), + meta: { requiresAuth: true, requiresTeacherAdmin: true }, + }, { path: "class", name: "class", diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index 79fcb44..5ae8314 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -156,8 +156,6 @@ export const STORAGE_KEY = { ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed", DEMO_MODE: "demoMode", LOGIN_CLASS: "loginClass", - STATISTICS_QUERY: "statisticsQuery", - FLOWCHART_STATISTICS_QUERY: "flowchartStatisticsQuery", } export const DIFFICULTY = { @@ -328,23 +326,6 @@ export const DURATION_OPTIONS = [ { label: "一年内", value: "years:1" }, ] as const -/** - * 两个统计面板(提交统计、流程图统计)的时段下拉。 - * - * 比通用的那份多了头尾:前面三档分钟级是给**上课当场**用的 —— 老师布置完一道题, - * 想看的就是「刚才这十分钟谁交了」;末尾的 `all` 不是一个时长,`query.duration === "all"` - * 会走各自的分支不带时间条件,所以它永远不会进 durationFromValue()。 - * - * 原来这份列表在两个面板里逐字抄了两遍。 - */ -export const PANEL_DURATION_OPTIONS = [ - { label: "10分钟内", value: "minutes:10" }, - { label: "20分钟内", value: "minutes:20" }, - { label: "30分钟内", value: "minutes:30" }, - ...DURATION_OPTIONS, - { label: "全部时段", value: "all" }, -] as const - /** * 榜单和班级对比用的长时段,一周起步。 *