fix(统计): 「提交记录」列出所有交过的人,不再只有做完的
Some checks failed
Deploy / deploy (push) Has been cancelled

统计面板那张表原来只给 `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:
2026-09-08 23:52:34 -06:00
parent 6bef55904f
commit 26b23aa7c0
3 changed files with 90 additions and 21 deletions

View File

@@ -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,

View File

@@ -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}%`
}) })

View File

@@ -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),
}) })