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>
249 lines
9.8 KiB
TypeScript
249 lines
9.8 KiB
TypeScript
"use client"
|
|
|
|
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_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,
|
|
} from "../types"
|
|
import { selectCourseAction, dropCourseAction } from "../actions"
|
|
|
|
export function StudentSelectionView({
|
|
availableCourses,
|
|
mySelections,
|
|
}: {
|
|
availableCourses: ElectiveCourseWithDetails[]
|
|
mySelections: CourseSelectionWithDetails[]
|
|
}) {
|
|
const router = useRouter()
|
|
const t = useTranslations("elective")
|
|
const [pendingId, setPendingId] = useState<string | null>(null)
|
|
const [isPending, startTransition] = useTransition()
|
|
|
|
const activeSelections = mySelections.filter((s) =>
|
|
["selected", "enrolled", "waitlist"].includes(s.status)
|
|
)
|
|
const selectedCourseIds = new Set(
|
|
activeSelections.map((s) => s.courseId)
|
|
)
|
|
|
|
const handleSelect = (courseId: string) => {
|
|
setPendingId(courseId)
|
|
startTransition(async () => {
|
|
const formData = new FormData()
|
|
formData.set("courseId", courseId)
|
|
const res = await selectCourseAction(null, formData)
|
|
if (res.success) {
|
|
toast.success(res.message || t("student.selectSuccess"))
|
|
router.refresh()
|
|
} else {
|
|
toast.error(res.message ?? t("errors.unexpected"))
|
|
}
|
|
setPendingId(null)
|
|
})
|
|
}
|
|
|
|
const handleDrop = (courseId: string) => {
|
|
setPendingId(courseId)
|
|
startTransition(async () => {
|
|
const formData = new FormData()
|
|
formData.set("courseId", courseId)
|
|
const res = await dropCourseAction(null, formData)
|
|
if (res.success) {
|
|
toast.success(res.message || t("student.dropSuccess"))
|
|
router.refresh()
|
|
} else {
|
|
toast.error(res.message ?? t("errors.unexpected"))
|
|
}
|
|
setPendingId(null)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<section className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">{t("student.mySelections")}</h3>
|
|
<span className="text-sm text-muted-foreground">
|
|
{activeSelections.length}
|
|
</span>
|
|
</div>
|
|
{activeSelections.length === 0 ? (
|
|
<EmptyState
|
|
title={t("list.empty")}
|
|
description={t("description.student")}
|
|
icon={BookOpen}
|
|
className="h-auto border-none shadow-none"
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{activeSelections.map((sel) => (
|
|
<Card key={sel.id}>
|
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
|
<CardTitle className="text-base">
|
|
{sel.courseName ?? t("errors.notFound")}
|
|
</CardTitle>
|
|
<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">
|
|
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
|
</p>
|
|
) : null}
|
|
{sel.lotteryRank ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
#{sel.lotteryRank}
|
|
</p>
|
|
) : null}
|
|
<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>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
|
|
<span className="text-sm text-muted-foreground">
|
|
{availableCourses.length}
|
|
</span>
|
|
</div>
|
|
{availableCourses.length === 0 ? (
|
|
<EmptyState
|
|
title={t("list.emptyStudent")}
|
|
description={t("description.student")}
|
|
icon={BookOpen}
|
|
className="h-auto border-none shadow-none"
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{availableCourses.map((course) => {
|
|
const isFull = course.enrolledCount >= course.capacity
|
|
const alreadySelected = selectedCourseIds.has(course.id)
|
|
const isPendingThis = isPending && pendingId === course.id
|
|
return (
|
|
<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={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
|
|
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
|
</Badge>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-1 flex-col gap-3">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
{course.subjectName ? (
|
|
<Badge variant="outline">{course.subjectName}</Badge>
|
|
) : null}
|
|
<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">
|
|
{course.description}
|
|
</p>
|
|
) : null}
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
|
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
|
<span className="font-medium">
|
|
{course.enrolledCount}/{course.capacity}
|
|
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{course.schedule ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
<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" />
|
|
{t("student.selected")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
disabled={isPendingThis}
|
|
onClick={() => handleSelect(course.id)}
|
|
>
|
|
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|