统计面板那张表原来只给 `isDone` 为真的人,于是一次没对的学生连同他的提交 在面板里根本不存在——tab 却叫「提交记录」,看起来就像统计只认成功的提交。 - `data` 改成给窗口里交过东西的全部人,每行带 `done`;「完成人数」和完成度 跟着 `done` 数,不是 `data.length`,两个数字口径不变。 - 表格加「完成」列区分两种人;展开行拉的仍是那个人的全部提交(items 接口 本来就不按结果过滤),对错都在里面。 - 「交了没全对」那一栏原来无条件按花名册取,不填班级/用户名时花名册为空、 整栏跟着空掉,这批人两栏都不在。改成没有花名册时退回「有提交但没做完的 全部普通学生」,教师和禁用账号仍然排除;全站视图里保留完整用户名不剥班级 前缀。「还没交」那一栏没有花名册是真算不出来,仍然为空。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZbWEaPCnGvmdfNFpPFNGb
This commit is contained in:
@@ -483,6 +483,10 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
|||||||
*/
|
*/
|
||||||
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
|
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
|
||||||
className: schema.user.className,
|
className: schema.user.className,
|
||||||
|
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
|
||||||
|
// 传了用户名时这件事是花名册(rosterRows)做的
|
||||||
|
isDisabled: schema.user.isDisabled,
|
||||||
|
adminType: schema.user.adminType,
|
||||||
submissionCount: count(),
|
submissionCount: count(),
|
||||||
acceptedCount: acceptedFilter.mapWith(Number),
|
acceptedCount: acceptedFilter.mapWith(Number),
|
||||||
solvedCount: solvedFilter.mapWith(Number),
|
solvedCount: solvedFilter.mapWith(Number),
|
||||||
@@ -493,7 +497,13 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
|||||||
.where(where)
|
.where(where)
|
||||||
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
|
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
|
||||||
// 不会多分出组来,但省掉再对它们套一层聚合函数
|
// 不会多分出组来,但省掉再对它们套一层聚合函数
|
||||||
.groupBy(schema.submission.userId, schema.user.username, schema.user.className)
|
.groupBy(
|
||||||
|
schema.submission.userId,
|
||||||
|
schema.user.username,
|
||||||
|
schema.user.className,
|
||||||
|
schema.user.isDisabled,
|
||||||
|
schema.user.adminType,
|
||||||
|
)
|
||||||
.orderBy(desc(count())),
|
.orderBy(desc(count())),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -515,17 +525,24 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
|||||||
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
|
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
|
||||||
requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0
|
requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0
|
||||||
|
|
||||||
// 表格列的是做完了的人。没做完的(一条没交 / 交了没全对)在「未完成」那一栏
|
/**
|
||||||
const acceptedUsers = perUser.filter(isDone)
|
* 「提交记录」那张表列的是**窗口里交过东西的所有人**,`done` 标出谁做完了 ——
|
||||||
// 要等 acceptedUsers 定下来才能查,所以进不了上面那个 Promise.all
|
* 原来只给做完的人,于是一次没对的学生连同他的提交在这张表里根本不存在,
|
||||||
|
* 教师想看「他到底错在哪」得切到提交列表再翻。展开一行拉的是那个人的全部
|
||||||
|
* 提交(GET /submissions/statistics/items 不按结果过滤),对错都在里面。
|
||||||
|
*
|
||||||
|
* 「完成人数」这些数字跟着 `done` 算,不是 `data.length`。
|
||||||
|
*/
|
||||||
|
const doneCount = perUser.filter(isDone).length
|
||||||
|
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
|
||||||
const astOnlyByUserMap = await astOnlyByUser(
|
const astOnlyByUserMap = await astOnlyByUser(
|
||||||
where,
|
where,
|
||||||
acceptedUsers.map((row) => row.userId),
|
perUser.map((row) => row.userId),
|
||||||
)
|
)
|
||||||
|
|
||||||
const submittedUserIds = new Set(perUser.map((row) => row.userId))
|
const submittedUserIds = new Set(perUser.map((row) => row.userId))
|
||||||
|
|
||||||
const data = acceptedUsers.map((row) => ({
|
const data = perUser.map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
className: row.className,
|
className: row.className,
|
||||||
submissionCount: row.submissionCount,
|
submissionCount: row.submissionCount,
|
||||||
@@ -534,6 +551,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
|||||||
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
|
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
|
||||||
judgingCount: row.judgingCount,
|
judgingCount: row.judgingCount,
|
||||||
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
|
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
|
||||||
|
done: isDone(row),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const dataUnaccepted = rosterRows
|
const dataUnaccepted = rosterRows
|
||||||
@@ -543,20 +561,38 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
|||||||
realName: stripClassPrefix(row.username, row.className),
|
realName: stripClassPrefix(row.username, row.className),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 交了但一次没对的。**按花名册取**,和 dataUnaccepted 同一个范围 ——
|
/**
|
||||||
// 不指定用户名时没有花名册,这一栏也就跟着为空,不会冒出一堆别的班的人。
|
* 交了但没做完的:包括一道都没对的,也包括三道里做出两道的。
|
||||||
const rosterClassNames = new Map(rosterRows.map((row) => [row.id, row.className]))
|
*
|
||||||
// 交了但没做完的:包括一道都没对的,也包括三道里做出两道的
|
* **传了用户名时按花名册取**,和 dataUnaccepted 同一个范围,查一个班不会冒出
|
||||||
const attemptedRows = perUser.filter(
|
* 一堆别的班的人。
|
||||||
(row) => !isDone(row) && rosterClassNames.has(row.userId),
|
*
|
||||||
)
|
* 不传用户名时没有花名册,这一栏原先跟着空掉 —— 于是只交了错误答案的学生
|
||||||
|
* 「已完成」那张表进不去(没做完)、「未完成」那一栏也没有,整个人从屏幕上
|
||||||
|
* 消失,看起来就像统计只认成功的提交。这种情况退回「有提交但没做完的全部人」,
|
||||||
|
* 教师和禁用账号照样排除(否则老师自己试题留下的错误提交会混进点名名单)。
|
||||||
|
*
|
||||||
|
* 「还没交」那一栏没有花名册是真的算不出来(不知道该有谁),仍然为空。
|
||||||
|
*/
|
||||||
|
const rosterIds = new Set(rosterRows.map((row) => row.id))
|
||||||
|
const attemptedRows = perUser.filter((row) => {
|
||||||
|
if (isDone(row)) return false
|
||||||
|
return username
|
||||||
|
? rosterIds.has(row.userId)
|
||||||
|
: !row.isDisabled && row.adminType === "Regular User"
|
||||||
|
})
|
||||||
const failureByUser = await lastFailureByUser(
|
const failureByUser = await lastFailureByUser(
|
||||||
where,
|
where,
|
||||||
attemptedRows.map((row) => row.userId),
|
attemptedRows.map((row) => row.userId),
|
||||||
)
|
)
|
||||||
const dataAttempted = attemptedRows.map((row) => ({
|
const dataAttempted = attemptedRows.map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
realName: stripClassPrefix(row.username, rosterClassNames.get(row.userId) ?? null),
|
/**
|
||||||
|
* 剥前缀只在**查了某个班**的时候做:那时满屏都是同一个班,留着 `ks251` 是噪音。
|
||||||
|
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
|
||||||
|
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
|
||||||
|
*/
|
||||||
|
realName: username ? stripClassPrefix(row.username, row.className) : row.username,
|
||||||
submissionCount: row.submissionCount,
|
submissionCount: row.submissionCount,
|
||||||
solvedCount: row.solvedCount,
|
solvedCount: row.solvedCount,
|
||||||
lastFailure: failureByUser.get(row.userId) ?? null,
|
lastFailure: failureByUser.get(row.userId) ?? null,
|
||||||
@@ -566,7 +602,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
|||||||
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
|
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
|
||||||
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
|
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
|
||||||
let personCount = rosterRows.length
|
let personCount = rosterRows.length
|
||||||
if (personCount && personCount < data.length) personCount = data.length
|
if (personCount && personCount < doneCount) personCount = doneCount
|
||||||
|
|
||||||
return success(
|
return success(
|
||||||
c,
|
c,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<n-text>完成人数</n-text>
|
<n-text>完成人数</n-text>
|
||||||
<n-gradient-text type="error" font-size="28">{{
|
<n-gradient-text type="error" font-size="28">{{
|
||||||
list.length
|
doneList.length
|
||||||
}}</n-gradient-text>
|
}}</n-gradient-text>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
@@ -99,7 +99,12 @@
|
|||||||
</n-grid>
|
</n-grid>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|
||||||
<n-tab-pane name="submissions" tab="提交记录">
|
<!--
|
||||||
|
窗口里交过东西的**所有人**,一行一个,展开是他这段时间的全部提交(对的错的
|
||||||
|
都在)。没做完的人也在表里 —— 一次没对的学生正是老师最想点开看的那个。
|
||||||
|
隔壁「未完成」是点名用的名单,只有名字,两栏用途不同。
|
||||||
|
-->
|
||||||
|
<n-tab-pane name="submissions" :tab="`提交记录(${list.length})`">
|
||||||
<n-data-table
|
<n-data-table
|
||||||
v-if="list.length"
|
v-if="list.length"
|
||||||
striped
|
striped
|
||||||
@@ -111,7 +116,7 @@
|
|||||||
:row-props="rowProps"
|
:row-props="rowProps"
|
||||||
style="margin-top: 12px"
|
style="margin-top: 12px"
|
||||||
/>
|
/>
|
||||||
<n-empty v-else description="还没有人做出来" style="margin: 24px 0" />
|
<n-empty v-else description="还没有人提交" style="margin: 24px 0" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|
||||||
<n-tab-pane name="unaccepted" :tab="`未完成(${unfinishedTotal})`">
|
<n-tab-pane name="unaccepted" :tab="`未完成(${unfinishedTotal})`">
|
||||||
@@ -198,7 +203,7 @@ import storage from "utils/storage"
|
|||||||
import { useConfigStore } from "../store/config"
|
import { useConfigStore } from "../store/config"
|
||||||
import { Doughnut } from "vue-chartjs"
|
import { Doughnut } from "vue-chartjs"
|
||||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||||||
import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
|
import { NButton, NFlex, NTag, NText, type DataTableRowKey } from "naive-ui"
|
||||||
import { JUDGE_STATUS } from "utils/constants"
|
import { JUDGE_STATUS } from "utils/constants"
|
||||||
import type {
|
import type {
|
||||||
AttemptedStudent,
|
AttemptedStudent,
|
||||||
@@ -265,6 +270,17 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: "用户", key: "username" },
|
{ title: "用户", key: "username" },
|
||||||
|
// 做完没做完在同一张表里,用标签区分 —— 少了它这张表看不出谁还卡着
|
||||||
|
{
|
||||||
|
title: "完成",
|
||||||
|
key: "done",
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: row.done ? "success" : "default", bordered: false },
|
||||||
|
() => (row.done ? "已完成" : "未完成"),
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "提交数",
|
title: "提交数",
|
||||||
key: "submissionCount",
|
key: "submissionCount",
|
||||||
@@ -331,7 +347,10 @@ const personCount = ref(0)
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 交过东西的所有人(做没做完看 done)
|
||||||
const list = ref<SubmissionStatisticsUser[]>([])
|
const list = ref<SubmissionStatisticsUser[]>([])
|
||||||
|
// 「完成人数」「完成度」算的是做完了的那些,不是整张表的行数
|
||||||
|
const doneList = computed(() => list.value.filter((row) => row.done))
|
||||||
const listUnaccepted = ref<UnacceptedStudent[]>([])
|
const listUnaccepted = ref<UnacceptedStudent[]>([])
|
||||||
// 交了但一次没对的。和上面那一栏合起来才是「未完成」的全部
|
// 交了但一次没对的。和上面那一栏合起来才是「未完成」的全部
|
||||||
const listAttempted = ref<AttemptedStudent[]>([])
|
const listAttempted = ref<AttemptedStudent[]>([])
|
||||||
@@ -499,7 +518,7 @@ const adjustedPersonRate = computed(() => {
|
|||||||
if (adjustedPersonCount.value <= 0) return "0%"
|
if (adjustedPersonCount.value <= 0) return "0%"
|
||||||
const rate = Math.min(
|
const rate = Math.min(
|
||||||
100,
|
100,
|
||||||
(list.value.length / adjustedPersonCount.value) * 100,
|
(doneList.value.length / adjustedPersonCount.value) * 100,
|
||||||
)
|
)
|
||||||
return `${Math.round(rate * 100) / 100}%`
|
return `${Math.round(rate * 100) / 100}%`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -172,6 +172,13 @@ export const submissionStatisticsUserSchema = z.object({
|
|||||||
judgingCount: z.number().int(),
|
judgingCount: z.number().int(),
|
||||||
// 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。
|
// 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。
|
||||||
correctRate: z.number(),
|
correctRate: z.number(),
|
||||||
|
/**
|
||||||
|
* 这个人在本次查询的口径下做完了没有(查了 N 道题就要 N 道都解决)。
|
||||||
|
*
|
||||||
|
* `data` 里**没做完的人也在**,教师才能在同一张表里展开看他错在哪;「完成人数」
|
||||||
|
* 和完成度算的是 `done` 为真的那些,不是 `data.length`。
|
||||||
|
*/
|
||||||
|
done: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,10 +206,17 @@ export const submissionStatisticsSchema = z.object({
|
|||||||
// 花名册人数(未禁用的普通用户)。**只有这一个分母下发**:完成度由前端算,
|
// 花名册人数(未禁用的普通用户)。**只有这一个分母下发**:完成度由前端算,
|
||||||
// 因为「请假隐藏」会把请假的人从分母里减掉,那是后端不知道的浏览器本地状态。
|
// 因为「请假隐藏」会把请假的人从分母里减掉,那是后端不知道的浏览器本地状态。
|
||||||
personCount: z.number().int(),
|
personCount: z.number().int(),
|
||||||
|
/** 窗口里交过东西的所有人(做没做完看 `done`),按提交数倒序 */
|
||||||
data: z.array(submissionStatisticsUserSchema),
|
data: z.array(submissionStatisticsUserSchema),
|
||||||
/** 一条都没交的(花名册里的人减去有提交的人) */
|
/** 一条都没交的(花名册里的人减去有提交的人) */
|
||||||
dataUnaccepted: z.array(unacceptedStudentSchema),
|
dataUnaccepted: z.array(unacceptedStudentSchema),
|
||||||
/** 交了但一次没对的。和 dataUnaccepted 一样只在传了用户名(有花名册)时才有内容 */
|
/**
|
||||||
|
* 交了但没做完的(一道没对,或者查三道只做出两道)。
|
||||||
|
*
|
||||||
|
* 传了用户名时按花名册取,和 dataUnaccepted 同一个范围;不传用户名时没有花名册,
|
||||||
|
* 退回「窗口内有提交但没做完的全部普通学生」—— 否则这批人两栏都不在,看起来
|
||||||
|
* 就像统计只认成功的提交。dataUnaccepted 没有花名册就真的算不出来,仍然为空。
|
||||||
|
*/
|
||||||
dataAttempted: z.array(attemptedStudentSchema),
|
dataAttempted: z.array(attemptedStudentSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user