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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user