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

@@ -7,6 +7,8 @@ import {
filterKnowledgePointsByChapter,
normalizeOptional,
sortChapters,
highlightKnowledgePoints,
hasCycleAfterAddingEdge,
} from "./utils"
// 测试辅助:构造最小合法 Chapter补齐 createdAt/updatedAt 等必填字段)
@@ -178,4 +180,87 @@ describe("textbooks/utils", () => {
expect(normalizeOptional(" hello ")).toBe("hello")
})
})
describe("highlightKnowledgePoints", () => {
const makeKp = (id: string, name: string): KnowledgePoint => ({
id,
name,
chapterId: "c1",
level: 1,
order: 0,
})
it("should return content unchanged when no knowledge points", () => {
expect(highlightKnowledgePoints("hello world", [])).toBe("hello world")
})
it("should return content unchanged when content is empty", () => {
expect(highlightKnowledgePoints("", [makeKp("1", "test")])).toBe("")
})
it("should wrap single knowledge point name with link", () => {
const result = highlightKnowledgePoints("学习物理很有趣", [makeKp("kp1", "物理")])
expect(result).toBe("学习[物理](#kp-kp1)很有趣")
})
it("should match case-insensitively", () => {
const result = highlightKnowledgePoints("Hello HELLO hello", [makeKp("kp1", "hello")])
expect(result).toBe("[Hello](#kp-kp1) [HELLO](#kp-kp1) [hello](#kp-kp1)")
})
it("should match multiple occurrences", () => {
const result = highlightKnowledgePoints("物理物理物理", [makeKp("kp1", "物理")])
expect(result).toBe("[物理](#kp-kp1)[物理](#kp-kp1)[物理](#kp-kp1)")
})
it("should prioritize longest match (物理学 before 物理)", () => {
const kps = [makeKp("kp1", "物理"), makeKp("kp2", "物理学")]
const result = highlightKnowledgePoints("物理学是研究物理的学科", kps)
// "物理学" 应被 kp2 匹配,独立的 "物理" 应被 kp1 匹配
expect(result).toBe("[物理学](#kp-kp2)是研究[物理](#kp-kp1)的学科")
})
it("should handle multiple different knowledge points in one pass", () => {
const kps = [makeKp("kp1", "数学"), makeKp("kp2", "物理")]
const result = highlightKnowledgePoints("数学和物理都是科学", kps)
expect(result).toBe("[数学](#kp-kp1)和[物理](#kp-kp2)都是科学")
})
it("should escape regex metacharacters in knowledge point names", () => {
const result = highlightKnowledgePoints("计算 1+2 的结果", [makeKp("kp1", "1+2")])
expect(result).toBe("计算 [1+2](#kp-kp1) 的结果")
})
it("should handle knowledge point names with parentheses", () => {
const result = highlightKnowledgePoints("函数 f(x) 是映射", [makeKp("kp1", "f(x)")])
expect(result).toBe("函数 [f(x)](#kp-kp1) 是映射")
})
})
})
describe("textbooks/utils - cycle detection", () => {
it("should return false when adding edge to empty graph", () => {
const edges: Array<[string, string]> = []
expect(hasCycleAfterAddingEdge(edges, "a", "b")).toBe(false)
})
it("should detect direct cycle (a->b then b->a)", () => {
const edges: Array<[string, string]> = [["a", "b"]]
expect(hasCycleAfterAddingEdge(edges, "b", "a")).toBe(true)
})
it("should detect indirect cycle (a->b->c then c->a)", () => {
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"]]
expect(hasCycleAfterAddingEdge(edges, "c", "a")).toBe(true)
})
it("should not detect cycle for independent chains", () => {
const edges: Array<[string, string]> = [["a", "b"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "b", "c")).toBe(false)
})
it("should not detect cycle for diamond shape", () => {
const edges: Array<[string, string]> = [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
})
})