feat: enhance textbook reader with anchor text support and improve knowledge point management

This commit is contained in:
SpecialX
2026-01-16 10:22:16 +08:00
parent 9bfc621d3f
commit bb4555f611
44 changed files with 6284 additions and 2090 deletions

View File

@@ -2,7 +2,7 @@ import "server-only";
import { randomInt } from "node:crypto"
import { cache } from "react"
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
@@ -17,6 +17,8 @@ import {
homeworkAssignments,
homeworkSubmissions,
schools,
subjects,
exams,
users,
} from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types"
@@ -169,7 +171,35 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
}))
list.sort(compareClassLike)
return list
// Fetch recent assignments for trends and schedule
const listWithTrends = await Promise.all(
list.map(async (c) => {
const [insights, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
getClassSchedule({ classId: c.id, teacherId }),
])
const recentAssignments = insights
? insights.assignments.map((a) => ({
id: a.assignmentId,
title: a.title,
status: a.status,
subject: a.subject,
isActive: a.isActive,
isOverdue: a.isOverdue,
dueAt: a.dueAt ? new Date(a.dueAt) : null,
submittedCount: a.submittedCount,
targetCount: a.targetCount,
avgScore: a.scoreStats.avg,
medianScore: a.scoreStats.median,
}))
: []
return { ...c, recentAssignments, schedule }
})
)
return listWithTrends
})
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
@@ -752,11 +782,22 @@ export const getClassHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignments = await db.query.homeworkAssignments.findMany({
where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)),
orderBy: [desc(homeworkAssignments.createdAt)],
limit,
})
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
status: homeworkAssignments.status,
createdAt: homeworkAssignments.createdAt,
dueAt: homeworkAssignments.dueAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) {
@@ -845,6 +886,7 @@ export const getClassHomeworkInsights = cache(
assignmentId: a.id,
title: a.title,
status: (a.status as string) ?? "draft",
subject: a.subjectName,
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
@@ -1694,3 +1736,104 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise<void>
await db.delete(classSchedule).where(eq(classSchedule.id, id))
}
export const getStudentsSubjectScores = cache(
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
if (studentIds.length === 0) return new Map()
// 1. Find assignments targeted at these students
const assignmentTargets = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
if (assignmentIds.length === 0) return new Map()
// 2. Get assignment details including subject from linked exam
const assignments = await db
.select({
id: homeworkAssignments.id,
createdAt: homeworkAssignments.createdAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(
inArray(homeworkAssignments.id, assignmentIds),
eq(homeworkAssignments.status, "published")
))
.orderBy(desc(homeworkAssignments.createdAt))
// 3. Filter subjects (exclude PE, Music, Art)
const excludeSubjects = ["体育", "音乐", "美术"]
const subjectAssignments = new Map<string, string>() // subject -> assignmentId (latest)
for (const a of assignments) {
if (!a.subjectName) continue
if (excludeSubjects.includes(a.subjectName)) continue
if (!subjectAssignments.has(a.subjectName)) {
subjectAssignments.set(a.subjectName, a.id)
}
}
const targetAssignmentIds = Array.from(subjectAssignments.values())
if (targetAssignmentIds.length === 0) return new Map()
// 4. Get submissions for these assignments
const submissions = await db
.select({
studentId: homeworkSubmissions.studentId,
assignmentId: homeworkSubmissions.assignmentId,
score: homeworkSubmissions.score,
createdAt: homeworkSubmissions.createdAt,
})
.from(homeworkSubmissions)
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
.orderBy(desc(homeworkSubmissions.createdAt))
// 5. Map back to subject scores per student
const studentScores = new Map<string, Record<string, number | null>>()
// Create reverse map for assignment -> subject
const assignmentSubjectMap = new Map<string, string>()
for (const [subject, id] of subjectAssignments.entries()) {
assignmentSubjectMap.set(id, subject)
}
for (const s of submissions) {
const subject = assignmentSubjectMap.get(s.assignmentId)
if (!subject) continue
if (!studentScores.has(s.studentId)) {
studentScores.set(s.studentId, {})
}
const scores = studentScores.get(s.studentId)!
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
if (scores[subject] === undefined) {
scores[subject] = s.score
}
}
return studentScores
}
)
export const getClassStudentSubjectScoresV2 = cache(
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
// 1. Get student IDs in the class
const enrollments = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(
eq(classEnrollments.classId, classId),
eq(classEnrollments.status, "active")
))
const studentIds = enrollments.map(e => e.studentId)
return getStudentsSubjectScores(studentIds)
}
)