feat(统计): 统计接口补上每人每道题的每一次提交、流程图每人最好的一次,流程图也能一次查几道题
- 新增 GET /submissions/statistics/grid:范围内每个学生的每次提交(只含普通学生,最多 5000 条), 没选班级时附带各班花名册人数,统计页按「人 × 题」画方块串、按班级汇总 - /flowcharts/statistics 题号可以一次填几道,多返回 people(每人分数最高的那张) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99c158498c
commit
f09da88ff7
8 files changed
+228
-1432
No files matched your search
@@ -358,21 +358,30 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
]
|
||||
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
|
||||
|
||||
const displayId = c.req.query("problemDisplayId")?.trim()
|
||||
if (displayId) {
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
// 可以一次查几道(统计页的题号框和代码统计同一个),有一道找不到就整体报错,
|
||||
// 不退化成「全部题目」—— 否则打错一个字看到的是全站数据
|
||||
const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
|
||||
if (displayIds.length) {
|
||||
const lowered = displayIds.map((id) => id.toLowerCase())
|
||||
const problems = await db
|
||||
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
inArray(sql`lower(${schema.problem.displayId})`, lowered),
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
|
||||
const found = new Set(problems.map((row) => row.displayId.toLowerCase()))
|
||||
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
|
||||
if (missing) return failure(c, 404, "problem-not-found", `Problem ${missing} does not exist`)
|
||||
filters.push(
|
||||
inArray(
|
||||
schema.flowchartSubmission.problemId,
|
||||
problems.map((row) => row.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const username = c.req.query("username")?.trim()
|
||||
@@ -400,7 +409,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
* (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次
|
||||
* 点击 18MB,而老师是开着面板反复切时段、切班的。
|
||||
*/
|
||||
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] = await Promise.all([
|
||||
const [[totals], gradeRows, criteriaRows, textRows, submittedRows, peopleRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
@@ -412,7 +422,9 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
scoreSum: sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(Number),
|
||||
scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(
|
||||
Number,
|
||||
),
|
||||
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
|
||||
// 按 user_id 就不必 join user
|
||||
completedCount:
|
||||
@@ -471,8 +483,34 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
: [],
|
||||
// 每人最好的一次。等级取分数最高那张的等级(同分取最近的),只算普通学生
|
||||
db
|
||||
.select({
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
bestScore: sql<number | null>`max(${schema.flowchartSubmission.aiScore})`,
|
||||
bestGrade: sql<string | null>`(array_agg(${schema.flowchartSubmission.aiGrade}
|
||||
order by ${schema.flowchartSubmission.aiScore} desc nulls last, ${schema.flowchartSubmission.createTime} desc))[1]`,
|
||||
count: count(),
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId))
|
||||
.where(
|
||||
and(where, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)),
|
||||
)
|
||||
.groupBy(schema.user.id, schema.user.username, schema.user.className)
|
||||
.orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`)
|
||||
.limit(500),
|
||||
])
|
||||
|
||||
const people = peopleRows.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
bestScore: row.bestScore === null ? null : Number(row.bestScore),
|
||||
bestGrade: row.bestGrade,
|
||||
count: row.count,
|
||||
}))
|
||||
|
||||
if (!totals || totals.total === 0) {
|
||||
return success(c, {
|
||||
totalCount: 0,
|
||||
@@ -488,6 +526,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
})),
|
||||
people: [],
|
||||
} satisfies FlowchartStatistics)
|
||||
}
|
||||
|
||||
@@ -551,6 +590,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
})),
|
||||
people,
|
||||
} satisfies FlowchartStatistics)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import {
|
||||
type SubmissionStatistics,
|
||||
type SubmissionStatisticsGrid,
|
||||
type SubmissionStatisticsItems,
|
||||
type TodaySubmissionStatistics,
|
||||
} from "@oj2/contract"
|
||||
@@ -652,3 +653,134 @@ submissionStatisticsRoutes.get("/submissions/statistics/items", requireTeacher,
|
||||
truncated,
|
||||
} satisfies SubmissionStatisticsItems)
|
||||
})
|
||||
|
||||
/** 方块串一次最多给多少条。一个班一个月七八百条,5000 是全年级查一周的量 */
|
||||
const GRID_LIMIT = 5000
|
||||
|
||||
/**
|
||||
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
|
||||
* 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。
|
||||
*/
|
||||
submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, async (c) => {
|
||||
const scope = await statisticsScope(c)
|
||||
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
|
||||
const filters = [...scope.filters]
|
||||
|
||||
const username = c.req.query("username")?.trim()
|
||||
if (username) {
|
||||
const ids = (await matchedUsers(username)).map((row) => row.id)
|
||||
filters.push(ids.length ? inArray(schema.submission.userId, ids) : sql`false`)
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.submission.id,
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
result: schema.submission.result,
|
||||
createTime: schema.submission.createTime,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
})
|
||||
.from(schema.submission)
|
||||
// 只要普通学生:老师试题的提交不该出现在「谁做了几次」里
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(
|
||||
and(...filters, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)),
|
||||
)
|
||||
.orderBy(desc(schema.submission.createTime))
|
||||
.limit(GRID_LIMIT + 1)
|
||||
const truncated = rows.length > GRID_LIMIT
|
||||
const kept = rows.slice(0, GRID_LIMIT).reverse()
|
||||
|
||||
// 题目:传了就按传的顺序,没传就按范围里第一次有人交的时间
|
||||
const requested = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
|
||||
const problemIds = [...new Set(kept.map((row) => row.problemId))]
|
||||
const problems = problemIds.length
|
||||
? await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(inArray(schema.problem.id, problemIds))
|
||||
: []
|
||||
const problemById = new Map(problems.map((row) => [row.id, row]))
|
||||
const ordered = requested.length
|
||||
? requested.flatMap((id) => {
|
||||
const hit = problems.find((row) => row.displayId.toLowerCase() === id.toLowerCase())
|
||||
return hit ? [hit] : []
|
||||
})
|
||||
: problemIds.map((id) => problemById.get(id)!).filter(Boolean)
|
||||
// 传了题号但这些题在范围里没人交:表头仍然要有它们
|
||||
if (requested.length && ordered.length < requested.length) {
|
||||
const missing = requested.filter(
|
||||
(id) => !ordered.some((row) => row.displayId.toLowerCase() === id.toLowerCase()),
|
||||
)
|
||||
const { ids } = await findPublicProblemsByDisplayIds(missing)
|
||||
if (ids.length) {
|
||||
const extra = await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(inArray(schema.problem.id, ids))
|
||||
ordered.push(...extra)
|
||||
const rank = new Map(requested.map((id, i) => [id.toLowerCase(), i]))
|
||||
ordered.sort(
|
||||
(a, b) =>
|
||||
(rank.get(a.displayId.toLowerCase()) ?? 0) - (rank.get(b.displayId.toLowerCase()) ?? 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const byUser = new Map<number, SubmissionStatisticsGrid["rows"][number]>()
|
||||
for (const row of kept) {
|
||||
let entry = byUser.get(row.userId)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
className: row.className,
|
||||
submissions: [],
|
||||
}
|
||||
byUser.set(row.userId, entry)
|
||||
}
|
||||
entry.submissions.push({
|
||||
id: row.id,
|
||||
problemDisplayId: problemById.get(row.problemId)?.displayId ?? "",
|
||||
result: row.result,
|
||||
createTime: row.createTime,
|
||||
})
|
||||
}
|
||||
|
||||
// 按班级汇总时要知道每个班多少人,「一道没交」才算得出来
|
||||
const classSizes: Record<string, number> = {}
|
||||
if (!username) {
|
||||
const names = [...new Set(kept.map((row) => row.className).filter((name) => name !== null))]
|
||||
if (names.length) {
|
||||
const sizes = await db
|
||||
.select({ className: schema.user.className, value: count() })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.user.className, names as string[]),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.user.className)
|
||||
for (const row of sizes) if (row.className) classSizes[row.className] = row.value
|
||||
}
|
||||
}
|
||||
|
||||
return success(c, {
|
||||
problems: ordered.map((row) => ({ problemDisplayId: row.displayId, title: row.title })),
|
||||
rows: [...byUser.values()],
|
||||
classSizes,
|
||||
truncated,
|
||||
} satisfies SubmissionStatisticsGrid)
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { statisticsOpen } from "../composables/editorMenu"
|
||||
|
||||
/**
|
||||
* 教师的「课堂统计」弹窗。入口有两个(工具栏的按钮、「更多」菜单 / 手机「⋯」),
|
||||
* 弹窗只在题目页上挂一个。
|
||||
*/
|
||||
// 只有老师看得见,静态 import 的话每个学生打开题目都要白拉一份 chart.js(~68KB gzip)
|
||||
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
const { isDesktop } = useBreakpoints()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-if="userStore.isTeacherOrAbove && problem"
|
||||
v-model:show="statisticsOpen"
|
||||
preset="card"
|
||||
title="提交记录的统计"
|
||||
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
>
|
||||
<StatisticsPanel :problem="problem._id" username="" />
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -1,513 +0,0 @@
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-input
|
||||
placeholder="用户(可选)"
|
||||
v-model:value="query.username"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-input
|
||||
placeholder="题号(可选)"
|
||||
v-model:value="query.problem"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
<n-select style="width: 120px" v-model:value="query.duration" :options="durationOptions" />
|
||||
<n-button type="primary" :loading="loading" @click="handleStatistics">统计</n-button>
|
||||
<n-text v-if="failed" type="error">统计没拉下来,稍后会自动再试</n-text>
|
||||
</n-flex>
|
||||
|
||||
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
|
||||
|
||||
<template v-if="hasResult">
|
||||
<n-divider style="margin: 16px 0" />
|
||||
<n-flex justify="space-around">
|
||||
<div class="stat-item">
|
||||
<n-text>总提交</n-text>
|
||||
<n-gradient-text type="info" font-size="28">
|
||||
{{ data.totalCount }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>平均分</n-text>
|
||||
<n-gradient-text type="primary" font-size="28">
|
||||
{{ data.avgScore }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
<template v-if="data.personCount > 0">
|
||||
<div class="stat-item">
|
||||
<n-text>完成人数</n-text>
|
||||
<n-gradient-text type="error" font-size="28">
|
||||
{{ data.completedCount }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>班级人数</n-text>
|
||||
<!-- 减掉请假隐藏的,和下面完成度的分母、代码统计面板是同一个数 -->
|
||||
<n-gradient-text type="warning" font-size="28">
|
||||
{{ adjustedPersonCount }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>完成度</n-text>
|
||||
<n-gradient-text type="success" font-size="28">
|
||||
{{ completionRate }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
</template>
|
||||
</n-flex>
|
||||
<n-divider style="margin: 16px 0" />
|
||||
|
||||
<n-tabs animated type="line">
|
||||
<n-tab-pane name="charts" tab="数据图表">
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
|
||||
<!-- 1. Grade pie chart -->
|
||||
<n-gi v-if="data.totalCount > 0">
|
||||
<n-card title="等级分布">
|
||||
<div class="chart-container">
|
||||
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<!-- 3. Completion doughnut -->
|
||||
<n-gi v-if="data.personCount > 0">
|
||||
<n-card title="班级完成度">
|
||||
<div class="chart-container">
|
||||
<Doughnut :data="completionChartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<!-- 2. Radar chart -->
|
||||
<n-gi v-if="hasRadarData">
|
||||
<n-card title="四维评分雷达图">
|
||||
<div class="chart-container">
|
||||
<Radar :data="radarChartData" :options="radarOptions" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<!-- 4. Criteria bar chart (only when class exists, pairs with radar) -->
|
||||
<n-gi v-if="data.personCount > 0 && hasRadarData">
|
||||
<n-card title="各维度平均得分">
|
||||
<div class="chart-container">
|
||||
<Bar :data="criteriaBarChartData" :options="barOptions" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<!-- 4. Word cloud -->
|
||||
<n-gi :span="2" v-if="data.wordFrequencies.length > 0">
|
||||
<n-card title="常见问题高频词">
|
||||
<div class="wordcloud-container">
|
||||
<canvas ref="wordcloudCanvas"></canvas>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 有花名册就一直在(和代码统计面板一样):原来人一做完这个页签就整个消失,
|
||||
老师找不到「全都完成了」那句 -->
|
||||
<n-tab-pane
|
||||
v-if="data.personCount > 0 || data.dataUnaccepted.length > 0"
|
||||
name="unaccepted"
|
||||
:tab="`未完成(${visibleUnaccepted.length})`"
|
||||
>
|
||||
<n-flex align="center" style="margin: 12px 0">
|
||||
<n-switch v-model:value="hideMode" size="large">
|
||||
<template #checked>请假隐藏中</template>
|
||||
<template #unchecked>请假隐藏</template>
|
||||
</n-switch>
|
||||
<n-button v-if="hiddenCount > 0" size="small" type="info" @click="showAll">
|
||||
恢复 {{ hiddenCount }} 位
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex size="large" align="center">
|
||||
<n-text v-if="visibleUnaccepted.length === 0 && hiddenCount > 0" depth="3">
|
||||
没完成的 {{ hiddenCount }} 位都隐藏了
|
||||
</n-text>
|
||||
<n-gradient-text v-else-if="visibleUnaccepted.length === 0" font-size="24" type="success">
|
||||
全都完成了
|
||||
</n-gradient-text>
|
||||
<template v-for="item in visibleUnaccepted" :key="item.username">
|
||||
<n-tag
|
||||
v-if="hideMode"
|
||||
closable
|
||||
size="large"
|
||||
style="font-size: 20px"
|
||||
@close="hideStudent(item.username)"
|
||||
>
|
||||
{{ item.realName }}
|
||||
</n-tag>
|
||||
<span v-else style="font-size: 24px">{{ item.realName }}</span>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import type { FlowchartStatistics } from "@oj2/contract"
|
||||
import { getFlowchartStatistics } from "oj/api"
|
||||
import { PANEL_DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER, STORAGE_KEY } from "utils/constants"
|
||||
import { durationFromValue } from "utils/functions"
|
||||
import { useHiddenStudents } from "../composables/hiddenStudents"
|
||||
import { useStatisticsQuery } from "../composables/statisticsQuery"
|
||||
import { Doughnut, Radar, Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
RadialLinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
} from "chart.js"
|
||||
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
|
||||
|
||||
ChartJS.register(
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
RadialLinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
WordCloudController,
|
||||
WordElement,
|
||||
)
|
||||
|
||||
interface Props {
|
||||
problem: string
|
||||
username: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const durationOptions: SelectOption[] = [...PANEL_DURATION_OPTIONS]
|
||||
|
||||
// 班级、题号、时间段记一小时,理由见 useStatisticsQuery
|
||||
const { query, save: saveQuery } = useStatisticsQuery(STORAGE_KEY.FLOWCHART_STATISTICS_QUERY, props)
|
||||
|
||||
const data = reactive<FlowchartStatistics>({
|
||||
totalCount: 0,
|
||||
avgScore: 0,
|
||||
gradeDistribution: {},
|
||||
criteriaAverages: {},
|
||||
personCount: 0,
|
||||
completedCount: 0,
|
||||
wordFrequencies: [],
|
||||
dataUnaccepted: [],
|
||||
})
|
||||
|
||||
/**
|
||||
* 「查出东西了吗」。和 StatisticsPanel 同一个道理:一节课刚开始时没有任何提交,
|
||||
* 但花名册整份都在 dataUnaccepted 里,那会儿正是老师要看名单的时候。
|
||||
*/
|
||||
const hasResult = computed(() => data.totalCount > 0 || data.dataUnaccepted.length > 0)
|
||||
|
||||
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
|
||||
let wordcloudChart: ChartJS | null = null
|
||||
|
||||
const { hideMode, hideStudent, showAll, isHidden, notHidden } = useHiddenStudents(
|
||||
"oj_hidden_students_flowchart",
|
||||
)
|
||||
|
||||
const visibleUnaccepted = computed(() => data.dataUnaccepted.filter(notHidden))
|
||||
|
||||
const hiddenCount = computed(
|
||||
() => data.dataUnaccepted.filter((item) => isHidden(item.username)).length,
|
||||
)
|
||||
|
||||
const adjustedPersonCount = computed(() => Math.max(0, data.personCount - hiddenCount.value))
|
||||
|
||||
const completionRate = computed(() => {
|
||||
if (adjustedPersonCount.value <= 0) return "0%"
|
||||
const rate = Math.min(100, (data.completedCount / adjustedPersonCount.value) * 100)
|
||||
return `${Math.round(rate * 100) / 100}%`
|
||||
})
|
||||
|
||||
const GRADE_COLORS: Record<string, { bg: string; border: string }> = {
|
||||
S: { bg: "rgba(24, 160, 88, 0.6)", border: "rgba(24, 160, 88, 1)" },
|
||||
A: { bg: "rgba(32, 128, 240, 0.6)", border: "rgba(32, 128, 240, 1)" },
|
||||
B: { bg: "rgba(240, 160, 32, 0.6)", border: "rgba(240, 160, 32, 1)" },
|
||||
C: { bg: "rgba(208, 48, 80, 0.6)", border: "rgba(208, 48, 80, 1)" },
|
||||
}
|
||||
|
||||
const gradeChartData = computed(() => {
|
||||
const grades = ["S", "A", "B", "C"]
|
||||
const counts = grades.map((g) => data.gradeDistribution[g] || 0)
|
||||
const labels = grades.map((g) => `${g}级 (${data.gradeDistribution[g] || 0})`)
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: counts,
|
||||
backgroundColor: grades.map((g) => GRADE_COLORS[g].bg),
|
||||
borderColor: grades.map((g) => GRADE_COLORS[g].border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const completionChartData = computed(() => {
|
||||
const uncompleted = Math.max(0, adjustedPersonCount.value - data.completedCount)
|
||||
return {
|
||||
labels: ["已完成", "未完成"],
|
||||
datasets: [
|
||||
{
|
||||
data: [data.completedCount, uncompleted],
|
||||
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
|
||||
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const doughnutOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: "bottom" as const },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context: any) {
|
||||
const label = context.label || ""
|
||||
const value = context.parsed || 0
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const pct = ((value / total) * 100).toFixed(1)
|
||||
return `${label}: ${value} (${pct}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 与评分明细共用同一份顺序,见 utils/constants
|
||||
const CRITERIA_ORDER = FLOWCHART_CRITERIA_ORDER
|
||||
|
||||
const hasRadarData = computed(() => CRITERIA_ORDER.some((k) => k in data.criteriaAverages))
|
||||
|
||||
const radarChartData = computed(() => {
|
||||
const labels = CRITERIA_ORDER
|
||||
const values = CRITERIA_ORDER.map((k) => {
|
||||
const item = data.criteriaAverages[k]
|
||||
if (!item) return 0
|
||||
return Math.round((item.avg / item.max) * 100)
|
||||
})
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "平均得分率 (%)",
|
||||
data: values,
|
||||
backgroundColor: "rgba(32, 128, 240, 0.2)",
|
||||
borderColor: "rgba(32, 128, 240, 1)",
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: "rgba(32, 128, 240, 1)",
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const radarOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: { stepSize: 20 },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context: any) {
|
||||
const key = CRITERIA_ORDER[context.dataIndex]
|
||||
const item = data.criteriaAverages[key]
|
||||
if (!item) return ""
|
||||
return `${key}: ${item.avg}/${item.max} (${context.parsed.r}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const criteriaBarChartData = computed(() => {
|
||||
const labels = CRITERIA_ORDER.filter((k) => k in data.criteriaAverages)
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "平均得分",
|
||||
data: labels.map((k) => data.criteriaAverages[k]?.avg ?? 0),
|
||||
backgroundColor: labels.map((_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].bg),
|
||||
borderColor: labels.map((_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const barOptions = {
|
||||
responsive: true,
|
||||
aspectRatio: 1,
|
||||
scales: {
|
||||
y: { beginAtZero: true },
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context: any) {
|
||||
const key = context.label
|
||||
const item = data.criteriaAverages[key]
|
||||
if (!item) return ""
|
||||
return `${item.avg} / ${item.max}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const WORD_COLORS = [
|
||||
"#2080f0",
|
||||
"#18a058",
|
||||
"#f0a020",
|
||||
"#d03050",
|
||||
"#722ed1",
|
||||
"#13c2c2",
|
||||
"#1890ff",
|
||||
"#52c41a",
|
||||
"#faad14",
|
||||
"#f5222d",
|
||||
]
|
||||
|
||||
function renderWordCloud() {
|
||||
if (!wordcloudCanvas.value || data.wordFrequencies.length === 0) return
|
||||
|
||||
if (wordcloudChart) {
|
||||
wordcloudChart.destroy()
|
||||
wordcloudChart = null
|
||||
}
|
||||
|
||||
const words = data.wordFrequencies
|
||||
const maxCount = Math.max(...words.map((w) => w.count))
|
||||
|
||||
wordcloudChart = new ChartJS(wordcloudCanvas.value, {
|
||||
type: "wordCloud" as any,
|
||||
data: {
|
||||
labels: words.map((w) => w.word),
|
||||
datasets: [
|
||||
{
|
||||
label: "",
|
||||
data: words.map((w) => 10 + (w.count / maxCount) * 50),
|
||||
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
|
||||
rotate: 0,
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context: any) {
|
||||
const word = words[context.dataIndex]
|
||||
return word ? `${word.word}: ${word.count}次` : ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const subOptions = computed<Duration>(
|
||||
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
/** 上一次统计失败了。不弹 toast:会自动刷新,断网时会一直弹 */
|
||||
const failed = ref(false)
|
||||
|
||||
async function handleStatistics() {
|
||||
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
const current = Date.now()
|
||||
const end = formatISO(current)
|
||||
const duration =
|
||||
query.duration === "all" ? { end } : { start: formatISO(sub(current, subOptions.value)), end }
|
||||
try {
|
||||
const res = await getFlowchartStatistics(duration, query.problem, query.username)
|
||||
Object.assign(data, res)
|
||||
saveQuery()
|
||||
failed.value = false
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
console.error("获取流程图统计失败:", error)
|
||||
return
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
await nextTick()
|
||||
renderWordCloud()
|
||||
}
|
||||
|
||||
/**
|
||||
* 和代码统计面板一样:打开就查、开着就刷(老师是投在屏幕上盯着看的)。
|
||||
* 流程图统计要给词云分词,比代码统计重,所以 30 秒一次,而且只刷一天以内的时间段 ——
|
||||
* 查一周、一学年的是回头看,不是盯课堂,不必反复重算
|
||||
*/
|
||||
onMounted(handleStatistics)
|
||||
|
||||
const visibility = useDocumentVisibility()
|
||||
useIntervalFn(() => {
|
||||
if (visibility.value !== "visible") return
|
||||
if (query.duration === "all") return
|
||||
const d = subOptions.value
|
||||
if (d.weeks || d.months || d.years || (d.days ?? 0) > 1) return
|
||||
handleStatistics()
|
||||
}, 30_000)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (wordcloudChart) {
|
||||
wordcloudChart.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wordcloud-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -1,813 +0,0 @@
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
placeholder="班级或用户(可选)"
|
||||
v-model:value="query.username"
|
||||
:options="classOptions"
|
||||
style="width: 190px"
|
||||
filterable
|
||||
tag
|
||||
clearable
|
||||
/>
|
||||
<n-input
|
||||
placeholder="题号(可选,逗号分隔)"
|
||||
v-model:value="query.problem"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
/>
|
||||
<n-select style="width: 120px" v-model:value="query.duration" :options="options" />
|
||||
<n-button type="primary" :loading="loading" @click="handleStatistics"> 统计 </n-button>
|
||||
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions"> 前往提交列表 </n-button>
|
||||
<n-text v-if="failed" type="error">统计没拉下来,15 秒后会自动再试</n-text>
|
||||
</n-flex>
|
||||
|
||||
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
|
||||
|
||||
<template v-if="hasResult">
|
||||
<n-divider style="margin: 16px 0" />
|
||||
<n-flex justify="space-around">
|
||||
<div class="stat-item">
|
||||
<n-text>总提交</n-text>
|
||||
<n-gradient-text type="info" font-size="28">{{ count.total }}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>正确提交</n-text>
|
||||
<n-gradient-text type="primary" font-size="28">{{ count.accepted }}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item" v-if="count.judging > 0">
|
||||
<n-text>判题中</n-text>
|
||||
<n-gradient-text type="info" font-size="28">{{ count.judging }}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>正确率</n-text>
|
||||
<n-gradient-text type="warning" font-size="28">{{ count.rate }}%</n-gradient-text>
|
||||
</div>
|
||||
<template v-if="personCount > 0">
|
||||
<div class="stat-item">
|
||||
<n-text>完成人数</n-text>
|
||||
<n-gradient-text type="error" font-size="28">{{ doneList.length }}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>班级人数</n-text>
|
||||
<n-gradient-text type="warning" font-size="28">{{ adjustedPersonCount }}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>完成度</n-text>
|
||||
<n-gradient-text type="success" font-size="28">{{ adjustedPersonRate }}</n-gradient-text>
|
||||
</div>
|
||||
</template>
|
||||
</n-flex>
|
||||
<n-divider style="margin: 16px 0" />
|
||||
|
||||
<n-tabs animated type="line">
|
||||
<n-tab-pane name="charts" tab="数据图表">
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
|
||||
<n-gi v-if="count.total > 0">
|
||||
<n-card title="提交正确率">
|
||||
<Doughnut :data="pieChartData" :options="pieChartOptions" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi v-if="personCount > 0">
|
||||
<n-card title="班级完成度">
|
||||
<Doughnut :data="completionChartData" :options="completionChartOptions" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-tab-pane>
|
||||
|
||||
<!--
|
||||
窗口里交过东西的**所有人**,一行一个,展开是他这段时间的全部提交(对的错的
|
||||
都在)。没做完的人也在表里 —— 一次没对的学生正是老师最想点开看的那个。
|
||||
隔壁「未完成」是点名用的名单,只有名字,两栏用途不同。
|
||||
-->
|
||||
<n-tab-pane name="submissions" :tab="`提交记录(${list.length})`">
|
||||
<n-data-table
|
||||
v-if="list.length"
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:row-key="rowKey"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
@update:expanded-row-keys="updateExpandedRowKeys"
|
||||
:row-props="rowProps"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
<n-empty v-else description="还没有人提交" style="margin: 24px 0" />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="unaccepted" :tab="`未完成(${unfinishedTotal})`">
|
||||
<n-flex align="center" style="margin: 12px 0">
|
||||
<n-switch v-model:value="hideMode" size="large">
|
||||
<template #checked>请假隐藏中</template>
|
||||
<template #unchecked>请假隐藏</template>
|
||||
</n-switch>
|
||||
<n-button v-if="hiddenCount > 0" size="small" type="info" @click="showAll">
|
||||
恢复 {{ hiddenCount }} 位
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<!-- 名单上的人全被「请假隐藏」了不等于全做完了,别让老师误以为全班过了 -->
|
||||
<n-text v-if="unfinishedGroups.length === 0 && hiddenCount > 0" depth="3">
|
||||
没完成的 {{ hiddenCount }} 位都隐藏了
|
||||
</n-text>
|
||||
<n-gradient-text v-else-if="unfinishedGroups.length === 0" font-size="24" type="success">
|
||||
全都完成了
|
||||
</n-gradient-text>
|
||||
<template v-for="group in unfinishedGroups" :key="group.title">
|
||||
<n-text depth="3" class="group-title">
|
||||
{{ group.title }}({{ group.items.length }})
|
||||
</n-text>
|
||||
<n-flex size="large" align="center">
|
||||
<template v-for="item in group.items" :key="item.username">
|
||||
<n-popover
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
:disabled="!item.failure"
|
||||
style="max-width: 460px"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-tag
|
||||
v-if="hideMode"
|
||||
closable
|
||||
size="large"
|
||||
style="font-size: 20px"
|
||||
@close="hideStudent(item.username)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</n-tag>
|
||||
<span v-else :class="{ name: true, 'name-clickable': !!item.failure }">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
<n-flex vertical size="small">
|
||||
<n-text depth="3">
|
||||
最近一次:{{ item.failure?.problemDisplayId }} ·
|
||||
{{ statusName(item.failure?.result) }}
|
||||
</n-text>
|
||||
<pre v-if="item.failure?.error" class="failure-error">{{
|
||||
item.failure?.error
|
||||
}}</pre>
|
||||
<n-button size="small" tertiary @click="openFailure(item.failure?.id)">
|
||||
看代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-popover>
|
||||
</template>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import { getSubmission, getSubmissionStatistics, getSubmissionStatisticsItems } from "oj/api"
|
||||
import { LANGUAGE_FORMAT_VALUE, PANEL_DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
|
||||
import { useConfigStore } from "../store/config"
|
||||
import { useHiddenStudents } from "../composables/hiddenStudents"
|
||||
import { useStatisticsQuery } from "../composables/statisticsQuery"
|
||||
import { Doughnut } from "vue-chartjs"
|
||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||||
import { NCode, NFlex, NPopover, NTag, NText, type DataTableRowKey } from "naive-ui"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import { durationFromValue, parseTime } from "utils/functions"
|
||||
import type {
|
||||
AttemptedStudent,
|
||||
SubmissionStatisticsItems,
|
||||
SubmissionStatisticsUser,
|
||||
UnacceptedStudent,
|
||||
} from "@oj2/contract"
|
||||
|
||||
// 注册 Chart.js 组件
|
||||
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
||||
|
||||
interface Props {
|
||||
problem: string
|
||||
username: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const options: SelectOption[] = [...PANEL_DURATION_OPTIONS]
|
||||
|
||||
/**
|
||||
* 轨迹方块的颜色。取值就是 JUDGE_STATUS 里那个 type,色号沿用 Naive UI 的语义色
|
||||
* (项目里 ExerciseMatch.vue 等处也是直接写这几个值)。
|
||||
*/
|
||||
const ATTEMPT_COLORS: Record<string, string> = {
|
||||
success: "#18a058",
|
||||
error: "#d03050",
|
||||
warning: "#f0a020",
|
||||
info: "#2080f0",
|
||||
default: "#909399",
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个学生的提交按题目聚成组,给展开行画「状态轨迹」用。
|
||||
*
|
||||
* 原来展开行是一排提交编号按钮 —— 12 位十六进制本身没有信息量,一节课里学生在好几道
|
||||
* 题之间来回跳,那一排看不出他到底卡在哪。现在一道题一行:题号、标题、交了几次、
|
||||
* 过没过,后面跟一排按时间**从早到晚**的小方块,颜色就是判题状态。
|
||||
* 「三红一绿」和「七红到底」一眼分得开。
|
||||
*
|
||||
* 排序按老师的用法来:**没过的排前面**,其中交得越多越靠前 —— 卡得最久的那道顶到眼前;
|
||||
* 已通过的沉底,它们只是「做完了」,不需要再看。
|
||||
*/
|
||||
function groupByProblem(list: SubmissionStatisticsItems["items"]) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
problemDisplayId: string
|
||||
problemTitle: string
|
||||
items: SubmissionStatisticsItems["items"]
|
||||
}
|
||||
>()
|
||||
for (const item of list) {
|
||||
const group = groups.get(item.problemDisplayId)
|
||||
if (group) group.items.push(item)
|
||||
else
|
||||
groups.set(item.problemDisplayId, {
|
||||
problemDisplayId: item.problemDisplayId,
|
||||
problemTitle: item.problemTitle,
|
||||
items: [item],
|
||||
})
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map((group) => ({
|
||||
...group,
|
||||
// 后端按时间倒序给,轨迹要从早到晚读,所以翻过来
|
||||
items: [...group.items].reverse(),
|
||||
// 「语法未过」(ast_check_failed) 也算做出来了 —— 答案对了,只是没按要求写;
|
||||
// 和统计表格上「已解决」那一列的口径保持一致
|
||||
solved: group.items.some(
|
||||
(item) =>
|
||||
item.result === SubmissionStatus.accepted ||
|
||||
item.result === SubmissionStatus.ast_check_failed,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => Number(a.solved) - Number(b.solved) || b.items.length - a.items.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬停方块时看到的代码,按提交编号缓存。
|
||||
*
|
||||
* 原来悬停只有「状态 · 时间 · 编号」,想看学生写了什么非得点开新页面,老师在一排
|
||||
* 红方块之间来回对比时要开一堆标签页。代码**悬停时才拉**、不随明细一起下发:
|
||||
* 明细一次最多 200 条、展开着每 15 秒还要重拉,而真正会被悬停的只是其中几个。
|
||||
* 代码交了就不会再变,所以缓存不随重新统计清空(和 items 不一样)。
|
||||
*/
|
||||
type CodeState =
|
||||
| { status: "loading" }
|
||||
| { status: "error" }
|
||||
| { status: "done"; code: string; language: string }
|
||||
|
||||
const codes = reactive<Record<string, CodeState>>({})
|
||||
|
||||
async function loadCode(id: string) {
|
||||
const cached = codes[id]
|
||||
if (cached && cached.status !== "error") return
|
||||
codes[id] = { status: "loading" }
|
||||
try {
|
||||
const submission = await getSubmission(id)
|
||||
codes[id] = {
|
||||
status: "done",
|
||||
code: submission.code,
|
||||
language: LANGUAGE_FORMAT_VALUE[submission.language],
|
||||
}
|
||||
} catch {
|
||||
codes[id] = { status: "error" }
|
||||
}
|
||||
}
|
||||
|
||||
function renderCode(id: string) {
|
||||
const cached = codes[id]
|
||||
if (!cached || cached.status === "loading") return h(NText, { depth: 3 }, () => "代码加载中…")
|
||||
if (cached.status === "error")
|
||||
return h(NText, { type: "error" }, () => "代码没拉下来,点方块打开看")
|
||||
return h(
|
||||
"div",
|
||||
// 太长就在浮层里滚:鼠标移进浮层它不会消失,可以接着往下翻
|
||||
{ style: "max-height: 360px; overflow: auto" },
|
||||
h(NCode, { code: cached.code, language: cached.language, showLineNumbers: true }),
|
||||
)
|
||||
}
|
||||
|
||||
function openSubmission(id: string) {
|
||||
window.open(`/submission/${id}`, "_blank", "noopener")
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||||
{
|
||||
type: "expand",
|
||||
renderExpand: (row) => {
|
||||
const loaded = items[row.username]
|
||||
if (!loaded) return h(NText, { depth: 3 }, () => "加载中…")
|
||||
return h(NFlex, { vertical: true, size: "medium" }, () => [
|
||||
...groupByProblem(loaded.items).map((group) =>
|
||||
// 题头一栏、轨迹一栏,**外层不换行**:一道题交了几十发时方块要在自己那一栏里
|
||||
// 折行,折下来的一行才会和上一行对齐
|
||||
h(NFlex, { size: "small", align: "flex-start", wrap: false }, () => [
|
||||
h(
|
||||
NFlex,
|
||||
{
|
||||
size: 4,
|
||||
align: "center",
|
||||
wrap: false,
|
||||
style: "width: 200px; flex: none",
|
||||
},
|
||||
() => [
|
||||
h(NTag, { size: "small", bordered: false }, () => group.problemDisplayId),
|
||||
h(
|
||||
NText,
|
||||
{
|
||||
depth: 2,
|
||||
style: "overflow: hidden; text-overflow: ellipsis; white-space: nowrap",
|
||||
},
|
||||
() => group.problemTitle,
|
||||
),
|
||||
],
|
||||
),
|
||||
h(
|
||||
NText,
|
||||
{
|
||||
depth: 3,
|
||||
style: "width: 104px; flex: none",
|
||||
},
|
||||
() => `${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
|
||||
),
|
||||
h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () =>
|
||||
group.items.map((item) =>
|
||||
h(
|
||||
NPopover,
|
||||
{
|
||||
delay: 200,
|
||||
// 代码比一行提示宽得多;限宽让长行在浮层里横向滚,不把浮层撑出屏幕
|
||||
style: "max-width: 560px",
|
||||
onUpdateShow: (show: boolean) => {
|
||||
if (show) loadCode(item.id)
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h("button", {
|
||||
// 内联样式而不是 class:这些方块是 h() 出来、挂在 NDataTable 的
|
||||
// 展开槽里渲染的,<style scoped> 能不能盖到它并不确定
|
||||
style: {
|
||||
width: "14px",
|
||||
height: "14px",
|
||||
padding: "0",
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
cursor: "pointer",
|
||||
background: ATTEMPT_COLORS[JUDGE_STATUS[item.result]?.type ?? "default"],
|
||||
},
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openSubmission(item.id)
|
||||
},
|
||||
}),
|
||||
default: () =>
|
||||
h(NFlex, { vertical: true, size: "small" }, () => [
|
||||
h(
|
||||
NText,
|
||||
{ depth: 3 },
|
||||
() =>
|
||||
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
|
||||
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
|
||||
`${item.id.toString().slice(0, 12)}`,
|
||||
),
|
||||
renderCode(item.id),
|
||||
]),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
loaded.truncated
|
||||
? h(
|
||||
NText,
|
||||
{ depth: 3 },
|
||||
() => `只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
|
||||
)
|
||||
: null,
|
||||
])
|
||||
},
|
||||
},
|
||||
{ title: "用户", key: "username" },
|
||||
// 做完没做完在同一张表里,用标签区分 —— 少了它这张表看不出谁还卡着
|
||||
{
|
||||
title: "完成",
|
||||
key: "done",
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
size: "small",
|
||||
type: row.done ? "success" : "default",
|
||||
bordered: false,
|
||||
},
|
||||
() => (row.done ? "已完成" : "未完成"),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "提交数",
|
||||
key: "submissionCount",
|
||||
render: (row) =>
|
||||
row.judgingCount > 0
|
||||
? `${row.submissionCount}(${row.judgingCount} 条判题中)`
|
||||
: `${row.submissionCount}`,
|
||||
},
|
||||
// 题数,不是通过的提交条数 —— 同一道题重复 AC 只算一道。
|
||||
// 「语法未过」是答案对了但没按要求写、而且最后也没改对的,算在已解决里但教学上没达标
|
||||
{
|
||||
title: "已解决",
|
||||
key: "solvedCount",
|
||||
render: (row) =>
|
||||
row.astOnlyCount > 0
|
||||
? `${row.solvedCount}(${row.astOnlyCount} 题语法未过)`
|
||||
: `${row.solvedCount}`,
|
||||
},
|
||||
// 新后端返回的是数值,百分号在这里补 —— 旧后端直接返回 "85.5%" 字符串
|
||||
{
|
||||
title: "正确率",
|
||||
key: "correctRate",
|
||||
render: (row) => `${row.correctRate}%`,
|
||||
},
|
||||
]
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
/**
|
||||
* 班级下拉。值就是用户名前缀(`ks231`),后端那边本来就是 ilike 模糊匹配,
|
||||
* 所以选班级和手打前缀是同一件事。`tag` 留着让老师仍然能直接打某个学生的名字。
|
||||
*/
|
||||
const classOptions = computed<SelectOption[]>(
|
||||
() =>
|
||||
configStore.config?.classList.map((item) => ({
|
||||
label: `${item.slice(0, 2)}计算机${item.slice(2)}班`,
|
||||
value: `ks${item}`,
|
||||
})) ?? [],
|
||||
)
|
||||
|
||||
// 班级、题号、时间段记一小时,理由见 useStatisticsQuery
|
||||
const { query, save: saveQuery } = useStatisticsQuery(STORAGE_KEY.STATISTICS_QUERY, props)
|
||||
|
||||
const count = reactive({
|
||||
total: 0,
|
||||
accepted: 0,
|
||||
// 还在判题队列里的条数。total 含它,rate 的分母不含 —— 全班同时交卷的那几秒,
|
||||
// 分母涨了分子没涨,正确率会凭空掉一截
|
||||
judging: 0,
|
||||
rate: 0,
|
||||
})
|
||||
// 花名册人数。后端只下发这一个分母,完成度在前端算 —— 「请假隐藏」要从分母里
|
||||
// 减人,那是浏览器本地状态,后端算不出来
|
||||
const personCount = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 交过东西的所有人(做没做完看 done)
|
||||
const list = ref<SubmissionStatisticsUser[]>([])
|
||||
// 「完成人数」「完成度」算的是做完了的那些,不是整张表的行数
|
||||
const doneList = computed(() => list.value.filter((row) => row.done))
|
||||
const listUnaccepted = ref<UnacceptedStudent[]>([])
|
||||
// 交了但一次没对的。和上面那一栏合起来才是「未完成」的全部
|
||||
const listAttempted = ref<AttemptedStudent[]>([])
|
||||
const expandedRowKeys = ref<DataTableRowKey[]>([])
|
||||
|
||||
/**
|
||||
* 展开行的明细,按人缓存。**每次重新统计都清空** —— 时间窗滚过之后旧明细就不对了;
|
||||
* 清完如果还有展开着的行,顺手把那一行重拉一遍,让它跟着自动刷新一起活着。
|
||||
*/
|
||||
const items = reactive<Record<string, SubmissionStatisticsItems>>({})
|
||||
|
||||
// 点一行会同时走 rowProps 的 onClick 和表格的 update:expanded-row-keys,
|
||||
// 两边都想拉一次;去重放在这里,调用方不用各自判
|
||||
const itemsLoading = new Set<string>()
|
||||
|
||||
async function loadItems(username: string) {
|
||||
if (items[username] || itemsLoading.has(username)) return
|
||||
itemsLoading.add(username)
|
||||
const current = Date.now()
|
||||
const duration =
|
||||
query.duration === "all"
|
||||
? { end: formatISO(current) }
|
||||
: {
|
||||
start: formatISO(sub(current, subOptions.value)),
|
||||
end: formatISO(current),
|
||||
}
|
||||
try {
|
||||
items[username] = await getSubmissionStatisticsItems(duration, username, query.problem)
|
||||
} catch {
|
||||
// 拉不到就当空的:展开行显示不出东西,但不该把整个面板带崩
|
||||
items[username] = { items: [], truncated: false }
|
||||
} finally {
|
||||
itemsLoading.delete(username)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「查出东西了吗」。**不能只看提交数** —— 一节课刚开始时一条提交都没有,但后端
|
||||
* 已经把整份花名册当作「未完成」返回了,而那正是老师最想看名单的时刻。
|
||||
* 原来整个面板 v-if 在 count.total > 0 上,那会儿只显示「暂无数据」。
|
||||
*/
|
||||
const hasResult = computed(() => count.total > 0 || listUnaccepted.value.length > 0)
|
||||
|
||||
const { hideMode, hideStudent, showAll, notHidden } = useHiddenStudents("oj_hidden_students")
|
||||
|
||||
const visibleUnaccepted = computed(() => listUnaccepted.value.filter(notHidden))
|
||||
const visibleAttempted = computed(() => listAttempted.value.filter(notHidden))
|
||||
|
||||
// 请假的人两栏都要藏 —— 他们同样占着班级人数这个分母
|
||||
const hiddenCount = computed(
|
||||
() =>
|
||||
listUnaccepted.value.length +
|
||||
listAttempted.value.length -
|
||||
visibleUnaccepted.value.length -
|
||||
visibleAttempted.value.length,
|
||||
)
|
||||
|
||||
const unfinishedTotal = computed(
|
||||
() => visibleUnaccepted.value.length + visibleAttempted.value.length,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这次查的是几道题。**取的是「上次点统计时」的值**,不是输入框的当前内容 ——
|
||||
* 老师改到一半的题号不该把已经查出来的结果重新解释一遍。
|
||||
*/
|
||||
const queriedProblemCount = ref(0)
|
||||
|
||||
/**
|
||||
* 未完成的两组。合成一栏而不是各占一个 tab:点名时来回切 tab 很别扭;
|
||||
* 但也不能混成一个名单 —— 「卡住了」和「还没动手」在课堂上要做的事不一样。
|
||||
* 次数缀在名字后面,教师一眼看得出谁卡得最久(后端按提交数倒序给)。
|
||||
*/
|
||||
type UnfinishedItem = {
|
||||
username: string
|
||||
label: string
|
||||
/** 只有「交了没对」那一组有,点名字弹出来看错在哪 */
|
||||
failure: AttemptedStudent["lastFailure"]
|
||||
}
|
||||
|
||||
const unfinishedGroups = computed<{ title: string; items: UnfinishedItem[] }[]>(() =>
|
||||
[
|
||||
{
|
||||
title: "还没交",
|
||||
items: visibleUnaccepted.value.map((item) => ({
|
||||
username: item.username,
|
||||
label: item.realName,
|
||||
failure: null,
|
||||
})),
|
||||
},
|
||||
{
|
||||
// 查多道题时这一栏混着「一道没对」和「差一道」两种人,标题得说清是「没全对」
|
||||
title: queriedProblemCount.value > 1 ? "交了没全对" : "交了没对",
|
||||
items: visibleAttempted.value.map((item) => ({
|
||||
username: item.username,
|
||||
label: attemptedLabel(item),
|
||||
failure: item.lastFailure,
|
||||
})),
|
||||
},
|
||||
].filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
type FailureResult = NonNullable<AttemptedStudent["lastFailure"]>["result"]
|
||||
|
||||
// 查多道题时把「做出几道」缀上:差一道和一道没做出来,老师要先管的不是同一个人
|
||||
function attemptedLabel(item: AttemptedStudent) {
|
||||
const total = queriedProblemCount.value
|
||||
const progress = total > 1 ? ` ${item.solvedCount}/${total}题` : ""
|
||||
return `${item.realName}${progress} ${item.submissionCount}次`
|
||||
}
|
||||
|
||||
function statusName(result?: FailureResult) {
|
||||
return result === undefined ? "" : (JUDGE_STATUS[result]?.name ?? "未知状态")
|
||||
}
|
||||
|
||||
function openFailure(id?: string) {
|
||||
if (id) openSubmission(id)
|
||||
}
|
||||
|
||||
const adjustedPersonCount = computed(() => personCount.value - hiddenCount.value)
|
||||
|
||||
const adjustedPersonRate = computed(() => {
|
||||
if (adjustedPersonCount.value <= 0) return "0%"
|
||||
const rate = Math.min(100, (doneList.value.length / adjustedPersonCount.value) * 100)
|
||||
return `${Math.round(rate * 100) / 100}%`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 过期清理在 useHiddenStudents 里,这里只管「打开就查一次」——
|
||||
// 老师是投在屏幕上盯着看的,不该还要先点一下按钮
|
||||
handleStatistics()
|
||||
})
|
||||
|
||||
/**
|
||||
* 课堂上「还剩几个没交」每十几秒就在变,所以面板自己刷。
|
||||
* 时间窗是相对当下算的,每次刷都是新的窗口,不是同一份快照重放。
|
||||
* 页面切到后台就停 —— 那会儿没人在看,白跑而已。
|
||||
*/
|
||||
const visibility = useDocumentVisibility()
|
||||
useIntervalFn(() => {
|
||||
if (visibility.value === "visible") handleStatistics()
|
||||
}, 15000)
|
||||
|
||||
// 饼图数据 - 提交正确率分布
|
||||
const pieChartData = computed(() => {
|
||||
// 判题中的既不算对也不算错,和正确率同一个口径
|
||||
const wrongCount = count.total - count.accepted - count.judging
|
||||
return {
|
||||
labels: ["正确提交", "错误提交"],
|
||||
datasets: [
|
||||
{
|
||||
label: "提交数",
|
||||
data: [count.accepted, wrongCount],
|
||||
backgroundColor: ["rgba(75, 192, 192, 0.6)", "rgba(255, 99, 132, 0.6)"],
|
||||
borderColor: ["rgba(75, 192, 192, 1)", "rgba(255, 99, 132, 1)"],
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const pieChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "bottom" as const,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
const label = context.label || ""
|
||||
const value = context.parsed || 0
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${label}: ${value} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 环形图数据 - 班级完成度。「已完成」和上面数字行的「完成人数」是同一个数(做完的),
|
||||
// 原来这里用的是 list.length —— 那是所有交过的人,交了没对的也被画成了已完成
|
||||
const completionChartData = computed(() => {
|
||||
const completedCount = doneList.value.length
|
||||
const uncompletedCount = Math.max(0, adjustedPersonCount.value - completedCount)
|
||||
return {
|
||||
labels: ["已完成", "未完成"],
|
||||
datasets: [
|
||||
{
|
||||
label: "人数",
|
||||
data: [completedCount, uncompletedCount],
|
||||
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
|
||||
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const completionChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "bottom" as const,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
const label = context.label || ""
|
||||
const value = context.parsed || 0
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${label}: ${value} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const subOptions = computed<Duration>(
|
||||
// 认不出来(含 all)就退回列表第一档,和原来 `?? options[0]` 一致;
|
||||
// all 实际不会走到这里,handleStatistics 先分支掉了
|
||||
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
|
||||
)
|
||||
|
||||
function goSubmissions() {
|
||||
router.push({
|
||||
name: "submissions",
|
||||
query: {
|
||||
username: query.username,
|
||||
problem: query.problem,
|
||||
},
|
||||
})
|
||||
}
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* 上一次统计失败了。原来没 catch,请求一失败面板上什么都不说,老师看着的还是旧数字。
|
||||
* 不弹 toast:每 15 秒自动刷新一次,断网时会一直弹
|
||||
*/
|
||||
const failed = ref(false)
|
||||
|
||||
async function handleStatistics() {
|
||||
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await fetchStatistics()
|
||||
failed.value = false
|
||||
} catch {
|
||||
failed.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatistics() {
|
||||
const current = Date.now()
|
||||
const end = formatISO(current)
|
||||
const duration =
|
||||
query.duration === "all" ? { end } : { start: formatISO(sub(current, subOptions.value)), end }
|
||||
const problems = query.problem
|
||||
.split(/[,,;;\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const res = await getSubmissionStatistics(duration, query.problem, query.username)
|
||||
saveQuery()
|
||||
queriedProblemCount.value = new Set(problems.map((p) => p.toLowerCase())).size
|
||||
count.total = res.submissionCount
|
||||
count.accepted = res.acceptedCount
|
||||
count.judging = res.judgingCount
|
||||
count.rate = res.correctRate
|
||||
// 这里的 res.data 是载荷**自己**的 data 字段(每个学生一行),
|
||||
// 不是原来那层信封 —— 契约 submissionStatisticsSchema 就是这么定的
|
||||
list.value = res.data
|
||||
listUnaccepted.value = res.dataUnaccepted
|
||||
listAttempted.value = res.dataAttempted
|
||||
personCount.value = res.personCount
|
||||
|
||||
const expanded = expandedRowKeys.value[0]
|
||||
for (const key of Object.keys(items)) delete items[key]
|
||||
if (typeof expanded === "string") loadItems(expanded)
|
||||
}
|
||||
|
||||
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
|
||||
return row.username
|
||||
}
|
||||
|
||||
function updateExpandedRowKeys(keys: DataTableRowKey[]) {
|
||||
expandedRowKeys.value = keys.slice(-1)
|
||||
const opened = expandedRowKeys.value[0]
|
||||
if (typeof opened === "string") loadItems(opened)
|
||||
}
|
||||
|
||||
function rowProps(row: SubmissionStatisticsUser) {
|
||||
return {
|
||||
style: "cursor: pointer;",
|
||||
onClick: () => {
|
||||
const key = rowKey(row)
|
||||
const isExpanded = expandedRowKeys.value.includes(key)
|
||||
expandedRowKeys.value = isExpanded ? [] : [key]
|
||||
if (!isExpanded) loadItems(row.username)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.name {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.name-clickable {
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.failure-error {
|
||||
margin: 0;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: block;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,65 +0,0 @@
|
||||
import { reactive } from "vue"
|
||||
import { PANEL_DURATION_OPTIONS } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
|
||||
/**
|
||||
* 统计面板的查询条件(班级/用户、题号、时间段),**记一小时**:一小时内再打开,
|
||||
* 框里是上次查的那一组。
|
||||
*
|
||||
* 老师一节课里会反复开关这个弹窗(看一眼、关掉、走下去辅导、回来再开),每次都从空的
|
||||
* 选班级、敲题号很烦。但也不能一直记着 —— 曾经把上次的班级永久写进 localStorage,
|
||||
* 下课换了班、或者换个人坐这台机器,上一个人查的班悄悄留在框里,老师没注意就按了
|
||||
* 「统计」,看到的整个是别人的班。一小时是折中:一节课够用,隔一节课就自己清掉。
|
||||
* 注意**连堂**时下一节仍在一小时内,框里会是上一个班,这一点是接受了的。
|
||||
*
|
||||
* 一小时从「最后一次查询」算起(调用方在每次查完后调 `save`,自动刷新也算),
|
||||
* 开着面板盯一整节课不会中途过期。
|
||||
*
|
||||
* 调用方传进来的初值优先:题目页固定是这道题、提交列表带着自己的筛选条件,
|
||||
* 那是比缓存更明确的上下文;为空的那几项才用缓存补上。
|
||||
* 登录框那份 LOGIN_CLASS 是另一回事:那记的是这台机器的身份,不是一次临时查询。
|
||||
*
|
||||
* @param storageKey localStorage 的键。**两个面板各用各的** —— 流程图题和代码题的
|
||||
* 题号不是一批,互相带过去只会查出一片空。
|
||||
*/
|
||||
export function useStatisticsQuery(
|
||||
storageKey: string,
|
||||
initial: { username: string; problem: string },
|
||||
) {
|
||||
const TTL = 60 * 60 * 1000
|
||||
|
||||
type Cached = { username: string; problem: string; duration: string; savedAt: number }
|
||||
|
||||
function load(): Partial<Cached> {
|
||||
try {
|
||||
const cached: Cached | null = storage.get(storageKey)
|
||||
if (!cached || Date.now() - cached.savedAt > TTL) return {}
|
||||
return cached
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const cached = load()
|
||||
|
||||
const query = reactive({
|
||||
username: initial.username || cached.username || "",
|
||||
problem: initial.problem || cached.problem || "",
|
||||
// 选项表改过之后旧缓存里的值可能已经不在里面了,认不出来就回到第一档
|
||||
duration: PANEL_DURATION_OPTIONS.some((option) => option.value === cached.duration)
|
||||
? cached.duration!
|
||||
: PANEL_DURATION_OPTIONS[0].value,
|
||||
})
|
||||
|
||||
function save() {
|
||||
storage.set(storageKey, {
|
||||
// 选择框点了清除是 null,不是空串
|
||||
username: query.username ?? "",
|
||||
problem: query.problem ?? "",
|
||||
duration: query.duration,
|
||||
savedAt: Date.now(),
|
||||
} satisfies Cached)
|
||||
}
|
||||
|
||||
return { query, save }
|
||||
}
|
||||
@@ -106,6 +106,19 @@ export const flowchartStatisticsSchema = z.object({
|
||||
wordFrequencies: z.array(z.object({ word: z.string(), count: z.number().int() })),
|
||||
// 与提交统计共用「未完成学生」的形状,见 submission.ts 的 unacceptedStudentSchema
|
||||
dataUnaccepted: z.array(z.object({ username: z.string(), realName: z.string() })),
|
||||
/**
|
||||
* 每人**最好的一次**(分数最高的那张),统计页的「每人最好的一次」表用。
|
||||
* 只含普通学生;按最好成绩从低到高排,没拿到 A 的排前面 —— 老师要先看的是他们
|
||||
*/
|
||||
people: z.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
realName: z.string(),
|
||||
bestScore: z.number().nullable(),
|
||||
bestGrade: z.string().nullable(),
|
||||
count: z.number().int(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const flowchartUpdateSchema = z.object({
|
||||
|
||||
@@ -386,6 +386,38 @@ export const submissionStatisticsItemsSchema = z.object({
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 统计页的方块串(GET /submissions/statistics/grid):范围内每个学生的每一次提交,
|
||||
* 前端按「人 × 题」排成方块串,并据此算每道题做完几人。计数口径(正确率、做完、没交)
|
||||
* 仍以 submissionStatisticsSchema 为准,这里只给「每一次交了什么结果」。
|
||||
*
|
||||
* 只含普通学生(老师试题留下的提交不混进来),最多 5000 条最近的,超了 `truncated`。
|
||||
* `classSizes` 只在没传用户名(从题目页进来、按班级汇总)时给:各班花名册人数。
|
||||
*/
|
||||
export const submissionStatisticsGridSchema = z.object({
|
||||
/** 传了题号就按传的顺序;没传就是范围内出现过的题,按第一次有人交的时间排 */
|
||||
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
|
||||
rows: z.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
/** 剥掉 `ks<班级号>` 前缀后的那段,同 unacceptedStudentSchema.realName */
|
||||
realName: z.string(),
|
||||
className: z.string().nullable(),
|
||||
/** 从早到晚 */
|
||||
submissions: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
problemDisplayId: z.string(),
|
||||
result: judgeStatusSchema,
|
||||
createTime: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
classSizes: z.record(z.string(), z.number().int()),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
|
||||
export const submissionStatisticsSchema = z.object({
|
||||
submissionCount: z.number().int(),
|
||||
acceptedCount: z.number().int(),
|
||||
@@ -528,6 +560,7 @@ export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema>
|
||||
export type TodaySubmissionStatistics = z.infer<typeof todaySubmissionStatisticsSchema>
|
||||
export type SubmissionStatisticsUser = z.infer<typeof submissionStatisticsUserSchema>
|
||||
export type SubmissionStatisticsItems = z.infer<typeof submissionStatisticsItemsSchema>
|
||||
export type SubmissionStatisticsGrid = z.infer<typeof submissionStatisticsGridSchema>
|
||||
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>
|
||||
export type AttemptedStudent = z.infer<typeof attemptedStudentSchema>
|
||||
|
||||
|
||||
Reference in new issue
Block a user