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:
SpecialX
2026-06-22 17:33:29 +08:00
parent 76966581b8
commit f62b8c0f86
46 changed files with 1748 additions and 545 deletions

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

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

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

View File

@@ -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[]
}