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