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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user