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:
SpecialX
2026-06-23 09:02:41 +08:00
parent c766951374
commit e2e0487a3b
50 changed files with 1514 additions and 411 deletions

View File

@@ -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 ? (