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

140 lines
5.2 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 { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination"
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 type { GradeRecordType, GradeRecordSemester } from "@/modules/grades/types"
export const dynamic = "force-dynamic"
const VALID_GRADE_TYPES: ReadonlySet<string> = new Set(["exam", "quiz", "homework", "other"])
const VALID_SEMESTERS: ReadonlySet<string> = new Set(["1", "2"])
function parseGradeType(v?: string): GradeRecordType | undefined {
return v && VALID_GRADE_TYPES.has(v) ? (v as GradeRecordType) : undefined
}
function parseSemester(v?: string): GradeRecordSemester | undefined {
return v && VALID_SEMESTERS.has(v) ? (v as GradeRecordSemester) : undefined
}
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 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" ? parseGradeType(type) : undefined,
semester: semester && semester !== "all" ? parseSemester(semester) : 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"></h1>
<p className="text-muted-foreground"></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" />
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/teacher/grades/entry">
<ClipboardList className="mr-2 h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<ExportButton
classId={classId && classId !== "all" ? classId : ""}
subjectId={subjectId && subjectId !== "all" ? subjectId : undefined}
variant="outline"
/>
<Button asChild>
<Link href="/teacher/grades/entry">
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
</Link>
</Button>
</div>
</div>
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
{total === 0 && !hasFilters ? (
<EmptyState
title="暂无成绩记录"
description="开始为您的班级录入成绩。"
icon={ClipboardList}
action={{
label: "录入成绩",
href: "/teacher/grades/entry",
}}
/>
) : (
<div className="space-y-4">
<GradeRecordList records={pagedRecords} />
{total > 0 ? (
<ListPagination
page={currentPage}
pageSize={PAGE_SIZE}
total={total}
totalPages={totalPages}
basePath="/teacher/grades"
searchParams={sp}
itemLabel="条记录"
/>
) : null}
</div>
)}
</div>
)
}