Просмотр исходного кода

增加因式分解相关图谱

yemeishu 1 месяц назад
Родитель
Сommit
4bfc39b713

+ 22 - 0
app/Filament/Pages/KnowledgeMindmap.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Filament\Pages;
+
+use BackedEnum;
+use Filament\Pages\Page;
+use UnitEnum;
+
+class KnowledgeMindmap extends Page
+{
+    protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-share';
+
+    protected static string|UnitEnum|null $navigationGroup = '知识图谱';
+
+    protected static ?string $navigationLabel = '知识图谱脑图';
+
+    protected static ?string $slug = 'knowledge-mindmap';
+
+    protected static ?string $title = '知识图谱脑图';
+
+    protected string $view = 'filament.pages.knowledge-mindmap';
+}

+ 110 - 0
public/data/edges.json

@@ -0,0 +1,110 @@
+[
+  {
+    "source": "P01",
+    "target": "P02",
+    "type": "successor",
+    "comment": "知识点之间存在必要的学习衔接,用于帮助学生更顺畅地理解后续内容。"
+  },
+  {
+    "source": "P02",
+    "target": "P03",
+    "type": "successor",
+    "comment": "知识点之间存在必要的学习衔接,用于帮助学生更顺畅地理解后续内容。"
+  },
+  {
+    "source": "P03",
+    "target": "P04",
+    "type": "successor",
+    "comment": "知识点之间存在必要的学习衔接,用于帮助学生更顺畅地理解后续内容。"
+  },
+  {
+    "source": "P04",
+    "target": "P05",
+    "type": "successor",
+    "comment": "知识点之间存在必要的学习衔接,用于帮助学生更顺畅地理解后续内容。"
+  },
+  {
+    "source": "P05",
+    "target": "P06",
+    "type": "successor",
+    "comment": "因式分解建立在乘法公式的理解基础上,掌握乘法公式有助于学生快速看出可分解结构。"
+  },
+  {
+    "source": "P06",
+    "target": "F01",
+    "type": "successor",
+    "comment": "分式化简通常需要先把分子分母因式分解,这是学生顺利完成约分的关键步骤。"
+  },
+  {
+    "source": "F01",
+    "target": "F02",
+    "type": "successor",
+    "comment": "完成分式化简后,学生才能进行分式的加减乘除运算,这是题目的常见流程。"
+  },
+  {
+    "source": "F02",
+    "target": "E05",
+    "type": "successor",
+    "comment": "处理含分式的一元二次方程前,需要先完成分式运算的化简与统一。"
+  },
+  {
+    "source": "E01",
+    "target": "E02",
+    "type": "successor",
+    "comment": "理解一元一次方程后,学生才能掌握方程组的求解方法,如代入法或加减法。"
+  },
+  {
+    "source": "E02",
+    "target": "E05",
+    "type": "successor",
+    "comment": "部分二次方程可通过构造方程组模型来理解,因此方程组经验有助于学生理解二次方程。"
+  },
+  {
+    "source": "H01",
+    "target": "H02",
+    "type": "successor",
+    "comment": "掌握函数的基本概念后,学生才能理解一次函数的表示与图像特征。"
+  },
+  {
+    "source": "H02",
+    "target": "H03",
+    "type": "successor",
+    "comment": "理解一次函数的变化特征有助于学生更好地理解二次函数的曲线规律。"
+  },
+  {
+    "source": "H03",
+    "target": "H04",
+    "type": "successor",
+    "comment": "理解二次函数图像后,学生才能准确判断最值与开口方向。"
+  },
+  {
+    "source": "E05",
+    "target": "H04",
+    "type": "successor",
+    "comment": "求二次函数的最值往往依赖方程求顶点或开口方向,因此二次方程能力影响最值判断。"
+  },
+  {
+    "source": "H04",
+    "target": "F05",
+    "type": "successor",
+    "comment": "压轴综合题往往以二次函数最值作为关键突破点,因此掌握最值是完成压轴题的重要能力。"
+  },
+  {
+    "source": "P06",
+    "target": "E05",
+    "type": "crosslink",
+    "comment": "部分一元二次方程可通过因式分解直接求解,因此因式分解能力影响方程求解效率。"
+  },
+  {
+    "source": "P06",
+    "target": "H04",
+    "type": "crosslink",
+    "comment": "二次函数的解析式常需因式分解才能看出顶点与最值特征,因此因式分解是函数最值的重要辅助。"
+  },
+  {
+    "source": "F02",
+    "target": "E04",
+    "type": "crosslink",
+    "comment": "处理应用题(如行程、浓度)前,学生通常需要先完成相关的分式运算化简。"
+  }
+]

+ 495 - 0
public/data/tree.json

@@ -0,0 +1,495 @@
+{
+  "id": "M01",
+  "label": "数与代数",
+  "children": [
+    {
+      "id": "S01",
+      "label": "有理数及其运算",
+      "children": [
+        {
+          "id": "R01",
+          "label": "有理数的分类",
+          "skills": [
+            "正负数判断",
+            "区间理解"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            1,
+            2
+          ],
+          "children": []
+        },
+        {
+          "id": "R02",
+          "label": "相反数与绝对值",
+          "skills": [
+            "绝对值意义",
+            "距离概念"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            1,
+            2
+          ],
+          "children": []
+        },
+        {
+          "id": "R03",
+          "label": "有理数加减",
+          "skills": [
+            "同号相加",
+            "异号作差"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            2,
+            3
+          ],
+          "children": []
+        },
+        {
+          "id": "R04",
+          "label": "有理数乘除",
+          "skills": [
+            "符号判断",
+            "倒数运用"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            2,
+            3
+          ],
+          "children": []
+        },
+        {
+          "id": "R05",
+          "label": "幂与指数",
+          "skills": [
+            "指数性质",
+            "乘方运算"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            2,
+            3
+          ],
+          "children": []
+        },
+        {
+          "id": "R06",
+          "label": "科学记数法",
+          "skills": [
+            "有效数字",
+            "数量级"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            1,
+            2
+          ],
+          "children": []
+        }
+      ]
+    },
+    {
+      "id": "S02",
+      "label": "整式及其运算",
+      "children": [
+        {
+          "id": "P01",
+          "label": "整式的概念",
+          "skills": [
+            "项与次数",
+            "单项式多项式识别"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            1,
+            2
+          ],
+          "children": []
+        },
+        {
+          "id": "P02",
+          "label": "同类项合并",
+          "skills": [
+            "合并规则",
+            "表达式化简"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            2,
+            3
+          ],
+          "children": []
+        },
+        {
+          "id": "P03",
+          "label": "整式的加减",
+          "skills": [
+            "去括号",
+            "合并同类项"
+          ],
+          "direct_score": [
+            1,
+            3
+          ],
+          "related_score": [
+            2,
+            4
+          ],
+          "children": []
+        },
+        {
+          "id": "P04",
+          "label": "整式的乘法",
+          "skills": [
+            "分配律",
+            "乘法技巧"
+          ],
+          "direct_score": [
+            2,
+            4
+          ],
+          "related_score": [
+            3,
+            5
+          ],
+          "children": []
+        },
+        {
+          "id": "P05",
+          "label": "乘法公式",
+          "skills": [
+            "平方差",
+            "完全平方"
+          ],
+          "direct_score": [
+            1,
+            3
+          ],
+          "related_score": [
+            2,
+            4
+          ],
+          "children": []
+        },
+        {
+          "id": "P06",
+          "label": "因式分解",
+          "skills": [
+            "提公因式",
+            "平方差逆用",
+            "完全平方逆用",
+            "分组分解",
+            "十字相乘法",
+            "AC分解法"
+          ],
+          "direct_score": [
+            3,
+            5
+          ],
+          "related_score": [
+            5,
+            8
+          ],
+          "children": [
+            {
+              "id": "F01",
+              "label": "分式化简",
+              "skills": [
+                "因式约分",
+                "整体化简"
+              ],
+              "direct_score": [
+                2,
+                3
+              ],
+              "related_score": [
+                4,
+                6
+              ],
+              "children": []
+            },
+            {
+              "id": "F02",
+              "label": "分式运算",
+              "skills": [
+                "通分",
+                "加减乘除"
+              ],
+              "direct_score": [
+                2,
+                4
+              ],
+              "related_score": [
+                4,
+                6
+              ],
+              "children": []
+            },
+            {
+              "id": "F03",
+              "label": "一元二次方程",
+              "skills": [
+                "因式分解解法",
+                "配方法",
+                "公式法"
+              ],
+              "direct_score": [
+                4,
+                6
+              ],
+              "related_score": [
+                6,
+                10
+              ],
+              "children": []
+            },
+            {
+              "id": "F04",
+              "label": "二次函数最值",
+              "skills": [
+                "顶点式",
+                "开口判断"
+              ],
+              "direct_score": [
+                2,
+                4
+              ],
+              "related_score": [
+                6,
+                10
+              ],
+              "children": []
+            },
+            {
+              "id": "F05",
+              "label": "压轴综合",
+              "skills": [
+                "代数几何结合",
+                "最值结构分析"
+              ],
+              "direct_score": [
+                0,
+                0
+              ],
+              "related_score": [
+                8,
+                18
+              ],
+              "children": []
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "id": "S03",
+      "label": "方程与不等式",
+      "children": [
+        {
+          "id": "E01",
+          "label": "一元一次方程",
+          "skills": [
+            "移项",
+            "合并同类项"
+          ],
+          "direct_score": [
+            2,
+            4
+          ],
+          "related_score": [
+            3,
+            5
+          ],
+          "children": []
+        },
+        {
+          "id": "E02",
+          "label": "一元一次方程组",
+          "skills": [
+            "代入法",
+            "加减法"
+          ],
+          "direct_score": [
+            2,
+            4
+          ],
+          "related_score": [
+            3,
+            5
+          ],
+          "children": []
+        },
+        {
+          "id": "E03",
+          "label": "一元一次不等式",
+          "skills": [
+            "不等式性质",
+            "数轴表示"
+          ],
+          "direct_score": [
+            1,
+            3
+          ],
+          "related_score": [
+            2,
+            4
+          ],
+          "children": []
+        },
+        {
+          "id": "E04",
+          "label": "实际问题与方程",
+          "skills": [
+            "建模",
+            "方程求解"
+          ],
+          "direct_score": [
+            2,
+            4
+          ],
+          "related_score": [
+            4,
+            6
+          ],
+          "children": []
+        },
+        {
+          "id": "E05",
+          "label": "一元二次方程",
+          "skills": [
+            "求根公式",
+            "判别式"
+          ],
+          "direct_score": [
+            4,
+            6
+          ],
+          "related_score": [
+            6,
+            10
+          ],
+          "children": []
+        }
+      ]
+    },
+    {
+      "id": "S04",
+      "label": "函数初步",
+      "children": [
+        {
+          "id": "H01",
+          "label": "函数概念",
+          "skills": [
+            "对应关系",
+            "自变量因变量"
+          ],
+          "direct_score": [
+            1,
+            2
+          ],
+          "related_score": [
+            2,
+            4
+          ],
+          "children": []
+        },
+        {
+          "id": "H02",
+          "label": "一次函数",
+          "skills": [
+            "斜率",
+            "截距"
+          ],
+          "direct_score": [
+            2,
+            4
+          ],
+          "related_score": [
+            4,
+            6
+          ],
+          "children": []
+        },
+        {
+          "id": "H03",
+          "label": "二次函数",
+          "skills": [
+            "图像",
+            "开口方向"
+          ],
+          "direct_score": [
+            2,
+            4
+          ],
+          "related_score": [
+            5,
+            8
+          ],
+          "children": []
+        },
+        {
+          "id": "H04",
+          "label": "函数最值",
+          "skills": [
+            "顶点判断",
+            "区间比较"
+          ],
+          "direct_score": [
+            2,
+            4
+          ],
+          "related_score": [
+            6,
+            10
+          ],
+          "children": []
+        },
+        {
+          "id": "H05",
+          "label": "函数综合应用",
+          "skills": [
+            "代数几何结合",
+            "方程法"
+          ],
+          "direct_score": [
+            2,
+            4
+          ],
+          "related_score": [
+            6,
+            10
+          ],
+          "children": []
+        }
+      ]
+    }
+  ]
+}

+ 568 - 0
resources/views/filament/pages/knowledge-mindmap.blade.php

@@ -0,0 +1,568 @@
+<x-filament::page>
+    <div
+        class="space-y-4"
+        x-data="knowledgeMindmap()"
+        x-init="initMindmap()"
+    >
+        <div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
+            <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+                <div>
+                    <h2 class="text-lg font-semibold text-gray-900">初中数学知识图谱 · 思维导图</h2>
+                    <p class="text-sm text-gray-500">
+                        tree.json 提供完整层级(模块 → 知识点),edges.json 描述跨节点关系;基于 AntV G6 MindMap 布局,节点可逐层展开/折叠并叠加前置/后继/兄弟/联合连线。
+                    </p>
+                    <div class="mt-2 flex gap-4 text-xs text-gray-500">
+                        <div>节点总数:<span x-text="stats.nodes"></span></div>
+                        <div>跨边数量:<span x-text="stats.extraEdges"></span></div>
+                    </div>
+                </div>
+                <div class="flex flex-wrap gap-3 text-xs text-gray-600">
+                    <span class="inline-flex items-center gap-1">
+                        <span class="h-2 w-4 rounded-full bg-blue-500"></span> 前置
+                    </span>
+                    <span class="inline-flex items-center gap-1">
+                        <span class="h-2 w-4 rounded-full bg-red-500"></span> 后继
+                    </span>
+                    <span class="inline-flex items-center gap-1">
+                        <span class="h-2 w-4 rounded-full border border-dashed border-gray-500"></span> 兄弟
+                    </span>
+                    <span class="inline-flex items-center gap-1">
+                        <span class="h-2 w-4 rounded-full bg-yellow-400"></span> 联合考查
+                    </span>
+                </div>
+            </div>
+        </div>
+
+        <div
+            id="knowledge-mindmap"
+            class="h-[80vh] min-h-[720px] w-full rounded-lg border border-gray-200 bg-white"
+        ></div>
+    </div>
+</x-filament::page>
+
+@push('scripts')
+    <script src="https://gw.alipayobjects.com/os/lib/antv/g6/5.0.18/dist/g6.min.js"></script>
+    <script>
+        document.addEventListener('alpine:init', () => {
+            window.knowledgeMindmap = () => ({
+                graph: null,
+                treeData: null,
+                relationEdges: [],
+                stats: { nodes: 0, extraEdges: 0 },
+                arrow(w = 12, h = 14, r = 5) {
+                    if (window.G6?.Arrow?.triangle) {
+                        return G6.Arrow.triangle(w, h, r);
+                    }
+                    return [
+                        ['M', 0, 0],
+                        ['L', w, h / 2],
+                        ['L', 0, h],
+                        ['Z'],
+                    ];
+                },
+                levelStyles: [
+                    {
+                        fill: '#0ea5e9',
+                        stroke: '#0369a1',
+                        labelColor: '#0f172a',
+                        fontSize: 17,
+                        fontWeight: 700,
+                        size: 34,
+                    },
+                    {
+                        fill: '#e0f2fe',
+                        stroke: '#38bdf8',
+                        labelColor: '#0f172a',
+                        fontSize: 16,
+                        fontWeight: 700,
+                        size: 30,
+                    },
+                    {
+                        fill: '#f1f5f9',
+                        stroke: '#cbd5e1',
+                        labelColor: '#0f172a',
+                        fontSize: 14,
+                        fontWeight: 600,
+                        size: 26,
+                    },
+                ],
+                relationStyles: {
+                    prerequisite: {
+                        type: 'quadratic',
+                        curveOffset: 60,
+                        style: {
+                            stroke: '#2563eb',
+                            lineWidth: 3.4,
+                            lineDash: [8, 6],
+                            endArrow: {
+                                path: null,
+                                fill: '#2563eb',
+                                d: 12,
+                            },
+                            startArrow: false,
+                        },
+                        label: '前置',
+                    },
+                    successor: {
+                        type: 'quadratic',
+                        curveOffset: 60,
+                        style: {
+                            stroke: '#dc2626',
+                            lineWidth: 3.4,
+                            lineDash: [8, 6],
+                            endArrow: {
+                                path: null,
+                                fill: '#dc2626',
+                                d: 12,
+                            },
+                            startArrow: false,
+                        },
+                        label: '后继',
+                    },
+                    sibling: {
+                        type: 'quadratic',
+                        curveOffset: 50,
+                        style: {
+                            stroke: '#64748b',
+                            lineDash: [6, 6],
+                            lineWidth: 3,
+                            endArrow: {
+                                path: null,
+                                fill: '#64748b',
+                                d: 10,
+                            },
+                        },
+                        label: '兄弟',
+                    },
+                    joint: {
+                        type: 'quadratic',
+                        curveOffset: 50,
+                        style: {
+                            stroke: '#fcd34d',
+                            lineWidth: 3,
+                            lineDash: [10, 8],
+                            endArrow: {
+                                path: null,
+                                fill: '#fbbf24',
+                                d: 10,
+                            },
+                        },
+                        label: '联合',
+                    },
+                    default: {
+                        type: 'quadratic',
+                        curveOffset: 50,
+                        style: {
+                            stroke: '#94a3b8',
+                            lineWidth: 3,
+                            lineDash: [10, 8],
+                            endArrow: {
+                                path: null,
+                                fill: '#94a3b8',
+                                d: 10,
+                            },
+                        },
+                        label: '',
+                    },
+                },
+                async initMindmap() {
+                    try {
+                        if (this.$nextTick) {
+                            await this.$nextTick();
+                        }
+                        if (!window.G6) {
+                            console.error('G6 未加载');
+                            return;
+                        }
+                        Object.keys(this.relationStyles).forEach((key) => {
+                            const rel = this.relationStyles[key];
+                            if (rel?.style && rel.style.endArrow && !rel.style.endArrow.path) {
+                                rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
+                            }
+                        });
+                        await this.loadData();
+                        this.applyInitialCollapse(this.treeData);
+                        this.renderGraph();
+                        window.addEventListener('resize', () => this.resizeGraph());
+                    } catch (err) {
+                        console.error('初始化思维导图失败', err);
+                    }
+                },
+                async loadData() {
+                    const [treeResp, edgesResp] = await Promise.all([
+                        fetch('/data/tree.json'),
+                        fetch('/data/edges.json'),
+                    ]);
+                    const rawTree = await treeResp.json();
+                    const edges = await edgesResp.json();
+                    const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
+                    this.treeData = this.transformNode(rawTree);
+                    this.relationEdges = this.normalizeEdges(rawEdges);
+                    this.stats = {
+                        nodes: this.countNodes(this.treeData),
+                        extraEdges: this.relationEdges.length,
+                    };
+                },
+                transformNode(node, depth = 0) {
+                    if (!node) {
+                        return null;
+                    }
+                    const id = node.code || node.id || node.label || `node-${Math.random().toString(36).slice(2, 8)}`;
+                    const label = node.name || node.label || node.code || node.id || '未命名节点';
+                    const meta = {
+                        code: node.code || node.id || '',
+                        name: label,
+                        direct_score: node.direct_score || [],
+                        related_score: node.related_score || [],
+                        skills: node.skills || [],
+                    };
+                    return {
+                        id,
+                        label,
+                        meta,
+                        depth,
+                        children: (node.children || []).map((child) => this.transformNode(child, depth + 1)).filter(Boolean),
+                    };
+                },
+                applyInitialCollapse(node, depth = 0) {
+                    if (!node) {
+                        return;
+                    }
+                    if (depth >= 2 && node.children.length > 0) {
+                        node.collapsed = true;
+                    }
+                    node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1));
+                },
+                countNodes(node) {
+                    if (!node) {
+                        return 0;
+                    }
+                    return 1 + node.children.reduce((sum, child) => sum + this.countNodes(child), 0);
+                },
+                normalizeEdges(rawEdges) {
+                    const seen = new Set();
+                    const normalized = [];
+                    (rawEdges || []).forEach((edge, index) => {
+                        if (!edge?.source || !edge?.target) {
+                            return;
+                        }
+                        const key = `${edge.source}-${edge.target}-${edge.type}`;
+                        if (seen.has(key)) {
+                            return;
+                        }
+                        seen.add(key);
+                        const relationStyle = this.relationStyles[edge.type] || this.relationStyles.default;
+                        normalized.push({
+                            id: `rel-${index}-${edge.source}-${edge.target}`,
+                            source: edge.source,
+                            target: edge.target,
+                            type: relationStyle.type || 'quadratic',
+                            curveOffset: relationStyle.curveOffset || 50,
+                            style: relationStyle.style,
+                            label: relationStyle.label,
+                            comment: edge.comment || edge.note || '',
+                        });
+                    });
+                    return normalized;
+                },
+                renderGraph(containerEl = null) {
+                    if (!this.treeData) {
+                        return;
+                    }
+                    const container = containerEl || document.getElementById('knowledge-mindmap');
+                    if (!container) {
+                        console.error('容器未找到');
+                        return;
+                    }
+                    const ensuredId = container.id || 'knowledge-mindmap';
+                    if (!container.id) {
+                        container.id = ensuredId;
+                    }
+                    const bounds = container.getBoundingClientRect();
+                    const width = Math.max(bounds.width, 600);
+                    const height = Math.max(bounds.height, 600);
+                    const tooltipEl = document.createElement('div');
+                    tooltipEl.className = 'fixed z-50 pointer-events-none hidden';
+                    document.body.appendChild(tooltipEl);
+                    const showTooltip = (html, x, y) => {
+                        tooltipEl.innerHTML = html;
+                        tooltipEl.style.left = `${x + 12}px`;
+                        tooltipEl.style.top = `${y + 12}px`;
+                        tooltipEl.classList.remove('hidden');
+                    };
+                    const hideTooltip = () => {
+                        tooltipEl.classList.add('hidden');
+                        tooltipEl.innerHTML = '';
+                    };
+                    const G6Lib = window.G6?.default || window.G6;
+                    const TreeGraphClass = G6Lib?.TreeGraph || null;
+                    if (!TreeGraphClass) {
+                        console.error('G6 TreeGraph 不可用');
+                        return;
+                    }
+                    const graphData = this.decorateTree(this.treeData);
+                    const graphConfig = {
+                        container: ensuredId,
+                        width,
+                        height,
+                        data: graphData,
+                        linkCenter: true,
+                        modes: {
+                            default: [
+                                'drag-canvas',
+                                'zoom-canvas',
+                                {
+                                    type: 'collapse-expand',
+                                    trigger: 'click',
+                                    onChange: function onChange(item, collapsed) {
+                                        if (!item) return;
+                                        item.getModel().collapsed = collapsed;
+                                        return true;
+                                    },
+                                },
+                            ],
+                        },
+                        layout: {
+                            type: 'mindmap',
+                            direction: 'H',
+                            getHeight: () => 32,
+                            getWidth: () => 140,
+                            getVGap: () => 32,
+                            getHGap: () => 110,
+                        },
+                        defaultNode: {
+                            size: 22,
+                            style: {
+                                stroke: '#94a3b8',
+                                fill: '#fff',
+                                radius: 4,
+                                shadowColor: undefined,
+                                shadowBlur: 0,
+                                lineWidth: 3,
+                            },
+                            labelCfg: {
+                                style: {
+                                    fontSize: 13,
+                                    fill: '#0f172a',
+                                    fontWeight: 500,
+                                },
+                                position: 'right',
+                                offset: 12,
+                            },
+                        },
+                        defaultEdge: {
+                            type: 'cubic-horizontal',
+                            style: {
+                                stroke: '#cbd5f5',
+                                lineWidth: 3,
+                                shadowBlur: 0,
+                                shadowColor: undefined,
+                            },
+                        },
+                        nodeStateStyles: {
+                            selected: {
+                                lineWidth: 3.2,
+                                stroke: '#2563eb',
+                                fill: '#e0f2fe',
+                            },
+                        },
+                        edgeStateStyles: {
+                            highlight: {
+                                lineWidth: 3.4,
+                                stroke: '#fb923c',
+                            },
+                        },
+                        plugins: [],
+                    };
+                    this.graph = new TreeGraphClass(graphConfig);
+                    if (typeof this.graph.data === 'function') {
+                        this.graph.data(graphData);
+                    } else if (typeof this.graph.changeData === 'function') {
+                        this.graph.changeData(graphData);
+                    }
+                    if (typeof this.graph.render === 'function') {
+                        this.graph.render();
+                    }
+                    this.graph.data(this.decorateTree(this.treeData));
+                    this.graph.render();
+                    this.graph.fitView(24);
+                    this.drawRelationEdges();
+                    this.bindEvents();
+                    this.graph.on('node:mouseenter', (evt) => {
+                        const { clientX, clientY } = evt;
+                        showTooltip(this.buildTooltip(evt?.item?.getModel()), clientX, clientY);
+                    });
+                    this.graph.on('node:mouseleave', hideTooltip);
+                    this.graph.on('edge:mouseenter', (evt) => {
+                        const model = evt?.item?.getModel() || {};
+                        const relation = model.label || '关联关系';
+                        const text = `${model.source || ''} → ${model.target || ''}`;
+                        const comment = model.comment ? `<div class="text-[11px] text-gray-600 mt-1 whitespace-pre-line">${model.comment}</div>` : '';
+                        const html = `
+                            <div class="rounded-md border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700 shadow-md">
+                                <div class="font-semibold text-gray-900 mb-1">${relation}</div>
+                                <div>${text}</div>
+                                ${comment}
+                            </div>
+                        `;
+                        const { clientX, clientY } = evt;
+                        showTooltip(html, clientX, clientY);
+                    });
+                    this.graph.on('edge:mouseleave', hideTooltip);
+                    const canvas = this.graph && typeof this.graph.get === 'function' ? this.graph.get('canvas') : null;
+                    if (canvas && typeof canvas.set === 'function') {
+                        canvas.set('localRefresh', false);
+                        const ctx = typeof canvas.get === 'function' ? canvas.get('context') : null;
+                        if (ctx) {
+                            ctx.shadowColor = 'transparent';
+                            ctx.shadowBlur = 0;
+                        }
+                    }
+                },
+                decorateTree(node) {
+                    if (!node) {
+                        return null;
+                    }
+                    const { nodeStyle, labelCfg, size } = this.getNodeLevelStyle(node.depth);
+                    return {
+                        id: node.id,
+                        label: `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`,
+                        meta: node.meta,
+                        collapsed: node.collapsed,
+                        depth: node.depth,
+                        size,
+                        style: nodeStyle,
+                        labelCfg,
+                        children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
+                    };
+                },
+                getNodeLevelStyle(depth = 0) {
+                    const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
+                    return {
+                        size: style.size || 22,
+                        nodeStyle: {
+                            fill: style.fill || '#fff',
+                            stroke: style.stroke || '#cbd5f5',
+                            lineWidth: 3,
+                            radius: 6,
+                            shadowColor: undefined,
+                            shadowBlur: 0,
+                        },
+                        labelCfg: {
+                            position: 'right',
+                            offset: 12,
+                            style: {
+                                fontSize: style.fontSize || 13,
+                                fontWeight: style.fontWeight || 600,
+                                fill: style.labelColor || '#0f172a',
+                            },
+                        },
+                    };
+                },
+                drawRelationEdges() {
+                    if (!this.graph || !this.relationEdges.length) {
+                        return;
+                    }
+                    this.relationEdges.forEach((edge, index) => {
+                        const style = { ...(edge.style || {}), lineAppendWidth: 14 };
+                        if (style.endArrow && !style.endArrow.path) {
+                            style.endArrow = {
+                                ...style.endArrow,
+                                path: this.arrow(style.endArrow.d || 10, (style.endArrow.d || 10) + 2, 4),
+                            };
+                        }
+                        this.graph.addItem('edge', {
+                            id: `extra-${index}`,
+                            source: edge.source,
+                            target: edge.target,
+                            type: edge.type || 'quadratic',
+                            curveOffset: edge.curveOffset || 50,
+                            style,
+                            label: edge.label,
+                            comment: edge.comment || '',
+                            labelCfg: {
+                                autoRotate: true,
+                                style: {
+                                    fill: '#475569',
+                                    fontSize: 11,
+                                    background: {
+                                        fill: 'rgba(255,255,255,0.85)',
+                                        padding: [2, 4],
+                                        radius: 4,
+                                    },
+                                },
+                            },
+                        });
+                    });
+                },
+                buildTooltip(model) {
+                    const meta = model?.meta;
+                    if (!meta) {
+                        return '<div class="text-xs text-gray-600">无数据</div>';
+                    }
+                    const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
+                    const skills = (meta.skills || [])
+                        .slice(0, 6)
+                        .map((skill) => `<li class="leading-snug">${skill.trim()}</li>`)
+                        .join('') || '<li>暂无技能</li>';
+                    return `
+                        <div class="min-w-[230px] max-w-sm rounded-md border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-lg">
+                            <div class="text-sm font-semibold text-gray-900 mb-1">${meta.code || model.id} · ${meta.name}</div>
+                            <div class="flex gap-3 text-xs">
+                                <span>直接:${range(meta.direct_score)}</span>
+                                <span>关联:${range(meta.related_score)}</span>
+                            </div>
+                            <div class="mt-2">
+                                <div class="font-medium">技能要点</div>
+                                <ul class="list-disc pl-5 space-y-0.5">
+                                    ${skills}
+                                </ul>
+                            </div>
+                        </div>
+                    `;
+                },
+                bindEvents() {
+                    if (!this.graph) {
+                        return;
+                    }
+                    this.graph.on('node:click', (evt) => {
+                        const nodeId = evt?.item?.getID();
+                        if (nodeId) {
+                            this.highlightEdges(nodeId);
+                        }
+                    });
+                    this.graph.on('canvas:click', () => this.resetHighlight());
+                },
+                highlightEdges(nodeId) {
+                    this.graph.getNodes().forEach((node) => {
+                        this.graph.clearItemStates(node);
+                        if (node.getID() === nodeId) {
+                            this.graph.setItemState(node, 'selected', true);
+                        }
+                    });
+                    this.graph.getEdges().forEach((edge) => {
+                        const { source, target } = edge.getModel();
+                        const linked = source === nodeId || target === nodeId;
+                        if (linked) {
+                            this.graph.setItemState(edge, 'highlight', true);
+                        } else {
+                            this.graph.clearItemStates(edge);
+                        }
+                    });
+                },
+                resetHighlight() {
+                    if (!this.graph) return;
+                    this.graph.getNodes().forEach((node) => this.graph.clearItemStates(node));
+                    this.graph.getEdges().forEach((edge) => this.graph.clearItemStates(edge));
+                },
+                resizeGraph() {
+                    if (!this.graph) return;
+                    const container = document.getElementById('knowledge-mindmap');
+                    if (!container) return;
+                    this.graph.changeSize(container.clientWidth, container.clientHeight);
+                    this.graph.fitView(24);
+                },
+            });
+        });
+    </script>
+@endpush

+ 648 - 0
resources/views/public/knowledge-mindmap.blade.php

@@ -0,0 +1,648 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>知识图谱脑图(公开查看)</title>
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;700&display=swap" rel="stylesheet">
+    <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
+    <script src="https://gw.alipayobjects.com/os/lib/antv/g6/5.0.18/dist/g6.min.js"></script>
+    <style>
+        :root {
+            color-scheme: light;
+        }
+        body {
+            margin: 0;
+            font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+            background: radial-gradient(circle at 10% 20%, #e0f2fe 0, transparent 20%), radial-gradient(circle at 90% 10%, #fce7f3 0, transparent 18%), #f8fafc;
+            color: #0f172a;
+        }
+        .page {
+            max-width: 1400px;
+            margin: 0 auto;
+            padding: 32px 20px 40px;
+        }
+        .card {
+            background: white;
+            border: 1px solid #e2e8f0;
+            border-radius: 14px;
+            box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
+            padding: 18px 20px;
+        }
+        .stat {
+            display: inline-flex;
+            align-items: center;
+            gap: 8px;
+            padding: 8px 12px;
+            border-radius: 10px;
+            background: #f8fafc;
+            border: 1px dashed #e2e8f0;
+            font-size: 12px;
+            color: #475569;
+        }
+        #knowledge-mindmap {
+            width: 100%;
+            min-height: 760px;
+            height: 78vh;
+            border: 1px solid #e2e8f0;
+            border-radius: 14px;
+            background: white;
+        }
+    </style>
+</head>
+<body>
+<div class="page" x-data="knowledgeMindmap()" x-init="initMindmap()">
+    <div class="card" style="margin-bottom: 16px;">
+        <div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px;">
+            <div style="max-width: 780px;">
+                <div style="font-size: 20px; font-weight: 700; color: #0f172a;">初中数学知识图谱 · 思维导图</div>
+                <p style="margin: 8px 0 6px; font-size: 13px; color: #64748b; line-height: 1.5;">
+                    tree.json 提供完整层级(模块 → 知识点),edges.json 描述跨节点关系;基于 AntV G6 MindMap 布局,节点可逐层展开/折叠,线条带方向。
+                </p>
+                <div style="display: flex; gap: 10px; flex-wrap: wrap;">
+                    <span class="stat">节点总数:<span x-text="stats.nodes"></span></span>
+                    <span class="stat">跨边数量:<span x-text="stats.extraEdges"></span></span>
+                </div>
+            </div>
+            <div style="display: flex; gap: 12px; flex-wrap: wrap; align-items: center; font-size: 12px; color: #475569;">
+                <span style="display:inline-flex;align-items:center;gap:6px;">
+                    <span style="display:block;width:16px;height:6px;border-radius:999px;background:#2563eb;"></span> 前置
+                </span>
+                <span style="display:inline-flex;align-items:center;gap:6px;">
+                    <span style="display:block;width:16px;height:6px;border-radius:999px;background:#dc2626;"></span> 后继
+                </span>
+                <span style="display:inline-flex;align-items:center;gap:6px;">
+                    <span style="display:block;width:16px;height:6px;border-radius:999px;border:2px dashed #64748b;"></span> 兄弟
+                </span>
+                <span style="display:inline-flex;align-items:center;gap:6px;">
+                    <span style="display:block;width:16px;height:6px;border-radius:999px;background:#fcd34d;"></span> 联合
+                </span>
+            </div>
+        </div>
+    </div>
+
+    <div id="knowledge-mindmap" aria-label="知识图谱脑图"></div>
+</div>
+
+<script>
+        window.knowledgeMindmap = () => ({
+            graph: null,
+            treeData: null,
+            relationEdges: [],
+            stats: { nodes: 0, extraEdges: 0 },
+            arrow(w = 12, h = 14, r = 5) {
+                if (window.G6?.Arrow?.triangle) {
+                    return G6.Arrow.triangle(w, h, r);
+                }
+                return [
+                    ['M', 0, 0],
+                    ['L', w, h / 2],
+                    ['L', 0, h],
+                    ['Z'],
+                ];
+            },
+            levelStyles: [
+                {
+                    fill: '#0ea5e9',
+                    stroke: '#0369a1',
+                    labelColor: '#0f172a',
+                    fontSize: 17,
+                    fontWeight: 700,
+                    size: 34,
+                },
+                {
+                    fill: '#e0f2fe',
+                    stroke: '#38bdf8',
+                    labelColor: '#0f172a',
+                    fontSize: 16,
+                    fontWeight: 700,
+                    size: 30,
+                },
+                {
+                    fill: '#f1f5f9',
+                    stroke: '#cbd5e1',
+                    labelColor: '#0f172a',
+                    fontSize: 14,
+                    fontWeight: 600,
+                    size: 26,
+                },
+            ],
+            relationStyles: {
+                prerequisite: {
+                    type: 'quadratic',
+                    curveOffset: 60,
+                    style: {
+                        stroke: '#2563eb',
+                        lineWidth: 3.4,
+                        lineDash: [8, 6],
+                        endArrow: {
+                            path: null,
+                            fill: '#2563eb',
+                            d: 12,
+                        },
+                        startArrow: false,
+                        shadowBlur: 0,
+                        shadowColor: undefined,
+                    },
+                    label: '前置',
+                },
+                successor: {
+                    type: 'quadratic',
+                    curveOffset: 60,
+                    style: {
+                        stroke: '#dc2626',
+                        lineWidth: 3.4,
+                        lineDash: [8, 6],
+                        endArrow: {
+                            path: null,
+                            fill: '#dc2626',
+                            d: 12,
+                        },
+                        startArrow: false,
+                        shadowBlur: 0,
+                        shadowColor: undefined,
+                    },
+                    label: '后继',
+                },
+                sibling: {
+                    type: 'quadratic',
+                    curveOffset: 50,
+                    style: {
+                        stroke: '#64748b',
+                        lineDash: [6, 6],
+                        lineWidth: 3,
+                        endArrow: {
+                            path: null,
+                            fill: '#64748b',
+                            d: 10,
+                        },
+                        shadowBlur: 0,
+                        shadowColor: undefined,
+                    },
+                    label: '兄弟',
+                },
+                joint: {
+                    type: 'quadratic',
+                    curveOffset: 50,
+                    style: {
+                        stroke: '#fcd34d',
+                        lineWidth: 3,
+                        lineDash: [10, 8],
+                        endArrow: {
+                            path: null,
+                            fill: '#fbbf24',
+                            d: 10,
+                        },
+                        shadowBlur: 0,
+                        shadowColor: undefined,
+                    },
+                    label: '联合',
+                },
+                default: {
+                    type: 'quadratic',
+                    curveOffset: 50,
+                    style: {
+                        stroke: '#94a3b8',
+                        lineWidth: 3,
+                        lineDash: [10, 8],
+                        endArrow: {
+                            path: null,
+                            fill: '#94a3b8',
+                            d: 10,
+                        },
+                        shadowBlur: 0,
+                        shadowColor: undefined,
+                    },
+                    label: '',
+                },
+            },
+            async initMindmap() {
+                try {
+                    if (this.$nextTick) {
+                        await this.$nextTick();
+                    }
+                    if (!window.G6) {
+                        console.error('G6 未加载');
+                        return;
+                    }
+                    // 补充箭头路径
+                    Object.keys(this.relationStyles).forEach((key) => {
+                        const rel = this.relationStyles[key];
+                        if (rel?.style && rel.style.endArrow && !rel.style.endArrow.path) {
+                            rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
+                        }
+                    });
+                    await this.loadData();
+                    this.applyInitialCollapse(this.treeData);
+                    this.renderGraph();
+                    window.addEventListener('resize', () => this.resizeGraph());
+                } catch (err) {
+                    console.error('初始化思维导图失败', err);
+                    const container = document.getElementById('knowledge-mindmap');
+                    if (container) {
+                        container.innerHTML = '<div style="padding:20px;color:#dc2626;">图数据加载失败,请检查 /data/tree.json 与 /data/edges.json 是否可访问。</div>';
+                    }
+                }
+            },
+            async loadData() {
+                const treeUrl = '/data/tree.json';
+                const edgeUrl = '/data/edges.json';
+                const [treeResp, edgesResp] = await Promise.all([fetch(treeUrl), fetch(edgeUrl)]);
+                if (!treeResp.ok || !edgesResp.ok) {
+                    throw new Error(`数据加载失败 tree:${treeResp.status} edge:${edgesResp.status}`);
+                }
+                const rawTree = await treeResp.json();
+                const edges = await edgesResp.json();
+                const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
+                this.treeData = this.transformNode(rawTree);
+                this.relationEdges = this.normalizeEdges(rawEdges);
+                if (!this.treeData) {
+                    throw new Error('tree.json 为空或格式不正确');
+                }
+                this.stats = {
+                    nodes: this.countNodes(this.treeData),
+                    extraEdges: this.relationEdges.length,
+                };
+            },
+            transformNode(node, depth = 0) {
+                if (!node) return null;
+                const id = node.code || node.id || node.label || `node-${Math.random().toString(36).slice(2, 8)}`;
+                const label = node.name || node.label || node.code || node.id || '未命名节点';
+                const meta = {
+                    code: node.code || node.id || '',
+                    name: label,
+                    direct_score: node.direct_score || [],
+                    related_score: node.related_score || [],
+                    skills: node.skills || [],
+                };
+                return {
+                    id,
+                    label,
+                    meta,
+                    depth,
+                    children: (node.children || []).map((child) => this.transformNode(child, depth + 1)).filter(Boolean),
+                };
+            },
+            applyInitialCollapse(node, depth = 0) {
+                if (!node) return;
+                if (depth >= 2 && node.children.length > 0) {
+                    node.collapsed = true;
+                }
+                node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1));
+            },
+            countNodes(node) {
+                if (!node) return 0;
+                return 1 + node.children.reduce((sum, child) => sum + this.countNodes(child), 0);
+            },
+            normalizeEdges(rawEdges) {
+                const seen = new Set();
+                const normalized = [];
+                (rawEdges || []).forEach((edge, index) => {
+                    if (!edge?.source || !edge?.target) return;
+                    const key = `${edge.source}-${edge.target}-${edge.type}`;
+                    if (seen.has(key)) return;
+                    seen.add(key);
+                    const relationStyle = this.relationStyles[edge.type] || this.relationStyles.default;
+                    normalized.push({
+                        id: `rel-${index}-${edge.source}-${edge.target}`,
+                        source: edge.source,
+                        target: edge.target,
+                        type: relationStyle.type || 'quadratic',
+                        curveOffset: relationStyle.curveOffset || 50,
+                        style: relationStyle.style,
+                        label: relationStyle.label,
+                        comment: edge.comment || edge.note || '',
+                    });
+                });
+                return normalized;
+            },
+            renderGraph(containerEl = null) {
+                if (!this.treeData) return;
+                const container = containerEl || document.getElementById('knowledge-mindmap');
+                if (!container) {
+                    console.error('容器未找到');
+                    return;
+                }
+                const ensuredId = container.id || 'knowledge-mindmap';
+                if (!container.id) {
+                    container.id = ensuredId;
+                }
+                const bounds = container.getBoundingClientRect();
+                const width = Math.max(bounds.width || container.clientWidth || 0, 600);
+                const height = Math.max(bounds.height || container.clientHeight || 0, 600);
+                const tooltipEl = document.createElement('div');
+                tooltipEl.style.position = 'fixed';
+                tooltipEl.style.pointerEvents = 'none';
+                tooltipEl.style.zIndex = '9999';
+                tooltipEl.style.display = 'none';
+                document.body.appendChild(tooltipEl);
+                const showTooltip = (html, x, y) => {
+                    tooltipEl.innerHTML = html;
+                    tooltipEl.style.left = `${x + 12}px`;
+                    tooltipEl.style.top = `${y + 12}px`;
+                    tooltipEl.style.display = 'block';
+                };
+                const hideTooltip = () => {
+                    tooltipEl.style.display = 'none';
+                    tooltipEl.innerHTML = '';
+                };
+                const G6Lib = window.G6?.default || window.G6;
+                const TreeGraphClass = G6Lib?.TreeGraph || null;
+                if (!TreeGraphClass) {
+                    console.error('G6 TreeGraph 不可用');
+                    return;
+                }
+                const graphData = this.decorateTree(this.treeData);
+                const graphConfig = {
+                    container: ensuredId,
+                    width,
+                    height,
+                    data: graphData,
+                    linkCenter: true,
+                    modes: {
+                        default: [
+                            'drag-canvas',
+                            'zoom-canvas',
+                            {
+                                type: 'collapse-expand',
+                                trigger: 'click',
+                                onChange: function onChange(item, collapsed) {
+                                    if (!item) return;
+                                    item.getModel().collapsed = collapsed;
+                                    return true;
+                                },
+                            },
+                        ],
+                    },
+                    layout: {
+                        type: 'mindmap',
+                        direction: 'H',
+                        getHeight: () => 32,
+                        getWidth: () => 140,
+                        getVGap: () => 32,
+                        getHGap: () => 110,
+                    },
+                    defaultNode: {
+                        size: 22,
+                        style: {
+                            stroke: '#94a3b8',
+                            fill: '#fff',
+                            radius: 4,
+                            shadowColor: undefined,
+                            shadowBlur: 0,
+                            lineWidth: 3,
+                        },
+                        labelCfg: {
+                            style: {
+                                fontSize: 13,
+                                fill: '#0f172a',
+                                fontWeight: 500,
+                            },
+                            position: 'right',
+                            offset: 12,
+                        },
+                    },
+                    defaultEdge: {
+                        type: 'cubic-horizontal',
+                        style: {
+                            stroke: '#cbd5f5',
+                            lineWidth: 3,
+                            shadowBlur: 0,
+                            shadowColor: undefined,
+                        },
+                    },
+                    nodeStateStyles: {
+                        selected: {
+                            lineWidth: 3.2,
+                            stroke: '#2563eb',
+                            fill: '#e0f2fe',
+                        },
+                    },
+                    edgeStateStyles: {
+                        highlight: {
+                            lineWidth: 3.4,
+                            stroke: '#fb923c',
+                        },
+                    },
+                    plugins: [],
+                };
+                try {
+                    this.graph = new TreeGraphClass(graphConfig);
+                    if (typeof this.graph.data === 'function') {
+                        this.graph.data(graphData);
+                    } else if (typeof this.graph.changeData === 'function') {
+                        this.graph.changeData(graphData);
+                    }
+                    if (typeof this.graph.render === 'function') {
+                        this.graph.render();
+                    }
+                } catch (e) {
+                    console.error('创建图失败', e);
+                    return;
+                }
+                this.drawRelationEdges();
+                this.graph.fitView(24);
+                this.bindEvents();
+                // 手动 tooltip
+                const handleNodeEnter = (evt) => {
+                    const { clientX, clientY } = evt;
+                    showTooltip(this.buildTooltip(evt?.item?.getModel()), clientX, clientY);
+                };
+                const handleEdgeEnter = (evt) => {
+                    const model = evt?.item?.getModel() || {};
+                    const relation = model.label || '关联关系';
+                    const text = `${model.source || ''} → ${model.target || ''}`;
+                    const comment = model.comment ? `<div style="font-size:11px;color:#475569;margin-top:4px;white-space:pre-line;">备注:${model.comment}</div>` : '';
+                    const html = `
+                        <div style="border:1px solid #e2e8f0;border-radius:8px;background:white;padding:8px 10px;font-size:12px;color:#475569;box-shadow:0 10px 20px rgba(15,23,42,0.08);">
+                            <div style="font-weight:700;color:#0f172a;margin-bottom:4px;">${relation}</div>
+                            <div>${text}</div>
+                            ${comment}
+                        </div>
+                    `;
+                    const { clientX, clientY } = evt;
+                    showTooltip(html, clientX, clientY);
+                };
+                const handleLeave = () => hideTooltip();
+                this.graph.on('node:mouseenter', handleNodeEnter);
+                this.graph.on('node:mouseleave', handleLeave);
+                this.graph.on('edge:mouseenter', handleEdgeEnter);
+                this.graph.on('edge:mouseleave', handleLeave);
+                // 全量刷新,避免缩放重影
+                const canvas = this.graph && typeof this.graph.get === 'function' ? this.graph.get('canvas') : null;
+                if (canvas && typeof canvas.set === 'function') {
+                    canvas.set('localRefresh', false);
+                    const ctx = typeof canvas.get === 'function' ? canvas.get('context') : null;
+                    if (ctx) {
+                        ctx.shadowColor = 'transparent';
+                        ctx.shadowBlur = 0;
+                    }
+                }
+            },
+            decorateTree(node) {
+                if (!node) return null;
+                const { nodeStyle, labelCfg, size } = this.getNodeLevelStyle(node.depth);
+                return {
+                    id: node.id,
+                    label: `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`,
+                    meta: node.meta,
+                    collapsed: node.collapsed,
+                    depth: node.depth,
+                    size,
+                    style: nodeStyle,
+                    labelCfg,
+                    children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
+                };
+            },
+            getNodeLevelStyle(depth = 0) {
+                const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
+                return {
+                    size: style.size || 22,
+                    nodeStyle: {
+                        fill: style.fill || '#fff',
+                        stroke: style.stroke || '#cbd5f5',
+                        lineWidth: 3,
+                        radius: 6,
+                        shadowColor: undefined,
+                        shadowBlur: 0,
+                    },
+                    labelCfg: {
+                        position: 'right',
+                        offset: 12,
+                        style: {
+                            fontSize: style.fontSize || 13,
+                            fontWeight: style.fontWeight || 600,
+                            fill: style.labelColor || '#0f172a',
+                        },
+                    },
+                };
+            },
+            drawRelationEdges() {
+                if (!this.graph || !this.relationEdges.length) return;
+                const canAddEdge = typeof this.graph.addEdge === 'function';
+                const canAddItem = typeof this.graph.addItem === 'function';
+                const buildModel = (edge, index) => {
+                    const style = { ...(edge.style || {}), lineAppendWidth: 14 };
+                    if (style.endArrow && !style.endArrow.path) {
+                        style.endArrow = {
+                            ...style.endArrow,
+                            path: this.arrow(style.endArrow.d || 10, (style.endArrow.d || 10) + 2, 4),
+                        };
+                    }
+                    return {
+                        id: `extra-${index}`,
+                        source: edge.source,
+                        target: edge.target,
+                        type: edge.type || 'quadratic',
+                        curveOffset: edge.curveOffset || 50,
+                        style,
+                        label: edge.label,
+                        comment: edge.comment || '',
+                        labelCfg: {
+                            autoRotate: true,
+                            style: {
+                                fill: '#475569',
+                                fontSize: 11,
+                                background: {
+                                    fill: 'rgba(255,255,255,0.85)',
+                                    padding: [2, 4],
+                                    radius: 4,
+                                },
+                            },
+                        },
+                    };
+                };
+                if (this.isTreeGraph && (canAddEdge || canAddItem)) {
+                    this.relationEdges.forEach((edge, index) => {
+                        const model = buildModel(edge, index);
+                        if (canAddEdge) {
+                            this.graph.addEdge(model);
+                        } else {
+                            this.graph.addItem('edge', model);
+                        }
+                    });
+                    if (typeof this.graph.paint === 'function') {
+                        this.graph.paint();
+                    }
+                } else {
+                    const currentData = this.graph.save?.() || this.graphDataset || { nodes: [], edges: [] };
+                    const nodes = currentData.nodes || [];
+                    const mergedEdges = (currentData.edges || []).concat(
+                        this.relationEdges.map((edge, index) => buildModel(edge, index))
+                    );
+                    const merged = { nodes, edges: mergedEdges };
+                    if (typeof this.graph.changeData === 'function') {
+                        this.graph.changeData(merged);
+                    } else if (typeof this.graph.data === 'function' && typeof this.graph.render === 'function') {
+                        this.graph.data(merged);
+                        this.graph.render();
+                    }
+                }
+            },
+            buildTooltip(model) {
+                const meta = model?.meta;
+                if (!meta) return '<div class="text-xs text-gray-600">无数据</div>';
+                const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
+                const skills = (meta.skills || [])
+                    .slice(0, 6)
+                    .map((skill) => `<li style="line-height:1.1;">${skill.trim()}</li>`)
+                    .join('') || '<li>暂无技能</li>';
+                return `
+                    <div style="min-width:230px;max-width:340px;border:1px solid #e2e8f0;border-radius:8px;background:white;padding:10px;font-size:12px;color:#475569;box-shadow:0 10px 30px rgba(15,23,42,0.12);">
+                        <div style="font-size:14px;font-weight:700;color:#0f172a;margin-bottom:4px;">${meta.code || model.id} · ${meta.name}</div>
+                        <div style="display:flex;gap:12px;font-size:12px;">
+                            <span>直接:${range(meta.direct_score)}</span>
+                            <span>关联:${range(meta.related_score)}</span>
+                        </div>
+                        <div style="margin-top:8px;">
+                            <div style="font-weight:600;">技能要点</div>
+                            <ul style="padding-left:18px;margin:6px 0; list-style: disc; display: grid; gap: 4px;">
+                                ${skills}
+                            </ul>
+                        </div>
+                    </div>
+                `;
+            },
+            bindEvents() {
+                if (!this.graph) return;
+                this.graph.on('node:click', (evt) => {
+                    const nodeId = evt?.item?.getID();
+                    if (nodeId) this.highlightEdges(nodeId);
+                });
+                this.graph.on('canvas:click', () => this.resetHighlight());
+            },
+            highlightEdges(nodeId) {
+                this.graph.getNodes().forEach((node) => {
+                    this.graph.clearItemStates(node);
+                    if (node.getID() === nodeId) {
+                        this.graph.setItemState(node, 'selected', true);
+                    }
+                });
+                this.graph.getEdges().forEach((edge) => {
+                    const { source, target } = edge.getModel();
+                    const linked = source === nodeId || target === nodeId;
+                    if (linked) {
+                        this.graph.setItemState(edge, 'highlight', true);
+                    } else {
+                        this.graph.clearItemStates(edge);
+                    }
+                });
+            },
+            resetHighlight() {
+                if (!this.graph) return;
+                this.graph.getNodes().forEach((node) => this.graph.clearItemStates(node));
+                this.graph.getEdges().forEach((edge) => this.graph.clearItemStates(edge));
+            },
+            resizeGraph() {
+                if (!this.graph) return;
+                const container = document.getElementById('knowledge-mindmap');
+                if (!container) return;
+                this.graph.changeSize(container.clientWidth, container.clientHeight);
+                this.graph.fitView(24);
+            },
+        });
+</script>
+</body>
+</html>

+ 1 - 0
routes/web.php

@@ -10,3 +10,4 @@ Route::get('/', function () {
 require __DIR__.'/api.php';
 Route::get('/test-math', function() { return view('test-math'); });
 Route::get('/test-case', function() { return view('test-case'); });
+Route::view('/knowledge-mindmap-public', 'public.knowledge-mindmap');