From 6ea8ba763bf78322e57d05e4bb24c5c72aa57b87 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:22:24 +0800 Subject: [PATCH] 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 --- .../student/learning/textbooks/[id]/error.tsx | 5 +- .../student/learning/textbooks/error.tsx | 5 +- .../teacher/textbooks/[id]/error.tsx | 5 +- .../(dashboard)/teacher/textbooks/error.tsx | 5 +- .../textbooks/components/force-graph.tsx | 446 ++++++++++++++++++ .../textbooks/components/graph-toolbar.tsx | 33 +- .../textbooks/components/knowledge-graph.tsx | 78 +-- .../components/knowledge-point-list.tsx | 13 +- .../textbooks/components/textbook-reader.tsx | 212 +++++---- src/modules/textbooks/hooks/use-graph-data.ts | 13 +- src/modules/textbooks/types.ts | 5 +- 11 files changed, 667 insertions(+), 153 deletions(-) create mode 100644 src/modules/textbooks/components/force-graph.tsx diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx index 5413b73..82c2e56 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx @@ -1,13 +1,12 @@ "use client" -import { RouteError } from "@/shared/components/route-error" +import { RouteErrorBoundary } from "@/shared/components/route-error" export default function StudentTextbookDetailError({ - error, reset, }: { error: Error & { digest?: string } reset: () => void }) { - return + return } diff --git a/src/app/(dashboard)/student/learning/textbooks/error.tsx b/src/app/(dashboard)/student/learning/textbooks/error.tsx index 1afbc28..e2c47bc 100644 --- a/src/app/(dashboard)/student/learning/textbooks/error.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/error.tsx @@ -1,13 +1,12 @@ "use client" -import { RouteError } from "@/shared/components/route-error" +import { RouteErrorBoundary } from "@/shared/components/route-error" export default function StudentTextbooksError({ - error, reset, }: { error: Error & { digest?: string } reset: () => void }) { - return + return } diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/error.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/error.tsx index 515a5ca..36779e6 100644 --- a/src/app/(dashboard)/teacher/textbooks/[id]/error.tsx +++ b/src/app/(dashboard)/teacher/textbooks/[id]/error.tsx @@ -1,13 +1,12 @@ "use client" -import { RouteError } from "@/shared/components/route-error" +import { RouteErrorBoundary } from "@/shared/components/route-error" export default function TeacherTextbookDetailError({ - error, reset, }: { error: Error & { digest?: string } reset: () => void }) { - return + return } diff --git a/src/app/(dashboard)/teacher/textbooks/error.tsx b/src/app/(dashboard)/teacher/textbooks/error.tsx index e65b4cc..b3f7b24 100644 --- a/src/app/(dashboard)/teacher/textbooks/error.tsx +++ b/src/app/(dashboard)/teacher/textbooks/error.tsx @@ -1,13 +1,12 @@ "use client" -import { RouteError } from "@/shared/components/route-error" +import { RouteErrorBoundary } from "@/shared/components/route-error" export default function TeacherTextbooksError({ - error, reset, }: { error: Error & { digest?: string } reset: () => void }) { - return + return } diff --git a/src/modules/textbooks/components/force-graph.tsx b/src/modules/textbooks/components/force-graph.tsx new file mode 100644 index 0000000..e4309b2 --- /dev/null +++ b/src/modules/textbooks/components/force-graph.tsx @@ -0,0 +1,446 @@ +"use client" + +/** + * Obsidian 风格的知识点力导向图谱。 + * + * 视觉对齐 Obsidian Graph View: + * - 小圆点节点(非大圆球),半径随连接数轻微变化 + * - 极细半透明边线,父子/依赖用颜色区分(Obsidian 无虚线) + * - 标签默认显示在节点右侧,缩放小时按连接数优先显示 + * - 选中/hover 节点时高亮邻居,无关节点淡化 + * - 节点拖拽联动(d3-force 物理模拟) + * + * 通过 next/dynamic ssr:false 加载,避免 SSR 阶段访问 canvas/window。 + * 顶部用 `import type` 引入类型,编译期擦除,防止模块顶层副作用在 SSR 执行。 + */ + +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react" +import dynamic from "next/dynamic" +import type ForceGraph2DType from "react-force-graph-2d" +import type { + ForceGraphMethods, + NodeObject, + LinkObject, +} from "react-force-graph-2d" +import { useTranslations } from "next-intl" +import { Share2 } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" +import type { + GraphViewMode, + KnowledgeGraphData, + KpWithRelations, + MasteryInfo, + MasteryLevel, +} from "../types" + +/** 章节颜色调色板(柔和、Obsidian 风格) */ +const CHAPTER_COLORS = [ + "#7c3aed", "#2563eb", "#dc2626", "#059669", + "#d97706", "#db2777", "#0891b2", "#65a30d", + "#9333ea", "#0284c7", "#e11d48", "#16a34a", +] + +/** 掌握度色彩(柔和) */ +const MASTERY_FILL: Record = { + low: "#dc2626", + medium: "#d97706", + high: "#059669", + unassessed: "#94a3b8", +} + +/** 边颜色:父子关系(中性灰)/ 前置依赖(紫色) */ +const EDGE_PARENT_COLOR = "rgba(148, 163, 184, 0.5)" +const EDGE_PREREQ_COLOR = "rgba(124, 58, 237, 0.6)" + +/** 力导向布局参数(参照 Obsidian 默认手感)。 + * Obsidian 用较强斥力让节点分散,链接距离短,collide 防重叠。 + */ +const FORCE_CHARGE = -280 +const FORCE_LINK_DISTANCE = 45 +const FORCE_CENTER_STRENGTH = 0.02 + +/** 节点基础半径(Obsidian 风格:小圆点) */ +const NODE_BASE_RADIUS = 4 +const NODE_MAX_RADIUS = 9 +/** 标签显示的缩放阈值(缩放小于此值时只显示高连接数节点标签) */ +const LABEL_MIN_SCALE = 1.2 +/** 缩放小于此值时不显示任何标签 */ +const LABEL_HIDE_SCALE = 0.6 + +/** + * 动态加载的 react-force-graph-2d(SSR 关闭)。 + * 用 `as unknown as ComponentType<...>` 保留完整 props 类型。 + */ +const ForceGraph2D = dynamic( + () => import("react-force-graph-2d").then((mod) => mod.default), + { + ssr: false, + loading: () => , + }, +) as unknown as ComponentType> + +function ForceGraphLoading() { + const t = useTranslations("textbooks") + return ( +
+ {t("reader.loadingKnowledge")} +
+ ) +} + +function getMasteryLevel(mastery: MasteryInfo | null): MasteryLevel { + if (!mastery) return "unassessed" + if (mastery.masteryLevel < 60) return "low" + if (mastery.masteryLevel < 85) return "medium" + return "high" +} + +/** 力导向图谱节点 */ +interface KpGraphNode extends NodeObject { + id: string + name: string + kp: KpWithRelations + mastery: MasteryInfo | null + /** 连接数(决定节点大小) */ + val: number + /** 章节颜色 */ + chapterColor: string +} + +/** 力导向图谱边 */ +interface KpGraphLink { + source: string | KpGraphNode + target: string | KpGraphNode + /** 边类型:parent(树归属)/ prerequisite(依赖) */ + edgeType: "parent" | "prerequisite" +} + +interface ForceGraphProps { + data: KnowledgeGraphData + viewMode: GraphViewMode + searchText: string + selectedKpId: string | null + onSelectKp: (kpId: string | null) => void +} + +/** 将业务数据转换为 react-force-graph-2d graphData 格式 */ +function buildGraphData( + data: KnowledgeGraphData, +): { nodes: KpGraphNode[]; links: KpGraphLink[] } { + const kpIdSet = new Set(data.knowledgePoints.map((kp) => kp.id)) + + // 章节颜色映射 + const chapterColorMap = new Map() + const chapterIds = [...new Set( + data.knowledgePoints + .map((kp) => kp.chapterId) + .filter((id): id is string => id !== null), + )] + chapterIds.forEach((id, index) => { + chapterColorMap.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!) + }) + + // 计算每个节点的连接数 + const connectionCount = new Map() + for (const kp of data.knowledgePoints) { + if (kp.parentId && kpIdSet.has(kp.parentId)) { + connectionCount.set(kp.id, (connectionCount.get(kp.id) ?? 0) + 1) + connectionCount.set(kp.parentId, (connectionCount.get(kp.parentId) ?? 0) + 1) + } + for (const prereqId of kp.prerequisiteIds) { + if (kpIdSet.has(prereqId)) { + connectionCount.set(kp.id, (connectionCount.get(kp.id) ?? 0) + 1) + connectionCount.set(prereqId, (connectionCount.get(prereqId) ?? 0) + 1) + } + } + } + + const nodes: KpGraphNode[] = data.knowledgePoints.map((kp) => ({ + id: kp.id, + name: kp.name, + kp, + mastery: data.masteryMap[kp.id] ?? null, + val: connectionCount.get(kp.id) ?? 0, + chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280", + })) + + const links: KpGraphLink[] = [] + for (const kp of data.knowledgePoints) { + if (kp.parentId && kpIdSet.has(kp.parentId)) { + links.push({ source: kp.parentId, target: kp.id, edgeType: "parent" }) + } + for (const prereqId of kp.prerequisiteIds) { + if (kpIdSet.has(prereqId)) { + links.push({ source: prereqId, target: kp.id, edgeType: "prerequisite" }) + } + } + } + + return { nodes, links } +} + +/** 计算节点邻居集合(含自身) */ +function computeNeighbors(data: KnowledgeGraphData, kpId: string): Set { + const result = new Set([kpId]) + const selectedKp = data.knowledgePoints.find((kp) => kp.id === kpId) + if (selectedKp) { + if (selectedKp.parentId) result.add(selectedKp.parentId) + for (const id of selectedKp.prerequisiteIds) result.add(id) + for (const kp of data.knowledgePoints) { + if (kp.parentId === kpId) result.add(kp.id) + if (kp.prerequisiteIds.includes(kpId)) result.add(kp.id) + } + } + return result +} + +function getNodeHighlightState( + nodeId: string, + searchText: string, + matchedIds: Set, + selectedKpId: string | null, + neighborIds: Set, +): "highlighted" | "dimmed" | "normal" { + if (searchText) { + return matchedIds.has(nodeId) ? "highlighted" : "dimmed" + } + if (selectedKpId) { + return neighborIds.has(nodeId) ? "highlighted" : "dimmed" + } + return "normal" +} + +/** 节点半径:基础 + 连接数轻微缩放(Obsidian 风格,变化幅度小) */ +function getNodeRadius(val: number): number { + const scaled = NODE_BASE_RADIUS + Math.log2(val + 1) * 1.8 + return Math.min(NODE_MAX_RADIUS, scaled) +} + +function ForceGraphInner({ + data, + viewMode, + searchText, + selectedKpId, + onSelectKp, +}: ForceGraphProps) { + const t = useTranslations("textbooks") + const containerRef = useRef(null) + const fgRef = useRef(undefined) + const [hoveredKpId, setHoveredKpId] = useState(null) + const [dimensions, setDimensions] = useState({ width: 800, height: 600 }) + + const graphData = useMemo(() => buildGraphData(data), [data]) + + // 搜索匹配 + const matchedIds = useMemo(() => { + if (!searchText) return new Set() + const searchLower = searchText.toLowerCase() + return new Set( + data.knowledgePoints + .filter((kp) => kp.name.toLowerCase().includes(searchLower)) + .map((kp) => kp.id), + ) + }, [searchText, data]) + + // 选中节点的邻居集合 + const selectedNeighborIds = useMemo(() => { + if (!selectedKpId) return new Set() + return computeNeighbors(data, selectedKpId) + }, [selectedKpId, data]) + + // hover 节点的邻居集合(Obsidian 风格:hover 也高亮邻居) + const hoveredNeighborIds = useMemo(() => { + if (!hoveredKpId) return new Set() + return computeNeighbors(data, hoveredKpId) + }, [hoveredKpId, data]) + + // 当前生效的高亮邻居集合(选中优先于 hover) + const activeNeighborIds = selectedKpId ? selectedNeighborIds : hoveredNeighborIds + const activeFocusId = selectedKpId ?? hoveredKpId + + // 容器尺寸自适应 + useEffect(() => { + const container = containerRef.current + if (!container) return + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (entry) { + setDimensions({ + width: Math.max(320, entry.contentRect.width), + height: Math.max(240, entry.contentRect.height), + }) + } + }) + observer.observe(container) + return () => observer.disconnect() + }, []) + + // 配置力(Obsidian 风格) + useEffect(() => { + const fg = fgRef.current + if (!fg) return + fg.d3Force("charge")?.strength(FORCE_CHARGE) + fg.d3Force("link")?.distance(FORCE_LINK_DISTANCE) + fg.d3Force("center")?.strength(FORCE_CENTER_STRENGTH) + fg.d3ReheatSimulation() + }, [graphData]) + + // 节点 Canvas 绘制(Obsidian 风格) + const nodeCanvasObject = useCallback( + (node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => { + const kpNode = node as KpGraphNode + const highlightState = getNodeHighlightState( + kpNode.id, + searchText, + matchedIds, + activeFocusId, + activeNeighborIds, + ) + + const radius = getNodeRadius(kpNode.val) + const showMastery = viewMode === "student-mastery" || viewMode === "class-mastery" + const fillColor = showMastery + ? MASTERY_FILL[getMasteryLevel(kpNode.mastery)] + : kpNode.chapterColor + + // 透明度(Obsidian 风格:淡化节点保持可见但低对比) + const alpha = highlightState === "dimmed" ? 0.15 : 1 + + ctx.save() + ctx.globalAlpha = alpha + + // 选中/hover 节点外圈高亮环(Obsidian 风格) + if (kpNode.id === activeFocusId) { + ctx.beginPath() + ctx.arc(kpNode.x ?? 0, kpNode.y ?? 0, radius + 3, 0, 2 * Math.PI) + ctx.strokeStyle = "rgba(124, 58, 237, 0.8)" + ctx.lineWidth = 1.5 + ctx.stroke() + } + + // 主圆(Obsidian 风格:实心小圆点 + 轻微白色描边) + ctx.beginPath() + ctx.arc(kpNode.x ?? 0, kpNode.y ?? 0, radius, 0, 2 * Math.PI) + ctx.fillStyle = fillColor + ctx.fill() + ctx.strokeStyle = "rgba(255,255,255,0.9)" + ctx.lineWidth = 0.8 + ctx.stroke() + + // 标签(Obsidian 风格:节点右侧,缩放小时只显示高连接数节点) + const shouldShowLabel = (() => { + if (globalScale < LABEL_HIDE_SCALE) return false + if (highlightState === "dimmed") return false + if (highlightState === "highlighted") return true + // 缩放足够大时显示所有标签,否则只显示连接数 ≥3 的 + if (globalScale > LABEL_MIN_SCALE) return true + return kpNode.val >= 3 + })() + + if (shouldShowLabel) { + const fontSize = 11 / globalScale + ctx.font = `${fontSize}px -apple-system, system-ui, sans-serif` + ctx.textAlign = "left" + ctx.textBaseline = "middle" + // 标签背景(Obsidian 风格:浅色背景提升可读性) + const labelX = (kpNode.x ?? 0) + radius + 2 + const labelY = kpNode.y ?? 0 + const labelWidth = ctx.measureText(kpNode.name).width + ctx.fillStyle = "rgba(255,255,255,0.85)" + ctx.fillRect(labelX - 1, labelY - fontSize / 2 - 1, labelWidth + 2, fontSize + 2) + // 标签文字 + ctx.fillStyle = "rgba(30, 41, 59, 0.95)" + ctx.fillText(kpNode.name, labelX, labelY) + } + + ctx.restore() + }, + [searchText, matchedIds, activeFocusId, activeNeighborIds, viewMode], + ) + + // 边绘制(Obsidian 风格:极细半透明线,颜色区分类型) + const linkCanvasObject = useCallback( + (link: LinkObject, ctx: CanvasRenderingContext2D) => { + const kpLink = link as KpGraphLink + const source = kpLink.source as KpGraphNode + const target = kpLink.target as KpGraphNode + if (!source.x || !source.y || !target.x || !target.y) return + + // 高亮逻辑:有焦点时仅高亮含邻居节点的边 + const isHighlighted = + activeFocusId === null || + activeNeighborIds.has(source.id) || + activeNeighborIds.has(target.id) + const alpha = isHighlighted ? 1 : 0.08 + + ctx.save() + ctx.globalAlpha = alpha + ctx.strokeStyle = kpLink.edgeType === "prerequisite" ? EDGE_PREREQ_COLOR : EDGE_PARENT_COLOR + ctx.lineWidth = 0.8 + ctx.beginPath() + ctx.moveTo(source.x, source.y) + ctx.lineTo(target.x, target.y) + ctx.stroke() + ctx.restore() + }, + [activeFocusId, activeNeighborIds], + ) + + const handleNodeClick = useCallback( + (node: NodeObject) => { + const kpNode = node as KpGraphNode + onSelectKp(kpNode.id === selectedKpId ? null : kpNode.id) + }, + [onSelectKp, selectedKpId], + ) + + const handleBackgroundClick = useCallback(() => { + onSelectKp(null) + }, [onSelectKp]) + + const handleNodeHover = useCallback((node: NodeObject | null) => { + setHoveredKpId(node ? (node as KpGraphNode).id : null) + if (containerRef.current) { + containerRef.current.style.cursor = node ? "pointer" : "default" + } + }, []) + + if (data.knowledgePoints.length === 0) { + return ( + + ) + } + + return ( +
+ +
+ ) +} + +export function ForceKnowledgeGraph(props: ForceGraphProps) { + return +} diff --git a/src/modules/textbooks/components/graph-toolbar.tsx b/src/modules/textbooks/components/graph-toolbar.tsx index 1f4fdc3..bea6d56 100644 --- a/src/modules/textbooks/components/graph-toolbar.tsx +++ b/src/modules/textbooks/components/graph-toolbar.tsx @@ -11,12 +11,14 @@ import { SelectTrigger, SelectValue, } from "@/shared/components/ui/select" -import type { GraphViewMode } from "../types" +import type { GraphLayoutMode, GraphViewMode } from "../types" interface GraphToolbarProps { viewMode: GraphViewMode onViewModeChange: (mode: GraphViewMode) => void availableViewModes: GraphViewMode[] + layoutMode: GraphLayoutMode + onLayoutModeChange: (mode: GraphLayoutMode) => void searchText: string onSearchChange: (text: string) => void onResetView: () => void @@ -30,14 +32,22 @@ const ALL_VIEW_MODES: readonly GraphViewMode[] = [ "class-mastery", ] +const ALL_LAYOUT_MODES: readonly GraphLayoutMode[] = ["hierarchical", "force"] + function isGraphViewMode(value: string): value is GraphViewMode { return ALL_VIEW_MODES.some((mode) => mode === value) } +function isGraphLayoutMode(value: string): value is GraphLayoutMode { + return ALL_LAYOUT_MODES.some((mode) => mode === value) +} + export function GraphToolbar({ viewMode, onViewModeChange, availableViewModes, + layoutMode, + onLayoutModeChange, searchText, onSearchChange, onResetView, @@ -45,15 +55,32 @@ export function GraphToolbar({ }: GraphToolbarProps) { const t = useTranslations("textbooks") - const handleValueChange = (v: string): void => { + const handleViewModeChange = (v: string): void => { if (isGraphViewMode(v)) { onViewModeChange(v) } } + const handleLayoutModeChange = (v: string): void => { + if (isGraphLayoutMode(v)) { + onLayoutModeChange(v) + } + } + return (
- + + + + + {t("graph.layout.hierarchical")} + {t("graph.layout.force")} + + + +