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

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

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

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

- Add attendance-report-print for printable reports

- Add class-comparison-card for class attendance comparison

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

View File

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

View File

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

View File

@@ -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>

View 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">&nbsp;</div>
</div>
<div>
<div className="mb-1 text-sm text-muted-foreground">{t("report.signature")}</div>
<div className="border-b border-dashed pb-1">&nbsp;</div>
</div>
<div>
<div className="mb-1 text-sm text-muted-foreground">{t("report.relationship")}</div>
<div className="border-b border-dashed pb-1">&nbsp;</div>
</div>
<div>
<div className="mb-1 text-sm text-muted-foreground">{t("report.signDate")}</div>
<div className="border-b border-dashed pb-1">&nbsp;</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">&nbsp;</div>
</div>
</div>
</div>
{/* 报告页脚 */}
<div className="border-t pt-2 text-xs text-muted-foreground">
<p>{t("report.footer")}</p>
</div>
</div>
)}
</>
)
}

View File

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

View File

@@ -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>

View File

@@ -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)}%`}

View File

@@ -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">

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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"

View File

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

View 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 scopeowned不应访问班级级分析返回 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 * 100late 视为出勤但迟到)
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 不允许班级级分析
}

View File

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

View File

@@ -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-6null 视为 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 = reviewerIdreason 注明请假类型)
* - 已存在非 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
}

View File

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

View 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},请关注子女考勤情况。`
}

View File

@@ -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>

View 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
}

View 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"

View 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
}

View 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 为显示标签。
* - dailyMM-DD
* - weeklyMM-DD周一日期
* - monthlyYYYY-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 记录列表,每项含 dateYYYY-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,
}
})
}

View File

@@ -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-100present+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

View File

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