- 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
87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
import { BookOpen, UserX } from "lucide-react"
|
||
import { getTranslations } from "next-intl/server"
|
||
|
||
import { getTextbooksWithScope } from "@/modules/textbooks/data-access"
|
||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
||
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||
import { getGradeNameById } from "@/modules/school/data-access"
|
||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||
|
||
export const dynamic = "force-dynamic"
|
||
|
||
export default async function StudentTextbooksPage({
|
||
searchParams,
|
||
}: {
|
||
searchParams: Promise<SearchParams>
|
||
}) {
|
||
const t = await getTranslations("textbooks")
|
||
const [student, sp] = await Promise.all([getCurrentStudentUser(), searchParams])
|
||
|
||
if (!student) {
|
||
return (
|
||
<div className="space-y-8">
|
||
<EmptyState
|
||
title={t("student.noUser")}
|
||
description={t("student.noUserDesc")}
|
||
icon={UserX}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const q = getParam(sp, "q")
|
||
const subject = getParam(sp, "subject")
|
||
const grade = getParam(sp, "grade")
|
||
|
||
// P1-1 数据范围过滤:学生端强制按学生所在年级过滤
|
||
// student.gradeId 是 grades 表 id,需通过 getGradeNameById 解析为年级名称(如 "Grade 7"),
|
||
// 才能与 textbooks.grade 字符串字段匹配
|
||
const studentGradeName = student.gradeId ? await getGradeNameById(student.gradeId) : null
|
||
const textbooks = await getTextbooksWithScope(q, subject, grade, {
|
||
grade: studentGradeName ?? undefined,
|
||
})
|
||
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<div>
|
||
<h2 className="text-2xl font-bold tracking-tight">{t("student.list.title")}</h2>
|
||
<p className="text-muted-foreground">{t("student.list.subtitle")}</p>
|
||
</div>
|
||
|
||
<TextbookFilters />
|
||
|
||
{textbooks.length === 0 ? (
|
||
<EmptyState
|
||
icon={BookOpen}
|
||
title={hasFilters ? t("student.list.empty.withFilters") : t("student.list.empty.withoutFilters")}
|
||
description={
|
||
hasFilters
|
||
? t("student.list.empty.withFiltersDesc")
|
||
: t("student.list.empty.withoutFiltersDesc")
|
||
}
|
||
action={
|
||
hasFilters
|
||
? { label: t("list.clearFilters"), href: "/student/learning/textbooks" }
|
||
: undefined
|
||
}
|
||
className="bg-card"
|
||
/>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||
{textbooks.map((textbook) => (
|
||
<TextbookCard
|
||
key={textbook.id}
|
||
textbook={textbook}
|
||
hrefBase="/student/learning/textbooks"
|
||
hideActions
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|