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 一致性。
This commit is contained in:
SpecialX
2026-06-23 00:13:03 +08:00
parent 15aa84b72c
commit 58656da983
28 changed files with 21377 additions and 575 deletions

View File

@@ -1,18 +1,19 @@
"use client"
import { useMemo, useState, useEffect, type ReactNode } from "react"
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Tag, List, Share2 } from "lucide-react"
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 } from "../actions"
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,
@@ -33,20 +34,30 @@ import {
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 } from "../utils"
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
export interface TextbookReaderProps {
chapters: Chapter[]
knowledgePoints?: KnowledgePoint[]
/**
* 教材 ID用于按章节懒加载知识点P2-3
* 必传,否则知识点面板将始终为空。
*/
textbookId: string
/**
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
* 保留 prop 仅为向后兼容,传入值会被忽略。
* @deprecated 改用权限系统自动判断
*/
canEdit?: boolean
textbookId?: string
/**
* 题目创建器渲染函数P0-1 解耦)。
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
@@ -57,7 +68,6 @@ export interface TextbookReaderProps {
export function TextbookReader({
chapters,
knowledgePoints = [],
textbookId,
renderQuestionCreator,
}: TextbookReaderProps) {
@@ -71,6 +81,8 @@ export function TextbookReader({
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("")
@@ -92,10 +104,42 @@ export function TextbookReader({
const selected = chapterId ? index.get(chapterId) ?? null : null
const selectedId = selected?.id ?? null
const currentChapterKPs = useMemo(() => {
if (!selectedId) return []
return knowledgePoints.filter((kp) => kp.chapterId === selectedId)
}, [knowledgePoints, selectedId])
// 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,
@@ -137,15 +181,21 @@ export function TextbookReader({
const handleSaveContent = async () => {
if (!selectedId || !textbookId) return
setIsSaving(true)
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
setIsSaving(false)
try {
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
if (result.success) {
toast.success(result.message)
setIsEditing(false)
setLocalContent(editContent)
} else {
toast.error(result.message)
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)
}
}
@@ -160,137 +210,165 @@ export function TextbookReader({
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 ""
let content = effectiveContent
const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
for (const kp of sortedKPs) {
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = new RegExp(`(${escapedName})`, "gi")
content = content.replace(regex, `[$1](#kp-${kp.id})`)
}
return content
return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
}, [effectiveContent, currentChapterKPs])
useEffect(() => {
if (highlightedKpId) {
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" })
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
setTimeout(() => {
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
}, 2000)
}
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])
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
<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" disabled={!selectedId}>
<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 ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.selectChapterKnowledge")}
</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")}
>
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.selectChapterGraph")}
</div>
) : (
<KnowledgeGraph
knowledgePoints={currentChapterKPs}
selectedId={highlightedKpId}
onHighlight={setHighlightedKpId}
/>
)}
</TextbookSectionErrorBoundary>
</TabsContent>
</Tabs>
// 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>
@@ -334,7 +412,6 @@ export function TextbookReader({
editContent={editContent}
setEditContent={setEditContent}
canEdit={canEdit}
knowledgePoints={currentChapterKPs}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
@@ -342,10 +419,7 @@ export function TextbookReader({
onPointerDown={handleContentPointerDown}
onContextMenuChange={handleContextMenuChange}
selectedText={selectedText}
createDialogOpen={createDialogOpen}
setCreateDialogOpen={setCreateDialogOpen}
isCreating={isCreating}
onCreateKnowledgePoint={onCreateKnowledgePoint}
startEditing={startEditing}
cancelEditing={() => setIsEditing(false)}
saveContent={handleSaveContent}