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)
This commit is contained in:
@@ -7,24 +7,46 @@ 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"
|
||||
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
|
||||
@@ -44,6 +66,12 @@ export const getRankingTrend = cache(
|
||||
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,
|
||||
@@ -76,3 +104,87 @@ export const getRankingTrend = cache(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user