feat(布置作业): 能布置「画流程图」「先画再写」,看板分清卡在画图还是写代码
Deploy / deploy (push) Canceled after 0s

布置时多一个「做什么」:写代码 / 先画再写 / 画流程图(只能选打开了画流程图的题)。
- 先画再写:一道题图画到 A/S、代码也交对才算做完;学生画一道写一道、全画完再写都行,
  打开题目自动停在该做的那一步(图没画对停画布,画对了换到布置的语言)
- 也能分开布置:先一节画流程图,看板上「用这几道题写代码」再开一节;学生那边把今天
  几节课的要求合起来,还没画完的照样看得到要画
- 看板格子是一条进度:图 1/6 → 空心「图 ✓」→ 代码 ✗ → ✓,先写代码没画图的写「缺图」;
  卡在画图上的点开讲那张图(要改的框标红 + AI 的哪里错/怎么改),按走对几组归堆
- 写代码的课不再拿画对的流程图顶替做完;数据统计里画流程图的课数流程图提交

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 21:50:48 +08:00
1 parent 71323d9b63
commit 38a331c2b7
33 files changed
+4987 -209

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
-- 这节课做什么:写代码 / 画流程图 / 先画再写。以前布置的都是写代码;带 DEFAULT,没升级的旧代码照样能布置
ALTER TABLE "class_lesson" ADD COLUMN "mode" text DEFAULT 'code' NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -288,6 +288,13 @@
"when": 1793622303180,
"tag": "0040_class_lesson_many_per_day",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1793626595966,
"tag": "0041_lesson_mode",
"breakpoints": true
}
]
}
+6
View File
@@ -45,6 +45,7 @@ import type {
HintDiagnosis,
JudgeStatus,
LessonLanguage,
LessonMode,
ProblemChallenges,
ProblemDifficulty,
ProblemLanguage,
@@ -1324,6 +1325,11 @@ export const classLesson = pgTable(
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
*/
language: text().$type<LessonLanguage>(),
/**
* 这节课做什么:写代码 / 画流程图 / 先画再写(见契约 lessonModeSchema)。加这一列之前布置的
* 都是写代码。写入侧按 lessonModeSchema 校验过
*/
mode: text().$type<LessonMode>().notNull().default('code'),
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
createdBy: integer('created_by').notNull(),
/**
+19 -7
View File
@@ -34,6 +34,7 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
day: schema.classLesson.day,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
updatedAt: schema.classLesson.updatedAt,
createdBy: teacherName,
@@ -85,6 +86,7 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
updatedAt: row.updatedAt,
createdBy: row.createdBy,
language: row.language ?? null,
mode: row.mode,
// 题被删掉的(problem_ids 不挂外键)直接不列
problems: row.problemIds.flatMap((id) => {
const problem = problemById.get(id)
@@ -98,23 +100,33 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
} satisfies AdminLessonList)
})
/** 新补一节、改一节共用的校验:班得有人、题号都对得上、语言每道都能交 */
async function lessonValues(
body: AdminLessonRequest,
): Promise<
/** 新补一节、改一节共用的校验:班得有人、题号都对得上、语言每道都能交、要画的都打开了画流程图 */
async function lessonValues(body: AdminLessonRequest): Promise<
| { code: string; message: string }
| { values: { className: string; day: string; problemIds: number[]; language: AdminLessonRequest['language']; createdAt: string } }
| {
values: {
className: string
day: string
problemIds: number[]
language: AdminLessonRequest['language']
mode: AdminLessonRequest['mode']
createdAt: string
}
}
> {
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} 这个班` }
const resolved = await resolveLessonProblems(body.problemDisplayIds, body.language)
// 只画流程图不用语言,存成 null(和看板布置一样)
const language = body.mode === 'flowchart' ? null : body.language
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
if ('code' in resolved) return resolved
return {
values: {
className: body.className,
day: body.day,
problemIds: resolved.problemIds,
language: body.language,
language,
mode: body.mode,
createdAt: localDateTime(body.day, body.time),
},
}
+197 -40
View File
@@ -9,6 +9,8 @@ import {
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'
@@ -24,7 +26,7 @@ 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, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time'
export const classroomRoutes = new Hono<AppEnv>()
@@ -50,30 +52,49 @@ interface ClassDayGroup {
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
*
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
*/
async function classDayGroups(
className: string,
since: string,
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null } = {},
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 userCount = sql<number>`count(distinct ${activity.userId})::int`
const query = db
const perUser = db
.select({
day,
problemId: activity.problemId,
firstAt: sql<string>`min(${activity.createTime})`,
userCount,
acceptedCount: sql<number>`count(distinct ${activity.userId}) filter (where ${activity.solved})::int`,
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)
.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
}
@@ -93,7 +114,7 @@ async function visibleProblems(ids: number[]) {
}
/**
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言、布置时刻和布置的老师;
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
*/
async function lessonPlan(className: string, day: string) {
@@ -102,6 +123,7 @@ async function lessonPlan(className: string, day: string) {
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})`,
})
@@ -129,7 +151,7 @@ async function classLessonProblems(className: string, lookbackDays: number) {
const planned = plan.problemIds
const [problems, groups] = await Promise.all([
visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language }),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
])
const groupById = new Map(groups.map((row) => [row.problemId, row]))
return {
@@ -137,6 +159,7 @@ async function classLessonProblems(className: string, lookbackDays: number) {
lessonId: plan.id,
day: today,
language: plan.language ?? null,
mode: plan.mode,
assignedAt: plan.createdAt,
assignedBy: plan.createdBy,
problems: planned.flatMap((id) => {
@@ -166,8 +189,9 @@ async function classLessonProblems(className: string, lookbackDays: number) {
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) => {
@@ -298,12 +322,15 @@ classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
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
@@ -319,14 +346,12 @@ classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId),
// 布置的是 C:以前用 Python 做对过不算这份作业做完
lessonLanguageFilter(lesson.language),
lessonLanguageFilter(codeLanguage),
),
)
.groupBy(schema.submission.problemId)
: [],
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」。
// 交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
ids.length
? db
.select({
@@ -338,29 +363,92 @@ classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
.groupBy(schema.flowchartSubmission.problemId)
: [],
])
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
for (const row of drawn) {
mineById.set(row.problemId, (mineById.get(row.problemId) ?? false) || row.passed)
}
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: lesson.language,
language: codeLanguage,
mode: lesson.mode,
problems: lesson.problems.map((problem) => {
const accepted = mineById.get(problem.id)
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: accepted === undefined ? 'none' : accepted ? 'accepted' : 'tried',
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 }
}
/**
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
* 形状见契约 FlowchartCaseReport)里数。老评法、还没评完的、评失败的都是 null
*/
function flowchartCaseCounts() {
const details = schema.flowchartSubmission.aiCriteriaDetails
const isReport = sql`${schema.flowchartSubmission.status} = 2 and ${details}->>'version' = '2' and jsonb_typeof(${details}->'cases') = 'array'`
return {
passed: sql<number | null>`case when ${isReport} then (select count(*) from jsonb_array_elements(${details}->'cases') e where e->>'ok' = 'true')::int end`,
total: sql<number | null>`case when ${isReport} then jsonb_array_length(${details}->'cases') end`,
}
}
/**
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
@@ -385,6 +473,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
source: null,
lessonId: null,
language: null,
mode: null,
assignedAt: null,
assignedBy: null,
lastLanguage: null,
@@ -438,7 +527,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
const challengeDoneKeys = new Set(
challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`),
)
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen] = await Promise.all([
// 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样)
const mode = lesson.source === 'teacher' ? lesson.mode : null
const needs = mode ? lessonNeeds(mode) : null
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([
ids.length && userIds.length
? db
.select({
@@ -537,7 +629,27 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
.orderBy(schema.submission.createTime)
: [],
seenToday(userIds),
/**
* 要画流程图的课:每人每题最后一张图(不限今天,和 drawn 的 firstPassedAt 同一个范围),
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
* 老评法和还没评完的为 null
*/
needs?.draw && ids.length && userIds.length
? db
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
id: schema.flowchartSubmission.id,
status: schema.flowchartSubmission.status,
grade: schema.flowchartSubmission.aiGrade,
...flowchartCaseCounts(),
})
.from(schema.flowchartSubmission)
.where(and(inArray(schema.flowchartSubmission.userId, userIds), inArray(schema.flowchartSubmission.problemId, ids)))
.orderBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId, desc(schema.flowchartSubmission.createTime))
: [],
])
const lastFlowByKey = new Map(lastFlows.map((row) => [`${row.userId}:${row.problemId}`, row]))
const todayByKey = Map.groupBy(todays, (row) => `${row.userId}:${row.problemId}`)
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
@@ -549,6 +661,8 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
if (!known || Date.parse(row.lastAt) > Date.parse(known)) lastByUser.set(row.userId, row.lastAt)
}
const earliest = (a: string | null | undefined, b: string | null | undefined) => (!a ? (b ?? null) : !b ? a : Date.parse(a) <= Date.parse(b) ? a : b)
const latestOf = (a: string, b: string) => (Date.parse(a) >= Date.parse(b) ? a : b)
type Step = 'accepted' | 'tried' | 'none'
const attendedByUser = new Map(attendance.map((row) => [row.userId, row.days]))
return success(c, {
@@ -557,6 +671,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
source: lesson.source,
lessonId: lesson.lessonId,
language: lesson.language,
mode,
assignedAt: lesson.assignedAt,
assignedBy: lesson.assignedBy,
lastLanguage: last?.language ?? null,
@@ -577,16 +692,50 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
const key = `${student.userId}:${id}`
const cell = cellByKey.get(key)
const flow = drawnByKey.get(key)
const acceptedAt = earliest(cell?.firstAcceptedAt, flow?.firstPassedAt)
const today = todayByKey.get(key) ?? []
const latest = acceptedAt ? undefined : today.at(-1)
const firstTry = acceptedAt && Date.parse(acceptedAt) >= Date.parse(start) ? today.findIndex((row) => isAccepted(row.result)) : -1
const codeAt = cell?.firstAcceptedAt ?? null
const flowAt = flow?.firstPassedAt ?? null
const codeStatus: Step = codeAt ? 'accepted' : cell ? 'tried' : 'none'
const drawStatus: Step = flowAt ? 'accepted' : flow ? 'tried' : 'none'
let status: Step
let acceptedAt: string | null
let attempts: number
if (!needs) {
acceptedAt = earliest(codeAt, flowAt)
status = !cell && !flow ? 'none' : acceptedAt ? 'accepted' : 'tried'
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
} else {
// 要哪几步看哪几步:两步都做到的时刻是晚的那一步
const steps = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter(
(step) => step !== null,
)
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none'
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
}
// 下面几样都是代码那一步的:只画流程图的课没有
const codeWanted = !needs || needs.code
const today = codeWanted ? (todayByKey.get(key) ?? []) : []
const codeDone = needs ? codeAt : acceptedAt
const latest = codeDone ? undefined : today.at(-1)
const firstTry = codeDone && Date.parse(codeDone) >= Date.parse(start) ? today.findIndex((row) => isAccepted(row.result)) : -1
const lastFlow = lastFlowByKey.get(key)
return {
status: !cell && !flow ? 'none' : acceptedAt ? 'accepted' : 'tried',
attempts: (cell?.attempts ?? 0) + (flow?.attempts ?? 0),
status,
attempts,
codeAttempts: cell?.attempts ?? 0,
flowchartAttempts: flow?.attempts ?? 0,
acceptedAt,
codeStatus,
flowchart: needs?.draw
? {
status: drawStatus,
passedAt: flowAt,
latest:
!flowAt && lastFlow
? { id: lastFlow.id, status: lastFlow.status, grade: lastFlow.grade, passed: lastFlow.passed, total: lastFlow.total }
: null,
}
: null,
latest: latest
? {
submissionId: latest.id,
@@ -595,7 +744,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
sampleCheck: latest.sampleCheck ?? undefined,
}
: null,
trail: acceptedAt
trail: codeDone
? today.map((row) => ({
id: row.id,
result: row.result,
@@ -604,11 +753,11 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
: [],
acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
challenge:
!acceptedAt || !liveChallenges.get(id)?.size
!codeWanted || !codeDone || !liveChallenges.get(id)?.size
? null
: challengeDoneKeys.has(key)
? 'done'
: today.some((row) => Date.parse(row.createTime) > Date.parse(acceptedAt))
: today.some((row) => Date.parse(row.createTime) > Date.parse(codeDone))
? 'doing'
: null,
}
@@ -660,13 +809,18 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
])
const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0
if (ids.length) {
// 做没做对不限今天(和看板的格子一样:以前做对的也算做完),语言跟着布置走
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),
problemIds: ids,
language: lesson.language,
})
const rows = await db
.select({
@@ -739,7 +893,9 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
if (!parsed.success) return parsed.response
const user = c.get('user')!
const { className, problemDisplayIds, language, lessonId, newLesson } = parsed.data
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
// 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」
const language = mode === 'flowchart' ? null : parsed.data.language
const day = calendarDay()
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, className)).limit(1)
@@ -768,13 +924,13 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
return success(c, null)
}
const resolved = await resolveLessonProblems(problemDisplayIds, language)
const resolved = await resolveLessonProblems(problemDisplayIds, language, mode)
if ('code' in resolved) return failure(c, 400, resolved.code, resolved.message)
const { problemIds } = resolved
const now = new Date().toISOString()
if (target) {
// 「改」不换布置的人,和 createdAt 一样认第一次
await db.update(schema.classLesson).set({ problemIds, language, updatedAt: now }).where(eq(schema.classLesson.id, target.id))
await db.update(schema.classLesson).set({ problemIds, language, mode, updatedAt: now }).where(eq(schema.classLesson.id, target.id))
} else {
// 新开一节、今天还没有,或者点名的那一节已经没了(别的老师清掉了)—— 最后这种也照新开处理,
// 老师手上的题号不至于白填
@@ -783,6 +939,7 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
day,
problemIds,
language,
mode,
createdBy: user.id,
createdAt: now,
updatedAt: now,
+8
View File
@@ -81,6 +81,11 @@ function lessonLanguageFilter(raw: string | undefined) {
return language ? sql`${schema.problem.languages} @> ${JSON.stringify([language])}::jsonb` : undefined
}
/** 要画流程图的课(`?flowchart=1`):只列打开了「让学生自己画」的题,没打开的学生那边没有画布 */
function lessonFlowchartFilter(raw: string | undefined) {
return raw === '1' ? eq(schema.problem.allowFlowchart, true) : undefined
}
type ProblemStates = Awaited<ReturnType<typeof problemStates>> | null
type UserCounts = Awaited<ReturnType<typeof problemUserCounts>>
@@ -149,6 +154,8 @@ problemRoutes.get('/problems', optionalAuth, async (c) => {
if (byType) filters.push(byType)
const byLanguage = lessonLanguageFilter(c.req.query('language'))
if (byLanguage) filters.push(byLanguage)
const byFlowchart = lessonFlowchartFilter(c.req.query('flowchart'))
if (byFlowchart) filters.push(byFlowchart)
const states = user ? await problemStates(user.id) : null
if (undone && states && states.solved.size > 0) filters.push(notInArray(schema.problem.id, [...states.solved]))
@@ -226,6 +233,7 @@ problemRoutes.get('/problem-tags', async (c) => {
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
lessonLanguageFilter(c.req.query('language')),
lessonFlowchartFilter(c.req.query('flowchart')),
),
)
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
+26 -5
View File
@@ -7,6 +7,7 @@
*/
import {
FLOWCHART_PASS_GRADES,
NO_CLASS,
STUDENT_ROLES,
type ProblemLanguage,
@@ -537,6 +538,7 @@ async function lessonPlans(range: StatisticsRange) {
day: schema.classLesson.day,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
})
.from(schema.classLesson)
@@ -560,6 +562,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
class_name: plan.className,
day: plan.day,
problem_id: problemId,
mode: plan.mode,
})),
),
)
@@ -571,7 +574,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
UNJUDGED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
const plan = sql`(select * from jsonb_to_recordset(${planRows}::jsonb) as p(k int, class_name text, day text, problem_id int))`
const plan = sql`(select * from jsonb_to_recordset(${planRows}::jsonb) as p(k int, class_name text, day text, problem_id int, mode text))`
const [counts, doneByLesson] = await Promise.all([
db.execute<{
k: number
@@ -584,22 +587,37 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
last: string
}>(sql`
with p as ${plan},
-- 这节课要写代码就数代码提交,要画流程图就数流程图(画到 A / S 算对、还在评算没判完),
-- 先画再写两种都数。同一天先布置画流程图、再布置同样的题写代码,两节各数各的
s as (
select p.k, s.user_id, s.problem_id, s.result, s.create_time
select p.k, s.user_id, s.problem_id, s.result in (${accepted}) as ok, s.result in (${judging}) as pending, s.create_time
from ${schema.submission} s
join ${schema.user} u on u.id = s.user_id
join p on p.class_name = u.class_name and p.problem_id = s.problem_id
and p.day = to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD')
where s.contest_id is null
where s.contest_id is null and p.mode <> 'flowchart'
and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false
union all
select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join(
FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`),
sql`, `,
)}), false)) as ok, f.status in (0, 1) as pending, f.create_time
from ${schema.flowchartSubmission} f
join ${schema.user} u on u.id = f.user_id
join p on p.class_name = u.class_name and p.problem_id = f.problem_id
and p.day = to_char(${localTime(sql`f.create_time`)}, 'YYYY-MM-DD')
where p.mode <> 'code'
and f.create_time <= ${range.end}
${range.start ? sql`and f.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false
)
select k, problem_id,
count(distinct user_id)::int as users,
count(*)::int as total,
(count(*) filter (where result in (${accepted})))::int as accepted,
(count(*) filter (where result in (${judging})))::int as judging,
(count(*) filter (where ok))::int as accepted,
(count(*) filter (where pending))::int as judging,
min(create_time) as first,
max(create_time) as last
from s
@@ -624,6 +642,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
className: lesson.className,
day: lesson.day,
problemIds: lesson.problemIds,
mode: lesson.mode,
start: first,
end: last,
users: total?.users ?? 0,
@@ -868,6 +887,7 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
.map((row) => row!.displayId)
.slice(0, STATISTICS_MAX_PROBLEMS),
assigned: false,
mode: null,
doneCount: null,
fromProblemSet: lesson.from_ps * 2 > lesson.total,
// 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上
@@ -901,6 +921,7 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
.map((row) => row!.displayId)
.slice(0, STATISTICS_MAX_PROBLEMS),
assigned: true,
mode: lesson.mode,
doneCount: lesson.doneCount,
fromProblemSet: false,
total: lesson.total,
+10 -1
View File
@@ -53,14 +53,20 @@ export function activityRows(options: {
problemIds?: number[]
/** 老师布置时选了语言:代码只数这个语言交的(见 lessonLanguageFilter),流程图不受影响 */
language?: LessonLanguage | null
/**
* 只要哪一种:老师布置的课选了「写代码」就只数代码、「画流程图」就只数流程图
* (契约 lessonModeSchema)。不给 = 两种都数,`kind` 列分得出是哪种
*/
only?: 'code' | 'flowchart'
}) {
const { since, users, problemIds, language } = options
const { since, users, problemIds, language, only } = 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'),
kind: sql<'code' | 'flowchart'>`'code'`.as('kind'),
})
.from(schema.submission)
.where(
@@ -70,6 +76,7 @@ export function activityRows(options: {
users ? inArray(schema.submission.userId, users) : undefined,
problemIds ? inArray(schema.submission.problemId, problemIds) : undefined,
lessonLanguageFilter(language),
only === 'flowchart' ? sql`false` : undefined,
),
)
const flowchart = db
@@ -78,6 +85,7 @@ export function activityRows(options: {
problemId: schema.flowchartSubmission.problemId,
createTime: schema.flowchartSubmission.createTime,
solved: sql<boolean>`${FLOWCHART_PASSED}`.as('solved'),
kind: sql<'code' | 'flowchart'>`'flowchart'`.as('kind'),
})
.from(schema.flowchartSubmission)
.where(
@@ -85,6 +93,7 @@ export function activityRows(options: {
gte(schema.flowchartSubmission.createTime, since),
users ? inArray(schema.flowchartSubmission.userId, users) : undefined,
problemIds ? inArray(schema.flowchartSubmission.problemId, problemIds) : undefined,
only === 'code' ? sql`false` : undefined,
),
)
return code.unionAll(flowchart).as('activity')
+31 -16
View File
@@ -1,4 +1,4 @@
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage, type LessonUpdate } from '@oj2/contract'
import { isSqlProblem, lessonNeeds, STUDENT_ROLES, type LessonLanguage, type LessonMode, type LessonUpdate } from '@oj2/contract'
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { db, schema } from '../db'
@@ -13,26 +13,34 @@ import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
/**
* 题号 → problem.id,保留输入顺序、去重、不分大小写。只认题库里公开的题;有对不上的整个拒掉并
* 点名是哪几个,免得存下半张单子老师还不知道。选了语言就得每道都能用它交(SQL 题除外:它只收
* SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL)
* 点名是哪几个,免得存下半张单子老师还不知道。要写代码的课选了语言就得每道都能用它交(SQL 题除外:
* 它只收 SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL);要画流程图的课
* 每道都得打开了「让学生自己画」,不然学生打开题目根本没有画布
*/
export async function resolveLessonProblems(
problemDisplayIds: string[],
language: LessonLanguage | null,
): Promise<{ problemIds: number[] } | { code: 'problem-not-found' | 'language-not-allowed'; message: string }> {
mode: LessonMode = 'code',
): Promise<{ problemIds: number[] } | { code: 'problem-not-found' | 'language-not-allowed' | 'flowchart-not-allowed'; message: string }> {
const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))]
const found = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
languages: schema.problem.languages,
allowFlowchart: schema.problem.allowFlowchart,
})
.from(schema.problem)
.where(and(inArray(sql<string>`lower(${schema.problem.displayId})`, wanted), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
const idByDisplay = new Map(found.map((row) => [row.displayId.toLowerCase(), row.id]))
const missing = wanted.filter((id) => !idByDisplay.has(id))
if (missing.length) return { code: 'problem-not-found', message: `这些题号不存在或没有公开:${missing.join('、')}` }
if (language) {
const needs = lessonNeeds(mode)
if (needs.draw) {
const undrawable = found.filter((row) => !row.allowFlowchart).map((row) => row.displayId)
if (undrawable.length) return { code: 'flowchart-not-allowed', message: `这些题没有打开画流程图:${undrawable.join('、')}` }
}
if (language && needs.code) {
const unsupported = found.filter((row) => !row.languages.includes(language) && !isSqlProblem(row)).map((row) => row.displayId)
if (unsupported.length) return { code: 'language-not-allowed', message: `这些题不能用 ${language} 交:${unsupported.join('、')}` }
}
@@ -45,17 +53,21 @@ export function lessonDayEnd(day: string) {
}
/**
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算),
* 代码只认布置的语言(SQL 题放行),流程图评到 A / S 也算 —— 和看板的格子同一个口径。
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/
export async function lessonDoneCounts(lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null }[]) {
export async function lessonDoneCounts(
lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null; mode: LessonMode }[],
) {
const rows = lessons.flatMap((lesson) =>
lesson.problemIds.map((problemId) => ({
k: lesson.id,
class_name: lesson.className,
problem_id: problemId,
language: lesson.language,
mode: lesson.mode,
until: lessonDayEnd(lesson.day),
})),
)
@@ -66,10 +78,10 @@ export async function lessonDoneCounts(lessons: { id: number; className: string;
)
const result = await db.execute<{ k: number; done: number }>(sql`
with p as (
select * from jsonb_to_recordset(${JSON.stringify(rows)}::jsonb) as p(k int, class_name text, problem_id int, language text, until timestamptz)
select * from jsonb_to_recordset(${JSON.stringify(rows)}::jsonb) as p(k int, class_name text, problem_id int, language text, mode text, until timestamptz)
),
members as (
select p.k, p.problem_id, p.language, p.until, u.id as user_id
select p.k, p.problem_id, p.language, p.mode, p.until, u.id as user_id
from p join ${schema.user} u on u.class_name = p.class_name
where u.is_disabled = false and u.admin_type in (${sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
@@ -79,16 +91,16 @@ export async function lessonDoneCounts(lessons: { id: number; className: string;
solved as (
select m.k, m.user_id, m.problem_id
from members m
where exists (
where (m.mode = 'flowchart' or exists (
select 1 from ${schema.submission} s
where s.user_id = m.user_id and s.problem_id = m.problem_id and s.contest_id is null
and s.result in (${solved}) and s.create_time < m.until
and (m.language is null or s.language in (m.language, 'SQL'))
) or exists (
)) and (m.mode = 'code' or exists (
select 1 from ${schema.flowchartSubmission}
where ${schema.flowchartSubmission.userId} = m.user_id and ${schema.flowchartSubmission.problemId} = m.problem_id
and ${FLOWCHART_PASSED} and ${schema.flowchartSubmission.createTime} < m.until
)
))
),
per_user as (
select k, user_id from solved group by k, user_id
@@ -103,6 +115,7 @@ interface LessonSnapshot {
id: number
problemIds: number[]
language: LessonLanguage | null
mode: LessonMode
}
/**
@@ -111,7 +124,7 @@ interface LessonSnapshot {
*/
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
const [row] = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language })
.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))
@@ -128,7 +141,8 @@ export async function announceLessonChange(className: string, before: LessonSnap
const after = await todayLessonSnapshot(className)
if (!before && !after) return
const sameLesson = !!before && !!after && before.id === after.id
if (sameLesson && before.language === after.language && before.problemIds.join() === after.problemIds.join()) return
const sameWay = sameLesson && before.language === after.language && before.mode === after.mode
if (sameWay && before.problemIds.join() === after.problemIds.join()) return
const ids = [...new Set([...(before?.problemIds ?? []), ...(after?.problemIds ?? [])])]
const rows = ids.length
@@ -150,10 +164,11 @@ export async function announceLessonChange(className: string, before: LessonSnap
type: 'lesson_update',
kind: !after ? 'cleared' : sameLesson ? 'changed' : 'assigned',
language: after?.language ?? null,
mode: after?.mode ?? null,
problems: pick(afterIds),
added: sameLesson ? pick(added) : [],
removed: pick(removed),
languageChanged: sameLesson && before.language !== after.language,
languageChanged: sameLesson && !sameWay,
reordered: sameLesson && kept.join() !== keptBefore.join(),
} satisfies LessonUpdate)
} catch (error) {