Files
NextEdu/src/modules/parent/components/parent-attendance-calendar.tsx
SpecialX e2e0487a3b feat(attendance,elective): 实现所有 P2 长期改进项
P2 修复(来自审计报告):
- 2.4.4: Server Action 错误消息 i18n 化(attendance/elective 全部 Action)
- 2.5.3: 抽取 AttendancePageLayout 组件复用(admin/teacher 页面)
- 2.5.4: 抽取 ElectivePageLayout 组件复用(admin/teacher 列表页)
- 2.6.3: 考勤月历键盘导航(tabIndex + 方向键 + Home/End + role=grid)
- 2.8.2: getStudentAttendanceSummary 分页优化(SQL 聚合统计 + LIMIT 分页)
- 2.8.3: resolveCourseDisplayNames 缓存优化(React cache 去重)
- 2.1.4: elective data-access 跨模块依赖接口抽象(resolvers.ts 可注入)

P2 建议项:
- 选课时间冲突检测(parseSchedule + isScheduleConflict 纯函数 + checkScheduleConflict)
- 学分上限校验(MAX_CREDIT_PER_TERM + checkCreditLimit)
- 考勤/选课数据导出 Excel(export.ts + API 路由扩展)

新增文件:
- src/modules/attendance/components/attendance-page-layout.tsx
- src/modules/elective/components/elective-page-layout.tsx
- src/modules/elective/resolvers.ts
- src/modules/attendance/export.ts
- src/modules/elective/export.ts

校验:
- npm run lint 通过(exit 0)
- npx tsc --noEmit attendance/elective/parent 相关零错误
2026-06-23 09:02:41 +08:00

281 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { useState, useRef, type KeyboardEvent } 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()
)
}
/**
* 家长视角的考勤月历视图。
* 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。
* 支持键盘方向键导航a11y← → ↑ ↓ 在日期格子间移动Home/End 跳至行首/尾。
*/
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 [focusedIdx, setFocusedIdx] = useState<number>(-1)
const gridRef = useRef<HTMLDivElement>(null)
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)
}
setFocusedIdx(-1)
}
const goNext = () => {
if (viewMonth === 11) {
setViewMonth(0)
setViewYear((y) => y + 1)
} else {
setViewMonth((m) => m + 1)
}
setFocusedIdx(-1)
}
/**
* 键盘导航方向键在日期格子间移动Home/End 跳至行首/尾。
* 仅在非空格子(有 Date 的格子)间移动,跳过 null 占位。
*/
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (focusedIdx < 0) return
const nonNullIndices = days
.map((d, i) => (d ? i : -1))
.filter((i) => i >= 0)
const currentPos = nonNullIndices.indexOf(focusedIdx)
if (currentPos < 0) return
const cols = 7
const currentRow = Math.floor(currentPos / cols)
const currentCol = currentPos % cols
let nextPos = currentPos
switch (e.key) {
case "ArrowRight":
nextPos = Math.min(currentPos + 1, nonNullIndices.length - 1)
break
case "ArrowLeft":
nextPos = Math.max(currentPos - 1, 0)
break
case "ArrowDown": {
const target = (currentRow + 1) * cols + currentCol
nextPos = target < nonNullIndices.length ? target : currentPos
break
}
case "ArrowUp": {
const target = (currentRow - 1) * cols + currentCol
nextPos = target >= 0 ? target : currentPos
break
}
case "Home":
nextPos = currentRow * cols
break
case "End":
nextPos = Math.min((currentRow + 1) * cols - 1, nonNullIndices.length - 1)
break
default:
return
}
e.preventDefault()
const nextIdx = nonNullIndices[nextPos]
if (nextIdx !== undefined && nextIdx !== focusedIdx) {
setFocusedIdx(nextIdx)
const cell = gridRef.current?.querySelector<HTMLElement>(`[data-day-idx="${nextIdx}"]`)
cell?.focus()
}
}
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
ref={gridRef}
className="grid grid-cols-7 gap-1"
onKeyDown={handleKeyDown}
role="grid"
aria-label={monthLabel}
>
{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)
const isFocused = focusedIdx === idx
return (
<div
key={key}
data-day-idx={idx}
tabIndex={isFocused || (focusedIdx < 0 && isToday) ? 0 : -1}
onFocus={() => setFocusedIdx(idx)}
className={cn(
"relative flex aspect-square flex-col items-center justify-center rounded-md border text-xs",
"min-h-[36px] cursor-default",
record ? "border-border bg-muted/30" : "border-transparent",
isToday && "ring-2 ring-primary ring-offset-1",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
)}
role="gridcell"
aria-label={
record
? `${formatDateKey(d)}: ${t(ATTENDANCE_STATUS_LABEL_KEYS[record.status])}`
: formatDateKey(d)
}
aria-selected={isFocused}
>
<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>
)
}