Files
NextEdu/src/modules/textbooks/components/textbook-reader.tsx
SpecialX 58656da983 feat(textbooks): 知识图谱功能全面重构 — 前置依赖 + dagre 布局 + React Flow 可视化 + 师生双视角
将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。

数据层:新增 knowledgePointPrerequisites 表(复合主键+双外键 cascade);新增 data-access-graph.ts(server-only)知识点关联聚合、学生/班级掌握度查询;utils.ts 新增 hasCycleAfterAddingEdge(DFS 循环依赖检测)。

业务层:3 个新 Server Action(getKnowledgeGraphDataAction 三视图模式、createPrerequisiteAction 含循环检测、deletePrerequisiteAction);graph-layout.ts 重写为 dagre 分层有向图布局。

视图层:knowledge-graph.tsx 重写为 React Flow 主组件(全书视图+搜索高亮+关联节点高亮+章节着色);4 个新组件(graph-kp-node/graph-prerequisite-edge/graph-toolbar/graph-node-detail-panel);use-graph-data.ts 派生值模式避免 effect 中 setState。

架构:严格三层架构,客户端通过 Server Action 间接访问 server-only 数据层;权限校验+ i18n 全覆盖;架构文档 004/005 同步。

测试:utils.test.ts 新增 5 个循环检测测试,graph-layout.test.ts 重写 5 个 dagre 布局测试,全部 30 个教材模块单元测试通过。

附带提交 drizzle/0005 error-book 迁移文件以保持 journal 一致性。
2026-06-23 00:13:03 +08:00

434 lines
15 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 } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
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 [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)
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("Failed to save content")
} 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 = (
<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")}
{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>
)
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
{/* P2-4 桌面端侧栏lg 及以上内联显示 */}
<div className="hidden lg:flex lg:col-span-4 lg:border-r lg:pr-6 flex-col min-h-0">
{sidebarContent}
</div>
{/* P2-4 移动端侧栏lg 以下用 Sheet 抽屉式展示 */}
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<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>
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
{/* P2-4 移动端侧栏触发按钮 */}
<div className="lg:hidden flex items-center gap-2 mb-3 px-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setMobileSidebarOpen(true)}
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">
{selected.title}
</span>
)}
</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
selected={selected}
isEditing={isEditing}
editContent={editContent}
setEditContent={setEditContent}
canEdit={canEdit}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
contentRef={contentRef}
onPointerDown={handleContentPointerDown}
onContextMenuChange={handleContextMenuChange}
selectedText={selectedText}
setCreateDialogOpen={setCreateDialogOpen}
startEditing={startEditing}
cancelEditing={() => setIsEditing(false)}
saveContent={handleSaveContent}
isSaving={isSaving}
processedContent={processedContent}
/>
</TextbookSectionErrorBoundary>
</div>
</div>
)
}