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,15 +1,23 @@
import { describe, it, expect } from "vitest"
import type { KnowledgePoint } from "./types"
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT, GAP_X, GAP_Y } from "./graph-layout"
import type { KpWithRelations } from "./types"
import { computeGraphLayout } from "./graph-layout"
describe("textbooks/graph-layout", () => {
const makeKp = (id: string, parentId: string | null = null): KnowledgePoint => ({
const makeKp = (
id: string,
parentId: string | null = null,
prerequisiteIds: string[] = [],
): KpWithRelations => ({
id,
name: `KP-${id}`,
chapterId: "c1",
description: null,
parentId,
chapterId: "c1",
level: 1,
order: 0,
chapterTitle: "Chapter 1",
questionCount: 0,
prerequisiteIds,
})
describe("computeGraphLayout", () => {
@@ -17,79 +25,40 @@ describe("textbooks/graph-layout", () => {
const layout = computeGraphLayout([])
expect(layout.nodes).toEqual([])
expect(layout.edges).toEqual([])
expect(layout.width).toBe(0)
expect(layout.height).toBe(0)
})
it("should place single root node", () => {
it("should place single node", () => {
const layout = computeGraphLayout([makeKp("1")])
expect(layout.nodes).toHaveLength(1)
expect(layout.nodes[0].x).toBe(GAP_X)
expect(layout.nodes[0].y).toBe(GAP_Y)
expect(layout.edges).toHaveLength(0)
expect(layout.nodes[0].id).toBe("1")
})
it("should compute parent-child layout with edge", () => {
it("should generate parent-child edge", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
expect(layout.nodes).toHaveLength(2)
expect(layout.edges).toHaveLength(1)
expect(layout.edges[0].id).toBe("1-2")
const parentEdge = layout.edges.find((e) => e.id === "parent-1-2")
expect(parentEdge).toBeDefined()
})
it("should place children at lower level (higher y)", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
const root = layout.nodes.find((n) => n.id === "1")
const child = layout.nodes.find((n) => n.id === "2")
expect(child!.y).toBeGreaterThan(root!.y)
})
it("should handle multiple roots", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2")])
expect(layout.nodes).toHaveLength(2)
expect(layout.edges).toHaveLength(0)
const n1 = layout.nodes.find((n) => n.id === "1")
const n2 = layout.nodes.find((n) => n.id === "2")
expect(n2!.x).toBeGreaterThan(n1!.x)
})
it("should handle circular references gracefully (no infinite loop)", () => {
// a → b → a 循环
const kps = [makeKp("a", "b"), makeKp("b", "a")]
const layout = computeGraphLayout(kps)
expect(layout.nodes).toHaveLength(2)
})
it("should handle all nodes referencing non-existent parent", () => {
const kps = [makeKp("1", "nonexistent"), makeKp("2", "nonexistent")]
const layout = computeGraphLayout(kps)
expect(layout.nodes).toHaveLength(2)
// 全部作为根节点处理
expect(layout.edges).toHaveLength(0)
})
it("should compute width based on max nodes per level", () => {
const kps = [
it("should generate prerequisite edge", () => {
const layout = computeGraphLayout([
makeKp("1"),
makeKp("2"),
makeKp("3"),
makeKp("4", "1"),
]
const layout = computeGraphLayout(kps)
// 第 0 层有 3 个节点,是最大层
const expectedWidth = 3 * (NODE_WIDTH + GAP_X) + GAP_X
expect(layout.width).toBe(expectedWidth)
makeKp("2", null, ["1"]),
])
const prereqEdge = layout.edges.find((e) => e.id === "prereq-1-2")
expect(prereqEdge).toBeDefined()
})
it("should compute height based on level count", () => {
const kps = [
it("should assign positions to all nodes", () => {
const layout = computeGraphLayout([
makeKp("1"),
makeKp("2", "1"),
makeKp("3", "2"),
]
const layout = computeGraphLayout(kps)
// 3 层
const expectedHeight = 3 * (NODE_HEIGHT + GAP_Y) + GAP_Y
expect(layout.height).toBe(expectedHeight)
makeKp("3", "1"),
])
for (const node of layout.nodes) {
expect(node.position.x).toBeGreaterThanOrEqual(0)
expect(node.position.y).toBeGreaterThanOrEqual(0)
}
})
})
})