feat(课堂): 只画了流程图的学生也算来上过课、做完了题——推断这节课的题、班上几人做完、最近来了几次都认流程图

原来课堂那边只有「我的状态」和看板格子认流程图 A/S,其余统计只数代码提交:
全班画流程图的那节课推断不出题,课堂条和看板都是空的;只画了图的学生在看板上
「最近来了 0 次」。现在代码提交和流程图提交合成一张表来数,口径统一:
流程图 A/S 算做完,交过流程图(含评分中、评失败)算做过。

- 推断这节课的题、「班上 M 人做完」、最近几节课是哪几天
- 看板「最近来了几次」、最后提交时间、各题尝试次数
- 看板没选班时猜正在上课的班
- 班级对比、个人资料的通过数不在这次范围,仍然只数代码

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 00:06:54 +08:00
1 parent 2857ff83c4
commit ee66cef367
1 file changed
+128 -55
+128 -55
View File
@@ -168,39 +168,108 @@ interface ClassDayGroup {
acceptedCount: number acceptedCount: number
} }
/**
* 流程图画到 A / S 算这道题做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
* 等级由分数推出(flowchart/grade.ts),重新评分会先把 grade 清空,所以 status = 2 其实
* 是冗余的,留着是把「评完了」写明。
*/
const FLOWCHART_PASSED = and(
eq(schema.flowchartSubmission.status, 2),
inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]),
)!
/** 这个班正常状态的学生,给下面的查询当 `user_id in (...)` 用 */
function classMemberIds(className: string) {
return db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
)
}
/**
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
* 口径和课堂条 / 看板的「做完」一致:
*
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
*
* 有的题一节课全班都在画流程图、代码提交个位数,只数代码的话这节课在统计里等于没上。
* 两边都按 `user_id in (...)` + `create_time >=` 走各自的 (user_id, create_time) 索引。
*/
function activityRows(options: {
since: string
users?: number[] | ReturnType<typeof classMemberIds>
problemIds?: number[]
}) {
const { since, users, problemIds } = options
const code = db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
createTime: schema.submission.createTime,
solved: sql<boolean>`${inArray(schema.submission.result, SOLVED_RESULTS)}`.as("solved"),
})
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, since),
users ? inArray(schema.submission.userId, users) : undefined,
problemIds ? inArray(schema.submission.problemId, problemIds) : undefined,
),
)
const flowchart = db
.select({
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
createTime: schema.flowchartSubmission.createTime,
solved: sql<boolean>`${FLOWCHART_PASSED}`.as("solved"),
})
.from(schema.flowchartSubmission)
.where(
and(
gte(schema.flowchartSubmission.createTime, since),
users ? inArray(schema.flowchartSubmission.userId, users) : undefined,
problemIds ? inArray(schema.flowchartSubmission.problemId, problemIds) : undefined,
),
)
return code.unionAll(flowchart).as("activity")
}
/** /**
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生, * 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交。`minUsers` 是「班里在做」的门槛,给了 `problemIds`(老师布置的题)时 * 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
* 不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。 * 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
*/ */
async function classDayGroups( async function classDayGroups(
className: string, className: string,
since: string, since: string,
options: { minUsers?: number; problemIds?: number[] } = {}, options: { minUsers?: number; problemIds?: number[] } = {},
): Promise<ClassDayGroup[]> { ): Promise<ClassDayGroup[]> {
const day = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')` const activity = activityRows({
const userCount = sql<number>`count(distinct ${schema.submission.userId})::int` since,
users: classMemberIds(className),
problemIds: options.problemIds,
})
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const userCount = sql<number>`count(distinct ${activity.userId})::int`
const query = db const query = db
.select({ .select({
day, day,
problemId: schema.submission.problemId, problemId: activity.problemId,
firstAt: sql<string>`min(${schema.submission.createTime})`, firstAt: sql<string>`min(${activity.createTime})`,
userCount, userCount,
acceptedCount: sql<number>`count(distinct ${schema.submission.userId}) filter (where ${inArray(schema.submission.result, SOLVED_RESULTS)})::int`, acceptedCount: sql<number>`count(distinct ${activity.userId}) filter (where ${activity.solved})::int`,
}) })
.from(schema.submission) .from(activity)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .groupBy(day, activity.problemId)
.where(
and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, since),
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
options.problemIds ? inArray(schema.submission.problemId, options.problemIds) : undefined,
),
)
.groupBy(day, schema.submission.problemId)
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
} }
@@ -328,20 +397,19 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
.groupBy(schema.submission.problemId) .groupBy(schema.submission.problemId)
: [], : [],
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。 // 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」 // 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」。
// 交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
ids.length ids.length
? db ? db
.select({ .select({
problemId: schema.flowchartSubmission.problemId, problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`bool_or(${inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES])})`, passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
}) })
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.where( .where(
and( and(
eq(schema.flowchartSubmission.userId, user.id), eq(schema.flowchartSubmission.userId, user.id),
inArray(schema.flowchartSubmission.problemId, ids), inArray(schema.flowchartSubmission.problemId, ids),
// 只数评完了的:评分中、评失败的那次不算「做过」
eq(schema.flowchartSubmission.status, 2),
), ),
) )
.groupBy(schema.flowchartSubmission.problemId) .groupBy(schema.flowchartSubmission.problemId)
@@ -386,18 +454,17 @@ const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
* 的班悄悄留在框里,老师看的整个是别人的班,见 StatisticsPanel.vue 的注释)。 * 的班悄悄留在框里,老师看的整个是别人的班,见 StatisticsPanel.vue 的注释)。
*/ */
async function suggestActiveClass() { async function suggestActiveClass() {
const users = sql<number>`count(distinct ${schema.submission.userId})::int` // 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
const activity = activityRows({
since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
})
const users = sql<number>`count(distinct ${activity.userId})::int`
const [row] = await db const [row] = await db
.select({ className: schema.user.className, users }) .select({ className: schema.user.className, users })
.from(schema.submission) .from(activity)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .innerJoin(schema.user, eq(schema.user.id, activity.userId))
.where( .where(
and( and(
gte(
schema.submission.createTime,
new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
),
isNull(schema.submission.contestId),
isNotNull(schema.user.className), isNotNull(schema.user.className),
eq(schema.user.isDisabled, false), eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]), inArray(schema.user.adminType, [...STUDENT_ROLES]),
@@ -463,8 +530,10 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
.sort() .sort()
.slice(-RECENT_LESSONS) .slice(-RECENT_LESSONS)
const localDay = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')` // 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
const [cells, lastSubmits, attendance, drawn] = await Promise.all([ const recentActivity = activityRows({ since: windowStart, users: userIds })
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
const [cells, lastSubmits, lastDrawn, attendance, drawn] = await Promise.all([
ids.length && userIds.length ids.length && userIds.length
? db ? db
.select({ .select({
@@ -500,26 +569,37 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
) )
.groupBy(schema.submission.userId) .groupBy(schema.submission.userId)
: [], : [],
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手。
// 和上面代码那条同一个范围:今天、所有题、不论评没评完
userIds.length
? db
.select({
userId: schema.flowchartSubmission.userId,
lastAt: sql<string>`max(${schema.flowchartSubmission.createTime})`,
})
.from(schema.flowchartSubmission)
.where(
and(
inArray(schema.flowchartSubmission.userId, userIds),
gte(schema.flowchartSubmission.createTime, start),
),
)
.groupBy(schema.flowchartSubmission.userId)
: [],
recentDays.length && userIds.length recentDays.length && userIds.length
? db ? db
.select({ .select({
userId: schema.submission.userId, userId: recentActivity.userId,
days: sql<number>`count(distinct ${localDay})::int`, days: sql<number>`count(distinct ${localDay})::int`,
}) })
.from(schema.submission) .from(recentActivity)
.where( .where(inArray(localDay, recentDays))
and( .groupBy(recentActivity.userId)
isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds),
gte(schema.submission.createTime, windowStart),
inArray(localDay, recentDays),
),
)
.groupBy(schema.submission.userId)
: [], : [],
/** /**
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。 * 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」 * 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」。
* 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算
*/ */
ids.length && userIds.length ids.length && userIds.length
? db ? db
@@ -529,18 +609,13 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
attempts: sql<number>`count(*) filter (where ${gte(schema.flowchartSubmission.createTime, start)})::int`, attempts: sql<number>`count(*) filter (where ${gte(schema.flowchartSubmission.createTime, start)})::int`,
firstPassedAt: sql< firstPassedAt: sql<
string | null string | null
>`min(${schema.flowchartSubmission.createTime}) filter (where ${and(eq(schema.flowchartSubmission.status, 2), inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]))})`, >`min(${schema.flowchartSubmission.createTime}) filter (where ${FLOWCHART_PASSED})`,
lastAt: sql<
string | null
>`max(${schema.flowchartSubmission.createTime}) filter (where ${gte(schema.flowchartSubmission.createTime, start)})`,
}) })
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.where( .where(
and( and(
inArray(schema.flowchartSubmission.userId, userIds), inArray(schema.flowchartSubmission.userId, userIds),
inArray(schema.flowchartSubmission.problemId, ids), inArray(schema.flowchartSubmission.problemId, ids),
// 只数评完了的:评分中、评失败的那次不算「做过」,和 /me/class-activity 一致
eq(schema.flowchartSubmission.status, 2),
), ),
) )
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId) .groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
@@ -549,9 +624,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row])) const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row])) const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt])) const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手 for (const row of lastDrawn) {
for (const row of drawn) {
if (!row.lastAt) continue
const known = lastByUser.get(row.userId) const known = lastByUser.get(row.userId)
if (!known || Date.parse(row.lastAt) > Date.parse(known)) lastByUser.set(row.userId, row.lastAt) if (!known || Date.parse(row.lastAt) > Date.parse(known)) lastByUser.set(row.userId, row.lastAt)
} }