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:
@@ -1,81 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useState, useMemo, useCallback } from "react"
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { KnowledgePoint } from "../types"
|
||||
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT } from "../graph-layout"
|
||||
import { Share2 } from "lucide-react"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { GraphViewMode, GraphNodeData } from "../types"
|
||||
import { computeGraphLayout } from "../graph-layout"
|
||||
import { useGraphData } from "../hooks/use-graph-data"
|
||||
import { GraphKpNode } from "./graph-kp-node"
|
||||
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
||||
import { GraphToolbar } from "./graph-toolbar"
|
||||
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
|
||||
|
||||
const nodeTypes = { kpNode: GraphKpNode }
|
||||
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
||||
|
||||
/** 章节颜色调色板 */
|
||||
const CHAPTER_COLORS = [
|
||||
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
|
||||
"#8b5cf6", "#ec4899", "#06b6d4", "#84cc16",
|
||||
]
|
||||
|
||||
interface KnowledgeGraphProps {
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
selectedId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
textbookId: string
|
||||
/** 初始视图模式,默认 structure */
|
||||
initialViewMode?: GraphViewMode
|
||||
}
|
||||
|
||||
export function KnowledgeGraph({
|
||||
knowledgePoints,
|
||||
selectedId,
|
||||
onHighlight,
|
||||
}: KnowledgeGraphProps) {
|
||||
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: KnowledgeGraphProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const { hasPermission } = usePermission()
|
||||
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const isTeacher = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
const layout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
||||
|
||||
if (knowledgePoints.length === 0) {
|
||||
const { data, isLoading, error } = useGraphData(textbookId, viewMode)
|
||||
|
||||
const availableViewModes: GraphViewMode[] = isTeacher
|
||||
? ["structure", "class-mastery"]
|
||||
: ["structure", "student-mastery"]
|
||||
|
||||
// 章节颜色映射
|
||||
const chapterColorMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
if (!data) return map
|
||||
const chapterIds = [...new Set(
|
||||
data.knowledgePoints
|
||||
.map((kp) => kp.chapterId)
|
||||
.filter((id): id is string => id !== null),
|
||||
)]
|
||||
chapterIds.forEach((id, index) => {
|
||||
map.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!)
|
||||
})
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (!data) return { nodes: [], edges: [], width: 0, height: 0 }
|
||||
return computeGraphLayout(data.knowledgePoints)
|
||||
}, [data])
|
||||
|
||||
// 搜索高亮
|
||||
const matchedIds = useMemo(() => {
|
||||
if (!searchText || !data) return new Set<string>()
|
||||
const searchLower = searchText.toLowerCase()
|
||||
return new Set(
|
||||
data.knowledgePoints
|
||||
.filter((kp) => kp.name.toLowerCase().includes(searchLower))
|
||||
.map((kp) => kp.id),
|
||||
)
|
||||
}, [searchText, data])
|
||||
|
||||
// 关联节点高亮(选中节点的前置+后置)
|
||||
const relatedIds = useMemo(() => {
|
||||
if (!selectedKpId || !data) return new Set<string>()
|
||||
const related = new Set<string>([selectedKpId])
|
||||
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
|
||||
if (selectedKp) {
|
||||
for (const id of selectedKp.prerequisiteIds) related.add(id)
|
||||
for (const kp of data.knowledgePoints) {
|
||||
if (kp.prerequisiteIds.includes(selectedKpId)) related.add(kp.id)
|
||||
}
|
||||
}
|
||||
return related
|
||||
}, [selectedKpId, data])
|
||||
|
||||
// 从已加载数据计算前置/后置列表(避免 server-only 导入)
|
||||
const prerequisites = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
|
||||
if (!selectedKpId || !data) return []
|
||||
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
|
||||
if (!selectedKp) return []
|
||||
return data.knowledgePoints
|
||||
.filter((kp) => selectedKp.prerequisiteIds.includes(kp.id))
|
||||
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
|
||||
}, [selectedKpId, data])
|
||||
|
||||
const successors = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
|
||||
if (!selectedKpId || !data) return []
|
||||
return data.knowledgePoints
|
||||
.filter((kp) => kp.prerequisiteIds.includes(selectedKpId))
|
||||
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
|
||||
}, [selectedKpId, data])
|
||||
|
||||
// 组装 React Flow nodes
|
||||
const rfNodes: Node[] = useMemo(() => {
|
||||
return layout.nodes.map((node) => {
|
||||
const kp = node.data.kp
|
||||
const mastery = data?.masteryMap[kp.id] ?? null
|
||||
const isSelected = selectedKpId === node.id
|
||||
const isHighlighted = !searchText
|
||||
? (selectedKpId === null || relatedIds.has(node.id))
|
||||
: matchedIds.has(node.id)
|
||||
|
||||
const graphData: GraphNodeData = {
|
||||
kp,
|
||||
mastery,
|
||||
viewMode,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280",
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: { ...node.data, graphData },
|
||||
selected: isSelected,
|
||||
}
|
||||
})
|
||||
}, [layout, data, selectedKpId, relatedIds, matchedIds, searchText, viewMode, chapterColorMap])
|
||||
|
||||
// 组装 React Flow edges
|
||||
const rfEdges: Edge[] = useMemo(() => {
|
||||
return layout.edges.map((edge) => ({
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
isHighlighted: selectedKpId === null || relatedIds.has(edge.source) || relatedIds.has(edge.target),
|
||||
},
|
||||
}))
|
||||
}, [layout, selectedKpId, relatedIds])
|
||||
|
||||
const onNodeClick = useCallback((_event: unknown, node: Node) => {
|
||||
setSelectedKpId(node.id)
|
||||
}, [])
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
reactFlow.fitView({ duration: 300 })
|
||||
setSearchText("")
|
||||
setSelectedKpId(null)
|
||||
}, [reactFlow])
|
||||
|
||||
const onJumpToKp = useCallback((kpId: string) => {
|
||||
setSelectedKpId(kpId)
|
||||
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
||||
}, [reactFlow])
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
{t("reader.emptyKnowledge")}
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t("reader.loadingKnowledge")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto p-4">
|
||||
<svg
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
role="img"
|
||||
aria-label={t("reader.tabs.graph")}
|
||||
className="mx-auto"
|
||||
>
|
||||
<title>{t("reader.tabs.graph")}</title>
|
||||
{/* 边 */}
|
||||
{layout.edges.map((edge) => (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.x1}
|
||||
y1={edge.y1}
|
||||
x2={edge.x2}
|
||||
y2={edge.y2}
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.3}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Share2}
|
||||
title={t("graph.error.loadFailed")}
|
||||
description={error}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* 节点 */}
|
||||
{layout.nodes.map((node) => {
|
||||
const isSelected = selectedId === node.id
|
||||
return (
|
||||
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}>
|
||||
<foreignObject width={NODE_WIDTH} height={NODE_HEIGHT}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHighlight(node.id)}
|
||||
className={`flex h-full w-full items-center justify-center rounded-lg border-2 px-3 text-center text-xs font-medium transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-card-foreground hover:border-primary/50 hover:bg-accent"
|
||||
}`}
|
||||
aria-label={`${t("reader.clickToViewKp")}: ${node.name}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="line-clamp-2">{node.name}</span>
|
||||
</button>
|
||||
</foreignObject>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
if (!data || data.knowledgePoints.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Share2}
|
||||
title={t("reader.emptyKnowledge")}
|
||||
description={t("reader.emptyKnowledgeDesc")}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null
|
||||
const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<GraphToolbar
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
availableViewModes={availableViewModes}
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
onResetView={resetView}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.2}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls className="!bg-background !border !rounded-lg" />
|
||||
<MiniMap
|
||||
className="!bg-background !border !rounded-lg"
|
||||
nodeColor={(node) => {
|
||||
// node.data 是 Record<string, unknown>;从 unknown 安全转换读取 graphData
|
||||
const graphData = (node.data as unknown as { graphData?: { chapterColor: string } })?.graphData
|
||||
return graphData?.chapterColor ?? "#6b7280"
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedKp && (
|
||||
<div className="w-[300px] shrink-0">
|
||||
<GraphNodeDetailPanel
|
||||
kp={selectedKp}
|
||||
mastery={selectedMastery}
|
||||
prerequisites={prerequisites}
|
||||
successors={successors}
|
||||
canEdit={canEdit}
|
||||
textbookId={textbookId}
|
||||
onClose={() => setSelectedKpId(null)}
|
||||
onJumpToKp={onJumpToKp}
|
||||
onAddPrerequisite={() => {
|
||||
// 后续迭代:打开添加前置对话框
|
||||
}}
|
||||
onRemovePrerequisite={(_prereqId: string) => {
|
||||
// 后续迭代:调用 deletePrerequisiteAction
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeGraph(props: KnowledgeGraphProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<KnowledgeGraphInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user