Files
NextEdu/src/modules/textbooks/data-access-graph.ts
SpecialX e2e0487a3b feat(attendance,elective): 实现所有 P2 长期改进项
P2 修复(来自审计报告):
- 2.4.4: Server Action 错误消息 i18n 化(attendance/elective 全部 Action)
- 2.5.3: 抽取 AttendancePageLayout 组件复用(admin/teacher 页面)
- 2.5.4: 抽取 ElectivePageLayout 组件复用(admin/teacher 列表页)
- 2.6.3: 考勤月历键盘导航(tabIndex + 方向键 + Home/End + role=grid)
- 2.8.2: getStudentAttendanceSummary 分页优化(SQL 聚合统计 + LIMIT 分页)
- 2.8.3: resolveCourseDisplayNames 缓存优化(React cache 去重)
- 2.1.4: elective data-access 跨模块依赖接口抽象(resolvers.ts 可注入)

P2 建议项:
- 选课时间冲突检测(parseSchedule + isScheduleConflict 纯函数 + checkScheduleConflict)
- 学分上限校验(MAX_CREDIT_PER_TERM + checkCreditLimit)
- 考勤/选课数据导出 Excel(export.ts + API 路由扩展)

新增文件:
- src/modules/attendance/components/attendance-page-layout.tsx
- src/modules/elective/components/elective-page-layout.tsx
- src/modules/elective/resolvers.ts
- src/modules/attendance/export.ts
- src/modules/elective/export.ts

校验:
- npm run lint 通过(exit 0)
- npx tsc --noEmit attendance/elective/parent 相关零错误
2026-06-23 09:02:41 +08:00

208 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only"
import { cache } from "react"
import { and, asc, eq, inArray, sql, count } from "drizzle-orm"
import { db } from "@/shared/db"
import {
chapters,
knowledgePoints,
knowledgePointPrerequisites,
questionsToKnowledgePoints,
knowledgePointMastery,
} from "@/shared/db/schema"
import type { KpWithRelations, MasteryInfo } from "./types"
/**
* 获取教材下全书知识点(含前置依赖 + 关联题目数 + 章节标题)。
*
* 一次查询聚合,避免 N+1。
*/
export const getKnowledgePointsWithRelations = cache(async (
textbookId: string,
): Promise<KpWithRelations[]> => {
// 1. 查询全书知识点 + 章节标题
const kpRows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
description: knowledgePoints.description,
parentId: knowledgePoints.parentId,
chapterId: knowledgePoints.chapterId,
level: knowledgePoints.level,
order: knowledgePoints.order,
chapterTitle: chapters.title,
})
.from(knowledgePoints)
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(eq(chapters.textbookId, textbookId))
.orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name))
if (kpRows.length === 0) return []
const kpIds = kpRows.map((r) => r.id)
// 2. 查询关联题目数(批量聚合)
const questionCountRows = await db
.select({
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
count: count(),
})
.from(questionsToKnowledgePoints)
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
.groupBy(questionsToKnowledgePoints.knowledgePointId)
const questionCountMap = new Map<string, number>()
for (const r of questionCountRows) {
questionCountMap.set(r.knowledgePointId, Number(r.count))
}
// 3. 查询前置依赖(批量,仅查询属于当前教材知识点的依赖)
// 双向过滤knowledgePointId 和 prerequisiteKpId 都必须在当前教材的知识点集合内
const prereqRows = await db
.select({
knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
})
.from(knowledgePointPrerequisites)
.where(and(
inArray(knowledgePointPrerequisites.knowledgePointId, kpIds),
inArray(knowledgePointPrerequisites.prerequisiteKpId, kpIds),
))
const prereqMap = new Map<string, string[]>()
for (const r of prereqRows) {
const arr = prereqMap.get(r.knowledgePointId) ?? []
arr.push(r.prerequisiteKpId)
prereqMap.set(r.knowledgePointId, arr)
}
// 4. 组装结果
return kpRows.map((r) => ({
id: r.id,
name: r.name,
description: r.description,
parentId: r.parentId,
chapterId: r.chapterId,
level: r.level ?? 0,
order: r.order ?? 0,
chapterTitle: r.chapterTitle,
questionCount: questionCountMap.get(r.id) ?? 0,
prerequisiteIds: prereqMap.get(r.id) ?? [],
}))
})
/**
* 获取学生在某教材下所有知识点的掌握度。
*/
export const getStudentKpMastery = cache(async (
studentId: string,
textbookId: string,
): Promise<Map<string, MasteryInfo>> => {
const rows = await db
.select({
knowledgePointId: knowledgePointMastery.knowledgePointId,
masteryLevel: knowledgePointMastery.masteryLevel,
totalQuestions: knowledgePointMastery.totalQuestions,
correctQuestions: knowledgePointMastery.correctQuestions,
lastAssessedAt: knowledgePointMastery.lastAssessedAt,
})
.from(knowledgePointMastery)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(and(
eq(knowledgePointMastery.studentId, studentId),
eq(chapters.textbookId, textbookId),
))
const map = new Map<string, MasteryInfo>()
for (const r of rows) {
map.set(r.knowledgePointId, {
masteryLevel: Number(r.masteryLevel),
totalQuestions: r.totalQuestions,
correctQuestions: r.correctQuestions,
lastAssessedAt: r.lastAssessedAt,
})
}
return map
})
/**
* 获取班级(教师所带班级的所有学生)在某教材下知识点的平均掌握度。
*
* @param studentIds 班级学生 ID 列表
* @param textbookId 教材 ID
*/
export const getClassKpMastery = cache(async (
studentIds: string[],
textbookId: string,
): Promise<Map<string, MasteryInfo>> => {
if (studentIds.length === 0) return new Map()
const rows = await db
.select({
knowledgePointId: knowledgePointMastery.knowledgePointId,
avgMastery: sql<number>`AVG(${knowledgePointMastery.masteryLevel})`,
totalQuestions: sql<number>`SUM(${knowledgePointMastery.totalQuestions})`,
correctQuestions: sql<number>`SUM(${knowledgePointMastery.correctQuestions})`,
lastAssessedAt: sql<Date>`MAX(${knowledgePointMastery.lastAssessedAt})`,
})
.from(knowledgePointMastery)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(and(
inArray(knowledgePointMastery.studentId, studentIds),
eq(chapters.textbookId, textbookId),
))
.groupBy(knowledgePointMastery.knowledgePointId)
const map = new Map<string, MasteryInfo>()
for (const r of rows) {
map.set(r.knowledgePointId, {
masteryLevel: Number(r.avgMastery),
totalQuestions: Number(r.totalQuestions),
correctQuestions: Number(r.correctQuestions),
lastAssessedAt: r.lastAssessedAt,
})
}
return map
})
/**
* 获取某个知识点的前置依赖列表(含知识点详情)。
*/
export const getPrerequisitesForKp = cache(async (
kpId: string,
): Promise<{ id: string; name: string; description: string | null }[]> => {
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
description: knowledgePoints.description,
})
.from(knowledgePointPrerequisites)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.prerequisiteKpId))
.where(eq(knowledgePointPrerequisites.knowledgePointId, kpId))
return rows
})
/**
* 获取某个知识点的后置知识点列表(即哪些知识点以此 KP 为前置)。
*/
export const getSuccessorsForKp = cache(async (
kpId: string,
): Promise<{ id: string; name: string; description: string | null }[]> => {
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
description: knowledgePoints.description,
})
.from(knowledgePointPrerequisites)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.knowledgePointId))
.where(eq(knowledgePointPrerequisites.prerequisiteKpId, kpId))
return rows
})