refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk

- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改
- 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转
- 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today),
  首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts
- 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 22:53:07 +08:00
1 parent 46d3fe2dbe
commit 29c31640b5
82 files changed
+798 -766

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ const ONLINE_WINDOW_MS = 5 * 60 * 1000
/**
* 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。
*
* 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
* 课堂任务拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
* 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。
* 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。
*/
+2 -2
View File
@@ -428,7 +428,7 @@ function acceptRequest(ws: CollabSocket, request: HelpRequest) {
/**
* 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找),
* 「帮完了」要回到哪个班的看板
* 「帮完了」要回到哪个班的任务页
*/
function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) {
const frame = (peer: Record<string, unknown>) =>
@@ -630,7 +630,7 @@ function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown)
}
/**
* 老师在看板上主动打开一个学生正在写的代码(学生没举手)。
* 老师在任务页上主动打开一个学生正在写的代码(学生没举手)。
*
* 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」;
* 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。
+3 -3
View File
@@ -24,7 +24,7 @@ export function normalizeCollabLanguage(value: unknown): CollabLanguage {
}
/**
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂看板的格子同一份形状,
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂任务的格子同一份形状,
* 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码
*/
export interface CollabLatest {
@@ -111,7 +111,7 @@ export interface Room {
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
studentId: number
/**
* help = 学生举了手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。
* help = 学生举了手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
* 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清
*/
mode: 'help' | 'peek'
@@ -144,7 +144,7 @@ export function roomOf(ws: CollabSocket) {
}
/**
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在看板上「在写」那一列看它、
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在任务页上「在写」那一列看它、
* 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报,
* 连接断了就清掉。同一账号开了两个标签页时,后报的算
*/
+5 -5
View File
@@ -1293,10 +1293,10 @@ export const exerciseAttempt = pgTable(
)
/**
* 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节
* 老师给某个班布置的一节课的题(课堂任务里布置的),一次布置一行。一个班一天可以有几节
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
*
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(services/lesson.ts 的 classLessonProblems)。
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
*
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
@@ -1322,7 +1322,7 @@ export const classLesson = pgTable(
problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
/**
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
* 没有它。写入侧在 PUT /task/lesson 按 lessonLanguageSchema 校验过
*/
language: text().$type<LessonLanguage>(),
/**
@@ -1330,11 +1330,11 @@ export const classLesson = pgTable(
* 都是写代码。写入侧按 lessonModeSchema 校验过
*/
mode: text().$type<LessonMode>().notNull().default('code'),
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。任务页上写「HH:mm 谁 布置」 */
createdBy: integer('created_by').notNull(),
/**
* 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
* 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。
* 同一天有几节时,按它分先后:最后布置的那一节是任务页上的「这节课」。
* 加这一列之前布置的用 updated_at 回填(0034)
*/
createdAt: timestamp('created_at', {
+4 -2
View File
@@ -10,7 +10,8 @@ import { authRoutes } from './routes/auth'
import { accountRoutes } from './routes/account'
import { contestRoutes } from './routes/contest'
import { contentRoutes } from './routes/content'
import { classroomRoutes } from './routes/classroom'
import { homeRoutes } from './routes/home'
import { taskRoutes } from './routes/task'
import { rankingRoutes } from './routes/ranking'
import { problemsetRoutes } from './routes/problemset'
import { achievementRoutes } from './routes/achievement'
@@ -32,7 +33,8 @@ app.route('/api', accountRoutes)
app.route('/api', siteRoutes)
app.route('/api', contestRoutes)
app.route('/api', contentRoutes)
app.route('/api', classroomRoutes)
app.route('/api', homeRoutes)
app.route('/api', taskRoutes)
app.route('/api', rankingRoutes)
app.route('/api', problemsetRoutes)
app.route('/api', achievementRoutes)
+1 -1
View File
@@ -446,7 +446,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
return users.length
})
// 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它,
// 新班级顺手加进班级列表:不加的话全站的班级下拉(任务页、提交列表、排名……)里都找不到它,
// 原来要再去设置页手动加,经常忘
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
const options = await getWebsiteOptions()
+1 -1
View File
@@ -64,7 +64,7 @@ function studentCondition(value: string | null) {
/**
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
* 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前
* 课堂任务(services/class-activity.ts 的 activityRows)同一个口径。不论早晚:读这课之前
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
*/
async function problemActivity(userIds: number[], problemIds: number[]) {
+5 -5
View File
@@ -10,10 +10,10 @@ import { localDateTime } from '../../time'
import { queryInteger } from '../helpers'
/**
* 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改,
* 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
* 后台「课堂任务」:布置过的每一节课(class_lesson)。任务页上布置错了班、错了题可以在这里改,
* 任务页外上的课(以前的、没在任务页布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
*
* 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。
* 权限和任务页一样是老师:任务页上任何老师都能改任何班的这节课,这里不另设门槛。
*/
export const adminLessonRoutes = new Hono<AppEnv>()
@@ -116,7 +116,7 @@ async function lessonValues(body: AdminLessonRequest): Promise<
> {
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1)
if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` }
// 只画流程图不用语言,存成 null(和看板布置一样)
// 只画流程图不用语言,存成 null(和任务页布置一样)
const language = body.mode === 'flowchart' ? null : body.language
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
if ('code' in resolved) return resolved
@@ -156,7 +156,7 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
const classes = await affectedClasses(id, result.values.className)
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
// 布置的老师不换,和看板上「改」一样认第一次
// 布置的老师不换,和任务页上「改」一样认第一次
const [row] = await db
.update(schema.classLesson)
.set({ ...result.values, updatedAt: new Date().toISOString() })
+253
View File
@@ -0,0 +1,253 @@
import { type ClassActivity, type ClassActivityProblem, type LastVisit, type LessonLanguage, type LessonMode, lessonNeeds } from '@oj2/contract'
import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware'
import { getLoginWindow } from '../auth/session'
import { db, schema } from '../db'
import { success } from '../http'
import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity'
import { accepted } from '../services/learning-stats'
import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson'
import { calendarDay } from '../time'
/**
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
* 「这节课是哪几道题」的取法在 services/lesson.ts(课堂任务、后台、统计共用)
*/
export const homeRoutes = new Hono<AppEnv>()
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
const LAST_VISIT_MAX_DAYS = 30
/**
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
*/
homeRoutes.get('/me/last-visit', requireAuth, async (c) => {
const user = c.get('user')!
const window = await getLoginWindow(c)
const previousLogin = window?.previousLogin ?? null
if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) {
return success(c, { previousLogin, summary: null } satisfies LastVisit)
}
const rows = await db
.select({
problemId: schema.submission.problemId,
problemDisplayId: schema.problem.displayId,
title: schema.problem.title,
result: schema.submission.result,
createTime: schema.submission.createTime,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(
and(
eq(schema.submission.userId, user.id),
isNull(schema.submission.contestId),
gte(schema.submission.createTime, previousLogin),
lt(schema.submission.createTime, window.loginAt),
),
)
.orderBy(asc(schema.submission.createTime))
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
const byProblem = new Map<
number,
{
problemDisplayId: string
title: string
attempts: number
lastResult: (typeof rows)[number]['result']
lastTime: string
solved: boolean
}
>()
const solved: { problemDisplayId: string; title: string }[] = []
for (const row of rows) {
const item = byProblem.get(row.problemId) ?? {
problemDisplayId: row.problemDisplayId,
title: row.title,
attempts: 0,
lastResult: row.result,
lastTime: row.createTime,
solved: false,
}
item.attempts++
item.lastResult = row.result
item.lastTime = row.createTime
if (!item.solved && accepted.includes(row.result)) {
item.solved = true
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
}
byProblem.set(row.problemId, item)
}
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
const everSolved = tried.length
? new Set(
(
await db
.selectDistinct({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
isNull(schema.submission.contestId),
inArray(schema.submission.problemId, tried),
inArray(schema.submission.result, accepted),
),
)
).map((row) => row.problemId),
)
: new Set<number>()
const unsolved = tried
.filter((id) => !everSolved.has(id))
.map((id) => byProblem.get(id)!)
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
.slice(0, 3)
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
problemDisplayId,
title,
attempts,
lastResult,
}))
return success(c, {
previousLogin,
summary: {
submissionCount: rows.length,
solvedCount: solved.length,
lastSubmitTime: rows.at(-1)!.createTime,
unsolved,
solved: solved.slice(0, 12),
},
} satisfies LastVisit)
})
/**
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
*/
homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
const user = c.get('user')!
if (!user.className) {
return success(c, {
className: null,
day: null,
source: null,
language: null,
mode: null,
problems: [],
} satisfies ClassActivity)
}
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null
const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([
ids.length
? db
.select({
problemId: schema.submission.problemId,
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId),
// 布置的是 C:以前用 Python 做对过不算这份作业做完
lessonLanguageFilter(codeLanguage),
),
)
.groupBy(schema.submission.problemId)
: [],
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
ids.length
? db
.select({
problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
})
.from(schema.flowchartSubmission)
.where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids)))
.groupBy(schema.flowchartSubmission.problemId)
: [],
])
type Step = 'accepted' | 'tried' | 'none'
const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried')
const codeById = new Map(mine.map((row) => [row.problemId, row.accepted]))
const drawById = new Map(drawn.map((row) => [row.problemId, row.passed]))
return success(c, {
className: user.className,
day: lesson.day,
source: lesson.source,
language: codeLanguage,
mode: lesson.mode,
problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id)
let myStatus: Step
if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完
const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null)
myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none'
} else {
// 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」)
myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none'
}
return {
problemDisplayId: problem.displayId,
title: problem.title,
userCount: problem.userCount,
acceptedCount: problem.acceptedCount,
myStatus,
draw: needs?.draw ? draw : null,
code: needs?.code ? code : null,
} satisfies ClassActivityProblem
}),
} satisfies ClassActivity)
})
/**
* 学生那边一道题要画还是要写:今天这个班布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再布置一节同样的题写代码时,还没画完的人照样看得到要画 ——
* 学生只看得到今天最后布置的那一节,不合起来的话,画流程图那节一布置写代码就没了。
*
* 写代码用的语言:今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
async function todaySteps(
className: string,
problemIds: number[],
latest: { lessonId: number | null; language: LessonLanguage | null; mode: LessonMode | null },
) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = latest.mode && lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = !!latest.mode && lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.lessonId ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
return { needs, language }
}
+1 -1
View File
@@ -73,7 +73,7 @@ function typeFilter(type: ProblemTypeFilter | undefined) {
}
/**
* 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
* 课堂任务「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
* 学生的题目列表不带这个参数
*/
function lessonLanguageFilter(raw: string | undefined) {
+1 -1
View File
@@ -361,7 +361,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
/**
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
*
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
* 也是任何老师看任何班,这里和它们一致。
*
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
+1 -1
View File
@@ -529,7 +529,7 @@ const LESSONS_MAX = 500
type StatisticsRange = { start: string | null; end: string }
/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */
/** 这段时间里(按东八区日子)老师在课堂任务布置过的课,同一天的按布置先后 */
async function lessonPlans(range: StatisticsRange) {
return db
.select({
@@ -1,440 +1,40 @@
import {
STUDENT_ROLES,
classLessonRequestSchema,
type ClassActivity,
type ClassToday,
type ClassBoard,
type ClassBoardStudent,
type TaskBoard,
type TaskBoardStudent,
type ClassLesson,
type ClassActivityProblem,
type LastVisit,
type LessonLanguage,
type LessonMode,
lessonNeeds,
type StatisticInfo,
} from '@oj2/contract'
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm'
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireAuth, requireTeacher, type AppEnv } from '../auth/middleware'
import { requireTeacher, type AppEnv } from '../auth/middleware'
import { seenToday } from '../auth/presence'
import { getLoginWindow } from '../auth/session'
import { presenceOf } from '../collab/state'
import { db, schema } from '../db'
import { challengeSuccessesOf } from '../services/challenge'
import { failure, parseBody, success } from '../http'
import { isAccepted } from '../judge/status'
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
import { accepted } from '../services/learning-stats'
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
import {
announceLessonChange,
CLASS_ACTIVITY_MIN_USERS,
classDayGroups,
classLessonProblems,
lessonDoneCounts,
resolveLessonProblems,
todayLessonSnapshot,
} from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time'
export const classroomRoutes = new Hono<AppEnv>()
/**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
* 课堂任务(老师):布置这节课的题、看全班做到哪、老师首页的「今天的课」。
* 「这节课是哪几道题」的取法在 services/lesson.ts,学生首页的「班里在做」也用它(routes/home.ts)
*/
const CLASS_ACTIVITY_MIN_USERS = 5
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
const CLASS_ACTIVITY_LIMIT = 8
interface ClassDayGroup {
day: string
problemId: number
firstAt: string
userCount: number
acceptedCount: number
}
/**
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
*
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
*/
async function classDayGroups(
className: string,
since: string,
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {},
): Promise<ClassDayGroup[]> {
const { mode } = options
const activity = activityRows({
since,
users: classMemberIds(className),
problemIds: options.problemIds,
language: options.language,
only: mode === 'code' || mode === 'flowchart' ? mode : undefined,
})
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const perUser = db
.select({
day: day.as('day'),
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
userId: sql<number>`${activity.userId}`.as('user_id'),
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
solved: (mode === 'both'
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
: sql<boolean>`bool_or(${activity.solved})`
).as('solved'),
})
.from(activity)
.groupBy(day, activity.problemId, activity.userId)
.as('per_user')
const userCount = sql<number>`count(*)::int`
const query = db
.select({
day: perUser.day,
problemId: perUser.problemId,
firstAt: sql<string>`min(${perUser.firstAt})`,
userCount,
acceptedCount: sql<number>`(count(*) filter (where ${perUser.solved}))::int`,
})
.from(perUser)
.groupBy(perUser.day, perUser.problemId)
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
}
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
async function visibleProblems(ids: number[]) {
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string; isSql: boolean }>()
const rows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
isSql: sql<boolean>`${schema.problem.languages} @> '["SQL"]'::jsonb`,
})
.from(schema.problem)
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
return new Map(rows.map((row) => [row.id, row]))
}
/**
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
*/
async function lessonPlan(className: string, day: string) {
const [row] = await db
.select({
id: schema.classLesson.id,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
createdBy: sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`,
})
.from(schema.classLesson)
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
.limit(1)
return row?.problemIds.length ? row : null
}
/**
* 班里「这节课」的题。老师在课堂看板布置过就用老师的(source = teacher),
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
*
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
* 学生最需要它的时候。
*/
async function classLessonProblems(className: string, lookbackDays: number) {
const today = calendarDay()
const plan = await lessonPlan(className, today)
if (plan) {
const planned = plan.problemIds
const [problems, groups] = await Promise.all([
visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
])
const groupById = new Map(groups.map((row) => [row.problemId, row]))
return {
source: 'teacher' as const,
lessonId: plan.id,
day: today,
language: plan.language ?? null,
mode: plan.mode,
assignedAt: plan.createdAt,
assignedBy: plan.createdBy,
problems: planned.flatMap((id) => {
const problem = problems.get(id)
if (!problem) return []
const group = groupById.get(id)
return [
{
...problem,
userCount: group?.userCount ?? 0,
acceptedCount: group?.acceptedCount ?? 0,
},
]
}),
}
}
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
const latest = groups.reduce<string | null>((acc, row) => (!acc || row.day > acc ? row.day : acc), null)
const picked = groups
.filter((row) => row.day === latest)
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
.slice(0, CLASS_ACTIVITY_LIMIT)
const problems = await visibleProblems(picked.map((row) => row.problemId))
return {
source: picked.length ? ('inferred' as const) : null,
lessonId: null,
day: picked.length ? latest : null,
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null,
mode: null,
assignedAt: null,
assignedBy: null,
problems: picked.flatMap((row) => {
const problem = problems.get(row.problemId)
return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : []
}),
}
}
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
const LAST_VISIT_MAX_DAYS = 30
/**
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
*/
classroomRoutes.get('/me/last-visit', requireAuth, async (c) => {
const user = c.get('user')!
const window = await getLoginWindow(c)
const previousLogin = window?.previousLogin ?? null
if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) {
return success(c, { previousLogin, summary: null } satisfies LastVisit)
}
const rows = await db
.select({
problemId: schema.submission.problemId,
problemDisplayId: schema.problem.displayId,
title: schema.problem.title,
result: schema.submission.result,
createTime: schema.submission.createTime,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(
and(
eq(schema.submission.userId, user.id),
isNull(schema.submission.contestId),
gte(schema.submission.createTime, previousLogin),
lt(schema.submission.createTime, window.loginAt),
),
)
.orderBy(asc(schema.submission.createTime))
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
const byProblem = new Map<
number,
{
problemDisplayId: string
title: string
attempts: number
lastResult: (typeof rows)[number]['result']
lastTime: string
solved: boolean
}
>()
const solved: { problemDisplayId: string; title: string }[] = []
for (const row of rows) {
const item = byProblem.get(row.problemId) ?? {
problemDisplayId: row.problemDisplayId,
title: row.title,
attempts: 0,
lastResult: row.result,
lastTime: row.createTime,
solved: false,
}
item.attempts++
item.lastResult = row.result
item.lastTime = row.createTime
if (!item.solved && accepted.includes(row.result)) {
item.solved = true
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
}
byProblem.set(row.problemId, item)
}
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
const everSolved = tried.length
? new Set(
(
await db
.selectDistinct({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
isNull(schema.submission.contestId),
inArray(schema.submission.problemId, tried),
inArray(schema.submission.result, accepted),
),
)
).map((row) => row.problemId),
)
: new Set<number>()
const unsolved = tried
.filter((id) => !everSolved.has(id))
.map((id) => byProblem.get(id)!)
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
.slice(0, 3)
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
problemDisplayId,
title,
attempts,
lastResult,
}))
return success(c, {
previousLogin,
summary: {
submissionCount: rows.length,
solvedCount: solved.length,
lastSubmitTime: rows.at(-1)!.createTime,
unsolved,
solved: solved.slice(0, 12),
},
} satisfies LastVisit)
})
/**
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
*/
classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
const user = c.get('user')!
if (!user.className) {
return success(c, {
className: null,
day: null,
source: null,
language: null,
mode: null,
problems: [],
} satisfies ClassActivity)
}
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null
const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([
ids.length
? db
.select({
problemId: schema.submission.problemId,
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId),
// 布置的是 C:以前用 Python 做对过不算这份作业做完
lessonLanguageFilter(codeLanguage),
),
)
.groupBy(schema.submission.problemId)
: [],
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
ids.length
? db
.select({
problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
})
.from(schema.flowchartSubmission)
.where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids)))
.groupBy(schema.flowchartSubmission.problemId)
: [],
])
type Step = 'accepted' | 'tried' | 'none'
const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried')
const codeById = new Map(mine.map((row) => [row.problemId, row.accepted]))
const drawById = new Map(drawn.map((row) => [row.problemId, row.passed]))
return success(c, {
className: user.className,
day: lesson.day,
source: lesson.source,
language: codeLanguage,
mode: lesson.mode,
problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id)
let myStatus: Step
if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完
const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null)
myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none'
} else {
// 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」)
myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none'
}
return {
problemDisplayId: problem.displayId,
title: problem.title,
userCount: problem.userCount,
acceptedCount: problem.acceptedCount,
myStatus,
draw: needs?.draw ? draw : null,
code: needs?.code ? code : null,
} satisfies ClassActivityProblem
}),
} satisfies ClassActivity)
})
/**
* 学生那边一道题要画还是要写:今天这个班布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再布置一节同样的题写代码时,还没画完的人照样看得到要画 ——
* 学生只看得到今天最后布置的那一节,不合起来的话,画流程图那节一布置写代码就没了。
*
* 写代码用的语言:今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
async function todaySteps(
className: string,
problemIds: number[],
latest: { lessonId: number | null; language: LessonLanguage | null; mode: LessonMode | null },
) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = latest.mode && lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = !!latest.mode && lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.lessonId ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
return { needs, language }
}
export const taskRoutes = new Hono<AppEnv>()
/**
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
@@ -450,7 +50,7 @@ function flowchartCaseCounts() {
}
/**
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
* 任务页上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
* —— 当堂那份名单把这两种人排在同一行里,老师分不出来。
*/
@@ -459,11 +59,11 @@ const RECENT_LESSONS = 5
const RECENT_LESSONS_WINDOW_DAYS = 45
/**
* 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
* 课堂任务:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
* 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。
*/
classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
taskRoutes.get('/task/board', requireTeacher, async (c) => {
const day = calendarDay()
const className = c.req.query('className')?.trim() || (await suggestActiveClass())
if (!className) {
@@ -480,10 +80,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
problems: [],
students: [],
recentLessons: 0,
} satisfies ClassBoard)
} satisfies TaskBoard)
}
// 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来。
// 推断只看今天(回看 1 天):任务页是给这节课用的,昨天的题不该冒出来。
// lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种)
const [lesson, [last]] = await Promise.all([
classLessonProblems(className, 1),
@@ -562,7 +162,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
.where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start)))
.groupBy(schema.submission.userId)
: [],
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手。
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在任务页上是一直没动手。
// 和上面代码那条同一个范围:今天、所有题、不论评没评完
userIds.length
? db
@@ -586,7 +186,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
: [],
/**
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」。
* 有的题这节课整个班都在画流程图,只数代码提交的话,任务页上满屏「没开始」。
* 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算
*/
ids.length && userIds.length
@@ -769,18 +369,18 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
editing: presenceOf(student.userId)?.problemId ?? null,
online: seen.get(student.userId)?.online ?? false,
seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null,
}) satisfies ClassBoardStudent,
}) satisfies TaskBoardStudent,
),
recentLessons: recentDays.length,
} satisfies ClassBoard)
} satisfies TaskBoard)
})
/**
* 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」
* 同一个门槛),或者老师今天在看板上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
* 和看板同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
* 同一个门槛),或者老师今天在任务页上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
* 和任务页同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
*/
classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
taskRoutes.get('/task/today', requireTeacher, async (c) => {
const start = dayStart()
const today = calendarDay()
const activity = activityRows({ since: start })
@@ -810,13 +410,13 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
// 老师布置的:和看板、后台课堂作业同一个口径(要画的看图、要写的看代码)
// 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码)
const counts = await lessonDoneCounts([
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode },
])
allDoneCount = counts.get(lesson.lessonId) ?? 0
} else if (ids.length) {
// 推断出来的:做没做对不限今天(和看板的格子一样:以前做对的也算做完),代码或流程图都算
// 推断出来的:做没做对不限今天(和任务页的格子一样:以前做对的也算做完),代码或流程图都算
const done = activityRows({
since: new Date(0).toISOString(),
users: classMemberIds(className),
@@ -857,10 +457,10 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
})
/**
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先,
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂任务(老师布置的优先,
* 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班
*/
classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
taskRoutes.get('/task/lesson', requireTeacher, async (c) => {
const className = c.req.query('className')?.trim() || (await suggestActiveClass())
if (!className)
return success(c, {
@@ -869,7 +469,7 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
language: null,
problems: [],
} satisfies ClassLesson)
// 和看板一样只回看今天:「这节课」不该冒出昨天的题
// 和任务页一样只回看今天:「这节课」不该冒出昨天的题
const lesson = await classLessonProblems(className, 1)
// C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language)
const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL'))
@@ -889,12 +489,12 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
*/
classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
taskRoutes.put('/task/lesson', requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
if (!parsed.success) return parsed.response
const user = c.get('user')!
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
// 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」
// 只画流程图不用语言:存成 null,任务页和学生那边就不会写「C语言」
const language = mode === 'flowchart' ? null : parsed.data.language
const day = calendarDay()
+1 -1
View File
@@ -333,7 +333,7 @@ export async function challengeList(user: { id: number; className: string | null
})
}
/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
/** 课堂任务用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
if (userIds.length === 0 || problemIds.length === 0) return []
return db
+4 -4
View File
@@ -5,8 +5,8 @@ import { db, schema } from '../db'
import { JudgeStatus } from '../judge/status'
/**
* 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」
* 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班,
* 课堂上「谁做了哪道题」的取数口径:课堂条、任务页、自学情况、后台首页「正在上课的班」
* 都从这里数。原来写在课堂任务的路由里,后台 conf.ts 要猜正在上课的班,
* 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。
*/
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
@@ -30,7 +30,7 @@ export function classMemberIds(className: string) {
/**
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
* 口径和课堂条 / 看板的「做完」一致:
* 口径和课堂条 / 任务页的「做完」一致:
*
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
@@ -99,7 +99,7 @@ export function activityRows(options: {
return code.unionAll(flowchart).as('activity')
}
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
/** 任务页没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/**
+4 -4
View File
@@ -3,10 +3,10 @@
*
* 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
* 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts):
* 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
* 一节课的题怎么定,和课堂任务 / 首页「班里在做」同一个口径(services/lesson.ts 的 classLessonProblems):
* 老师在任务页布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
*
* 做对的口径也同看板:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
* 做对的口径也同任务页:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
* (比赛是课堂测验,题目也不在题库里)。
*/
import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LearningDay, type LearningItem, type LearningPeriod, type LearningReport } from '@oj2/contract'
@@ -19,7 +19,7 @@ import { calendarDay, monthStart, termStart, weekStart } from '../time'
export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 同 routes/classroom.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
/** 同 services/lesson.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
const LESSON_MIN_USERS = 5
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
+176 -6
View File
@@ -3,12 +3,12 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { db, schema } from '../db'
import { publishLessonUpdate } from '../events'
import { calendarDay, dayNumber, dayStart } from '../time'
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
import { calendarDay, dayNumber, dayStart, localTime } from '../time'
import { activityRows, classMemberIds, FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
/**
* 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、
* 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。
* 布置的课(class_lesson)几处共用的口径:课堂任务布置、后台「课堂任务」增改、
* 数据统计按布置切课。放在一起,免得任务页能布置的题后台布置不上,或者两边「做完」数得不一样。
*/
/**
@@ -55,7 +55,7 @@ export function lessonDayEnd(day: string) {
/**
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和任务页的格子同一个口径。
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/
export async function lessonDoneCounts(
@@ -119,7 +119,7 @@ interface LessonSnapshot {
}
/**
* 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法),
* 学生此刻看到的那一节:这个班今天最后布置的(和下面 lessonPlan 同一个取法),
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
*/
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
@@ -175,3 +175,173 @@ export async function announceLessonChange(className: string, before: LessonSnap
console.error('Failed to announce lesson change', error)
}
}
/**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
*/
export const CLASS_ACTIVITY_MIN_USERS = 5
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
export const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
const CLASS_ACTIVITY_LIMIT = 8
interface ClassDayGroup {
day: string
problemId: number
firstAt: string
userCount: number
acceptedCount: number
}
/**
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
*
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
*/
export async function classDayGroups(
className: string,
since: string,
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {},
): Promise<ClassDayGroup[]> {
const { mode } = options
const activity = activityRows({
since,
users: classMemberIds(className),
problemIds: options.problemIds,
language: options.language,
only: mode === 'code' || mode === 'flowchart' ? mode : undefined,
})
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const perUser = db
.select({
day: day.as('day'),
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
userId: sql<number>`${activity.userId}`.as('user_id'),
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
solved: (mode === 'both'
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
: sql<boolean>`bool_or(${activity.solved})`
).as('solved'),
})
.from(activity)
.groupBy(day, activity.problemId, activity.userId)
.as('per_user')
const userCount = sql<number>`count(*)::int`
const query = db
.select({
day: perUser.day,
problemId: perUser.problemId,
firstAt: sql<string>`min(${perUser.firstAt})`,
userCount,
acceptedCount: sql<number>`(count(*) filter (where ${perUser.solved}))::int`,
})
.from(perUser)
.groupBy(perUser.day, perUser.problemId)
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
}
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
async function visibleProblems(ids: number[]) {
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string; isSql: boolean }>()
const rows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
isSql: sql<boolean>`${schema.problem.languages} @> '["SQL"]'::jsonb`,
})
.from(schema.problem)
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
return new Map(rows.map((row) => [row.id, row]))
}
/**
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
*/
async function lessonPlan(className: string, day: string) {
const [row] = await db
.select({
id: schema.classLesson.id,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
createdBy: sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`,
})
.from(schema.classLesson)
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
.limit(1)
return row?.problemIds.length ? row : null
}
/**
* 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher),
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
*
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
* 学生最需要它的时候。
*/
export async function classLessonProblems(className: string, lookbackDays: number) {
const today = calendarDay()
const plan = await lessonPlan(className, today)
if (plan) {
const planned = plan.problemIds
const [problems, groups] = await Promise.all([
visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
])
const groupById = new Map(groups.map((row) => [row.problemId, row]))
return {
source: 'teacher' as const,
lessonId: plan.id,
day: today,
language: plan.language ?? null,
mode: plan.mode,
assignedAt: plan.createdAt,
assignedBy: plan.createdBy,
problems: planned.flatMap((id) => {
const problem = problems.get(id)
if (!problem) return []
const group = groupById.get(id)
return [
{
...problem,
userCount: group?.userCount ?? 0,
acceptedCount: group?.acceptedCount ?? 0,
},
]
}),
}
}
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
const latest = groups.reduce<string | null>((acc, row) => (!acc || row.day > acc ? row.day : acc), null)
const picked = groups
.filter((row) => row.day === latest)
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
.slice(0, CLASS_ACTIVITY_LIMIT)
const problems = await visibleProblems(picked.map((row) => row.problemId))
return {
source: picked.length ? ('inferred' as const) : null,
lessonId: null,
day: picked.length ? latest : null,
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null,
mode: null,
assignedAt: null,
assignedBy: null,
problems: picked.flatMap((row) => {
const problem = problems.get(row.problemId)
return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : []
}),
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function dayNumber(day: string): number {
return Date.UTC(year!, month! - 1, date!) / DAY_MS
}
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂作业用 */
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂任务用 */
export function localDateTime(day: string, time: string): string {
const [hour, minute] = time.split(':').map(Number)
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()