diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 6fc5307..75508a1 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -4220,6 +4220,34 @@ "usedBy": [ "questions/data-access.getKnowledgePointOptions" ] + }, + { + "name": "getTextbooksWithScope", + "signature": "(query?, subject?, grade?, scope?: TextbookQueryScope) => Promise", + "purpose": "P1-1 新增:按数据范围获取教材列表,学生端强制按年级过滤", + "usedBy": [ + "student/learning/textbooks/page.tsx" + ] + }, + { + "name": "verifyChapterBelongsToTextbook", + "signature": "(chapterId, textbookId) => Promise", + "purpose": "P0-4 新增:资源归属校验,防止跨教材越权操作章节", + "usedBy": [ + "reorderChaptersAction", + "updateChapterContentAction", + "deleteChapterAction", + "createKnowledgePointAction" + ] + }, + { + "name": "verifyKnowledgePointBelongsToTextbook", + "signature": "(kpId, textbookId) => Promise", + "purpose": "P0-4 新增:资源归属校验,防止跨教材越权操作知识点", + "usedBy": [ + "updateKnowledgePointAction", + "deleteKnowledgePointAction" + ] } ], "hooks": [ @@ -4251,6 +4279,15 @@ "questions (知识点关联)" ] }, + { + "name": "ChapterTreeNode", + "definition": "Chapter & { children: ChapterTreeNode[] }", + "purpose": "P1-5 新增:buildChapterTree 返回类型,强制 children 为非空数组", + "usedBy": [ + "textbooks/utils.buildChapterTree", + "textbooks/components/chapter-sidebar-list.tsx" + ] + }, { "name": "KnowledgePoint", "definition": "{ id, name, description?, anchorText?, parentId?, chapterId?, level, order }", diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx new file mode 100644 index 0000000..c997226 --- /dev/null +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx @@ -0,0 +1,29 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function StudentTextbookDetailError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations("textbooks") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx index 72d9f5e..73b208e 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" import { BookOpen } from "lucide-react" @@ -7,6 +8,7 @@ import { TextbookReader } from "@/modules/textbooks/components/textbook-reader" import { Badge } from "@/shared/components/ui/badge" import { EmptyState } from "@/shared/components/ui/empty-state" import { getCurrentStudentUser } from "@/modules/users/data-access" +import { getGradeNameById } from "@/modules/school/data-access" export const dynamic = "force-dynamic" @@ -15,6 +17,7 @@ export default async function StudentTextbookDetailPage({ }: { params: Promise<{ id: string }> }) { + const t = await getTranslations("textbooks") const student = await getCurrentStudentUser() if (!student) return notFound() @@ -28,6 +31,13 @@ export default async function StudentTextbookDetailPage({ if (!textbook) notFound() + // P1-1 数据范围过滤:校验教材年级与学生年级匹配 + // student.gradeId 是 grades 表 id,需解析为年级名称后才能与 textbooks.grade 字符串比较 + const studentGradeName = student.gradeId ? await getGradeNameById(student.gradeId) : null + if (studentGradeName && textbook.grade && textbook.grade !== studentGradeName) { + notFound() + } + return (
@@ -48,13 +58,14 @@ export default async function StudentTextbookDetailPage({
) : (
+ {/* 学生端不传 renderQuestionCreator,无题目创建权限 */}
)} diff --git a/src/app/(dashboard)/student/learning/textbooks/error.tsx b/src/app/(dashboard)/student/learning/textbooks/error.tsx new file mode 100644 index 0000000..2d98f34 --- /dev/null +++ b/src/app/(dashboard)/student/learning/textbooks/error.tsx @@ -0,0 +1,29 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function StudentTextbooksError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations("textbooks") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/textbooks/page.tsx b/src/app/(dashboard)/student/learning/textbooks/page.tsx index a3b8ddb..0140dba 100644 --- a/src/app/(dashboard)/student/learning/textbooks/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/page.tsx @@ -1,16 +1,18 @@ import { BookOpen, UserX } from "lucide-react" +import { getTranslations } from "next-intl/server" -import { getTextbooks } from "@/modules/textbooks/data-access" +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) => { +const getParam = (params: SearchParams, key: string): string | undefined => { const v = params[key] return Array.isArray(v) ? v[0] : v } @@ -20,28 +22,39 @@ export default async function StudentTextbooksPage({ }: { searchParams: Promise }) { + const t = await getTranslations("textbooks") const [student, sp] = await Promise.all([getCurrentStudentUser(), searchParams]) if (!student) { return ( -
- +
+
) } - const q = getParam(sp, "q") || undefined - const subject = getParam(sp, "subject") || undefined - const grade = getParam(sp, "grade") || undefined + const q = getParam(sp, "q") + const subject = getParam(sp, "subject") + const grade = getParam(sp, "grade") - const textbooks = await getTextbooks(q, subject, 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 ( -
+
-

Textbooks

-

Browse your course textbooks.

+

{t("student.list.title")}

+

{t("student.list.subtitle")}

@@ -49,19 +62,31 @@ export default async function StudentTextbooksPage({ {textbooks.length === 0 ? ( ) : (
{textbooks.map((textbook) => ( - + ))}
)}
) } - diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/error.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/error.tsx new file mode 100644 index 0000000..ba6f028 --- /dev/null +++ b/src/app/(dashboard)/teacher/textbooks/[id]/error.tsx @@ -0,0 +1,29 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function TeacherTextbookDetailError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations("textbooks") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx index 5a23c51..f022ce4 100644 --- a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx @@ -1,12 +1,15 @@ -import type { JSX } from "react" +import type { JSX, ReactNode } from "react" import { notFound } from "next/navigation" import { ArrowLeft } from "lucide-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 { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access" -import { TextbookReader } from "@/modules/textbooks/components/textbook-reader" +import { TextbookReader, type TextbookReaderProps } from "@/modules/textbooks/components/textbook-reader" import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog" +import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog" +import type { KnowledgePoint } from "@/modules/textbooks/types" export const dynamic = "force-dynamic" @@ -16,6 +19,7 @@ export default async function TextbookDetailPage({ params: Promise<{ id: string }> }): Promise { const { id } = await params + const t = await getTranslations("textbooks") const [textbook, chapters, knowledgePoints] = await Promise.all([ getTextbookById(id), @@ -27,6 +31,25 @@ export default async function TextbookDetailPage({ notFound() } + // P0-1 在页面层注入 questions 模块的 CreateQuestionDialog 实现 + const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({ + open, + onOpenChange, + targetKp, + }: { + open: boolean + onOpenChange: (open: boolean) => void + targetKp: KnowledgePoint | null + }): ReactNode => ( + + ) + return (
{/* Header / Nav (Fixed height) */} @@ -34,7 +57,7 @@ export default async function TextbookDetailPage({
@@ -57,7 +80,7 @@ export default async function TextbookDetailPage({ chapters={chapters} knowledgePoints={knowledgePoints} textbookId={id} - canEdit={true} + renderQuestionCreator={renderQuestionCreator} />
diff --git a/src/app/(dashboard)/teacher/textbooks/error.tsx b/src/app/(dashboard)/teacher/textbooks/error.tsx new file mode 100644 index 0000000..800a329 --- /dev/null +++ b/src/app/(dashboard)/teacher/textbooks/error.tsx @@ -0,0 +1,29 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function TeacherTextbooksError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations("textbooks") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/textbooks/page.tsx b/src/app/(dashboard)/teacher/textbooks/page.tsx index 9770dfc..a416f13 100644 --- a/src/app/(dashboard)/teacher/textbooks/page.tsx +++ b/src/app/(dashboard)/teacher/textbooks/page.tsx @@ -1,6 +1,7 @@ import type { JSX } from "react" import { Suspense } from "react" import { BookOpen } from "lucide-react" +import { getTranslations } from "next-intl/server" import { TextbookCard } from "@/modules/textbooks/components/textbook-card" import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog" import { getTextbooks } from "@/modules/textbooks/data-access" @@ -10,7 +11,13 @@ import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" -async function TextbooksResults({ searchParams }: { searchParams: Promise }): Promise { +async function TextbooksResults({ + searchParams, + t, +}: { + searchParams: Promise + t: Awaited>> +}): Promise { const params = await searchParams const q = getParam(params, "q") || undefined @@ -25,9 +32,17 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise ) @@ -42,16 +57,20 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise }): Promise { +export default async function TextbooksPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const t = await getTranslations("textbooks") + return (
{/* Page Header */}
-

Textbooks

-

- Manage your digital curriculum resources and chapters. -

+

{t("list.title")}

+

{t("list.subtitle")}

@@ -61,7 +80,7 @@ export default async function TextbooksPage({ searchParams }: { searchParams: Pr }> - +
) diff --git a/src/modules/textbooks/actions.ts b/src/modules/textbooks/actions.ts index 01bd717..3df7d12 100644 --- a/src/modules/textbooks/actions.ts +++ b/src/modules/textbooks/actions.ts @@ -14,7 +14,9 @@ import { updateKnowledgePoint, updateTextbook, deleteTextbook, - reorderChapters + reorderChapters, + verifyChapterBelongsToTextbook, + verifyKnowledgePointBelongsToTextbook, } from "./data-access"; import { CreateTextbookSchema, @@ -38,6 +40,11 @@ export async function reorderChaptersAction( ): Promise { try { await requirePermission(Permissions.TEXTBOOK_UPDATE); + // P0-4 资源归属校验:防止越权操作其他教材的章节 + const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId) + if (!belongs) { + return { success: false, message: "Chapter does not belong to this textbook" }; + } await reorderChapters(chapterId, newIndex, parentId); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: "Chapters reordered successfully" }; @@ -203,6 +210,11 @@ export async function updateChapterContentAction( try { await requirePermission(Permissions.TEXTBOOK_UPDATE); + // P0-4 资源归属校验 + const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId) + if (!belongs) { + return { success: false, message: "Chapter does not belong to this textbook" }; + } await updateChapterContent(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: "Content updated successfully" }; @@ -220,6 +232,11 @@ export async function deleteChapterAction( ): Promise { try { await requirePermission(Permissions.TEXTBOOK_DELETE); + // P0-4 资源归属校验 + const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId) + if (!belongs) { + return { success: false, message: "Chapter does not belong to this textbook" }; + } await deleteChapter(chapterId); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: "Chapter deleted successfully" }; @@ -254,6 +271,11 @@ export async function createKnowledgePointAction( try { await requirePermission(Permissions.TEXTBOOK_CREATE); + // P0-4 资源归属校验:确保 chapter 属于该 textbook,防止跨教材越权创建知识点 + const chapterBelongs = await verifyChapterBelongsToTextbook(chapterId, textbookId); + if (!chapterBelongs) { + return { success: false, message: "Chapter does not belong to this textbook" }; + } await createKnowledgePoint(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: "Knowledge point created successfully" }; @@ -271,6 +293,11 @@ export async function deleteKnowledgePointAction( ): Promise { try { await requirePermission(Permissions.TEXTBOOK_DELETE); + // P0-4 资源归属校验 + const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId) + if (!belongs) { + return { success: false, message: "Knowledge point does not belong to this textbook" }; + } await deleteKnowledgePoint(kpId); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: "Knowledge point deleted successfully" }; @@ -305,6 +332,11 @@ export async function updateKnowledgePointAction( try { await requirePermission(Permissions.TEXTBOOK_UPDATE); + // P0-4 资源归属校验 + const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId) + if (!belongs) { + return { success: false, message: "Knowledge point does not belong to this textbook" }; + } await updateKnowledgePoint(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: "Knowledge point updated successfully" }; diff --git a/src/modules/textbooks/analytics.tsx b/src/modules/textbooks/analytics.tsx new file mode 100644 index 0000000..7fdde55 --- /dev/null +++ b/src/modules/textbooks/analytics.tsx @@ -0,0 +1,43 @@ +/** + * 教材模块埋点接口(预留)。 + * + * 当前为 no-op 实现,后续接入真实监控 SDK 时只需替换 Provider。 + * 通过 React Context 注入,组件内调用 `useTextbookAnalytics()` 获取。 + */ + +"use client" + +import { createContext, useContext, type ReactNode } from "react" + +export interface TextbookAnalytics { + /** 教材被打开时触发 */ + onTextbookOpen?(textbookId: string): void + /** 章节被阅读时触发(含停留时长) */ + onChapterRead?(textbookId: string, chapterId: string, durationMs: number): void + /** 知识点被点击时触发 */ + onKnowledgePointClick?(kpId: string): void + /** 知识点被创建时触发 */ + onKnowledgePointCreate?(chapterId: string, kpId: string): void + /** 章节内容被编辑保存时触发 */ + onChapterContentUpdate?(chapterId: string): void +} + +const TextbookAnalyticsContext = createContext({}) + +export function TextbookAnalyticsProvider({ + analytics, + children, +}: { + analytics?: TextbookAnalytics + children: ReactNode +}) { + return ( + + {children} + + ) +} + +export function useTextbookAnalytics(): TextbookAnalytics { + return useContext(TextbookAnalyticsContext) +} diff --git a/src/modules/textbooks/components/chapter-sidebar-list.tsx b/src/modules/textbooks/components/chapter-sidebar-list.tsx index 6c3836e..543af00 100644 --- a/src/modules/textbooks/components/chapter-sidebar-list.tsx +++ b/src/modules/textbooks/components/chapter-sidebar-list.tsx @@ -136,9 +136,9 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
- {hasChildren && ( - - {pending ? "Creating..." : "Create Chapter"} + {pending ? t("dialog.chapter.creating") : t("dialog.chapter.submit")} ) } @@ -35,7 +37,14 @@ interface CreateChapterDialogProps { onOpenChange?: (open: boolean) => void } -export function CreateChapterDialog({ textbookId, parentId, trigger, open: controlledOpen, onOpenChange }: CreateChapterDialogProps) { +export function CreateChapterDialog({ + textbookId, + parentId, + trigger, + open: controlledOpen, + onOpenChange, +}: CreateChapterDialogProps) { + const t = useTranslations("textbooks") const [uncontrolledOpen, setUncontrolledOpen] = useState(false) const open = controlledOpen ?? uncontrolledOpen const setOpen = onOpenChange ?? setUncontrolledOpen @@ -54,9 +63,13 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr trigger === null ? null : trigger || ( - ) @@ -65,21 +78,19 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr {triggerNode ? {triggerNode} : null} - Add New Chapter - - Create a new chapter or section. - + {t("dialog.chapter.createTitle")} + {t("dialog.chapter.createDesc")}
diff --git a/src/modules/textbooks/components/create-knowledge-point-dialog.tsx b/src/modules/textbooks/components/create-knowledge-point-dialog.tsx deleted file mode 100644 index c17e4b7..0000000 --- a/src/modules/textbooks/components/create-knowledge-point-dialog.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client" - -import { useState } from "react" -import { Plus } from "lucide-react" -import { useFormStatus } from "react-dom" -import { Button } from "@/shared/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/shared/components/ui/dialog" -import { Input } from "@/shared/components/ui/input" -import { Label } from "@/shared/components/ui/label" -import { Textarea } from "@/shared/components/ui/textarea" -import { createKnowledgePointAction } from "../actions" -import { toast } from "sonner" - -function SubmitButton() { - const { pending } = useFormStatus() - return ( - - ) -} - -interface CreateKnowledgePointDialogProps { - chapterId: string - textbookId: string -} - -export function CreateKnowledgePointDialog({ chapterId, textbookId }: CreateKnowledgePointDialogProps) { - const [open, setOpen] = useState(false) - - const handleSubmit = async (formData: FormData) => { - const result = await createKnowledgePointAction(chapterId, textbookId, null, formData) - if (result.success) { - toast.success(result.message) - setOpen(false) - } else { - toast.error(result.message) - } - } - - return ( - - - - - - - Add Knowledge Point - - Link a key concept to this chapter. - - - -
-
- - -
-
- -