Files
NextEdu/src/app/(dashboard)/teacher/exams/all/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

145 lines
5.0 KiB
TypeScript

import type { JSX } from "react"
import { Suspense } from "react"
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
import { ExamFilters } from "@/modules/exams/components/exam-filters"
import { getExams } from "@/modules/exams/data-access"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { FileText, PlusCircle } from "lucide-react"
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const params = await searchParams
const { dataScope } = await getAuthContext()
const t = await getTranslations("examHomework")
const q = getParam(params, "q")
const status = getParam(params, "status")
const difficulty = getParam(params, "difficulty")
const exams = await getExams({
q,
status,
difficulty,
scope: dataScope,
})
const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all"))
const counts = exams.reduce(
(acc, e) => {
acc.total += 1
if (e.status === "draft") acc.draft += 1
if (e.status === "published") acc.published += 1
if (e.status === "archived") acc.archived += 1
return acc
},
{ total: 0, draft: 0, published: 0, archived: 0 }
)
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">{t("exam.list.showing")}</span>
<span className="text-sm font-medium">{counts.total}</span>
<span className="text-sm text-muted-foreground">{t("exam.list.examsUnit")}</span>
<Badge variant="outline" className="ml-0 md:ml-2">
{t("exam.status.draft")} {counts.draft}
</Badge>
<Badge variant="outline">{t("exam.status.published")} {counts.published}</Badge>
<Badge variant="outline">{t("exam.status.archived")} {counts.archived}</Badge>
</div>
<div className="flex items-center gap-2">
<Button asChild size="sm">
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
<PlusCircle className="h-4 w-4" aria-hidden="true" />
{t("exam.list.create")}
</Link>
</Button>
</div>
</div>
{exams.length === 0 ? (
<EmptyState
icon={FileText}
title={hasFilters ? t("exam.list.emptyFiltered") : t("exam.list.empty")}
description={
hasFilters
? t("exam.list.emptyFilteredDescription")
: t("exam.list.emptyDescription")
}
action={
hasFilters
? {
label: t("exam.list.clearFilters"),
href: "/teacher/exams/all",
}
: {
label: t("exam.list.create"),
href: "/teacher/exams/create",
}
}
className="h-[360px] bg-card"
/>
) : (
<ExamDataTable data={exams} />
)}
</div>
)
}
function ExamsResultsFallback() {
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-4 w-[160px]" />
<Skeleton className="h-5 w-[92px]" />
<Skeleton className="h-5 w-[112px]" />
<Skeleton className="h-5 w-[106px]" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-[120px]" />
<Skeleton className="h-9 w-[132px]" />
</div>
</div>
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 6 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}
export default async function AllExamsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ExamFilters />
</Suspense>
<Suspense fallback={<ExamsResultsFallback />}>
<ExamsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}