Files
NextEdu/src/app/(dashboard)/student/learning/assignments/page.tsx
SpecialX 21c7e65fee feat(exam-homework): add audit report, i18n, error boundaries, and permission hardening
- Add comprehensive audit report for exam and homework module

- Create exam-homework i18n message files (zh-CN + en) and register namespace

- Add permission check to gradeHomeworkSubmissionAction to prevent horizontal privilege escalation

- Add Error Boundary + loading.tsx for 5 key pages (exam build/proctoring, homework assignment/submissions, student assignment)

- Refactor exam-columns to createExamColumns(t) factory for i18n support

- Refactor exam-data-table to manage columns internally via useTranslations

- Replace hardcoded strings with i18n keys in all exam/homework components and pages

- Add getHomeworkSubmissionForGrading data-access for secure grading flow
2026-06-22 16:08:39 +08:00

283 lines
9.8 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 { 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"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
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>
)
}