将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。 数据层:新增 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 一致性。
70 lines
1.9 KiB
TypeScript
70 lines
1.9 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect, useRef, useCallback } from "react"
|
||
import { getKnowledgeGraphDataAction } from "../actions"
|
||
import type { GraphViewMode, KnowledgeGraphData } from "../types"
|
||
|
||
interface UseGraphDataResult {
|
||
data: KnowledgeGraphData | null
|
||
isLoading: boolean
|
||
error: string | null
|
||
reload: () => void
|
||
}
|
||
|
||
/**
|
||
* 图谱数据加载 Hook。
|
||
*
|
||
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
|
||
* 使用派生值模式(isLoading 从 data.viewMode 派生),避免 effect 中同步 setState。
|
||
*/
|
||
export function useGraphData(
|
||
textbookId: string,
|
||
viewMode: GraphViewMode,
|
||
): UseGraphDataResult {
|
||
const [data, setData] = useState<KnowledgeGraphData | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [reloadTrigger, setReloadTrigger] = useState(0)
|
||
const lastRequestKey = useRef<string>("")
|
||
|
||
const reload = useCallback(() => {
|
||
setReloadTrigger((n) => n + 1)
|
||
}, [])
|
||
|
||
// 派生 loading 状态:无数据或当前数据不匹配请求的 viewMode
|
||
const isLoading = data === null || data.viewMode !== viewMode
|
||
|
||
useEffect(() => {
|
||
if (!textbookId) return
|
||
|
||
const requestKey = `${textbookId}:${viewMode}:${reloadTrigger}`
|
||
if (lastRequestKey.current === requestKey) return
|
||
lastRequestKey.current = requestKey
|
||
|
||
let cancelled = false
|
||
|
||
getKnowledgeGraphDataAction(textbookId, viewMode)
|
||
.then((result) => {
|
||
if (cancelled) return
|
||
if (result.success && result.data) {
|
||
setData(result.data)
|
||
setError(null)
|
||
} else {
|
||
setData(null)
|
||
setError(result.message ?? "Unknown error")
|
||
}
|
||
})
|
||
.catch((e: unknown) => {
|
||
if (!cancelled) {
|
||
setData(null)
|
||
setError(e instanceof Error ? e.message : "Unknown error")
|
||
}
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [textbookId, viewMode, reloadTrigger])
|
||
|
||
return { data, isLoading, error, reload }
|
||
}
|