From e3be3cd62c2eac7280e2995c092ffeaab59e3853 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 30 Sep 2026 09:23:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BB=9F=E8=AE=A1):=20=E3=80=8C=E4=BB=8A?= =?UTF-8?q?=E6=97=A5=E7=BB=9F=E8=AE=A1=E3=80=8D=E3=80=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E3=80=8D=E5=90=88=E6=88=90=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E3=80=8C=E7=BB=9F=E8=AE=A1=E3=80=8D=EF=BC=8C=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=98=AF=E4=B8=80=E8=A1=8C=E4=B8=80=E8=8A=82=E8=AF=BE=EF=BC=8C?= =?UTF-8?q?=E8=BF=99=E8=8A=82=E8=AF=BE=E5=9C=A8=E6=9C=80=E4=B8=8A=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 老师的提交列表上只剩一个「统计」按钮;什么都没筛时按课列(选「今天」就是原来今日统计看的), 点一行进那节课(班、题、时段都带好),左上「← 全部」回来;只填题号时仍按班级汇总,和题目页 统计页签对得上。数字行加结果条,统计里的「课堂看板」按钮去掉,改成有班正在上课时才引过去。 学生仍是「今日统计」。 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/oj/api.ts | 8 + apps/web/src/oj/classroom/board.vue | 2 +- .../src/oj/problem/components/ProblemInfo.vue | 10 +- .../src/oj/statistics/components/Lessons.vue | 561 ++++++++++++++++++ .../statistics/components/StatisticsView.vue | 270 ++++++++- apps/web/src/oj/statistics/index.vue | 2 +- apps/web/src/oj/statistics/period.ts | 17 +- apps/web/src/oj/statistics/results.ts | 25 + .../submission/components/TodayStatistics.vue | 490 +-------------- apps/web/src/oj/submission/list.vue | 54 +- apps/web/src/shared/components/Header.vue | 2 +- 11 files changed, 891 insertions(+), 550 deletions(-) create mode 100644 apps/web/src/oj/statistics/components/Lessons.vue create mode 100644 apps/web/src/oj/statistics/results.ts diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index e35bc2d..7e94f07 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -40,6 +40,7 @@ import { type UserBadge, type FlowchartStatistics, type SubmissionStatistics, + type SubmissionLessons, type SubmissionStatisticsGrid, type TodaySubmissionStatistics, } from "@oj2/contract" @@ -189,6 +190,13 @@ export function getSubmissionStatistics( }) } +/** 统计「一行一节课」的总览(没选班、没填学生和题号时)。limit 是列几节课,「再往前看」加它 */ +export function getSubmissionLessons(duration: { start?: string; end: string }, limit?: number) { + return api.get("submissions/statistics/lessons", { + params: { ...duration, limit }, + }) +} + /** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */ export function getSubmissionStatisticsGrid( duration: { start?: string; end: string }, diff --git a/apps/web/src/oj/classroom/board.vue b/apps/web/src/oj/classroom/board.vue index c3ffc79..bda6fe9 100644 --- a/apps/web/src/oj/classroom/board.vue +++ b/apps/web/src/oj/classroom/board.vue @@ -269,7 +269,7 @@ function submissionsHref( > 刷新 - + () @@ -139,7 +139,7 @@ const hiddenClasses = computed( () => (stats.value?.classes?.length ?? 0) - shownClasses.value.length, ) -/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */ +/** 点一个班、或者表下面那行:去「统计」按人看(新标签,题目页不动) */ function openStatistics(className?: string) { const href = router.resolve({ name: "statistics", @@ -273,7 +273,7 @@ function segmentWidth(value: number, total: number) { - +
各班做得怎样 @@ -292,7 +292,7 @@ function segmentWidth(value: number, total: number) { v-for="item in shownClasses" :key="item.className ?? ''" :class="{ pickable: item.className }" - :title="item.className ? '去数据统计看这个班的每个人' : undefined" + :title="item.className ? '新标签看这个班的每个人' : undefined" @click="item.className && openStatistics(item.className)" >
@@ -332,7 +332,7 @@ function segmentWidth(value: number, total: number) { - 按人看谁没做对,去数据统计 + 按人看谁没做对 diff --git a/apps/web/src/oj/statistics/components/Lessons.vue b/apps/web/src/oj/statistics/components/Lessons.vue new file mode 100644 index 0000000..a8a21c0 --- /dev/null +++ b/apps/web/src/oj/statistics/components/Lessons.vue @@ -0,0 +1,561 @@ + + + + + diff --git a/apps/web/src/oj/statistics/components/StatisticsView.vue b/apps/web/src/oj/statistics/components/StatisticsView.vue index cc677d5..246ba6f 100644 --- a/apps/web/src/oj/statistics/components/StatisticsView.vue +++ b/apps/web/src/oj/statistics/components/StatisticsView.vue @@ -3,11 +3,13 @@ import { Icon } from "@iconify/vue" import { useThemeVars } from "naive-ui" import type { FlowchartStatistics, + SubmissionLessons, SubmissionStatistics, SubmissionStatisticsGrid, } from "@oj2/contract" import { getFlowchartStatistics, + getSubmissionLessons, getSubmissionStatistics, getSubmissionStatisticsGrid, } from "oj/api" @@ -16,15 +18,24 @@ import { fromPickerValue, toPickerValue } from "utils/functions" import { useTone } from "oj/submission/composables/tone" import { classLabel } from "oj/submission/utils" import { DEFAULT_PERIOD, PERIOD_OPTIONS, periodRange, type Period } from "../period" +import { resultName, useResultColor } from "../results" import ByClass from "./ByClass.vue" import CodeStats from "./CodeStats.vue" +import Lessons from "./Lessons.vue" import RingChart from "./RingChart.vue" import StatItem from "./StatItem.vue" /** - * 数据统计的主体(设计稿「统计重设计 · 定稿 B」)。两处用它:提交列表里的弹框(老师 - * 平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页、今日统计之类从别处 - * 进来的,条件放在地址栏里能刷新、能转发)。 + * 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框 + * (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的, + * 条件放在地址栏里能刷新、能转发)。 + * + * 代码页签按条件分三种看法: + * - 什么都没填:**一行一节课**(Lessons)。原来「今日统计」老师那一版并进来了,选「今天」 + * 就是它,选别的时间段也一样有 + * - 只填了题号:**按班级汇总**(ByClass)—— 题目页统计页签点进来就是这种,口径和那边的各班表一致 + * - 选了班或填了学生:左边题目、右边学生(CodeStats) + * 从前两种点进去时左上角有「← 全部」回来。 * * 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有 * 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。 @@ -44,10 +55,10 @@ const emit = defineEmits<{ list: [query: Record] }>() -const router = useRouter() const configStore = useConfigStore() const theme = useThemeVars() const tone = useTone() +const resultColor = useResultColor() const tab = defineModel("tab", { default: "code" }) const className = defineModel("className", { default: "" }) @@ -57,12 +68,6 @@ const period = defineModel("period", { default: DEFAULT_PERIOD }) const from = defineModel("from", { default: "" }) const to = defineModel("to", { default: "" }) -const periodValue = computed(() => - PERIOD_OPTIONS.some((option) => option.value === period.value) - ? (period.value as Period) - : DEFAULT_PERIOD, -) - /** 地址栏里的 from / to 是手改过、不是数字的话当没选(不然 toISOString 抛错整页空白) */ const custom = computed<[number, number] | null>(() => { const a = Number(from.value) @@ -70,6 +75,20 @@ const custom = computed<[number, number] | null>(() => { return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null }) +const periodValue = computed(() => { + // 「那节课」要带着精确的起止时刻,地址栏里缺了就退回默认 + if (period.value === "lesson") return custom.value ? "lesson" : DEFAULT_PERIOD + return PERIOD_OPTIONS.some((option) => option.value === period.value) + ? (period.value as Period) + : DEFAULT_PERIOD +}) + +/** 从「一行一节课」点进某一节课时,下拉里临时多一项「那节课」 */ +const periodOptions = computed(() => [ + ...(periodValue.value === "lesson" ? [{ label: "那节课", value: "lesson" }] : []), + ...PERIOD_OPTIONS.map((o) => ({ label: o.label, value: o.value as string })), +]) + /** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */ const pickerValue = computed<[number, number] | null>(() => custom.value ? [toPickerValue(custom.value[0]), toPickerValue(custom.value[1])] : null, @@ -99,14 +118,31 @@ const loading = ref(false) const failed = ref("") const stats = ref(null) const grid = ref(null) +const lessons = ref(null) const flow = ref(null) +type Mode = "lessons" | "byClass" | "people" +/** 三种看法,见顶上的注释 */ +const mode = computed(() => + className.value || studentParam.value ? "people" : problem.value.trim() ? "byClass" : "lessons", +) + +/** 一次列几节课,「更早的课」每点一次多 50 节 */ +const LESSON_PAGE = 50 +const lessonLimit = ref(LESSON_PAGE) +const loadingMore = ref(false) + /** * 这一份数据是按什么条件查出来的。页面上的标签(按班级汇总还是看人、「做完」还是 * 「做对过题」、时间范围那句话)跟着它走,不跟着输入框 —— 老师边打字边看时,数据还是 * 上一次的,标签先变了就对不上 */ -const shown = ref({ byClass: true, explicit: false, rangeText: "", withinToday: false }) +const shown = ref<{ mode: Mode; explicit: boolean; rangeText: string; withinToday: boolean }>({ + mode: "lessons", + explicit: false, + rangeText: "", + withinToday: false, +}) let seq = 0 const ERROR_TEXT: Record = { @@ -119,7 +155,7 @@ async function load() { const range = periodRange(periodValue.value, custom.value) const duration = { start: range.start, end: range.end } const snapshot = { - byClass: !studentParam.value && !className.value, + mode: mode.value, explicit: !!problem.value.trim(), rangeText: range.text, withinToday: range.withinToday, @@ -136,6 +172,16 @@ async function load() { ) if (current !== seq) return flow.value = res + } else if (snapshot.mode === "lessons") { + // 一行一节课不要方块串:那个接口最多 5000 条,课在后端切 + lessonLimit.value = LESSON_PAGE + const [s, l] = await Promise.all([ + getSubmissionStatistics(duration, problem.value, studentParam.value, className.value), + getSubmissionLessons(duration, lessonLimit.value), + ]) + if (current !== seq) return + stats.value = s + lessons.value = l } else { const [s, g] = await Promise.all([ getSubmissionStatistics(duration, problem.value, studentParam.value, className.value), @@ -151,6 +197,7 @@ async function load() { // 失败了就别留着上一次的结果配这一次的条件 —— 清掉,说清楚为什么 stats.value = null grid.value = null + lessons.value = null flow.value = null const code = (error as { error?: string })?.error ?? "" failed.value = ERROR_TEXT[code] ?? "没拉下来,改一下条件再试" @@ -159,6 +206,26 @@ async function load() { } } +/** 「更早的课」:只重拉课那一份,数字行不动 */ +async function loadMore() { + const current = seq + loadingMore.value = true + try { + const range = periodRange(periodValue.value, custom.value) + const next = await getSubmissionLessons( + { start: range.start, end: range.end }, + lessonLimit.value + LESSON_PAGE, + ) + if (current !== seq) return + lessonLimit.value += LESSON_PAGE + lessons.value = next + } catch { + if (current === seq) failed.value = "更早的课没拉下来,再点一次试试" + } finally { + loadingMore.value = false + } +} + onMounted(load) watch([tab, className, periodValue, custom], load) watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 }) @@ -176,11 +243,13 @@ const judged = computed(() => stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0, ) -// ---------- 两个入口 ---------- +// ---------- 右上角的入口 ---------- /** - * 1366 以下(机房 1280 的屏)右上两个入口只留图标。弹框比屏幕窄一圈、还多一个关闭, - * 1366 的屏上整排放不下,所以弹框里宽一档才出字 + * 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭, + * 1366 的屏上整排放不下,所以弹框里宽一档才出字。 + * 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的), + * 现在只在有班正在上课时,在课的列表上方引过去 */ const narrow = useMediaQuery(props.modal ? "(max-width: 1439px)" : "(max-width: 1365px)") @@ -194,16 +263,79 @@ function openList() { }) } +// ---------- 点进去 / 回来 ---------- + +type Snapshot = Record<"className" | "username" | "problem" | "period" | "from" | "to", string> +/** 从「一行一节课」或「按班级汇总」点进去之前的条件,「← 全部」拿它回去 */ +const backTo = ref(null) + +function remember() { + backTo.value = { + className: className.value, + username: username.value, + problem: problem.value, + period: period.value, + from: from.value, + to: to.value, + } +} + +function goBack() { + const snap = backTo.value + if (!snap) return + backTo.value = null + className.value = snap.className + username.value = snap.username + problem.value = snap.problem + period.value = snap.period + from.value = snap.from + to.value = snap.to +} + +// 自己把条件清回了总览,就不用再显示「← 全部」 +watch(mode, (value) => { + if (value === "lessons") backTo.value = null +}) + function pickClass(value: string) { + remember() className.value = value username.value = "" } + +/** 点一节课:这个班、这节课的题、这节课第一条到最后一条(多留 1 秒:库里存到微秒,转成毫秒会截掉零头) */ +function pickLesson(lesson: SubmissionLessons["lessons"][number]) { + remember() + className.value = lesson.className + username.value = "" + problem.value = lesson.problems.join(" ") + from.value = String(Date.parse(lesson.start)) + to.value = String(Date.parse(lesson.end) + 1000) + period.value = "lesson" +} + +/** 点「错得最多的题」里的一道:只看这道,按班级汇总 */ +function pickProblem(displayId: string) { + remember() + problem.value = displayId +} + +// ---------- 数字行的结果条 ---------- + +const results = computed(() => stats.value?.results ?? []) +function resultWidth(value: number) { + const total = stats.value?.submissionCount ?? 0 + return total ? `${(value / total) * 100}%` : "0" +} -