feat(attendance): add correlation, trend, warnings, report print, and services

- Add attendance-grade-correlation-card and data-access-correlation, correlation-compute

- Add attendance-trend-chart and trend-compute for trend analysis

- Add attendance-warnings-card and warning-compute for attendance warnings

- Add attendance-report-print for printable reports

- Add class-comparison-card for class attendance comparison

- Add notifications and services directory
This commit is contained in:
SpecialX
2026-07-03 10:24:16 +08:00
parent 7567f317e1
commit 048fc1c386
25 changed files with 3220 additions and 256 deletions

View File

@@ -0,0 +1,210 @@
/**
* L-9 考勤与成绩关联分析:纯函数实现。
*
* 与 IO 解耦,便于单测。包含:
* - Pearson 相关系数计算
* - 风险等级分类
* - 相关系数解释
* - 班级汇总聚合
*
* 参考 warning-compute.ts / trend-compute.ts 的纯函数模式。
*/
import type {
AttendanceGradeCorrelationItem,
AttendanceGradeCorrelationSummary,
AttendanceGradeRiskLevel,
} from "./types"
/** 风险阈值:出勤率(百分比)。 */
export const RISK_ATTENDANCE_HIGH_THRESHOLD = 80
export const RISK_ATTENDANCE_MEDIUM_THRESHOLD = 90
/** 风险阈值成绩0-100 归一化分数)。 */
export const RISK_SCORE_HIGH_THRESHOLD = 60
export const RISK_SCORE_MEDIUM_THRESHOLD = 75
/** Pearson 相关系数解释阈值(绝对值)。 */
const CORRELATION_STRONG_THRESHOLD = 0.7
const CORRELATION_WEAK_THRESHOLD = 0.3
/** 相关系数解释类型(与 types.ts 中保持一致)。 */
export type CorrelationInterpretation =
| "strong_negative"
| "weak_negative"
| "negligible"
| "weak_positive"
| "strong_positive"
| "insufficient_data"
/**
* 计算 Pearson 相关系数。
*
* @param x 自变量数组(如出勤率)
* @param y 因变量数组(如平均成绩)
* @returns r ∈ [-1, +1];数据不足(<2 个点)或方差为 0 时返回 null
*/
export function computePearsonCorrelation(
x: readonly number[],
y: readonly number[]
): number | null {
if (x.length !== y.length) return null
if (x.length < 2) return null
const n = x.length
let sumX = 0
let sumY = 0
let sumXY = 0
let sumX2 = 0
let sumY2 = 0
for (let i = 0; i < n; i++) {
const xi = x[i]
const yi = y[i]
if (!Number.isFinite(xi) || !Number.isFinite(yi)) return null
sumX += xi
sumY += yi
sumXY += xi * yi
sumX2 += xi * xi
sumY2 += yi * yi
}
const numerator = n * sumXY - sumX * sumY
const denominator = Math.sqrt(
(n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY)
)
if (denominator === 0) return null
// 限制到 [-1, 1] 防止浮点误差溢出
return Math.max(-1, Math.min(1, numerator / denominator))
}
/**
* 根据出勤率和平均成绩判定风险等级。
* - high低出勤<80%)且低分(<60%
* - medium中低出勤<90%)且中低分(<75%),但未达 high
* - low其余正常
*/
export function classifyRiskLevel(
attendanceRate: number,
averageScore: number
): AttendanceGradeRiskLevel {
const isLowAttendance = attendanceRate < RISK_ATTENDANCE_HIGH_THRESHOLD
const isMediumAttendance =
attendanceRate < RISK_ATTENDANCE_MEDIUM_THRESHOLD && !isLowAttendance
const isLowScore = averageScore < RISK_SCORE_HIGH_THRESHOLD
const isMediumScore =
averageScore < RISK_SCORE_MEDIUM_THRESHOLD && !isLowScore
if (isLowAttendance && isLowScore) return "high"
if ((isLowAttendance || isMediumAttendance) && (isLowScore || isMediumScore))
return "medium"
return "low"
}
/**
* 解释 Pearson 相关系数。
* - |r| >= 0.7:强相关
* - 0.3 <= |r| < 0.7:弱相关
* - |r| < 0.3:可忽略
*/
export function interpretCorrelation(
r: number | null
): CorrelationInterpretation {
if (r === null) return "insufficient_data"
const abs = Math.abs(r)
if (abs >= CORRELATION_STRONG_THRESHOLD) {
return r > 0 ? "strong_positive" : "strong_negative"
}
if (abs >= CORRELATION_WEAK_THRESHOLD) {
return r > 0 ? "weak_positive" : "weak_negative"
}
return "negligible"
}
/** 风险等级排序权重用于降序排列high > medium > low。 */
const RISK_ORDER: Record<AttendanceGradeRiskLevel, number> = {
high: 0,
medium: 1,
low: 2,
}
/**
* 聚合班级考勤-成绩关联汇总(纯函数)。
*
* 输入每个学生的原始数据studentId, studentName, attendanceRate, averageScore, 计数)。
* 输出:含 Pearson 相关系数、风险分级、排序后的 items。
*
* 排除无考勤或无成绩记录的学生(视为数据不全,不参与关联分析)。
*/
export function computeCorrelationSummary(
classId: string,
className: string,
startDate: string,
endDate: string,
rawItems: ReadonlyArray<{
studentId: string
studentName: string
attendanceRate: number
attendanceRecordCount: number
absentCount: number
averageScore: number
gradeRecordCount: number
}>
): AttendanceGradeCorrelationSummary {
// 过滤掉无考勤或无成绩记录的学生
const validItems = rawItems.filter(
(it) => it.attendanceRecordCount > 0 && it.gradeRecordCount > 0
)
const items: AttendanceGradeCorrelationItem[] = validItems.map((it) => ({
studentId: it.studentId,
studentName: it.studentName,
attendanceRate: round2(it.attendanceRate),
attendanceRecordCount: it.attendanceRecordCount,
absentCount: it.absentCount,
averageScore: round2(it.averageScore),
gradeRecordCount: it.gradeRecordCount,
riskLevel: classifyRiskLevel(it.attendanceRate, it.averageScore),
}))
// 排序:风险等级降序 → 出勤率升序(高风险学生排在最前)
items.sort((a, b) => {
const riskDiff = RISK_ORDER[a.riskLevel] - RISK_ORDER[b.riskLevel]
if (riskDiff !== 0) return riskDiff
return a.attendanceRate - b.attendanceRate
})
// Pearson 相关系数x=出勤率y=平均成绩
const correlation = computePearsonCorrelation(
items.map((it) => it.attendanceRate),
items.map((it) => it.averageScore)
)
const riskCounts = {
high: items.filter((it) => it.riskLevel === "high").length,
medium: items.filter((it) => it.riskLevel === "medium").length,
low: items.filter((it) => it.riskLevel === "low").length,
}
return {
classId,
className,
startDate,
endDate,
items,
correlation: correlation !== null ? round4(correlation) : null,
correlationInterpretation: interpretCorrelation(correlation),
riskCounts,
}
}
/** 保留两位小数。 */
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/** 保留四位小数(相关系数精度)。 */
function round4(n: number): number {
return Math.round(n * 10000) / 10000
}