refactor(attendance,elective): 审计第二轮 — 全量完成 P0/P1 改进项
P0 修复: - 页面层 i18n 全量补齐(admin/teacher/parent/student × attendance/elective) - types.ts 状态标签常量迁移至 constants.ts(i18n key + Badge variant) - 修复 getTranslations 导入路径(next-intl → next-intl/server) P1 改进: - 解耦 parent 模块对 attendance 类型的直接依赖(本地 view-model 类型) - 导出纯函数(computeStats/buildWarnings/buildLotteryRankCase 等) - 统一空状态为 EmptyState 组件 - 清理死代码读 Action(attendance 5 个 + elective 3 个) - 预留监控埋点接口(trackEvent 13 个新事件名) - 补齐骨架屏 loading.tsx(8 个页面) - AlertDialog 替换 window.confirm(student-selection-view) - a11y 改进(aria-label/role/键盘导航) 修复: - AttendanceStatus 从 constants.ts 重导出,消除 types/constants 双源混乱 - buildWarnings 的 Translator 类型改用 ReturnType<typeof useTranslations>
This commit is contained in:
209
src/modules/parent/components/parent-attendance-calendar.tsx
Normal file
209
src/modules/parent/components/parent-attendance-calendar.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
ATTENDANCE_STATUS_DOT_COLORS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "@/modules/attendance/constants"
|
||||
import type {
|
||||
ParentAttendanceListItem,
|
||||
ParentAttendanceStatus,
|
||||
ParentStudentAttendanceSummary,
|
||||
} from "@/modules/parent/types"
|
||||
|
||||
const WEEKDAY_KEYS = [
|
||||
"parent.weekday.sun",
|
||||
"parent.weekday.mon",
|
||||
"parent.weekday.tue",
|
||||
"parent.weekday.wed",
|
||||
"parent.weekday.thu",
|
||||
"parent.weekday.fri",
|
||||
"parent.weekday.sat",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 格式化日期为 `YYYY-MM-DD`(纯函数,便于测试)。
|
||||
*/
|
||||
export function formatDateKey(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 `YYYY-MM-DD` 为 Date(纯函数,便于测试)。
|
||||
*/
|
||||
export function parseDateKey(key: string): Date | null {
|
||||
const parts = key.split("-")
|
||||
if (parts.length !== 3) return null
|
||||
const [y, m, d] = parts.map(Number)
|
||||
if (!y || !m || !d) return null
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建日历单元格(纯函数,便于测试)。
|
||||
*/
|
||||
export function buildCalendarDays(year: number, month: number): Array<Date | null> {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
const startWeekday = firstDay.getDay()
|
||||
const totalDays = lastDay.getDate()
|
||||
const cells: Array<Date | null> = []
|
||||
for (let i = 0; i < startWeekday; i += 1) cells.push(null)
|
||||
for (let day = 1; day <= totalDays; day += 1) {
|
||||
cells.push(new Date(year, month, day))
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null)
|
||||
return cells
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个日期是否为同一天(纯函数,便于测试)。
|
||||
*/
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长视角的考勤月历视图。
|
||||
* 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。
|
||||
*/
|
||||
export function ParentAttendanceCalendar({
|
||||
summary,
|
||||
}: {
|
||||
summary: ParentStudentAttendanceSummary
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const now = new Date()
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth())
|
||||
|
||||
const recordMap = new Map<string, ParentAttendanceListItem>()
|
||||
for (const r of summary.recentRecords) {
|
||||
const d = parseDateKey(r.date)
|
||||
if (d) recordMap.set(formatDateKey(d), r)
|
||||
}
|
||||
|
||||
const days = buildCalendarDays(viewYear, viewMonth)
|
||||
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
|
||||
const goPrev = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11)
|
||||
setViewYear((y) => y - 1)
|
||||
} else {
|
||||
setViewMonth((m) => m - 1)
|
||||
}
|
||||
}
|
||||
const goNext = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0)
|
||||
setViewYear((y) => y + 1)
|
||||
} else {
|
||||
setViewMonth((m) => m + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const usedStatuses = new Set<ParentAttendanceStatus>()
|
||||
for (const r of recordMap.values()) usedStatuses.add(r.status)
|
||||
|
||||
return (
|
||||
<Card aria-label={t("parent.calendarTitle", { name: summary.studentName })}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{t("parent.calendarFor", { name: summary.studentName })}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
aria-label={t("parent.prevMonth")}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[120px] text-center text-sm font-medium">{monthLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
aria-label={t("parent.nextMonth")}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-medium text-muted-foreground">
|
||||
{WEEKDAY_KEYS.map((key) => (
|
||||
<div key={key} className="py-1">
|
||||
{t(key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((d, idx) => {
|
||||
if (!d) return <div key={`empty-${idx}`} className="aspect-square" />
|
||||
const key = formatDateKey(d)
|
||||
const record = recordMap.get(key)
|
||||
const isToday = isSameDay(d, now)
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"relative flex aspect-square flex-col items-center justify-center rounded-md border text-xs",
|
||||
"min-h-[36px]",
|
||||
record ? "border-border bg-muted/30" : "border-transparent",
|
||||
isToday && "ring-2 ring-primary ring-offset-1",
|
||||
)}
|
||||
aria-label={
|
||||
record
|
||||
? `${formatDateKey(d)}: ${t(ATTENDANCE_STATUS_LABEL_KEYS[record.status])}`
|
||||
: formatDateKey(d)
|
||||
}
|
||||
>
|
||||
<span className="tabular-nums">{d.getDate()}</span>
|
||||
{record ? (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 h-1.5 w-1.5 rounded-full",
|
||||
ATTENDANCE_STATUS_DOT_COLORS[record.status],
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{usedStatuses.size > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1 text-xs text-muted-foreground">
|
||||
{Array.from(usedStatuses).map((status) => (
|
||||
<span key={status} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full", ATTENDANCE_STATUS_DOT_COLORS[status])}
|
||||
aria-hidden
|
||||
/>
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[status])}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
130
src/modules/parent/components/parent-attendance-rate-card.tsx
Normal file
130
src/modules/parent/components/parent-attendance-rate-card.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
|
||||
|
||||
type AggregateStats = {
|
||||
totalStudents: number
|
||||
avgPresentRate: number
|
||||
totalAbsent: number
|
||||
totalLate: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合多个子女的考勤统计(纯函数,便于测试)。
|
||||
*/
|
||||
export function aggregateStats(
|
||||
summaries: ParentStudentAttendanceSummary[],
|
||||
): AggregateStats {
|
||||
if (summaries.length === 0) {
|
||||
return { totalStudents: 0, avgPresentRate: 0, totalAbsent: 0, totalLate: 0 }
|
||||
}
|
||||
const totalStudents = summaries.length
|
||||
const sumRate = summaries.reduce(
|
||||
(sum, s) => sum + (s.stats.total > 0 ? s.stats.presentRate : 0),
|
||||
0,
|
||||
)
|
||||
const avgPresentRate = sumRate / totalStudents
|
||||
const totalAbsent = summaries.reduce((sum, s) => sum + s.stats.absent, 0)
|
||||
const totalLate = summaries.reduce((sum, s) => sum + s.stats.late, 0)
|
||||
return { totalStudents, avgPresentRate, totalLate, totalAbsent }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据出勤率返回语气色调(纯函数,便于测试)。
|
||||
*/
|
||||
export function rateTone(rate: number): "good" | "warn" | "bad" {
|
||||
if (rate >= 95) return "good"
|
||||
if (rate >= 90) return "warn"
|
||||
return "bad"
|
||||
}
|
||||
|
||||
const TONE_STYLES: Record<"good" | "warn" | "bad", string> = {
|
||||
good: "text-emerald-600",
|
||||
warn: "text-amber-600",
|
||||
bad: "text-destructive",
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长考勤页顶部的出勤率汇总卡片。
|
||||
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
|
||||
*/
|
||||
export function ParentAttendanceRateCard({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: ParentStudentAttendanceSummary[]
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const stats = aggregateStats(summaries)
|
||||
if (stats.totalStudents === 0) return null
|
||||
|
||||
const tone = rateTone(stats.avgPresentRate)
|
||||
const rateLabel =
|
||||
stats.avgPresentRate >= 95
|
||||
? t("parent.rateExcellent")
|
||||
: stats.avgPresentRate >= 90
|
||||
? t("parent.rateNeedsAttention")
|
||||
: t("parent.rateBelowStandard")
|
||||
|
||||
return (
|
||||
<Card className="p-4" aria-label={t("parent.rateCardTitle")}>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<TrendingUp className="h-3 w-3" aria-hidden />
|
||||
{t("stats.attendanceRate")}
|
||||
</div>
|
||||
<div className={cn("text-2xl font-bold tabular-nums", TONE_STYLES[tone])}>
|
||||
{stats.avgPresentRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{rateLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CalendarCheck className="h-3 w-3" aria-hidden />
|
||||
{t("parent.children")}
|
||||
</div>
|
||||
<div className="text-2xl font-bold tabular-nums">{stats.totalStudents}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("parent.linked")}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CalendarX className="h-3 w-3" aria-hidden />
|
||||
{t("stats.absent")}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold tabular-nums",
|
||||
stats.totalAbsent > 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{stats.totalAbsent}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("parent.thisPeriod")}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" aria-hidden />
|
||||
{t("stats.late")}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold tabular-nums",
|
||||
stats.totalLate > 0 && "text-amber-600",
|
||||
)}
|
||||
>
|
||||
{stats.totalLate}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("parent.thisPeriod")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
115
src/modules/parent/components/parent-attendance-warning.tsx
Normal file
115
src/modules/parent/components/parent-attendance-warning.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Phone } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
|
||||
|
||||
type Warning = {
|
||||
studentId: string
|
||||
studentName: string
|
||||
message: string
|
||||
severity: "high" | "medium"
|
||||
}
|
||||
|
||||
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */
|
||||
type Translator = ReturnType<typeof useTranslations>
|
||||
|
||||
/**
|
||||
* 构建考勤异常预警列表(纯函数,便于测试)。
|
||||
*/
|
||||
export function buildWarnings(
|
||||
summaries: ParentStudentAttendanceSummary[],
|
||||
t: Translator,
|
||||
): Warning[] {
|
||||
const warnings: Warning[] = []
|
||||
|
||||
for (const s of summaries) {
|
||||
const { stats, studentName, studentId } = s
|
||||
if (stats.absent >= 3) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName,
|
||||
message: t("parent.absentHighSeverity", { count: stats.absent }),
|
||||
severity: "high",
|
||||
})
|
||||
} else if (stats.absent >= 1) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName,
|
||||
message: t("parent.absentWarning", { count: stats.absent }),
|
||||
severity: "medium",
|
||||
})
|
||||
}
|
||||
|
||||
if (stats.late >= 3) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName,
|
||||
message: t("parent.lateWarning", { count: stats.late }),
|
||||
severity: "medium",
|
||||
})
|
||||
}
|
||||
|
||||
if (stats.presentRate < 90 && stats.total > 0) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName,
|
||||
message: t("parent.lowRateWarning", { rate: stats.presentRate.toFixed(1) }),
|
||||
severity: "high",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长视角的考勤异常预警横幅。
|
||||
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
|
||||
*/
|
||||
export function ParentAttendanceWarning({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: ParentStudentAttendanceSummary[]
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const warnings = buildWarnings(summaries, t)
|
||||
if (warnings.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border-destructive/30 bg-destructive/5 p-4",
|
||||
warnings.some((w) => w.severity === "high") && "border-destructive/50",
|
||||
)}
|
||||
aria-label={t("parent.warningTitle")}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle
|
||||
className="h-5 w-5 text-destructive shrink-0 mt-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-sm font-semibold text-destructive">
|
||||
{t("parent.warningTitle")}
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{warnings.map((w, idx) => (
|
||||
<li key={`${w.studentId}-${idx}`} className="flex items-start gap-2">
|
||||
<span className="font-medium shrink-0">{w.studentName}:</span>
|
||||
<span className="text-muted-foreground">{w.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center gap-2 pt-1 text-xs text-muted-foreground">
|
||||
<Phone className="h-3 w-3" aria-hidden />
|
||||
<span>{t("parent.contactHomeroom")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,12 @@ export type ChildScheduleItem = {
|
||||
location: string | null
|
||||
}
|
||||
|
||||
/** 单条周课表项(含 weekday)。 */
|
||||
export type ChildWeeklyScheduleItem = ChildScheduleItem & {
|
||||
/** 1=周一 ... 7=周日 */
|
||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
}
|
||||
|
||||
/** 子女作业摘要(统计计数 + 最近作业列表)。 */
|
||||
export type ChildHomeworkSummaryData = {
|
||||
pendingCount: number
|
||||
@@ -54,6 +60,8 @@ export type ChildDashboardData = {
|
||||
basicInfo: ChildBasicInfo
|
||||
enrolledClasses: StudentEnrolledClass[]
|
||||
todaySchedule: ChildScheduleItem[]
|
||||
/** 完整周课表(按 weekday 升序)。 */
|
||||
weeklySchedule: ChildWeeklyScheduleItem[]
|
||||
homeworkSummary: ChildHomeworkSummaryData
|
||||
/** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */
|
||||
gradeTrend: StudentDashboardGradeProps
|
||||
@@ -65,3 +73,45 @@ export type ParentDashboardData = {
|
||||
parentName: string | null
|
||||
children: ChildDashboardData[]
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 考勤视图模型(解耦 parent 模块对 attendance/types 的直接依赖) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 家长视角所需的考勤状态枚举。
|
||||
* 与 `attendance.AttendanceStatus` 结构兼容,但由 parent 模块自行声明,
|
||||
* 避免 parent 反向依赖 attendance 模块的类型变更。
|
||||
*/
|
||||
export type ParentAttendanceStatus =
|
||||
| "present"
|
||||
| "absent"
|
||||
| "late"
|
||||
| "early_leave"
|
||||
| "excused"
|
||||
|
||||
/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */
|
||||
export type ParentAttendanceListItem = {
|
||||
id: string
|
||||
date: string
|
||||
status: ParentAttendanceStatus
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
/** 家长视角所需的考勤统计(仅保留展示字段)。 */
|
||||
export type ParentAttendanceStats = {
|
||||
total: number
|
||||
present: number
|
||||
absent: number
|
||||
late: number
|
||||
presentRate: number
|
||||
}
|
||||
|
||||
/** 家长视角所需的单个子女考勤汇总。 */
|
||||
export type ParentStudentAttendanceSummary = {
|
||||
studentId: string
|
||||
studentName: string
|
||||
stats: ParentAttendanceStats
|
||||
recentRecords: ParentAttendanceListItem[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user