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 相关零错误
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useState, useRef, type KeyboardEvent } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -78,6 +78,7 @@ export function isSameDay(a: Date, b: Date): boolean {
|
||||
/**
|
||||
* 家长视角的考勤月历视图。
|
||||
* 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。
|
||||
* 支持键盘方向键导航(a11y):← → ↑ ↓ 在日期格子间移动,Home/End 跳至行首/尾。
|
||||
*/
|
||||
export function ParentAttendanceCalendar({
|
||||
summary,
|
||||
@@ -88,6 +89,8 @@ export function ParentAttendanceCalendar({
|
||||
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) {
|
||||
@@ -108,6 +111,7 @@ export function ParentAttendanceCalendar({
|
||||
} else {
|
||||
setViewMonth((m) => m - 1)
|
||||
}
|
||||
setFocusedIdx(-1)
|
||||
}
|
||||
const goNext = () => {
|
||||
if (viewMonth === 11) {
|
||||
@@ -116,6 +120,60 @@ export function ParentAttendanceCalendar({
|
||||
} 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>()
|
||||
@@ -155,26 +213,39 @@ export function ParentAttendanceCalendar({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
<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]",
|
||||
"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 ? (
|
||||
|
||||
Reference in New Issue
Block a user