"use client" import { useMemo, useState, useEffect, useRef, type ReactNode } from "react" import { useQueryState, parseAsString } from "nuqs" import { Tag, List, Share2, Menu, GraduationCap } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import Link from "next/link" import type { Chapter, KnowledgePoint } from "../types" import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions" import { Permissions } from "@/shared/types/permissions" import { usePermission } from "@/shared/hooks/use-permission" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/shared/components/ui/alert-dialog" import { ChapterSidebarList } from "./chapter-sidebar-list" import { KnowledgeGraph } from "./knowledge-graph" import { KnowledgePointList } from "./knowledge-point-list" import { TextbookContentPanel } from "./textbook-content-panel" import { KnowledgePointDialogs, type QuestionCreatorRenderProps, } from "./knowledge-point-dialogs" import { TextbookSectionErrorBoundary } from "./section-error-boundary" import { EmptyState } from "@/shared/components/ui/empty-state" import { Sheet, SheetContent, SheetHeader, SheetTitle, } from "@/shared/components/ui/sheet" import { useTextSelection } from "../hooks/use-text-selection" import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions" import { buildChapterIndex, highlightKnowledgePoints } from "../utils" export interface TextbookReaderProps { chapters: Chapter[] /** * 教材 ID,用于按章节懒加载知识点(P2-3)。 * 必传,否则知识点面板将始终为空。 */ textbookId: string /** * 是否可编辑。已废弃——改由内部 usePermission() 自动判断。 * 保留 prop 仅为向后兼容,传入值会被忽略。 * @deprecated 改用权限系统自动判断 */ canEdit?: boolean /** * 题目创建器渲染函数(P0-1 解耦)。 * 由页面层注入 questions 模块的 CreateQuestionDialog 实现。 * 不传则不渲染题目创建入口。 */ renderQuestionCreator?: (props: QuestionCreatorRenderProps) => ReactNode } export function TextbookReader({ chapters, textbookId, renderQuestionCreator, }: TextbookReaderProps) { const t = useTranslations("textbooks") const { hasPermission } = usePermission() // P0-2 前端权限改由 usePermission 判断,不再接受外部 canEdit 硬编码 const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE) const canCreateQuestion = hasPermission(Permissions.QUESTION_CREATE) const canCreateLessonPlan = hasPermission(Permissions.LESSON_PLAN_CREATE) const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault("")) const [activeTab, setActiveTab] = useState("chapters") const [highlightedKpId, setHighlightedKpId] = useState(null) // P2-4 移动端抽屉式侧栏 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) const [editContent, setEditContent] = useState("") const [isSaving, setIsSaving] = useState(false) const { selectedText, setSelectedText, contentRef, createDialogOpen, setCreateDialogOpen, isCreating, setIsCreating, handleContentPointerDown, handleContextMenuChange, } = useTextSelection() const index = useMemo(() => buildChapterIndex(chapters), [chapters]) const selected = chapterId ? index.get(chapterId) ?? null : null const selectedId = selected?.id ?? null // P2-3 知识点懒加载:按章节通过 Server Action 按需加载,避免一次性拉取全部知识点 // 使用缓存 + 派生值模式,避免在 effect 主体中同步 setState // v2-P2: textbookId 变化时通过页面层 key={textbookId} 重置整个 reader,无需手动清理缓存 const [kpsByChapter, setKpsByChapter] = useState>({}) const requestedChaptersRef = useRef>(new Set()) // 用 useMemo 包裹以稳定引用,避免下游 useMemo 因 [] 引用变化而重复计算 const currentChapterKPs = useMemo( () => (selectedId ? kpsByChapter[selectedId] ?? [] : []), [selectedId, kpsByChapter] ) // 加载状态派生:选中了章节但缓存中尚无数据时视为加载中 const isLoadingKPs = selectedId !== null && kpsByChapter[selectedId] === undefined useEffect(() => { if (!selectedId || !textbookId) { return } // 已请求过的章节不重复请求(缓存命中) if (requestedChaptersRef.current.has(selectedId)) { return } requestedChaptersRef.current.add(selectedId) let cancelled = false getKnowledgePointsByChapterAction(selectedId, textbookId) .then((result) => { if (cancelled) return const data = result.success ? result.data : undefined setKpsByChapter((prev) => ({ ...prev, [selectedId]: data ?? [] })) }) .catch(() => { if (!cancelled) setKpsByChapter((prev) => ({ ...prev, [selectedId]: [] })) }) return () => { cancelled = true } }, [selectedId, textbookId]) const { editingKp, setEditingKp, editKpDialogOpen, setEditKpDialogOpen, isUpdatingKp, questionDialogOpen, setQuestionDialogOpen, targetKpForQuestion, setTargetKpForQuestion, deleteConfirmOpen, setDeleteConfirmOpen, handleCreateKnowledgePoint, requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint, handleUpdateKnowledgePoint, } = useKnowledgePointActions( textbookId, selectedId, selected?.textbookId, highlightedKpId, setHighlightedKpId, () => { setCreateDialogOpen(false) setActiveTab("knowledge") setSelectedText("") }, ) const [localContent, setLocalContent] = useState(null) const onCreateKnowledgePoint = async (formData: FormData) => { setIsCreating(true) await handleCreateKnowledgePoint(formData) setIsCreating(false) } const handleSaveContent = async () => { if (!selectedId || !textbookId) return setIsSaving(true) try { const result = await updateChapterContentAction(selectedId, editContent, textbookId) if (result.success) { toast.success(result.message) setIsEditing(false) setLocalContent(editContent) } else { toast.error(result.message) } } catch (e) { console.error("Failed to save chapter content", e) toast.error(t("reader.saveFailed")) } finally { setIsSaving(false) } } const startEditing = () => { if (selected) { setEditContent(selected.content || "") setIsEditing(true) } } const handleSelect = (chapter: Chapter) => { setChapterId(chapter.id) setIsEditing(false) setLocalContent(null) // P2-4 移动端选择章节后关闭抽屉 setMobileSidebarOpen(false) } const effectiveContent = localContent ?? selected?.content // P2-2 性能优化:单遍 alternation 正则替换,避免 O(n×m) 多遍扫描 const processedContent = useMemo(() => { if (!effectiveContent) return "" return highlightKnowledgePoints(effectiveContent, currentChapterKPs) }, [effectiveContent, currentChapterKPs]) useEffect(() => { if (!highlightedKpId) return const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`) if (!el) return el.scrollIntoView({ behavior: "smooth", block: "center" }) el.classList.add("ring-2", "ring-primary", "ring-offset-2") const timer = setTimeout(() => { el.classList.remove("ring-2", "ring-primary", "ring-offset-2") }, 2000) return () => { clearTimeout(timer) } }, [highlightedKpId]) // P2-4 侧边栏内容(章节/知识点/图谱 Tabs),桌面端内联、移动端抽屉复用同一份 const sidebarContent = (
{t("reader.tabs.chapters")} {t("reader.tabs.knowledge")} {/* 任意值 text-[10px]:紧凑徽章,text-xs(12px) 在标签栏中过大 */} {currentChapterKPs.length > 0 && ( {currentChapterKPs.length} )} {t("reader.tabs.graph")}
{!selectedId ? ( ) : isLoadingKPs ? (
{t("reader.loadingKnowledge")}
) : ( { setEditingKp(kp) setEditKpDialogOpen(true) }} onDelete={requestDeleteKnowledgePoint} onCreateQuestion={(kp) => { setTargetKpForQuestion(kp) setQuestionDialogOpen(true) }} /> )}
) return (
{/* P2-4 桌面端侧栏:lg 及以上内联显示 */}
{sidebarContent}
{/* P2-4 移动端侧栏:lg 以下用 Sheet 抽屉式展示 */} {/* 任意值 w-[85vw]:移动端抽屉占视口宽度,max-w-sm 防止超宽屏过大 */} {t("reader.sidebar")}
{sidebarContent}
{/* P2-4 移动端侧栏触发按钮 + 为此课文备课按钮 */}
{selected && ( {selected.title} )} {selected && canCreateLessonPlan && ( )}
{t("dialog.knowledge.deleteTitle")} {t("dialog.knowledge.deleteDesc")} {t("dialog.knowledge.cancel")} {t("dialog.knowledge.delete")} setActiveTab("knowledge")} contentRef={contentRef} onPointerDown={handleContentPointerDown} onContextMenuChange={handleContextMenuChange} selectedText={selectedText} setCreateDialogOpen={setCreateDialogOpen} startEditing={startEditing} cancelEditing={() => setIsEditing(false)} saveContent={handleSaveContent} isSaving={isSaving} processedContent={processedContent} />
) }