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:
@@ -23,6 +23,7 @@ import {
|
||||
getAttendanceRecordClassId,
|
||||
upsertAttendanceRules,
|
||||
} from "./data-access"
|
||||
import { notifyParentsOfAbsence } from "./notifications"
|
||||
|
||||
/**
|
||||
* 校验当前用户对考勤记录的归属权限。
|
||||
@@ -79,6 +80,8 @@ export async function recordAttendanceAction(
|
||||
classId: formData.get("classId"),
|
||||
date: formData.get("date"),
|
||||
status: formData.get("status"),
|
||||
// L-6:节次考勤,未传时由 Zod schema default("full_day") 兜底
|
||||
period: formData.get("period") || undefined,
|
||||
remark: formData.get("remark") || undefined,
|
||||
scheduleId: formData.get("scheduleId") || undefined,
|
||||
})
|
||||
@@ -139,6 +142,23 @@ export async function batchRecordAttendanceAction(
|
||||
targetType: "attendance_record",
|
||||
properties: { count, classId: parsed.data.records[0]?.classId },
|
||||
})
|
||||
|
||||
// L-2: 考勤记录保存成功后,异步通知家长(失败不阻断主流程)
|
||||
const classId = parsed.data.records[0]?.classId
|
||||
if (classId) {
|
||||
await notifyParentsOfAbsence(
|
||||
classId,
|
||||
parsed.data.records.map((r) => ({
|
||||
studentId: r.studentId,
|
||||
status: r.status,
|
||||
date: r.date,
|
||||
reason: r.reason ?? null,
|
||||
})),
|
||||
).catch(() => {
|
||||
// 通知失败已被 notifyParentsOfAbsence 内部捕获,此处兜底防止未预期异常影响主流程
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, message: t("messages.batchRecorded", { count }), data: count }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
@@ -161,6 +181,8 @@ export async function updateAttendanceAction(
|
||||
|
||||
const parsed = UpdateAttendanceSchema.safeParse({
|
||||
status: formData.get("status") || undefined,
|
||||
// L-6:节次考勤,支持更新节次
|
||||
period: formData.get("period") || undefined,
|
||||
remark: formData.get("remark") || undefined,
|
||||
scheduleId: formData.get("scheduleId") || undefined,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
CartesianGrid,
|
||||
Scatter,
|
||||
ScatterChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ZAxis,
|
||||
Cell,
|
||||
} from "recharts"
|
||||
import { TrendingDown, AlertTriangle, CheckCircle2, Inbox } from "lucide-react"
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import type {
|
||||
AttendanceGradeCorrelationSummary,
|
||||
AttendanceGradeRiskLevel,
|
||||
} from "../types"
|
||||
|
||||
/** 风险等级对应的颜色(散点图着色,与 attendance-sheet 调色板对齐)。 */
|
||||
const RISK_COLORS: Record<AttendanceGradeRiskLevel, string> = {
|
||||
high: "#ef4444", // red-500
|
||||
medium: "#f59e0b", // amber-500
|
||||
low: "#10b981", // emerald-500
|
||||
}
|
||||
|
||||
/** 风险等级对应的 Badge variant。 */
|
||||
const RISK_BADGE_VARIANTS: Record<
|
||||
AttendanceGradeRiskLevel,
|
||||
"destructive" | "default" | "secondary"
|
||||
> = {
|
||||
high: "destructive",
|
||||
medium: "default",
|
||||
low: "secondary",
|
||||
}
|
||||
|
||||
/** 风险等级图标。 */
|
||||
const RISK_ICONS: Record<AttendanceGradeRiskLevel, typeof AlertTriangle> = {
|
||||
high: TrendingDown,
|
||||
medium: AlertTriangle,
|
||||
low: CheckCircle2,
|
||||
}
|
||||
|
||||
/** 风险等级图标颜色(Tailwind 类名,与 attendance-sheet 调色板对齐)。 */
|
||||
const RISK_ICON_CLASSNAMES: Record<AttendanceGradeRiskLevel, string> = {
|
||||
high: "text-red-500",
|
||||
medium: "text-amber-500",
|
||||
low: "text-emerald-500",
|
||||
}
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
students: {
|
||||
label: "Students",
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* L-9 考勤与成绩关联分析卡片。
|
||||
*
|
||||
* 包含:
|
||||
* - 顶部汇总:Pearson 相关系数 + 解释 + 风险学生数
|
||||
* - 散点图:x=出勤率,y=平均成绩,按风险等级着色
|
||||
* - 风险学生表格:列出所有学生的关联数据,按风险降序
|
||||
*/
|
||||
export function AttendanceGradeCorrelationCard({
|
||||
summary,
|
||||
}: {
|
||||
summary: AttendanceGradeCorrelationSummary | null
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
|
||||
const scatterData = useMemo(() => {
|
||||
if (!summary) return []
|
||||
return summary.items.map((it) => ({
|
||||
studentId: it.studentId,
|
||||
studentName: it.studentName,
|
||||
attendanceRate: it.attendanceRate,
|
||||
averageScore: it.averageScore,
|
||||
riskLevel: it.riskLevel,
|
||||
attendanceRecordCount: it.attendanceRecordCount,
|
||||
gradeRecordCount: it.gradeRecordCount,
|
||||
absentCount: it.absentCount,
|
||||
}))
|
||||
}, [summary])
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("correlation.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title={t("correlation.noData")}
|
||||
description={t("correlation.noDataDescription")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const interpretationLabel = t(
|
||||
`correlation.interpretation.${summary.correlationInterpretation}`
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("correlation.title")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("correlation.description", {
|
||||
className: summary.className,
|
||||
start: summary.startDate,
|
||||
end: summary.endDate,
|
||||
})}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 顶部汇总指标 */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<SummaryStat
|
||||
label={t("correlation.correlationCoefficient")}
|
||||
value={
|
||||
summary.correlation !== null
|
||||
? summary.correlation.toFixed(4)
|
||||
: t("correlation.notAvailable")
|
||||
}
|
||||
sublabel={interpretationLabel}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("correlation.riskCounts.high")}
|
||||
value={String(summary.riskCounts.high)}
|
||||
icon={TrendingDown}
|
||||
iconClassName={RISK_ICON_CLASSNAMES.high}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("correlation.riskCounts.medium")}
|
||||
value={String(summary.riskCounts.medium)}
|
||||
icon={AlertTriangle}
|
||||
iconClassName={RISK_ICON_CLASSNAMES.medium}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("correlation.riskCounts.low")}
|
||||
value={String(summary.riskCounts.low)}
|
||||
icon={CheckCircle2}
|
||||
iconClassName={RISK_ICON_CLASSNAMES.low}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 散点图 */}
|
||||
{scatterData.length > 0 ? (
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium">
|
||||
{t("correlation.scatterTitle")}
|
||||
</h4>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[320px] w-full"
|
||||
>
|
||||
<ScatterChart
|
||||
margin={{ top: 16, right: 16, bottom: 32, left: 16 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.4}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="attendanceRate"
|
||||
name={t("correlation.attendanceRate")}
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
label={{
|
||||
value: t("correlation.attendanceRate"),
|
||||
position: "bottom",
|
||||
offset: 16,
|
||||
style: { fontSize: 12, fill: "hsl(var(--muted-foreground))" },
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="averageScore"
|
||||
name={t("correlation.averageScore")}
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={40}
|
||||
/>
|
||||
<ZAxis type="number" range={[60, 60]} />
|
||||
<ChartTooltip
|
||||
cursor={{ strokeDasharray: "3 3" }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
hideLabel
|
||||
formatter={(
|
||||
_value: unknown,
|
||||
_name: unknown,
|
||||
item: { payload?: unknown }
|
||||
) => {
|
||||
const data = item?.payload as
|
||||
| (typeof scatterData)[number]
|
||||
| undefined
|
||||
if (!data) return null
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{data.studentName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("correlation.attendanceRate")}:{" "}
|
||||
{data.attendanceRate}%
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("correlation.averageScore")}:{" "}
|
||||
{data.averageScore}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("correlation.absentCount")}:{" "}
|
||||
{data.absentCount}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Scatter
|
||||
name={t("correlation.scatterSeries")}
|
||||
data={scatterData}
|
||||
shape="circle"
|
||||
>
|
||||
{scatterData.map((entry) => (
|
||||
<Cell
|
||||
key={entry.studentId}
|
||||
fill={RISK_COLORS[entry.riskLevel]}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ChartContainer>
|
||||
{/* 风险等级图例 */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs">
|
||||
{(["high", "medium", "low"] as const).map((level) => (
|
||||
<div key={level} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: RISK_COLORS[level] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t(`correlation.riskLevel.${level}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title={t("correlation.noStudents")}
|
||||
description={t("correlation.noStudentsDescription")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 学生明细表格 */}
|
||||
{summary.items.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium">
|
||||
{t("correlation.studentDetails")}
|
||||
</h4>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("list.columns.student")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("correlation.attendanceRate")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("correlation.absentCount")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("correlation.averageScore")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("correlation.gradeRecordCount")}
|
||||
</TableHead>
|
||||
<TableHead>{t("correlation.riskLevelLabel")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{summary.items.map((item) => {
|
||||
const Icon = RISK_ICONS[item.riskLevel]
|
||||
return (
|
||||
<TableRow key={item.studentId}>
|
||||
<TableCell className="font-medium">
|
||||
{item.studentName}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{item.attendanceRate}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{item.absentCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{item.averageScore}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{item.gradeRecordCount}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={RISK_BADGE_VARIANTS[item.riskLevel]}>
|
||||
<Icon className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
{t(`correlation.riskLevel.${item.riskLevel}`)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** 汇总统计项(label + value + 可选图标/副标题)。 */
|
||||
function SummaryStat({
|
||||
label,
|
||||
value,
|
||||
sublabel,
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
sublabel?: string
|
||||
icon?: typeof AlertTriangle
|
||||
iconClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn("h-4 w-4", iconClassName)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-semibold tabular-nums">{value}</p>
|
||||
{sublabel && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{sublabel}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,17 +26,31 @@ import {
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { resolveActionError } from "@/shared/lib/resolve-action-error"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { deleteAttendanceAction } from "../actions"
|
||||
import {
|
||||
ATTENDANCE_STATUS_BADGE_VARIANTS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type { AttendanceListItem } from "../types"
|
||||
import type { AttendanceListItem, AttendancePeriod } from "../types"
|
||||
|
||||
/** L-6:节次 i18n key 映射(与 attendance-sheet 一致)。 */
|
||||
const ATTENDANCE_PERIOD_LABEL_KEYS: Record<AttendancePeriod, string> = {
|
||||
full_day: "period.full_day",
|
||||
morning_reading: "period.morning_reading",
|
||||
morning: "period.morning",
|
||||
afternoon: "period.afternoon",
|
||||
evening: "period.evening",
|
||||
}
|
||||
|
||||
export function AttendanceRecordList({ records }: { records: AttendanceListItem[] }) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("attendance")
|
||||
const { hasPermission } = usePermission()
|
||||
const canManage = hasPermission(Permissions.ATTENDANCE_MANAGE)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
@@ -50,7 +64,7 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
setDeleteId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
toast.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
@@ -79,7 +93,10 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
<TableHead>{t("list.columns.student")}</TableHead>
|
||||
<TableHead>{t("list.columns.class")}</TableHead>
|
||||
<TableHead>{t("list.columns.date")}</TableHead>
|
||||
{/* L-6:节次列,null 视为 full_day */}
|
||||
<TableHead>{t("period.label")}</TableHead>
|
||||
<TableHead>{t("list.columns.status")}</TableHead>
|
||||
<TableHead>{t("list.columns.reason")}</TableHead>
|
||||
<TableHead>{t("list.columns.remark")}</TableHead>
|
||||
<TableHead>{t("list.columns.recorder")}</TableHead>
|
||||
<TableHead>{t("list.columns.createdAt")}</TableHead>
|
||||
@@ -87,34 +104,46 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
{records.map((r) => {
|
||||
// L-6:节次 null 视为 full_day,对外统一展示
|
||||
const periodValue: AttendancePeriod = r.period ?? "full_day"
|
||||
return (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.studentName}</TableCell>
|
||||
<TableCell>{r.className}</TableCell>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{t(ATTENDANCE_PERIOD_LABEL_KEYS[periodValue])}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ATTENDANCE_STATUS_BADGE_VARIANTS[r.status]} className="capitalize">
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-muted-foreground">
|
||||
{r.reason ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-muted-foreground">
|
||||
{r.remark ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
aria-label={t("actions.delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
aria-label={t("actions.delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -124,7 +153,7 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sheet.confirmDelete")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("errors.unexpected")}
|
||||
{t("sheet.confirmDelete")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
283
src/modules/attendance/components/attendance-report-print.tsx
Normal file
283
src/modules/attendance/components/attendance-report-print.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation"
|
||||
import { Printer, FileText } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import { ATTENDANCE_STATUS_LABEL_KEYS } from "@/shared/constants/attendance-status"
|
||||
import type { ClassAttendanceSummary } from "../types"
|
||||
|
||||
interface AttendanceReportPrintProps {
|
||||
summary: ClassAttendanceSummary | null
|
||||
classes: Array<{ id: string; name: string }>
|
||||
currentClassId: string
|
||||
currentClassName: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
reportType: "weekly" | "monthly"
|
||||
}
|
||||
|
||||
export function AttendanceReportPrint({
|
||||
summary,
|
||||
classes,
|
||||
currentClassId,
|
||||
currentClassName,
|
||||
startDate,
|
||||
endDate,
|
||||
reportType,
|
||||
}: AttendanceReportPrintProps) {
|
||||
const t = useTranslations("attendance")
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [isPrinting, setIsPrinting] = useState(false)
|
||||
|
||||
const handleParamChange = (key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (value) {
|
||||
params.set(key, value)
|
||||
} else {
|
||||
params.delete(key)
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handlePrint = () => {
|
||||
setIsPrinting(true)
|
||||
// 使用 setTimeout 确保 UI 更新后再触发打印
|
||||
setTimeout(() => {
|
||||
window.print()
|
||||
setIsPrinting(false)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 控制面板(打印时隐藏) */}
|
||||
<Card className="print:hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("report.controls")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="report-class">{t("filters.class")}</Label>
|
||||
<Select value={currentClassId} onValueChange={(v) => handleParamChange("classId", v)}>
|
||||
<SelectTrigger id="report-class">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="report-type">{t("report.type")}</Label>
|
||||
<Select
|
||||
value={reportType}
|
||||
onValueChange={(v) => handleParamChange("reportType", v)}
|
||||
>
|
||||
<SelectTrigger id="report-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="weekly">{t("report.types.weekly")}</SelectItem>
|
||||
<SelectItem value="monthly">{t("report.types.monthly")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="report-start">{t("report.startDate")}</Label>
|
||||
<Input
|
||||
id="report-start"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => handleParamChange("startDate", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="report-end">{t("report.endDate")}</Label>
|
||||
<Input
|
||||
id="report-end"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => handleParamChange("endDate", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handlePrint} disabled={isPrinting || !summary} className="gap-2">
|
||||
<Printer className="h-4 w-4" aria-hidden="true" />
|
||||
{isPrinting ? t("report.printing") : t("report.print")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 打印区域(屏幕上也可见,但样式针对打印优化) */}
|
||||
{!summary || summary.studentRecords.length === 0 ? (
|
||||
<Card className="print:hidden">
|
||||
<CardContent className="pt-6">
|
||||
<EmptyState
|
||||
title={t("report.noData")}
|
||||
description={t("report.noDataDescription")}
|
||||
icon={FileText}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="report-card space-y-6">
|
||||
{/* 报告头部 */}
|
||||
<div className="border-b pb-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{currentClassName} - {t(`report.types.${reportType}`)}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("report.period")}: {startDate || summary.date} ~ {endDate || summary.date}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("report.generatedAt")}: {new Date().toISOString().slice(0, 10)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 统计汇总 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">{t("report.summary")}</h2>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm sm:grid-cols-6">
|
||||
<div className="border rounded p-2">
|
||||
<div className="text-muted-foreground">{t("stats.totalRecords")}</div>
|
||||
<div className="text-lg font-bold">{summary.stats.total}</div>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
<div className="text-muted-foreground">{t("stats.present")}</div>
|
||||
<div className="text-lg font-bold text-green-600">{summary.stats.present}</div>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
<div className="text-muted-foreground">{t("stats.absent")}</div>
|
||||
<div className="text-lg font-bold text-red-600">{summary.stats.absent}</div>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
<div className="text-muted-foreground">{t("stats.late")}</div>
|
||||
<div className="text-lg font-bold text-yellow-600">{summary.stats.late}</div>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
<div className="text-muted-foreground">{t("stats.earlyLeave")}</div>
|
||||
<div className="text-lg font-bold text-blue-600">{summary.stats.earlyLeave}</div>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
<div className="text-muted-foreground">{t("stats.attendanceRate")}</div>
|
||||
<div className="text-lg font-bold">{summary.stats.presentRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 学生明细 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">{t("report.studentDetails")}</h2>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>{t("list.columns.student")}</TableHead>
|
||||
<TableHead>{t("list.columns.date")}</TableHead>
|
||||
<TableHead>{t("list.columns.status")}</TableHead>
|
||||
<TableHead>{t("list.columns.reason")}</TableHead>
|
||||
<TableHead>{t("list.columns.remark")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{summary.studentRecords.map((r, idx) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="tabular-nums">{idx + 1}</TableCell>
|
||||
<TableCell className="font-medium">{r.studentName}</TableCell>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn(
|
||||
"inline-block rounded px-2 py-0.5 text-xs font-medium",
|
||||
r.status === "present" && "bg-green-100 text-green-700",
|
||||
r.status === "absent" && "bg-red-100 text-red-700",
|
||||
r.status === "late" && "bg-yellow-100 text-yellow-700",
|
||||
r.status === "early_leave" && "bg-blue-100 text-blue-700",
|
||||
r.status === "excused" && "bg-purple-100 text-purple-700",
|
||||
r.status === "school_activity" && "bg-cyan-100 text-cyan-700",
|
||||
)}>
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{r.reason ?? "-"}</TableCell>
|
||||
<TableCell>{r.remark ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 家长签字单 */}
|
||||
<div className="border-t-2 border-dashed pt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">{t("report.parentSignature")}</h2>
|
||||
<div className="rounded border p-4">
|
||||
<p className="mb-4 text-sm">
|
||||
{t("report.parentSignatureNotice")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-muted-foreground">{t("report.parentName")}</div>
|
||||
<div className="border-b border-dashed pb-1"> </div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-muted-foreground">{t("report.signature")}</div>
|
||||
<div className="border-b border-dashed pb-1"> </div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-muted-foreground">{t("report.relationship")}</div>
|
||||
<div className="border-b border-dashed pb-1"> </div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-muted-foreground">{t("report.signDate")}</div>
|
||||
<div className="border-b border-dashed pb-1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 text-sm text-muted-foreground">{t("report.parentComment")}</div>
|
||||
<div className="border border-dashed rounded h-20"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 报告页脚 */}
|
||||
<div className="border-t pt-2 text-xs text-muted-foreground">
|
||||
<p>{t("report.footer")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { resolveActionError } from "@/shared/lib/resolve-action-error"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -47,6 +48,8 @@ export function AttendanceRulesForm({
|
||||
const [lateThreshold, setLateThreshold] = useState("15")
|
||||
const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15")
|
||||
const [enableAutoMark, setEnableAutoMark] = useState(false)
|
||||
const [attendanceRateThreshold, setAttendanceRateThreshold] = useState("90")
|
||||
const [consecutiveAbsenceThreshold, setConsecutiveAbsenceThreshold] = useState("3")
|
||||
|
||||
const handleClassChange = (id: string) => {
|
||||
setClassId(id)
|
||||
@@ -55,10 +58,14 @@ export function AttendanceRulesForm({
|
||||
setLateThreshold(String(rule.lateThresholdMinutes ?? 15))
|
||||
setEarlyLeaveThreshold(String(rule.earlyLeaveThresholdMinutes ?? 15))
|
||||
setEnableAutoMark(rule.enableAutoMark ?? false)
|
||||
setAttendanceRateThreshold(String(rule.attendanceRateThreshold ?? 90))
|
||||
setConsecutiveAbsenceThreshold(String(rule.consecutiveAbsenceThreshold ?? 3))
|
||||
} else {
|
||||
setLateThreshold("15")
|
||||
setEarlyLeaveThreshold("15")
|
||||
setEnableAutoMark(false)
|
||||
setAttendanceRateThreshold("90")
|
||||
setConsecutiveAbsenceThreshold("3")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +78,8 @@ export function AttendanceRulesForm({
|
||||
formData.set("lateThresholdMinutes", lateThreshold)
|
||||
formData.set("earlyLeaveThresholdMinutes", earlyLeaveThreshold)
|
||||
formData.set("enableAutoMark", enableAutoMark ? "true" : "false")
|
||||
formData.set("attendanceRateThreshold", attendanceRateThreshold)
|
||||
formData.set("consecutiveAbsenceThreshold", consecutiveAbsenceThreshold)
|
||||
|
||||
try {
|
||||
const result = await saveAttendanceRulesAction(null, formData)
|
||||
@@ -78,7 +87,7 @@ export function AttendanceRulesForm({
|
||||
toast.success(result.message || t("rules.saved"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
toast.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
@@ -142,6 +151,32 @@ export function AttendanceRulesForm({
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 border-t pt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="attendanceRateThreshold">{t("rules.attendanceRateThreshold")}</Label>
|
||||
<Input
|
||||
id="attendanceRateThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={attendanceRateThreshold}
|
||||
onChange={(e) => setAttendanceRateThreshold(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("rules.attendanceRateThresholdHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="consecutiveAbsenceThreshold">{t("rules.consecutiveAbsenceThreshold")}</Label>
|
||||
<Input
|
||||
id="consecutiveAbsenceThreshold"
|
||||
type="number"
|
||||
min="1"
|
||||
value={consecutiveAbsenceThreshold}
|
||||
onChange={(e) => setConsecutiveAbsenceThreshold(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("rules.consecutiveAbsenceThresholdHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
{t("actions.cancel")}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react"
|
||||
import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText, School } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -37,30 +37,37 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { resolveActionError } from "@/shared/lib/resolve-action-error"
|
||||
|
||||
import { batchRecordAttendanceAction } from "../actions"
|
||||
import {
|
||||
ATTENDANCE_STATUS_OPTIONS,
|
||||
ATTENDANCE_STATUS_SHORTCUTS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
createInitialStatusCounts,
|
||||
type AttendanceStatus,
|
||||
} from "../constants"
|
||||
import type { AttendancePeriod } from "../types"
|
||||
|
||||
type Option = { id: string; name: string }
|
||||
type Student = { id: string; name: string; email: string }
|
||||
|
||||
const STATUS_OPTIONS: AttendanceStatus[] = [
|
||||
"present",
|
||||
"absent",
|
||||
"late",
|
||||
"early_leave",
|
||||
"excused",
|
||||
/** L-6:节次选项(顺序即选择器中的展示顺序)。 */
|
||||
const ATTENDANCE_PERIOD_OPTIONS: AttendancePeriod[] = [
|
||||
"full_day",
|
||||
"morning_reading",
|
||||
"morning",
|
||||
"afternoon",
|
||||
"evening",
|
||||
]
|
||||
|
||||
const STATUS_SHORTCUTS: Record<string, AttendanceStatus> = {
|
||||
p: "present",
|
||||
a: "absent",
|
||||
l: "late",
|
||||
e: "early_leave",
|
||||
x: "excused",
|
||||
/** L-6:节次对应的 i18n key 后缀。 */
|
||||
const ATTENDANCE_PERIOD_LABEL_KEYS: Record<AttendancePeriod, string> = {
|
||||
full_day: "period.full_day",
|
||||
morning_reading: "period.morning_reading",
|
||||
morning: "period.morning",
|
||||
afternoon: "period.afternoon",
|
||||
evening: "period.evening",
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<AttendanceStatus, { active: string; icon: typeof CheckCircle2 }> = {
|
||||
@@ -69,18 +76,17 @@ const STATUS_STYLES: Record<AttendanceStatus, { active: string; icon: typeof Che
|
||||
late: { active: "bg-amber-500 text-white border-amber-500 hover:bg-amber-600", icon: Clock },
|
||||
early_leave: { active: "bg-blue-500 text-white border-blue-500 hover:bg-blue-600", icon: LogOut },
|
||||
excused: { active: "bg-purple-500 text-white border-purple-500 hover:bg-purple-600", icon: FileText },
|
||||
school_activity: { active: "bg-cyan-500 text-white border-cyan-500 hover:bg-cyan-600", icon: School },
|
||||
}
|
||||
|
||||
/** 初始化状态计数,避免 `{} as Record<...>` 类型断言 */
|
||||
function createInitialStatusCounts(): Record<AttendanceStatus, number> {
|
||||
return {
|
||||
present: 0,
|
||||
absent: 0,
|
||||
late: 0,
|
||||
early_leave: 0,
|
||||
excused: 0,
|
||||
}
|
||||
}
|
||||
/** 需要填写原因的状态(缺勤/迟到/早退/请假/校内活动)。 */
|
||||
const STATUSES_REQUIRING_REASON: ReadonlySet<AttendanceStatus> = new Set([
|
||||
"absent",
|
||||
"late",
|
||||
"early_leave",
|
||||
"excused",
|
||||
"school_activity",
|
||||
])
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
@@ -108,22 +114,40 @@ export function AttendanceSheet({
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
||||
const [date, setDate] = useState(defaultDate ?? today)
|
||||
// L-6:节次默认 full_day(向后兼容已有数据)
|
||||
const [period, setPeriod] = useState<AttendancePeriod>("full_day")
|
||||
const [statuses, setStatuses] = useState<Record<string, AttendanceStatus>>({})
|
||||
const [reasons, setReasons] = useState<Record<string, string>>({})
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [focusedStudentIndex, setFocusedStudentIndex] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showSwitchConfirm, setShowSwitchConfirm] = useState(false)
|
||||
const [pendingClassId, setPendingClassId] = useState<string | null>(null)
|
||||
const studentRefs = useRef<(HTMLTableRowElement | null)[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleStatusChange = useCallback((studentId: string, status: AttendanceStatus) => {
|
||||
setStatuses((prev) => ({ ...prev, [studentId]: status }))
|
||||
// 切回 present 时清除原因(present 不需要原因)
|
||||
if (status === "present") {
|
||||
setReasons((prev) => {
|
||||
if (!prev[studentId]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[studentId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleReasonChange = useCallback((studentId: string, reason: string) => {
|
||||
setReasons((prev) => ({ ...prev, [studentId]: reason }))
|
||||
}, [])
|
||||
|
||||
const markAllPresent = useCallback(() => {
|
||||
const all: Record<string, AttendanceStatus> = {}
|
||||
for (const s of students) all[s.id] = "present"
|
||||
setStatuses(all)
|
||||
setReasons({})
|
||||
toast.success(t("actions.markAllPresent"))
|
||||
}, [students, t])
|
||||
|
||||
@@ -140,6 +164,7 @@ export function AttendanceSheet({
|
||||
const confirmClassSwitch = (newClassId: string) => {
|
||||
setClassId(newClassId)
|
||||
setStatuses({})
|
||||
setReasons({})
|
||||
const newUrl = newClassId ? `/teacher/attendance/sheet?classId=${encodeURIComponent(newClassId)}` : "/teacher/attendance/sheet"
|
||||
router.push(newUrl)
|
||||
}
|
||||
@@ -148,12 +173,16 @@ export function AttendanceSheet({
|
||||
(s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const statusCounts = STATUS_OPTIONS.reduce(
|
||||
(acc, st) => {
|
||||
acc[st] = students.filter((s) => (statuses[s.id] ?? "present") === st).length
|
||||
return acc
|
||||
},
|
||||
createInitialStatusCounts()
|
||||
const statusCounts = useMemo(
|
||||
() =>
|
||||
ATTENDANCE_STATUS_OPTIONS.reduce(
|
||||
(acc, st) => {
|
||||
acc[st] = students.filter((s) => (statuses[s.id] ?? "present") === st).length
|
||||
return acc
|
||||
},
|
||||
createInitialStatusCounts()
|
||||
),
|
||||
[students, statuses]
|
||||
)
|
||||
|
||||
// 派生值:当筛选结果变少时,焦点索引自动夹紧到有效范围,避免 useEffect 重置导致的级联渲染
|
||||
@@ -162,12 +191,23 @@ export function AttendanceSheet({
|
||||
: Math.min(focusedStudentIndex, filteredStudents.length - 1)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
// P2-7 修复:限制监听范围到容器内,并排除所有可交互元素
|
||||
const target = e.target
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const key = e.key.toLowerCase()
|
||||
if (STATUS_SHORTCUTS[key] && filteredStudents[effectiveFocusedIndex]) {
|
||||
if (ATTENDANCE_STATUS_SHORTCUTS[key] && filteredStudents[effectiveFocusedIndex]) {
|
||||
e.preventDefault()
|
||||
handleStatusChange(filteredStudents[effectiveFocusedIndex].id, STATUS_SHORTCUTS[key])
|
||||
handleStatusChange(filteredStudents[effectiveFocusedIndex].id, ATTENDANCE_STATUS_SHORTCUTS[key])
|
||||
if (effectiveFocusedIndex < filteredStudents.length - 1) {
|
||||
setFocusedStudentIndex((prev) => prev + 1)
|
||||
}
|
||||
@@ -181,8 +221,8 @@ export function AttendanceSheet({
|
||||
setFocusedStudentIndex((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
container.addEventListener("keydown", handleKeyDown)
|
||||
return () => container.removeEventListener("keydown", handleKeyDown)
|
||||
}, [filteredStudents, effectiveFocusedIndex, handleStatusChange])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -195,12 +235,22 @@ export function AttendanceSheet({
|
||||
return
|
||||
}
|
||||
|
||||
const records = students.map((s) => ({
|
||||
studentId: s.id,
|
||||
classId,
|
||||
date,
|
||||
status: statuses[s.id] ?? "present",
|
||||
}))
|
||||
const records = students.map((s) => {
|
||||
const status = statuses[s.id] ?? "present"
|
||||
return {
|
||||
studentId: s.id,
|
||||
classId,
|
||||
date,
|
||||
status,
|
||||
// L-6:节次考勤,提交时携带当前选中的节次
|
||||
period,
|
||||
// 仅当状态需要原因且原因非空时携带(present 不需要原因)
|
||||
reason:
|
||||
STATUSES_REQUIRING_REASON.has(status) && reasons[s.id]
|
||||
? reasons[s.id].slice(0, 255)
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
if (records.length === 0) {
|
||||
toast.error(t("sheet.noStudents"))
|
||||
@@ -217,7 +267,7 @@ export function AttendanceSheet({
|
||||
router.push("/teacher/attendance")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
toast.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
@@ -232,7 +282,7 @@ export function AttendanceSheet({
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
{t("sheet.saved")}...
|
||||
{t("sheet.saving")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -244,7 +294,7 @@ export function AttendanceSheet({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="class-select">{t("filters.class")}</Label>
|
||||
<Select value={classId} onValueChange={handleClassChange}>
|
||||
@@ -276,6 +326,23 @@ export function AttendanceSheet({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* L-6:节次选择器,允许按节次记录考勤 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="period-select">{t("period.label")}</Label>
|
||||
<Select value={period} onValueChange={(v) => setPeriod(v as AttendancePeriod)}>
|
||||
<SelectTrigger id="period-select">
|
||||
<SelectValue placeholder={t("period.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ATTENDANCE_PERIOD_OPTIONS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{t(ATTENDANCE_PERIOD_LABEL_KEYS[p])}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{students.length === 0 ? (
|
||||
@@ -286,7 +353,7 @@ export function AttendanceSheet({
|
||||
<>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{STATUS_OPTIONS.map((st) => {
|
||||
{ATTENDANCE_STATUS_OPTIONS.map((st) => {
|
||||
const Icon = STATUS_STYLES[st].icon
|
||||
return (
|
||||
<span key={st} className="inline-flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-1 text-xs">
|
||||
@@ -314,13 +381,13 @@ export function AttendanceSheet({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<div ref={containerRef} className="rounded-md border" tabIndex={-1}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>{t("list.columns.student")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("list.columns.remark")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("list.columns.reason")}</TableHead>
|
||||
<TableHead>{t("list.columns.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -328,6 +395,7 @@ export function AttendanceSheet({
|
||||
{filteredStudents.map((s, idx) => {
|
||||
const currentStatus = statuses[s.id] ?? "present"
|
||||
const isFocused = idx === effectiveFocusedIndex
|
||||
const needsReason = STATUSES_REQUIRING_REASON.has(currentStatus)
|
||||
return (
|
||||
<TableRow
|
||||
key={s.id}
|
||||
@@ -339,11 +407,28 @@ export function AttendanceSheet({
|
||||
aria-label={s.name}
|
||||
>
|
||||
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">{s.email}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span>{s.name}</span>
|
||||
<span className="text-xs text-muted-foreground md:hidden">{s.email}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Input
|
||||
type="text"
|
||||
value={reasons[s.id] ?? ""}
|
||||
onChange={(e) => handleReasonChange(s.id, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!needsReason}
|
||||
placeholder={needsReason ? t("sheet.reasonPlaceholder") : ""}
|
||||
maxLength={255}
|
||||
className="h-8 text-sm"
|
||||
aria-label={t("list.columns.reason")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{STATUS_OPTIONS.map((st) => {
|
||||
{ATTENDANCE_STATUS_OPTIONS.map((st) => {
|
||||
const Icon = STATUS_STYLES[st].icon
|
||||
const isActive = currentStatus === st
|
||||
return (
|
||||
@@ -392,9 +477,9 @@ export function AttendanceSheet({
|
||||
<AlertDialog open={showSwitchConfirm} onOpenChange={setShowSwitchConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("sheet.confirmDelete")}</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("sheet.confirmClassSwitch")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("description.teacherRecords")}
|
||||
{t("sheet.confirmClassSwitch")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -408,7 +493,7 @@ export function AttendanceSheet({
|
||||
setShowSwitchConfirm(false)
|
||||
}}
|
||||
>
|
||||
{t("actions.save")}
|
||||
{t("sheet.confirmClassSwitchAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Clock,
|
||||
LogOut,
|
||||
FileText,
|
||||
School,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
} from "lucide-react"
|
||||
@@ -65,6 +66,11 @@ export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }
|
||||
value={stats.excused}
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label={t("stats.schoolActivity")}
|
||||
value={stats.schoolActivity}
|
||||
icon={<School className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label={t("stats.attendanceRate")}
|
||||
value={`${stats.presentRate.toFixed(1)}%`}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react"
|
||||
import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText, School } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { AttendanceStats } from "../types"
|
||||
|
||||
interface AttendanceStatsCardsProps {
|
||||
stats: {
|
||||
totalRecords: number
|
||||
presentCount: number
|
||||
absentCount: number
|
||||
lateCount: number
|
||||
earlyLeaveCount: number
|
||||
excusedCount: number
|
||||
attendanceRate: number
|
||||
}
|
||||
stats: AttendanceStats
|
||||
}
|
||||
|
||||
export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
|
||||
@@ -21,42 +14,49 @@ export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: t("stats.totalRecords"),
|
||||
value: stats.totalRecords,
|
||||
value: stats.total,
|
||||
icon: FileText,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.present"),
|
||||
value: stats.presentCount,
|
||||
value: stats.present,
|
||||
icon: CheckCircle2,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.absent"),
|
||||
value: stats.absentCount,
|
||||
value: stats.absent,
|
||||
icon: XCircle,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.late"),
|
||||
value: stats.lateCount,
|
||||
value: stats.late,
|
||||
icon: Clock,
|
||||
color: "text-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.earlyLeave"),
|
||||
value: stats.earlyLeaveCount,
|
||||
value: stats.earlyLeave,
|
||||
icon: LogOut,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.schoolActivity"),
|
||||
value: stats.schoolActivity,
|
||||
icon: School,
|
||||
color: "text-cyan-500",
|
||||
bgColor: "bg-cyan-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.attendanceRate"),
|
||||
value: `${stats.attendanceRate}%`,
|
||||
value: `${stats.presentRate.toFixed(1)}%`,
|
||||
icon: Users,
|
||||
color: "text-primary",
|
||||
bgColor: "bg-primary/10",
|
||||
@@ -64,7 +64,7 @@ export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title} className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
|
||||
108
src/modules/attendance/components/attendance-trend-chart.tsx
Normal file
108
src/modules/attendance/components/attendance-trend-chart.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation"
|
||||
import { TrendingUp } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { TrendLineChart, type TrendLineSeries } from "@/shared/components/charts/trend-line-chart"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import { formatBucketLabel, type TrendGranularity } from "../trend-compute"
|
||||
import type { AttendanceTrendSummary } from "../types"
|
||||
|
||||
interface AttendanceTrendChartProps {
|
||||
summary: AttendanceTrendSummary | null
|
||||
/** 当前粒度(从 searchParams 传入)。 */
|
||||
granularity: TrendGranularity
|
||||
}
|
||||
|
||||
const GRANULARITIES: TrendGranularity[] = ["daily", "weekly", "monthly"]
|
||||
|
||||
export function AttendanceTrendChart({
|
||||
summary,
|
||||
granularity,
|
||||
}: AttendanceTrendChartProps) {
|
||||
const t = useTranslations("attendance.trend")
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!summary) return []
|
||||
return summary.points.map((p) => ({
|
||||
title: formatBucketLabel(p.date, summary.granularity),
|
||||
fullTitle: formatBucketLabel(p.date, summary.granularity),
|
||||
presentRate: p.presentRate,
|
||||
lateRate: p.lateRate,
|
||||
absentRate: p.absentRate,
|
||||
total: p.total,
|
||||
}))
|
||||
}, [summary])
|
||||
|
||||
const series: TrendLineSeries[] = [
|
||||
{ dataKey: "presentRate", name: t("series.presentRate"), color: "hsl(var(--chart-1))" },
|
||||
{ dataKey: "lateRate", name: t("series.lateRate"), color: "hsl(var(--chart-2))" },
|
||||
{ dataKey: "absentRate", name: t("series.absentRate"), color: "hsl(var(--chart-3))" },
|
||||
]
|
||||
|
||||
const handleGranularityChange = (g: TrendGranularity) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("granularity", g)
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
{t("title")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{GRANULARITIES.map((g) => (
|
||||
<Button
|
||||
key={g}
|
||||
type="button"
|
||||
variant={granularity === g ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn("h-7 px-2 text-xs")}
|
||||
onClick={() => handleGranularityChange(g)}
|
||||
>
|
||||
{t(`granularity.${g}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description", {
|
||||
start: summary ? formatBucketLabel(summary.startDate, summary.granularity) : "-",
|
||||
end: summary ? formatBucketLabel(summary.endDate, summary.granularity) : "-",
|
||||
})}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!summary || summary.points.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("noData")}
|
||||
description={t("noDataDescription")}
|
||||
icon={TrendingUp}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={series}
|
||||
xKey="title"
|
||||
yDomain={[0, 100]}
|
||||
heightClassName="h-[320px]"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
119
src/modules/attendance/components/attendance-warnings-card.tsx
Normal file
119
src/modules/attendance/components/attendance-warnings-card.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { AlertTriangle, TrendingDown, CalendarX } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import type {
|
||||
AttendanceWarning,
|
||||
AttendanceWarningSeverity,
|
||||
AttendanceWarningSummary,
|
||||
} from "../types"
|
||||
|
||||
const SEVERITY_STYLES: Record<AttendanceWarningSeverity, {
|
||||
badge: string
|
||||
row: string
|
||||
}> = {
|
||||
high: {
|
||||
badge: "bg-red-500/10 text-red-700 border-red-500/30",
|
||||
row: "border-l-4 border-l-red-500 bg-red-50/50",
|
||||
},
|
||||
medium: {
|
||||
badge: "bg-amber-500/10 text-amber-700 border-amber-500/30",
|
||||
row: "border-l-4 border-l-amber-500 bg-amber-50/50",
|
||||
},
|
||||
low: {
|
||||
badge: "bg-yellow-500/10 text-yellow-700 border-yellow-500/30",
|
||||
row: "border-l-4 border-l-yellow-500 bg-yellow-50/50",
|
||||
},
|
||||
}
|
||||
|
||||
const WARNING_ICONS = {
|
||||
low_attendance_rate: TrendingDown,
|
||||
consecutive_absence: CalendarX,
|
||||
} as const
|
||||
|
||||
function WarningRow({ warning }: { warning: AttendanceWarning }) {
|
||||
const t = useTranslations("attendance.warnings")
|
||||
const Icon = WARNING_ICONS[warning.type]
|
||||
const style = SEVERITY_STYLES[warning.severity]
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2 rounded-md p-3 sm:flex-row sm:items-center sm:justify-between", style.row)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{warning.studentName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(`types.${warning.type}`, {
|
||||
current: warning.currentValue,
|
||||
threshold: warning.threshold,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{warning.relatedDates.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("dates")}: {warning.relatedDates.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="outline" className={style.badge}>
|
||||
{t(`severity.${warning.severity}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AttendanceWarningsCard({ summary }: { summary: AttendanceWarningSummary | null }) {
|
||||
const t = useTranslations("attendance.warnings")
|
||||
|
||||
if (!summary || summary.warnings.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
{t("title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
title={t("noWarnings")}
|
||||
description={t("noWarningsDescription")}
|
||||
icon={AlertTriangle}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
{t("title")}
|
||||
</span>
|
||||
<Badge variant="secondary">{summary.warnings.length}</Badge>
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("thresholdSummary", {
|
||||
rate: summary.attendanceRateThreshold,
|
||||
absence: summary.consecutiveAbsenceThreshold,
|
||||
})}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{summary.warnings.map((w, idx) => (
|
||||
<WarningRow key={`${w.studentId}-${w.type}-${idx}`} warning={w} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
163
src/modules/attendance/components/class-comparison-card.tsx
Normal file
163
src/modules/attendance/components/class-comparison-card.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation"
|
||||
import { GitCompare } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { SimpleBarChart, type BarSeries } from "@/shared/components/charts/simple-bar-chart"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import type { ClassComparisonSummary } from "../types"
|
||||
|
||||
interface ClassComparisonCardProps {
|
||||
summary: ClassComparisonSummary | null
|
||||
/** 可选年级列表(用于切换年级)。 */
|
||||
grades?: Array<{ id: string; name: string }>
|
||||
/** 当前选中的年级 ID。 */
|
||||
currentGradeId?: string
|
||||
}
|
||||
|
||||
export function ClassComparisonCard({
|
||||
summary,
|
||||
grades,
|
||||
currentGradeId,
|
||||
}: ClassComparisonCardProps) {
|
||||
const t = useTranslations("attendance.comparison")
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!summary) return []
|
||||
return summary.items.map((i) => ({
|
||||
name: i.className,
|
||||
presentRate: i.presentRate,
|
||||
lateRate: i.lateRate,
|
||||
absentRate: i.absentRate,
|
||||
}))
|
||||
}, [summary])
|
||||
|
||||
const bars: BarSeries[] = [
|
||||
{ dataKey: "presentRate", name: t("series.presentRate"), color: "hsl(var(--chart-1))" },
|
||||
]
|
||||
|
||||
const handleGradeChange = (gradeId: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("gradeId", gradeId)
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}
|
||||
|
||||
// 标记班级是否高于/低于年级平均
|
||||
const getRateBadge = (rate: number, average: number) => {
|
||||
if (rate > average + 1) {
|
||||
return <Badge variant="outline" className="bg-green-500/10 text-green-700 border-green-500/30">{t("aboveAverage")}</Badge>
|
||||
}
|
||||
if (rate < average - 1) {
|
||||
return <Badge variant="outline" className="bg-red-500/10 text-red-700 border-red-500/30">{t("belowAverage")}</Badge>
|
||||
}
|
||||
return <Badge variant="outline" className="bg-muted text-muted-foreground">{t("average")}</Badge>
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<GitCompare className="h-5 w-5" />
|
||||
{t("title")}
|
||||
</span>
|
||||
{grades && grades.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{grades.map((g) => (
|
||||
<Button
|
||||
key={g.id}
|
||||
type="button"
|
||||
variant={currentGradeId === g.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn("h-7 px-2 text-xs")}
|
||||
onClick={() => handleGradeChange(g.id)}
|
||||
>
|
||||
{g.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
{summary && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description", {
|
||||
grade: summary.gradeName,
|
||||
avg: summary.averagePresentRate,
|
||||
start: summary.startDate,
|
||||
end: summary.endDate,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!summary || summary.items.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("noData")}
|
||||
description={t("noDataDescription")}
|
||||
icon={GitCompare}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={bars}
|
||||
xKey="name"
|
||||
yDomain={[0, 100]}
|
||||
yTickFormatter={(v) => `${v}%`}
|
||||
heightClassName="h-[280px]"
|
||||
/>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>{t("columns.class")}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.total")}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.presentRate")}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.lateRate")}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.absentRate")}</TableHead>
|
||||
<TableHead className="text-center">{t("columns.badge")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{summary.items.map((item, idx) => (
|
||||
<TableRow key={item.classId}>
|
||||
<TableCell className="font-medium tabular-nums">{idx + 1}</TableCell>
|
||||
<TableCell>{item.className}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{item.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{item.presentRate.toFixed(1)}%</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{item.lateRate.toFixed(1)}%</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{item.absentRate.toFixed(1)}%</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{getRateBadge(item.presentRate, summary.averagePresentRate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +1,19 @@
|
||||
/**
|
||||
* 考勤模块共享常量(消除 types.ts / attendance-sheet.tsx / attendance-filters.tsx / parent-attendance-calendar.tsx 重复定义)。
|
||||
* 考勤模块常量。
|
||||
*
|
||||
* 考勤状态相关的类型与视觉映射已下沉至 `@/shared/constants/attendance-status`,
|
||||
* 由 attendance / parent 等模块共享消费,消除跨模块常量依赖(P1-3 修复)。
|
||||
* 本文件仅做重导出,保持 attendance 模块内部代码的导入路径不变。
|
||||
*
|
||||
* 注意:标签使用 i18n key(`status.*`),由组件层通过 `useTranslations("attendance")` 解析。
|
||||
*/
|
||||
import type { AttendanceStatus } from "./types"
|
||||
|
||||
/** 重导出类型,便于组件层从 constants 单点导入(消除 types/constants 双源混乱) */
|
||||
export type { AttendanceStatus } from "./types"
|
||||
|
||||
/** 考勤状态选项(顺序即 UI 展示顺序) */
|
||||
export const ATTENDANCE_STATUS_OPTIONS: AttendanceStatus[] = [
|
||||
"present",
|
||||
"absent",
|
||||
"late",
|
||||
"early_leave",
|
||||
"excused",
|
||||
]
|
||||
|
||||
/** 考勤状态 → i18n key 映射(组件层 `t(key)` 解析) */
|
||||
export const ATTENDANCE_STATUS_LABEL_KEYS: Record<AttendanceStatus, string> = {
|
||||
present: "status.present",
|
||||
absent: "status.absent",
|
||||
late: "status.late",
|
||||
early_leave: "status.early_leave",
|
||||
excused: "status.excused",
|
||||
}
|
||||
|
||||
/** 考勤状态 → Badge variant 映射 */
|
||||
export const ATTENDANCE_STATUS_BADGE_VARIANTS: Record<AttendanceStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
present: "default",
|
||||
absent: "destructive",
|
||||
late: "secondary",
|
||||
early_leave: "outline",
|
||||
excused: "outline",
|
||||
}
|
||||
|
||||
/** 考勤状态 → Tailwind 圆点颜色类 */
|
||||
export const ATTENDANCE_STATUS_DOT_COLORS: Record<AttendanceStatus, string> = {
|
||||
present: "bg-green-500",
|
||||
absent: "bg-red-500",
|
||||
late: "bg-yellow-500",
|
||||
early_leave: "bg-blue-500",
|
||||
excused: "bg-purple-500",
|
||||
}
|
||||
|
||||
/** 键盘快捷键映射 */
|
||||
export const ATTENDANCE_STATUS_SHORTCUTS: Record<string, AttendanceStatus> = {
|
||||
p: "present",
|
||||
a: "absent",
|
||||
l: "late",
|
||||
e: "early_leave",
|
||||
x: "excused",
|
||||
}
|
||||
|
||||
/** 初始化状态计数,避免 `{} as Record<...>` 类型断言 */
|
||||
export function createInitialStatusCounts(): Record<AttendanceStatus, number> {
|
||||
return {
|
||||
present: 0,
|
||||
absent: 0,
|
||||
late: 0,
|
||||
early_leave: 0,
|
||||
excused: 0,
|
||||
}
|
||||
}
|
||||
export type { AttendanceStatus } from "@/shared/constants/attendance-status"
|
||||
export {
|
||||
ATTENDANCE_STATUS_OPTIONS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
ATTENDANCE_STATUS_BADGE_VARIANTS,
|
||||
ATTENDANCE_STATUS_DOT_COLORS,
|
||||
ATTENDANCE_STATUS_SHORTCUTS,
|
||||
createInitialStatusCounts,
|
||||
isAttendanceStatus,
|
||||
} from "@/shared/constants/attendance-status"
|
||||
|
||||
210
src/modules/attendance/correlation-compute.ts
Normal file
210
src/modules/attendance/correlation-compute.ts
Normal 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
|
||||
}
|
||||
205
src/modules/attendance/data-access-correlation.ts
Normal file
205
src/modules/attendance/data-access-correlation.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, count, eq, gte, lte, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { attendanceRecords } from "@/shared/db/schema"
|
||||
import {
|
||||
getClassActiveStudentsWithInfo,
|
||||
getClassNameById,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getGradeRecords } from "@/modules/grades/data-access"
|
||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { computeCorrelationSummary } from "./correlation-compute"
|
||||
import type { AttendanceGradeCorrelationSummary } from "./types"
|
||||
|
||||
/**
|
||||
* L-9 考勤与成绩关联分析:跨模块数据访问层。
|
||||
*
|
||||
* 数据流:
|
||||
* 1. 通过 classes/data-access 获取班级学生列表
|
||||
* 2. 通过 attendance/data-access(本模块)SQL 聚合查询每个学生的出勤统计
|
||||
* 3. 通过 grades/data-access 获取该班级所有成绩记录,按学生聚合归一化平均分
|
||||
* 4. 调用纯函数 computeCorrelationSummary 计算相关系数和风险分级
|
||||
*
|
||||
* 跨模块通信严格遵循项目规则:通过对方 data-access 函数,不直接查询对方 DB 表。
|
||||
* 唯一例外是 attendance 模块自身的 attendanceRecords 表(本模块内查询)。
|
||||
*
|
||||
* 注意:gradeRecords 表查询通过 grades/data-access.getGradeRecords 完成。
|
||||
*/
|
||||
|
||||
/** 默认时间范围:最近 90 天(无显式日期时使用)。 */
|
||||
const DEFAULT_RANGE_DAYS = 90
|
||||
|
||||
/**
|
||||
* 获取班级考勤-成绩关联分析汇总。
|
||||
*
|
||||
* @param classId 班级 ID
|
||||
* @param startDate 起始日期(YYYY-MM-DD,可选)
|
||||
* @param endDate 结束日期(YYYY-MM-DD,可选)
|
||||
* @param scope 数据范围(用于权限校验)
|
||||
* @returns 关联分析汇总;班级不存在或无学生返回 null
|
||||
*/
|
||||
export async function getAttendanceGradeCorrelation(
|
||||
classId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
scope?: DataScope
|
||||
): Promise<AttendanceGradeCorrelationSummary | null> {
|
||||
// Step 1: 委托 classes data-access 获取班级名称与学生列表
|
||||
const className = await getClassNameById(classId)
|
||||
if (!className) return null
|
||||
|
||||
// scope 校验:teacher scope 仅能查询自己任教的班级(class_taught)
|
||||
if (scope && scope.type === "class_taught") {
|
||||
if (!scope.classIds.includes(classId)) return null
|
||||
}
|
||||
// scope 校验:parent scope 仅能查询自己子女所在班级(class_members)
|
||||
// scope 校验:student scope(owned)不应访问班级级分析(返回 null)
|
||||
if (scope && scope.type === "owned") return null
|
||||
|
||||
const students = await getClassActiveStudentsWithInfo(classId)
|
||||
if (students.length === 0) return null
|
||||
|
||||
const studentIds = students.map((s) => s.id)
|
||||
const studentNameMap = new Map(students.map((s) => [s.id, s.name]))
|
||||
|
||||
// 默认时间范围:最近 90 天
|
||||
const today = new Date()
|
||||
const effectiveEndDate = endDate ?? today.toISOString().slice(0, 10)
|
||||
const effectiveStartDate =
|
||||
startDate ??
|
||||
new Date(today.getTime() - DEFAULT_RANGE_DAYS * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
|
||||
// Step 2: 查询班级在时间范围内的考勤记录,按学生聚合
|
||||
// 复用 attendance 模块自身的 attendanceRecords 表(本模块内查询合规)
|
||||
const attendanceConditions = [
|
||||
eq(attendanceRecords.classId, classId),
|
||||
gte(attendanceRecords.date, safeParseDate(effectiveStartDate, "startDate")),
|
||||
lte(attendanceRecords.date, safeParseDate(effectiveEndDate, "endDate")),
|
||||
]
|
||||
const attendanceWhere = and(...attendanceConditions)
|
||||
|
||||
const attendanceRows = await db
|
||||
.select({
|
||||
studentId: attendanceRecords.studentId,
|
||||
total: count(),
|
||||
present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
|
||||
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
absent: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
|
||||
earlyLeave: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(attendanceWhere)
|
||||
.groupBy(attendanceRecords.studentId)
|
||||
|
||||
const attendanceMap = new Map<
|
||||
string,
|
||||
{
|
||||
total: number
|
||||
present: number
|
||||
late: number
|
||||
absent: number
|
||||
earlyLeave: number
|
||||
}
|
||||
>()
|
||||
for (const r of attendanceRows) {
|
||||
attendanceMap.set(r.studentId, {
|
||||
total: Number(r.total),
|
||||
present: Number(r.present),
|
||||
late: Number(r.late),
|
||||
absent: Number(r.absent),
|
||||
earlyLeave: Number(r.earlyLeave),
|
||||
})
|
||||
}
|
||||
|
||||
// Step 3: 委托 grades data-access 获取该班级所有成绩记录(按时间范围筛选 createdAt)
|
||||
// 注意:getGradeRecords 不直接支持 createdAt 范围筛选,因此这里传入 classId + scope
|
||||
// 后续在内存中按 createdAt 过滤时间范围(成绩数据量通常较小,可接受)
|
||||
const gradeResult = await getGradeRecords({
|
||||
classId,
|
||||
scope: scope ?? { type: "all" },
|
||||
limit: 5000,
|
||||
})
|
||||
|
||||
// 内存中按时间范围过滤成绩记录(按 createdAt)
|
||||
const startTime = new Date(effectiveStartDate).getTime()
|
||||
const endTime = new Date(effectiveEndDate).getTime() + 24 * 60 * 60 * 1000 // 含结束日当天
|
||||
const filteredGradeRecords = gradeResult.records.filter((r) => {
|
||||
const createdAt = new Date(r.createdAt).getTime()
|
||||
return createdAt >= startTime && createdAt < endTime
|
||||
})
|
||||
|
||||
// 按学生聚合成绩:归一化分数(score/fullScore*100)的加权平均(按 fullScore 加权)
|
||||
const gradeAggMap = new Map<
|
||||
string,
|
||||
{ weightedSum: number; weightTotal: number; count: number }
|
||||
>()
|
||||
for (const r of filteredGradeRecords) {
|
||||
if (!studentIds.includes(r.studentId)) continue
|
||||
if (r.fullScore <= 0) continue // 跳过满分异常的记录
|
||||
const normalized = (r.score / r.fullScore) * 100
|
||||
const agg = gradeAggMap.get(r.studentId) ?? {
|
||||
weightedSum: 0,
|
||||
weightTotal: 0,
|
||||
count: 0,
|
||||
}
|
||||
agg.weightedSum += normalized * r.fullScore
|
||||
agg.weightTotal += r.fullScore
|
||||
agg.count += 1
|
||||
gradeAggMap.set(r.studentId, agg)
|
||||
}
|
||||
|
||||
// Step 4: 合并考勤与成绩数据,构造原始数据点列表
|
||||
const rawItems = students.map((s) => {
|
||||
const attendance = attendanceMap.get(s.id)
|
||||
const grade = gradeAggMap.get(s.id)
|
||||
const total = attendance?.total ?? 0
|
||||
const present = attendance?.present ?? 0
|
||||
const late = attendance?.late ?? 0
|
||||
const absent = (attendance?.absent ?? 0) + (attendance?.earlyLeave ?? 0)
|
||||
// 出勤率:(present + late) / total * 100(late 视为出勤但迟到)
|
||||
const attendanceRate =
|
||||
total > 0 ? ((present + late) / total) * 100 : 0
|
||||
const averageScore =
|
||||
grade && grade.weightTotal > 0
|
||||
? grade.weightedSum / grade.weightTotal
|
||||
: 0
|
||||
return {
|
||||
studentId: s.id,
|
||||
studentName: studentNameMap.get(s.id) ?? "Unknown",
|
||||
attendanceRate,
|
||||
attendanceRecordCount: total,
|
||||
absentCount: absent,
|
||||
averageScore,
|
||||
gradeRecordCount: grade?.count ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
// Step 5: 调用纯函数计算相关系数与风险分级
|
||||
return computeCorrelationSummary(
|
||||
classId,
|
||||
className,
|
||||
effectiveStartDate,
|
||||
effectiveEndDate,
|
||||
rawItems
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope 辅助:检查 scope 是否允许访问班级级关联分析。
|
||||
* 仅 all / class_taught(含该班级)/ grade_managed 允许;其余返回 false。
|
||||
*/
|
||||
export function canAccessCorrelationAnalysis(
|
||||
scope: DataScope,
|
||||
classId: string
|
||||
): boolean {
|
||||
if (scope.type === "all") return true
|
||||
if (scope.type === "class_taught") return scope.classIds.includes(classId)
|
||||
if (scope.type === "grade_managed") return false // 暂不支持年级管理员跨班级分析
|
||||
return false // class_members / children / owned 不允许班级级分析
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, count, desc, eq, gte, lte, sql } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, gte, inArray, lte, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { attendanceRecords, classes, users } from "@/shared/db/schema"
|
||||
import { attendanceRecords } from "@/shared/db/schema"
|
||||
import { getClassNameById, getClassNamesByIds, getClassesByGradeId } from "@/modules/classes/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { getGradeNameById } from "@/modules/school/data-access"
|
||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||
|
||||
import { getAttendanceRules } from "./data-access"
|
||||
import {
|
||||
computeAttendanceWarnings,
|
||||
createEmptyWarningSummary,
|
||||
DEFAULT_ATTENDANCE_RATE_THRESHOLD,
|
||||
DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD,
|
||||
} from "./warning-compute"
|
||||
import {
|
||||
computeTrendPoints,
|
||||
type TrendGranularity,
|
||||
} from "./trend-compute"
|
||||
import type {
|
||||
AttendanceListItem,
|
||||
AttendanceStats,
|
||||
AttendanceTrendSummary,
|
||||
AttendanceWarningSummary,
|
||||
ClassAttendanceSummary,
|
||||
ClassComparisonSummary,
|
||||
ClassComparisonItem,
|
||||
StudentAttendanceSummary,
|
||||
} from "./types"
|
||||
|
||||
@@ -20,6 +38,7 @@ const EMPTY_STATS: AttendanceStats = {
|
||||
late: 0,
|
||||
earlyLeave: 0,
|
||||
excused: 0,
|
||||
schoolActivity: 0,
|
||||
presentRate: 0,
|
||||
lateRate: 0,
|
||||
}
|
||||
@@ -39,6 +58,7 @@ export const computeStats = (rows: { status: string }[]): AttendanceStats => {
|
||||
else if (r.status === "late") stats.late += 1
|
||||
else if (r.status === "early_leave") stats.earlyLeave += 1
|
||||
else if (r.status === "excused") stats.excused += 1
|
||||
else if (r.status === "school_activity") stats.schoolActivity += 1
|
||||
}
|
||||
stats.presentRate = Math.round((stats.present / stats.total) * 10000) / 100
|
||||
stats.lateRate = Math.round((stats.late / stats.total) * 10000) / 100
|
||||
@@ -55,6 +75,7 @@ const statsFromAggregate = (row: {
|
||||
late: number
|
||||
earlyLeave: number
|
||||
excused: number
|
||||
schoolActivity: number
|
||||
}): AttendanceStats => {
|
||||
const total = Number(row.total ?? 0)
|
||||
const present = Number(row.present ?? 0)
|
||||
@@ -66,6 +87,7 @@ const statsFromAggregate = (row: {
|
||||
late,
|
||||
earlyLeave: Number(row.earlyLeave ?? 0),
|
||||
excused: Number(row.excused ?? 0),
|
||||
schoolActivity: Number(row.schoolActivity ?? 0),
|
||||
presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0,
|
||||
lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
|
||||
}
|
||||
@@ -84,16 +106,15 @@ export async function getStudentAttendanceSummary(
|
||||
endDate?: string,
|
||||
recentLimit: number = DEFAULT_RECENT_LIMIT
|
||||
): Promise<StudentAttendanceSummary | null> {
|
||||
const [student] = await db
|
||||
.select({ name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, studentId))
|
||||
.limit(1)
|
||||
// 委托 users data-access 查询学生姓名,避免跨模块直查 users 表
|
||||
const userMap = await getUserNamesByIds([studentId])
|
||||
const student = userMap.get(studentId)
|
||||
if (!student) return null
|
||||
const studentName = student.name ?? "Unknown"
|
||||
|
||||
const conditions = [eq(attendanceRecords.studentId, studentId)]
|
||||
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "开始日期")))
|
||||
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "结束日期")))
|
||||
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
|
||||
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
|
||||
const where = and(...conditions)
|
||||
|
||||
// 统计使用 SQL 聚合,避免拉全量记录
|
||||
@@ -105,6 +126,7 @@ export async function getStudentAttendanceSummary(
|
||||
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
earlyLeave: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
|
||||
excused: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
|
||||
schoolActivity: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'school_activity' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
@@ -116,30 +138,36 @@ export async function getStudentAttendanceSummary(
|
||||
late: 0,
|
||||
earlyLeave: 0,
|
||||
excused: 0,
|
||||
schoolActivity: 0,
|
||||
})
|
||||
|
||||
// 最近记录使用 LIMIT 分页,避免拉全量
|
||||
// 最近记录:仅查询 attendanceRecords 表,className 通过 data-access 委托查询
|
||||
const rows = await db
|
||||
.select({
|
||||
record: attendanceRecords,
|
||||
className: classes.name,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
|
||||
.where(where)
|
||||
.orderBy(desc(attendanceRecords.date))
|
||||
.limit(recentLimit)
|
||||
|
||||
// 批量解析 className(委托 classes data-access)
|
||||
const classIds = Array.from(new Set(rows.map((r) => r.record.classId)))
|
||||
const classNameMap = await getClassNamesByIds(classIds)
|
||||
|
||||
const recentRecords: AttendanceListItem[] = rows.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
studentName,
|
||||
classId: r.record.classId,
|
||||
className: r.className ?? "Unknown",
|
||||
className: classNameMap.get(r.record.classId) ?? "Unknown",
|
||||
scheduleId: r.record.scheduleId ?? null,
|
||||
date: serializeDate(r.record.date),
|
||||
status: r.record.status,
|
||||
// L-6:节次考勤,null 视为 full_day
|
||||
period: r.record.period ?? "full_day",
|
||||
remark: r.record.remark ?? null,
|
||||
reason: r.record.reason ?? null,
|
||||
recordedBy: r.record.recordedBy,
|
||||
recorderName: "Unknown",
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
@@ -147,7 +175,7 @@ export async function getStudentAttendanceSummary(
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
studentName,
|
||||
stats,
|
||||
recentRecords,
|
||||
}
|
||||
@@ -158,39 +186,65 @@ export async function getClassAttendanceStats(
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<ClassAttendanceSummary | null> {
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
if (!classRow) return null
|
||||
// 委托 classes data-access 查询班级名称,避免跨模块直查 classes 表
|
||||
const className = await getClassNameById(classId)
|
||||
if (!className) return null
|
||||
|
||||
const conditions = [eq(attendanceRecords.classId, classId)]
|
||||
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "开始日期")))
|
||||
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "结束日期")))
|
||||
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
|
||||
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
|
||||
const where = and(...conditions)
|
||||
|
||||
// 统计使用 SQL 聚合(P1-11 修复:避免全量查询 + 内存 computeStats)
|
||||
const [statsRow] = await db
|
||||
.select({
|
||||
total: count(),
|
||||
present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
|
||||
absent: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
|
||||
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
earlyLeave: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
|
||||
excused: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
|
||||
schoolActivity: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'school_activity' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
|
||||
const stats = statsFromAggregate(statsRow ?? {
|
||||
total: 0,
|
||||
present: 0,
|
||||
absent: 0,
|
||||
late: 0,
|
||||
earlyLeave: 0,
|
||||
excused: 0,
|
||||
schoolActivity: 0,
|
||||
})
|
||||
|
||||
// 学生明细记录:仅查询 attendanceRecords 表,studentName 通过 data-access 委托查询
|
||||
const rows = await db
|
||||
.select({
|
||||
record: attendanceRecords,
|
||||
studentName: users.name,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(users.name))
|
||||
.where(where)
|
||||
.orderBy(asc(attendanceRecords.studentId))
|
||||
|
||||
const stats = computeStats(rows.map((r) => ({ status: r.record.status })))
|
||||
// 批量解析 studentName(委托 users data-access)
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
|
||||
const studentNameMap = await getUserNamesByIds(studentIds)
|
||||
|
||||
const studentRecords: AttendanceListItem[] = rows.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName: r.studentName ?? "Unknown",
|
||||
studentName: studentNameMap.get(r.record.studentId)?.name ?? "Unknown",
|
||||
classId: r.record.classId,
|
||||
className: classRow.name,
|
||||
className,
|
||||
scheduleId: r.record.scheduleId ?? null,
|
||||
date: serializeDate(r.record.date),
|
||||
status: r.record.status,
|
||||
// L-6:节次考勤,null 视为 full_day
|
||||
period: r.record.period ?? "full_day",
|
||||
remark: r.record.remark ?? null,
|
||||
reason: r.record.reason ?? null,
|
||||
recordedBy: r.record.recordedBy,
|
||||
recorderName: "Unknown",
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
@@ -198,9 +252,215 @@ export async function getClassAttendanceStats(
|
||||
|
||||
return {
|
||||
classId,
|
||||
className: classRow.name,
|
||||
className,
|
||||
date: startDate ?? endDate ?? new Date().toISOString().slice(0, 10),
|
||||
stats,
|
||||
studentRecords,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L-3:获取班级所有学生的出勤率预警汇总。
|
||||
* 按学生聚合统计 + 连续缺勤段,调用纯函数 computeAttendanceWarnings 计算预警。
|
||||
*/
|
||||
export async function getClassAttendanceWarnings(
|
||||
classId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<AttendanceWarningSummary | null> {
|
||||
const className = await getClassNameById(classId)
|
||||
if (!className) return null
|
||||
|
||||
// 读取班级规则(取第一条匹配的规则或使用默认值)
|
||||
const rules = await getAttendanceRules(classId)
|
||||
const classRule = rules.find((r) => r.classId === classId)
|
||||
const attendanceRateThreshold = classRule?.attendanceRateThreshold ?? DEFAULT_ATTENDANCE_RATE_THRESHOLD
|
||||
const consecutiveAbsenceThreshold = classRule?.consecutiveAbsenceThreshold ?? DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD
|
||||
|
||||
// 拉取该班级所有考勤记录,按 studentId 聚合
|
||||
const conditions = [eq(attendanceRecords.classId, classId)]
|
||||
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
|
||||
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
|
||||
const where = and(...conditions)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: attendanceRecords.studentId,
|
||||
date: attendanceRecords.date,
|
||||
status: attendanceRecords.status,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
.orderBy(asc(attendanceRecords.studentId), asc(attendanceRecords.date))
|
||||
|
||||
if (rows.length === 0) {
|
||||
return createEmptyWarningSummary(classId, className)
|
||||
}
|
||||
|
||||
// 按 studentId 聚合
|
||||
const studentMap = new Map<string, { date: string; status: string }[]>()
|
||||
for (const r of rows) {
|
||||
const list = studentMap.get(r.studentId) ?? []
|
||||
list.push({ date: serializeDate(r.date), status: r.status })
|
||||
studentMap.set(r.studentId, list)
|
||||
}
|
||||
|
||||
// 批量查询学生姓名(委托 users data-access)
|
||||
const studentIds = Array.from(studentMap.keys())
|
||||
const studentNameMap = await getUserNamesByIds(studentIds)
|
||||
|
||||
const studentStats = studentIds.map((id) => {
|
||||
const records = studentMap.get(id) ?? []
|
||||
const total = records.length
|
||||
const present = records.filter((r) => r.status === "present").length
|
||||
return {
|
||||
studentId: id,
|
||||
studentName: studentNameMap.get(id)?.name ?? "Unknown",
|
||||
total,
|
||||
present,
|
||||
records,
|
||||
}
|
||||
})
|
||||
|
||||
const warnings = computeAttendanceWarnings(
|
||||
studentStats,
|
||||
attendanceRateThreshold,
|
||||
consecutiveAbsenceThreshold
|
||||
)
|
||||
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
attendanceRateThreshold,
|
||||
consecutiveAbsenceThreshold,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L-4:获取班级考勤趋势(按日/周/月聚合)。
|
||||
* 使用纯函数 computeTrendPoints 计算趋势点,便于测试。
|
||||
*/
|
||||
export async function getAttendanceTrend(
|
||||
classId: string,
|
||||
granularity: TrendGranularity,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<AttendanceTrendSummary | null> {
|
||||
const className = await getClassNameById(classId)
|
||||
if (!className) return null
|
||||
|
||||
const conditions = [eq(attendanceRecords.classId, classId)]
|
||||
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
|
||||
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
|
||||
const where = and(...conditions)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
date: attendanceRecords.date,
|
||||
status: attendanceRecords.status,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
.orderBy(asc(attendanceRecords.date))
|
||||
|
||||
const records = rows.map((r) => ({
|
||||
date: serializeDate(r.date),
|
||||
status: r.status,
|
||||
}))
|
||||
|
||||
const points = computeTrendPoints(records, granularity)
|
||||
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
granularity,
|
||||
startDate: startDate ?? (points[0]?.date ?? new Date().toISOString().slice(0, 10)),
|
||||
endDate: endDate ?? (points[points.length - 1]?.date ?? new Date().toISOString().slice(0, 10)),
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L-7:获取同年级所有班级的出勤率对比。
|
||||
* 通过 SQL 聚合按 classId 分组统计,避免拉全量记录。
|
||||
*/
|
||||
export async function getClassComparison(
|
||||
gradeId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<ClassComparisonSummary | null> {
|
||||
const gradeName = await getGradeNameById(gradeId)
|
||||
if (!gradeName) return null
|
||||
|
||||
// 获取年级所有班级
|
||||
const gradeClasses = await getClassesByGradeId(gradeId)
|
||||
if (gradeClasses.length === 0) {
|
||||
return {
|
||||
gradeId,
|
||||
gradeName,
|
||||
startDate: startDate ?? new Date().toISOString().slice(0, 10),
|
||||
endDate: endDate ?? new Date().toISOString().slice(0, 10),
|
||||
items: [],
|
||||
averagePresentRate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const classIds = gradeClasses.map((c) => c.id)
|
||||
const classNameMap = new Map(gradeClasses.map((c) => [c.id, c.name]))
|
||||
|
||||
// 构建时间范围条件
|
||||
const conditions = [inArray(attendanceRecords.classId, classIds)]
|
||||
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
|
||||
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
|
||||
const where = and(...conditions)
|
||||
|
||||
// SQL 聚合:按 classId 分组统计
|
||||
const rows = await db
|
||||
.select({
|
||||
classId: attendanceRecords.classId,
|
||||
total: count(),
|
||||
present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
|
||||
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
absent: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
.groupBy(attendanceRecords.classId)
|
||||
|
||||
// 构建对比项(含无记录的班级,total=0)
|
||||
const items: ClassComparisonItem[] = classIds.map((cid) => {
|
||||
const row = rows.find((r) => r.classId === cid)
|
||||
const total = Number(row?.total ?? 0)
|
||||
const present = Number(row?.present ?? 0)
|
||||
const late = Number(row?.late ?? 0)
|
||||
const absent = Number(row?.absent ?? 0)
|
||||
return {
|
||||
classId: cid,
|
||||
className: classNameMap.get(cid) ?? "Unknown",
|
||||
total,
|
||||
presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0,
|
||||
lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
|
||||
absentRate: total > 0 ? Math.round((absent / total) * 10000) / 100 : 0,
|
||||
}
|
||||
})
|
||||
|
||||
// 按出勤率降序排序
|
||||
items.sort((a, b) => b.presentRate - a.presentRate)
|
||||
|
||||
// 计算年级平均出勤率(加权平均,按 total 加权)
|
||||
const totalRecords = items.reduce((sum, i) => sum + i.total, 0)
|
||||
const totalPresent = items.reduce((sum, i) => sum + Math.round(i.presentRate * i.total) / 100, 0)
|
||||
const averagePresentRate = totalRecords > 0
|
||||
? Math.round((totalPresent / totalRecords) * 10000) / 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
gradeId,
|
||||
gradeName,
|
||||
startDate: startDate ?? new Date().toISOString().slice(0, 10),
|
||||
endDate: endDate ?? new Date().toISOString().slice(0, 10),
|
||||
items,
|
||||
averagePresentRate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, count, desc, eq, gte, inArray, lte, or, sql, type SQL } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, gte, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
attendanceRecords,
|
||||
attendanceRules,
|
||||
classes,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access"
|
||||
import { getClassActiveStudentsWithInfo, getClassNamesByIds } from "@/modules/classes/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
AttendanceListItem,
|
||||
AttendancePeriod,
|
||||
AttendanceQueryParams,
|
||||
AttendanceRule,
|
||||
AttendanceStats,
|
||||
PaginatedAttendanceResult,
|
||||
} from "./types"
|
||||
import type {
|
||||
@@ -48,6 +49,20 @@ const buildScopeFilter = (scope: DataScope): SQL | null => {
|
||||
const serializeDate = (d: Date | string | null): string =>
|
||||
d ? new Date(d).toISOString().slice(0, 10) : ""
|
||||
|
||||
/**
|
||||
* L-6 节次考勤:构建 period 过滤条件。
|
||||
* - period = "full_day":匹配 period IS NULL OR period = 'full_day'(向后兼容)
|
||||
* - period = 其他值:精确匹配
|
||||
* - period 未传:返回 null(不按节次过滤)
|
||||
*/
|
||||
function buildPeriodFilter(period?: AttendancePeriod): SQL | null {
|
||||
if (!period) return null
|
||||
if (period === "full_day") {
|
||||
return or(isNull(attendanceRecords.period), eq(attendanceRecords.period, "full_day")) as SQL
|
||||
}
|
||||
return eq(attendanceRecords.period, period)
|
||||
}
|
||||
|
||||
const mapListItem = (
|
||||
r: typeof attendanceRecords.$inferSelect,
|
||||
studentName: string | null,
|
||||
@@ -62,7 +77,10 @@ const mapListItem = (
|
||||
scheduleId: r.scheduleId ?? null,
|
||||
date: serializeDate(r.date),
|
||||
status: r.status,
|
||||
// L-6:null 视为 full_day(向后兼容),对外暴露统一为 "full_day"
|
||||
period: r.period ?? "full_day",
|
||||
remark: r.remark ?? null,
|
||||
reason: r.reason ?? null,
|
||||
recordedBy: r.recordedBy,
|
||||
recorderName,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
@@ -72,15 +90,14 @@ const resolveRecorderNames = async (
|
||||
rows: { record: typeof attendanceRecords.$inferSelect }[]
|
||||
): Promise<Map<string, string>> => {
|
||||
const ids = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
|
||||
const map = new Map<string, string>()
|
||||
if (ids.length > 0) {
|
||||
const recorders = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, ids))
|
||||
for (const r of recorders) map.set(r.id, r.name ?? "Unknown")
|
||||
if (ids.length === 0) return new Map()
|
||||
// 委托 users data-access 查询,避免跨模块直查 users 表
|
||||
const userMap = await getUserNamesByIds(ids)
|
||||
const result = new Map<string, string>()
|
||||
for (const [id, user] of userMap) {
|
||||
result.set(id, user.name ?? "Unknown")
|
||||
}
|
||||
return map
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getAttendanceRecords(
|
||||
@@ -97,10 +114,13 @@ export async function getAttendanceRecords(
|
||||
}
|
||||
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
||||
if (params.studentId) conditions.push(eq(attendanceRecords.studentId, params.studentId))
|
||||
if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "日期")))
|
||||
if (params.startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(params.startDate, "开始日期")))
|
||||
if (params.endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(params.endDate, "结束日期")))
|
||||
if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "date")))
|
||||
if (params.startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(params.startDate, "startDate")))
|
||||
if (params.endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(params.endDate, "endDate")))
|
||||
if (params.status) conditions.push(eq(attendanceRecords.status, params.status))
|
||||
// L-6:按节次过滤
|
||||
const periodFilter = buildPeriodFilter(params.period)
|
||||
if (periodFilter) conditions.push(periodFilter)
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
@@ -108,25 +128,34 @@ export async function getAttendanceRecords(
|
||||
const total = Number(totalRow?.c ?? 0)
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
// 仅查询 attendanceRecords 表,姓名通过 data-access 委托查询(避免跨模块 JOIN)
|
||||
const rows = await db
|
||||
.select({
|
||||
record: attendanceRecords,
|
||||
studentName: users.name,
|
||||
className: classes.name,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
|
||||
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
|
||||
.where(where)
|
||||
.orderBy(desc(attendanceRecords.date), desc(attendanceRecords.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize)
|
||||
|
||||
const recorderMap = await resolveRecorderNames(rows)
|
||||
// 批量解析 studentName / className / recorderName(委托 users/classes data-access)
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
|
||||
const classIds = Array.from(new Set(rows.map((r) => r.record.classId)))
|
||||
const [studentNameMap, classNameMap, recorderMap] = await Promise.all([
|
||||
getUserNamesByIds(studentIds),
|
||||
getClassNamesByIds(classIds),
|
||||
resolveRecorderNames(rows),
|
||||
])
|
||||
|
||||
return {
|
||||
items: rows.map((r) =>
|
||||
mapListItem(r.record, r.studentName, r.className, recorderMap.get(r.record.recordedBy) ?? "Unknown")
|
||||
mapListItem(
|
||||
r.record,
|
||||
studentNameMap.get(r.record.studentId)?.name ?? null,
|
||||
classNameMap.get(r.record.classId) ?? null,
|
||||
recorderMap.get(r.record.recordedBy) ?? "Unknown"
|
||||
)
|
||||
),
|
||||
total,
|
||||
page,
|
||||
@@ -137,21 +166,41 @@ export async function getAttendanceRecords(
|
||||
|
||||
export async function getClassAttendanceForDate(
|
||||
classId: string,
|
||||
date: string
|
||||
date: string,
|
||||
/** L-6:按节次过滤,未传则返回当日所有节次记录 */
|
||||
period?: AttendancePeriod
|
||||
): Promise<AttendanceListItem[]> {
|
||||
const conditions: SQL[] = [
|
||||
eq(attendanceRecords.classId, classId),
|
||||
eq(attendanceRecords.date, new Date(date)),
|
||||
]
|
||||
const periodFilter = buildPeriodFilter(period)
|
||||
if (periodFilter) conditions.push(periodFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
record: attendanceRecords,
|
||||
studentName: users.name,
|
||||
className: classes.name,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
|
||||
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
|
||||
.where(and(eq(attendanceRecords.classId, classId), eq(attendanceRecords.date, new Date(date))))
|
||||
.orderBy(asc(users.name))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(attendanceRecords.studentId))
|
||||
|
||||
return rows.map((r) => mapListItem(r.record, r.studentName, r.className, "Unknown"))
|
||||
// 批量解析姓名(委托 users/classes data-access)
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
|
||||
const [studentNameMap, classNameMap] = await Promise.all([
|
||||
getUserNamesByIds(studentIds),
|
||||
getClassNamesByIds([classId]),
|
||||
])
|
||||
const className = classNameMap.get(classId) ?? null
|
||||
|
||||
return rows.map((r) =>
|
||||
mapListItem(
|
||||
r.record,
|
||||
studentNameMap.get(r.record.studentId)?.name ?? null,
|
||||
className,
|
||||
"Unknown"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function createAttendanceRecord(
|
||||
@@ -164,9 +213,12 @@ export async function createAttendanceRecord(
|
||||
studentId: data.studentId,
|
||||
classId: data.classId,
|
||||
scheduleId: data.scheduleId ?? null,
|
||||
date: safeParseDate(data.date, "日期"),
|
||||
date: safeParseDate(data.date, "date"),
|
||||
status: data.status,
|
||||
// L-6:节次考勤,schema 默认 full_day
|
||||
period: data.period,
|
||||
remark: data.remark ?? null,
|
||||
reason: data.reason ?? null,
|
||||
recordedBy,
|
||||
})
|
||||
return id
|
||||
@@ -182,9 +234,12 @@ export async function batchCreateAttendanceRecords(
|
||||
studentId: r.studentId,
|
||||
classId: r.classId,
|
||||
scheduleId: r.scheduleId ?? null,
|
||||
date: safeParseDate(r.date, "日期"),
|
||||
date: safeParseDate(r.date, "date"),
|
||||
status: r.status,
|
||||
// L-6:节次考勤,schema 默认 full_day
|
||||
period: r.period,
|
||||
remark: r.remark ?? null,
|
||||
reason: r.reason ?? null,
|
||||
recordedBy,
|
||||
}))
|
||||
await db.insert(attendanceRecords).values(rows)
|
||||
@@ -197,7 +252,10 @@ export async function updateAttendanceRecord(
|
||||
): Promise<void> {
|
||||
const update: Partial<typeof attendanceRecords.$inferSelect> = { updatedAt: new Date() }
|
||||
if (data.status !== undefined) update.status = data.status
|
||||
// L-6:支持更新节次
|
||||
if (data.period !== undefined) update.period = data.period
|
||||
if (data.remark !== undefined) update.remark = data.remark
|
||||
if (data.reason !== undefined) update.reason = data.reason || null
|
||||
if (data.scheduleId !== undefined) update.scheduleId = data.scheduleId
|
||||
await db.update(attendanceRecords).set(update).where(eq(attendanceRecords.id, id))
|
||||
}
|
||||
@@ -244,6 +302,8 @@ export async function getAttendanceRules(classId?: string): Promise<AttendanceRu
|
||||
lateThresholdMinutes: r.lateThresholdMinutes ?? null,
|
||||
earlyLeaveThresholdMinutes: r.earlyLeaveThresholdMinutes ?? null,
|
||||
enableAutoMark: r.enableAutoMark ?? null,
|
||||
attendanceRateThreshold: r.attendanceRateThreshold ?? null,
|
||||
consecutiveAbsenceThreshold: r.consecutiveAbsenceThreshold ?? null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}))
|
||||
@@ -263,6 +323,8 @@ export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<
|
||||
lateThresholdMinutes: data.lateThresholdMinutes ?? 15,
|
||||
earlyLeaveThresholdMinutes: data.earlyLeaveThresholdMinutes ?? 15,
|
||||
enableAutoMark: data.enableAutoMark ?? false,
|
||||
attendanceRateThreshold: data.attendanceRateThreshold ?? 90,
|
||||
consecutiveAbsenceThreshold: data.consecutiveAbsenceThreshold ?? 3,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(attendanceRules.id, existing.id))
|
||||
@@ -276,26 +338,22 @@ export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<
|
||||
lateThresholdMinutes: data.lateThresholdMinutes ?? 15,
|
||||
earlyLeaveThresholdMinutes: data.earlyLeaveThresholdMinutes ?? 15,
|
||||
enableAutoMark: data.enableAutoMark ?? false,
|
||||
attendanceRateThreshold: data.attendanceRateThreshold ?? 90,
|
||||
consecutiveAbsenceThreshold: data.consecutiveAbsenceThreshold ?? 3,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export type AttendanceOverviewStats = {
|
||||
totalRecords: number
|
||||
presentCount: number
|
||||
absentCount: number
|
||||
lateCount: number
|
||||
earlyLeaveCount: number
|
||||
excusedCount: number
|
||||
attendanceRate: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考勤总览统计。
|
||||
* 返回统一的 `AttendanceStats` 类型(P2-5 修复:消除数据结构分裂)。
|
||||
*/
|
||||
export async function getAttendanceStats(params: {
|
||||
scope: DataScope
|
||||
currentUserId: string
|
||||
classId?: string
|
||||
date?: string
|
||||
}): Promise<AttendanceOverviewStats> {
|
||||
}): Promise<AttendanceStats> {
|
||||
// 直接使用 SQL 聚合查询,避免分页截断导致统计失真(P0 修复)
|
||||
const conditions: SQL[] = []
|
||||
|
||||
@@ -305,36 +363,120 @@ export async function getAttendanceStats(params: {
|
||||
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
|
||||
}
|
||||
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
||||
if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "日期")))
|
||||
if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "date")))
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
totalRecords: count(),
|
||||
presentCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
|
||||
absentCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
|
||||
lateCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
earlyLeaveCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
|
||||
excusedCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
|
||||
total: count(),
|
||||
present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
|
||||
absent: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
|
||||
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
earlyLeave: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
|
||||
excused: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
|
||||
schoolActivity: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'school_activity' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
|
||||
const total = Number(row?.totalRecords ?? 0)
|
||||
const present = Number(row?.presentCount ?? 0)
|
||||
const absent = Number(row?.absentCount ?? 0)
|
||||
const late = Number(row?.lateCount ?? 0)
|
||||
const earlyLeave = Number(row?.earlyLeaveCount ?? 0)
|
||||
const excused = Number(row?.excusedCount ?? 0)
|
||||
const total = Number(row?.total ?? 0)
|
||||
const present = Number(row?.present ?? 0)
|
||||
const absent = Number(row?.absent ?? 0)
|
||||
const late = Number(row?.late ?? 0)
|
||||
const earlyLeave = Number(row?.earlyLeave ?? 0)
|
||||
const excused = Number(row?.excused ?? 0)
|
||||
const schoolActivity = Number(row?.schoolActivity ?? 0)
|
||||
|
||||
return {
|
||||
totalRecords: total,
|
||||
presentCount: present,
|
||||
absentCount: absent,
|
||||
lateCount: late,
|
||||
earlyLeaveCount: earlyLeave,
|
||||
excusedCount: excused,
|
||||
attendanceRate: total > 0 ? Math.round((present / total) * 1000) / 10 : 0,
|
||||
total,
|
||||
present,
|
||||
absent,
|
||||
late,
|
||||
earlyLeave,
|
||||
excused,
|
||||
schoolActivity,
|
||||
presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0,
|
||||
lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L-5:将请假申请同步为考勤记录(status='excused')。
|
||||
*
|
||||
* 同步策略:
|
||||
* - 遍历 [startDate, endDate] 区间每一天
|
||||
* - 查询当天是否已有该学生的考勤记录
|
||||
* - 无记录 → 插入 excused 记录(recordedBy = reviewerId,reason 注明请假类型)
|
||||
* - 已存在非 excused 记录 → 跳过(不覆盖教师手动录入)
|
||||
* - 已存在 excused 记录 → 跳过(幂等)
|
||||
*
|
||||
* @returns 实际插入的记录数
|
||||
*/
|
||||
export async function syncExcusedFromLeaveRequest(params: {
|
||||
studentId: string
|
||||
classId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
reason: string
|
||||
reviewerId: string
|
||||
}): Promise<number> {
|
||||
const { studentId, classId, startDate, endDate, reason, reviewerId } = params
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start > end) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 构造日期区间(含两端,按天递增)
|
||||
const dates: Date[] = []
|
||||
const cursor = new Date(start)
|
||||
cursor.setUTCHours(0, 0, 0, 0)
|
||||
const endBoundary = new Date(end)
|
||||
endBoundary.setUTCHours(0, 0, 0, 0)
|
||||
while (cursor <= endBoundary) {
|
||||
dates.push(new Date(cursor))
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||
}
|
||||
|
||||
// 一次性查询该学生区间内所有现有考勤记录
|
||||
const existing = await db
|
||||
.select({
|
||||
date: attendanceRecords.date,
|
||||
status: attendanceRecords.status,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(attendanceRecords.studentId, studentId),
|
||||
eq(attendanceRecords.classId, classId),
|
||||
gte(attendanceRecords.date, start),
|
||||
lte(attendanceRecords.date, end),
|
||||
),
|
||||
)
|
||||
|
||||
const existingDateKeys = new Set(
|
||||
existing.map((r) => new Date(r.date).toISOString().slice(0, 10)),
|
||||
)
|
||||
|
||||
// 仅插入不存在的日期
|
||||
const toInsert = dates
|
||||
.filter((d) => !existingDateKeys.has(d.toISOString().slice(0, 10)))
|
||||
.map((d) => ({
|
||||
id: createId(),
|
||||
studentId,
|
||||
classId,
|
||||
scheduleId: null,
|
||||
date: d,
|
||||
status: "excused" as const,
|
||||
// L-6:请假同步为全日 excused 记录
|
||||
period: "full_day" as const,
|
||||
remark: null,
|
||||
reason,
|
||||
recordedBy: reviewerId,
|
||||
}))
|
||||
|
||||
if (toInsert.length === 0) return 0
|
||||
await db.insert(attendanceRecords).values(toInsert)
|
||||
return toInsert.length
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DataScope } from "@/shared/types/permissions"
|
||||
import { exportToExcel } from "@/shared/lib/excel"
|
||||
|
||||
import { getAttendanceRecords, getAttendanceStats } from "./data-access"
|
||||
import { isAttendanceStatus } from "@/shared/constants/attendance-status"
|
||||
|
||||
/**
|
||||
* 导出考勤记录到 Excel
|
||||
@@ -21,17 +22,14 @@ export async function exportAttendanceRecordsToExcel(params: {
|
||||
}): Promise<Buffer> {
|
||||
const t = await getTranslations("attendance")
|
||||
|
||||
// 使用类型守卫替代 as 断言(P1-6 修复);复用 shared 层 isAttendanceStatus
|
||||
const status = isAttendanceStatus(params.status) ? params.status : undefined
|
||||
|
||||
const records = await getAttendanceRecords({
|
||||
scope: params.scope,
|
||||
currentUserId: params.currentUserId,
|
||||
classId: params.classId,
|
||||
status: params.status as
|
||||
| "present"
|
||||
| "absent"
|
||||
| "late"
|
||||
| "early_leave"
|
||||
| "excused"
|
||||
| undefined,
|
||||
status,
|
||||
date: params.date,
|
||||
})
|
||||
|
||||
@@ -40,6 +38,7 @@ export async function exportAttendanceRecordsToExcel(params: {
|
||||
[t("list.columns.class")]: r.className,
|
||||
[t("list.columns.date")]: r.date,
|
||||
[t("list.columns.status")]: t(`status.${r.status}`),
|
||||
[t("list.columns.reason")]: r.reason ?? "",
|
||||
[t("list.columns.remark")]: r.remark ?? "",
|
||||
[t("list.columns.recorder")]: r.recorderName,
|
||||
[t("list.columns.createdAt")]: r.createdAt.split("T")[0],
|
||||
@@ -53,13 +52,14 @@ export async function exportAttendanceRecordsToExcel(params: {
|
||||
})
|
||||
|
||||
const statsRows = [
|
||||
{ metric: t("stats.totalRecords"), value: stats.totalRecords },
|
||||
{ metric: t("stats.present"), value: stats.presentCount },
|
||||
{ metric: t("stats.absent"), value: stats.absentCount },
|
||||
{ metric: t("stats.late"), value: stats.lateCount },
|
||||
{ metric: t("stats.earlyLeave"), value: stats.earlyLeaveCount },
|
||||
{ metric: t("stats.excused"), value: stats.excusedCount },
|
||||
{ metric: t("stats.attendanceRate"), value: `${stats.attendanceRate}%` },
|
||||
{ metric: t("stats.totalRecords"), value: stats.total },
|
||||
{ metric: t("stats.present"), value: stats.present },
|
||||
{ metric: t("stats.absent"), value: stats.absent },
|
||||
{ metric: t("stats.late"), value: stats.late },
|
||||
{ metric: t("stats.earlyLeave"), value: stats.earlyLeave },
|
||||
{ metric: t("stats.excused"), value: stats.excused },
|
||||
{ metric: t("stats.schoolActivity"), value: stats.schoolActivity },
|
||||
{ metric: t("stats.attendanceRate"), value: `${stats.presentRate.toFixed(1)}%` },
|
||||
]
|
||||
|
||||
return exportToExcel({
|
||||
@@ -71,6 +71,7 @@ export async function exportAttendanceRecordsToExcel(params: {
|
||||
{ header: t("list.columns.class"), key: t("list.columns.class"), width: 18 },
|
||||
{ header: t("list.columns.date"), key: t("list.columns.date"), width: 14 },
|
||||
{ header: t("list.columns.status"), key: t("list.columns.status"), width: 12 },
|
||||
{ header: t("list.columns.reason"), key: t("list.columns.reason"), width: 24 },
|
||||
{ header: t("list.columns.remark"), key: t("list.columns.remark"), width: 24 },
|
||||
{ header: t("list.columns.recorder"), key: t("list.columns.recorder"), width: 16 },
|
||||
{ header: t("list.columns.createdAt"), key: t("list.columns.createdAt"), width: 14 },
|
||||
@@ -80,8 +81,8 @@ export async function exportAttendanceRecordsToExcel(params: {
|
||||
{
|
||||
name: t("actions.stats"),
|
||||
columns: [
|
||||
{ header: "Metric", key: "metric", width: 24 },
|
||||
{ header: "Value", key: "value", width: 16 },
|
||||
{ header: t("stats.totalRecords"), key: "metric", width: 24 },
|
||||
{ header: t("stats.attendanceRate"), key: "value", width: 16 },
|
||||
],
|
||||
rows: statsRows,
|
||||
},
|
||||
|
||||
154
src/modules/attendance/notifications.ts
Normal file
154
src/modules/attendance/notifications.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import "server-only"
|
||||
|
||||
import { sendBatchNotifications } from "@/modules/notifications"
|
||||
import { getParentStudentMapByStudentIds } from "@/modules/parent/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { getClassNameById } from "@/modules/classes/data-access"
|
||||
|
||||
import type { AttendanceStatus } from "./types"
|
||||
|
||||
/**
|
||||
* L-2:触发家长缺勤通知的状态集合。
|
||||
* 缺勤/迟到/早退/请假 需要通知家长;出勤/校内活动 不通知。
|
||||
*/
|
||||
const NOTIFIABLE_STATUSES: ReadonlySet<AttendanceStatus> = new Set([
|
||||
"absent",
|
||||
"late",
|
||||
"early_leave",
|
||||
"excused",
|
||||
])
|
||||
|
||||
/**
|
||||
* L-2:考勤记录输入(用于通知)。
|
||||
*/
|
||||
interface AttendanceNotificationInput {
|
||||
studentId: string
|
||||
status: AttendanceStatus
|
||||
date: string
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* L-2:对缺勤/异常考勤记录批量发送家长通知。
|
||||
*
|
||||
* 通知策略:
|
||||
* - 仅对 NOTIFIABLE_STATUSES 中的状态发送通知
|
||||
* - 一次性批量查询学生→家长映射,避免 N+1
|
||||
* - 调用 notifications 模块的 sendBatchNotifications 多渠道分发
|
||||
* - 通知失败不阻断考勤记录保存(仅记录日志)
|
||||
*
|
||||
* @param classId 班级 ID(用于通知内容)
|
||||
* @param records 考勤记录列表
|
||||
* @returns 发送结果(成功/失败计数)
|
||||
*/
|
||||
export async function notifyParentsOfAbsence(
|
||||
classId: string,
|
||||
records: AttendanceNotificationInput[],
|
||||
): Promise<{ sent: number; failed: number; skipped: number }> {
|
||||
// 过滤需要通知的记录
|
||||
const notifiable = records.filter((r) => NOTIFIABLE_STATUSES.has(r.status))
|
||||
if (notifiable.length === 0) {
|
||||
return { sent: 0, failed: 0, skipped: records.length }
|
||||
}
|
||||
|
||||
// 一次性批量查询所有受影响学生的家长映射(保留 parentId-studentId 关联)
|
||||
const studentIds = notifiable.map((r) => r.studentId)
|
||||
const parentStudentPairs = await getParentStudentMapByStudentIds(studentIds)
|
||||
if (parentStudentPairs.length === 0) {
|
||||
return { sent: 0, failed: 0, skipped: records.length }
|
||||
}
|
||||
|
||||
// 并行查询班级名称和学生姓名
|
||||
const [className, studentNameMap] = await Promise.all([
|
||||
getClassNameById(classId),
|
||||
getUserNamesByIds(studentIds),
|
||||
])
|
||||
|
||||
// 构造按学生索引的记录映射(同一学生一天可能只一条记录,但保险起见用 Map)
|
||||
const recordByStudent = new Map<string, AttendanceNotificationInput>()
|
||||
for (const r of notifiable) {
|
||||
recordByStudent.set(r.studentId, r)
|
||||
}
|
||||
|
||||
// 构造通知负载:每个家长-学生组合一条通知
|
||||
const notificationPayloads = parentStudentPairs
|
||||
.map((pair) => {
|
||||
const record = recordByStudent.get(pair.studentId)
|
||||
if (!record) return null
|
||||
const studentName = studentNameMap.get(pair.studentId)?.name ?? "Unknown"
|
||||
return {
|
||||
userId: pair.parentId,
|
||||
title: `考勤提醒:${studentName} ${statusLabel(record.status)}`,
|
||||
content: buildNotificationContent(
|
||||
studentName,
|
||||
record.status,
|
||||
record.date,
|
||||
record.reason ?? null,
|
||||
className ?? "",
|
||||
),
|
||||
type: "warning" as const,
|
||||
metadata: {
|
||||
module: "attendance",
|
||||
studentId: pair.studentId,
|
||||
classId,
|
||||
status: record.status,
|
||||
date: record.date,
|
||||
},
|
||||
actionUrl: "/parent/attendance",
|
||||
}
|
||||
})
|
||||
.filter((p): p is NonNullable<typeof p> => p !== null)
|
||||
|
||||
if (notificationPayloads.length === 0) {
|
||||
return { sent: 0, failed: 0, skipped: records.length - notifiable.length }
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await sendBatchNotifications(notificationPayloads)
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
for (const channelResults of results) {
|
||||
const hasSuccess = channelResults.some((r) => r.success)
|
||||
if (hasSuccess) {
|
||||
sent += 1
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
return { sent, failed, skipped: records.length - notifiable.length }
|
||||
} catch {
|
||||
// 通知发送失败不阻断主流程
|
||||
return {
|
||||
sent: 0,
|
||||
failed: notificationPayloads.length,
|
||||
skipped: records.length - notifiable.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态中文标签(通知内容用,不走 i18n 因通知在服务端发送且无请求上下文)。 */
|
||||
function statusLabel(status: AttendanceStatus): string {
|
||||
const labels: Record<AttendanceStatus, string> = {
|
||||
present: "出勤",
|
||||
absent: "缺勤",
|
||||
late: "迟到",
|
||||
early_leave: "早退",
|
||||
excused: "请假",
|
||||
school_activity: "校内活动",
|
||||
}
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
/** 构造通知正文。 */
|
||||
function buildNotificationContent(
|
||||
studentName: string,
|
||||
status: AttendanceStatus,
|
||||
date: string,
|
||||
reason: string | null,
|
||||
className: string,
|
||||
): string {
|
||||
const statusText = statusLabel(status)
|
||||
const classInfo = className ? `${className} ` : ""
|
||||
const reasonInfo = reason ? `(原因:${reason})` : ""
|
||||
return `${classInfo}${studentName} 同学于 ${date} 被标记为「${statusText}」${reasonInfo},请关注子女考勤情况。`
|
||||
}
|
||||
@@ -6,6 +6,23 @@ export const AttendanceStatusEnum = z.enum([
|
||||
"late",
|
||||
"early_leave",
|
||||
"excused",
|
||||
"school_activity",
|
||||
])
|
||||
|
||||
/**
|
||||
* L-6 节次考勤:节次枚举。
|
||||
* - morning_reading: 早读
|
||||
* - morning: 上午
|
||||
* - afternoon: 下午
|
||||
* - evening: 晚自习
|
||||
* - full_day: 全日(默认值,向后兼容)
|
||||
*/
|
||||
export const AttendancePeriodEnum = z.enum([
|
||||
"morning_reading",
|
||||
"morning",
|
||||
"afternoon",
|
||||
"evening",
|
||||
"full_day",
|
||||
])
|
||||
|
||||
export const RecordAttendanceSchema = z.object({
|
||||
@@ -13,7 +30,10 @@ export const RecordAttendanceSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
date: z.string().min(1),
|
||||
status: AttendanceStatusEnum,
|
||||
/** L-6:节次,未传时默认 full_day */
|
||||
period: AttendancePeriodEnum.default("full_day"),
|
||||
remark: z.string().optional(),
|
||||
reason: z.string().max(255).optional(),
|
||||
scheduleId: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -27,7 +47,9 @@ export type BatchRecordAttendanceInput = z.infer<typeof BatchRecordAttendanceSch
|
||||
|
||||
export const UpdateAttendanceSchema = z.object({
|
||||
status: AttendanceStatusEnum.optional(),
|
||||
period: AttendancePeriodEnum.optional(),
|
||||
remark: z.string().optional(),
|
||||
reason: z.string().max(255).optional(),
|
||||
scheduleId: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -38,6 +60,8 @@ export const AttendanceRuleSchema = z.object({
|
||||
lateThresholdMinutes: z.coerce.number().int().min(0).optional(),
|
||||
earlyLeaveThresholdMinutes: z.coerce.number().int().min(0).optional(),
|
||||
enableAutoMark: z.coerce.boolean().optional(),
|
||||
attendanceRateThreshold: z.coerce.number().int().min(0).max(100).optional(),
|
||||
consecutiveAbsenceThreshold: z.coerce.number().int().min(1).optional(),
|
||||
})
|
||||
|
||||
export type AttendanceRuleInput = z.infer<typeof AttendanceRuleSchema>
|
||||
|
||||
45
src/modules/attendance/services/attendance-context.tsx
Normal file
45
src/modules/attendance/services/attendance-context.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
import type { AttendanceDataService } from "./types"
|
||||
|
||||
/**
|
||||
* 考勤数据服务 Context。
|
||||
*
|
||||
* 客户端组件通过 `<AttendanceProvider service={...}>` 注入服务实现,
|
||||
* 内部用 `useAttendanceService()` 读取,实现"消费方依赖接口而非具体实现"。
|
||||
*
|
||||
* 注意:Server Component 无法使用 Context,应直接以接口类型消费
|
||||
* `createAttendanceDataService()` 返回的实现。
|
||||
*/
|
||||
const AttendanceServiceContext = createContext<AttendanceDataService | null>(null)
|
||||
|
||||
export function AttendanceProvider({
|
||||
service,
|
||||
children,
|
||||
}: {
|
||||
service: AttendanceDataService
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<AttendanceServiceContext.Provider value={service}>
|
||||
{children}
|
||||
</AttendanceServiceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取注入的考勤数据服务。
|
||||
* 若未在 `<AttendanceProvider>` 内调用,抛出明确错误以便定位。
|
||||
*/
|
||||
export function useAttendanceService(): AttendanceDataService {
|
||||
const service = useContext(AttendanceServiceContext)
|
||||
if (!service) {
|
||||
throw new Error(
|
||||
"useAttendanceService 必须在 <AttendanceProvider> 内调用;" +
|
||||
"请在页面根节点注入 AttendanceProvider。",
|
||||
)
|
||||
}
|
||||
return service
|
||||
}
|
||||
160
src/modules/attendance/services/attendance-data-service.ts
Normal file
160
src/modules/attendance/services/attendance-data-service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import "server-only"
|
||||
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import {
|
||||
batchCreateAttendanceRecords,
|
||||
createAttendanceRecord,
|
||||
deleteAttendanceRecord,
|
||||
getAttendanceRecords,
|
||||
getAttendanceRules,
|
||||
getAttendanceStats,
|
||||
getClassAttendanceForDate,
|
||||
upsertAttendanceRules,
|
||||
updateAttendanceRecord,
|
||||
} from "../data-access"
|
||||
import {
|
||||
getClassAttendanceStats,
|
||||
getStudentAttendanceSummary,
|
||||
} from "../data-access-stats"
|
||||
import type {
|
||||
AttendanceDataService,
|
||||
AttendanceReadService,
|
||||
AttendanceWriteService,
|
||||
DateRange,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* 基于真实 data-access 的考勤数据服务实现(读操作)。
|
||||
* 各角色共用此实现,差异由调用方传入的 `scope` 隔离。
|
||||
*/
|
||||
class AttendanceReadServiceImpl implements AttendanceReadService {
|
||||
async getStudentSummary(
|
||||
studentId: string,
|
||||
range?: DateRange,
|
||||
recentLimit?: number,
|
||||
) {
|
||||
return getStudentAttendanceSummary(
|
||||
studentId,
|
||||
range?.startDate,
|
||||
range?.endDate,
|
||||
recentLimit,
|
||||
)
|
||||
}
|
||||
|
||||
async getRecentRecords(studentId: string, limit: number) {
|
||||
const result = await getStudentAttendanceSummary(studentId, undefined, undefined, limit)
|
||||
return result?.recentRecords ?? []
|
||||
}
|
||||
|
||||
async getClassRecordsForDate(classId: string, date: string) {
|
||||
return getClassAttendanceForDate(classId, date)
|
||||
}
|
||||
|
||||
async getClassStats(classId: string, range?: DateRange) {
|
||||
return getClassAttendanceStats(classId, range?.startDate, range?.endDate)
|
||||
}
|
||||
|
||||
async queryRecords(params: Parameters<AttendanceReadService["queryRecords"]>[0]) {
|
||||
return getAttendanceRecords(params)
|
||||
}
|
||||
|
||||
async getOverviewStats(params: Parameters<AttendanceReadService["getOverviewStats"]>[0]) {
|
||||
return getAttendanceStats(params)
|
||||
}
|
||||
|
||||
async getRules(classId?: string) {
|
||||
return getAttendanceRules(classId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于真实 data-access 的考勤数据服务实现(写操作)。
|
||||
* 仅教师/管理员角色使用;学生/家长不应调用。
|
||||
*/
|
||||
class AttendanceWriteServiceImpl implements AttendanceWriteService {
|
||||
async recordAttendance(data: Parameters<AttendanceWriteService["recordAttendance"]>[0], recordedBy: string) {
|
||||
return createAttendanceRecord(data, recordedBy)
|
||||
}
|
||||
|
||||
async batchRecordAttendance(data: Parameters<AttendanceWriteService["batchRecordAttendance"]>[0], recordedBy: string) {
|
||||
return batchCreateAttendanceRecords(data, recordedBy)
|
||||
}
|
||||
|
||||
async updateAttendance(id: string, data: Parameters<AttendanceWriteService["updateAttendance"]>[1]) {
|
||||
return updateAttendanceRecord(id, data)
|
||||
}
|
||||
|
||||
async deleteAttendance(id: string) {
|
||||
return deleteAttendanceRecord(id)
|
||||
}
|
||||
|
||||
async saveRules(data: Parameters<AttendanceWriteService["saveRules"]>[0]) {
|
||||
return upsertAttendanceRules(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的考勤数据服务实现(读 + 写)。
|
||||
* data-access 层函数的薄封装,统一接口契约。
|
||||
*/
|
||||
class AttendanceDataServiceImpl
|
||||
implements AttendanceDataService
|
||||
{
|
||||
private readonly reader = new AttendanceReadServiceImpl()
|
||||
private readonly writer = new AttendanceWriteServiceImpl()
|
||||
|
||||
getStudentSummary = this.reader.getStudentSummary.bind(this.reader)
|
||||
getRecentRecords = this.reader.getRecentRecords.bind(this.reader)
|
||||
getClassRecordsForDate = this.reader.getClassRecordsForDate.bind(this.reader)
|
||||
getClassStats = this.reader.getClassStats.bind(this.reader)
|
||||
queryRecords = this.reader.queryRecords.bind(this.reader)
|
||||
getOverviewStats = this.reader.getOverviewStats.bind(this.reader)
|
||||
getRules = this.reader.getRules.bind(this.reader)
|
||||
|
||||
recordAttendance = this.writer.recordAttendance.bind(this.writer)
|
||||
batchRecordAttendance = this.writer.batchRecordAttendance.bind(this.writer)
|
||||
updateAttendance = this.writer.updateAttendance.bind(this.writer)
|
||||
deleteAttendance = this.writer.deleteAttendance.bind(this.writer)
|
||||
saveRules = this.writer.saveRules.bind(this.writer)
|
||||
}
|
||||
|
||||
/** 单例实例(无状态,可安全共享)。 */
|
||||
const sharedService = new AttendanceDataServiceImpl()
|
||||
|
||||
/**
|
||||
* 获取考勤数据服务实例。
|
||||
*
|
||||
* Server Component 调用此工厂以接口类型 `AttendanceDataService` 消费,
|
||||
* 避免直接 import data-access 函数(P1-12 修复)。
|
||||
*
|
||||
* @param _scope 当前用户的数据范围(用于文档化角色差异;实际过滤由 data-access 内 buildScopeFilter 执行)
|
||||
*/
|
||||
export function createAttendanceDataService(
|
||||
_scope?: DataScope,
|
||||
): AttendanceDataService {
|
||||
// 当前实现无状态且共享;scope 在具体 data-access 调用时传入。
|
||||
// 未来若需按角色返回不同实现,可在此分支。
|
||||
return sharedService
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取只读考勤数据服务(学生/家长角色使用)。
|
||||
* 返回的接口不包含写操作,编译期即阻止误用。
|
||||
*/
|
||||
export function createAttendanceReadService(
|
||||
_scope?: DataScope,
|
||||
): AttendanceReadService {
|
||||
return sharedService
|
||||
}
|
||||
|
||||
// 重新导出常用类型,便于消费方单点导入
|
||||
export type {
|
||||
AttendanceDataService,
|
||||
AttendanceReadService,
|
||||
AttendanceWriteService,
|
||||
DateRange,
|
||||
} from "./types"
|
||||
|
||||
// 暴露 getAttendanceRecordClassId(归属校验所需,不属于读写服务契约)
|
||||
export { getAttendanceRecordClassId } from "../data-access"
|
||||
157
src/modules/attendance/services/types.ts
Normal file
157
src/modules/attendance/services/types.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 考勤模块服务接口抽象。
|
||||
*
|
||||
* 设计目标(P1-12 / P2-6):
|
||||
* - 通过 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务。
|
||||
* - 模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access,
|
||||
* 只能通过注入的接口调用。
|
||||
* - 不同角色的差异通过接口的不同实现来隔离。
|
||||
* - 导出清晰的接口类型以便 mock(可测试性)。
|
||||
*
|
||||
* 使用方式:
|
||||
* - Server Component:通过 `createAttendanceDataService(scope)` 获取实现,
|
||||
* 以接口类型 `AttendanceDataService` 消费。
|
||||
* - Client Component:通过 `<AttendanceProvider service={...}>` 注入,
|
||||
* 内部用 `useAttendanceService()` 读取。
|
||||
*/
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
AttendanceListItem,
|
||||
AttendanceQueryParams,
|
||||
AttendanceRule,
|
||||
AttendanceStats,
|
||||
ClassAttendanceSummary,
|
||||
PaginatedAttendanceResult,
|
||||
StudentAttendanceSummary,
|
||||
} from "../types"
|
||||
import type {
|
||||
AttendanceRuleInput,
|
||||
BatchRecordAttendanceInput,
|
||||
RecordAttendanceInput,
|
||||
UpdateAttendanceInput,
|
||||
} from "../schema"
|
||||
|
||||
/** 日期范围参数。 */
|
||||
export interface DateRange {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤数据读取服务接口(查询契约)。
|
||||
* 消费方(parent / student / 其他模块)依赖此接口,不依赖具体 data-access 实现。
|
||||
*/
|
||||
export interface AttendanceReadService {
|
||||
/** 获取学生考勤汇总(统计 + 最近记录)。 */
|
||||
getStudentSummary(
|
||||
studentId: string,
|
||||
range?: DateRange,
|
||||
recentLimit?: number,
|
||||
): Promise<StudentAttendanceSummary | null>
|
||||
|
||||
/** 获取学生最近考勤记录。 */
|
||||
getRecentRecords(
|
||||
studentId: string,
|
||||
limit: number,
|
||||
): Promise<AttendanceListItem[]>
|
||||
|
||||
/** 按日期获取班级考勤记录(点名场景)。 */
|
||||
getClassRecordsForDate(
|
||||
classId: string,
|
||||
date: string,
|
||||
): Promise<AttendanceListItem[]>
|
||||
|
||||
/** 获取班级考勤统计。 */
|
||||
getClassStats(
|
||||
classId: string,
|
||||
range?: DateRange,
|
||||
): Promise<ClassAttendanceSummary | null>
|
||||
|
||||
/** 分页查询考勤记录(admin/teacher 列表场景)。 */
|
||||
queryRecords(
|
||||
params: AttendanceQueryParams & { scope: DataScope; currentUserId?: string },
|
||||
): Promise<PaginatedAttendanceResult>
|
||||
|
||||
/** 获取考勤总览统计(admin/teacher 仪表盘)。 */
|
||||
getOverviewStats(params: {
|
||||
scope: DataScope
|
||||
currentUserId: string
|
||||
classId?: string
|
||||
date?: string
|
||||
}): Promise<AttendanceStats>
|
||||
|
||||
/** 获取班级考勤规则。 */
|
||||
getRules(classId?: string): Promise<AttendanceRule[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤数据写入服务接口(命令契约)。
|
||||
* 仅教师/管理员角色实现;学生/家长角色不实现此接口(无写权限)。
|
||||
*/
|
||||
export interface AttendanceWriteService {
|
||||
/** 创建单条考勤记录。 */
|
||||
recordAttendance(
|
||||
data: RecordAttendanceInput,
|
||||
recordedBy: string,
|
||||
): Promise<string>
|
||||
|
||||
/** 批量创建考勤记录(点名)。 */
|
||||
batchRecordAttendance(
|
||||
data: BatchRecordAttendanceInput,
|
||||
recordedBy: string,
|
||||
): Promise<number>
|
||||
|
||||
/** 更新考勤记录。 */
|
||||
updateAttendance(id: string, data: UpdateAttendanceInput): Promise<void>
|
||||
|
||||
/** 删除考勤记录。 */
|
||||
deleteAttendance(id: string): Promise<void>
|
||||
|
||||
/** 保存(upsert)考勤规则。 */
|
||||
saveRules(data: AttendanceRuleInput): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的考勤数据服务接口(读 + 写)。
|
||||
* 教师与管理员角色使用此接口;学生/家长仅使用 `AttendanceReadService`。
|
||||
*/
|
||||
export interface AttendanceDataService
|
||||
extends AttendanceReadService,
|
||||
AttendanceWriteService {}
|
||||
|
||||
/**
|
||||
* 考勤 Repository 接口(P2-6)。
|
||||
* 仅供 data-access 层实现与测试 mock 使用,定义底层 CRUD 契约。
|
||||
*/
|
||||
export interface AttendanceRepository {
|
||||
findRecords(
|
||||
params: AttendanceQueryParams & { scope: DataScope; currentUserId?: string },
|
||||
): Promise<PaginatedAttendanceResult>
|
||||
findClassRecordsForDate(
|
||||
classId: string,
|
||||
date: string,
|
||||
): Promise<AttendanceListItem[]>
|
||||
findRecordClassId(id: string): Promise<string | null>
|
||||
createRecord(
|
||||
data: RecordAttendanceInput,
|
||||
recordedBy: string,
|
||||
): Promise<string>
|
||||
batchCreateRecords(
|
||||
data: BatchRecordAttendanceInput,
|
||||
recordedBy: string,
|
||||
): Promise<number>
|
||||
updateRecord(id: string, data: UpdateAttendanceInput): Promise<void>
|
||||
deleteRecord(id: string): Promise<void>
|
||||
findRules(classId?: string): Promise<AttendanceRule[]>
|
||||
upsertRules(data: AttendanceRuleInput): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤纯计算服务接口(可测试性:纯逻辑与 UI 分离)。
|
||||
* 实现见 `data-access-stats.ts` 的 `computeStats`。
|
||||
*/
|
||||
export interface AttendanceStatsCalculator {
|
||||
/** 根据记录行计算统计。 */
|
||||
computeStats(rows: { status: string }[]): AttendanceStats
|
||||
}
|
||||
85
src/modules/attendance/trend-compute.ts
Normal file
85
src/modules/attendance/trend-compute.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { AttendanceTrendPoint } from "./types"
|
||||
|
||||
/**
|
||||
* L-4:趋势粒度类型。
|
||||
*/
|
||||
export type TrendGranularity = "daily" | "weekly" | "monthly"
|
||||
|
||||
/**
|
||||
* 将日期字符串(YYYY-MM-DD)按粒度分组的 bucket key。
|
||||
* - daily:返回原日期
|
||||
* - weekly:返回该日期所在周的周一日期(ISO 周一为一周开始)
|
||||
* - monthly:返回该日期所在月份的第一天(YYYY-MM-01)
|
||||
*/
|
||||
export const bucketizeDate = (dateStr: string, granularity: TrendGranularity): string => {
|
||||
if (granularity === "daily") return dateStr
|
||||
|
||||
const d = new Date(dateStr)
|
||||
if (Number.isNaN(d.getTime())) return dateStr
|
||||
|
||||
if (granularity === "weekly") {
|
||||
// ISO 周一为一周开始:getDay() 周日=0, 周一=1, ... 周六=6
|
||||
const day = d.getDay()
|
||||
const diff = day === 0 ? -6 : 1 - day // 周日回到上周一,其他回到本周一
|
||||
const monday = new Date(d)
|
||||
monday.setDate(d.getDate() + diff)
|
||||
return monday.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// monthly
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 bucket key 为显示标签。
|
||||
* - daily:MM-DD
|
||||
* - weekly:MM-DD(周一日期)
|
||||
* - monthly:YYYY-MM
|
||||
*/
|
||||
export const formatBucketLabel = (bucketKey: string, granularity: TrendGranularity): string => {
|
||||
if (granularity === "monthly") {
|
||||
return bucketKey.slice(0, 7) // YYYY-MM
|
||||
}
|
||||
return bucketKey.slice(5) // MM-DD
|
||||
}
|
||||
|
||||
/**
|
||||
* L-4:从按日期的原始记录列表计算趋势数据点(纯函数,便于测试)。
|
||||
*
|
||||
* @param records 记录列表,每项含 date(YYYY-MM-DD)和 status
|
||||
* @param granularity 聚合粒度
|
||||
* @returns 按 bucket key 升序的趋势数据点
|
||||
*/
|
||||
export const computeTrendPoints = (
|
||||
records: { date: string; status: string }[],
|
||||
granularity: TrendGranularity
|
||||
): AttendanceTrendPoint[] => {
|
||||
if (records.length === 0) return []
|
||||
|
||||
// 按 bucket 聚合
|
||||
const buckets = new Map<string, { total: number; present: number; late: number; absent: number }>()
|
||||
|
||||
for (const r of records) {
|
||||
const bucket = bucketizeDate(r.date, granularity)
|
||||
const stat = buckets.get(bucket) ?? { total: 0, present: 0, late: 0, absent: 0 }
|
||||
stat.total += 1
|
||||
if (r.status === "present") stat.present += 1
|
||||
else if (r.status === "late") stat.late += 1
|
||||
else if (r.status === "absent") stat.absent += 1
|
||||
buckets.set(bucket, stat)
|
||||
}
|
||||
|
||||
// 按 bucket key 升序转换为趋势点
|
||||
const sortedBuckets = Array.from(buckets.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return sortedBuckets.map(([bucket, stat]) => {
|
||||
const { total, present, late, absent } = stat
|
||||
return {
|
||||
date: bucket,
|
||||
presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0,
|
||||
lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
|
||||
absentRate: total > 0 ? Math.round((absent / total) * 10000) / 100 : 0,
|
||||
total,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,25 @@
|
||||
export type AttendanceStatus = "present" | "absent" | "late" | "early_leave" | "excused"
|
||||
export type AttendanceStatus =
|
||||
| "present"
|
||||
| "absent"
|
||||
| "late"
|
||||
| "early_leave"
|
||||
| "excused"
|
||||
| "school_activity"
|
||||
|
||||
/**
|
||||
* L-6 节次考勤:节次类型。
|
||||
* - morning_reading: 早读
|
||||
* - morning: 上午
|
||||
* - afternoon: 下午
|
||||
* - evening: 晚自习
|
||||
* - full_day: 全日(默认值,向后兼容 null)
|
||||
*/
|
||||
export type AttendancePeriod =
|
||||
| "morning_reading"
|
||||
| "morning"
|
||||
| "afternoon"
|
||||
| "evening"
|
||||
| "full_day"
|
||||
|
||||
export interface AttendanceRecord {
|
||||
id: string
|
||||
@@ -7,7 +28,10 @@ export interface AttendanceRecord {
|
||||
scheduleId: string | null
|
||||
date: string
|
||||
status: AttendanceStatus
|
||||
/** L-6:节次(null 视为 full_day,向后兼容) */
|
||||
period: AttendancePeriod | null
|
||||
remark: string | null
|
||||
reason: string | null
|
||||
recordedBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -22,7 +46,10 @@ export interface AttendanceListItem {
|
||||
scheduleId: string | null
|
||||
date: string
|
||||
status: AttendanceStatus
|
||||
/** L-6:节次(null 视为 full_day,向后兼容) */
|
||||
period: AttendancePeriod | null
|
||||
remark: string | null
|
||||
reason: string | null
|
||||
recordedBy: string
|
||||
recorderName: string
|
||||
createdAt: string
|
||||
@@ -35,6 +62,7 @@ export interface AttendanceStats {
|
||||
late: number
|
||||
earlyLeave: number
|
||||
excused: number
|
||||
schoolActivity: number
|
||||
presentRate: number
|
||||
lateRate: number
|
||||
}
|
||||
@@ -60,10 +88,100 @@ export interface AttendanceRule {
|
||||
lateThresholdMinutes: number | null
|
||||
earlyLeaveThresholdMinutes: number | null
|
||||
enableAutoMark: boolean | null
|
||||
attendanceRateThreshold: number | null
|
||||
consecutiveAbsenceThreshold: number | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** L-3:出勤率预警严重等级。 */
|
||||
export type AttendanceWarningSeverity = "high" | "medium" | "low"
|
||||
|
||||
/** L-3:单个学生的出勤率预警。 */
|
||||
export interface AttendanceWarning {
|
||||
studentId: string
|
||||
studentName: string
|
||||
/** 触发的预警类型。 */
|
||||
type: "low_attendance_rate" | "consecutive_absence"
|
||||
/** 严重等级。 */
|
||||
severity: AttendanceWarningSeverity
|
||||
/** 当前数值(出勤率百分比或连续缺勤次数)。 */
|
||||
currentValue: number
|
||||
/** 配置的阈值。 */
|
||||
threshold: number
|
||||
/** 最近记录日期列表(用于连续缺勤场景,按日期升序)。 */
|
||||
relatedDates: string[]
|
||||
}
|
||||
|
||||
/** L-3:班级所有学生的预警汇总。 */
|
||||
export interface AttendanceWarningSummary {
|
||||
classId: string
|
||||
className: string
|
||||
/** 配置的阈值(取班级规则或默认值)。 */
|
||||
attendanceRateThreshold: number
|
||||
consecutiveAbsenceThreshold: number
|
||||
/** 触发的预警列表。 */
|
||||
warnings: AttendanceWarning[]
|
||||
}
|
||||
|
||||
/** L-4:单日趋势数据点。 */
|
||||
export interface AttendanceTrendPoint {
|
||||
/** 日期(YYYY-MM-DD)。 */
|
||||
date: string
|
||||
/** 出勤率(百分比,0-100)。 */
|
||||
presentRate: number
|
||||
/** 迟到率(百分比,0-100)。 */
|
||||
lateRate: number
|
||||
/** 缺勤率(百分比,0-100)。 */
|
||||
absentRate: number
|
||||
/** 当日记录总数。 */
|
||||
total: number
|
||||
}
|
||||
|
||||
/** L-4:班级考勤趋势汇总。 */
|
||||
export interface AttendanceTrendSummary {
|
||||
classId: string
|
||||
className: string
|
||||
/** 趋势粒度。 */
|
||||
granularity: "daily" | "weekly" | "monthly"
|
||||
/** 起始日期(含)。 */
|
||||
startDate: string
|
||||
/** 结束日期(含)。 */
|
||||
endDate: string
|
||||
/** 按时间升序的趋势数据点。 */
|
||||
points: AttendanceTrendPoint[]
|
||||
}
|
||||
|
||||
/** L-7:单个班级的对比数据点。 */
|
||||
export interface ClassComparisonItem {
|
||||
classId: string
|
||||
className: string
|
||||
/** 总记录数。 */
|
||||
total: number
|
||||
/** 出勤率(百分比,0-100)。 */
|
||||
presentRate: number
|
||||
/** 迟到率(百分比,0-100)。 */
|
||||
lateRate: number
|
||||
/** 缺勤率(百分比,0-100)。 */
|
||||
absentRate: number
|
||||
}
|
||||
|
||||
/** L-7:同年级班级出勤率对比汇总。 */
|
||||
export interface ClassComparisonSummary {
|
||||
/** 年级 ID。 */
|
||||
gradeId: string
|
||||
/** 年级名称。 */
|
||||
gradeName: string
|
||||
/** 起始日期(含)。 */
|
||||
startDate: string
|
||||
/** 结束日期(含)。 */
|
||||
endDate: string
|
||||
/** 各班级对比数据(按出勤率降序)。 */
|
||||
items: ClassComparisonItem[]
|
||||
/** 年级平均出勤率。 */
|
||||
averagePresentRate: number
|
||||
}
|
||||
|
||||
export interface AttendanceQueryParams {
|
||||
classId?: string
|
||||
studentId?: string
|
||||
@@ -71,10 +189,71 @@ export interface AttendanceQueryParams {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
status?: AttendanceStatus
|
||||
/** L-6:按节次过滤(null/undefined 表示不按节次过滤) */
|
||||
period?: AttendancePeriod
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* L-9 考勤与成绩关联分析:风险等级。
|
||||
* - high:低出勤(<80%)且低分(<60)
|
||||
* - medium:中等出勤(<90%)且中低分(<75),但未达 high
|
||||
* - low:正常(其余情况)
|
||||
*/
|
||||
export type AttendanceGradeRiskLevel = "high" | "medium" | "low"
|
||||
|
||||
/**
|
||||
* L-9:单个学生在班级内的考勤-成绩关联数据点。
|
||||
* 每个学生作为散点图中的一个点(x=attendanceRate, y=averageScore)。
|
||||
*/
|
||||
export interface AttendanceGradeCorrelationItem {
|
||||
studentId: string
|
||||
studentName: string
|
||||
/** 出勤率(0-100,present+late 计为出勤) */
|
||||
attendanceRate: number
|
||||
/** 出勤记录总数 */
|
||||
attendanceRecordCount: number
|
||||
/** 缺勤次数(absent + early_leave) */
|
||||
absentCount: number
|
||||
/** 平均成绩(0-100,跨学科归一化后的加权平均) */
|
||||
averageScore: number
|
||||
/** 成绩记录总数 */
|
||||
gradeRecordCount: number
|
||||
/** 风险等级 */
|
||||
riskLevel: AttendanceGradeRiskLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* L-9:班级考勤-成绩关联分析汇总。
|
||||
*/
|
||||
export interface AttendanceGradeCorrelationSummary {
|
||||
classId: string
|
||||
className: string
|
||||
/** 起始日期(含) */
|
||||
startDate: string
|
||||
/** 结束日期(含) */
|
||||
endDate: string
|
||||
/** 学生数据点列表(按风险等级降序,再按出勤率升序) */
|
||||
items: AttendanceGradeCorrelationItem[]
|
||||
/** Pearson 相关系数 r(-1 到 +1),数据不足时为 null */
|
||||
correlation: number | null
|
||||
/** 相关系数解释(i18n key 后缀,如 "strong_negative") */
|
||||
correlationInterpretation:
|
||||
| "strong_negative"
|
||||
| "weak_negative"
|
||||
| "negligible"
|
||||
| "weak_positive"
|
||||
| "strong_positive"
|
||||
| "insufficient_data"
|
||||
/** 各风险等级学生数 */
|
||||
riskCounts: {
|
||||
high: number
|
||||
medium: number
|
||||
low: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaginatedAttendanceResult {
|
||||
items: AttendanceListItem[]
|
||||
total: number
|
||||
|
||||
152
src/modules/attendance/warning-compute.ts
Normal file
152
src/modules/attendance/warning-compute.ts
Normal 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: [],
|
||||
})
|
||||
Reference in New Issue
Block a user