Files
NextEdu/src/modules/grades/data-access-ranking.ts
SpecialX 95145cd03b feat(grades): add ranking trend, school-wide summary, score cell, and scope filter
- Add ranking-trend-card and school-wide-summary-card for broader analytics

- Add score-cell and grade-filters components for table rendering

- Add scope-filter and type-guards lib utilities for grade data filtering

- Update actions, data-access (analytics, ranking, main), stats-service, export

- Update schema, types, and grade-utils lib

- Update all grade chart and report components (distribution, trend, comparison, query)
2026-06-23 17:37:32 +08:00

191 lines
6.0 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 } from "drizzle-orm"
import { db } from "@/shared/db"
import { gradeRecords } from "@/shared/db/schema"
import { getStudentActiveClassId } from "@/modules/classes/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import type { DataScope } from "@/shared/types/permissions"
import { normalize, toNumber } from "./lib/grade-utils"
import { buildScopeClassFilter } from "./lib/scope-filter"
import { buildRankingTrendPoints, type RankingTrendEntry } from "./stats-service"
import type {
ClassAverageTrendPoint,
ClassAverageTrendResult,
RankingTrendResult,
} from "./types"
/**
* v3-P2-2: 班级平均成绩趋势点
* 与 RankingTrendPoint 对齐(按 title 分组),用于在学生趋势图上叠加班级平均对比线。
*/
export type { ClassAverageTrendPoint, ClassAverageTrendResult }
/**
* Get a student's ranking trend across assessments within their class.
* Each point represents one assessment (grouped by title), with the
* student's normalized score, rank, and total participants.
*
* P3 修复:添加 scope 参数,对 class_taught scope 校验学生归属
*/
export const getRankingTrend = cache(
async (
studentId: string,
subjectId?: string,
semester?: "1" | "2",
scope?: DataScope
): Promise<RankingTrendResult | null> => {
// P3 修复:对 class_taught scope 校验学生是否属于教师所教的班级
if (scope?.type === "class_taught") {
const allowedClassIds = new Set(scope.classIds)
const studentClassId = await getStudentActiveClassId(studentId)
if (studentClassId && !allowedClassIds.has(studentClassId)) {
return null
}
}
const studentNameMap = await getUserNamesByIds([studentId])
const studentInfo = studentNameMap.get(studentId)
if (!studentInfo) return null
const studentName = studentInfo.name ?? "Unknown"
const classId = await getStudentActiveClassId(studentId)
if (!classId) {
return {
studentId,
studentName,
points: [],
}
}
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (semester) conditions.push(eq(gradeRecords.semester, semester))
// 应用 scope 过滤
if (scope) {
const scopeFilter = buildScopeClassFilter(scope, studentId)
if (scopeFilter) conditions.push(scopeFilter)
}
const rows = await db
.select({
title: gradeRecords.title,
createdAt: gradeRecords.createdAt,
studentId: gradeRecords.studentId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
const byTitle = new Map<string, RankingTrendEntry>()
for (const r of rows) {
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
entry.entries.push({
studentId: r.studentId,
normalized: normalize(toNumber(r.score), toNumber(r.fullScore)),
})
byTitle.set(r.title, entry)
}
const points = buildRankingTrendPoints(byTitle, studentId)
return {
studentId,
studentName,
points,
}
}
)
/**
* v3-P2-2: 获取班级平均成绩趋势(按 assessment title 分组)。
*
* 用于在学生个人成绩趋势图上叠加"班级平均"对比线,让学生/家长能直观
* 看到个人与班级整体的差距。与 `getRankingTrend` 共享相同的过滤条件
* 与分组逻辑,确保两条线的 X 轴对齐。
*
* @param studentId 目标学生 ID用于定位其所在班级
* @param subjectId 可选科目过滤
* @param semester 可选学期过滤
* @param scope 数据权限范围
*/
export const getClassAverageTrend = cache(
async (
studentId: string,
subjectId?: string,
semester?: "1" | "2",
scope?: DataScope
): Promise<ClassAverageTrendResult | null> => {
// 对 class_taught scope 校验学生归属
if (scope?.type === "class_taught") {
const allowedClassIds = new Set(scope.classIds)
const studentClassId = await getStudentActiveClassId(studentId)
if (studentClassId && !allowedClassIds.has(studentClassId)) {
return null
}
}
const classId = await getStudentActiveClassId(studentId)
if (!classId) return null
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (semester) conditions.push(eq(gradeRecords.semester, semester))
if (scope) {
const scopeFilter = buildScopeClassFilter(scope, studentId)
if (scopeFilter) conditions.push(scopeFilter)
}
const rows = await db
.select({
title: gradeRecords.title,
createdAt: gradeRecords.createdAt,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
// 按 title 分组,计算每次 assessment 的班级平均分normalized 0-100
const byTitle = new Map<string, { date: Date; scores: number[] }>()
for (const r of rows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
const entry = byTitle.get(r.title) ?? { date: r.createdAt, scores: [] }
entry.scores.push(normalized)
byTitle.set(r.title, entry)
}
const points: ClassAverageTrendPoint[] = []
for (const [title, entry] of byTitle.entries()) {
if (entry.scores.length === 0) continue
const sum = entry.scores.reduce((acc, s) => acc + s, 0)
const avg = Math.round((sum / entry.scores.length) * 100) / 100
points.push({
title,
date: entry.date.toISOString(),
averageScore: avg,
studentCount: entry.scores.length,
})
}
points.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
)
return {
classId,
points,
}
}
)