Browse Source

逐步修复 bug——修复权重问题

yemeishu 1 month ago
parent
commit
c2748aebff

+ 118 - 12
app/Livewire/StudentKnowledgeGraph.php

@@ -83,11 +83,13 @@ class StudentKnowledgeGraph extends Component
     {
         $baseUrl = config('services.learning_analytics.url', 'http://localhost:5010');
 
+        $masteryPayload = [];
+
         try {
             // 获取掌握度数据
             $masteryResponse = Http::timeout(10)->get($baseUrl . '/api/mastery/' . $studentId);
             if ($masteryResponse->successful()) {
-                $this->masteryData = $masteryResponse->json();
+                $masteryPayload = $masteryResponse->json();
             }
 
             // 获取依赖关系
@@ -108,6 +110,7 @@ class StudentKnowledgeGraph extends Component
                 $this->learningPath = $pathResponse->json();
             }
 
+            $this->setMasteryData($masteryPayload);
             // 构建知识点图谱数据
             $this->buildKnowledgeGraphData();
 
@@ -125,18 +128,27 @@ class StudentKnowledgeGraph extends Component
     {
         $nodes = [];
         $links = [];
+        $masteries = $this->masteryData['masteries'] ?? [];
 
         // 处理掌握度数据,构建节点
-        if (isset($this->masteryData['masteries'])) {
-            foreach ($this->masteryData['masteries'] as $mastery) {
-                $nodes[] = [
-                    'id' => $mastery['kp_code'],
-                    'label' => $mastery['kp_code'],
-                    'mastery' => $mastery['mastery_level'],
-                    'color' => $this->getMasteryColor($mastery['mastery_level']),
-                    'size' => $this->getMasterySize($mastery['mastery_level']),
-                ];
+        foreach ($masteries as $mastery) {
+            if (!isset($mastery['mastery_level'])) {
+                continue;
+            }
+
+            $masteryLevel = (float) $mastery['mastery_level'];
+            $kpCode = $mastery['kp_code'] ?? null;
+            if (!$kpCode) {
+                continue;
             }
+
+            $nodes[] = [
+                'id' => $kpCode,
+                'label' => $mastery['kp_name'] ?? $kpCode,
+                'mastery' => $masteryLevel,
+                'color' => $this->getMasteryColor($masteryLevel),
+                'size' => $this->getMasterySize($masteryLevel),
+            ];
         }
 
         // 处理依赖关系,构建边
@@ -155,6 +167,8 @@ class StudentKnowledgeGraph extends Component
             'nodes' => $nodes,
             'links' => $links,
         ];
+
+        $this->dispatchGraphUpdated();
     }
 
     private function loadMockData($studentId)
@@ -197,7 +211,7 @@ class StudentKnowledgeGraph extends Component
             'links' => $links,
         ];
 
-        $this->masteryData = [
+        $this->setMasteryData([
             'masteries' => array_map(function ($code, $data) use ($studentId) {
                 return [
                     'student_id' => $studentId,
@@ -206,7 +220,7 @@ class StudentKnowledgeGraph extends Component
                     'confidence_level' => 0.8,
                 ];
             }, array_keys($mockKnowledgePoints), $mockKnowledgePoints),
-        ];
+        ]);
 
         $this->statistics = [
             'total_knowledge_points' => count($mockKnowledgePoints),
@@ -215,6 +229,8 @@ class StudentKnowledgeGraph extends Component
             'medium_mastery_count' => count(array_filter($mockKnowledgePoints, fn($d) => $d['mastery'] >= 0.4 && $d['mastery'] < 0.7)),
             'low_mastery_count' => count(array_filter($mockKnowledgePoints, fn($d) => $d['mastery'] < 0.4)),
         ];
+
+        $this->dispatchGraphUpdated();
     }
 
     private function getMasteryColor($mastery)
@@ -239,6 +255,96 @@ class StudentKnowledgeGraph extends Component
         $this->masteryData = [];
         $this->statistics = [];
         $this->learningPath = [];
+
+        $this->dispatchGraphUpdated();
+    }
+
+    private function dispatchGraphUpdated(): void
+    {
+        // 通知前端重新渲染图谱(更新节点颜色/大小等)
+        $this->dispatch('knowledgeGraphUpdated', $this->knowledgePoints);
+    }
+
+    private function setMasteryData(array $payload): void
+    {
+        $masteries = $this->normalizeMasteries($payload);
+
+        $this->masteryData = array_merge($payload, [
+            'masteries' => $masteries,
+            'mastery_map' => $this->buildMasteryMap($masteries),
+        ]);
+    }
+
+    private function normalizeMasteries(array $raw): array
+    {
+        if (isset($raw['masteries']) && is_array($raw['masteries'])) {
+            return array_values($raw['masteries']);
+        }
+
+        $candidates = [];
+        if (isset($raw['data']) && is_array($raw['data'])) {
+            $candidates[] = $raw['data'];
+        }
+        if (isset($raw['Target']) && is_array($raw['Target'])) {
+            $candidates[] = $raw['Target'];
+        }
+        if ($this->isAssociativeArray($raw) && $this->looksLikeMasteryRecord(reset($raw))) {
+            $candidates[] = $raw;
+        }
+
+        foreach ($candidates as $candidate) {
+            $normalized = $this->normalizeCandidateMasteries($candidate);
+            if (!empty($normalized)) {
+                return $normalized;
+            }
+        }
+
+        return [];
+    }
+
+    private function normalizeCandidateMasteries(array $candidate): array
+    {
+        $normalized = [];
+
+        foreach ($candidate as $key => $entry) {
+            if (!is_array($entry) || !isset($entry['mastery_level'])) {
+                continue;
+            }
+
+            if (!isset($entry['kp_code']) && is_string($key)) {
+                $entry['kp_code'] = $key;
+            }
+
+            if (isset($entry['kp_code'])) {
+                $normalized[] = $entry;
+            }
+        }
+
+        return $normalized;
+    }
+
+    private function isAssociativeArray(array $array): bool
+    {
+        return array_keys($array) !== range(0, count($array) - 1);
+    }
+
+    private function looksLikeMasteryRecord($entry): bool
+    {
+        return is_array($entry) && array_key_exists('mastery_level', $entry);
+    }
+
+    private function buildMasteryMap(array $masteries): array
+    {
+        $map = [];
+
+        foreach ($masteries as $mastery) {
+            if (!isset($mastery['kp_code'])) {
+                continue;
+            }
+            $map[$mastery['kp_code']] = $mastery;
+        }
+
+        return $map;
     }
 
     public function render()

+ 1 - 1
public/data/tree.json

@@ -492,4 +492,4 @@
       ]
     }
   ]
-}
+}

+ 139 - 51
public/js/g6-custom-node.js

@@ -1,5 +1,15 @@
-G6.registerNode(
-    'hexagon-card',
+// 等待G6加载完成后再注册自定义节点
+function registerCustomNode() {
+    if (typeof window.G6 === 'undefined') {
+        console.log('G6库尚未加载,100ms后重试...');
+        setTimeout(registerCustomNode, 100);
+        return;
+    }
+
+    console.log('G6库已加载,开始注册自定义节点...');
+
+    G6.registerNode(
+        'hexagon-card',
     {
         draw(cfg, group) {
             const size = Array.isArray(cfg.size)
@@ -9,11 +19,12 @@ G6.registerNode(
                 0,
                 Math.min(1, cfg.meta?.mastery_level ?? 0)
             );
+            const hasMastery = Boolean(cfg.meta?.has_mastery);
             const percent = Math.round(mastery * 100);
             const locked = cfg.locked || false;
             const recommended = cfg.meta?.recommended;
             const hexagonPath = this.getHexagonPath(size);
-            const palette = this.getPalette(mastery, locked);
+            const palette = this.getPalette(mastery, locked, hasMastery);
 
             const ring = group.addShape('path', {
                 attrs: {
@@ -28,12 +39,12 @@ G6.registerNode(
             const hexagon = group.addShape('path', {
                 attrs: {
                     path: hexagonPath,
-                    fill: '#ffffff',
+                    fill: palette.fill,
                     stroke: palette.stroke,
                     lineWidth: palette.lineWidth,
                     shadowColor: palette.shadow,
                     shadowBlur: palette.shadow ? 12 : 0,
-                    opacity: locked ? 0.55 : 1,
+                    opacity: locked ? 0.55 : hasMastery ? 1 : 0.6,
                     cursor: locked ? 'not-allowed' : 'pointer',
                 },
                 name: 'hexagon-shape',
@@ -48,9 +59,9 @@ G6.registerNode(
                     y: -cardHeight / 2,
                     width: cardWidth,
                     height: cardHeight,
-                    fill: '#ffffff',
+                    fill: palette.cardFill,
                     radius: 6,
-                    opacity: locked ? 0.75 : 0.95,
+                    opacity: locked ? 0.75 : hasMastery ? 0.95 : 0.6,
                     shadowColor: 'rgba(15, 23, 42, 0.12)',
                     shadowBlur: 8,
                 },
@@ -59,11 +70,11 @@ G6.registerNode(
 
             group.addShape('text', {
                 attrs: {
-                    text: `${cfg.meta?.code || cfg.id} · ${cfg.label || cfg.meta?.name || cfg.id}`,
+                    text: `${cfg.id} · ${cfg.label || cfg.meta?.name || cfg.meta?.code || cfg.id}`,
                     x: 0,
-                    y: -12,
-                    fontSize: 24,
-                    fontWeight: 700,
+                    y: -10,
+                    fontSize: 22,
+                    fontWeight: 800,
                     fill: locked ? '#94a3b8' : '#0f172a',
                     textAlign: 'center',
                     textBaseline: 'middle',
@@ -73,35 +84,21 @@ G6.registerNode(
 
             group.addShape('text', {
                 attrs: {
-                    text: cfg.meta?.name || cfg.label || cfg.id,
+                    text: cfg.meta?.code || cfg.id,
                     x: 0,
-                    y: 6,
-                    fontSize: 16,
+                    y: 10,
+                    fontSize: 15,
                     fontWeight: 700,
-                    fill: '#1e293b',
+                    fill: '#334155',
                     textAlign: 'center',
                     textBaseline: 'middle',
                 },
                 name: 'code-text',
             });
 
-            group.addShape('text', {
-                attrs: {
-                    text: `ID: ${cfg.id}`,
-                    x: 0,
-                    y: 22,
-                    fontSize: 13,
-                    fontWeight: 700,
-                    fill: '#0f172a',
-                    textAlign: 'center',
-                    textBaseline: 'middle',
-                },
-                name: 'id-text',
-            });
-
             const barWidth = cardWidth - 16;
             const barHeight = 8;
-            const barY = cardHeight / 2 - 8;
+            const barY = cardHeight / 2 - 4;
 
             group.addShape('text', {
                 attrs: {
@@ -266,63 +263,73 @@ G6.registerNode(
             }
         },
 
-        getPalette(mastery, locked) {
-            if (locked) {
+        getPalette(mastery, locked, hasMastery) {
+            if (!hasMastery) {
                 return {
-                    stroke: '#e2e8f0',
-                    lineWidth: 2.5,
+                    stroke: '#cbd5e1',
+                    lineWidth: 3,
                     ring: '#e2e8f0',
-                    ringWidth: 4,
+                    ringWidth: 6,
                     ringOpacity: 0.35,
-                    shadow: '',
-                    progress: '#cbd5e1',
+                    shadow: 'rgba(148, 163, 184, 0.15)',
+                    progress: '#e5e7eb',
+                    fill: '#f8fafc',
+                    cardFill: '#ffffff',
                 };
             }
 
             if (mastery >= 0.8) {
                 return {
                     stroke: '#d3b55f',
-                    lineWidth: 3,
+                    lineWidth: 4,
                     ring: '#f7e4ad',
-                    ringWidth: 7,
-                    ringOpacity: 0.4,
+                    ringWidth: 8,
+                    ringOpacity: 0.55,
                     shadow: 'rgba(212, 181, 95, 0.28)',
                     progress: '#22c55e',
+                    fill: '#fffbeb',
+                    cardFill: '#ffffff',
                 };
             }
 
             if (mastery >= 0.6) {
                 return {
                     stroke: '#34d399',
-                    lineWidth: 3,
+                    lineWidth: 3.5,
                     ring: '#bbf7d0',
-                    ringWidth: 6,
-                    ringOpacity: 0.28,
+                    ringWidth: 7,
+                    ringOpacity: 0.45,
                     shadow: 'rgba(52, 211, 153, 0.25)',
                     progress: '#34d399',
+                    fill: '#ecfdf3',
+                    cardFill: '#ffffff',
                 };
             }
 
             if (mastery >= 0.4) {
                 return {
                     stroke: '#f59e0b',
-                    lineWidth: 3,
+                    lineWidth: 3.5,
                     ring: '#fde68a',
-                    ringWidth: 6,
-                    ringOpacity: 0.28,
+                    ringWidth: 7,
+                    ringOpacity: 0.45,
                     shadow: 'rgba(245, 158, 11, 0.18)',
                     progress: '#f59e0b',
+                    fill: '#fffbeb',
+                    cardFill: '#fff7ed',
                 };
             }
 
             return {
                 stroke: '#f87171',
-                lineWidth: 2.5,
-                ring: '#fee2e2',
-                ringWidth: 6,
-                ringOpacity: 0.32,
-                shadow: 'rgba(248, 113, 113, 0.18)',
+                lineWidth: 3,
+                ring: '#fecaca',
+                ringWidth: 8,
+                ringOpacity: 0.55,
+                shadow: 'rgba(248, 113, 113, 0.25)',
                 progress: '#f87171',
+                fill: '#fef2f2',
+                cardFill: '#fff1f2',
             };
         },
 
@@ -339,6 +346,87 @@ G6.registerNode(
                 ['Z'],
             ];
         },
+
+        update(cfg, item) {
+            const group = item.getContainer();
+            const size = Array.isArray(cfg.size) ? cfg.size[0] : cfg.size || 110;
+            const mastery = Math.max(
+                0,
+                Math.min(1, cfg.meta?.mastery_level ?? 0)
+            );
+            const hasMastery = Boolean(cfg.meta?.has_mastery);
+            const percent = Math.round(mastery * 100);
+            const locked = cfg.locked || false;
+            const palette = this.getPalette(mastery, locked, hasMastery);
+
+            const ring = group.find((e) => e.get('name') === 'ring-shape');
+            if (ring) {
+                ring.attr({
+                    stroke: palette.ring,
+                    lineWidth: palette.ringWidth,
+                    opacity: palette.ringOpacity,
+                });
+            }
+
+            const hexagon = group.find((e) => e.get('name') === 'hexagon-shape');
+            if (hexagon) {
+                hexagon.attr({
+                    stroke: palette.stroke,
+                    lineWidth: palette.lineWidth,
+                    shadowColor: palette.shadow,
+                    shadowBlur: palette.shadow ? 12 : 0,
+                    opacity: locked ? 0.55 : hasMastery ? 1 : 0.6,
+                    cursor: locked ? 'not-allowed' : 'pointer',
+                    fill: palette.fill,
+                });
+            }
+
+            const title = group.find((e) => e.get('name') === 'title-text');
+            if (title) {
+                title.attr({
+                    text: `${cfg.id} · ${cfg.label || cfg.meta?.name || cfg.meta?.code || cfg.id}`,
+                    fill: locked ? '#94a3b8' : '#0f172a',
+                });
+            }
+
+            const codeText = group.find((e) => e.get('name') === 'code-text');
+            if (codeText) {
+                codeText.attr({
+                    text: cfg.meta?.code || cfg.id,
+                });
+            }
+
+            const progressFill = group.find((e) => e.get('name') === 'progress-fill');
+            const progressBg = group.find((e) => e.get('name') === 'progress-bg');
+            if (progressFill && progressBg) {
+                const cardWidth = size * 0.82;
+                const barWidth = cardWidth - 16;
+                progressFill.attr({
+                    width: (barWidth * percent) / 100,
+                    fill: palette.progress,
+                });
+            }
+
+            const card = group.find((e) => e.get('name') === 'card-shape');
+            if (card) {
+                card.attr({
+                    fill: palette.cardFill,
+                });
+            }
+
+            const percentText = group.find((e) => e.get('name') === 'percent-text');
+            if (percentText) {
+                percentText.attr({
+                    text: `${percent}%`,
+                });
+            }
+        },
     },
     'single-node'
 );
+
+    console.log('自定义节点注册完成');
+}
+
+// 启动注册流程
+registerCustomNode();

+ 342 - 15
public/js/knowledge-mindmap-graph.js

@@ -4,7 +4,7 @@ class KnowledgeMindmapGraph {
         this.rawTree = null;
         this.treeData = null;
         this.relationEdges = [];
-        this.masteryData = options.masteryData || {};
+        this.masteryData = {};
         this.masteryCache = {};
         this.stats = { nodes: 0, extraEdges: 0 };
         this.containerId = options.containerId || 'knowledge-mindmap';
@@ -13,10 +13,14 @@ class KnowledgeMindmapGraph {
         this.livewireId = options.livewireId || null;
         this.highlightLowMastery = options.highlightLowMastery ?? true;
         this.emitSelection = options.emitSelection ?? true;
+        this.tooltipEl = null;
+        this.nodeIdSet = new Set();
         this.lockRules = options.lockRules || [
             { prerequisite: 'P04', target: 'P05', threshold: 0.6 },
             { prerequisite: 'P05', target: 'P06', threshold: 0.6 },
         ];
+
+        this.setMasteryData(options.masteryData || {});
     }
 
     async init() {
@@ -47,6 +51,11 @@ class KnowledgeMindmapGraph {
         this.masteryCache = {};
         this.treeData = this.transformNode(this.rawTree);
         this.relationEdges = this.normalizeEdges(rawEdges);
+        const flatIds = [];
+        this.collectIds(this.treeData, flatIds);
+        console.log('知识点总数', flatIds.length, '列表:', flatIds);
+        this.nodeIdSet = new Set(flatIds);
+        this.logMasteryCoverage();
         this.stats = {
             nodes: this.countNodes(this.treeData),
             extraEdges: this.relationEdges.length,
@@ -62,8 +71,9 @@ class KnowledgeMindmapGraph {
             node.label ||
             `node-${Math.random().toString(36).slice(2, 8)}`;
         const label = node.name || node.label || node.code || node.id || id;
+        const masteryInfo = this.masteryData[id] || null;
         const masteryLevel = this.getMasteryLevel(id);
-        const accuracy = this.masteryData[id]?.accuracy_rate || 0;
+        const accuracy = this.toNumber(masteryInfo?.accuracy_rate);
         const recommended = masteryLevel < 0.6;
 
         const model = {
@@ -71,14 +81,15 @@ class KnowledgeMindmapGraph {
             label,
             depth,
             locked: false,
-            collapsed: depth > 0 && (node.children || []).length > 0,
+            collapsed: depth > 0 && (node.children || []).length > 0, // 默认折叠所有有子节点的节点(除根节点)
             meta: {
                 code: id,
                 name: label,
                 mastery_level: masteryLevel,
                 accuracy_rate: accuracy,
-                total_attempts: this.masteryData[id]?.total_attempts || 0,
-                mastery_info: this.masteryData[id] || null,
+                total_attempts: this.toNumber(masteryInfo?.total_attempts),
+                mastery_info: masteryInfo,
+                has_mastery: Boolean(masteryInfo),
                 recommended,
             },
             children: (node.children || [])
@@ -95,11 +106,7 @@ class KnowledgeMindmapGraph {
         }
 
         const remote = this.masteryData[id]?.mastery_level;
-        const randomFallback = Math.random() * 0.55 + 0.25;
-        const value =
-            typeof remote === 'number' && !Number.isNaN(remote)
-                ? remote
-                : randomFallback;
+        const value = this.normalizeMasteryLevel(remote);
 
         this.masteryCache[id] = value;
         return value;
@@ -127,6 +134,7 @@ class KnowledgeMindmapGraph {
 
     applyInitialCollapse(node, depth = 0) {
         if (!node) return;
+        // 默认折叠所有有子节点的节点(除了根节点)
         if (depth > 0 && node.children.length > 0) {
             node.collapsed = true;
         }
@@ -211,6 +219,12 @@ class KnowledgeMindmapGraph {
         return normalized;
     }
 
+    collectIds(node, bucket) {
+        if (!node) return;
+        bucket.push(node.id);
+        (node.children || []).forEach((child) => this.collectIds(child, bucket));
+    }
+
     renderGraph() {
         const container = document.getElementById(this.containerId);
         if (!container) return;
@@ -219,6 +233,9 @@ class KnowledgeMindmapGraph {
         const width = Math.max(bounds.width, 640);
         const height = Math.max(bounds.height, 640);
 
+        // 直接在数据层面设置折叠状态
+        this.setCollapsedState(this.treeData);
+
         this.graph = new G6.TreeGraph({
             container: this.containerId,
             width,
@@ -270,16 +287,38 @@ class KnowledgeMindmapGraph {
                 getWidth: () => 150,
                 getVGap: () => 18,
                 getHGap: () => 50,
+                preventOverlap: true,
             },
         });
 
         this.graph.data(this.treeData);
         this.graph.render();
         this.graph.fitView(12);
+        // 暴露实例便于调试
+        window.KnowledgeMindmapGraphInstance = this;
+        window.KnowledgeMindmapG6Graph = this.graph;
+
         this.drawRelationEdges();
         this.applyNodeStates();
         this.startEdgeFlows();
         this.focusOnLowestMastery();
+        this.repaintNodes();
+    }
+
+    setCollapsedState(nodeData, depth = 0) {
+        if (!nodeData) return;
+
+        // 折叠除根节点外的所有有子节点的节点
+        if (depth > 0 && nodeData.children && nodeData.children.length > 0) {
+            if (nodeData.collapsed === undefined) {
+                nodeData.collapsed = true;
+            }
+        }
+
+        // 递归处理子节点
+        if (nodeData.children) {
+            nodeData.children.forEach(child => this.setCollapsedState(child, depth + 1));
+        }
     }
 
     clearRelationEdges() {
@@ -295,6 +334,11 @@ class KnowledgeMindmapGraph {
     drawRelationEdges() {
         if (!this.graph || !this.relationEdges.length) return;
         this.relationEdges.forEach((edge) => {
+            // 跳过缺失节点的边,避免 G6 报错导致后续渲染异常
+            if (!this.graph.findById(edge.source) || !this.graph.findById(edge.target)) {
+                console.warn('[mindmap] 跳过无效关联边', edge.id || edge.source + '-' + edge.target);
+                return;
+            }
             this.graph.addItem('edge', edge);
         });
     }
@@ -304,13 +348,16 @@ class KnowledgeMindmapGraph {
         this.graph.getNodes().forEach((node) => {
             const model = node.getModel();
             const mastery = model.meta?.mastery_level ?? 0;
+            const hasMastery =
+                model.meta?.has_mastery ?? Boolean(this.masteryData[model.id]);
+            this.graph.setItemState(node, 'dimmed', !hasMastery);
             if (model.locked) {
                 this.graph.setItemState(node, 'locked', true);
             }
-            if (mastery < 0.4 && this.highlightLowMastery) {
+            if (hasMastery && mastery < 0.4 && this.highlightLowMastery) {
                 this.graph.setItemState(node, 'weak', true);
             }
-            if (mastery >= 0.8 && !model.locked) {
+            if (hasMastery && mastery >= 0.8 && !model.locked) {
                 this.playHalo(node);
             }
         });
@@ -393,6 +440,7 @@ class KnowledgeMindmapGraph {
             if (!item || item.getModel().locked) return;
             this.graph.setItemState(item, 'hover', true);
             this.highlightNeighbors(item.getModel().id);
+            this.showTooltip(evt, item.getModel());
         });
 
         this.graph.on('node:mouseleave', (evt) => {
@@ -400,6 +448,7 @@ class KnowledgeMindmapGraph {
             if (!item) return;
             this.graph.setItemState(item, 'hover', false);
             this.clearNeighborHighlight();
+            this.hideTooltip();
         });
 
         this.graph.on('node:click', (evt) => {
@@ -418,6 +467,7 @@ class KnowledgeMindmapGraph {
             if (this.emitSelection) {
                 this.notifySelection(model);
             }
+            this.showTooltip(evt, model);
         });
 
         this.graph.on('canvas:click', () => {
@@ -425,6 +475,7 @@ class KnowledgeMindmapGraph {
                 this.graph.clearItemStates(node);
             });
             this.clearNeighborHighlight();
+            this.hideTooltip();
         });
     }
 
@@ -489,10 +540,67 @@ class KnowledgeMindmapGraph {
         }
     }
 
+    ensureTooltipEl() {
+        if (this.tooltipEl) return this.tooltipEl;
+        const div = document.createElement('div');
+        div.style.position = 'fixed';
+        div.style.zIndex = '9999';
+        div.style.pointerEvents = 'none';
+        div.style.padding = '10px 12px';
+        div.style.background = 'rgba(15,23,42,0.95)';
+        div.style.color = '#e2e8f0';
+        div.style.borderRadius = '10px';
+        div.style.boxShadow = '0 10px 30px rgba(0,0,0,0.18)';
+        div.style.fontSize = '12px';
+        div.style.lineHeight = '1.4';
+        document.body.appendChild(div);
+        this.tooltipEl = div;
+        return div;
+    }
+
+    showTooltip(evt, model) {
+        const tip = this.ensureTooltipEl();
+        const mastery = (model.meta?.mastery_level ?? 0) * 100;
+        const attempts = model.meta?.total_attempts ?? 0;
+        const recommended = model.meta?.recommended ? '是' : '否';
+        const locked = model.locked ? '是' : '否';
+        tip.innerHTML = `
+            <div style="font-weight:700;font-size:13px;">${model.id} · ${model.meta?.name || model.label}</div>
+            <div>掌握度:${mastery.toFixed(1)}%</div>
+            <div>推荐练习:${recommended}</div>
+            <div>尝试次数:${attempts}</div>
+            <div>锁定:${locked}</div>
+        `;
+        const x =
+            evt?.clientX ??
+            evt?.canvasX ??
+            evt?.x ??
+            evt?.event?.clientX ??
+            0;
+        const y =
+            evt?.clientY ??
+            evt?.canvasY ??
+            evt?.y ??
+            evt?.event?.clientY ??
+            0;
+        tip.style.left = `${x + 16}px`;
+        tip.style.top = `${y + 12}px`;
+        tip.style.opacity = '1';
+    }
+
+    hideTooltip() {
+        if (!this.tooltipEl) return;
+        this.tooltipEl.style.opacity = '0';
+    }
+
     setupLivewireListeners() {
         ['mastery-updated', 'mindmap-mastery-updated'].forEach((event) => {
             window.addEventListener(event, (detailEvent) => {
-                this.masteryData = detailEvent.detail?.data || {};
+                const payload =
+                    detailEvent.detail?.data ??
+                    detailEvent.detail ??
+                    {};
+                this.setMasteryData(payload);
                 this.refreshGraph();
             });
         });
@@ -538,19 +646,68 @@ class KnowledgeMindmapGraph {
 
         this.masteryCache = {};
         this.treeData = this.transformNode(this.rawTree);
+        const flatIds = [];
+        this.collectIds(this.treeData, flatIds);
+        this.nodeIdSet = new Set(flatIds);
+        this.logMasteryCoverage();
         this.applyUnlockRules(this.treeData);
         this.applyInitialCollapse(this.treeData);
         this.expandForMastery();
 
-        this.graph.changeData(this.treeData);
+        // 强制全量重绘,确保 meta/颜色更新
+        this.graph.clear();
+        this.graph.data(this.treeData);
         this.graph.render();
+        this.repaintNodes();
         this.graph.fitView(12);
+        // 暴露实例便于调试(刷新后仍可用)
+        window.KnowledgeMindmapGraphInstance = this;
+        window.KnowledgeMindmapG6Graph = this.graph;
 
         this.clearRelationEdges();
         this.drawRelationEdges();
         this.applyNodeStates();
         this.startEdgeFlows();
         this.focusOnLowestMastery();
+        this.graph.paint();
+    }
+
+    forceCollapseNodes() {
+        if (!this.graph) return;
+    }
+
+    forceCollapse() {
+        if (!this.graph) {
+            setTimeout(() => this.forceCollapse(), 200);
+            return;
+        }
+        const nodes = this.graph.getNodes();
+        let collapsedCount = 0;
+
+        nodes.forEach(node => {
+            const model = node.getModel();
+            // 折叠除根节点外的所有有子节点的节点
+            if (
+                this.masteryData[model.id] ||
+                (model.meta && this.masteryData[model.meta.code])
+            ) {
+                model.collapsed = false;
+                return;
+            }
+
+            if (model.depth > 0 && model.children && model.children.length > 0) {
+                try {
+                    this.graph.collapseItem(node);
+                    collapsedCount++;
+                } catch (error) {
+                    console.error('折叠节点失败:', model.id, error);
+                }
+            }
+        });
+
+        // 重新渲染和适配视图
+        this.graph.refresh();
+        this.graph.fitView(12);
     }
 
     resizeGraph() {
@@ -560,6 +717,176 @@ class KnowledgeMindmapGraph {
         this.graph.changeSize(container.clientWidth, container.clientHeight);
         this.graph.fitView(12);
     }
+
+    setMasteryData(payload = {}) {
+        this.masteryData = this.normalizeMasteryPayload(payload);
+        this.masteryCache = {};
+        this.logMasteryCoverage();
+    }
+
+    normalizeMasteryPayload(payload = {}) {
+        // 支持 {masteries: []} / {data: []} / {Target: {KP: {...}}} / 直接的键值对
+        const map = {};
+        const addEntry = (entry, fallbackKey = null) => {
+            if (!entry || typeof entry !== 'object') return;
+            const code =
+                entry.kp_code ||
+                entry.code ||
+                entry.id ||
+                fallbackKey;
+            if (!code) return;
+            const masteryLevel = this.normalizeMasteryLevel(
+                entry.mastery_level
+            );
+            map[code] = {
+                ...entry,
+                kp_code: code,
+                mastery_level: masteryLevel,
+            };
+        };
+
+        const normalizeCandidate = (candidate) => {
+            if (!candidate) return;
+            if (Array.isArray(candidate)) {
+                candidate.forEach((item) => addEntry(item));
+                return;
+            }
+            if (typeof candidate === 'object') {
+                Object.entries(candidate).forEach(([key, value]) =>
+                    addEntry(value, key)
+                );
+            }
+        };
+
+        normalizeCandidate(payload.masteries);
+        normalizeCandidate(payload.data);
+        normalizeCandidate(payload.Target);
+        // 允许直接传入键值对
+        normalizeCandidate(payload);
+
+        return map;
+    }
+
+    normalizeMasteryLevel(value) {
+        const num = this.toNumber(value);
+        if (!Number.isFinite(num)) return 0;
+        return Math.max(0, Math.min(1, num));
+    }
+
+    toNumber(value) {
+        const num = Number(value);
+        return Number.isFinite(num) ? num : 0;
+    }
+
+    repaintNodes() {
+        if (!this.graph || !this.treeData) return;
+        const nodeMap = {};
+        const walk = (node) => {
+            if (!node) return;
+            nodeMap[node.id] = node;
+            (node.children || []).forEach((child) => walk(child));
+        };
+        walk(this.treeData);
+
+        let updatedCount = 0;
+        let firstUpdated = null;
+        this.graph.getNodes().forEach((node) => {
+            const id = node.getModel().id;
+            const freshModel = nodeMap[id];
+            if (!freshModel) return;
+            // 确保 meta 中 has_mastery 等字段存在
+            const masteryInfo =
+                this.masteryData[id] ||
+                this.masteryData[freshModel.meta?.code] ||
+                null;
+            freshModel.meta = {
+                ...freshModel.meta,
+                mastery_level: this.normalizeMasteryLevel(
+                    masteryInfo?.mastery_level ?? freshModel.meta?.mastery_level
+                ),
+                has_mastery: Boolean(masteryInfo),
+                mastery_info: masteryInfo,
+                total_attempts: this.toNumber(
+                    masteryInfo?.total_attempts ?? freshModel.meta?.total_attempts
+                ),
+            };
+            this.graph.updateItem(node, freshModel);
+            this.graph.refreshItem?.(node);
+            this.applyDirectStyles(node, freshModel.meta.mastery_level, freshModel.meta.has_mastery);
+            // 直接打上选中状态,避免样式被缓存
+            if (freshModel.meta.has_mastery) {
+                this.graph.setItemState(node, 'selected', true);
+            } else {
+                this.graph.clearItemStates(node, ['selected']);
+            }
+            updatedCount += 1;
+            if (!firstUpdated) {
+                firstUpdated = {
+                    id,
+                    mastery: freshModel.meta?.mastery_level,
+                    has_mastery: freshModel.meta?.has_mastery,
+                };
+            }
+        });
+
+        // 强制重绘,避免样式缓存
+        this.graph.paint();
+    }
+
+    applyDirectStyles(node, mastery, hasMastery) {
+        // 直接对关键 shape 赋色,避免 G6 缓存导致颜色不变
+        const keyShape = node.getKeyShape?.();
+        const group = node.getContainer?.();
+        if (!keyShape || !group) return;
+
+        const palette = (() => {
+            if (!hasMastery) {
+                return { fill: '#f8fafc', stroke: '#cbd5e1', card: '#ffffff' };
+            }
+            if (mastery >= 0.8) return { fill: '#fffbeb', stroke: '#d3b55f', card: '#ffffff' };
+            if (mastery >= 0.6) return { fill: '#ecfdf3', stroke: '#34d399', card: '#ffffff' };
+            if (mastery >= 0.4) return { fill: '#fffbeb', stroke: '#f59e0b', card: '#fff7ed' };
+            return { fill: '#fef2f2', stroke: '#f87171', card: '#fff1f2' };
+        })();
+
+        keyShape.attr({
+            fill: palette.fill,
+            stroke: palette.stroke,
+        });
+
+        const card = group.find((e) => e.get?.('name') === 'card-shape');
+        if (card) {
+            card.attr({ fill: palette.card });
+        }
+        // 额外高亮轮廓,确保视觉可见
+        this.graph.setItemState(node, 'selected', hasMastery);
+        this.graph.refreshItem?.(node);
+    }
+
+    logMasteryCoverage() {
+        if (!this.nodeIdSet || !this.nodeIdSet.size) return;
+        const masteries = Object.keys(this.masteryData || {});
+        if (!masteries.length) return;
+        const missing = masteries.filter((id) => !this.nodeIdSet.has(id));
+        if (missing.length) {
+            console.warn(
+                '掌握度返回的知识点未在图谱中找到:',
+                missing.slice(0, 20),
+                missing.length > 20 ? `...共${missing.length}条` : ''
+            );
+        }
+    }
+
+}
+
+// 定义KnowledgeMindmapGraph类,确保G6已加载
+function defineGraphClass() {
+    if (typeof window.G6 === 'undefined') {
+        setTimeout(defineGraphClass, 100);
+        return;
+    }
+    window.KnowledgeMindmapGraph = KnowledgeMindmapGraph;
 }
 
-window.KnowledgeMindmapGraph = KnowledgeMindmapGraph;
+// 启动定义流程
+defineGraphClass();

+ 121 - 49
resources/js/g6-custom-node.js

@@ -19,11 +19,12 @@ function registerCustomNode() {
                 0,
                 Math.min(1, cfg.meta?.mastery_level ?? 0)
             );
+            const hasMastery = Boolean(cfg.meta?.has_mastery);
             const percent = Math.round(mastery * 100);
             const locked = cfg.locked || false;
             const recommended = cfg.meta?.recommended;
             const hexagonPath = this.getHexagonPath(size);
-            const palette = this.getPalette(mastery, locked);
+            const palette = this.getPalette(mastery, locked, hasMastery);
 
             const ring = group.addShape('path', {
                 attrs: {
@@ -38,12 +39,12 @@ function registerCustomNode() {
             const hexagon = group.addShape('path', {
                 attrs: {
                     path: hexagonPath,
-                    fill: '#ffffff',
+                    fill: palette.fill,
                     stroke: palette.stroke,
                     lineWidth: palette.lineWidth,
                     shadowColor: palette.shadow,
                     shadowBlur: palette.shadow ? 12 : 0,
-                    opacity: locked ? 0.55 : 1,
+                    opacity: locked ? 0.55 : hasMastery ? 1 : 0.6,
                     cursor: locked ? 'not-allowed' : 'pointer',
                 },
                 name: 'hexagon-shape',
@@ -58,9 +59,9 @@ function registerCustomNode() {
                     y: -cardHeight / 2,
                     width: cardWidth,
                     height: cardHeight,
-                    fill: '#ffffff',
+                    fill: palette.cardFill,
                     radius: 6,
-                    opacity: locked ? 0.75 : 0.95,
+                    opacity: locked ? 0.75 : hasMastery ? 0.95 : 0.6,
                     shadowColor: 'rgba(15, 23, 42, 0.12)',
                     shadowBlur: 8,
                 },
@@ -69,11 +70,11 @@ function registerCustomNode() {
 
             group.addShape('text', {
                 attrs: {
-                    text: `${cfg.meta?.code || cfg.id} · ${cfg.label || cfg.meta?.name || cfg.id}`,
+                    text: `${cfg.id} · ${cfg.label || cfg.meta?.name || cfg.meta?.code || cfg.id}`,
                     x: 0,
-                    y: -12,
-                    fontSize: 24,
-                    fontWeight: 700,
+                    y: -10,
+                    fontSize: 22,
+                    fontWeight: 800,
                     fill: locked ? '#94a3b8' : '#0f172a',
                     textAlign: 'center',
                     textBaseline: 'middle',
@@ -83,35 +84,21 @@ function registerCustomNode() {
 
             group.addShape('text', {
                 attrs: {
-                    text: cfg.meta?.name || cfg.label || cfg.id,
+                    text: cfg.meta?.code || cfg.id,
                     x: 0,
-                    y: 6,
-                    fontSize: 16,
+                    y: 10,
+                    fontSize: 15,
                     fontWeight: 700,
-                    fill: '#1e293b',
+                    fill: '#334155',
                     textAlign: 'center',
                     textBaseline: 'middle',
                 },
                 name: 'code-text',
             });
 
-            group.addShape('text', {
-                attrs: {
-                    text: `ID: ${cfg.id}`,
-                    x: 0,
-                    y: 22,
-                    fontSize: 13,
-                    fontWeight: 700,
-                    fill: '#0f172a',
-                    textAlign: 'center',
-                    textBaseline: 'middle',
-                },
-                name: 'id-text',
-            });
-
             const barWidth = cardWidth - 16;
             const barHeight = 8;
-            const barY = cardHeight / 2 - 8;
+            const barY = cardHeight / 2 - 4;
 
             group.addShape('text', {
                 attrs: {
@@ -276,63 +263,73 @@ function registerCustomNode() {
             }
         },
 
-        getPalette(mastery, locked) {
-            if (locked) {
+        getPalette(mastery, locked, hasMastery) {
+            if (!hasMastery) {
                 return {
-                    stroke: '#e2e8f0',
-                    lineWidth: 2.5,
+                    stroke: '#cbd5e1',
+                    lineWidth: 3,
                     ring: '#e2e8f0',
-                    ringWidth: 4,
+                    ringWidth: 6,
                     ringOpacity: 0.35,
-                    shadow: '',
-                    progress: '#cbd5e1',
+                    shadow: 'rgba(148, 163, 184, 0.15)',
+                    progress: '#e5e7eb',
+                    fill: '#f8fafc',
+                    cardFill: '#ffffff',
                 };
             }
 
             if (mastery >= 0.8) {
                 return {
                     stroke: '#d3b55f',
-                    lineWidth: 3,
+                    lineWidth: 4,
                     ring: '#f7e4ad',
-                    ringWidth: 7,
-                    ringOpacity: 0.4,
+                    ringWidth: 8,
+                    ringOpacity: 0.55,
                     shadow: 'rgba(212, 181, 95, 0.28)',
                     progress: '#22c55e',
+                    fill: '#fffbeb',
+                    cardFill: '#ffffff',
                 };
             }
 
             if (mastery >= 0.6) {
                 return {
                     stroke: '#34d399',
-                    lineWidth: 3,
+                    lineWidth: 3.5,
                     ring: '#bbf7d0',
-                    ringWidth: 6,
-                    ringOpacity: 0.28,
+                    ringWidth: 7,
+                    ringOpacity: 0.45,
                     shadow: 'rgba(52, 211, 153, 0.25)',
                     progress: '#34d399',
+                    fill: '#ecfdf3',
+                    cardFill: '#ffffff',
                 };
             }
 
             if (mastery >= 0.4) {
                 return {
                     stroke: '#f59e0b',
-                    lineWidth: 3,
+                    lineWidth: 3.5,
                     ring: '#fde68a',
-                    ringWidth: 6,
-                    ringOpacity: 0.28,
+                    ringWidth: 7,
+                    ringOpacity: 0.45,
                     shadow: 'rgba(245, 158, 11, 0.18)',
                     progress: '#f59e0b',
+                    fill: '#fffbeb',
+                    cardFill: '#fff7ed',
                 };
             }
 
             return {
                 stroke: '#f87171',
-                lineWidth: 2.5,
-                ring: '#fee2e2',
-                ringWidth: 6,
-                ringOpacity: 0.32,
-                shadow: 'rgba(248, 113, 113, 0.18)',
+                lineWidth: 3,
+                ring: '#fecaca',
+                ringWidth: 8,
+                ringOpacity: 0.55,
+                shadow: 'rgba(248, 113, 113, 0.25)',
                 progress: '#f87171',
+                fill: '#fef2f2',
+                cardFill: '#fff1f2',
             };
         },
 
@@ -349,6 +346,81 @@ function registerCustomNode() {
                 ['Z'],
             ];
         },
+
+        update(cfg, item) {
+            const group = item.getContainer();
+            const size = Array.isArray(cfg.size) ? cfg.size[0] : cfg.size || 110;
+            const mastery = Math.max(
+                0,
+                Math.min(1, cfg.meta?.mastery_level ?? 0)
+            );
+            const hasMastery = Boolean(cfg.meta?.has_mastery);
+            const percent = Math.round(mastery * 100);
+            const locked = cfg.locked || false;
+            const palette = this.getPalette(mastery, locked, hasMastery);
+
+            const ring = group.find((e) => e.get('name') === 'ring-shape');
+            if (ring) {
+                ring.attr({
+                    stroke: palette.ring,
+                    lineWidth: palette.ringWidth,
+                    opacity: palette.ringOpacity,
+                });
+            }
+
+            const hexagon = group.find((e) => e.get('name') === 'hexagon-shape');
+            if (hexagon) {
+                hexagon.attr({
+                    stroke: palette.stroke,
+                    lineWidth: palette.lineWidth,
+                    shadowColor: palette.shadow,
+                    shadowBlur: palette.shadow ? 12 : 0,
+                    opacity: locked ? 0.55 : hasMastery ? 1 : 0.6,
+                    cursor: locked ? 'not-allowed' : 'pointer',
+                    fill: palette.fill,
+                });
+            }
+
+            const title = group.find((e) => e.get('name') === 'title-text');
+            if (title) {
+                title.attr({
+                    text: `${cfg.id} · ${cfg.label || cfg.meta?.name || cfg.meta?.code || cfg.id}`,
+                    fill: locked ? '#94a3b8' : '#0f172a',
+                });
+            }
+
+            const codeText = group.find((e) => e.get('name') === 'code-text');
+            if (codeText) {
+                codeText.attr({
+                    text: cfg.meta?.code || cfg.id,
+                });
+            }
+
+            const progressFill = group.find((e) => e.get('name') === 'progress-fill');
+            const progressBg = group.find((e) => e.get('name') === 'progress-bg');
+            if (progressFill && progressBg) {
+                const cardWidth = size * 0.82;
+                const barWidth = cardWidth - 16;
+                progressFill.attr({
+                    width: (barWidth * percent) / 100,
+                    fill: palette.progress,
+                });
+            }
+
+            const card = group.find((e) => e.get('name') === 'card-shape');
+            if (card) {
+                card.attr({
+                    fill: palette.cardFill,
+                });
+            }
+
+            const percentText = group.find((e) => e.get('name') === 'percent-text');
+            if (percentText) {
+                percentText.attr({
+                    text: `${percent}%`,
+                });
+            }
+        },
     },
     'single-node'
 );

+ 270 - 23
resources/js/knowledge-mindmap-graph.js

@@ -4,7 +4,7 @@ class KnowledgeMindmapGraph {
         this.rawTree = null;
         this.treeData = null;
         this.relationEdges = [];
-        this.masteryData = options.masteryData || {};
+        this.masteryData = {};
         this.masteryCache = {};
         this.stats = { nodes: 0, extraEdges: 0 };
         this.containerId = options.containerId || 'knowledge-mindmap';
@@ -13,10 +13,14 @@ class KnowledgeMindmapGraph {
         this.livewireId = options.livewireId || null;
         this.highlightLowMastery = options.highlightLowMastery ?? true;
         this.emitSelection = options.emitSelection ?? true;
+        this.tooltipEl = null;
+        this.nodeIdSet = new Set();
         this.lockRules = options.lockRules || [
             { prerequisite: 'P04', target: 'P05', threshold: 0.6 },
             { prerequisite: 'P05', target: 'P06', threshold: 0.6 },
         ];
+
+        this.setMasteryData(options.masteryData || {});
     }
 
     async init() {
@@ -47,6 +51,11 @@ class KnowledgeMindmapGraph {
         this.masteryCache = {};
         this.treeData = this.transformNode(this.rawTree);
         this.relationEdges = this.normalizeEdges(rawEdges);
+        const flatIds = [];
+        this.collectIds(this.treeData, flatIds);
+        console.log('知识点总数', flatIds.length, '列表:', flatIds);
+        this.nodeIdSet = new Set(flatIds);
+        this.logMasteryCoverage();
         this.stats = {
             nodes: this.countNodes(this.treeData),
             extraEdges: this.relationEdges.length,
@@ -62,8 +71,9 @@ class KnowledgeMindmapGraph {
             node.label ||
             `node-${Math.random().toString(36).slice(2, 8)}`;
         const label = node.name || node.label || node.code || node.id || id;
+        const masteryInfo = this.masteryData[id] || null;
         const masteryLevel = this.getMasteryLevel(id);
-        const accuracy = this.masteryData[id]?.accuracy_rate || 0;
+        const accuracy = this.toNumber(masteryInfo?.accuracy_rate);
         const recommended = masteryLevel < 0.6;
 
         const model = {
@@ -77,8 +87,9 @@ class KnowledgeMindmapGraph {
                 name: label,
                 mastery_level: masteryLevel,
                 accuracy_rate: accuracy,
-                total_attempts: this.masteryData[id]?.total_attempts || 0,
-                mastery_info: this.masteryData[id] || null,
+                total_attempts: this.toNumber(masteryInfo?.total_attempts),
+                mastery_info: masteryInfo,
+                has_mastery: Boolean(masteryInfo),
                 recommended,
             },
             children: (node.children || [])
@@ -95,11 +106,7 @@ class KnowledgeMindmapGraph {
         }
 
         const remote = this.masteryData[id]?.mastery_level;
-        const randomFallback = Math.random() * 0.55 + 0.25;
-        const value =
-            typeof remote === 'number' && !Number.isNaN(remote)
-                ? remote
-                : randomFallback;
+        const value = this.normalizeMasteryLevel(remote);
 
         this.masteryCache[id] = value;
         return value;
@@ -212,6 +219,12 @@ class KnowledgeMindmapGraph {
         return normalized;
     }
 
+    collectIds(node, bucket) {
+        if (!node) return;
+        bucket.push(node.id);
+        (node.children || []).forEach((child) => this.collectIds(child, bucket));
+    }
+
     renderGraph() {
         const container = document.getElementById(this.containerId);
         if (!container) return;
@@ -281,11 +294,15 @@ class KnowledgeMindmapGraph {
         this.graph.data(this.treeData);
         this.graph.render();
         this.graph.fitView(12);
+        // 暴露实例便于调试
+        window.KnowledgeMindmapGraphInstance = this;
+        window.KnowledgeMindmapG6Graph = this.graph;
 
         this.drawRelationEdges();
         this.applyNodeStates();
         this.startEdgeFlows();
         this.focusOnLowestMastery();
+        this.repaintNodes();
     }
 
     setCollapsedState(nodeData, depth = 0) {
@@ -317,6 +334,11 @@ class KnowledgeMindmapGraph {
     drawRelationEdges() {
         if (!this.graph || !this.relationEdges.length) return;
         this.relationEdges.forEach((edge) => {
+            // 跳过缺失节点的边,避免 G6 报错导致后续渲染异常
+            if (!this.graph.findById(edge.source) || !this.graph.findById(edge.target)) {
+                console.warn('[mindmap] 跳过无效关联边', edge.id || edge.source + '-' + edge.target);
+                return;
+            }
             this.graph.addItem('edge', edge);
         });
     }
@@ -326,13 +348,16 @@ class KnowledgeMindmapGraph {
         this.graph.getNodes().forEach((node) => {
             const model = node.getModel();
             const mastery = model.meta?.mastery_level ?? 0;
+            const hasMastery =
+                model.meta?.has_mastery ?? Boolean(this.masteryData[model.id]);
+            this.graph.setItemState(node, 'dimmed', !hasMastery);
             if (model.locked) {
                 this.graph.setItemState(node, 'locked', true);
             }
-            if (mastery < 0.4 && this.highlightLowMastery) {
+            if (hasMastery && mastery < 0.4 && this.highlightLowMastery) {
                 this.graph.setItemState(node, 'weak', true);
             }
-            if (mastery >= 0.8 && !model.locked) {
+            if (hasMastery && mastery >= 0.8 && !model.locked) {
                 this.playHalo(node);
             }
         });
@@ -415,6 +440,7 @@ class KnowledgeMindmapGraph {
             if (!item || item.getModel().locked) return;
             this.graph.setItemState(item, 'hover', true);
             this.highlightNeighbors(item.getModel().id);
+            this.showTooltip(evt, item.getModel());
         });
 
         this.graph.on('node:mouseleave', (evt) => {
@@ -422,6 +448,7 @@ class KnowledgeMindmapGraph {
             if (!item) return;
             this.graph.setItemState(item, 'hover', false);
             this.clearNeighborHighlight();
+            this.hideTooltip();
         });
 
         this.graph.on('node:click', (evt) => {
@@ -440,6 +467,7 @@ class KnowledgeMindmapGraph {
             if (this.emitSelection) {
                 this.notifySelection(model);
             }
+            this.showTooltip(evt, model);
         });
 
         this.graph.on('canvas:click', () => {
@@ -447,6 +475,7 @@ class KnowledgeMindmapGraph {
                 this.graph.clearItemStates(node);
             });
             this.clearNeighborHighlight();
+            this.hideTooltip();
         });
     }
 
@@ -511,10 +540,67 @@ class KnowledgeMindmapGraph {
         }
     }
 
+    ensureTooltipEl() {
+        if (this.tooltipEl) return this.tooltipEl;
+        const div = document.createElement('div');
+        div.style.position = 'fixed';
+        div.style.zIndex = '9999';
+        div.style.pointerEvents = 'none';
+        div.style.padding = '10px 12px';
+        div.style.background = 'rgba(15,23,42,0.95)';
+        div.style.color = '#e2e8f0';
+        div.style.borderRadius = '10px';
+        div.style.boxShadow = '0 10px 30px rgba(0,0,0,0.18)';
+        div.style.fontSize = '12px';
+        div.style.lineHeight = '1.4';
+        document.body.appendChild(div);
+        this.tooltipEl = div;
+        return div;
+    }
+
+    showTooltip(evt, model) {
+        const tip = this.ensureTooltipEl();
+        const mastery = (model.meta?.mastery_level ?? 0) * 100;
+        const attempts = model.meta?.total_attempts ?? 0;
+        const recommended = model.meta?.recommended ? '是' : '否';
+        const locked = model.locked ? '是' : '否';
+        tip.innerHTML = `
+            <div style="font-weight:700;font-size:13px;">${model.id} · ${model.meta?.name || model.label}</div>
+            <div>掌握度:${mastery.toFixed(1)}%</div>
+            <div>推荐练习:${recommended}</div>
+            <div>尝试次数:${attempts}</div>
+            <div>锁定:${locked}</div>
+        `;
+        const x =
+            evt?.clientX ??
+            evt?.canvasX ??
+            evt?.x ??
+            evt?.event?.clientX ??
+            0;
+        const y =
+            evt?.clientY ??
+            evt?.canvasY ??
+            evt?.y ??
+            evt?.event?.clientY ??
+            0;
+        tip.style.left = `${x + 16}px`;
+        tip.style.top = `${y + 12}px`;
+        tip.style.opacity = '1';
+    }
+
+    hideTooltip() {
+        if (!this.tooltipEl) return;
+        this.tooltipEl.style.opacity = '0';
+    }
+
     setupLivewireListeners() {
         ['mastery-updated', 'mindmap-mastery-updated'].forEach((event) => {
             window.addEventListener(event, (detailEvent) => {
-                this.masteryData = detailEvent.detail?.data || {};
+                const payload =
+                    detailEvent.detail?.data ??
+                    detailEvent.detail ??
+                    {};
+                this.setMasteryData(payload);
                 this.refreshGraph();
             });
         });
@@ -560,19 +646,30 @@ class KnowledgeMindmapGraph {
 
         this.masteryCache = {};
         this.treeData = this.transformNode(this.rawTree);
+        const flatIds = [];
+        this.collectIds(this.treeData, flatIds);
+        this.nodeIdSet = new Set(flatIds);
+        this.logMasteryCoverage();
         this.applyUnlockRules(this.treeData);
         this.applyInitialCollapse(this.treeData);
         this.expandForMastery();
 
-        this.graph.changeData(this.treeData);
+        // 强制全量重绘,确保 meta/颜色更新
+        this.graph.clear();
+        this.graph.data(this.treeData);
         this.graph.render();
+        this.repaintNodes();
         this.graph.fitView(12);
+        // 暴露实例便于调试(刷新后仍可用)
+        window.KnowledgeMindmapGraphInstance = this;
+        window.KnowledgeMindmapG6Graph = this.graph;
 
         this.clearRelationEdges();
         this.drawRelationEdges();
         this.applyNodeStates();
         this.startEdgeFlows();
         this.focusOnLowestMastery();
+        this.graph.paint();
     }
 
     forceCollapseNodes() {
@@ -581,12 +678,9 @@ class KnowledgeMindmapGraph {
 
     forceCollapse() {
         if (!this.graph) {
-            console.log('等待graph初始化...');
             setTimeout(() => this.forceCollapse(), 200);
             return;
         }
-
-        console.log('开始强制折叠节点...');
         const nodes = this.graph.getNodes();
         let collapsedCount = 0;
 
@@ -605,15 +699,12 @@ class KnowledgeMindmapGraph {
                 try {
                     this.graph.collapseItem(node);
                     collapsedCount++;
-                    console.log('成功折叠节点:', model.id);
                 } catch (error) {
                     console.error('折叠节点失败:', model.id, error);
                 }
             }
         });
 
-        console.log(`共折叠了 ${collapsedCount} 个节点`);
-
         // 重新渲染和适配视图
         this.graph.refresh();
         this.graph.fitView(12);
@@ -626,19 +717,175 @@ class KnowledgeMindmapGraph {
         this.graph.changeSize(container.clientWidth, container.clientHeight);
         this.graph.fitView(12);
     }
+
+    setMasteryData(payload = {}) {
+        this.masteryData = this.normalizeMasteryPayload(payload);
+        this.masteryCache = {};
+        this.logMasteryCoverage();
+    }
+
+    normalizeMasteryPayload(payload = {}) {
+        // 支持 {masteries: []} / {data: []} / {Target: {KP: {...}}} / 直接的键值对
+        const map = {};
+        const addEntry = (entry, fallbackKey = null) => {
+            if (!entry || typeof entry !== 'object') return;
+            const code =
+                entry.kp_code ||
+                entry.code ||
+                entry.id ||
+                fallbackKey;
+            if (!code) return;
+            const masteryLevel = this.normalizeMasteryLevel(
+                entry.mastery_level
+            );
+            map[code] = {
+                ...entry,
+                kp_code: code,
+                mastery_level: masteryLevel,
+            };
+        };
+
+        const normalizeCandidate = (candidate) => {
+            if (!candidate) return;
+            if (Array.isArray(candidate)) {
+                candidate.forEach((item) => addEntry(item));
+                return;
+            }
+            if (typeof candidate === 'object') {
+                Object.entries(candidate).forEach(([key, value]) =>
+                    addEntry(value, key)
+                );
+            }
+        };
+
+        normalizeCandidate(payload.masteries);
+        normalizeCandidate(payload.data);
+        normalizeCandidate(payload.Target);
+        // 允许直接传入键值对
+        normalizeCandidate(payload);
+
+        return map;
+    }
+
+    normalizeMasteryLevel(value) {
+        const num = this.toNumber(value);
+        if (!Number.isFinite(num)) return 0;
+        return Math.max(0, Math.min(1, num));
+    }
+
+    toNumber(value) {
+        const num = Number(value);
+        return Number.isFinite(num) ? num : 0;
+    }
+
+    repaintNodes() {
+        if (!this.graph || !this.treeData) return;
+        const nodeMap = {};
+        const walk = (node) => {
+            if (!node) return;
+            nodeMap[node.id] = node;
+            (node.children || []).forEach((child) => walk(child));
+        };
+        walk(this.treeData);
+
+        let updatedCount = 0;
+        let firstUpdated = null;
+        this.graph.getNodes().forEach((node) => {
+            const id = node.getModel().id;
+            const freshModel = nodeMap[id];
+            if (!freshModel) return;
+            // 确保 meta 中 has_mastery 等字段存在
+            const masteryInfo =
+                this.masteryData[id] ||
+                this.masteryData[freshModel.meta?.code] ||
+                null;
+            freshModel.meta = {
+                ...freshModel.meta,
+                mastery_level: this.normalizeMasteryLevel(
+                    masteryInfo?.mastery_level ?? freshModel.meta?.mastery_level
+                ),
+                has_mastery: Boolean(masteryInfo),
+                mastery_info: masteryInfo,
+                total_attempts: this.toNumber(
+                    masteryInfo?.total_attempts ?? freshModel.meta?.total_attempts
+                ),
+            };
+            this.graph.updateItem(node, freshModel);
+            this.graph.refreshItem?.(node);
+            this.applyDirectStyles(node, freshModel.meta.mastery_level, freshModel.meta.has_mastery);
+            // 直接打上选中状态,避免样式被缓存
+            if (freshModel.meta.has_mastery) {
+                this.graph.setItemState(node, 'selected', true);
+            } else {
+                this.graph.clearItemStates(node, ['selected']);
+            }
+            updatedCount += 1;
+            if (!firstUpdated) {
+                firstUpdated = {
+                    id,
+                    mastery: freshModel.meta?.mastery_level,
+                    has_mastery: freshModel.meta?.has_mastery,
+                };
+            }
+        });
+
+        // 强制重绘,避免样式缓存
+        this.graph.paint();
+    }
+
+    applyDirectStyles(node, mastery, hasMastery) {
+        // 直接对关键 shape 赋色,避免 G6 缓存导致颜色不变
+        const keyShape = node.getKeyShape?.();
+        const group = node.getContainer?.();
+        if (!keyShape || !group) return;
+
+        const palette = (() => {
+            if (!hasMastery) {
+                return { fill: '#f8fafc', stroke: '#cbd5e1', card: '#ffffff' };
+            }
+            if (mastery >= 0.8) return { fill: '#fffbeb', stroke: '#d3b55f', card: '#ffffff' };
+            if (mastery >= 0.6) return { fill: '#ecfdf3', stroke: '#34d399', card: '#ffffff' };
+            if (mastery >= 0.4) return { fill: '#fffbeb', stroke: '#f59e0b', card: '#fff7ed' };
+            return { fill: '#fef2f2', stroke: '#f87171', card: '#fff1f2' };
+        })();
+
+        keyShape.attr({
+            fill: palette.fill,
+            stroke: palette.stroke,
+        });
+
+        const card = group.find((e) => e.get?.('name') === 'card-shape');
+        if (card) {
+            card.attr({ fill: palette.card });
+        }
+        // 额外高亮轮廓,确保视觉可见
+        this.graph.setItemState(node, 'selected', hasMastery);
+        this.graph.refreshItem?.(node);
+    }
+
+    logMasteryCoverage() {
+        if (!this.nodeIdSet || !this.nodeIdSet.size) return;
+        const masteries = Object.keys(this.masteryData || {});
+        if (!masteries.length) return;
+        const missing = masteries.filter((id) => !this.nodeIdSet.has(id));
+        if (missing.length) {
+            console.warn(
+                '掌握度返回的知识点未在图谱中找到:',
+                missing.slice(0, 20),
+                missing.length > 20 ? `...共${missing.length}条` : ''
+            );
+        }
+    }
+
 }
 
 // 定义KnowledgeMindmapGraph类,确保G6已加载
 function defineGraphClass() {
     if (typeof window.G6 === 'undefined') {
-        console.log('G6库尚未加载,100ms后重试定义Graph类...');
         setTimeout(defineGraphClass, 100);
         return;
     }
-
-    console.log('G6库已加载,定义KnowledgeMindmapGraph类...');
     window.KnowledgeMindmapGraph = KnowledgeMindmapGraph;
-    console.log('KnowledgeMindmapGraph类定义完成');
 }
 
 // 启动定义流程