- 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)
191 lines
6.0 KiB
TypeScript
191 lines
6.0 KiB
TypeScript
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,
|
||
}
|
||
}
|
||
)
|