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 相关零错误
281 lines
9.3 KiB
TypeScript
281 lines
9.3 KiB
TypeScript
"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>
|
||
)
|
||
}
|