Files
NextEdu/src/app/(dashboard)/student/learning/textbooks/page.tsx
SpecialX 22d3f07fcf feat(textbooks): 教材模块审计重构 — 跨模块解耦 + 权限 + i18n + 错误边界 + 纯函数抽取
P0 修复:
- 解耦跨模块 UI 依赖:knowledge-point-dialogs 不再直接 import questions,
  改为 renderQuestionCreator render prop 由页面注入
- 接入 usePermission Hook 替换 canEdit 硬编码
- 全模块 i18n 改造:新增 en/zh-CN 翻译文件,替换所有硬编码文案
- Server Action 资源归属校验:新增 verifyChapterBelongsToTextbook/
  verifyKnowledgePointBelongsToTextbook,在 reorder/update/delete/create 中校验

P1 改进:
- 补齐 Error Boundary:4 个 error.tsx + TextbookSectionErrorBoundary 区块包裹
- 抽取纯函数到 utils.ts/graph-layout.ts/constants.ts 并补单测(26 用例全通过)
- 消除重复组件:删除 knowledge-point-panel/create-knowledge-point-dialog
- 修复类型断言:chapter.children! → 守卫式访问
- 图谱 a11y:添加 role/aria-label/aria-pressed
- 统一删除确认:confirm() → AlertDialog
- 数据范围过滤:getTextbooksWithScope 支持学生端按年级过滤

P2 预留:
- TextbookAnalytics 埋点接口 + Provider + Hook

同步 005 架构数据 JSON:补充 getTextbooksWithScope/verify*/ChapterTreeNode 等
2026-06-22 16:25:59 +08:00

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