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:
SpecialX
2026-06-23 17:37:32 +08:00
parent 2197e68069
commit 95145cd03b
32 changed files with 3202 additions and 682 deletions

View File

@@ -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,
}
}
)