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,152 @@
import type {
AttendanceWarning,
AttendanceWarningSeverity,
AttendanceWarningSummary,
} from "./types"
/**
* L-3预警阈值默认值当班级未配置规则时使用
*/
export const DEFAULT_ATTENDANCE_RATE_THRESHOLD = 90
export const DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD = 3
/**
* 出勤率预警的严重等级划分:
* - high低于阈值 10 个百分点以上
* - medium低于阈值 5-10 个百分点
* - low低于阈值 5 个百分点以内
*/
const rateSeverity = (current: number, threshold: number): AttendanceWarningSeverity => {
const diff = threshold - current
if (diff >= 10) return "high"
if (diff >= 5) return "medium"
return "low"
}
/**
* 连续缺勤预警的严重等级划分:
* - high连续缺勤 ≥ 阈值 + 2
* - medium连续缺勤 ≥ 阈值 + 1
* - low连续缺勤 = 阈值
*/
const absenceSeverity = (current: number, threshold: number): AttendanceWarningSeverity => {
if (current >= threshold + 2) return "high"
if (current >= threshold + 1) return "medium"
return "low"
}
/**
* 从按日期升序的状态列表中计算最长连续缺勤段。
* 缺勤定义为 status === "absent"(不含 late/early_leave 等其他异常状态)。
* 返回 { maxStreak, lastStreakDates }:最长连续段及其日期列表。
*/
export const computeConsecutiveAbsence = (
records: { date: string; status: string }[]
): { maxStreak: number; lastStreakDates: string[] } => {
if (records.length === 0) return { maxStreak: 0, lastStreakDates: [] }
// 按日期升序排序
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date))
let maxStreak = 0
let currentStreak = 0
let currentStreakStart = 0
let lastMaxStart = 0
for (let i = 0; i < sorted.length; i++) {
if (sorted[i].status === "absent") {
if (currentStreak === 0) currentStreakStart = i
currentStreak += 1
if (currentStreak > maxStreak) {
maxStreak = currentStreak
lastMaxStart = currentStreakStart
}
} else {
currentStreak = 0
}
}
const lastStreakDates = maxStreak > 0
? sorted.slice(lastMaxStart, lastMaxStart + maxStreak).map((r) => r.date)
: []
return { maxStreak, lastStreakDates }
}
/**
* L-3根据学生出勤率统计和阈值生成预警列表纯函数便于测试
*
* @param studentStats 学生列表,每项含 studentId/studentName/total/present 和按日期升序的记录
* @param attendanceRateThreshold 出勤率阈值(百分比)
* @param consecutiveAbsenceThreshold 连续缺勤阈值(次)
*/
export const computeAttendanceWarnings = (
studentStats: {
studentId: string
studentName: string
total: number
present: number
records: { date: string; status: string }[]
}[],
attendanceRateThreshold: number,
consecutiveAbsenceThreshold: number
): AttendanceWarning[] => {
const warnings: AttendanceWarning[] = []
for (const s of studentStats) {
// 跳过无记录的学生(避免除零,且无记录不触发预警)
if (s.total === 0) continue
const presentRate = Math.round((s.present / s.total) * 10000) / 100
// 出勤率低于阈值
if (presentRate < attendanceRateThreshold) {
warnings.push({
studentId: s.studentId,
studentName: s.studentName,
type: "low_attendance_rate",
severity: rateSeverity(presentRate, attendanceRateThreshold),
currentValue: presentRate,
threshold: attendanceRateThreshold,
relatedDates: [],
})
}
// 连续缺勤达到阈值
const { maxStreak, lastStreakDates } = computeConsecutiveAbsence(s.records)
if (maxStreak >= consecutiveAbsenceThreshold) {
warnings.push({
studentId: s.studentId,
studentName: s.studentName,
type: "consecutive_absence",
severity: absenceSeverity(maxStreak, consecutiveAbsenceThreshold),
currentValue: maxStreak,
threshold: consecutiveAbsenceThreshold,
relatedDates: lastStreakDates,
})
}
}
// 严重等级排序high → medium → low
const severityOrder: Record<AttendanceWarningSeverity, number> = {
high: 0,
medium: 1,
low: 2,
}
warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])
return warnings
}
/**
* 构造空的预警汇总(当班级无数据或无规则时使用)。
*/
export const createEmptyWarningSummary = (
classId: string,
className: string
): AttendanceWarningSummary => ({
classId,
className,
attendanceRateThreshold: DEFAULT_ATTENDANCE_RATE_THRESHOLD,
consecutiveAbsenceThreshold: DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD,
warnings: [],
})