feat(课堂): 布置这节课的题时可以选用什么语言做,学生用别的语言交对不算这份作业做完

class_lesson 加一列 language(0024,可空:以前布置的、按提交推断出来的课都不限语言)。
布置时有题不支持选的语言就整个拒掉并点名是哪几道;看板的格子、学生首页的做完状态、
提交列表「这节课」都只认这个语言的代码提交,流程图照旧算。看板多给 lastLanguage 当默认值。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-30 09:54:21 +08:00
1 parent e3be3cd62c
commit a18e129f4c
6 files changed
+4218 -19

No files matched your search

@@ -0,0 +1 @@
ALTER TABLE "class_lesson" ADD COLUMN "language" text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -169,6 +169,13 @@
"when": 1790519287984, "when": 1790519287984,
"tag": "0023_add_class_lesson", "tag": "0023_add_class_lesson",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1790732755406,
"tag": "0024_add_class_lesson_language",
"breakpoints": true
} }
] ]
} }
+6
View File
@@ -50,6 +50,7 @@ import type {
ProblemSetDifficulty, ProblemSetDifficulty,
ProblemSetStatus, ProblemSetStatus,
ReactionKey, ReactionKey,
RunnableLanguage,
SqlConfig, SqlConfig,
SqlDisplay, SqlDisplay,
TagCategory, TagCategory,
@@ -1477,6 +1478,11 @@ export const classLesson = pgTable(
className: text("class_name").notNull(), className: text("class_name").notNull(),
day: text().notNull(), day: text().notNull(),
problemIds: jsonb("problem_ids").notNull().$type<number[]>(), problemIds: jsonb("problem_ids").notNull().$type<number[]>(),
/**
* 这份作业用什么语言做(Python / C / C++),老师布置时选。可空:加这一列之前布置的
* 没有它。写入侧在 PUT /classroom/lesson 按 runnableLanguageSchema 校验过
*/
language: text().$type<RunnableLanguage>(),
createdBy: integer("created_by").notNull(), createdBy: integer("created_by").notNull(),
updatedAt: timestamp("updated_at", { updatedAt: timestamp("updated_at", {
withTimezone: true, withTimezone: true,
+71 -16
View File
@@ -12,6 +12,7 @@ import {
type ClassRankItem, type ClassRankItem,
type ClassUserRank, type ClassUserRank,
type LastVisit, type LastVisit,
type RunnableLanguage,
FLOWCHART_PASS_GRADES, FLOWCHART_PASS_GRADES,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
@@ -224,8 +225,10 @@ function activityRows(options: {
since: string since: string
users?: number[] | ReturnType<typeof classMemberIds> users?: number[] | ReturnType<typeof classMemberIds>
problemIds?: number[] problemIds?: number[]
/** 老师布置时选了语言:代码只数这个语言交的,流程图不受影响 */
language?: RunnableLanguage | null
}) { }) {
const { since, users, problemIds } = options const { since, users, problemIds, language } = options
const code = db const code = db
.select({ .select({
userId: schema.submission.userId, userId: schema.submission.userId,
@@ -240,6 +243,7 @@ function activityRows(options: {
gte(schema.submission.createTime, since), gte(schema.submission.createTime, since),
users ? inArray(schema.submission.userId, users) : undefined, users ? inArray(schema.submission.userId, users) : undefined,
problemIds ? inArray(schema.submission.problemId, problemIds) : undefined, problemIds ? inArray(schema.submission.problemId, problemIds) : undefined,
language ? eq(schema.submission.language, language) : undefined,
), ),
) )
const flowchart = db const flowchart = db
@@ -268,12 +272,13 @@ function activityRows(options: {
async function classDayGroups( async function classDayGroups(
className: string, className: string,
since: string, since: string,
options: { minUsers?: number; problemIds?: number[] } = {}, options: { minUsers?: number; problemIds?: number[]; language?: RunnableLanguage | null } = {},
): Promise<ClassDayGroup[]> { ): Promise<ClassDayGroup[]> {
const activity = activityRows({ const activity = activityRows({
since, since,
users: classMemberIds(className), users: classMemberIds(className),
problemIds: options.problemIds, problemIds: options.problemIds,
language: options.language,
}) })
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')` const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const userCount = sql<number>`count(distinct ${activity.userId})::int` const userCount = sql<number>`count(distinct ${activity.userId})::int`
@@ -310,14 +315,14 @@ async function visibleProblems(ids: number[]) {
return new Map(rows.map((row) => [row.id, row])) return new Map(rows.map((row) => [row.id, row]))
} }
/** 老师给这个班今天布置的题(problem.id,按输入顺序);没布置为 null */ /** 老师给这个班今天布置的题(problem.id,按输入顺序)和选的语言;没布置为 null */
async function lessonProblemIds(className: string, day: string) { async function lessonPlan(className: string, day: string) {
const [row] = await db const [row] = await db
.select({ problemIds: schema.classLesson.problemIds }) .select({ problemIds: schema.classLesson.problemIds, language: schema.classLesson.language })
.from(schema.classLesson) .from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day))) .where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
.limit(1) .limit(1)
return row?.problemIds.length ? row.problemIds : null return row?.problemIds.length ? { problemIds: row.problemIds, language: row.language } : null
} }
/** /**
@@ -330,16 +335,18 @@ async function lessonProblemIds(className: string, day: string) {
*/ */
async function classLessonProblems(className: string, lookbackDays: number) { async function classLessonProblems(className: string, lookbackDays: number) {
const today = calendarDay() const today = calendarDay()
const planned = await lessonProblemIds(className, today) const plan = await lessonPlan(className, today)
if (planned) { if (plan) {
const planned = plan.problemIds
const [problems, groups] = await Promise.all([ const [problems, groups] = await Promise.all([
visibleProblems(planned), visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned }), classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language }),
]) ])
const groupById = new Map(groups.map((row) => [row.problemId, row])) const groupById = new Map(groups.map((row) => [row.problemId, row]))
return { return {
source: "teacher" as const, source: "teacher" as const,
day: today, day: today,
language: plan.language ?? null,
problems: planned.flatMap((id) => { problems: planned.flatMap((id) => {
const problem = problems.get(id) const problem = problems.get(id)
if (!problem) return [] if (!problem) return []
@@ -369,6 +376,8 @@ async function classLessonProblems(className: string, lookbackDays: number) {
return { return {
source: picked.length ? ("inferred" as const) : null, source: picked.length ? ("inferred" as const) : null,
day: picked.length ? latest : null, day: picked.length ? latest : null,
// 推断出来的课不知道老师要的是哪种语言,不限
language: null,
problems: picked.flatMap((row) => { problems: picked.flatMap((row) => {
const problem = problems.get(row.problemId) const problem = problems.get(row.problemId)
return problem return problem
@@ -502,6 +511,7 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
className: null, className: null,
day: null, day: null,
source: null, source: null,
language: null,
problems: [], problems: [],
} satisfies ClassActivity) } satisfies ClassActivity)
} }
@@ -522,6 +532,8 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
eq(schema.submission.userId, user.id), eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
// 布置的是 C:以前用 Python 做对过不算这份作业做完
lesson.language ? eq(schema.submission.language, lesson.language) : undefined,
), ),
) )
.groupBy(schema.submission.problemId) .groupBy(schema.submission.problemId)
@@ -554,6 +566,7 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
className: user.className, className: user.className,
day: lesson.day, day: lesson.day,
source: lesson.source, source: lesson.source,
language: lesson.language,
problems: lesson.problems.map((problem) => { problems: lesson.problems.map((problem) => {
const accepted = mineById.get(problem.id) const accepted = mineById.get(problem.id)
return { return {
@@ -619,14 +632,27 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
className: null, className: null,
day, day,
source: null, source: null,
language: null,
lastLanguage: null,
problems: [], problems: [],
students: [], students: [],
recentLessons: 0, recentLessons: 0,
} satisfies ClassBoard) } satisfies ClassBoard)
} }
// 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来 // 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来。
const lesson = await classLessonProblems(className, 1) // lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种)
const [lesson, [last]] = await Promise.all([
classLessonProblems(className, 1),
db
.select({ language: schema.classLesson.language })
.from(schema.classLesson)
.where(
and(eq(schema.classLesson.className, className), isNotNull(schema.classLesson.language)),
)
.orderBy(desc(schema.classLesson.day))
.limit(1),
])
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
const start = dayStart() const start = dayStart()
@@ -680,6 +706,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
lesson.language ? eq(schema.submission.language, lesson.language) : undefined,
), ),
) )
.groupBy(schema.submission.userId, schema.submission.problemId) .groupBy(schema.submission.userId, schema.submission.problemId)
@@ -774,6 +801,8 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
className, className,
day, day,
source: lesson.source, source: lesson.source,
language: lesson.language,
lastLanguage: last?.language ?? null,
problems: lesson.problems.map((problem) => ({ problems: lesson.problems.map((problem) => ({
problemId: problem.id, problemId: problem.id,
problemDisplayId: problem.displayId, problemDisplayId: problem.displayId,
@@ -815,12 +844,18 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => { classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => {
const className = c.req.query("className")?.trim() || (await suggestActiveClass()) const className = c.req.query("className")?.trim() || (await suggestActiveClass())
if (!className) if (!className)
return success(c, { className: null, source: null, problems: [] } satisfies ClassLesson) return success(c, {
className: null,
source: null,
language: null,
problems: [],
} satisfies ClassLesson)
// 和看板一样只回看今天:「这节课」不该冒出昨天的题 // 和看板一样只回看今天:「这节课」不该冒出昨天的题
const lesson = await classLessonProblems(className, 1) const lesson = await classLessonProblems(className, 1)
return success(c, { return success(c, {
className, className,
source: lesson.source, source: lesson.source,
language: lesson.language,
problems: lesson.problems.map((problem) => ({ problems: lesson.problems.map((problem) => ({
problemDisplayId: problem.displayId, problemDisplayId: problem.displayId,
title: problem.title, title: problem.title,
@@ -834,10 +869,11 @@ classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => {
* 学生那边退回推断。 * 学生那边退回推断。
*/ */
classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => { classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对") const parsed = await parseBody(c, classLessonRequestSchema, "题号或语言不对")
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const user = c.get("user")! const user = c.get("user")!
const { className, problemDisplayIds } = parsed.data const { className, problemDisplayIds } = parsed.data
const language = parsed.data.language ?? null
const day = calendarDay() const day = calendarDay()
const [exists] = await db const [exists] = await db
@@ -856,7 +892,11 @@ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))] const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))]
const found = await db const found = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId }) .select({
id: schema.problem.id,
displayId: schema.problem.displayId,
languages: schema.problem.languages,
})
.from(schema.problem) .from(schema.problem)
.where( .where(
and( and(
@@ -871,14 +911,29 @@ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
return failure(c, 400, "problem-not-found", `这些题号不存在或没有公开:${missing.join("、")}`) return failure(c, 400, "problem-not-found", `这些题号不存在或没有公开:${missing.join("、")}`)
} }
// 选了语言就得每道都能用它交,不然学生点开才发现交不了(提交接口会拒:language-not-allowed)
if (language) {
const unsupported = found
.filter((row) => !row.languages.includes(language))
.map((row) => row.displayId)
if (unsupported.length) {
return failure(
c,
400,
"language-not-allowed",
`这些题不能用 ${language} 交:${unsupported.join("、")}`,
)
}
}
const problemIds = wanted.map((id) => idByDisplay.get(id)!) const problemIds = wanted.map((id) => idByDisplay.get(id)!)
const now = new Date().toISOString() const now = new Date().toISOString()
await db await db
.insert(schema.classLesson) .insert(schema.classLesson)
.values({ className, day, problemIds, createdBy: user.id, updatedAt: now }) .values({ className, day, problemIds, language, createdBy: user.id, updatedAt: now })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [schema.classLesson.className, schema.classLesson.day], target: [schema.classLesson.className, schema.classLesson.day],
set: { problemIds, createdBy: user.id, updatedAt: now }, set: { problemIds, language, createdBy: user.id, updatedAt: now },
}) })
return success(c, null) return success(c, null)
}) })
+27 -3
View File
@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { judgeStatusSchema } from "./judge-status" import { judgeStatusSchema } from "./judge-status"
import { runnableLanguageSchema } from "./language"
export const classRankItemSchema = z.object({ export const classRankItemSchema = z.object({
className: z.string(), className: z.string(),
@@ -76,15 +77,25 @@ export const classActivityProblemSchema = z.object({
userCount: z.number().int(), userCount: z.number().int(),
/** 其中那天通过了的人数 */ /** 其中那天通过了的人数 */
acceptedCount: z.number().int(), acceptedCount: z.number().int(),
/** 我自己在题库里(不含比赛)做这道题的状态,不限那一天 */ /**
* 我自己在题库里(不含比赛)做这道题的状态,不限那一天。老师布置时选了语言的,
* 代码只认用那个语言交的(流程图照旧算)
*/
myStatus: z.enum(["accepted", "tried", "none"]), myStatus: z.enum(["accepted", "tried", "none"]),
}) })
/**
* 老师布置这节课时选的语言(Python / C / C++)。null = 没选:推断出来的课、加这一项之前
* 布置的课。选了之后这份作业「做完」只认这个语言交对的,学生打开这几道题编辑器默认就是它
*/
const lessonLanguageSchema = runnableLanguageSchema.nullable()
export const classActivitySchema = z.object({ export const classActivitySchema = z.object({
className: z.string().nullable(), className: z.string().nullable(),
day: z.string().nullable(), day: z.string().nullable(),
/** teacher = 老师在课堂看板里布置的;inferred = 从同班提交记录推断的 */ /** teacher = 老师在课堂看板里布置的;inferred = 从同班提交记录推断的 */
source: z.enum(["teacher", "inferred"]).nullable(), source: z.enum(["teacher", "inferred"]).nullable(),
language: lessonLanguageSchema,
problems: z.array(classActivityProblemSchema), problems: z.array(classActivityProblemSchema),
}) })
@@ -122,10 +133,14 @@ export const lastVisitSchema = z.object({
.nullable(), .nullable(),
}) })
/** 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉 */ /**
* 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉。
* `language` 是这份作业用什么语言做;不传当 null(上线时还开着的旧看板页面不带它)
*/
export const classLessonRequestSchema = z.object({ export const classLessonRequestSchema = z.object({
className: z.string().trim().min(1), className: z.string().trim().min(1),
problemDisplayIds: z.array(z.string().trim().min(1)).max(20), problemDisplayIds: z.array(z.string().trim().min(1)).max(20),
language: lessonLanguageSchema.optional(),
}) })
export const classBoardProblemSchema = z.object({ export const classBoardProblemSchema = z.object({
@@ -135,7 +150,10 @@ export const classBoardProblemSchema = z.object({
}) })
export const classBoardCellSchema = z.object({ export const classBoardCellSchema = z.object({
/** accepted 看的是题库里(不含比赛)有没有通过过,不限今天 —— 以前做过的也算做完 */ /**
* accepted 看的是题库里(不含比赛)有没有通过过,不限今天 —— 以前做过的也算做完。
* 布置时选了语言的,代码的次数和通过都只数那个语言(流程图照旧)
*/
status: z.enum(["accepted", "tried", "none"]), status: z.enum(["accepted", "tried", "none"]),
/** 今天在这道题上交了几次(代码 + 流程图) */ /** 今天在这道题上交了几次(代码 + 流程图) */
attempts: z.number().int(), attempts: z.number().int(),
@@ -175,6 +193,10 @@ export const classBoardSchema = z.object({
className: z.string().nullable(), className: z.string().nullable(),
day: z.string(), day: z.string(),
source: z.enum(["teacher", "inferred"]).nullable(), source: z.enum(["teacher", "inferred"]).nullable(),
/** 今天布置的语言(见 lessonLanguageSchema) */
language: lessonLanguageSchema,
/** 这个班最近一次布置选的语言(含今天),给语言下拉当默认值;从没选过为 null */
lastLanguage: lessonLanguageSchema,
problems: z.array(classBoardProblemSchema), problems: z.array(classBoardProblemSchema),
students: z.array(classBoardStudentSchema), students: z.array(classBoardStudentSchema),
/** /**
@@ -192,6 +214,8 @@ export const classBoardSchema = z.object({
export const classLessonSchema = z.object({ export const classLessonSchema = z.object({
className: z.string().nullable(), className: z.string().nullable(),
source: z.enum(["teacher", "inferred"]).nullable(), source: z.enum(["teacher", "inferred"]).nullable(),
/** 选了语言时,列表那边连语言一起筛 */
language: lessonLanguageSchema,
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })), problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
}) })