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

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