Files
NextEdu/src/app/(dashboard)/student/learning/assignments/page.tsx
SpecialX 1a9377222c feat(app): add error/loading boundaries and update dashboard routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes

- Add dashboard-error-fallback and dashboard-loading-skeleton components

- Add student/learning page, parent/leave routes, teacher textbook components

- Update existing app routes across auth, dashboard, and API endpoints

- Update proxy middleware and next-auth type declarations
2026-06-23 17:38:28 +08:00

277 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { formatDate, cn } from "@/shared/lib/utils"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
import { AssignmentFilters } from "@/modules/homework/components/assignment-filters"
import { Inbox, UserX, TriangleAlert } from "lucide-react"
import type {
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
} from "@/modules/homework/types"
import {
STUDENT_HOMEWORK_PROGRESS_VARIANT,
} from "@/modules/homework/types"
export const dynamic = "force-dynamic"
const getActionLabel = (status: StudentHomeworkProgressStatus, t: (key: string) => string): string => {
switch (status) {
case "graded":
return t("homework.review.title")
case "submitted":
return t("common.view")
case "in_progress":
return t("common.continue")
default:
return t("homework.take.startAssignment")
}
}
const getActionVariant = (
status: StudentHomeworkProgressStatus
): "default" | "secondary" | "outline" => {
return status === "graded" || status === "submitted" ? "outline" : "default"
}
const isAnswered = (status: StudentHomeworkProgressStatus): boolean =>
status === "submitted" || status === "graded"
const matchesStatusFilter = (
status: StudentHomeworkProgressStatus,
filter: string
): boolean => {
if (filter === "all") return true
if (filter === "pending") return status === "not_started" || status === "in_progress"
if (filter === "submitted") return status === "submitted"
if (filter === "graded") return status === "graded"
return true
}
// Stable color mapping for subjects (hash-based, deterministic)
const SUBJECT_DOT_COLORS = [
"bg-blue-500",
"bg-emerald-500",
"bg-amber-500",
"bg-violet-500",
"bg-rose-500",
"bg-cyan-500",
"bg-orange-500",
"bg-pink-500",
"bg-indigo-500",
"bg-teal-500",
]
const getSubjectColor = (subject: string): string => {
let hash = 0
for (let i = 0; i < subject.length; i++) {
hash = (hash * 31 + subject.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % SUBJECT_DOT_COLORS.length
return SUBJECT_DOT_COLORS[idx]
}
type TranslationFn = (key: string) => string
function AssignmentCard({
assignment: a,
t,
statusLabelMap,
}: {
assignment: StudentHomeworkAssignmentListItem
t: TranslationFn
statusLabelMap: Record<StudentHomeworkProgressStatus, string>
}) {
const now = new Date()
const isOverdue = a.dueAt ? new Date(a.dueAt) < now : false
const showOverdueBadge = isOverdue && !isAnswered(a.progressStatus)
return (
<Card className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<CardHeader className="gap-2 pb-3">
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</CardTitle>
<StatusBadge
status={a.progressStatus}
variantMap={STUDENT_HOMEWORK_PROGRESS_VARIANT}
labelMap={statusLabelMap}
/>
</div>
<div className={cn(
"text-xs",
showOverdueBadge ? "text-destructive font-medium" : "text-muted-foreground"
)}>
<span>{t("homework.list.columns.dueAt")} {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2" aria-hidden="true"></span>
<span>{t("homework.take.attempts")} {a.attemptsUsed}/{a.maxAttempts}</span>
{showOverdueBadge && (
<>
<span className="px-2" aria-hidden="true"></span>
<span className="inline-flex items-center gap-1">
<TriangleAlert className="h-3 w-3" />
{t("homework.take.overdue")}
</span>
</>
)}
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">{t("homework.grade.score")}</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus, t)}
</Link>
</Button>
</CardContent>
</Card>
)
}
export default async function StudentAssignmentsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const t = await getTranslations("examHomework")
const student = await getCurrentStudentUser()
const statusLabelMap: Record<StudentHomeworkProgressStatus, string> = {
not_started: t("homework.status.not_started"),
in_progress: t("homework.status.in_progress"),
submitted: t("homework.status.submitted"),
graded: t("homework.status.graded"),
}
if (!student) {
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("homework.list.title")}</h2>
<p className="text-muted-foreground">{t("homework.list.description")}</p>
</div>
<EmptyState title={t("homework.error.notFound")} description={t("homework.error.assignmentNotFound")} icon={UserX} />
</div>
)
}
const [sp, assignments] = await Promise.all([
searchParams,
getStudentHomeworkAssignments(student.id),
])
const q = (getParam(sp, "q") || "").toLowerCase().trim()
const statusFilter = getParam(sp, "status") || "all"
// 应用筛选
const filtered = assignments.filter((a) => {
if (q && !a.title.toLowerCase().includes(q)) return false
if (!matchesStatusFilter(a.progressStatus, statusFilter)) return false
return true
})
const hasAssignments = assignments.length > 0
const hasFiltered = filtered.length > 0
const assignmentsBySubject = filtered.reduce((acc, assignment) => {
const subject = assignment.subjectName?.trim() || t("common.other")
const existing = acc.get(subject)
if (existing) {
existing.push(assignment)
} else {
acc.set(subject, [assignment])
}
return acc
}, new Map<string, StudentHomeworkAssignmentListItem[]>())
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) =>
a[0].localeCompare(b[0])
)
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("homework.list.title")}</h2>
<p className="text-muted-foreground">{t("homework.list.description")}</p>
</div>
{hasAssignments && <AssignmentFilters />}
{!hasAssignments ? (
<EmptyState title={t("homework.list.empty")} description={t("homework.list.emptyDescription")} icon={Inbox} />
) : !hasFiltered ? (
<EmptyState
title={t("homework.list.emptyFiltered")}
description={t("homework.list.emptyFilteredDescription")}
icon={Inbox}
/>
) : (
<div className="space-y-6">
{subjectEntries.map(([subject, items]) => {
// 单次遍历分桶,避免重复 filterPERF-05
const answered: StudentHomeworkAssignmentListItem[] = []
const unanswered: StudentHomeworkAssignmentListItem[] = []
for (const a of items) {
if (isAnswered(a.progressStatus)) {
answered.push(a)
} else {
unanswered.push(a)
}
}
return (
<div key={subject} className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<span
className={cn("h-2.5 w-2.5 rounded-full", getSubjectColor(subject))}
aria-hidden="true"
/>
<span className="text-muted-foreground">{subject}</span>
<span className="text-xs font-normal text-muted-foreground/70">
({items.length})
</span>
</div>
{unanswered.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("homework.status.not_started")}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{unanswered.map((a) => (
<AssignmentCard key={a.id} assignment={a} t={t} statusLabelMap={statusLabelMap} />
))}
</div>
</div>
)}
{answered.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("common.completed")}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{answered.map((a) => (
<AssignmentCard key={a.id} assignment={a} t={t} statusLabelMap={statusLabelMap} />
))}
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}