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:
@@ -3,19 +3,32 @@
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
import {
|
||||
COURSE_SELECTION_STATUS_COLORS,
|
||||
COURSE_SELECTION_STATUS_LABELS,
|
||||
ELECTIVE_STATUS_LABELS,
|
||||
SELECTION_MODE_LABELS,
|
||||
} from "../types"
|
||||
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
|
||||
COURSE_SELECTION_STATUS_LABEL_KEYS,
|
||||
ELECTIVE_STATUS_BADGE_VARIANTS,
|
||||
ELECTIVE_STATUS_LABEL_KEYS,
|
||||
SELECTION_MODE_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type {
|
||||
CourseSelectionWithDetails,
|
||||
ElectiveCourseWithDetails,
|
||||
@@ -30,6 +43,7 @@ export function StudentSelectionView({
|
||||
mySelections: CourseSelectionWithDetails[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
@@ -47,10 +61,10 @@ export function StudentSelectionView({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await selectCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
toast.success(res.message || t("student.selectSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Failed to select course")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -63,10 +77,10 @@ export function StudentSelectionView({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await dropCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
toast.success(res.message || t("student.dropSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Failed to drop course")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -76,15 +90,15 @@ export function StudentSelectionView({
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">My Selections</h3>
|
||||
<h3 className="text-lg font-semibold">{t("student.mySelections")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activeSelections.length} active
|
||||
{activeSelections.length}
|
||||
</span>
|
||||
</div>
|
||||
{activeSelections.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No selections yet"
|
||||
description="Browse available courses below and select your electives."
|
||||
title={t("list.empty")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
@@ -94,33 +108,52 @@ export function StudentSelectionView({
|
||||
<Card key={sel.id}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
{sel.courseName ?? "Unknown course"}
|
||||
{sel.courseName ?? t("errors.notFound")}
|
||||
</CardTitle>
|
||||
<Badge variant={COURSE_SELECTION_STATUS_COLORS[sel.status]}>
|
||||
{COURSE_SELECTION_STATUS_LABELS[sel.status]}
|
||||
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
|
||||
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enrolled: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
</p>
|
||||
) : null}
|
||||
{sel.lotteryRank ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lottery rank: #{sel.lotteryRank}
|
||||
#{sel.lotteryRank}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending && pendingId === sel.courseId}
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
Drop
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending && pendingId === sel.courseId}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("actions.drop")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("student.confirmDrop")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
{t("actions.drop")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -130,15 +163,15 @@ export function StudentSelectionView({
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Available Courses</h3>
|
||||
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{availableCourses.length} open
|
||||
{availableCourses.length}
|
||||
</span>
|
||||
</div>
|
||||
{availableCourses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No available courses"
|
||||
description="There are no elective courses open for selection right now."
|
||||
title={t("list.emptyStudent")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
@@ -149,11 +182,11 @@ export function StudentSelectionView({
|
||||
const alreadySelected = selectedCourseIds.has(course.id)
|
||||
const isPendingThis = isPending && pendingId === course.id
|
||||
return (
|
||||
<Card key={course.id} className="flex h-full flex-col">
|
||||
<Card key={course.id} className="flex h-full flex-col" role="article" aria-label={course.name}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||
<Badge variant="outline">
|
||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
|
||||
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
@@ -161,8 +194,8 @@ export function StudentSelectionView({
|
||||
{course.subjectName ? (
|
||||
<Badge variant="outline">{course.subjectName}</Badge>
|
||||
) : null}
|
||||
<span>Credit: {course.credit}</span>
|
||||
<span>· {SELECTION_MODE_LABELS[course.selectionMode]}</span>
|
||||
<span>{t("fields.credit")}: {course.credit}</span>
|
||||
<span>· {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}</span>
|
||||
</div>
|
||||
{course.description ? (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
@@ -171,27 +204,27 @@ export function StudentSelectionView({
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? " (Full)" : ""}
|
||||
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-auto pt-2">
|
||||
{alreadySelected ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Already selected
|
||||
{t("student.selected")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -199,7 +232,7 @@ export function StudentSelectionView({
|
||||
disabled={isPendingThis}
|
||||
onClick={() => handleSelect(course.id)}
|
||||
>
|
||||
{isPendingThis ? "Selecting..." : "Select"}
|
||||
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user