Files
NextEdu/src/app/(dashboard)/teacher/grades/page.tsx
SpecialX 21142f9b99 feat(app): add error/loading boundaries across all dashboard routes and new routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes

- Add admin announcements edit, audit-logs overview, curriculum-map, invitation-codes, permissions, questions, roles routes

- Add admin elective detail and components, files, course-plans, users, scheduling boundaries

- Add messages group-compose route

- Add parent course-plans, elective, grades report-card, practice routes

- Add student course-plans, elective detail, error-book dialogs, grades report-card, learning study-path, leave, schedule boundaries

- Add teacher attendance report, classes boundaries, course-plans boundaries, elective, exams analytics/edit-rich/all/create/new, grades report-card, homework boundaries, leave, lesson-plans calendar

- Add auth loading, onboarding loading, api cron
2026-07-03 10:26:25 +08:00

141 lines
5.5 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 type { JSX } from "react"
import Link from "next/link"
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGradeRecords } from "@/modules/grades/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
import { ExportButton } from "@/modules/grades/components/export-button"
import { ExcelImportDialog } from "@/modules/grades/components/excel-import-dialog"
import { isGradeType, isSemester } from "@/modules/grades/lib/type-guards"
export const dynamic = "force-dynamic"
const PAGE_SIZE = 20
export default async function TeacherGradesPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
const type = getParam(sp, "type")
const semester = getParam(sp, "semester")
// P3 修复:使用 DB 层分页,移除重复计算
const { page } = computePagination(sp, PAGE_SIZE)
const offset = (page - 1) * PAGE_SIZE
const [classes, allSubjects, result] = await Promise.all([
getTeacherClasses(),
getSubjectOptions(),
getGradeRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
type: type && type !== "all" ? isGradeType(type) ? type : undefined : undefined,
semester: semester && semester !== "all" ? isSemester(semester) ? semester : undefined : undefined,
limit: PAGE_SIZE,
offset,
}),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
// 使用 DB 返回的 total 和 totalPages移除重复计算
const total = result.total
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const currentPage = Math.min(page, totalPages)
const pagedRecords = result.records
const hasFilters = Boolean(classId || subjectId || type || semester)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title.list")}</h1>
<p className="text-muted-foreground">{t("page.list.description")}</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/grades/stats">
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
{t("page.list.stats")}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/teacher/grades/entry">
<ClipboardList className="mr-2 h-4 w-4" aria-hidden="true" />
{t("page.list.batchEntry")}
</Link>
</Button>
<ExportButton
classId={classId && classId !== "all" ? classId : ""}
subjectId={subjectId && subjectId !== "all" ? subjectId : undefined}
variant="outline"
/>
<ExcelImportDialog
classId={classId && classId !== "all" ? classId : (classes[0]?.id ?? "")}
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
/>
<Button asChild>
<Link href="/teacher/grades/entry">
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
{t("page.list.enterGrades")}
</Link>
</Button>
</div>
</div>
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
{total === 0 && !hasFilters ? (
<EmptyState
title={t("page.list.emptyTitle")}
description={t("page.list.emptyDescription")}
icon={ClipboardList}
action={{
label: t("page.list.emptyActionLabel"),
href: "/teacher/grades/entry",
}}
/>
) : (
<div className="space-y-4">
{/* P1-9: 用 WidgetBoundary 包裹独立数据区块,隔离故障域 */}
<WidgetBoundary title={t("title.list")}>
<GradeRecordList records={pagedRecords} />
</WidgetBoundary>
{total > 0 ? (
<ListPagination
page={currentPage}
pageSize={PAGE_SIZE}
total={total}
totalPages={totalPages}
basePath="/teacher/grades"
searchParams={sp}
itemLabel={t("page.list.recordUnit")}
/>
) : null}
</div>
)}
</div>
)
}