feat(统计): 没填班级的学生也能和班级一样看——班级下拉多一项「没填班级」,一起上的课能切出来、点进去看小方块;零散提交也能点了

- 班级筛选多一个取值 NO_CLASS("none"),统计、方块串、流程图、提交列表都认;只圈普通学生,
  老师管理员的号本来就不填班级,不挡的话试题的提交会算进去
- 切课时没填班级的号合在一起当一个班切(原来 class_name is null 直接排除,7 月 4 日那两节课看不到)
- 没填班级不是真班:不列「还没交」,课上只写几人不写几人之几
- 「另有零散提交」每一项可点,进那个班那一整天

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-30 12:18:06 +08:00
1 parent c2ef55b10b
commit 923f3efeca
10 files changed
+153 -66

No files matched your search

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import type { SubmissionStatisticsGrid } from "@oj2/contract"
import { NO_CLASS, type SubmissionStatisticsGrid } from "@oj2/contract"
import { SubmissionStatus } from "utils/constants"
import { useTone } from "oj/submission/composables/tone"
import { classLabel, submissionDayText } from "oj/submission/utils"
@@ -27,7 +27,7 @@ const classes = computed(() => {
const need = props.grid.problems.map((problem) => problem.problemDisplayId)
const groups = new Map<string, SubmissionStatisticsGrid["rows"]>()
for (const row of props.grid.rows) {
const key = row.className ?? ""
const key = row.className ?? NO_CLASS
groups.set(key, [...(groups.get(key) ?? []), row])
}
return (
@@ -56,7 +56,7 @@ const classes = computed(() => {
const size = props.grid.classSizes[className] ?? rows.length
return {
className,
label: className ? classLabel(className) : "没有班级",
label: classLabel(className),
size,
done,
tried: rows.length - done,
@@ -101,13 +101,7 @@ const classes = computed(() => {
<span class="c-n muted">{{ row.rate }}</span>
<span class="c-last muted">{{ row.last ? submissionDayText(row.last) : "—" }}</span>
<span class="spacer"></span>
<n-button
v-if="row.className"
size="small"
text
type="primary"
@click="emit('pick', row.className)"
>
<n-button size="small" text type="primary" @click="emit('pick', row.className)">
看这个班 →
</n-button>
</div>
@@ -8,6 +8,7 @@ import { classLabel } from "oj/submission/utils"
import { resultName, useResultColor } from "../results"
type Lesson = SubmissionLessons["lessons"][number]
type Scattered = SubmissionLessons["scattered"][number]
/**
* 统计「一行一节课」的总览(设计稿「统计合二为一」B)。没选班、没填学生和题号时用它:
@@ -24,6 +25,8 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
pick: [lesson: Lesson]
/** 点某天的零散提交:那个班(或没填班级的)那一天 */
scattered: [item: Scattered]
/** 点「错得最多的题」里的一道:只看这道(按班级汇总) */
problem: [displayId: string]
more: []
@@ -49,27 +52,23 @@ function dayText(day: string) {
/** 按天分组,天和天里的课都是从近到远,零散提交挂在对应那天下面 */
const days = computed(() => {
const map = new Map<string, { lessons: Lesson[]; scattered: string }>()
const map = new Map<string, Lesson[]>()
for (const lesson of props.data.lessons) {
const entry = map.get(lesson.day) ?? { lessons: [], scattered: "" }
entry.lessons.push(lesson)
map.set(lesson.day, entry)
map.set(lesson.day, [...(map.get(lesson.day) ?? []), lesson])
}
const scatteredByDay = new Map<string, string[]>()
const scatteredByDay = new Map<string, Scattered[]>()
for (const item of props.data.scattered) {
const name = item.className ? classLabel(item.className) : "没填班级的"
const text = `${name} ${item.userCount} 人 ${item.total} 次`
scatteredByDay.set(item.day, [...(scatteredByDay.get(item.day) ?? []), text])
if (!map.has(item.day)) map.set(item.day, { lessons: [], scattered: "" })
scatteredByDay.set(item.day, [...(scatteredByDay.get(item.day) ?? []), item])
if (!map.has(item.day)) map.set(item.day, [])
}
return [...map.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([day, entry]) => ({
.map(([day, lessons]) => ({
day,
label: dayText(day),
// 最近的一节在最上面(用户说的):打开就看到这节课,往下是更早的
lessons: [...entry.lessons].sort((a, b) => b.start.localeCompare(a.start)),
scattered: (scatteredByDay.get(day) ?? []).join(" · "),
lessons: [...lessons].sort((a, b) => b.start.localeCompare(a.start)),
scattered: scatteredByDay.get(day) ?? [],
}))
})
@@ -157,7 +156,7 @@ function percent(value: number, total: number) {
<div class="day">{{ group.label }}</div>
<a
v-for="lesson in group.lessons"
:key="lesson.className + lesson.start"
:key="`${lesson.className}|${lesson.start}`"
href="#"
class="row"
@click.prevent="emit('pick', lesson)"
@@ -192,8 +191,14 @@ function percent(value: number, total: number) {
</span>
<span class="c-go">看这节课<Icon icon="ph:caret-right-bold" :width="12" /></span>
</a>
<div v-if="group.scattered" class="scattered">
{{ group.lessons.length ? "另有零散提交:" : "零散提交:" }}{{ group.scattered }}
<div v-if="group.scattered.length" class="scattered">
{{ group.lessons.length ? "另有零散提交:" : "零散提交:" }}
<template v-for="(item, index) in group.scattered" :key="item.className ?? ''">
<template v-if="index"> · </template>
<a href="#" title="看这个班这一天的提交" @click.prevent="emit('scattered', item)"
>{{ classLabel(item.className) }} {{ item.userCount }} 人 {{ item.total }} 次</a
>
</template>
</div>
</template>
@@ -244,7 +249,7 @@ function percent(value: number, total: number) {
</a>
</td>
<td>
<n-text depth="2">{{ item.className ? classLabel(item.className) : "—" }}</n-text>
<n-text depth="2">{{ classLabel(item.className) }}</n-text>
</td>
<td>{{ item.total }} 次</td>
<td>
@@ -465,6 +470,16 @@ function percent(value: number, total: number) {
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.scattered a {
color: v-bind("theme.textColor2");
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.scattered a:hover {
color: v-bind("theme.primaryColor");
}
.more {
padding: 12px 20px 0;
}
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type {
FlowchartStatistics,
SubmissionLessons,
SubmissionStatistics,
SubmissionStatisticsGrid,
import {
NO_CLASS,
type FlowchartStatistics,
type SubmissionLessons,
type SubmissionStatistics,
type SubmissionStatisticsGrid,
} from "@oj2/contract"
import {
getFlowchartStatistics,
@@ -16,8 +17,8 @@ import {
import { useConfigStore } from "shared/store/config"
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 { classSelectOptions } from "oj/submission/utils"
import { DEFAULT_PERIOD, PERIOD_OPTIONS, dayStartOfDate, periodRange, type Period } from "../period"
import { resultName, useResultColor } from "../results"
import ByClass from "./ByClass.vue"
import CodeStats from "./CodeStats.vue"
@@ -106,11 +107,9 @@ function pickDates(value: [number, number] | null) {
/** 学生框:包含匹配。班级另外单独传(按 class_name 精确匹配,见后端 scopedUsers) */
const studentParam = computed(() => username.value.trim())
const classOptions = computed(() => {
const list = configStore.config?.classList ?? []
const all = className.value && !list.includes(className.value) ? [className.value, ...list] : list
return all.map((item) => ({ label: classLabel(item), value: item }))
})
const classOptions = computed(() =>
classSelectOptions(configStore.config?.classList ?? [], className.value),
)
// ---------- 取数 ----------
@@ -306,7 +305,7 @@ function pickClass(value: string) {
/** 点一节课:这个班、这节课的题、这节课第一条到最后一条(多留 1 秒:库里存到微秒,转成毫秒会截掉零头) */
function pickLesson(lesson: SubmissionLessons["lessons"][number]) {
remember()
className.value = lesson.className
className.value = lesson.className ?? NO_CLASS
username.value = ""
problem.value = lesson.problems.join(" ")
from.value = String(Date.parse(lesson.start))
@@ -314,6 +313,18 @@ function pickLesson(lesson: SubmissionLessons["lessons"][number]) {
period.value = "lesson"
}
/** 点某天的零散提交:这个班(或没填班级的)那一整天,题号不限 —— 补做、课后练的都在里面 */
function pickScattered(item: SubmissionLessons["scattered"][number]) {
remember()
className.value = item.className ?? NO_CLASS
username.value = ""
problem.value = ""
const day = String(dayStartOfDate(item.day))
from.value = day
to.value = day
period.value = "custom"
}
/** 点「错得最多的题」里的一道:只看这道,按班级汇总 */
function pickProblem(displayId: string) {
remember()
@@ -478,6 +489,7 @@ function resultWidth(value: number) {
:timeline="shown.withinToday"
:loading-more="loadingMore"
@pick="pickLesson"
@scattered="pickScattered"
@problem="pickProblem"
@more="loadMore"
/>
+6
View File
@@ -34,6 +34,12 @@ function dayStartOf(year: number, month: number, day: number) {
return Date.UTC(year, month - 1, day) - OFFSET_MS
}
/** 东八区某天(`2026-09-29`)0 点的真实时刻,毫秒 —— 点零散提交时拿它当「自己选日子」的起止 */
export function dayStartOfDate(day: string) {
const [year, month, date] = day.split("-").map(Number) as [number, number, number]
return dayStartOf(year, month, date)
}
/** 东八区「现在」这一天的 0 点 */
function todayStart(now: number) {
const p = zonedParts(new Date(now))!
+4 -7
View File
@@ -31,7 +31,7 @@ import FlowchartState from "./components/FlowchartState.vue"
import StatusPill from "./components/StatusPill.vue"
import UserName from "./components/UserName.vue"
import { useTone } from "./composables/tone"
import { classLabel, submissionClockText, submissionDayText } from "./utils"
import { classLabel, classSelectOptions, submissionClockText, submissionDayText } from "./utils"
/**
* 提交列表(设计稿「提交列表重设计 · 定稿」):左边列表、右边常驻选中那条的代码。
@@ -448,12 +448,9 @@ const languageOptions = [
{ label: "SQL", value: "SQL" },
]
const classOptions = computed(() => {
const list = configStore.config?.classList ?? []
// 带进来的班级不在配置里(老班、手打的)也要显示得出来,不然框里是空的却在筛
const all = query.className && !list.includes(query.className) ? [query.className, ...list] : list
return all.map((item) => ({ label: classLabel(item), value: item }))
})
const classOptions = computed(() =>
classSelectOptions(configStore.config?.classList ?? [], query.className),
)
const contestProblemOptions = computed(() => [
{ label: "全部题目", value: "" },
+16 -2
View File
@@ -1,3 +1,4 @@
import { NO_CLASS } from "@oj2/contract"
import { parseTime } from "utils/functions"
/**
@@ -30,7 +31,20 @@ export function submissionDayText(time: string) {
return parseTime(time, "YYYY年M月D日")
}
/** 班级下拉的标签:`253` → `25计算机3班`,和统计面板、班级 PK 同一个写法 */
export function classLabel(className: string) {
/**
* 班级下拉的标签:`253` → `25计算机3班`,和统计面板、班级 PK 同一个写法。
* null(接口里没填班级的号)和 NO_CLASS(筛选里的「没填班级」)都写成「没填班级」
*/
export function classLabel(className: string | null) {
if (className === null || className === NO_CLASS) return "没填班级"
return `${className.slice(0, 2)}计算机${className.slice(2)}班`
}
/**
* 班级下拉的选项:配置里的班,最后一项「没填班级」(这批号也要能和普通班一样圈出来看)。
* 带进来的班不在配置里(老班、手打的)也要显示得出来,不然框里是空的却在筛
*/
export function classSelectOptions(list: string[], current: string) {
const all = current && current !== NO_CLASS && !list.includes(current) ? [current, ...list] : list
return [...all, NO_CLASS].map((item) => ({ label: classLabel(item), value: item }))
}