- 课堂看板「选题」弹框:左边知识点(加必会题),右边搜题号/题名、点选,按点的先后排; 只列这次作业的语言能交的题(/problems、/problem-tags 加 ?language=) - 一天多节课:0040 拿掉 class_lesson (class_name, day) 唯一约束,每次布置一行; 看板「改」只改当前一节,加「新的一节」;这一节布置超过 100 分钟时先问这节课做哪几道题。 不带 lessonId/newLesson 的老请求照旧改今天最后一节 - 数据统计「一行一节课」:布置过的课以布置为准(题目和顺序照布置、做完按全班算、标「布置」), 这些提交不再参与推断切段,也不算零散;详情页「做完」注明是这一天/这节课/这段时间 - 后台「课堂作业」:一节一行,改(班级/日子/时刻/语言/题目)、看统计、删除、补一节; 看板加「布置过的课」入口。题号校验和「做完」口径抽到 services/lesson.ts,三处共用 ⚠️ 机房站点要和服务器一起升级:旧代码布置时 ON CONFLICT (class_name, day) 找不到约束会报错 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0916015405
commit
b344bd1dd6
26 files changed
+5685
-99
No files matched your search
@@ -0,0 +1,4 @@
|
||||
-- 一个班一天可以上几节内容不同的课:拿掉一天一行的唯一约束,每次布置一行。
|
||||
-- 共库的机房站点要一起升级:旧代码布置时 ON CONFLICT (class_name, day) 会因为找不到这条约束报错。
|
||||
ALTER TABLE "class_lesson" DROP CONSTRAINT "class_lesson_class_day_unique";--> statement-breakpoint
|
||||
CREATE INDEX "class_lesson_class_day_idx" ON "class_lesson" USING btree ("class_name","day");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -281,6 +281,13 @@
|
||||
"when": 1792930018504,
|
||||
"tag": "0039_seed_challenges",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "7",
|
||||
"when": 1793622303180,
|
||||
"tag": "0040_class_lesson_many_per_day",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1292,7 +1292,8 @@ export const exerciseAttempt = pgTable(
|
||||
)
|
||||
|
||||
/**
|
||||
* 老师给某个班「这节课」布置的题(课堂看板里输入的题号),一个班一天一行。
|
||||
* 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节
|
||||
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
|
||||
*
|
||||
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
|
||||
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
|
||||
@@ -1323,10 +1324,11 @@ export const classLesson = pgTable(
|
||||
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
|
||||
*/
|
||||
language: text().$type<LessonLanguage>(),
|
||||
/** 这天第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
|
||||
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
|
||||
createdBy: integer('created_by').notNull(),
|
||||
/**
|
||||
* 这天第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
|
||||
* 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
|
||||
* 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。
|
||||
* 加这一列之前布置的用 updated_at 回填(0034)
|
||||
*/
|
||||
createdAt: timestamp('created_at', {
|
||||
@@ -1341,7 +1343,8 @@ export const classLesson = pgTable(
|
||||
}).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
unique('class_lesson_class_day_unique').on(table.className, table.day),
|
||||
// 一个班一天可以上几节内容不同的课,每次布置一行(0040 拿掉了原来的一天一行唯一约束)
|
||||
index('class_lesson_class_day_idx').on(table.className, table.day),
|
||||
index('class_lesson_created_by_idx').on(table.createdBy),
|
||||
foreignKey({
|
||||
columns: [table.createdBy],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { adminAnalyticsRoutes } from './analytics'
|
||||
import { adminConfRoutes } from './conf'
|
||||
import { adminContestRoutes } from './contest'
|
||||
import { adminLearnRoutes } from './learn'
|
||||
import { adminLessonRoutes } from './lesson'
|
||||
import { adminProblemRoutes } from './problem'
|
||||
import { adminProblemSetRoutes } from './problemset'
|
||||
import { adminTagRoutes } from './tag'
|
||||
@@ -31,6 +32,7 @@ adminRoutes.route('/', adminAnalyticsRoutes)
|
||||
adminRoutes.route('/', adminConfRoutes)
|
||||
adminRoutes.route('/', adminContestRoutes)
|
||||
adminRoutes.route('/', adminLearnRoutes)
|
||||
adminRoutes.route('/', adminLessonRoutes)
|
||||
adminRoutes.route('/', adminProblemRoutes)
|
||||
adminRoutes.route('/', adminProblemSetRoutes)
|
||||
adminRoutes.route('/', adminTagRoutes)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { adminLessonRequestSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
|
||||
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { requireTeacher, type AppEnv } from '../../auth/middleware'
|
||||
import { db, schema } from '../../db'
|
||||
import { failure, parseBody, success } from '../../http'
|
||||
import { lessonDoneCounts, resolveLessonProblems } from '../../services/lesson'
|
||||
import { localDateTime } from '../../time'
|
||||
import { queryInteger } from '../helpers'
|
||||
|
||||
/**
|
||||
* 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改,
|
||||
* 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
|
||||
*
|
||||
* 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。
|
||||
*/
|
||||
export const adminLessonRoutes = new Hono<AppEnv>()
|
||||
|
||||
const teacherName = sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`
|
||||
|
||||
adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
|
||||
const limit = queryInteger(c.req.query('limit'), 20, { min: 1, max: 100 })
|
||||
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
|
||||
const className = c.req.query('className')?.trim()
|
||||
const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 })
|
||||
const where = and(className ? eq(schema.classLesson.className, className) : undefined, teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined)
|
||||
|
||||
const [rows, [totalRow], teachers] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: schema.classLesson.id,
|
||||
className: schema.classLesson.className,
|
||||
day: schema.classLesson.day,
|
||||
problemIds: schema.classLesson.problemIds,
|
||||
language: schema.classLesson.language,
|
||||
createdAt: schema.classLesson.createdAt,
|
||||
updatedAt: schema.classLesson.updatedAt,
|
||||
createdBy: teacherName,
|
||||
})
|
||||
.from(schema.classLesson)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
|
||||
.where(where)
|
||||
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ value: count() }).from(schema.classLesson).where(where),
|
||||
db
|
||||
.select({ id: schema.classLesson.createdBy, name: teacherName })
|
||||
.from(schema.classLesson)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
|
||||
.groupBy(schema.classLesson.createdBy, schema.user.username, schema.userProfile.realName)
|
||||
.orderBy(desc(count())),
|
||||
])
|
||||
|
||||
const problemIds = [...new Set(rows.flatMap((row) => row.problemIds))]
|
||||
const classNames = [...new Set(rows.map((row) => row.className))]
|
||||
const [problems, sizes, done] = await Promise.all([
|
||||
problemIds.length
|
||||
? db
|
||||
.select({ id: schema.problem.id, displayId: schema.problem.displayId, title: schema.problem.title, visible: schema.problem.visible })
|
||||
.from(schema.problem)
|
||||
.where(inArray(schema.problem.id, problemIds))
|
||||
: [],
|
||||
classNames.length
|
||||
? db
|
||||
.select({ className: schema.user.className, value: count() })
|
||||
.from(schema.user)
|
||||
.where(and(inArray(schema.user.className, classNames), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false)))
|
||||
.groupBy(schema.user.className)
|
||||
: [],
|
||||
lessonDoneCounts(rows),
|
||||
])
|
||||
const problemById = new Map(problems.map((row) => [row.id, row]))
|
||||
const sizeByClass = new Map(sizes.map((row) => [row.className, row.value]))
|
||||
|
||||
return success(c, {
|
||||
results: rows.map((row) => ({
|
||||
id: row.id,
|
||||
className: row.className,
|
||||
day: row.day,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
createdBy: row.createdBy,
|
||||
language: row.language ?? null,
|
||||
// 题被删掉的(problem_ids 不挂外键)直接不列
|
||||
problems: row.problemIds.flatMap((id) => {
|
||||
const problem = problemById.get(id)
|
||||
return problem ? [{ problemDisplayId: problem.displayId, title: problem.title, visible: problem.visible }] : []
|
||||
}),
|
||||
classSize: sizeByClass.get(row.className) ?? 0,
|
||||
doneCount: done.get(row.id) ?? 0,
|
||||
})),
|
||||
total: totalRow?.value ?? 0,
|
||||
teachers,
|
||||
} satisfies AdminLessonList)
|
||||
})
|
||||
|
||||
/** 新补一节、改一节共用的校验:班得有人、题号都对得上、语言每道都能交 */
|
||||
async function lessonValues(
|
||||
body: AdminLessonRequest,
|
||||
): Promise<
|
||||
| { code: string; message: string }
|
||||
| { values: { className: string; day: string; problemIds: number[]; language: AdminLessonRequest['language']; 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)
|
||||
if ('code' in resolved) return resolved
|
||||
return {
|
||||
values: {
|
||||
className: body.className,
|
||||
day: body.day,
|
||||
problemIds: resolved.problemIds,
|
||||
language: body.language,
|
||||
createdAt: localDateTime(body.day, body.time),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
adminLessonRoutes.post('/lessons', requireTeacher, async (c) => {
|
||||
const parsed = await parseBody(c, adminLessonRequestSchema, '班级、日子、时刻或题号不对')
|
||||
if (!parsed.success) return parsed.response
|
||||
const result = await lessonValues(parsed.data)
|
||||
if ('code' in result) return failure(c, 400, result.code, result.message)
|
||||
const [row] = await db
|
||||
.insert(schema.classLesson)
|
||||
.values({ ...result.values, createdBy: c.get('user')!.id, updatedAt: new Date().toISOString() })
|
||||
.returning({ id: schema.classLesson.id })
|
||||
return success(c, { id: row!.id })
|
||||
})
|
||||
|
||||
adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
|
||||
const parsed = await parseBody(c, adminLessonRequestSchema, '班级、日子、时刻或题号不对')
|
||||
if (!parsed.success) return parsed.response
|
||||
const result = await lessonValues(parsed.data)
|
||||
if ('code' in result) return failure(c, 400, result.code, result.message)
|
||||
// 布置的老师不换,和看板上「改」一样认第一次
|
||||
const [row] = await db
|
||||
.update(schema.classLesson)
|
||||
.set({ ...result.values, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.classLesson.id, id))
|
||||
.returning({ id: schema.classLesson.id })
|
||||
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
adminLessonRoutes.delete('/lessons/:id', requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
|
||||
await db.delete(schema.classLesson).where(eq(schema.classLesson.id, id))
|
||||
return success(c, null)
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
isSqlProblem,
|
||||
STUDENT_ROLES,
|
||||
classLessonRequestSchema,
|
||||
type ClassActivity,
|
||||
@@ -25,6 +24,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 { resolveLessonProblems } from '../services/lesson'
|
||||
import { calendarDay, dayStart, localTime } from '../time'
|
||||
|
||||
export const classroomRoutes = new Hono<AppEnv>()
|
||||
@@ -94,11 +94,12 @@ async function visibleProblems(ids: number[]) {
|
||||
|
||||
/**
|
||||
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言、布置时刻和布置的老师;
|
||||
* 没布置为 null。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
|
||||
* 没布置为 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,
|
||||
createdAt: schema.classLesson.createdAt,
|
||||
@@ -108,6 +109,7 @@ async function lessonPlan(className: string, day: string) {
|
||||
.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
|
||||
}
|
||||
@@ -132,6 +134,7 @@ async function classLessonProblems(className: string, lookbackDays: number) {
|
||||
const groupById = new Map(groups.map((row) => [row.problemId, row]))
|
||||
return {
|
||||
source: 'teacher' as const,
|
||||
lessonId: plan.id,
|
||||
day: today,
|
||||
language: plan.language ?? null,
|
||||
assignedAt: plan.createdAt,
|
||||
@@ -161,6 +164,7 @@ async function classLessonProblems(className: string, lookbackDays: number) {
|
||||
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,
|
||||
@@ -379,6 +383,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
||||
className: null,
|
||||
day,
|
||||
source: null,
|
||||
lessonId: null,
|
||||
language: null,
|
||||
assignedAt: null,
|
||||
assignedBy: null,
|
||||
@@ -397,7 +402,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
||||
.select({ language: schema.classLesson.language })
|
||||
.from(schema.classLesson)
|
||||
.where(and(eq(schema.classLesson.className, className), isNotNull(schema.classLesson.language)))
|
||||
.orderBy(desc(schema.classLesson.day))
|
||||
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.createdAt))
|
||||
.limit(1),
|
||||
])
|
||||
const ids = lesson.problems.map((problem) => problem.id)
|
||||
@@ -550,6 +555,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
||||
className,
|
||||
day,
|
||||
source: lesson.source,
|
||||
lessonId: lesson.lessonId,
|
||||
language: lesson.language,
|
||||
assignedAt: lesson.assignedAt,
|
||||
assignedBy: lesson.assignedBy,
|
||||
@@ -725,54 +731,51 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序;
|
||||
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉,
|
||||
* 学生那边退回推断。
|
||||
* 老师给这个班布置今天的一节课。按展示题号给、不分大小写,存 problem.id 并保留输入顺序;
|
||||
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
|
||||
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
|
||||
*/
|
||||
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 } = parsed.data
|
||||
const { className, problemDisplayIds, language, lessonId, newLesson } = parsed.data
|
||||
const day = calendarDay()
|
||||
|
||||
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, className)).limit(1)
|
||||
if (!exists) return failure(c, 400, 'class-not-found', `没有 ${className} 这个班`)
|
||||
|
||||
// 要改的是哪一行:点名的那一节(只认这个班今天的),新开一节就没有,老写法是今天最后布置的那一节
|
||||
const [target] = newLesson
|
||||
? []
|
||||
: await db
|
||||
.select({ id: schema.classLesson.id })
|
||||
.from(schema.classLesson)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.classLesson.className, className),
|
||||
eq(schema.classLesson.day, day),
|
||||
lessonId === undefined ? undefined : eq(schema.classLesson.id, lessonId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||
.limit(1)
|
||||
if (!problemDisplayIds.length) {
|
||||
await db.delete(schema.classLesson).where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
|
||||
if (target) await db.delete(schema.classLesson).where(eq(schema.classLesson.id, target.id))
|
||||
return success(c, null)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
.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 failure(c, 400, 'problem-not-found', `这些题号不存在或没有公开:${missing.join('、')}`)
|
||||
}
|
||||
|
||||
// 选了语言就得每道都能用它交,不然学生点开才发现交不了(提交接口会拒:language-not-allowed)。
|
||||
// SQL 题除外:它只收 SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL
|
||||
if (language) {
|
||||
const unsupported = found.filter((row) => !row.languages.includes(language) && !isSqlProblem(row)).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 resolved = await resolveLessonProblems(problemDisplayIds, language)
|
||||
if ('code' in resolved) return failure(c, 400, resolved.code, resolved.message)
|
||||
const { problemIds } = resolved
|
||||
const now = new Date().toISOString()
|
||||
await db
|
||||
.insert(schema.classLesson)
|
||||
.values({
|
||||
if (target) {
|
||||
// 「改」不换布置的人,和 createdAt 一样认第一次
|
||||
await db.update(schema.classLesson).set({ problemIds, language, updatedAt: now }).where(eq(schema.classLesson.id, target.id))
|
||||
} else {
|
||||
// 新开一节、今天还没有,或者点名的那一节已经没了(别的老师清掉了)—— 最后这种也照新开处理,
|
||||
// 老师手上的题号不至于白填
|
||||
await db.insert(schema.classLesson).values({
|
||||
className,
|
||||
day,
|
||||
problemIds,
|
||||
@@ -781,10 +784,6 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.classLesson.className, schema.classLesson.day],
|
||||
// 「改」不换布置的人,和 createdAt 一样认第一次
|
||||
set: { problemIds, language, updatedAt: now },
|
||||
})
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AST_SUPPORTED_LANGUAGES,
|
||||
isSqlProblem,
|
||||
lessonLanguageSchema,
|
||||
parseLanguage,
|
||||
problemListSortSchema,
|
||||
problemTypeFilterSchema,
|
||||
@@ -71,6 +72,15 @@ function typeFilter(type: ProblemTypeFilter | undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
|
||||
* 学生的题目列表不带这个参数
|
||||
*/
|
||||
function lessonLanguageFilter(raw: string | undefined) {
|
||||
const language = lessonLanguageSchema.safeParse(raw).data
|
||||
return language ? sql`${schema.problem.languages} @> ${JSON.stringify([language])}::jsonb` : undefined
|
||||
}
|
||||
|
||||
type ProblemStates = Awaited<ReturnType<typeof problemStates>> | null
|
||||
type UserCounts = Awaited<ReturnType<typeof problemUserCounts>>
|
||||
|
||||
@@ -137,6 +147,8 @@ problemRoutes.get('/problems', optionalAuth, async (c) => {
|
||||
}
|
||||
const byType = typeFilter(type)
|
||||
if (byType) filters.push(byType)
|
||||
const byLanguage = lessonLanguageFilter(c.req.query('language'))
|
||||
if (byLanguage) filters.push(byLanguage)
|
||||
|
||||
const states = user ? await problemStates(user.id) : null
|
||||
if (undone && states && states.solved.size > 0) filters.push(notInArray(schema.problem.id, [...states.solved]))
|
||||
@@ -207,7 +219,15 @@ problemRoutes.get('/problem-tags', async (c) => {
|
||||
})
|
||||
.from(schema.problemTag)
|
||||
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.innerJoin(schema.problem, and(eq(schema.problem.id, schema.problemTags.problemId), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
and(
|
||||
eq(schema.problem.id, schema.problemTags.problemId),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
lessonLanguageFilter(c.req.query('language')),
|
||||
),
|
||||
)
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name, schema.problemTag.category)
|
||||
.having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type SubmissionStatisticsGrid,
|
||||
type TodaySubmissionStatistics,
|
||||
} from '@oj2/contract'
|
||||
import { and, count, desc, eq, inArray, isNull, sql, type SQL } from 'drizzle-orm'
|
||||
import { and, count, desc, eq, gte, inArray, isNull, lte, sql, type SQL } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { optionalAuth, requireTeacher } from '../auth/middleware'
|
||||
@@ -24,7 +24,8 @@ import { failure, success } from '../http'
|
||||
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from '../judge/status'
|
||||
import { type ContestEnv } from '../services/contest'
|
||||
import { getBooleanOption } from '../services/options'
|
||||
import { dayStart, localTime } from '../time'
|
||||
import { lessonDoneCounts } from '../services/lesson'
|
||||
import { calendarDay, dayStart, localTime } from '../time'
|
||||
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from './helpers'
|
||||
|
||||
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||
@@ -525,6 +526,122 @@ const LESSON_LIVE_MINUTES = 15
|
||||
const LESSONS_DEFAULT = 50
|
||||
const LESSONS_MAX = 500
|
||||
|
||||
type StatisticsRange = { start: string | null; end: string }
|
||||
|
||||
/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */
|
||||
async function lessonPlans(range: StatisticsRange) {
|
||||
return db
|
||||
.select({
|
||||
id: schema.classLesson.id,
|
||||
className: schema.classLesson.className,
|
||||
day: schema.classLesson.day,
|
||||
problemIds: schema.classLesson.problemIds,
|
||||
language: schema.classLesson.language,
|
||||
createdAt: schema.classLesson.createdAt,
|
||||
})
|
||||
.from(schema.classLesson)
|
||||
.where(and(range.start ? gte(schema.classLesson.day, calendarDay(range.start)) : undefined, lte(schema.classLesson.day, calendarDay(range.end))))
|
||||
.orderBy(schema.classLesson.createdAt, schema.classLesson.id)
|
||||
}
|
||||
|
||||
type LessonPlan = Awaited<ReturnType<typeof lessonPlans>>[number]
|
||||
|
||||
/**
|
||||
* 布置的课各自的数:那天这个班交布置的这几道题的提交(不论隔了多久、几个人;和推断的课
|
||||
* 一样只算普通学生、不含比赛),加上全做对的人数(口径见 lessonDoneCounts)。时间窗之内一条提交都没有、布置时刻也不在
|
||||
* 窗里的不列 —— 「那节课」那种按时段筛的,别把同一天另一节课带出来。
|
||||
*/
|
||||
async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
|
||||
if (!plans.length) return []
|
||||
const planRows = JSON.stringify(
|
||||
plans.flatMap((plan) =>
|
||||
plan.problemIds.map((problemId) => ({
|
||||
k: plan.id,
|
||||
class_name: plan.className,
|
||||
day: plan.day,
|
||||
problem_id: problemId,
|
||||
})),
|
||||
),
|
||||
)
|
||||
const accepted = sql.join(
|
||||
ACCEPTED_RESULTS.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)
|
||||
const judging = sql.join(
|
||||
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 [counts, doneByLesson] = await Promise.all([
|
||||
db.execute<{
|
||||
k: number
|
||||
problem_id: number | null
|
||||
users: number
|
||||
total: number
|
||||
accepted: number
|
||||
judging: number
|
||||
first: string
|
||||
last: string
|
||||
}>(sql`
|
||||
with p as ${plan},
|
||||
s as (
|
||||
select p.k, s.user_id, s.problem_id, s.result, 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
|
||||
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
|
||||
)
|
||||
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,
|
||||
min(create_time) as first,
|
||||
max(create_time) as last
|
||||
from s
|
||||
group by grouping sets ((k), (k, problem_id))
|
||||
`),
|
||||
lessonDoneCounts(plans),
|
||||
])
|
||||
const totalByLesson = new Map([...counts].filter((row) => row.problem_id === null).map((row) => [row.k, row]))
|
||||
const byProblem = Map.groupBy(
|
||||
[...counts].filter((row) => row.problem_id !== null),
|
||||
(row) => row.k,
|
||||
)
|
||||
const inRange = (time: string) => Date.parse(time) <= Date.parse(range.end) && (!range.start || Date.parse(time) >= Date.parse(range.start))
|
||||
return plans.flatMap((lesson) => {
|
||||
const total = totalByLesson.get(lesson.id)
|
||||
if (!total && !inRange(lesson.createdAt)) return []
|
||||
// 刚布置、还没人交:时段就是布置那一刻
|
||||
const first = total ? new Date(total.first).toISOString() : lesson.createdAt
|
||||
const last = total ? new Date(total.last).toISOString() : lesson.createdAt
|
||||
return [
|
||||
{
|
||||
className: lesson.className,
|
||||
day: lesson.day,
|
||||
problemIds: lesson.problemIds,
|
||||
start: first,
|
||||
end: last,
|
||||
users: total?.users ?? 0,
|
||||
total: total?.total ?? 0,
|
||||
accepted: total?.accepted ?? 0,
|
||||
judging: total?.judging ?? 0,
|
||||
byProblem: (byProblem.get(lesson.id) ?? []).map((row) => ({
|
||||
problemId: row.problem_id!,
|
||||
total: row.total,
|
||||
accepted: row.accepted,
|
||||
judging: row.judging,
|
||||
})),
|
||||
doneCount: doneByLesson.get(lesson.id) ?? 0,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
type LessonAggRow = {
|
||||
level: number
|
||||
class_name: string | null
|
||||
@@ -532,7 +649,8 @@ type LessonAggRow = {
|
||||
sid: number | null
|
||||
problem_id: number | null
|
||||
result: JudgeStatusValue | null
|
||||
lesson: boolean | null
|
||||
/** plan = 布置的课的题(另算,见 assignedLessons);lesson = 推断出来的课;scattered = 零散 */
|
||||
kind: 'plan' | 'lesson' | 'scattered' | null
|
||||
users: number
|
||||
total: number
|
||||
accepted: number
|
||||
@@ -563,10 +681,19 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
sql`, `,
|
||||
)
|
||||
|
||||
// grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、lesson 1。
|
||||
// 这段时间里老师布置过的课。它们的题那天交的提交另算(assignedLessons),不参与切段
|
||||
const plans = await lessonPlans(range)
|
||||
const planRows = JSON.stringify(
|
||||
plans.flatMap((plan) => plan.problemIds.map((problemId) => ({ class_name: plan.className, day: plan.day, problem_id: problemId }))),
|
||||
)
|
||||
|
||||
// grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、kind 1。
|
||||
// 没参与分组的那几列记 1,所以每种粒度对应一个固定的 level
|
||||
const rows = await db.execute<LessonAggRow>(sql`
|
||||
with s as (
|
||||
const rowsPromise = db.execute<LessonAggRow>(sql`
|
||||
with plan as (
|
||||
select * from jsonb_to_recordset(${planRows}::jsonb) as p(class_name text, day text, problem_id int)
|
||||
),
|
||||
s as (
|
||||
select u.class_name, s.user_id, s.problem_id, s.result, s.problemset_id, s.create_time,
|
||||
to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') as day
|
||||
from ${schema.submission} s
|
||||
@@ -580,7 +707,11 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
g as (
|
||||
select *,
|
||||
case when create_time - lag(create_time) over w > ${`${LESSON_GAP_MINUTES} minutes`}::interval
|
||||
then 1 else 0 end as brk
|
||||
then 1 else 0 end as brk,
|
||||
exists (
|
||||
select 1 from plan p
|
||||
where p.class_name is not distinct from s.class_name and p.day = s.day and p.problem_id = s.problem_id
|
||||
) as planned
|
||||
from s
|
||||
window w as (partition by class_name, day order by create_time)
|
||||
),
|
||||
@@ -594,20 +725,22 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
from (
|
||||
select class_name, day, sid, problem_id, count(distinct user_id) as n
|
||||
from seg
|
||||
where not planned
|
||||
group by class_name, day, sid, problem_id
|
||||
) p
|
||||
group by class_name, day, sid
|
||||
having max(n) >= ${LESSON_MIN_USERS}
|
||||
),
|
||||
marked as (
|
||||
select seg.*, (l.sid is not null) as lesson
|
||||
select seg.*,
|
||||
case when seg.planned then 'plan' when l.sid is not null then 'lesson' else 'scattered' end as kind
|
||||
from seg
|
||||
left join lesson_seg l
|
||||
-- 没填班级的号(class_name 为 null)合在一起当一个班切,= 会把它们全漏掉
|
||||
on l.class_name is not distinct from seg.class_name and l.day = seg.day and l.sid = seg.sid
|
||||
)
|
||||
select grouping(class_name, day, sid, problem_id, result, lesson) as level,
|
||||
class_name, day, sid::int as sid, problem_id, result, lesson,
|
||||
select grouping(class_name, day, sid, problem_id, result, kind) as level,
|
||||
class_name, day, sid::int as sid, problem_id, result, kind,
|
||||
count(distinct user_id)::int as users,
|
||||
count(*)::int as total,
|
||||
(count(*) filter (where result in (${accepted})))::int as accepted,
|
||||
@@ -618,26 +751,33 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
(count(distinct user_id) filter (where result in (${accepted})))::int as accepted_users
|
||||
from marked
|
||||
group by grouping sets (
|
||||
(class_name, day, sid, problem_id),
|
||||
(class_name, day, sid, lesson),
|
||||
(class_name, day, sid, problem_id, kind),
|
||||
(class_name, day, sid, kind),
|
||||
(problem_id, class_name),
|
||||
(problem_id, result),
|
||||
(lesson, class_name, day)
|
||||
(kind, class_name, day)
|
||||
)
|
||||
`)
|
||||
const [rows, assigned] = await Promise.all([rowsPromise, assignedLessons(plans, range)])
|
||||
|
||||
const LEVEL = { segProblem: 3, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 }
|
||||
const LEVEL = { segProblem: 2, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 }
|
||||
const segKey = (row: LessonAggRow) => `${row.class_name}|${row.day}|${row.sid}`
|
||||
const failedOf = (row: { total: number; accepted: number; judging: number }) => row.total - row.accepted - row.judging
|
||||
|
||||
const problemsOfSeg = new Map<string, LessonAggRow[]>()
|
||||
for (const row of rows) {
|
||||
if (row.level !== LEVEL.segProblem) continue
|
||||
if (row.level !== LEVEL.segProblem || row.kind !== 'lesson') continue
|
||||
const key = segKey(row)
|
||||
problemsOfSeg.set(key, [...(problemsOfSeg.get(key) ?? []), row])
|
||||
}
|
||||
const allLessons = rows.filter((row) => row.level === LEVEL.segment && row.lesson).sort((a, b) => b.first.localeCompare(a.first))
|
||||
const picked = allLessons.slice(0, limit)
|
||||
// 推断的课和布置的课排在一起,按开始时间从近到远取前 limit 节
|
||||
const allLessons = [
|
||||
...rows.filter((row) => row.level === LEVEL.segment && row.kind === 'lesson').map((row) => ({ inferred: row, assigned: null, start: row.first })),
|
||||
...assigned.map((lesson) => ({ inferred: null, assigned: lesson, start: lesson.start })),
|
||||
].sort((a, b) => b.start.localeCompare(a.start))
|
||||
const pickedAll = allLessons.slice(0, limit)
|
||||
const picked = pickedAll.flatMap((item) => (item.inferred ? [item.inferred] : []))
|
||||
const pickedAssigned = pickedAll.flatMap((item) => (item.assigned ? [item.assigned] : []))
|
||||
const hasMore = allLessons.length > limit
|
||||
|
||||
// 题号、标题一次查齐:课的题、每节课错得最多的那道、整段时间错得最多的几道
|
||||
@@ -675,7 +815,13 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
const lessonProblemRows = picked.map((lesson) =>
|
||||
(problemsOfSeg.get(segKey(lesson)) ?? []).filter((row) => row.users >= LESSON_MIN_USERS).sort((a, b) => b.users - a.users),
|
||||
)
|
||||
const problemIds = [...new Set([...lessonProblemRows.flat().map((row) => row.problem_id!), ...hardRanked.map((row) => row.problemId)])]
|
||||
const problemIds = [
|
||||
...new Set([
|
||||
...lessonProblemRows.flat().map((row) => row.problem_id!),
|
||||
...hardRanked.map((row) => row.problemId),
|
||||
...pickedAssigned.flatMap((lesson) => lesson.problemIds),
|
||||
]),
|
||||
]
|
||||
const problems = problemIds.length
|
||||
? await db
|
||||
.select({
|
||||
@@ -689,7 +835,9 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
: []
|
||||
const problemById = new Map(problems.map((row) => [row.id, row]))
|
||||
|
||||
const lessonClasses = [...new Set(picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name])))]
|
||||
const lessonClasses = [
|
||||
...new Set([...picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name])), ...pickedAssigned.map((lesson) => lesson.className)]),
|
||||
]
|
||||
const sizes = lessonClasses.length
|
||||
? await db
|
||||
.select({ className: schema.user.className, value: count() })
|
||||
@@ -700,7 +848,8 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
|
||||
const sum = (list: LessonAggRow[], key: 'total' | 'accepted' | 'judging') => list.reduce((acc, row) => acc + row[key], 0)
|
||||
const liveSince = new Date(Date.now() - LESSON_LIVE_MINUTES * 60_000).toISOString()
|
||||
const lessons = picked.map((lesson, index) => {
|
||||
const sizeOf = (className: string | null) => (className === null ? 0 : (sizes.find((row) => row.className === className)?.value ?? 0))
|
||||
const inferredLessons = picked.map((lesson, index) => {
|
||||
const own = lessonProblemRows[index]!
|
||||
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
|
||||
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
|
||||
@@ -711,13 +860,15 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
end: lesson.last,
|
||||
userCount: lesson.users,
|
||||
// 没填班级的谈不上花名册,给 0(界面上就只写「几人」)
|
||||
classSize: lesson.class_name === null ? 0 : (sizes.find((row) => row.className === lesson.class_name)?.value ?? 0),
|
||||
classSize: sizeOf(lesson.class_name),
|
||||
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
|
||||
problems: own
|
||||
.map((row) => problemById.get(row.problem_id!))
|
||||
.filter((row) => row?.visible)
|
||||
.map((row) => row!.displayId)
|
||||
.slice(0, STATISTICS_MAX_PROBLEMS),
|
||||
assigned: false,
|
||||
doneCount: null,
|
||||
fromProblemSet: lesson.from_ps * 2 > lesson.total,
|
||||
// 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上
|
||||
// (一段里零星一两个人顺手交的别的题不算)
|
||||
@@ -734,11 +885,44 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
: null,
|
||||
}
|
||||
})
|
||||
const assignedOut = pickedAssigned.map((lesson) => {
|
||||
const hardest = [...lesson.byProblem].sort((a, b) => failedOf(b) - failedOf(a))[0]
|
||||
const hardestProblem = hardest ? problemById.get(hardest.problemId) : undefined
|
||||
return {
|
||||
className: lesson.className,
|
||||
day: lesson.day,
|
||||
start: lesson.start,
|
||||
end: lesson.end,
|
||||
userCount: lesson.users,
|
||||
classSize: sizeOf(lesson.className),
|
||||
problems: lesson.problemIds
|
||||
.map((id) => problemById.get(id))
|
||||
.filter((row) => row?.visible)
|
||||
.map((row) => row!.displayId)
|
||||
.slice(0, STATISTICS_MAX_PROBLEMS),
|
||||
assigned: true,
|
||||
doneCount: lesson.doneCount,
|
||||
fromProblemSet: false,
|
||||
total: lesson.total,
|
||||
correctRate: judgedRate(lesson.accepted, lesson.total - lesson.judging),
|
||||
// 刚布置还没人交的也算在上课(没人交时 end 就是布置时刻)
|
||||
live: Date.parse(lesson.end) >= Date.parse(liveSince),
|
||||
hardest:
|
||||
hardest && hardestProblem && failedOf(hardest) > 0
|
||||
? {
|
||||
problemDisplayId: hardestProblem.displayId,
|
||||
problemTitle: hardestProblem.title,
|
||||
failed: failedOf(hardest),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
const lessons = [...inferredLessons, ...assignedOut].sort((a, b) => b.start.localeCompare(a.start))
|
||||
|
||||
// 零散提交只给列出来的这些课覆盖到的日子(「这学期」只列了最近 50 节时,更早的零散不给)
|
||||
const earliestDay = hasMore ? (picked.at(-1)?.day ?? null) : null
|
||||
const earliestDay = hasMore ? (lessons.at(-1)?.day ?? null) : null
|
||||
const scattered = rows
|
||||
.filter((row) => row.level === LEVEL.dayClass && row.lesson === false && (earliestDay === null || row.day! >= earliestDay))
|
||||
.filter((row) => row.level === LEVEL.dayClass && row.kind === 'scattered' && (earliestDay === null || row.day! >= earliestDay))
|
||||
.sort((a, b) => (a.day !== b.day ? b.day!.localeCompare(a.day!) : a.class_name === null ? 1 : b.class_name === null ? -1 : b.total - a.total))
|
||||
.map((row) => ({
|
||||
day: row.day!,
|
||||
|
||||
@@ -117,6 +117,8 @@ export async function buildReport(
|
||||
.select({ day: schema.classLesson.day, problemIds: schema.classLesson.problemIds })
|
||||
.from(schema.classLesson)
|
||||
.where(and(eq(schema.classLesson.className, target.className), start ? gte(schema.classLesson.day, calendarDay(start)) : undefined))
|
||||
// 一天布置过几节的,按布置先后接着排(下面的 planOrder 跨节累加)
|
||||
.orderBy(asc(schema.classLesson.createdAt), asc(schema.classLesson.id))
|
||||
: [],
|
||||
])
|
||||
|
||||
@@ -149,9 +151,12 @@ export async function buildReport(
|
||||
// 否则在 ≥ 5 人的那些天里挑人最多的,打平取晚的
|
||||
const lessonDay = new Map<number, string>()
|
||||
const planOrder = new Map<string, number>()
|
||||
const dayOffset = new Map<string, number>()
|
||||
for (const plan of plans) {
|
||||
const offset = dayOffset.get(plan.day) ?? 0
|
||||
dayOffset.set(plan.day, offset + plan.problemIds.length)
|
||||
plan.problemIds.forEach((problemId, index) => {
|
||||
planOrder.set(`${plan.day}|${problemId}`, index)
|
||||
if (!planOrder.has(`${plan.day}|${problemId}`)) planOrder.set(`${plan.day}|${problemId}`, offset + index)
|
||||
const current = lessonDay.get(problemId)
|
||||
if (!current || usersOn(plan.day, problemId) >= usersOn(current, problemId)) lessonDay.set(problemId, plan.day)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage } from '@oj2/contract'
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import { db, schema } from '../db'
|
||||
import { dayNumber, dayStart } from '../time'
|
||||
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
|
||||
|
||||
/**
|
||||
* 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、
|
||||
* 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 题号 → problem.id,保留输入顺序、去重、不分大小写。只认题库里公开的题;有对不上的整个拒掉并
|
||||
* 点名是哪几个,免得存下半张单子老师还不知道。选了语言就得每道都能用它交(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 }> {
|
||||
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,
|
||||
})
|
||||
.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 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('、')}` }
|
||||
}
|
||||
return { problemIds: wanted.map((id) => idByDisplay.get(id)!) }
|
||||
}
|
||||
|
||||
/** 东八区某一天结束(第二天零点)的时刻 */
|
||||
export function lessonDayEnd(day: string) {
|
||||
return dayStart(new Date((dayNumber(day) + 1) * 86_400_000))
|
||||
}
|
||||
|
||||
/**
|
||||
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算),
|
||||
* 代码只认布置的语言(SQL 题放行),流程图评到 A / S 也算 —— 和看板的格子同一个口径。
|
||||
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
|
||||
*/
|
||||
export async function lessonDoneCounts(lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null }[]) {
|
||||
const rows = lessons.flatMap((lesson) =>
|
||||
lesson.problemIds.map((problemId) => ({
|
||||
k: lesson.id,
|
||||
class_name: lesson.className,
|
||||
problem_id: problemId,
|
||||
language: lesson.language,
|
||||
until: lessonDayEnd(lesson.day),
|
||||
})),
|
||||
)
|
||||
if (!rows.length) return new Map<number, number>()
|
||||
const solved = sql.join(
|
||||
SOLVED_RESULTS.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)
|
||||
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)
|
||||
),
|
||||
members as (
|
||||
select p.k, p.problem_id, p.language, 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}`),
|
||||
sql`, `,
|
||||
)})
|
||||
),
|
||||
solved as (
|
||||
select m.k, m.user_id, m.problem_id
|
||||
from members m
|
||||
where 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 (
|
||||
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
|
||||
having count(distinct problem_id) >= (select count(*) from p p2 where p2.k = solved.k)
|
||||
)
|
||||
select k, count(*)::int as done from per_user group by k
|
||||
`)
|
||||
return new Map([...result].map((row) => [row.k, row.done]))
|
||||
}
|
||||
@@ -52,6 +52,12 @@ export function dayNumber(day: string): number {
|
||||
return Date.UTC(year!, month! - 1, date!) / DAY_MS
|
||||
}
|
||||
|
||||
/** 东八区的「哪天几点」(`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()
|
||||
}
|
||||
|
||||
/** 日历日序号 → `YYYY-MM-DD` */
|
||||
export function dayText(day: number): string {
|
||||
return new Date(day * DAY_MS).toISOString().slice(0, 10)
|
||||
|
||||
Reference in new issue
Block a user