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:
1 parent
c2ef55b10b
commit
923f3efeca
10 files changed
+151
-64
No files matched your search
@@ -10,6 +10,7 @@ import {
|
|||||||
type FlowchartListItem,
|
type FlowchartListItem,
|
||||||
type FlowchartStatistics,
|
type FlowchartStatistics,
|
||||||
type FlowchartSubmission,
|
type FlowchartSubmission,
|
||||||
|
NO_CLASS,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -27,6 +28,7 @@ import { buildWordFrequencies } from "../services/word-frequency"
|
|||||||
import { dayStart } from "../time"
|
import { dayStart } from "../time"
|
||||||
import { unknownDisplayIds } from "./submission-statistics"
|
import { unknownDisplayIds } from "./submission-statistics"
|
||||||
import {
|
import {
|
||||||
|
classCondition,
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
matchedUsers,
|
matchedUsers,
|
||||||
asRecord,
|
asRecord,
|
||||||
@@ -208,7 +210,7 @@ async function flowchartClassFilter(className: string) {
|
|||||||
const users = await db
|
const users = await db
|
||||||
.select({ id: schema.user.id })
|
.select({ id: schema.user.id })
|
||||||
.from(schema.user)
|
.from(schema.user)
|
||||||
.where(eq(schema.user.className, className))
|
.where(classCondition(className))
|
||||||
return users.length
|
return users.length
|
||||||
? inArray(
|
? inArray(
|
||||||
schema.flowchartSubmission.userId,
|
schema.flowchartSubmission.userId,
|
||||||
@@ -422,8 +424,11 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
|||||||
}
|
}
|
||||||
const where = and(...filters)
|
const where = and(...filters)
|
||||||
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
|
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
|
||||||
// 未禁用的普通用户才进分母,教师和管理员不算
|
// 未禁用的普通用户才进分母,教师和管理员不算。
|
||||||
const roster = scoped
|
// 「没填班级」不是一个真的班(两百来个夏令营、自己注册的号混在一起),「谁没做」列出来全是
|
||||||
|
// 不相干的人,当没有花名册
|
||||||
|
const roster =
|
||||||
|
scoped && c.req.query("className")?.trim() !== NO_CLASS
|
||||||
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
|
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
|
import { ADMIN_ROLES, NO_CLASS, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
|
||||||
|
|
||||||
import { and, count, eq, ilike, notInArray, sql } from "drizzle-orm"
|
import { and, count, eq, ilike, isNull, notInArray, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import type { AuthUser } from "../auth/session"
|
import type { AuthUser } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
@@ -159,6 +159,19 @@ export async function countFailedSubmissions(userId: number, problemId: number)
|
|||||||
* 代码提交和流程图两条统计都走这里。流程图那张表连冻结用户名都没有(只有
|
* 代码提交和流程图两条统计都走这里。流程图那张表连冻结用户名都没有(只有
|
||||||
* `user_id`),更是只能从这儿拿 id。
|
* `user_id`),更是只能从这儿拿 id。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 「这个班的人」:`user.class_name` 精确匹配;NO_CLASS 是没填班级的学生(class_name 为 null)。
|
||||||
|
* 统计、提交列表、流程图按班级筛都走它,没填班级的才能和普通班一样被圈出来。
|
||||||
|
*
|
||||||
|
* NO_CLASS 只圈普通用户:老师、管理员的号本来就都不填班级,不挡的话老师自己试题的提交
|
||||||
|
* 会混进「没填班级」,还算进做完的人数里(切课那边也只算普通学生,两边口径一致)
|
||||||
|
*/
|
||||||
|
export function classCondition(className: string) {
|
||||||
|
return className === NO_CLASS
|
||||||
|
? and(isNull(schema.user.className), eq(schema.user.adminType, "Regular User"))
|
||||||
|
: eq(schema.user.className, className)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计接口「圈哪些人」:班级按 `user.class_name` **精确**匹配,学生名按包含匹配,两个都给
|
* 统计接口「圈哪些人」:班级按 `user.class_name` **精确**匹配,学生名按包含匹配,两个都给
|
||||||
* 就取交集,都没给返回 null(不圈人)。
|
* 就取交集,都没给返回 null(不圈人)。
|
||||||
@@ -179,7 +192,7 @@ export async function scopedUsers(username: string | undefined, className: strin
|
|||||||
.from(schema.user)
|
.from(schema.user)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
className ? eq(schema.user.className, className) : undefined,
|
className ? classCondition(className) : undefined,
|
||||||
username ? ilike(schema.user.username, `%${username}%`) : undefined,
|
username ? ilike(schema.user.username, `%${username}%`) : undefined,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
NO_CLASS,
|
||||||
type ProblemLanguage,
|
type ProblemLanguage,
|
||||||
type SubmissionLessons,
|
type SubmissionLessons,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
@@ -23,7 +24,14 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s
|
|||||||
import { type ContestEnv } from "../services/contest"
|
import { type ContestEnv } from "../services/contest"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { dayStart, localTime } from "../time"
|
import { dayStart, localTime } from "../time"
|
||||||
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers"
|
import {
|
||||||
|
classCondition,
|
||||||
|
isAdminRole,
|
||||||
|
parseDisplayIds,
|
||||||
|
rounded,
|
||||||
|
scopedUsers,
|
||||||
|
stripClassPrefix,
|
||||||
|
} from "./helpers"
|
||||||
|
|
||||||
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||||
|
|
||||||
@@ -240,7 +248,7 @@ export async function classNameFilter(className: string) {
|
|||||||
const users = await db
|
const users = await db
|
||||||
.select({ id: schema.user.id })
|
.select({ id: schema.user.id })
|
||||||
.from(schema.user)
|
.from(schema.user)
|
||||||
.where(eq(schema.user.className, className))
|
.where(classCondition(className))
|
||||||
return users.length
|
return users.length
|
||||||
? inArray(
|
? inArray(
|
||||||
schema.submission.userId,
|
schema.submission.userId,
|
||||||
@@ -381,7 +389,7 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
|
|||||||
}
|
}
|
||||||
const where = and(...filters)
|
const where = and(...filters)
|
||||||
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
|
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
|
||||||
const rosterRows = matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
|
const enrolled = matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
|
||||||
|
|
||||||
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||||
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
|
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
|
||||||
@@ -474,6 +482,13 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
|
|||||||
const doneCount = perUser.filter(isDone).length
|
const doneCount = perUser.filter(isDone).length
|
||||||
|
|
||||||
const submittedUserIds = new Set(perUser.map((row) => row.userId))
|
const submittedUserIds = new Set(perUser.map((row) => row.userId))
|
||||||
|
/**
|
||||||
|
* 「没填班级」不是一个真的班:两百来个夏令营、兴趣班、自己注册的号混在一起,「还没交」
|
||||||
|
* 列出来全是不相干的人,分母也跟着失真。花名册只取这段时间交过的人 —— 「交了没对」照常有,
|
||||||
|
* 「还没交」为空,和不选班时一样
|
||||||
|
*/
|
||||||
|
const rosterRows =
|
||||||
|
className === NO_CLASS ? enrolled.filter((row) => submittedUserIds.has(row.id)) : enrolled
|
||||||
|
|
||||||
const data = perUser.map((row) => ({
|
const data = perUser.map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@@ -755,7 +770,7 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
|
|||||||
select class_name, day, sid
|
select class_name, day, sid
|
||||||
from (
|
from (
|
||||||
select class_name, day, sid, problem_id, count(distinct user_id) as n
|
select class_name, day, sid, problem_id, count(distinct user_id) as n
|
||||||
from seg where class_name is not null
|
from seg
|
||||||
group by class_name, day, sid, problem_id
|
group by class_name, day, sid, problem_id
|
||||||
) p
|
) p
|
||||||
group by class_name, day, sid
|
group by class_name, day, sid
|
||||||
@@ -765,7 +780,8 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
|
|||||||
select seg.*, (l.sid is not null) as lesson
|
select seg.*, (l.sid is not null) as lesson
|
||||||
from seg
|
from seg
|
||||||
left join lesson_seg l
|
left join lesson_seg l
|
||||||
on l.class_name = seg.class_name and l.day = seg.day and l.sid = seg.sid
|
-- 没填班级的号(class_name 为 null)合在一起当一个班切,= 会把它们全漏掉
|
||||||
|
on l.class_name is not distinct from seg.class_name and l.day = seg.day and l.sid = seg.sid
|
||||||
)
|
)
|
||||||
select grouping(class_name, day, sid, problem_id, result, lesson) as level,
|
select grouping(class_name, day, sid, problem_id, result, lesson) as level,
|
||||||
class_name, day, sid::int as sid, problem_id, result, lesson,
|
class_name, day, sid::int as sid, problem_id, result, lesson,
|
||||||
@@ -866,7 +882,9 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
|
|||||||
: []
|
: []
|
||||||
const problemById = new Map(problems.map((row) => [row.id, row]))
|
const problemById = new Map(problems.map((row) => [row.id, row]))
|
||||||
|
|
||||||
const lessonClasses = [...new Set(picked.map((row) => row.class_name!))]
|
const lessonClasses = [
|
||||||
|
...new Set(picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name]))),
|
||||||
|
]
|
||||||
const sizes = lessonClasses.length
|
const sizes = lessonClasses.length
|
||||||
? await db
|
? await db
|
||||||
.select({ className: schema.user.className, value: count() })
|
.select({ className: schema.user.className, value: count() })
|
||||||
@@ -889,12 +907,16 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
|
|||||||
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
|
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
|
||||||
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
|
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
|
||||||
return {
|
return {
|
||||||
className: lesson.class_name!,
|
className: lesson.class_name,
|
||||||
day: lesson.day!,
|
day: lesson.day!,
|
||||||
start: lesson.first,
|
start: lesson.first,
|
||||||
end: lesson.last,
|
end: lesson.last,
|
||||||
userCount: lesson.users,
|
userCount: lesson.users,
|
||||||
classSize: sizes.find((row) => row.className === lesson.class_name)?.value ?? 0,
|
// 没填班级的谈不上花名册,给 0(界面上就只写「几人」)
|
||||||
|
classSize:
|
||||||
|
lesson.class_name === null
|
||||||
|
? 0
|
||||||
|
: (sizes.find((row) => row.className === lesson.class_name)?.value ?? 0),
|
||||||
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
|
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
|
||||||
problems: own
|
problems: own
|
||||||
.map((row) => problemById.get(row.problem_id!))
|
.map((row) => problemById.get(row.problem_id!))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeVars } from "naive-ui"
|
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 { SubmissionStatus } from "utils/constants"
|
||||||
import { useTone } from "oj/submission/composables/tone"
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
import { classLabel, submissionDayText } from "oj/submission/utils"
|
import { classLabel, submissionDayText } from "oj/submission/utils"
|
||||||
@@ -27,7 +27,7 @@ const classes = computed(() => {
|
|||||||
const need = props.grid.problems.map((problem) => problem.problemDisplayId)
|
const need = props.grid.problems.map((problem) => problem.problemDisplayId)
|
||||||
const groups = new Map<string, SubmissionStatisticsGrid["rows"]>()
|
const groups = new Map<string, SubmissionStatisticsGrid["rows"]>()
|
||||||
for (const row of props.grid.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])
|
groups.set(key, [...(groups.get(key) ?? []), row])
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -56,7 +56,7 @@ const classes = computed(() => {
|
|||||||
const size = props.grid.classSizes[className] ?? rows.length
|
const size = props.grid.classSizes[className] ?? rows.length
|
||||||
return {
|
return {
|
||||||
className,
|
className,
|
||||||
label: className ? classLabel(className) : "没有班级",
|
label: classLabel(className),
|
||||||
size,
|
size,
|
||||||
done,
|
done,
|
||||||
tried: rows.length - done,
|
tried: rows.length - done,
|
||||||
@@ -101,13 +101,7 @@ const classes = computed(() => {
|
|||||||
<span class="c-n muted">{{ row.rate }}</span>
|
<span class="c-n muted">{{ row.rate }}</span>
|
||||||
<span class="c-last muted">{{ row.last ? submissionDayText(row.last) : "—" }}</span>
|
<span class="c-last muted">{{ row.last ? submissionDayText(row.last) : "—" }}</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<n-button
|
<n-button size="small" text type="primary" @click="emit('pick', row.className)">
|
||||||
v-if="row.className"
|
|
||||||
size="small"
|
|
||||||
text
|
|
||||||
type="primary"
|
|
||||||
@click="emit('pick', row.className)"
|
|
||||||
>
|
|
||||||
看这个班 →
|
看这个班 →
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { classLabel } from "oj/submission/utils"
|
|||||||
import { resultName, useResultColor } from "../results"
|
import { resultName, useResultColor } from "../results"
|
||||||
|
|
||||||
type Lesson = SubmissionLessons["lessons"][number]
|
type Lesson = SubmissionLessons["lessons"][number]
|
||||||
|
type Scattered = SubmissionLessons["scattered"][number]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计「一行一节课」的总览(设计稿「统计合二为一」B)。没选班、没填学生和题号时用它:
|
* 统计「一行一节课」的总览(设计稿「统计合二为一」B)。没选班、没填学生和题号时用它:
|
||||||
@@ -24,6 +25,8 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
pick: [lesson: Lesson]
|
pick: [lesson: Lesson]
|
||||||
|
/** 点某天的零散提交:那个班(或没填班级的)那一天 */
|
||||||
|
scattered: [item: Scattered]
|
||||||
/** 点「错得最多的题」里的一道:只看这道(按班级汇总) */
|
/** 点「错得最多的题」里的一道:只看这道(按班级汇总) */
|
||||||
problem: [displayId: string]
|
problem: [displayId: string]
|
||||||
more: []
|
more: []
|
||||||
@@ -49,27 +52,23 @@ function dayText(day: string) {
|
|||||||
|
|
||||||
/** 按天分组,天和天里的课都是从近到远,零散提交挂在对应那天下面 */
|
/** 按天分组,天和天里的课都是从近到远,零散提交挂在对应那天下面 */
|
||||||
const days = computed(() => {
|
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) {
|
for (const lesson of props.data.lessons) {
|
||||||
const entry = map.get(lesson.day) ?? { lessons: [], scattered: "" }
|
map.set(lesson.day, [...(map.get(lesson.day) ?? []), lesson])
|
||||||
entry.lessons.push(lesson)
|
|
||||||
map.set(lesson.day, entry)
|
|
||||||
}
|
}
|
||||||
const scatteredByDay = new Map<string, string[]>()
|
const scatteredByDay = new Map<string, Scattered[]>()
|
||||||
for (const item of props.data.scattered) {
|
for (const item of props.data.scattered) {
|
||||||
const name = item.className ? classLabel(item.className) : "没填班级的"
|
scatteredByDay.set(item.day, [...(scatteredByDay.get(item.day) ?? []), item])
|
||||||
const text = `${name} ${item.userCount} 人 ${item.total} 次`
|
if (!map.has(item.day)) map.set(item.day, [])
|
||||||
scatteredByDay.set(item.day, [...(scatteredByDay.get(item.day) ?? []), text])
|
|
||||||
if (!map.has(item.day)) map.set(item.day, { lessons: [], scattered: "" })
|
|
||||||
}
|
}
|
||||||
return [...map.entries()]
|
return [...map.entries()]
|
||||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||||
.map(([day, entry]) => ({
|
.map(([day, lessons]) => ({
|
||||||
day,
|
day,
|
||||||
label: dayText(day),
|
label: dayText(day),
|
||||||
// 最近的一节在最上面(用户说的):打开就看到这节课,往下是更早的
|
// 最近的一节在最上面(用户说的):打开就看到这节课,往下是更早的
|
||||||
lessons: [...entry.lessons].sort((a, b) => b.start.localeCompare(a.start)),
|
lessons: [...lessons].sort((a, b) => b.start.localeCompare(a.start)),
|
||||||
scattered: (scatteredByDay.get(day) ?? []).join(" · "),
|
scattered: scatteredByDay.get(day) ?? [],
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -157,7 +156,7 @@ function percent(value: number, total: number) {
|
|||||||
<div class="day">{{ group.label }}</div>
|
<div class="day">{{ group.label }}</div>
|
||||||
<a
|
<a
|
||||||
v-for="lesson in group.lessons"
|
v-for="lesson in group.lessons"
|
||||||
:key="lesson.className + lesson.start"
|
:key="`${lesson.className}|${lesson.start}`"
|
||||||
href="#"
|
href="#"
|
||||||
class="row"
|
class="row"
|
||||||
@click.prevent="emit('pick', lesson)"
|
@click.prevent="emit('pick', lesson)"
|
||||||
@@ -192,8 +191,14 @@ function percent(value: number, total: number) {
|
|||||||
</span>
|
</span>
|
||||||
<span class="c-go">看这节课<Icon icon="ph:caret-right-bold" :width="12" /></span>
|
<span class="c-go">看这节课<Icon icon="ph:caret-right-bold" :width="12" /></span>
|
||||||
</a>
|
</a>
|
||||||
<div v-if="group.scattered" class="scattered">
|
<div v-if="group.scattered.length" class="scattered">
|
||||||
{{ group.lessons.length ? "另有零散提交:" : "零散提交:" }}{{ group.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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -244,7 +249,7 @@ function percent(value: number, total: number) {
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<n-text depth="2">{{ item.className ? classLabel(item.className) : "—" }}</n-text>
|
<n-text depth="2">{{ classLabel(item.className) }}</n-text>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.total }} 次</td>
|
<td>{{ item.total }} 次</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -465,6 +470,16 @@ function percent(value: number, total: number) {
|
|||||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
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 {
|
.more {
|
||||||
padding: 12px 20px 0;
|
padding: 12px 20px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import type {
|
import {
|
||||||
FlowchartStatistics,
|
NO_CLASS,
|
||||||
SubmissionLessons,
|
type FlowchartStatistics,
|
||||||
SubmissionStatistics,
|
type SubmissionLessons,
|
||||||
SubmissionStatisticsGrid,
|
type SubmissionStatistics,
|
||||||
|
type SubmissionStatisticsGrid,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
getFlowchartStatistics,
|
getFlowchartStatistics,
|
||||||
@@ -16,8 +17,8 @@ import {
|
|||||||
import { useConfigStore } from "shared/store/config"
|
import { useConfigStore } from "shared/store/config"
|
||||||
import { fromPickerValue, toPickerValue } from "utils/functions"
|
import { fromPickerValue, toPickerValue } from "utils/functions"
|
||||||
import { useTone } from "oj/submission/composables/tone"
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
import { classLabel } from "oj/submission/utils"
|
import { classSelectOptions } from "oj/submission/utils"
|
||||||
import { DEFAULT_PERIOD, PERIOD_OPTIONS, periodRange, type Period } from "../period"
|
import { DEFAULT_PERIOD, PERIOD_OPTIONS, dayStartOfDate, periodRange, type Period } from "../period"
|
||||||
import { resultName, useResultColor } from "../results"
|
import { resultName, useResultColor } from "../results"
|
||||||
import ByClass from "./ByClass.vue"
|
import ByClass from "./ByClass.vue"
|
||||||
import CodeStats from "./CodeStats.vue"
|
import CodeStats from "./CodeStats.vue"
|
||||||
@@ -106,11 +107,9 @@ function pickDates(value: [number, number] | null) {
|
|||||||
/** 学生框:包含匹配。班级另外单独传(按 class_name 精确匹配,见后端 scopedUsers) */
|
/** 学生框:包含匹配。班级另外单独传(按 class_name 精确匹配,见后端 scopedUsers) */
|
||||||
const studentParam = computed(() => username.value.trim())
|
const studentParam = computed(() => username.value.trim())
|
||||||
|
|
||||||
const classOptions = computed(() => {
|
const classOptions = computed(() =>
|
||||||
const list = configStore.config?.classList ?? []
|
classSelectOptions(configStore.config?.classList ?? [], className.value),
|
||||||
const all = className.value && !list.includes(className.value) ? [className.value, ...list] : list
|
)
|
||||||
return all.map((item) => ({ label: classLabel(item), value: item }))
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- 取数 ----------
|
// ---------- 取数 ----------
|
||||||
|
|
||||||
@@ -306,7 +305,7 @@ function pickClass(value: string) {
|
|||||||
/** 点一节课:这个班、这节课的题、这节课第一条到最后一条(多留 1 秒:库里存到微秒,转成毫秒会截掉零头) */
|
/** 点一节课:这个班、这节课的题、这节课第一条到最后一条(多留 1 秒:库里存到微秒,转成毫秒会截掉零头) */
|
||||||
function pickLesson(lesson: SubmissionLessons["lessons"][number]) {
|
function pickLesson(lesson: SubmissionLessons["lessons"][number]) {
|
||||||
remember()
|
remember()
|
||||||
className.value = lesson.className
|
className.value = lesson.className ?? NO_CLASS
|
||||||
username.value = ""
|
username.value = ""
|
||||||
problem.value = lesson.problems.join(" ")
|
problem.value = lesson.problems.join(" ")
|
||||||
from.value = String(Date.parse(lesson.start))
|
from.value = String(Date.parse(lesson.start))
|
||||||
@@ -314,6 +313,18 @@ function pickLesson(lesson: SubmissionLessons["lessons"][number]) {
|
|||||||
period.value = "lesson"
|
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) {
|
function pickProblem(displayId: string) {
|
||||||
remember()
|
remember()
|
||||||
@@ -478,6 +489,7 @@ function resultWidth(value: number) {
|
|||||||
:timeline="shown.withinToday"
|
:timeline="shown.withinToday"
|
||||||
:loading-more="loadingMore"
|
:loading-more="loadingMore"
|
||||||
@pick="pickLesson"
|
@pick="pickLesson"
|
||||||
|
@scattered="pickScattered"
|
||||||
@problem="pickProblem"
|
@problem="pickProblem"
|
||||||
@more="loadMore"
|
@more="loadMore"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ function dayStartOf(year: number, month: number, day: number) {
|
|||||||
return Date.UTC(year, month - 1, day) - OFFSET_MS
|
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 点 */
|
/** 东八区「现在」这一天的 0 点 */
|
||||||
function todayStart(now: number) {
|
function todayStart(now: number) {
|
||||||
const p = zonedParts(new Date(now))!
|
const p = zonedParts(new Date(now))!
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import FlowchartState from "./components/FlowchartState.vue"
|
|||||||
import StatusPill from "./components/StatusPill.vue"
|
import StatusPill from "./components/StatusPill.vue"
|
||||||
import UserName from "./components/UserName.vue"
|
import UserName from "./components/UserName.vue"
|
||||||
import { useTone } from "./composables/tone"
|
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" },
|
{ label: "SQL", value: "SQL" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const classOptions = computed(() => {
|
const classOptions = computed(() =>
|
||||||
const list = configStore.config?.classList ?? []
|
classSelectOptions(configStore.config?.classList ?? [], query.className),
|
||||||
// 带进来的班级不在配置里(老班、手打的)也要显示得出来,不然框里是空的却在筛
|
)
|
||||||
const all = query.className && !list.includes(query.className) ? [query.className, ...list] : list
|
|
||||||
return all.map((item) => ({ label: classLabel(item), value: item }))
|
|
||||||
})
|
|
||||||
|
|
||||||
const contestProblemOptions = computed(() => [
|
const contestProblemOptions = computed(() => [
|
||||||
{ label: "全部题目", value: "" },
|
{ label: "全部题目", value: "" },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NO_CLASS } from "@oj2/contract"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +31,20 @@ export function submissionDayText(time: string) {
|
|||||||
return parseTime(time, "YYYY年M月D日")
|
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)}班`
|
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 }))
|
||||||
|
}
|
||||||
@@ -405,12 +405,20 @@ export const submissionStatisticsSchema = z.object({
|
|||||||
results: z.array(z.object({ result: judgeStatusSchema, count: z.number().int() })),
|
results: z.array(z.object({ result: judgeStatusSchema, count: z.number().int() })),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级筛选(统计、提交列表、流程图的 `className` 参数)里代表「没填班级」的取值:
|
||||||
|
* 这批号(夏令营、兴趣班、自己注册的)`user.class_name` 是 null,当成一个班来看。
|
||||||
|
* 真班名全是数字(`253`、`2511`),撞不上
|
||||||
|
*/
|
||||||
|
export const NO_CLASS = "none"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计「一行一节课」的总览(GET /submissions/statistics/lessons,设计稿「统计合二为一」B)。
|
* 统计「一行一节课」的总览(GET /submissions/statistics/lessons,设计稿「统计合二为一」B)。
|
||||||
* 没选班、没填学生、没填题号时用它;原来「今日统计」老师那一版并进来了,换成任意时间段。
|
* 没选班、没填学生、没填题号时用它;原来「今日统计」老师那一版并进来了,换成任意时间段。
|
||||||
*
|
*
|
||||||
* **一节课怎么切**:只算普通学生(和方块串同一个口径),按「班 + 东八区哪一天」分组,
|
* **一节课怎么切**:只算普通学生(和方块串同一个口径),按「班 + 东八区哪一天」分组,
|
||||||
* 相邻两条提交隔 30 分钟以上就切开;切出来的一段里有一道题 5 人以上做过,才算一节课。
|
* 相邻两条提交隔 30 分钟以上就切开;切出来的一段里有一道题 5 人以上做过,才算一节课。
|
||||||
|
* 没填班级的号合在一起当一个班切(`className` 为 null)。
|
||||||
* 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38,
|
* 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38,
|
||||||
* 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。
|
* 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。
|
||||||
*/
|
*/
|
||||||
@@ -420,14 +428,15 @@ export const submissionLessonsSchema = z.object({
|
|||||||
/** 最近的 N 节课,按开始时间倒序(界面上按天分组,这节课在最上面) */
|
/** 最近的 N 节课,按开始时间倒序(界面上按天分组,这节课在最上面) */
|
||||||
lessons: z.array(
|
lessons: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
className: z.string(),
|
/** null = 没填班级的那批号,点进去按 NO_CLASS 筛 */
|
||||||
|
className: z.string().nullable(),
|
||||||
/** 东八区的日子,`2026-09-29` */
|
/** 东八区的日子,`2026-09-29` */
|
||||||
day: z.string(),
|
day: z.string(),
|
||||||
/** 这一段第一条、最后一条提交 */
|
/** 这一段第一条、最后一条提交 */
|
||||||
start: z.string(),
|
start: z.string(),
|
||||||
end: z.string(),
|
end: z.string(),
|
||||||
userCount: z.number().int(),
|
userCount: z.number().int(),
|
||||||
/** 班里的学生数(未禁用的普通用户) */
|
/** 班里的学生数(未禁用的普通用户)。没填班级的谈不上花名册,给 0 */
|
||||||
classSize: z.number().int(),
|
classSize: z.number().int(),
|
||||||
/** 这节课做的题(这一段里 5 人以上做过的),做的人多的在前,最多 20 道 —— 点进去就筛这几道 */
|
/** 这节课做的题(这一段里 5 人以上做过的),做的人多的在前,最多 20 道 —— 点进去就筛这几道 */
|
||||||
problems: z.array(z.string()),
|
problems: z.array(z.string()),
|
||||||
|
|||||||
Reference in new issue
Block a user