Files
NextEdu/src/modules/textbooks/components/textbook-reader.tsx
SpecialX 6ea8ba763b feat(textbooks): add force-graph component and update graph components
- Add force-graph.tsx for new graph visualization

- Update graph-toolbar, knowledge-graph, knowledge-point-list, textbook-reader

- Update use-graph-data hook and types

- Update textbooks error boundary pages
2026-07-04 10:22:24 +08:00

459 lines
16 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.
"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 { ResizablePanel } from "@/shared/components/ui/resizable-panel"
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
/**
* 题目创建器渲染函数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<string | null>(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<Record<string, KnowledgePoint[]>>({})
const requestedChaptersRef = useRef<Set<string>>(new Set())
// 用 useMemo 包裹以稳定引用,避免下游 useMemo 因 [] 引用变化而重复计算
const currentChapterKPs = useMemo<KnowledgePoint[]>(
() => (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<string | null>(null)
const onCreateKnowledgePoint = async (formData: FormData) => {
setIsCreating(true)
try {
await handleCreateKnowledgePoint(formData)
} finally {
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])
// P2 状态驱动:仅保留 scrollIntoView滚动无法声明式实现
// 视觉高亮已由 TextbookContentPanel 中 isHighlighted 状态驱动,无需命令式 classList 操作
useEffect(() => {
if (!highlightedKpId) return
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
if (!el) return
el.scrollIntoView({ behavior: "smooth", block: "center" })
}, [highlightedKpId])
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs桌面端内联、移动端抽屉复用同一份
const sidebarContent = (
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
{t("reader.tabs.chapters")}
</TabsTrigger>
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{t("reader.tabs.knowledge")}
{/* 任意值 text-[10px]紧凑徽章text-xs(12px) 在标签栏中过大 */}
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
{currentChapterKPs.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="graph" className="gap-2">
<Share2 className="h-4 w-4" />
{t("reader.tabs.graph")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
</TextbookSectionErrorBoundary>
</TabsContent>
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!selectedId ? (
<EmptyState
icon={Tag}
title={t("reader.selectChapterKnowledge")}
description={t("reader.selectChapterKnowledgeDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
) : isLoadingKPs ? (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
{t("reader.loadingKnowledge")}
</div>
) : (
<KnowledgePointList
knowledgePoints={currentChapterKPs}
canEdit={canEdit}
canCreateQuestion={canCreateQuestion}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onEdit={(kp) => {
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
onDelete={requestDeleteKnowledgePoint}
onCreateQuestion={(kp) => {
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
/>
)}
</TextbookSectionErrorBoundary>
</TabsContent>
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<KnowledgeGraph textbookId={textbookId} />
</TextbookSectionErrorBoundary>
</TabsContent>
</Tabs>
)
// 图谱 tab 时侧栏(图谱)占更大比例,其他 tab 时侧栏(目录/知识点)占较小比例
const sidebarInitialPct = activeTab === "graph" ? 60 : 35
const contentPanel = (
<div className="flex flex-col h-full min-h-0 relative">
{/* 移动端侧栏触发按钮 + 为此课文备课按钮 */}
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setMobileSidebarOpen(true)}
className="lg:hidden"
aria-expanded={mobileSidebarOpen}
aria-controls="mobile-sidebar-sheet"
>
<Menu className="mr-2 h-4 w-4" />
{t("reader.openSidebar")}
</Button>
{selected && (
<span className="text-sm text-muted-foreground truncate hidden lg:inline">
{selected.title}
</span>
)}
{selected && canCreateLessonPlan && (
<Button asChild variant="default" size="sm" className="ml-auto">
<Link
href={`/teacher/lesson-plans/new?textbookId=${encodeURIComponent(textbookId)}&chapterId=${encodeURIComponent(selected.id)}`}
>
<GraduationCap className="mr-2 h-4 w-4" />
{t("reader.prepareLesson")}
</Link>
</Button>
)}
</div>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("dialog.knowledge.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("dialog.knowledge.deleteDesc")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}>
{t("dialog.knowledge.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<KnowledgePointDialogs
createDialogOpen={createDialogOpen}
setCreateDialogOpen={setCreateDialogOpen}
selectedText={selectedText}
isCreating={isCreating}
onCreateKnowledgePoint={onCreateKnowledgePoint}
editKpDialogOpen={editKpDialogOpen}
setEditKpDialogOpen={setEditKpDialogOpen}
editingKp={editingKp}
isUpdatingKp={isUpdatingKp}
onUpdateKnowledgePoint={handleUpdateKnowledgePoint}
questionDialogOpen={questionDialogOpen}
setQuestionDialogOpen={setQuestionDialogOpen}
targetKpForQuestion={targetKpForQuestion}
renderQuestionCreator={canCreateQuestion ? renderQuestionCreator : undefined}
/>
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<TextbookContentPanel
chapter={{ selected, processedContent }}
editing={{
isEditing,
editContent,
setEditContent,
isSaving,
startEditing,
cancelEditing: () => setIsEditing(false),
saveContent: handleSaveContent,
}}
selection={{
contentRef,
onPointerDown: handleContentPointerDown,
onContextMenuChange: handleContextMenuChange,
selectedText,
setCreateDialogOpen,
}}
highlight={{
highlightedKpId,
onHighlight: setHighlightedKpId,
onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
}}
canEdit={canEdit}
/>
</TextbookSectionErrorBoundary>
</div>
)
return (
<div className="flex flex-col h-full lg:h-full">
{/* 移动端:单列布局 + Sheet 抽屉侧栏 */}
<div className="lg:hidden flex-1 min-h-0">{contentPanel}</div>
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
{/* 任意值 w-[85vw]移动端抽屉占视口宽度max-w-sm 防止超宽屏过大 */}
<SheetContent side="left" className="w-[85vw] max-w-sm p-0 flex flex-col">
<SheetHeader className="px-4 py-3 border-b shrink-0">
<SheetTitle className="text-left">{t("reader.sidebar")}</SheetTitle>
</SheetHeader>
<div className="flex-1 min-h-0 p-2">{sidebarContent}</div>
</SheetContent>
</Sheet>
{/* 桌面端:可拖拽分栏,左侧侧栏 + 右侧正文,切换 tab 时重置比例 */}
<div className="hidden lg:flex h-full min-h-0">
<ResizablePanel
key={activeTab}
initialLeft={sidebarInitialPct}
minLeft={20}
minRight={25}
left={<div className="h-full pr-3 border-r">{sidebarContent}</div>}
right={<div className="h-full pl-3">{contentPanel}</div>}
/>
</div>
</div>
)
}