feat(布置作业): 能按知识点点选题目;一个班一天能布置几节课;数据统计认老师布置的课;后台能管布置过的课
Deploy / deploy (push) Canceled after 0s

- 课堂看板「选题」弹框:左边知识点(加必会题),右边搜题号/题名、点选,按点的先后排;
  只列这次作业的语言能交的题(/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:
xuyueandClaude Opus 5.5 committed 2026-11-02 20:41:25 +08:00
1 parent 0916015405
commit b344bd1dd6
26 files changed
+5685 -99

No files matched your search

+155
View File
@@ -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)
})