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