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 相关零错误
208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
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
|
||
})
|