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
This commit is contained in:
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function StudentTextbookDetailError({
|
export default function StudentTextbookDetailError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function StudentTextbooksError({
|
export default function StudentTextbooksError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function TeacherTextbookDetailError({
|
export default function TeacherTextbookDetailError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function TeacherTextbooksError({
|
export default function TeacherTextbooksError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
|
||||||
}
|
}
|
||||||
|
|||||||
446
src/modules/textbooks/components/force-graph.tsx
Normal file
446
src/modules/textbooks/components/force-graph.tsx
Normal file
@@ -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<MasteryLevel, string> = {
|
||||||
|
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: () => <ForceGraphLoading />,
|
||||||
|
},
|
||||||
|
) as unknown as ComponentType<React.ComponentProps<typeof ForceGraph2DType>>
|
||||||
|
|
||||||
|
function ForceGraphLoading() {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("reader.loadingKnowledge")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>()
|
||||||
|
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<string, number>()
|
||||||
|
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<string> {
|
||||||
|
const result = new Set<string>([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<string>,
|
||||||
|
selectedKpId: string | null,
|
||||||
|
neighborIds: Set<string>,
|
||||||
|
): "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<HTMLDivElement>(null)
|
||||||
|
const fgRef = useRef<ForceGraphMethods | undefined>(undefined)
|
||||||
|
const [hoveredKpId, setHoveredKpId] = useState<string | null>(null)
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
||||||
|
|
||||||
|
const graphData = useMemo(() => buildGraphData(data), [data])
|
||||||
|
|
||||||
|
// 搜索匹配
|
||||||
|
const matchedIds = useMemo(() => {
|
||||||
|
if (!searchText) 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 selectedNeighborIds = useMemo(() => {
|
||||||
|
if (!selectedKpId) return new Set<string>()
|
||||||
|
return computeNeighbors(data, selectedKpId)
|
||||||
|
}, [selectedKpId, data])
|
||||||
|
|
||||||
|
// hover 节点的邻居集合(Obsidian 风格:hover 也高亮邻居)
|
||||||
|
const hoveredNeighborIds = useMemo(() => {
|
||||||
|
if (!hoveredKpId) return new Set<string>()
|
||||||
|
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 (
|
||||||
|
<EmptyState
|
||||||
|
icon={Share2}
|
||||||
|
title={t("reader.emptyKnowledge")}
|
||||||
|
description={t("reader.emptyKnowledgeDesc")}
|
||||||
|
className="h-full border-none shadow-none bg-transparent"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full h-full relative bg-muted/20">
|
||||||
|
<ForceGraph2D
|
||||||
|
ref={fgRef}
|
||||||
|
graphData={graphData}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
nodeId="id"
|
||||||
|
nodeRelSize={1}
|
||||||
|
nodeCanvasObject={nodeCanvasObject}
|
||||||
|
linkCanvasObject={linkCanvasObject}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onBackgroundClick={handleBackgroundClick}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
cooldownTicks={150}
|
||||||
|
enableNodeDrag
|
||||||
|
enableZoomInteraction
|
||||||
|
enablePanInteraction
|
||||||
|
minZoom={0.15}
|
||||||
|
maxZoom={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForceKnowledgeGraph(props: ForceGraphProps) {
|
||||||
|
return <ForceGraphInner {...props} />
|
||||||
|
}
|
||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import type { GraphViewMode } from "../types"
|
import type { GraphLayoutMode, GraphViewMode } from "../types"
|
||||||
|
|
||||||
interface GraphToolbarProps {
|
interface GraphToolbarProps {
|
||||||
viewMode: GraphViewMode
|
viewMode: GraphViewMode
|
||||||
onViewModeChange: (mode: GraphViewMode) => void
|
onViewModeChange: (mode: GraphViewMode) => void
|
||||||
availableViewModes: GraphViewMode[]
|
availableViewModes: GraphViewMode[]
|
||||||
|
layoutMode: GraphLayoutMode
|
||||||
|
onLayoutModeChange: (mode: GraphLayoutMode) => void
|
||||||
searchText: string
|
searchText: string
|
||||||
onSearchChange: (text: string) => void
|
onSearchChange: (text: string) => void
|
||||||
onResetView: () => void
|
onResetView: () => void
|
||||||
@@ -30,14 +32,22 @@ const ALL_VIEW_MODES: readonly GraphViewMode[] = [
|
|||||||
"class-mastery",
|
"class-mastery",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const ALL_LAYOUT_MODES: readonly GraphLayoutMode[] = ["hierarchical", "force"]
|
||||||
|
|
||||||
function isGraphViewMode(value: string): value is GraphViewMode {
|
function isGraphViewMode(value: string): value is GraphViewMode {
|
||||||
return ALL_VIEW_MODES.some((mode) => mode === value)
|
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({
|
export function GraphToolbar({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
availableViewModes,
|
availableViewModes,
|
||||||
|
layoutMode,
|
||||||
|
onLayoutModeChange,
|
||||||
searchText,
|
searchText,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onResetView,
|
onResetView,
|
||||||
@@ -45,15 +55,32 @@ export function GraphToolbar({
|
|||||||
}: GraphToolbarProps) {
|
}: GraphToolbarProps) {
|
||||||
const t = useTranslations("textbooks")
|
const t = useTranslations("textbooks")
|
||||||
|
|
||||||
const handleValueChange = (v: string): void => {
|
const handleViewModeChange = (v: string): void => {
|
||||||
if (isGraphViewMode(v)) {
|
if (isGraphViewMode(v)) {
|
||||||
onViewModeChange(v)
|
onViewModeChange(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLayoutModeChange = (v: string): void => {
|
||||||
|
if (isGraphLayoutMode(v)) {
|
||||||
|
onLayoutModeChange(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 p-2 border-b bg-background/95 shrink-0">
|
<div className="flex flex-wrap items-center gap-2 p-2 border-b bg-background/95 shrink-0">
|
||||||
<Select value={viewMode} onValueChange={handleValueChange}>
|
{/* 布局切换:分层有向图 / Obsidian 风格力导向图 */}
|
||||||
|
<Select value={layoutMode} onValueChange={handleLayoutModeChange}>
|
||||||
|
<SelectTrigger className="w-[120px] h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hierarchical">{t("graph.layout.hierarchical")}</SelectItem>
|
||||||
|
<SelectItem value="force">{t("graph.layout.force")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={viewMode} onValueChange={handleViewModeChange}>
|
||||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import type { GraphViewMode, GraphNodeData } from "../types"
|
import type { GraphLayoutMode, GraphViewMode, GraphNodeData } from "../types"
|
||||||
import { computeGraphLayout } from "../graph-layout"
|
import { computeGraphLayout } from "../graph-layout"
|
||||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
import type { GraphLayoutNodeData } from "../graph-layout"
|
||||||
import { useGraphData } from "../hooks/use-graph-data"
|
import { useGraphData } from "../hooks/use-graph-data"
|
||||||
@@ -47,6 +47,7 @@ import { GraphKpNode } from "./graph-kp-node"
|
|||||||
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
||||||
import { GraphToolbar } from "./graph-toolbar"
|
import { GraphToolbar } from "./graph-toolbar"
|
||||||
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
|
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
|
||||||
|
import { ForceKnowledgeGraph } from "./force-graph"
|
||||||
|
|
||||||
const nodeTypes = { kpNode: GraphKpNode }
|
const nodeTypes = { kpNode: GraphKpNode }
|
||||||
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
||||||
@@ -61,15 +62,18 @@ interface KnowledgeGraphProps {
|
|||||||
textbookId: string
|
textbookId: string
|
||||||
/** 初始视图模式,默认 structure */
|
/** 初始视图模式,默认 structure */
|
||||||
initialViewMode?: GraphViewMode
|
initialViewMode?: GraphViewMode
|
||||||
|
/** 初始布局模式,默认 hierarchical(分层有向图) */
|
||||||
|
initialLayoutMode?: GraphLayoutMode
|
||||||
}
|
}
|
||||||
|
|
||||||
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: KnowledgeGraphProps) {
|
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure", initialLayoutMode = "hierarchical" }: KnowledgeGraphProps) {
|
||||||
const t = useTranslations("textbooks")
|
const t = useTranslations("textbooks")
|
||||||
const { hasPermission } = usePermission()
|
const { hasPermission } = usePermission()
|
||||||
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||||
const reactFlow = useReactFlow()
|
const reactFlow = useReactFlow()
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
||||||
|
const [layoutMode, setLayoutMode] = useState<GraphLayoutMode>(initialLayoutMode)
|
||||||
const [searchText, setSearchText] = useState("")
|
const [searchText, setSearchText] = useState("")
|
||||||
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
||||||
// 添加前置依赖对话框状态
|
// 添加前置依赖对话框状态
|
||||||
@@ -189,15 +193,19 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resetView = useCallback(() => {
|
const resetView = useCallback(() => {
|
||||||
|
if (layoutMode === "hierarchical") {
|
||||||
reactFlow.fitView({ duration: 300 })
|
reactFlow.fitView({ duration: 300 })
|
||||||
|
}
|
||||||
setSearchText("")
|
setSearchText("")
|
||||||
setSelectedKpId(null)
|
setSelectedKpId(null)
|
||||||
}, [reactFlow])
|
}, [reactFlow, layoutMode])
|
||||||
|
|
||||||
const onJumpToKp = useCallback((kpId: string) => {
|
const onJumpToKp = useCallback((kpId: string) => {
|
||||||
setSelectedKpId(kpId)
|
setSelectedKpId(kpId)
|
||||||
|
if (layoutMode === "hierarchical") {
|
||||||
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
||||||
}, [reactFlow])
|
}
|
||||||
|
}, [reactFlow, layoutMode])
|
||||||
|
|
||||||
// 添加前置依赖
|
// 添加前置依赖
|
||||||
const handleAddPrerequisite = useCallback(async () => {
|
const handleAddPrerequisite = useCallback(async () => {
|
||||||
@@ -293,12 +301,15 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
|||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
availableViewModes={availableViewModes}
|
availableViewModes={availableViewModes}
|
||||||
|
layoutMode={layoutMode}
|
||||||
|
onLayoutModeChange={setLayoutMode}
|
||||||
searchText={searchText}
|
searchText={searchText}
|
||||||
onSearchChange={setSearchText}
|
onSearchChange={setSearchText}
|
||||||
onResetView={resetView}
|
onResetView={resetView}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-h-0 relative">
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
{layoutMode === "hierarchical" ? (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={rfNodes}
|
nodes={rfNodes}
|
||||||
edges={rfEdges}
|
edges={rfEdges}
|
||||||
@@ -323,6 +334,15 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
) : (
|
||||||
|
<ForceKnowledgeGraph
|
||||||
|
data={data}
|
||||||
|
viewMode={viewMode}
|
||||||
|
searchText={searchText}
|
||||||
|
selectedKpId={selectedKpId}
|
||||||
|
onSelectKp={setSelectedKpId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,14 +49,21 @@ export function KnowledgePointList({
|
|||||||
<ScrollArea className="flex-1 h-full px-2">
|
<ScrollArea className="flex-1 h-full px-2">
|
||||||
<div className="space-y-2 pb-4">
|
<div className="space-y-2 pb-4">
|
||||||
{knowledgePoints.map((kp) => (
|
{knowledgePoints.map((kp) => (
|
||||||
<button
|
<div
|
||||||
key={kp.id}
|
key={kp.id}
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
|
"w-full text-left p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
|
||||||
highlightedKpId === kp.id && "border-primary bg-primary/5"
|
highlightedKpId === kp.id && "border-primary bg-primary/5"
|
||||||
)}
|
)}
|
||||||
onClick={() => onHighlight(kp.id)}
|
onClick={() => onHighlight(kp.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onHighlight(kp.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
||||||
@@ -115,7 +122,7 @@ export function KnowledgePointList({
|
|||||||
{kp.description}
|
{kp.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/shared/components/ui/sheet"
|
} from "@/shared/components/ui/sheet"
|
||||||
|
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
|
||||||
import { useTextSelection } from "../hooks/use-text-selection"
|
import { useTextSelection } from "../hooks/use-text-selection"
|
||||||
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
|
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
|
||||||
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
|
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
|
||||||
@@ -326,26 +327,12 @@ export function TextbookReader({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
// 图谱 tab 时侧栏(图谱)占更大比例,其他 tab 时侧栏(目录/知识点)占较小比例
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
|
const sidebarInitialPct = activeTab === "graph" ? 60 : 35
|
||||||
{/* 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 抽屉式展示 */}
|
const contentPanel = (
|
||||||
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
{/* 任意值 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>
|
|
||||||
|
|
||||||
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
|
|
||||||
{/* P2-4 移动端侧栏触发按钮 + 为此课文备课按钮 */}
|
|
||||||
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
|
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -439,6 +426,33 @@ export function TextbookReader({
|
|||||||
/>
|
/>
|
||||||
</TextbookSectionErrorBoundary>
|
</TextbookSectionErrorBoundary>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
import { getKnowledgeGraphDataAction } from "../actions"
|
import { getKnowledgeGraphDataAction } from "../actions"
|
||||||
import type { GraphViewMode, KnowledgeGraphData } from "../types"
|
import type { GraphViewMode, KnowledgeGraphData } from "../types"
|
||||||
|
|
||||||
@@ -20,6 +20,12 @@ interface UseGraphDataResult {
|
|||||||
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
|
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
|
||||||
* 区分 isLoading(首次加载)和 isRefreshing(切换模式刷新),
|
* 区分 isLoading(首次加载)和 isRefreshing(切换模式刷新),
|
||||||
* 切换模式时保留旧数据避免 UI 闪烁。
|
* 切换模式时保留旧数据避免 UI 闪烁。
|
||||||
|
*
|
||||||
|
* 注意:不使用 lastRequestKey 防重复机制。该机制在 React StrictMode
|
||||||
|
* (Next.js 默认启用)下会导致 action 结果被丢弃:
|
||||||
|
* 第一次 mount 发起 action → cleanup 设置 cancelled=true →
|
||||||
|
* 第二次 mount 因 requestKey 相同跳过 → action 结果被忽略 → 永久 loading。
|
||||||
|
* useEffect 依赖数组已足够控制 effect 执行频率,无需额外防重复。
|
||||||
*/
|
*/
|
||||||
export function useGraphData(
|
export function useGraphData(
|
||||||
textbookId: string,
|
textbookId: string,
|
||||||
@@ -28,7 +34,6 @@ export function useGraphData(
|
|||||||
const [data, setData] = useState<KnowledgeGraphData | null>(null)
|
const [data, setData] = useState<KnowledgeGraphData | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [reloadTrigger, setReloadTrigger] = useState(0)
|
const [reloadTrigger, setReloadTrigger] = useState(0)
|
||||||
const lastRequestKey = useRef<string>("")
|
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
setReloadTrigger((n) => n + 1)
|
setReloadTrigger((n) => n + 1)
|
||||||
@@ -42,10 +47,6 @@ export function useGraphData(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!textbookId) return
|
if (!textbookId) return
|
||||||
|
|
||||||
const requestKey = `${textbookId}:${viewMode}:${reloadTrigger}`
|
|
||||||
if (lastRequestKey.current === requestKey) return
|
|
||||||
lastRequestKey.current = requestKey
|
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
getKnowledgeGraphDataAction(textbookId, viewMode)
|
getKnowledgeGraphDataAction(textbookId, viewMode)
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ export type KnowledgePoint = {
|
|||||||
|
|
||||||
// ===== 知识图谱相关类型 =====
|
// ===== 知识图谱相关类型 =====
|
||||||
|
|
||||||
/** 图谱视图模式 */
|
/** 图谱视图模式(数据视角:结构 / 学生掌握度 / 班级掌握度) */
|
||||||
export type GraphViewMode = "structure" | "student-mastery" | "class-mastery"
|
export type GraphViewMode = "structure" | "student-mastery" | "class-mastery"
|
||||||
|
|
||||||
|
/** 图谱布局模式(渲染方式:分层有向图 / Obsidian 风格力导向图) */
|
||||||
|
export type GraphLayoutMode = "hierarchical" | "force"
|
||||||
|
|
||||||
/** 掌握度信息 */
|
/** 掌握度信息 */
|
||||||
export interface MasteryInfo {
|
export interface MasteryInfo {
|
||||||
/** 掌握度等级 0-100 */
|
/** 掌握度等级 0-100 */
|
||||||
|
|||||||
Reference in New Issue
Block a user