feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components

dashboard:

- Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter

- Add parent-dashboard components directory

- Add config, hooks, and services directories

diagnostic:

- Add role-config and services directory

elective:

- Add elective-course-detail, elective-stats-cards, parent-selection-view components

- Add data-access-settings and data-access-stats
This commit is contained in:
SpecialX
2026-07-03 10:25:46 +08:00
parent dfffb61e94
commit 138b6f1b00
58 changed files with 3313 additions and 695 deletions

View File

@@ -21,6 +21,8 @@ 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 { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
@@ -35,24 +37,171 @@ import type {
} from "../types"
import { selectCourseAction, dropCourseAction } from "../actions"
export function StudentSelectionView({
availableCourses,
/**
* 已选课程区块(我的选课)
* - 独立 Suspense 边界内的客户端组件
* - 仅负责"退课"操作与展示
*/
export function StudentMySelectionsSection({
mySelections,
}: {
availableCourses: ElectiveCourseWithDetails[]
mySelections: CourseSelectionWithDetails[]
}) {
const router = useRouter()
const t = useTranslations("elective")
const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
// P2-4退课理由输入按课程 ID 隔离,便于多卡片独立填写)
const [dropReasonMap, setDropReasonMap] = useState<Record<string, string>>({})
const activeSelections = mySelections.filter((s) =>
["selected", "enrolled", "waitlist"].includes(s.status)
)
const selectedCourseIds = new Set(
activeSelections.map((s) => s.courseId)
const handleDrop = (courseId: string) => {
setPendingId(courseId)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
// P2-4透传退课理由
const reason = dropReasonMap[courseId]
if (reason && reason.trim().length > 0) {
formData.set("dropReason", reason.trim())
}
const res = await dropCourseAction(null, formData)
if (res.success) {
toast.success(res.message || t("student.dropSuccess"))
router.refresh()
// 清空该课程的退课理由
setDropReasonMap((prev) => {
const next = { ...prev }
delete next[courseId]
return next
})
} else {
toast.error(res.message ?? t("errors.unexpected"))
}
setPendingId(null)
})
}
return (
<section className="space-y-4" aria-labelledby="student-my-selections-heading">
<div className="flex items-center justify-between">
<h3
id="student-my-selections-heading"
className="text-lg font-semibold"
>
{t("student.mySelections")}
</h3>
<span className="text-sm text-muted-foreground" aria-live="polite">
{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>
{/* P2-4可选退课理由输入 */}
<div className="grid gap-2 py-2">
<Label htmlFor={`dropReason-${sel.courseId}`}>
{t("fields.dropReason")}
</Label>
<Textarea
id={`dropReason-${sel.courseId}`}
value={dropReasonMap[sel.courseId] ?? ""}
onChange={(e) =>
setDropReasonMap((prev) => ({
...prev,
[sel.courseId]: e.target.value,
}))
}
placeholder={t("student.dropReasonPlaceholder")}
className="min-h-[60px]"
maxLength={255}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDrop(sel.courseId)}
>
{t("actions.drop")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
))}
</div>
)}
</section>
)
}
/**
* 可选课程区块
* - 独立 Suspense 边界内的客户端组件
* - 仅负责"选课"操作与展示
* - 通过 selectedCourseIds prop 接收已选课程 ID 集合,避免与已选区块直接耦合
*/
export function StudentAvailableCoursesSection({
availableCourses,
selectedCourseIds,
}: {
availableCourses: ElectiveCourseWithDetails[]
selectedCourseIds: Set<string>
}) {
const router = useRouter()
const t = useTranslations("elective")
const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const handleSelect = (courseId: string) => {
setPendingId(courseId)
@@ -70,179 +219,93 @@ export function StudentSelectionView({
})
}
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}>
<section className="space-y-4" aria-labelledby="student-available-courses-heading">
<div className="flex items-center justify-between">
<h3
id="student-available-courses-heading"
className="text-lg font-semibold"
>
{t("student.availableCourses")}
</h3>
<span className="text-sm text-muted-foreground" aria-live="polite">
{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="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])}
<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="space-y-3">
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
<p className="text-xs text-muted-foreground">
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
<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}
{sel.lotteryRank ? (
<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">
#{sel.lotteryRank}
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
</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")}
<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>
</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>
) : (
<Button
size="sm"
disabled={isPendingThis}
onClick={() => handleSelect(course.id)}
>
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
</Button>
)}
</div>
</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>
)}
</section>
)
}