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:
SpecialX
2026-06-22 17:33:29 +08:00
parent 76966581b8
commit f62b8c0f86
46 changed files with 1748 additions and 545 deletions

View File

@@ -19,6 +19,7 @@ import {
} from "@/shared/components/ui/select"
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
import { isSelectionMode } from "../constants"
import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types"
type Mode = "create" | "edit"
@@ -28,9 +29,6 @@ interface Option {
name: string
}
const isSelectionMode = (v: string): v is ElectiveSelectionMode =>
v === "fcfs" || v === "lottery"
export function ElectiveCourseForm({
mode,
course,

View File

@@ -15,10 +15,10 @@ import type { ActionState } from "@/shared/types/action-state"
import { Permissions } from "@/shared/types/permissions"
import {
ELECTIVE_STATUS_COLORS,
ELECTIVE_STATUS_LABELS,
SELECTION_MODE_LABELS,
} from "../types"
ELECTIVE_STATUS_BADGE_VARIANTS,
ELECTIVE_STATUS_LABEL_KEYS,
SELECTION_MODE_LABEL_KEYS,
} from "../constants"
import type { ElectiveCourseWithDetails } from "../types"
import {
deleteElectiveCourseAction,
@@ -59,7 +59,7 @@ export function ElectiveCourseList({
toast.success(res.message ?? successMsg)
router.refresh()
} else {
toast.error(res.message ?? "Operation failed")
toast.error(res.message ?? t("errors.unexpected"))
}
setPendingId(null)
})
@@ -72,10 +72,10 @@ export function ElectiveCourseList({
formData.set("courseId", courseId)
const res = await deleteElectiveCourseAction(null, formData)
if (res.success) {
toast.success(res.message ?? "Course deleted")
toast.success(res.message ?? t("actions.delete"))
router.refresh()
} else {
toast.error(res.message ?? "Delete failed")
toast.error(res.message ?? t("errors.unexpected"))
}
setPendingId(null)
})
@@ -113,8 +113,8 @@ export function ElectiveCourseList({
<Card key={course.id} className="flex h-full flex-col">
<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_COLORS[course.status]} className="shrink-0">
{ELECTIVE_STATUS_LABELS[course.status]}
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]} className="shrink-0">
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
</Badge>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
@@ -125,7 +125,7 @@ export function ElectiveCourseList({
{course.gradeName ? (
<Badge variant="outline">{course.gradeName}</Badge>
) : null}
<span>Credit: {course.credit}</span>
<span>{t("fields.credit")}: {course.credit}</span>
</div>
{course.description ? (
@@ -136,25 +136,25 @@ export function ElectiveCourseList({
<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">Mode:</span>{" "}
<span className="text-muted-foreground">{t("fields.selectionMode")}:</span>{" "}
<span className="font-medium">
{SELECTION_MODE_LABELS[course.selectionMode]}
{t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
</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>
{course.classroom ? (
<div>
<span className="text-muted-foreground">Room:</span>{" "}
<span className="text-muted-foreground">{t("fields.classroom")}:</span>{" "}
<span className="font-medium">{course.classroom}</span>
</div>
) : null}
@@ -162,7 +162,7 @@ export function ElectiveCourseList({
{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}
@@ -176,7 +176,7 @@ export function ElectiveCourseList({
>
<a href={`${editBaseHref}/${course.id}/edit`}>
<Pencil className="mr-1 h-3 w-3" />
Edit
{t("actions.edit")}
</a>
</Button>
) : null}
@@ -185,10 +185,10 @@ export function ElectiveCourseList({
variant="outline"
size="sm"
disabled={isPendingThis}
onClick={() => runAction(openSelectionAction, course.id, "Selection opened")}
onClick={() => runAction(openSelectionAction, course.id, t("actions.openSelection"))}
>
<Unlock className="mr-1 h-3 w-3" />
Open
{t("actions.openSelection")}
</Button>
) : null}
{course.status === "open" ? (
@@ -196,10 +196,10 @@ export function ElectiveCourseList({
variant="outline"
size="sm"
disabled={isPendingThis}
onClick={() => runAction(closeSelectionAction, course.id, "Selection closed")}
onClick={() => runAction(closeSelectionAction, course.id, t("actions.closeSelection"))}
>
<Lock className="mr-1 h-3 w-3" />
Close
{t("actions.closeSelection")}
</Button>
) : null}
{course.selectionMode === "lottery" && course.status !== "draft" ? (
@@ -207,10 +207,10 @@ export function ElectiveCourseList({
variant="outline"
size="sm"
disabled={isPendingThis}
onClick={() => runAction(runLotteryAction, course.id, "Lottery completed")}
onClick={() => runAction(runLotteryAction, course.id, t("actions.runLottery"))}
>
<Shuffle className="mr-1 h-3 w-3" />
Lottery
{t("actions.runLottery")}
</Button>
) : null}
<Button
@@ -221,7 +221,7 @@ export function ElectiveCourseList({
onClick={() => handleDelete(course.id)}
>
<Trash2 className="mr-1 h-3 w-3" />
Delete
{t("actions.delete")}
</Button>
</div>
) : null}

View File

@@ -1,6 +1,7 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
import {
Select,
@@ -12,6 +13,7 @@ import {
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function ElectiveFilters() {
const t = useTranslations("elective")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [mode, setMode] = useQueryState("mode", parseAsString.withDefault("all"))
@@ -29,18 +31,18 @@ export function ElectiveFilters() {
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="Search by course name, teacher..."
placeholder={t("form.namePlaceholder")}
/>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={mode} onValueChange={(val) => setMode(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Selection Mode" />
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20" aria-label={t("fields.selectionMode")}>
<SelectValue placeholder={t("fields.selectionMode")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Modes</SelectItem>
<SelectItem value="fcfs">First Come First Served</SelectItem>
<SelectItem value="lottery">Lottery</SelectItem>
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
<SelectItem value="fcfs">{t("selectionMode.fcfs")}</SelectItem>
<SelectItem value="lottery">{t("selectionMode.lottery")}</SelectItem>
</SelectContent>
</Select>
</div>

View File

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