Преглед изворни кода

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

yemeishu пре 1 месец
родитељ
комит
60c318f7f8

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

@@ -2,12 +2,15 @@
 
 namespace App\Filament\Pages;
 
+use App\Filament\Traits\HandlesMindmapDetails;
 use BackedEnum;
 use Filament\Pages\Page;
 use UnitEnum;
 
 class KnowledgeMindmap extends Page
 {
+    use HandlesMindmapDetails;
+
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-share';
 
     protected static string|UnitEnum|null $navigationGroup = '资源';
@@ -28,6 +31,9 @@ class KnowledgeMindmap extends Page
     public $selectedStudentId = '';
     public $selectedStudentName = '';
     public $masteryData = [];
+    public $drawerOpen = false;
+    public $selectedNode = null;
+    public $nodeDetails = [];
 
     public function mount(): void
     {
@@ -122,4 +128,25 @@ class KnowledgeMindmap extends Page
             $this->dispatch('mastery-updated', data: []);
         }
     }
+
+    public function openDrawer($nodeId)
+    {
+        $this->selectedNode = $nodeId;
+        $this->nodeDetails = $this->getNodeDetails($nodeId, $this->masteryData);
+        $this->drawerOpen = true;
+    }
+
+    public function closeDrawer()
+    {
+        $this->drawerOpen = false;
+        $this->selectedNode = null;
+        $this->nodeDetails = [];
+    }
+
+    // Alias for shared mindmap drawer
+    public function openMindmapDrawer(string $nodeId): void
+    {
+        $this->openDrawer($nodeId);
+    }
+
 }

+ 56 - 0
app/Filament/Pages/StudentDashboard.php

@@ -2,6 +2,7 @@
 
 namespace App\Filament\Pages;
 
+use App\Filament\Traits\HandlesMindmapDetails;
 use App\Models\Student;
 use App\Models\Teacher;
 use App\Services\LearningAnalyticsService;
@@ -19,6 +20,8 @@ use Livewire\Attributes\Computed;
 
 class StudentDashboard extends Page
 {
+    use HandlesMindmapDetails;
+
     use \Filament\Pages\Concerns\InteractsWithFormActions;
 
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
@@ -38,6 +41,10 @@ class StudentDashboard extends Page
     public array $dashboardData = [];
     public bool $isLoading = false;
     public string $errorMessage = '';
+    public array $mindmapMasteryData = [];
+    public bool $mindmapDrawerOpen = false;
+    public array $mindmapNodeDetails = [];
+    public ?string $mindmapSelectedNode = null;
     // teachers 和 students 现在是 Computed 属性,不再需要声明
 
     public function mount(Request $request): void
@@ -148,6 +155,9 @@ class StudentDashboard extends Page
     {
         if (!empty($this->studentId)) {
             $this->loadDashboardData();
+        } else {
+            $this->mindmapMasteryData = [];
+            $this->dispatch('mastery-updated', data: []);
         }
     }
 
@@ -214,6 +224,11 @@ class StudentDashboard extends Page
                 ],
             ];
 
+            $this->mindmapMasteryData = $this->buildMasteryMap(
+                $this->dashboardData['mastery']['list'] ?? []
+            );
+            $this->dispatch('mastery-updated', data: $this->mindmapMasteryData);
+
             Log::info('仪表板数据加载完成', [
                 'student_id' => $this->studentId,
                 'dashboard_data_keys' => array_keys($this->dashboardData)
@@ -225,6 +240,8 @@ class StudentDashboard extends Page
                 'student_id' => $this->studentId,
                 'error' => $e->getMessage()
             ]);
+            $this->mindmapMasteryData = [];
+            $this->dispatch('mastery-updated', data: []);
         } finally {
             $this->isLoading = false;
         }
@@ -294,6 +311,45 @@ class StudentDashboard extends Page
         }
     }
 
+    protected function buildMasteryMap(array $list): array
+    {
+        $map = [];
+        $items = $list['data'] ?? $list['masteries'] ?? $list;
+
+        if (!is_array($items)) {
+            return $map;
+        }
+
+        foreach ($items as $item) {
+            if (!is_array($item)) {
+                continue;
+            }
+
+            $code = $item['kp_code'] ?? $item['code'] ?? null;
+            if (!$code) {
+                continue;
+            }
+
+            $map[$code] = $item;
+        }
+
+        return $map;
+    }
+
+    public function openMindmapDrawer(string $nodeId): void
+    {
+        $this->mindmapSelectedNode = $nodeId;
+        $this->mindmapNodeDetails = $this->getNodeDetails($nodeId, $this->mindmapMasteryData);
+        $this->mindmapDrawerOpen = true;
+    }
+
+    public function closeMindmapDrawer(): void
+    {
+        $this->mindmapDrawerOpen = false;
+        $this->mindmapSelectedNode = null;
+        $this->mindmapNodeDetails = [];
+    }
+
 
     /**
      * 监听TeacherStudentSelector组件的老师变化事件

+ 178 - 0
app/Filament/Traits/HandlesMindmapDetails.php

@@ -0,0 +1,178 @@
+<?php
+
+namespace App\Filament\Traits;
+
+use Illuminate\Support\Facades\Log;
+
+trait HandlesMindmapDetails
+{
+    protected function getNodeDetails(string $nodeId, ?array $masteryData = null): array
+    {
+        $treePath = public_path('data/tree.json');
+        $edgesPath = public_path('data/edges.json');
+
+        if (!file_exists($treePath)) {
+            return ['error' => 'Tree data not found'];
+        }
+
+        $tree = json_decode(file_get_contents($treePath), true);
+        $edges = file_exists($edgesPath) ? json_decode(file_get_contents($edgesPath), true) : [];
+
+        $node = $this->findNodeInTree($tree, $nodeId);
+        if (!$node) {
+            return ['error' => 'Node not found'];
+        }
+
+        $masteryMap = $masteryData ?? $this->getActiveMasteryData();
+        $masteryInfo = $masteryMap[$nodeId] ?? null;
+        $masteryLevel = $masteryInfo ? ($masteryInfo['mastery_level'] ?? 0) : 0;
+        $totalAttempts = $masteryInfo ? ($masteryInfo['total_attempts'] ?? 0) : 0;
+        $accuracy = $masteryInfo ? ($masteryInfo['accuracy_rate'] ?? 0) : 0;
+
+        $prerequisites = [];
+        $successors = [];
+
+        foreach ($edges as $edge) {
+            if (($edge['target'] ?? null) === $nodeId && ($edge['type'] ?? '') === 'prerequisite') {
+                $prereqNode = $this->findNodeInTree($tree, $edge['source']);
+                if ($prereqNode) {
+                    $prerequisites[] = [
+                        'id' => $edge['source'],
+                        'name' => $prereqNode['name'] ?? $prereqNode['label'] ?? $edge['source'],
+                        'mastery' => $masteryMap[$edge['source']]['mastery_level'] ?? 0,
+                    ];
+                }
+            }
+
+            if (($edge['source'] ?? null) === $nodeId && ($edge['type'] ?? '') === 'successor') {
+                $succNode = $this->findNodeInTree($tree, $edge['target']);
+                if ($succNode) {
+                    $successors[] = [
+                        'id' => $edge['target'],
+                        'name' => $succNode['name'] ?? $succNode['label'] ?? $edge['target'],
+                        'mastery' => $masteryMap[$edge['target']]['mastery_level'] ?? 0,
+                    ];
+                }
+            }
+        }
+
+        $recommendations = $this->getRecommendations($nodeId);
+
+        return [
+            'id' => $nodeId,
+            'name' => $node['name'] ?? $node['label'] ?? $nodeId,
+            'code' => $node['code'] ?? $nodeId,
+            'mastery_level' => $masteryLevel,
+            'total_attempts' => $totalAttempts,
+            'accuracy_rate' => $accuracy,
+            'error_rate' => $totalAttempts > 0 ? (1 - $accuracy) : 0,
+            'prerequisites' => $prerequisites,
+            'successors' => $successors,
+            'recommendations' => $recommendations,
+            'skills' => $node['skills'] ?? [],
+        ];
+    }
+
+    protected function findNodeInTree(array $node, string $targetId): ?array
+    {
+        $nodeId = $node['code'] ?? $node['id'] ?? $node['label'] ?? null;
+        if ($nodeId === $targetId) {
+            return $node;
+        }
+
+        if (isset($node['children'])) {
+            foreach ($node['children'] as $child) {
+                $found = $this->findNodeInTree($child, $targetId);
+                if ($found) {
+                    return $found;
+                }
+            }
+        }
+
+        return null;
+    }
+
+    protected function getRecommendations(string $nodeId): array
+    {
+        try {
+            $questionBankService = app(\App\Services\QuestionBankService::class);
+            $response = $questionBankService->getQuestionsByKpCode($nodeId, 3);
+
+            if (empty($response['data'])) {
+                return $this->getMockRecommendations($nodeId);
+            }
+
+            $recommendations = [];
+            foreach ($response['data'] as $question) {
+                $difficultyMap = [
+                    0.3 => '简单',
+                    0.6 => '中等',
+                    0.9 => '困难',
+                ];
+                $difficulty = $difficultyMap[$question['difficulty'] ?? 0.6] ?? '中等';
+
+                $typeMap = [
+                    'choice' => '选择题',
+                    'fill' => '填空题',
+                    'answer' => '解答题',
+                ];
+                $type = $typeMap[$question['question_type'] ?? 'answer'] ?? '解答题';
+
+                $stem = $question['stem'] ?? $question['content'] ?? '练习题';
+
+                $recommendations[] = [
+                    'id' => $question['id'] ?? $question['question_code'] ?? uniqid(),
+                    'title' => mb_strimwidth($stem, 0, 50, '...'),
+                    'difficulty' => $difficulty,
+                    'type' => $type,
+                ];
+            }
+
+            return $recommendations;
+        } catch (\Exception $e) {
+            Log::error('Failed to fetch recommendations', [
+                'node_id' => $nodeId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return $this->getMockRecommendations($nodeId);
+        }
+    }
+
+    protected function getMockRecommendations(string $nodeId): array
+    {
+        return [
+            [
+                'id' => 1,
+                'title' => '基础练习:' . $nodeId,
+                'difficulty' => '简单',
+                'type' => '选择题',
+            ],
+            [
+                'id' => 2,
+                'title' => '进阶练习:' . $nodeId,
+                'difficulty' => '中等',
+                'type' => '填空题',
+            ],
+            [
+                'id' => 3,
+                'title' => '综合应用:' . $nodeId,
+                'difficulty' => '困难',
+                'type' => '解答题',
+            ],
+        ];
+    }
+
+    protected function getActiveMasteryData(): array
+    {
+        if (property_exists($this, 'masteryData')) {
+            return $this->masteryData ?? [];
+        }
+
+        if (property_exists($this, 'mindmapMasteryData')) {
+            return $this->mindmapMasteryData ?? [];
+        }
+
+        return [];
+    }
+}

+ 344 - 0
public/js/g6-custom-node.js

@@ -0,0 +1,344 @@
+G6.registerNode(
+    'hexagon-card',
+    {
+        draw(cfg, group) {
+            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 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 ring = group.addShape('path', {
+                attrs: {
+                    path: hexagonPath,
+                    stroke: palette.ring,
+                    lineWidth: palette.ringWidth,
+                    opacity: palette.ringOpacity,
+                },
+                name: 'ring-shape',
+            });
+
+            const hexagon = group.addShape('path', {
+                attrs: {
+                    path: hexagonPath,
+                    fill: '#ffffff',
+                    stroke: palette.stroke,
+                    lineWidth: palette.lineWidth,
+                    shadowColor: palette.shadow,
+                    shadowBlur: palette.shadow ? 12 : 0,
+                    opacity: locked ? 0.55 : 1,
+                    cursor: locked ? 'not-allowed' : 'pointer',
+                },
+                name: 'hexagon-shape',
+                draggable: true,
+            });
+
+            const cardWidth = size * 0.82;
+            const cardHeight = size * 0.64;
+            group.addShape('rect', {
+                attrs: {
+                    x: -cardWidth / 2,
+                    y: -cardHeight / 2,
+                    width: cardWidth,
+                    height: cardHeight,
+                    fill: '#ffffff',
+                    radius: 6,
+                    opacity: locked ? 0.75 : 0.95,
+                    shadowColor: 'rgba(15, 23, 42, 0.12)',
+                    shadowBlur: 8,
+                },
+                name: 'card-shape',
+            });
+
+            group.addShape('text', {
+                attrs: {
+                    text: `${cfg.meta?.code || cfg.id} · ${cfg.label || cfg.meta?.name || cfg.id}`,
+                    x: 0,
+                    y: -12,
+                    fontSize: 24,
+                    fontWeight: 700,
+                    fill: locked ? '#94a3b8' : '#0f172a',
+                    textAlign: 'center',
+                    textBaseline: 'middle',
+                },
+                name: 'title-text',
+            });
+
+            group.addShape('text', {
+                attrs: {
+                    text: cfg.meta?.name || cfg.label || cfg.id,
+                    x: 0,
+                    y: 6,
+                    fontSize: 16,
+                    fontWeight: 700,
+                    fill: '#1e293b',
+                    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;
+
+            group.addShape('text', {
+                attrs: {
+                    text: '掌握度',
+                    x: -barWidth / 2,
+                    y: barY - 10,
+                    fontSize: 12,
+                    fontWeight: 700,
+                    fill: '#334155',
+                    textAlign: 'left',
+                    textBaseline: 'middle',
+                },
+                name: 'mastery-label',
+            });
+
+            group.addShape('rect', {
+                attrs: {
+                    x: -barWidth / 2,
+                    y: barY,
+                    width: barWidth,
+                    height: barHeight,
+                    fill: '#e5e7eb',
+                    radius: 3,
+                },
+                name: 'progress-bg',
+            });
+
+            group.addShape('rect', {
+                attrs: {
+                    x: -barWidth / 2,
+                    y: barY,
+                    width: (barWidth * percent) / 100,
+                    height: barHeight,
+                    fill: palette.progress,
+                    radius: 3,
+                },
+                name: 'progress-fill',
+            });
+
+            group.addShape('text', {
+                attrs: {
+                    text: `${percent}%`,
+                    x: 0,
+                    y: barY + barHeight / 2,
+                    fontSize: 12,
+                    fontWeight: 800,
+                    fill: '#0f172a',
+                    textAlign: 'center',
+                    textBaseline: 'middle',
+                },
+                name: 'percent-text',
+            });
+
+            if (recommended && !locked) {
+                group.addShape('circle', {
+                    attrs: {
+                        x: cardWidth / 2 - 8,
+                        y: -cardHeight / 2 + 10,
+                        r: 8,
+                        fill: 'rgba(251, 191, 36, 0.15)',
+                        stroke: '#f59e0b',
+                    },
+                    name: 'recommend-pill',
+                });
+                group.addShape('text', {
+                    attrs: {
+                        text: '荐',
+                        x: cardWidth / 2 - 8,
+                        y: -cardHeight / 2 + 10,
+                        fontSize: 12,
+                        fontWeight: 700,
+                        fill: '#b45309',
+                        textAlign: 'center',
+                        textBaseline: 'middle',
+                    },
+                    name: 'recommend-text',
+                });
+            }
+
+            if (locked) {
+                group.addShape('rect', {
+                    attrs: {
+                        x: -cardWidth / 2,
+                        y: -cardHeight / 2,
+                        width: cardWidth,
+                        height: cardHeight,
+                        fill: '#ffffff',
+                        opacity: 0.4,
+                    },
+                    name: 'lock-mask',
+                });
+                group.addShape('text', {
+                    attrs: {
+                        text: '🔒',
+                        x: 0,
+                        y: 0,
+                        fontSize: 16,
+                        textAlign: 'center',
+                        textBaseline: 'middle',
+                    },
+                    name: 'lock-icon',
+                });
+            }
+
+            if (percent < 40) {
+                group.addShape('rect', {
+                    attrs: {
+                        x: -cardWidth / 2,
+                        y: -cardHeight / 2,
+                        width: cardWidth,
+                        height: cardHeight,
+                        fill: '#ffffff',
+                        opacity: 0.15,
+                    },
+                    name: 'weak-mask',
+                });
+            }
+
+            return hexagon;
+        },
+
+        setState(name, value, item) {
+            const group = item.getContainer();
+            const hexagon = group.find((e) => e.get('name') === 'hexagon-shape');
+            if (!hexagon) return;
+
+            if (name === 'hover') {
+                hexagon.animate(
+                    { lineWidth: value ? 4 : 3 },
+                    { duration: 180 }
+                );
+                group.animate(
+                    (ratio) => ({
+                        matrix: [
+                            1 + (value ? ratio * 0.05 : -ratio * 0.05),
+                            0,
+                            0,
+                            0,
+                            1 + (value ? ratio * 0.05 : -ratio * 0.05),
+                            0,
+                            0,
+                            0,
+                            1,
+                        ],
+                    }),
+                    { duration: 200 }
+                );
+            }
+
+            if (name === 'selected') {
+                hexagon.attr('shadowColor', value ? '#fb923c' : undefined);
+                hexagon.attr('shadowBlur', value ? 16 : 0);
+            }
+
+            if (name === 'weak') {
+                hexagon.attr('opacity', value ? 0.75 : 1);
+            }
+
+            if (name === 'locked') {
+                hexagon.attr('opacity', value ? 0.5 : 1);
+                hexagon.attr('cursor', value ? 'not-allowed' : 'pointer');
+            }
+        },
+
+        getPalette(mastery, locked) {
+            if (locked) {
+                return {
+                    stroke: '#e2e8f0',
+                    lineWidth: 2.5,
+                    ring: '#e2e8f0',
+                    ringWidth: 4,
+                    ringOpacity: 0.35,
+                    shadow: '',
+                    progress: '#cbd5e1',
+                };
+            }
+
+            if (mastery >= 0.8) {
+                return {
+                    stroke: '#d3b55f',
+                    lineWidth: 3,
+                    ring: '#f7e4ad',
+                    ringWidth: 7,
+                    ringOpacity: 0.4,
+                    shadow: 'rgba(212, 181, 95, 0.28)',
+                    progress: '#22c55e',
+                };
+            }
+
+            if (mastery >= 0.6) {
+                return {
+                    stroke: '#34d399',
+                    lineWidth: 3,
+                    ring: '#bbf7d0',
+                    ringWidth: 6,
+                    ringOpacity: 0.28,
+                    shadow: 'rgba(52, 211, 153, 0.25)',
+                    progress: '#34d399',
+                };
+            }
+
+            if (mastery >= 0.4) {
+                return {
+                    stroke: '#f59e0b',
+                    lineWidth: 3,
+                    ring: '#fde68a',
+                    ringWidth: 6,
+                    ringOpacity: 0.28,
+                    shadow: 'rgba(245, 158, 11, 0.18)',
+                    progress: '#f59e0b',
+                };
+            }
+
+            return {
+                stroke: '#f87171',
+                lineWidth: 2.5,
+                ring: '#fee2e2',
+                ringWidth: 6,
+                ringOpacity: 0.32,
+                shadow: 'rgba(248, 113, 113, 0.18)',
+                progress: '#f87171',
+            };
+        },
+
+        getHexagonPath(size) {
+            const r = size / 2;
+            const points = [];
+            for (let i = 0; i < 6; i++) {
+                const angle = (Math.PI / 3) * i - Math.PI / 2;
+                points.push([r * Math.cos(angle), r * Math.sin(angle)]);
+            }
+            return [
+                ['M', points[0][0], points[0][1]],
+                ...points.slice(1).map((p) => ['L', p[0], p[1]]),
+                ['Z'],
+            ];
+        },
+    },
+    'single-node'
+);

+ 565 - 0
public/js/knowledge-mindmap-graph.js

@@ -0,0 +1,565 @@
+class KnowledgeMindmapGraph {
+    constructor(options = {}) {
+        this.graph = null;
+        this.rawTree = null;
+        this.treeData = null;
+        this.relationEdges = [];
+        this.masteryData = options.masteryData || {};
+        this.masteryCache = {};
+        this.stats = { nodes: 0, extraEdges: 0 };
+        this.containerId = options.containerId || 'knowledge-mindmap';
+        this.livewireMethod = options.livewireMethod || 'openDrawer';
+        this.onNodeSelect = options.onNodeSelect || null;
+        this.livewireId = options.livewireId || null;
+        this.highlightLowMastery = options.highlightLowMastery ?? true;
+        this.emitSelection = options.emitSelection ?? true;
+        this.lockRules = options.lockRules || [
+            { prerequisite: 'P04', target: 'P05', threshold: 0.6 },
+            { prerequisite: 'P05', target: 'P06', threshold: 0.6 },
+        ];
+    }
+
+    async init() {
+        try {
+            await this.loadData();
+            this.applyUnlockRules(this.treeData);
+            this.applyInitialCollapse(this.treeData);
+            this.expandForMastery();
+            this.renderGraph();
+            this.bindEvents();
+            this.setupLivewireListeners();
+            window.addEventListener('resize', () => this.resizeGraph());
+        } catch (error) {
+            console.error('初始化思维导图失败', error);
+        }
+    }
+
+    async loadData() {
+        const [treeResp, edgesResp] = await Promise.all([
+            fetch('/data/tree.json'),
+            fetch('/data/edges.json'),
+        ]);
+
+        this.rawTree = await treeResp.json();
+        const edges = await edgesResp.json();
+        const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
+
+        this.masteryCache = {};
+        this.treeData = this.transformNode(this.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 || id;
+        const masteryLevel = this.getMasteryLevel(id);
+        const accuracy = this.masteryData[id]?.accuracy_rate || 0;
+        const recommended = masteryLevel < 0.6;
+
+        const model = {
+            id,
+            label,
+            depth,
+            locked: false,
+            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,
+                recommended,
+            },
+            children: (node.children || [])
+                .map((child) => this.transformNode(child, depth + 1))
+                .filter(Boolean),
+        };
+
+        return model;
+    }
+
+    getMasteryLevel(id) {
+        if (this.masteryCache[id] !== undefined) {
+            return this.masteryCache[id];
+        }
+
+        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;
+
+        this.masteryCache[id] = value;
+        return value;
+    }
+
+    applyUnlockRules(node) {
+        if (!node) return;
+
+        const rule = this.lockRules.find((item) => item.target === node.id);
+        const prereqMastery = rule
+            ? this.masteryCache[rule.prerequisite] ?? 0
+            : 1;
+
+        const lockedByRule = rule ? prereqMastery < rule.threshold : false;
+
+        node.locked = lockedByRule;
+        if (node.locked) {
+            node.meta.lock_reason = lockedByRule
+                ? `需先掌握前置知识点:${rule.prerequisite}`
+                : '需先掌握前置知识点';
+        }
+
+        node.children.forEach((child) => this.applyUnlockRules(child));
+    }
+
+    applyInitialCollapse(node, depth = 0) {
+        if (!node) return;
+        if (depth > 0 && node.children.length > 0) {
+            node.collapsed = true;
+        }
+        node.children.forEach((child) =>
+            this.applyInitialCollapse(child, depth + 1)
+        );
+    }
+
+    expandForMastery() {
+        if (!this.treeData) return;
+        const masteryKeys = new Set(Object.keys(this.masteryData || {}));
+        if (!masteryKeys.size) return;
+        this.expandNodesForMastery(this.treeData, masteryKeys);
+    }
+
+    expandNodesForMastery(node, masteryKeys) {
+        if (!node) return false;
+        const hasMastery = masteryKeys.has(node.id);
+        let childHas = false;
+        (node.children || []).forEach((child) => {
+            if (this.expandNodesForMastery(child, masteryKeys)) {
+                childHas = true;
+            }
+        });
+
+        if (hasMastery || childHas) {
+            node.collapsed = false;
+        }
+
+        return hasMastery || childHas;
+    }
+
+    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 = [];
+        const styleMap = {
+            prerequisite: { stroke: '#60a5fa', lineDash: [10, 8], lineWidth: 3 },
+            successor: { stroke: '#7dd3fc', lineWidth: 3 },
+            crosslink: { stroke: '#fb923c', lineDash: [8, 6], lineWidth: 2.5 },
+            sibling: { stroke: '#94a3b8', lineDash: [6, 6], lineWidth: 2.5 },
+        };
+
+        (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 category = edge.type || 'successor';
+            const renderType =
+                category === 'successor' ? 'cubic-horizontal' : 'quadratic';
+            const style = styleMap[category] || {
+                stroke: '#cbd5e1',
+                lineWidth: 2.5,
+            };
+
+            normalized.push({
+                id: `rel-${index}`,
+                source: edge.source,
+                target: edge.target,
+                type: renderType,
+                edgeType: category,
+                style: {
+                    ...style,
+                },
+                label: category,
+            });
+        });
+
+        return normalized;
+    }
+
+    renderGraph() {
+        const container = document.getElementById(this.containerId);
+        if (!container) return;
+
+        const bounds = container.getBoundingClientRect();
+        const width = Math.max(bounds.width, 640);
+        const height = Math.max(bounds.height, 640);
+
+        this.graph = new G6.TreeGraph({
+            container: this.containerId,
+            width,
+            height,
+            modes: {
+                default: [
+                    'drag-canvas',
+                    'zoom-canvas',
+                    {
+                        type: 'collapse-expand',
+                        trigger: 'click',
+                        onChange: (item, collapsed) => {
+                            if (!item) return;
+                            item.getModel().collapsed = collapsed;
+                            return true;
+                        },
+                    },
+                ],
+            },
+            defaultNode: {
+                type: 'hexagon-card',
+                size: 110,
+            },
+            defaultEdge: {
+                type: 'cubic-horizontal',
+                style: {
+                    stroke: '#cbd5e1',
+                    lineWidth: 2,
+                },
+            },
+            nodeStateStyles: {
+                hover: { shadowColor: '#38bdf8', shadowBlur: 24 },
+                selected: { shadowColor: '#fb923c', shadowBlur: 28 },
+                dimmed: { opacity: 0.3 },
+                weak: { opacity: 0.6 },
+                locked: { opacity: 0.35, cursor: 'not-allowed' },
+            },
+            edgeStateStyles: {
+                hover: { lineWidth: 3, stroke: '#38bdf8' },
+                connected: { opacity: 0.95, lineWidth: 3 },
+                dimmed: { opacity: 0.25 },
+                crosshover: { stroke: '#fb923c', lineWidth: 3 },
+                glow: { shadowColor: '#facc15', shadowBlur: 12 },
+            },
+            layout: {
+                type: 'mindmap',
+                direction: 'H',
+                getHeight: () => 110,
+                getWidth: () => 150,
+                getVGap: () => 18,
+                getHGap: () => 50,
+            },
+        });
+
+        this.graph.data(this.treeData);
+        this.graph.render();
+        this.graph.fitView(12);
+        this.drawRelationEdges();
+        this.applyNodeStates();
+        this.startEdgeFlows();
+        this.focusOnLowestMastery();
+    }
+
+    clearRelationEdges() {
+        if (!this.graph) return;
+        this.graph.getEdges().forEach((edge) => {
+            const id = edge.getModel()?.id || '';
+            if (id.startsWith('rel-')) {
+                this.graph.removeItem(edge);
+            }
+        });
+    }
+
+    drawRelationEdges() {
+        if (!this.graph || !this.relationEdges.length) return;
+        this.relationEdges.forEach((edge) => {
+            this.graph.addItem('edge', edge);
+        });
+    }
+
+    applyNodeStates() {
+        if (!this.graph) return;
+        this.graph.getNodes().forEach((node) => {
+            const model = node.getModel();
+            const mastery = model.meta?.mastery_level ?? 0;
+            if (model.locked) {
+                this.graph.setItemState(node, 'locked', true);
+            }
+            if (mastery < 0.4 && this.highlightLowMastery) {
+                this.graph.setItemState(node, 'weak', true);
+            }
+            if (mastery >= 0.8 && !model.locked) {
+                this.playHalo(node);
+            }
+        });
+    }
+
+    startEdgeFlows() {
+        if (!this.graph) return;
+        const nodeMap = new Map(
+            this.graph
+                .getNodes()
+                .map((node) => [node.getModel().id, node.getModel()])
+        );
+        this.graph.getEdges().forEach((edge) => {
+            const model = edge.getModel();
+            const sourceMastery =
+                nodeMap.get(model.source)?.meta?.mastery_level ?? 0;
+            const category = model.edgeType || model.type;
+            if (sourceMastery >= 0.8 && category !== 'crosslink') {
+                this.animateEdgeFlow(edge, '#facc15');
+            }
+        });
+    }
+
+    animateEdgeFlow(edge, color) {
+        if (!edge || edge.__flowing) return;
+        const keyShape = edge.getKeyShape?.();
+        if (!keyShape || !keyShape.getTotalLength) return;
+
+        const totalLength = keyShape.getTotalLength();
+        keyShape.attr('lineDash', [20, 12]);
+        keyShape.attr('stroke', color);
+        edge.__flowing = true;
+
+        keyShape.animate(
+            (ratio) => ({
+                lineDashOffset: -ratio * totalLength,
+                opacity: 0.8 + 0.2 * Math.sin(ratio * Math.PI),
+            }),
+            { duration: 1600, repeat: true }
+        );
+    }
+
+    playHalo(node) {
+        const group = node.getContainer();
+        const keyShape = node.getKeyShape();
+        if (!group || !keyShape) return;
+
+        const bbox = keyShape.getBBox();
+        const halo = group.addShape('circle', {
+            attrs: {
+                x: bbox.centerX,
+                y: bbox.centerY,
+                r: Math.max(bbox.width, bbox.height) * 0.65,
+                stroke: '#facc15',
+                lineWidth: 2,
+                opacity: 0.4,
+            },
+            name: 'halo-shape',
+        });
+
+        halo.animate(
+            (ratio) => ({
+                r: halo.attr('r') + ratio * 16,
+                opacity: 0.4 - ratio * 0.4,
+            }),
+            {
+                duration: 1200,
+                easing: 'easeCubic',
+                repeat: false,
+                removeOnEnd: true,
+            }
+        );
+    }
+
+    bindEvents() {
+        if (!this.graph) return;
+
+        this.graph.on('node:mouseenter', (evt) => {
+            const item = evt.item;
+            if (!item || item.getModel().locked) return;
+            this.graph.setItemState(item, 'hover', true);
+            this.highlightNeighbors(item.getModel().id);
+        });
+
+        this.graph.on('node:mouseleave', (evt) => {
+            const item = evt.item;
+            if (!item) return;
+            this.graph.setItemState(item, 'hover', false);
+            this.clearNeighborHighlight();
+        });
+
+        this.graph.on('node:click', (evt) => {
+            const model = evt.item?.getModel();
+            if (!model) return;
+
+            if (model.locked) {
+                return;
+            }
+
+            this.graph.getNodes().forEach((node) => {
+                this.graph.clearItemStates(node);
+            });
+            this.graph.setItemState(evt.item, 'selected', true);
+            this.flashNode(evt.item);
+            if (this.emitSelection) {
+                this.notifySelection(model);
+            }
+        });
+
+        this.graph.on('canvas:click', () => {
+            this.graph.getNodes().forEach((node) => {
+                this.graph.clearItemStates(node);
+            });
+            this.clearNeighborHighlight();
+        });
+    }
+
+    flashNode(item) {
+        const keyShape = item?.getKeyShape?.();
+        if (!keyShape) return;
+        keyShape.animate({ opacity: 0.8 }, { duration: 80 });
+        keyShape.animate({ opacity: 1 }, { duration: 200, delay: 80 });
+    }
+
+    highlightNeighbors(nodeId) {
+        const connected = new Set([nodeId]);
+        this.graph.getEdges().forEach((edge) => {
+            const model = edge.getModel();
+            const related =
+                model.source === nodeId || model.target === nodeId;
+            this.graph.setItemState(edge, 'hover', related);
+            const category = model.edgeType || model.type;
+            if (category === 'crosslink') {
+                this.graph.setItemState(edge, 'crosshover', related);
+            }
+            if (related) {
+                connected.add(model.source);
+                connected.add(model.target);
+            } else {
+                this.graph.clearItemStates(edge, ['hover', 'crosshover']);
+            }
+        });
+
+        this.graph.getNodes().forEach((node) => {
+            const id = node.getModel().id;
+            this.graph.setItemState(node, 'dimmed', !connected.has(id));
+        });
+    }
+
+    clearNeighborHighlight() {
+        this.graph.getEdges().forEach((edge) => {
+            this.graph.clearItemStates(edge, ['hover', 'crosshover']);
+        });
+        this.graph.getNodes().forEach((node) => {
+            this.graph.setItemState(node, 'dimmed', false);
+        });
+    }
+
+    notifySelection(model) {
+        if (typeof this.onNodeSelect === 'function') {
+            this.onNodeSelect(model);
+            return;
+        }
+
+        if (!window.Livewire) return;
+
+        const targetId =
+            this.livewireId ||
+            document
+                .querySelector('[data-knowledge-mindmap-root] [wire\\:id], [wire\\:id]')
+                ?.getAttribute('wire:id');
+
+        const component = targetId ? window.Livewire.find(targetId) : null;
+        if (component?.call) {
+            component.call(this.livewireMethod, model.id);
+        }
+    }
+
+    setupLivewireListeners() {
+        ['mastery-updated', 'mindmap-mastery-updated'].forEach((event) => {
+            window.addEventListener(event, (detailEvent) => {
+                this.masteryData = detailEvent.detail?.data || {};
+                this.refreshGraph();
+            });
+        });
+    }
+
+    focusOnLowestMastery() {
+        if (!this.graph) return;
+
+        const entries = Object.entries(this.masteryData || {}).filter(
+            ([, value]) =>
+                value && typeof value.mastery_level === 'number'
+        );
+        if (!entries.length) return;
+
+        let targetId = null;
+        let minLevel = Infinity;
+        entries.forEach(([id, value]) => {
+            const level = value.mastery_level;
+            if (typeof level === 'number' && level < minLevel) {
+                minLevel = level;
+                targetId = id;
+            }
+        });
+
+        if (!targetId) return;
+
+        this.graph.getNodes().forEach((node) => {
+            this.graph.clearItemStates(node);
+        });
+
+        const item = this.graph.findById(targetId);
+        if (item) {
+            this.graph.focusItem(item, true, {
+                easing: 'easeCubic',
+                duration: 500,
+            });
+            this.graph.setItemState(item, 'selected', true);
+        }
+    }
+
+    refreshGraph() {
+        if (!this.graph || !this.rawTree) return;
+
+        this.masteryCache = {};
+        this.treeData = this.transformNode(this.rawTree);
+        this.applyUnlockRules(this.treeData);
+        this.applyInitialCollapse(this.treeData);
+        this.expandForMastery();
+
+        this.graph.changeData(this.treeData);
+        this.graph.render();
+        this.graph.fitView(12);
+
+        this.clearRelationEdges();
+        this.drawRelationEdges();
+        this.applyNodeStates();
+        this.startEdgeFlows();
+        this.focusOnLowestMastery();
+    }
+
+    resizeGraph() {
+        if (!this.graph) return;
+        const container = document.getElementById(this.containerId);
+        if (!container) return;
+        this.graph.changeSize(container.clientWidth, container.clientHeight);
+        this.graph.fitView(12);
+    }
+}
+
+window.KnowledgeMindmapGraph = KnowledgeMindmapGraph;

+ 360 - 0
resources/js/g6-custom-node.js

@@ -0,0 +1,360 @@
+// 等待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)
+                ? cfg.size[0]
+                : cfg.size || 110;
+            const mastery = Math.max(
+                0,
+                Math.min(1, cfg.meta?.mastery_level ?? 0)
+            );
+            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 ring = group.addShape('path', {
+                attrs: {
+                    path: hexagonPath,
+                    stroke: palette.ring,
+                    lineWidth: palette.ringWidth,
+                    opacity: palette.ringOpacity,
+                },
+                name: 'ring-shape',
+            });
+
+            const hexagon = group.addShape('path', {
+                attrs: {
+                    path: hexagonPath,
+                    fill: '#ffffff',
+                    stroke: palette.stroke,
+                    lineWidth: palette.lineWidth,
+                    shadowColor: palette.shadow,
+                    shadowBlur: palette.shadow ? 12 : 0,
+                    opacity: locked ? 0.55 : 1,
+                    cursor: locked ? 'not-allowed' : 'pointer',
+                },
+                name: 'hexagon-shape',
+                draggable: true,
+            });
+
+            const cardWidth = size * 0.82;
+            const cardHeight = size * 0.64;
+            group.addShape('rect', {
+                attrs: {
+                    x: -cardWidth / 2,
+                    y: -cardHeight / 2,
+                    width: cardWidth,
+                    height: cardHeight,
+                    fill: '#ffffff',
+                    radius: 6,
+                    opacity: locked ? 0.75 : 0.95,
+                    shadowColor: 'rgba(15, 23, 42, 0.12)',
+                    shadowBlur: 8,
+                },
+                name: 'card-shape',
+            });
+
+            group.addShape('text', {
+                attrs: {
+                    text: `${cfg.meta?.code || cfg.id} · ${cfg.label || cfg.meta?.name || cfg.id}`,
+                    x: 0,
+                    y: -12,
+                    fontSize: 24,
+                    fontWeight: 700,
+                    fill: locked ? '#94a3b8' : '#0f172a',
+                    textAlign: 'center',
+                    textBaseline: 'middle',
+                },
+                name: 'title-text',
+            });
+
+            group.addShape('text', {
+                attrs: {
+                    text: cfg.meta?.name || cfg.label || cfg.id,
+                    x: 0,
+                    y: 6,
+                    fontSize: 16,
+                    fontWeight: 700,
+                    fill: '#1e293b',
+                    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;
+
+            group.addShape('text', {
+                attrs: {
+                    text: '掌握度',
+                    x: -barWidth / 2,
+                    y: barY - 10,
+                    fontSize: 12,
+                    fontWeight: 700,
+                    fill: '#334155',
+                    textAlign: 'left',
+                    textBaseline: 'middle',
+                },
+                name: 'mastery-label',
+            });
+
+            group.addShape('rect', {
+                attrs: {
+                    x: -barWidth / 2,
+                    y: barY,
+                    width: barWidth,
+                    height: barHeight,
+                    fill: '#e5e7eb',
+                    radius: 3,
+                },
+                name: 'progress-bg',
+            });
+
+            group.addShape('rect', {
+                attrs: {
+                    x: -barWidth / 2,
+                    y: barY,
+                    width: (barWidth * percent) / 100,
+                    height: barHeight,
+                    fill: palette.progress,
+                    radius: 3,
+                },
+                name: 'progress-fill',
+            });
+
+            group.addShape('text', {
+                attrs: {
+                    text: `${percent}%`,
+                    x: 0,
+                    y: barY + barHeight / 2,
+                    fontSize: 12,
+                    fontWeight: 800,
+                    fill: '#0f172a',
+                    textAlign: 'center',
+                    textBaseline: 'middle',
+                },
+                name: 'percent-text',
+            });
+
+            if (recommended && !locked) {
+                group.addShape('circle', {
+                    attrs: {
+                        x: cardWidth / 2 - 8,
+                        y: -cardHeight / 2 + 10,
+                        r: 8,
+                        fill: 'rgba(251, 191, 36, 0.15)',
+                        stroke: '#f59e0b',
+                    },
+                    name: 'recommend-pill',
+                });
+                group.addShape('text', {
+                    attrs: {
+                        text: '荐',
+                        x: cardWidth / 2 - 8,
+                        y: -cardHeight / 2 + 10,
+                        fontSize: 12,
+                        fontWeight: 700,
+                        fill: '#b45309',
+                        textAlign: 'center',
+                        textBaseline: 'middle',
+                    },
+                    name: 'recommend-text',
+                });
+            }
+
+            if (locked) {
+                group.addShape('rect', {
+                    attrs: {
+                        x: -cardWidth / 2,
+                        y: -cardHeight / 2,
+                        width: cardWidth,
+                        height: cardHeight,
+                        fill: '#ffffff',
+                        opacity: 0.4,
+                    },
+                    name: 'lock-mask',
+                });
+                group.addShape('text', {
+                    attrs: {
+                        text: '🔒',
+                        x: 0,
+                        y: 0,
+                        fontSize: 16,
+                        textAlign: 'center',
+                        textBaseline: 'middle',
+                    },
+                    name: 'lock-icon',
+                });
+            }
+
+            if (percent < 40) {
+                group.addShape('rect', {
+                    attrs: {
+                        x: -cardWidth / 2,
+                        y: -cardHeight / 2,
+                        width: cardWidth,
+                        height: cardHeight,
+                        fill: '#ffffff',
+                        opacity: 0.15,
+                    },
+                    name: 'weak-mask',
+                });
+            }
+
+            return hexagon;
+        },
+
+        setState(name, value, item) {
+            const group = item.getContainer();
+            const hexagon = group.find((e) => e.get('name') === 'hexagon-shape');
+            if (!hexagon) return;
+
+            if (name === 'hover') {
+                hexagon.animate(
+                    { lineWidth: value ? 4 : 3 },
+                    { duration: 180 }
+                );
+                group.animate(
+                    (ratio) => ({
+                        matrix: [
+                            1 + (value ? ratio * 0.05 : -ratio * 0.05),
+                            0,
+                            0,
+                            0,
+                            1 + (value ? ratio * 0.05 : -ratio * 0.05),
+                            0,
+                            0,
+                            0,
+                            1,
+                        ],
+                    }),
+                    { duration: 200 }
+                );
+            }
+
+            if (name === 'selected') {
+                hexagon.attr('shadowColor', value ? '#fb923c' : undefined);
+                hexagon.attr('shadowBlur', value ? 16 : 0);
+            }
+
+            if (name === 'weak') {
+                hexagon.attr('opacity', value ? 0.75 : 1);
+            }
+
+            if (name === 'locked') {
+                hexagon.attr('opacity', value ? 0.5 : 1);
+                hexagon.attr('cursor', value ? 'not-allowed' : 'pointer');
+            }
+        },
+
+        getPalette(mastery, locked) {
+            if (locked) {
+                return {
+                    stroke: '#e2e8f0',
+                    lineWidth: 2.5,
+                    ring: '#e2e8f0',
+                    ringWidth: 4,
+                    ringOpacity: 0.35,
+                    shadow: '',
+                    progress: '#cbd5e1',
+                };
+            }
+
+            if (mastery >= 0.8) {
+                return {
+                    stroke: '#d3b55f',
+                    lineWidth: 3,
+                    ring: '#f7e4ad',
+                    ringWidth: 7,
+                    ringOpacity: 0.4,
+                    shadow: 'rgba(212, 181, 95, 0.28)',
+                    progress: '#22c55e',
+                };
+            }
+
+            if (mastery >= 0.6) {
+                return {
+                    stroke: '#34d399',
+                    lineWidth: 3,
+                    ring: '#bbf7d0',
+                    ringWidth: 6,
+                    ringOpacity: 0.28,
+                    shadow: 'rgba(52, 211, 153, 0.25)',
+                    progress: '#34d399',
+                };
+            }
+
+            if (mastery >= 0.4) {
+                return {
+                    stroke: '#f59e0b',
+                    lineWidth: 3,
+                    ring: '#fde68a',
+                    ringWidth: 6,
+                    ringOpacity: 0.28,
+                    shadow: 'rgba(245, 158, 11, 0.18)',
+                    progress: '#f59e0b',
+                };
+            }
+
+            return {
+                stroke: '#f87171',
+                lineWidth: 2.5,
+                ring: '#fee2e2',
+                ringWidth: 6,
+                ringOpacity: 0.32,
+                shadow: 'rgba(248, 113, 113, 0.18)',
+                progress: '#f87171',
+            };
+        },
+
+        getHexagonPath(size) {
+            const r = size / 2;
+            const points = [];
+            for (let i = 0; i < 6; i++) {
+                const angle = (Math.PI / 3) * i - Math.PI / 2;
+                points.push([r * Math.cos(angle), r * Math.sin(angle)]);
+            }
+            return [
+                ['M', points[0][0], points[0][1]],
+                ...points.slice(1).map((p) => ['L', p[0], p[1]]),
+                ['Z'],
+            ];
+        },
+    },
+    'single-node'
+);
+
+    console.log('自定义节点注册完成');
+}
+
+// 启动注册流程
+registerCustomNode();

+ 645 - 0
resources/js/knowledge-mindmap-graph.js

@@ -0,0 +1,645 @@
+class KnowledgeMindmapGraph {
+    constructor(options = {}) {
+        this.graph = null;
+        this.rawTree = null;
+        this.treeData = null;
+        this.relationEdges = [];
+        this.masteryData = options.masteryData || {};
+        this.masteryCache = {};
+        this.stats = { nodes: 0, extraEdges: 0 };
+        this.containerId = options.containerId || 'knowledge-mindmap';
+        this.livewireMethod = options.livewireMethod || 'openDrawer';
+        this.onNodeSelect = options.onNodeSelect || null;
+        this.livewireId = options.livewireId || null;
+        this.highlightLowMastery = options.highlightLowMastery ?? true;
+        this.emitSelection = options.emitSelection ?? true;
+        this.lockRules = options.lockRules || [
+            { prerequisite: 'P04', target: 'P05', threshold: 0.6 },
+            { prerequisite: 'P05', target: 'P06', threshold: 0.6 },
+        ];
+    }
+
+    async init() {
+        try {
+            await this.loadData();
+            this.applyUnlockRules(this.treeData);
+            this.applyInitialCollapse(this.treeData);
+            this.expandForMastery();
+            this.renderGraph();
+            this.bindEvents();
+            this.setupLivewireListeners();
+            window.addEventListener('resize', () => this.resizeGraph());
+        } catch (error) {
+            console.error('初始化思维导图失败', error);
+        }
+    }
+
+    async loadData() {
+        const [treeResp, edgesResp] = await Promise.all([
+            fetch('/data/tree.json'),
+            fetch('/data/edges.json'),
+        ]);
+
+        this.rawTree = await treeResp.json();
+        const edges = await edgesResp.json();
+        const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
+
+        this.masteryCache = {};
+        this.treeData = this.transformNode(this.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 || id;
+        const masteryLevel = this.getMasteryLevel(id);
+        const accuracy = this.masteryData[id]?.accuracy_rate || 0;
+        const recommended = masteryLevel < 0.6;
+
+        const model = {
+            id,
+            label,
+            depth,
+            locked: false,
+            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,
+                recommended,
+            },
+            children: (node.children || [])
+                .map((child) => this.transformNode(child, depth + 1))
+                .filter(Boolean),
+        };
+
+        return model;
+    }
+
+    getMasteryLevel(id) {
+        if (this.masteryCache[id] !== undefined) {
+            return this.masteryCache[id];
+        }
+
+        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;
+
+        this.masteryCache[id] = value;
+        return value;
+    }
+
+    applyUnlockRules(node) {
+        if (!node) return;
+
+        const rule = this.lockRules.find((item) => item.target === node.id);
+        const prereqMastery = rule
+            ? this.masteryCache[rule.prerequisite] ?? 0
+            : 1;
+
+        const lockedByRule = rule ? prereqMastery < rule.threshold : false;
+
+        node.locked = lockedByRule;
+        if (node.locked) {
+            node.meta.lock_reason = lockedByRule
+                ? `需先掌握前置知识点:${rule.prerequisite}`
+                : '需先掌握前置知识点';
+        }
+
+        node.children.forEach((child) => this.applyUnlockRules(child));
+    }
+
+    applyInitialCollapse(node, depth = 0) {
+        if (!node) return;
+        // 默认折叠所有有子节点的节点(除了根节点)
+        if (depth > 0 && node.children.length > 0) {
+            node.collapsed = true;
+        }
+        node.children.forEach((child) =>
+            this.applyInitialCollapse(child, depth + 1)
+        );
+    }
+
+    expandForMastery() {
+        if (!this.treeData) return;
+        const masteryKeys = new Set(Object.keys(this.masteryData || {}));
+        if (!masteryKeys.size) return;
+        this.expandNodesForMastery(this.treeData, masteryKeys);
+    }
+
+    expandNodesForMastery(node, masteryKeys) {
+        if (!node) return false;
+        const hasMastery = masteryKeys.has(node.id);
+        let childHas = false;
+        (node.children || []).forEach((child) => {
+            if (this.expandNodesForMastery(child, masteryKeys)) {
+                childHas = true;
+            }
+        });
+
+        if (hasMastery || childHas) {
+            node.collapsed = false;
+        }
+
+        return hasMastery || childHas;
+    }
+
+    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 = [];
+        const styleMap = {
+            prerequisite: { stroke: '#60a5fa', lineDash: [10, 8], lineWidth: 3 },
+            successor: { stroke: '#7dd3fc', lineWidth: 3 },
+            crosslink: { stroke: '#fb923c', lineDash: [8, 6], lineWidth: 2.5 },
+            sibling: { stroke: '#94a3b8', lineDash: [6, 6], lineWidth: 2.5 },
+        };
+
+        (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 category = edge.type || 'successor';
+            const renderType =
+                category === 'successor' ? 'cubic-horizontal' : 'quadratic';
+            const style = styleMap[category] || {
+                stroke: '#cbd5e1',
+                lineWidth: 2.5,
+            };
+
+            normalized.push({
+                id: `rel-${index}`,
+                source: edge.source,
+                target: edge.target,
+                type: renderType,
+                edgeType: category,
+                style: {
+                    ...style,
+                },
+                label: category,
+            });
+        });
+
+        return normalized;
+    }
+
+    renderGraph() {
+        const container = document.getElementById(this.containerId);
+        if (!container) return;
+
+        const bounds = container.getBoundingClientRect();
+        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,
+            height,
+            modes: {
+                default: [
+                    'drag-canvas',
+                    'zoom-canvas',
+                    {
+                        type: 'collapse-expand',
+                        trigger: 'click',
+                        onChange: (item, collapsed) => {
+                            if (!item) return;
+                            item.getModel().collapsed = collapsed;
+                            return true;
+                        },
+                    },
+                ],
+            },
+            defaultNode: {
+                type: 'hexagon-card',
+                size: 110,
+            },
+            defaultEdge: {
+                type: 'cubic-horizontal',
+                style: {
+                    stroke: '#cbd5e1',
+                    lineWidth: 2,
+                },
+            },
+            nodeStateStyles: {
+                hover: { shadowColor: '#38bdf8', shadowBlur: 24 },
+                selected: { shadowColor: '#fb923c', shadowBlur: 28 },
+                dimmed: { opacity: 0.3 },
+                weak: { opacity: 0.6 },
+                locked: { opacity: 0.35, cursor: 'not-allowed' },
+            },
+            edgeStateStyles: {
+                hover: { lineWidth: 3, stroke: '#38bdf8' },
+                connected: { opacity: 0.95, lineWidth: 3 },
+                dimmed: { opacity: 0.25 },
+                crosshover: { stroke: '#fb923c', lineWidth: 3 },
+                glow: { shadowColor: '#facc15', shadowBlur: 12 },
+            },
+            layout: {
+                type: 'mindmap',
+                direction: 'H',
+                getHeight: () => 110,
+                getWidth: () => 150,
+                getVGap: () => 18,
+                getHGap: () => 50,
+                preventOverlap: true,
+            },
+        });
+
+        this.graph.data(this.treeData);
+        this.graph.render();
+        this.graph.fitView(12);
+
+        this.drawRelationEdges();
+        this.applyNodeStates();
+        this.startEdgeFlows();
+        this.focusOnLowestMastery();
+    }
+
+    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() {
+        if (!this.graph) return;
+        this.graph.getEdges().forEach((edge) => {
+            const id = edge.getModel()?.id || '';
+            if (id.startsWith('rel-')) {
+                this.graph.removeItem(edge);
+            }
+        });
+    }
+
+    drawRelationEdges() {
+        if (!this.graph || !this.relationEdges.length) return;
+        this.relationEdges.forEach((edge) => {
+            this.graph.addItem('edge', edge);
+        });
+    }
+
+    applyNodeStates() {
+        if (!this.graph) return;
+        this.graph.getNodes().forEach((node) => {
+            const model = node.getModel();
+            const mastery = model.meta?.mastery_level ?? 0;
+            if (model.locked) {
+                this.graph.setItemState(node, 'locked', true);
+            }
+            if (mastery < 0.4 && this.highlightLowMastery) {
+                this.graph.setItemState(node, 'weak', true);
+            }
+            if (mastery >= 0.8 && !model.locked) {
+                this.playHalo(node);
+            }
+        });
+    }
+
+    startEdgeFlows() {
+        if (!this.graph) return;
+        const nodeMap = new Map(
+            this.graph
+                .getNodes()
+                .map((node) => [node.getModel().id, node.getModel()])
+        );
+        this.graph.getEdges().forEach((edge) => {
+            const model = edge.getModel();
+            const sourceMastery =
+                nodeMap.get(model.source)?.meta?.mastery_level ?? 0;
+            const category = model.edgeType || model.type;
+            if (sourceMastery >= 0.8 && category !== 'crosslink') {
+                this.animateEdgeFlow(edge, '#facc15');
+            }
+        });
+    }
+
+    animateEdgeFlow(edge, color) {
+        if (!edge || edge.__flowing) return;
+        const keyShape = edge.getKeyShape?.();
+        if (!keyShape || !keyShape.getTotalLength) return;
+
+        const totalLength = keyShape.getTotalLength();
+        keyShape.attr('lineDash', [20, 12]);
+        keyShape.attr('stroke', color);
+        edge.__flowing = true;
+
+        keyShape.animate(
+            (ratio) => ({
+                lineDashOffset: -ratio * totalLength,
+                opacity: 0.8 + 0.2 * Math.sin(ratio * Math.PI),
+            }),
+            { duration: 1600, repeat: true }
+        );
+    }
+
+    playHalo(node) {
+        const group = node.getContainer();
+        const keyShape = node.getKeyShape();
+        if (!group || !keyShape) return;
+
+        const bbox = keyShape.getBBox();
+        const halo = group.addShape('circle', {
+            attrs: {
+                x: bbox.centerX,
+                y: bbox.centerY,
+                r: Math.max(bbox.width, bbox.height) * 0.65,
+                stroke: '#facc15',
+                lineWidth: 2,
+                opacity: 0.4,
+            },
+            name: 'halo-shape',
+        });
+
+        halo.animate(
+            (ratio) => ({
+                r: halo.attr('r') + ratio * 16,
+                opacity: 0.4 - ratio * 0.4,
+            }),
+            {
+                duration: 1200,
+                easing: 'easeCubic',
+                repeat: false,
+                removeOnEnd: true,
+            }
+        );
+    }
+
+    bindEvents() {
+        if (!this.graph) return;
+
+        this.graph.on('node:mouseenter', (evt) => {
+            const item = evt.item;
+            if (!item || item.getModel().locked) return;
+            this.graph.setItemState(item, 'hover', true);
+            this.highlightNeighbors(item.getModel().id);
+        });
+
+        this.graph.on('node:mouseleave', (evt) => {
+            const item = evt.item;
+            if (!item) return;
+            this.graph.setItemState(item, 'hover', false);
+            this.clearNeighborHighlight();
+        });
+
+        this.graph.on('node:click', (evt) => {
+            const model = evt.item?.getModel();
+            if (!model) return;
+
+            if (model.locked) {
+                return;
+            }
+
+            this.graph.getNodes().forEach((node) => {
+                this.graph.clearItemStates(node);
+            });
+            this.graph.setItemState(evt.item, 'selected', true);
+            this.flashNode(evt.item);
+            if (this.emitSelection) {
+                this.notifySelection(model);
+            }
+        });
+
+        this.graph.on('canvas:click', () => {
+            this.graph.getNodes().forEach((node) => {
+                this.graph.clearItemStates(node);
+            });
+            this.clearNeighborHighlight();
+        });
+    }
+
+    flashNode(item) {
+        const keyShape = item?.getKeyShape?.();
+        if (!keyShape) return;
+        keyShape.animate({ opacity: 0.8 }, { duration: 80 });
+        keyShape.animate({ opacity: 1 }, { duration: 200, delay: 80 });
+    }
+
+    highlightNeighbors(nodeId) {
+        const connected = new Set([nodeId]);
+        this.graph.getEdges().forEach((edge) => {
+            const model = edge.getModel();
+            const related =
+                model.source === nodeId || model.target === nodeId;
+            this.graph.setItemState(edge, 'hover', related);
+            const category = model.edgeType || model.type;
+            if (category === 'crosslink') {
+                this.graph.setItemState(edge, 'crosshover', related);
+            }
+            if (related) {
+                connected.add(model.source);
+                connected.add(model.target);
+            } else {
+                this.graph.clearItemStates(edge, ['hover', 'crosshover']);
+            }
+        });
+
+        this.graph.getNodes().forEach((node) => {
+            const id = node.getModel().id;
+            this.graph.setItemState(node, 'dimmed', !connected.has(id));
+        });
+    }
+
+    clearNeighborHighlight() {
+        this.graph.getEdges().forEach((edge) => {
+            this.graph.clearItemStates(edge, ['hover', 'crosshover']);
+        });
+        this.graph.getNodes().forEach((node) => {
+            this.graph.setItemState(node, 'dimmed', false);
+        });
+    }
+
+    notifySelection(model) {
+        if (typeof this.onNodeSelect === 'function') {
+            this.onNodeSelect(model);
+            return;
+        }
+
+        if (!window.Livewire) return;
+
+        const targetId =
+            this.livewireId ||
+            document
+                .querySelector('[data-knowledge-mindmap-root] [wire\\:id], [wire\\:id]')
+                ?.getAttribute('wire:id');
+
+        const component = targetId ? window.Livewire.find(targetId) : null;
+        if (component?.call) {
+            component.call(this.livewireMethod, model.id);
+        }
+    }
+
+    setupLivewireListeners() {
+        ['mastery-updated', 'mindmap-mastery-updated'].forEach((event) => {
+            window.addEventListener(event, (detailEvent) => {
+                this.masteryData = detailEvent.detail?.data || {};
+                this.refreshGraph();
+            });
+        });
+    }
+
+    focusOnLowestMastery() {
+        if (!this.graph) return;
+
+        const entries = Object.entries(this.masteryData || {}).filter(
+            ([, value]) =>
+                value && typeof value.mastery_level === 'number'
+        );
+        if (!entries.length) return;
+
+        let targetId = null;
+        let minLevel = Infinity;
+        entries.forEach(([id, value]) => {
+            const level = value.mastery_level;
+            if (typeof level === 'number' && level < minLevel) {
+                minLevel = level;
+                targetId = id;
+            }
+        });
+
+        if (!targetId) return;
+
+        this.graph.getNodes().forEach((node) => {
+            this.graph.clearItemStates(node);
+        });
+
+        const item = this.graph.findById(targetId);
+        if (item) {
+            this.graph.focusItem(item, true, {
+                easing: 'easeCubic',
+                duration: 500,
+            });
+            this.graph.setItemState(item, 'selected', true);
+        }
+    }
+
+    refreshGraph() {
+        if (!this.graph || !this.rawTree) return;
+
+        this.masteryCache = {};
+        this.treeData = this.transformNode(this.rawTree);
+        this.applyUnlockRules(this.treeData);
+        this.applyInitialCollapse(this.treeData);
+        this.expandForMastery();
+
+        this.graph.changeData(this.treeData);
+        this.graph.render();
+        this.graph.fitView(12);
+
+        this.clearRelationEdges();
+        this.drawRelationEdges();
+        this.applyNodeStates();
+        this.startEdgeFlows();
+        this.focusOnLowestMastery();
+    }
+
+    forceCollapseNodes() {
+        if (!this.graph) return;
+    }
+
+    forceCollapse() {
+        if (!this.graph) {
+            console.log('等待graph初始化...');
+            setTimeout(() => this.forceCollapse(), 200);
+            return;
+        }
+
+        console.log('开始强制折叠节点...');
+        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++;
+                    console.log('成功折叠节点:', model.id);
+                } catch (error) {
+                    console.error('折叠节点失败:', model.id, error);
+                }
+            }
+        });
+
+        console.log(`共折叠了 ${collapsedCount} 个节点`);
+
+        // 重新渲染和适配视图
+        this.graph.refresh();
+        this.graph.fitView(12);
+    }
+
+    resizeGraph() {
+        if (!this.graph) return;
+        const container = document.getElementById(this.containerId);
+        if (!container) return;
+        this.graph.changeSize(container.clientWidth, container.clientHeight);
+        this.graph.fitView(12);
+    }
+}
+
+// 定义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类定义完成');
+}
+
+// 启动定义流程
+defineGraphClass();

+ 169 - 0
resources/views/components/mindmap/detail-drawer.blade.php

@@ -0,0 +1,169 @@
+@props([
+    'open' => false,
+    'details' => [],
+    'closeAction' => 'closeDrawer',
+    'selectAction' => 'openDrawer',
+    'panelTitle' => '知识点详情',
+])
+
+@php
+    $mastery = $details['mastery_level'] ?? 0;
+    $errorRate = $details['error_rate'] ?? 0;
+    $name = $details['name'] ?? '';
+    $code = $details['code'] ?? '';
+@endphp
+
+<div
+    x-cloak
+    x-show="@js($open)"
+    x-transition:enter="transition ease-out duration-300"
+    x-transition:enter-start="translate-x-full opacity-50"
+    x-transition:enter-end="translate-x-0 opacity-100"
+    x-transition:leave="transition ease-in duration-200"
+    x-transition:leave-start="translate-x-0 opacity-100"
+    x-transition:leave-end="translate-x-full opacity-0"
+    class="fixed inset-y-0 right-0 w-full max-w-xl z-50"
+    aria-live="polite"
+>
+    <div class="h-full bg-gradient-to-b from-white/95 to-slate-50/95 backdrop-blur border-l border-white/60 shadow-2xl overflow-y-auto">
+        <div class="p-6 space-y-6">
+            <div class="flex items-start justify-between">
+                <div>
+                    <p class="text-xs uppercase tracking-[0.3em] text-slate-400">Mindmap Drawer</p>
+                    <h3 class="mt-1 text-2xl font-bold text-slate-900 leading-tight">{{ $panelTitle }}</h3>
+                </div>
+                <button
+                    wire:click="{{ $closeAction }}"
+                    type="button"
+                    class="p-2 rounded-full bg-white/80 border border-slate-200 text-slate-500 hover:text-slate-700 hover:shadow"
+                    aria-label="关闭"
+                >
+                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
+                    </svg>
+                </button>
+            </div>
+
+            <div class="rounded-2xl border border-white/60 bg-white/80 shadow-inner p-5 space-y-3">
+                <div class="flex items-center justify-between">
+                    <div>
+                        <p class="text-sm text-slate-500">编号 {{ $code }}</p>
+                        <h4 class="text-xl font-semibold text-slate-900">{{ $name }}</h4>
+                    </div>
+                    <div class="px-3 py-1.5 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-50 to-amber-50 text-emerald-700 border border-emerald-100">
+                        掌握度 {{ number_format($mastery * 100, 1) }}%
+                    </div>
+                </div>
+
+                <x-mindmap.mastery-meter :value="$mastery" :error-rate="$errorRate" />
+            </div>
+
+            @if(!empty($details['skills']))
+                <div class="rounded-2xl border border-white/60 bg-white/80 shadow-sm p-4">
+                    <p class="text-sm font-semibold text-slate-800 mb-3">关联技能</p>
+                    <div class="flex flex-wrap gap-2">
+                        @foreach($details['skills'] as $skill)
+                            <span class="px-3 py-1 rounded-full text-xs bg-indigo-50 text-indigo-700 border border-indigo-100">
+                                {{ $skill }}
+                            </span>
+                        @endforeach
+                    </div>
+                </div>
+            @endif
+
+            @if(!empty($details['prerequisites']))
+                <div class="rounded-2xl border border-white/60 bg-white/80 shadow-sm p-4 space-y-3">
+                    <p class="text-sm font-semibold text-slate-800">前置知识</p>
+                    <div class="space-y-2">
+                        @foreach($details['prerequisites'] as $prereq)
+                            <button
+                                wire:click="{{ $selectAction }}('{{ $prereq['id'] }}')"
+                                type="button"
+                                class="w-full flex items-center justify-between px-3 py-2 rounded-lg border border-slate-200/70 bg-white/60 hover:border-indigo-200 hover:bg-indigo-50/80 transition"
+                            >
+                                <span class="text-sm text-slate-700">{{ $prereq['name'] }}</span>
+                                <span class="text-xs px-2 py-1 rounded-full {{ ($prereq['mastery'] ?? 0) >= 0.6 ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700' }}">
+                                    {{ number_format(($prereq['mastery'] ?? 0) * 100, 0) }}%
+                                </span>
+                            </button>
+                        @endforeach
+                    </div>
+                </div>
+            @endif
+
+            @if(!empty($details['successors']))
+                <div class="rounded-2xl border border-white/60 bg-white/80 shadow-sm p-4 space-y-3">
+                    <p class="text-sm font-semibold text-slate-800">后续知识</p>
+                    <div class="space-y-2">
+                        @foreach($details['successors'] as $succ)
+                            <button
+                                wire:click="{{ $selectAction }}('{{ $succ['id'] }}')"
+                                type="button"
+                                class="w-full flex items-center justify-between px-3 py-2 rounded-lg border border-slate-200/70 bg-white/60 hover:border-amber-200 hover:bg-amber-50/70 transition"
+                            >
+                                <span class="text-sm text-slate-700">{{ $succ['name'] }}</span>
+                                <span class="text-xs px-2 py-1 rounded-full {{ ($succ['mastery'] ?? 0) >= 0.6 ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700' }}">
+                                    {{ number_format(($succ['mastery'] ?? 0) * 100, 0) }}%
+                                </span>
+                            </button>
+                        @endforeach
+                    </div>
+                </div>
+            @endif
+
+            @if(!empty($details['recommendations']))
+                <div class="rounded-2xl border border-white/60 bg-white/80 shadow-sm p-4 space-y-3">
+                    <div class="flex items-center justify-between">
+                        <p class="text-sm font-semibold text-slate-800">推荐练习</p>
+                        <span class="text-[11px] text-slate-500">自动挑选的3道题</span>
+                    </div>
+                    <div class="space-y-3">
+                        @foreach($details['recommendations'] as $rec)
+                            <div class="p-3 rounded-lg border border-slate-200/70 bg-white/60 hover:border-indigo-200 hover:shadow-sm transition">
+                                <div class="flex items-start justify-between gap-2">
+                                    <div class="text-sm font-medium text-slate-900">{{ $rec['title'] }}</div>
+                                    <span class="text-xs px-2 py-0.5 rounded-full border border-indigo-100 text-indigo-700 bg-indigo-50">
+                                        {{ $rec['difficulty'] ?? '中等' }}
+                                    </span>
+                                </div>
+                                <p class="mt-1 text-xs text-slate-500">{{ $rec['type'] ?? '练习题' }}</p>
+                            </div>
+                        @endforeach
+                    </div>
+                </div>
+            @endif
+
+            <div class="flex gap-3">
+                <a
+                    href="{{ url('/admin/practice?kp=' . ($details['id'] ?? '')) }}"
+                    class="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 text-white font-semibold py-3 shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition"
+                >
+                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2"/>
+                    </svg>
+                    开始练习
+                </a>
+                <button
+                    wire:click="{{ $closeAction }}"
+                    type="button"
+                    class="px-4 py-3 rounded-lg border border-slate-200 bg-white/70 text-slate-700 hover:border-slate-300"
+                >
+                    收起
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div
+    x-cloak
+    x-show="@js($open)"
+    @click="{{ $closeAction }}"
+    x-transition:enter="transition ease-out duration-300"
+    x-transition:enter-start="opacity-0"
+    x-transition:enter-end="opacity-100"
+    x-transition:leave="transition ease-in duration-200"
+    x-transition:leave-start="opacity-100"
+    x-transition:leave-end="opacity-0"
+    class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-40"
+></div>

+ 30 - 0
resources/views/components/mindmap/mastery-meter.blade.php

@@ -0,0 +1,30 @@
+@props([
+    'value' => 0,
+    'errorRate' => 0,
+    'label' => '掌握度',
+])
+
+@php
+    $percent = round(max(0, min(1, $value ?? 0)) * 100, 1);
+    $errorPercent = round(max(0, min(1, $errorRate ?? 0)) * 100, 1);
+    $tone = $percent >= 80 ? 'text-emerald-600' : ($percent >= 60 ? 'text-amber-600' : 'text-rose-600');
+@endphp
+
+<div class="rounded-xl border border-white/40 bg-white/70 shadow-sm backdrop-blur p-4">
+    <div class="flex items-center justify-between mb-2">
+        <div class="text-xs uppercase tracking-widest text-slate-500">{{ $label }}</div>
+        <div class="text-lg font-semibold {{ $tone }}">{{ $percent }}%</div>
+    </div>
+    <div class="w-full h-2 rounded-full bg-slate-200 overflow-hidden">
+        <div
+            class="h-full rounded-full bg-gradient-to-r from-rose-400 via-amber-300 to-emerald-500 transition-all duration-500"
+            style="width: {{ $percent }}%;"
+        ></div>
+    </div>
+    <div class="mt-2 flex items-center justify-between text-[11px] text-slate-500">
+        <span>错误率 {{ $errorPercent }}%</span>
+        <span>
+            {{ $percent >= 80 ? '稳固' : ($percent >= 60 ? '可提升' : '需巩固') }}
+        </span>
+    </div>
+</div>

+ 116 - 740
resources/views/filament/pages/knowledge-mindmap.blade.php

@@ -1,753 +1,129 @@
 <x-filament::page>
     <div
-        class="space-y-4"
-        x-data="knowledgeMindmap()"
+        class="space-y-6 bg-white p-4 rounded-xl"
+        x-data="{
+            graphInstance: null,
+            stats: { nodes: 0, extraEdges: 0 },
+            livewireId: '{{ $this->getId() }}',
+
+            async initMindmap() {
+                try {
+                    // 等待G6和自定义组件加载
+                    await this.waitForComponents();
+
+                    if (!window.G6 || !window.KnowledgeMindmapGraph) {
+                        console.error('G6组件未加载');
+                        return;
+                    }
+
+                    this.graphInstance = new window.KnowledgeMindmapGraph({
+                        containerId: 'knowledge-mindmap',
+                        livewireMethod: 'openDrawer',
+                        highlightLowMastery: true,
+                        livewireId: this.livewireId,
+                    });
+                    await this.graphInstance.init();
+                    this.stats = this.graphInstance.stats;
+                } catch (error) {
+                    console.error('知识图谱初始化失败:', error);
+                }
+            },
+
+            async waitForComponents() {
+                let attempts = 0;
+                const maxAttempts = 50;
+
+                while ((!window.G6 || !window.KnowledgeMindmapGraph) && attempts < maxAttempts) {
+                    await new Promise(resolve => setTimeout(resolve, 100));
+                    attempts++;
+                }
+
+                if (attempts >= maxAttempts) {
+                    throw new Error('G6组件加载超时');
+                }
+            }
+        }"
         x-init="initMindmap()"
+        data-knowledge-mindmap-root
     >
-        <div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
+        <div class="rounded-xl border border-slate-200 bg-white shadow-sm p-5">
             <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
-                <div class="flex-1">
-                    <h2 class="text-lg font-semibold text-gray-900">初中数学知识图谱</h2>
-                    <div class="mt-2 flex gap-4 text-xs text-gray-500">
-                        <div>知识点总数:<span x-text="stats.nodes"></span></div>
-                        <div>已选中学生:<span x-text="$wire.selectedStudentName || '未选择'"></span></div>
-                    </div>
-
-                    <!-- 学生选择器 -->
-                    <div class="mt-3 grid grid-cols-2 gap-3 max-w-md">
-                        <div>
-                            <label class="block text-xs font-medium text-gray-700 mb-1">选择老师</label>
-                            <select
-                                wire:model.live="selectedTeacherId"
-                                class="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
-                            >
-                                <option value="">请选择老师...</option>
-                                @foreach($teachers as $teacher)
-                                    <option value="{{ $teacher['teacher_id'] }}">{{ $teacher['name'] }}</option>
-                                @endforeach
-                            </select>
-                        </div>
-                        <div>
-                            <label class="block text-xs font-medium text-gray-700 mb-1">选择学生</label>
-                            <select
-                                wire:model.live="selectedStudentId"
-                                class="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
-                                {{ empty($selectedTeacherId) ? 'disabled' : '' }}
-                            >
-                                <option value="">
-                                    {{ empty($selectedTeacherId) ? '请先选择老师...' : '请选择学生...' }}
-                                </option>
-                                @foreach($students as $student)
-                                    <option value="{{ $student['student_id'] }}">{{ $student['name'] }}</option>
-                                @endforeach
-                            </select>
-                        </div>
+                <div class="space-y-2 text-slate-900">
+                    <h2 class="text-3xl font-bold">初中数学知识图谱</h2>
+                    <p class="text-lg text-slate-600">掌握度、互动与视觉反馈同步升级,点击任意节点查看学习路径与练习推荐。</p>
+                    <div class="flex flex-wrap items-center gap-4 text-base text-slate-600">
+                        <span>知识点 <span class="font-semibold text-slate-900" x-text="stats.nodes"></span></span>
+                        <span>额外关联 <span class="font-semibold text-slate-900" x-text="stats.extraEdges"></span></span>
+                        <span>已选学生:<span class="font-semibold text-slate-900">{{ $selectedStudentName ?: '未选择' }}</span></span>
                     </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>
-                    <span class="inline-flex items-center gap-1 ml-2 border-l border-gray-300 pl-2">
-                        <span class="h-2 w-4 rounded-full" style="background: linear-gradient(90deg, #ef4444 0%, #22c55e 100%);"></span> 掌握度
-                    </span>
-                    <span class="inline-flex items-center gap-1 text-xs">
-                        <span class="text-red-500">●</span> < 60% (薄弱)
-                    </span>
-                    <span class="inline-flex items-center gap-1 text-xs">
-                        <span class="text-yellow-500">●</span> 60-85% (良好)
-                    </span>
-                    <span class="inline-flex items-center gap-1 text-xs">
-                        <span class="text-green-500">●</span> > 85% (优秀)
-                    </span>
-                    <span class="inline-flex items-center gap-1 text-xs">
-                        <span class="text-yellow-400 text-sm">★</span> 大师级
-                    </span>
+                <div class="grid w-full max-w-xl grid-cols-1 gap-3 rounded-lg border border-slate-200 bg-slate-50 p-4 sm:grid-cols-2 text-slate-900">
+                    <div>
+                        <label class="mb-1 block text-base font-medium text-slate-700">选择老师</label>
+                        <select
+                            wire:model.live="selectedTeacherId"
+                            class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-base text-slate-900 placeholder:text-slate-500 focus:border-sky-300 focus:ring-2 focus:ring-sky-200/50"
+                        >
+                            <option value="" class="text-slate-800">请选择老师...</option>
+                            @foreach($teachers as $teacher)
+                                <option value="{{ $teacher['teacher_id'] }}" class="text-slate-900">{{ $teacher['name'] }}</option>
+                            @endforeach
+                        </select>
+                    </div>
+                    <div>
+                        <label class="mb-1 block text-base font-medium text-slate-700">选择学生</label>
+                        <select
+                            wire:model.live="selectedStudentId"
+                            class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-base text-slate-900 placeholder:text-slate-500 focus:border-amber-300 focus:ring-2 focus:ring-amber-200/50"
+                            {{ empty($selectedTeacherId) ? 'disabled' : '' }}
+                        >
+                            <option value="" class="text-slate-800">
+                                {{ empty($selectedTeacherId) ? '请先选择老师...' : '请选择学生...' }}
+                            </option>
+                            @foreach($students as $student)
+                                <option value="{{ $student['student_id'] }}" class="text-slate-900">{{ $student['name'] }}</option>
+                            @endforeach
+                        </select>
+                    </div>
                 </div>
             </div>
         </div>
 
-        <div
-            wire:ignore
-            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 },
-
-                // 学生选择相关
-                // 学生选择相关 - 现在由Livewire管理
-                masteryData: {}, // 存储掌握度数据 { 'F01': 80, 'F02': 65, ... }
-
-
-                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 Promise.all([
-                            this.loadData(),
-                        ]);
-                        this.applyInitialCollapse(this.treeData);
-                        this.renderGraph();
-                        window.addEventListener('resize', () => this.resizeGraph());
-
-                        // 监听 Livewire 事件
-                        window.addEventListener('mastery-updated', (event) => {
-                            console.log('Mastery updated:', event.detail.data);
-                            this.masteryData = event.detail.data || {};
-                            this.refreshGraph();
-                        });
-                    } catch (err) {
-                        console.error('初始化思维导图失败', err);
-                    }
-                },
-                // loadTeachers, loadStudents, loadMasteryData 已移除,由 Livewire 处理
-                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,
-                    };
-                },
-                refreshGraph() {
-                    if (this.graph && this.treeData) {
-                        // 重新装饰树数据以应用掌握度
-                        const decoratedData = this.decorateTree(this.treeData);
-                        this.graph.changeData(decoratedData);
-                        this.graph.render();
-                        this.graph.fitView(24);
-                    }
-                },
-                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 dynamicMastery = this.masteryData[id] || this.masteryData[node.code] || 0;
-                    const staticMastery = node.mastery_level || 0;
-
-                    const meta = {
-                        code: node.code || node.id || '',
-                        name: label,
-                        direct_score: node.direct_score || [],
-                        related_score: node.related_score || [],
-                        skills: node.skills || [],
-                        mastery_level: dynamicMastery || staticMastery, // 动态掌握度优先
-                    };
-                    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 id = node.id;
-                    const code = node.meta?.code;
-                    // masteryData 现在是对象 { 'KP_CODE': { mastery_level: 0.8, total_attempts: 5, ... } }
-                    const masteryInfo = this.masteryData[id] || (code && this.masteryData[code]) || null;
-                    
-                    const masteryLevel = masteryInfo ? (masteryInfo.mastery_level || 0) : (node.meta.mastery_level || 0);
-                    const totalAttempts = masteryInfo ? (masteryInfo.total_attempts || 0) : 0;
-
-                    const { nodeStyle, labelCfg, size, icon } = this.getNodeLevelStyle(node.depth, masteryLevel, totalAttempts);
-                    
-                    // 构建带图标的标签
-                    let label = `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`;
-                    if (icon) {
-                        label += ` ${icon}`;
-                    }
-
-                    // 更新meta中的掌握度,以便tooltip使用
-                    const meta = { 
-                        ...node.meta, 
-                        mastery_level: masteryLevel,
-                        total_attempts: totalAttempts,
-                        mastery_info: masteryInfo 
-                    };
-
-                    return {
-                        id: node.id,
-                        label: label,
-                        meta: meta,
-                        collapsed: node.collapsed,
-                        depth: node.depth,
-                        size,
-                        style: nodeStyle,
-                        labelCfg,
-                        children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
-                    };
-                },
-                getNodeLevelStyle(depth = 0, masteryLevel = 0, totalAttempts = 0) {
-                    const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
-
-                    // 根据掌握度调整颜色和样式
-                    let fillColor, strokeColor, shadowColor, shadowBlur, icon;
-                    
-                    // 只要有答题记录(totalAttempts > 0),即使掌握度为0,也视为"薄弱"(红色)
-                    // 如果没有答题记录,则保持默认样式(白色)
-                    const hasAttempts = totalAttempts > 0;
-
-                    if (masteryLevel >= 85) {
-                        // 85%以上:大师级(绿色 + 光晕 + 星星)
-                        fillColor = '#dcfce7'; // green-100
-                        strokeColor = '#16a34a'; // green-600
-                        shadowColor = 'rgba(34, 197, 94, 0.6)';
-                        shadowBlur = 10;
-                        icon = '★';
-                    } else if (masteryLevel >= 60) {
-                        // 60-85%:良好(黄色)
-                        fillColor = '#fef9c3'; // yellow-100
-                        strokeColor = '#ca8a04'; // yellow-600
-                        shadowColor = undefined;
-                        shadowBlur = 0;
-                        icon = '';
-                    } else if (masteryLevel > 0 || hasAttempts) {
-                        // 1-60% 或 掌握度为0但有答题记录:薄弱(红色)
-                        fillColor = '#fee2e2'; // red-100
-                        strokeColor = '#dc2626'; // red-600
-                        shadowColor = undefined;
-                        shadowBlur = 0;
-                        icon = '';
-                    } else {
-                        // 未掌握且无记录:默认
-                        fillColor = style.fill || '#fff';
-                        strokeColor = style.stroke || '#cbd5f5';
-                        shadowColor = undefined;
-                        shadowBlur = 0;
-                        icon = '';
-                    }
-
-                    return {
-                        size: style.size || 22,
-                        icon,
-                        nodeStyle: {
-                            fill: fillColor,
-                            stroke: strokeColor,
-                            lineWidth: masteryLevel > 0 ? 3 : 3, // 保持一致线条宽度,靠颜色区分
-                            radius: 6,
-                            shadowColor: shadowColor,
-                            shadowBlur: shadowBlur,
-                            cursor: 'pointer',
-                        },
-                        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 mastery = meta.mastery_level || 0;
-                    const attempts = meta.total_attempts || 0;
-                    
-                    // 进度条颜色
-                    let progressColorClass = 'bg-gray-300';
-                    let masteryColor = '#9ca3af';
-                    
-                    if (mastery >= 85) {
-                        progressColorClass = 'bg-green-500';
-                        masteryColor = '#22c55e';
-                    } else if (mastery >= 60) {
-                        progressColorClass = 'bg-yellow-500';
-                        masteryColor = '#eab308';
-                    } else if (mastery > 0 || attempts > 0) {
-                        progressColorClass = 'bg-red-500';
-                        masteryColor = '#ef4444';
-                    }
-
-                    const skills = (meta.skills || []).map(s => `<li class="text-[10px] text-gray-600">• ${s}</li>`).join('') || '<li class="text-[10px] text-gray-400 italic">暂无技能要点</li>';
-
-                    // 下一级所需经验(模拟)
-                    const nextLevel = mastery >= 100 ? '已满级' : `距离下一级还需 ${Math.max(0, 100 - mastery)} 点`;
-
-                    return `
-                        <div class="min-w-[260px] max-w-sm rounded-lg border border-gray-200 bg-white p-4 text-xs text-gray-700 shadow-xl">
-                            <div class="flex items-center justify-between mb-2">
-                                <div class="text-sm font-bold text-gray-900">${meta.code || model.id} · ${meta.name}</div>
-                                ${mastery >= 85 ? '<span class="px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 text-[10px] font-bold border border-yellow-200">★ 大师</span>' : ''}
-                            </div>
-                            
-                            <!-- 掌握度进度条 -->
-                            <div class="mb-3">
-                                <div class="flex justify-between text-[10px] text-gray-500 mb-1">
-                                    <span>掌握度 Lv.${Math.floor(mastery / 10)} <span class="text-gray-400 ml-1">(${attempts}次练习)</span></span>
-                                    <span class="font-medium" style="color: ${masteryColor}">${mastery}%</span>
-                                </div>
-                                <div class="h-2 w-full rounded-full bg-gray-100 overflow-hidden">
-                                    <div class="h-full rounded-full ${progressColorClass} transition-all duration-500" style="width: ${mastery}%"></div>
-                                </div>
-                                <div class="mt-1 text-[10px] text-gray-400 text-right">${nextLevel}</div>
-                            </div>
+        <div class="relative overflow-hidden rounded-2xl border border-slate-200 shadow-sm bg-white text-slate-900 knowledge-mindmap-card">
+            <div
+                wire:ignore
+                id="knowledge-mindmap"
+                class="knowledge-mindmap-canvas relative h-[82vh] min-h-[720px] w-full"
+            >
+                <div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.08),transparent_40%),radial-gradient(circle_at_80%_60%,rgba(255,255,255,0.06),transparent_45%)]"></div>
+            </div>
+        </div>
 
-                            <div class="grid grid-cols-2 gap-2 mb-3 bg-gray-50 p-2 rounded border border-gray-100">
-                                <div>
-                                    <div class="text-[10px] text-gray-500">直接得分</div>
-                                    <div class="font-medium">${range(meta.direct_score)}</div>
-                                </div>
-                                <div>
-                                    <div class="text-[10px] text-gray-500">关联得分</div>
-                                    <div class="font-medium">${range(meta.related_score)}</div>
-                                </div>
-                            </div>
+        <x-mindmap.detail-drawer
+            :open="$drawerOpen"
+            :details="$nodeDetails"
+            closeAction="closeDrawer"
+            selectAction="openDrawer"
+            panelTitle="知识点详情"
+        />
+    </div>
 
-                            <div>
-                                <div class="font-medium mb-1 flex items-center gap-1">
-                                    <svg class="w-3 h-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
-                                    技能要点
-                                </div>
-                                <ul class="list-none space-y-1 pl-1">
-                                    ${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
+    @push('styles')
+        <style>
+            .knowledge-mindmap-canvas {
+                background: #ffffff;
+                color: #0f172a;
+            }
+
+            .g6-grid {
+                opacity: 0.08;
+            }
+        </style>
+    @endpush
+
+    @push('scripts')
+        <script src="https://gw.alipayobjects.com/os/lib/antv/g6/4.8.24/dist/g6.min.js"></script>
+        <script src="{{ asset('js/g6-custom-node.js') }}"></script>
+        <script src="{{ asset('js/knowledge-mindmap-graph.js') }}"></script>
+    @endpush
+</x-filament::page>

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

@@ -0,0 +1,934 @@
+<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 class="flex-1">
+                    <h2 class="text-lg font-semibold text-gray-900">初中数学知识图谱</h2>
+                    <div class="mt-2 flex gap-4 text-xs text-gray-500">
+                        <div>知识点总数:<span x-text="stats.nodes"></span></div>
+                        <div>已选中学生:<span x-text="$wire.selectedStudentName || '未选择'"></span></div>
+                    </div>
+
+                    <!-- 学生选择器 -->
+                    <div class="mt-3 grid grid-cols-2 gap-3 max-w-md">
+                        <div>
+                            <label class="block text-xs font-medium text-gray-700 mb-1">选择老师</label>
+                            <select
+                                wire:model.live="selectedTeacherId"
+                                class="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                            >
+                                <option value="">请选择老师...</option>
+                                @foreach($teachers as $teacher)
+                                    <option value="{{ $teacher['teacher_id'] }}">{{ $teacher['name'] }}</option>
+                                @endforeach
+                            </select>
+                        </div>
+                        <div>
+                            <label class="block text-xs font-medium text-gray-700 mb-1">选择学生</label>
+                            <select
+                                wire:model.live="selectedStudentId"
+                                class="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                                {{ empty($selectedTeacherId) ? 'disabled' : '' }}
+                            >
+                                <option value="">
+                                    {{ empty($selectedTeacherId) ? '请先选择老师...' : '请选择学生...' }}
+                                </option>
+                                @foreach($students as $student)
+                                    <option value="{{ $student['student_id'] }}">{{ $student['name'] }}</option>
+                                @endforeach
+                            </select>
+                        </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>
+                    <span class="inline-flex items-center gap-1 ml-2 border-l border-gray-300 pl-2">
+                        <span class="h-2 w-4 rounded-full" style="background: linear-gradient(90deg, #ef4444 0%, #22c55e 100%);"></span> 掌握度
+                    </span>
+                    <span class="inline-flex items-center gap-1 text-xs">
+                        <span class="text-red-500">●</span> < 60% (薄弱)
+                    </span>
+                    <span class="inline-flex items-center gap-1 text-xs">
+                        <span class="text-yellow-500">●</span> 60-85% (良好)
+                    </span>
+                    <span class="inline-flex items-center gap-1 text-xs">
+                        <span class="text-green-500">●</span> > 85% (优秀)
+                    </span>
+                    <span class="inline-flex items-center gap-1 text-xs">
+                        <span class="text-yellow-400 text-sm">★</span> 大师级
+                    </span>
+                </div>
+            </div>
+        </div>
+
+        <div
+            wire:ignore
+            id="knowledge-mindmap"
+            class="knowledge-mindmap-container h-[80vh] min-h-[720px] w-full rounded-lg border border-gray-200 overflow-hidden relative"
+        ></div>
+
+        <!-- Drawer Component -->
+        <div 
+            x-show="$wire.drawerOpen" 
+            x-transition:enter="transition ease-out duration-300"
+            x-transition:enter-start="translate-x-full"
+            x-transition:enter-end="translate-x-0"
+            x-transition:leave="transition ease-in duration-200"
+            x-transition:leave-start="translate-x-0"
+            x-transition:leave-end="translate-x-full"
+            class="fixed inset-y-0 right-0 w-96 bg-white shadow-2xl z-50 overflow-y-auto"
+            style="display: none;"
+        >
+            <div class="p-6">
+                <!-- Header -->
+                <div class="flex items-center justify-between mb-6">
+                    <h3 class="text-lg font-bold text-gray-900">知识点详情</h3>
+                    <button 
+                        wire:click="closeDrawer"
+                        class="text-gray-400 hover:text-gray-600 transition"
+                    >
+                        <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
+                        </svg>
+                    </button>
+                </div>
+
+                @if(!empty($nodeDetails))
+                    <!-- Node Info -->
+                    <div class="mb-6">
+                        <h4 class="text-xl font-semibold text-gray-800 mb-2">{{ $nodeDetails['name'] ?? '' }}</h4>
+                        <p class="text-sm text-gray-500">编号: {{ $nodeDetails['code'] ?? '' }}</p>
+                    </div>
+
+                    <!-- Mastery Progress -->
+                    <div class="mb-6 p-4 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg">
+                        <div class="flex justify-between items-center mb-2">
+                            <span class="text-sm font-medium text-gray-700">掌握度</span>
+                            <span class="text-lg font-bold text-indigo-600">{{ number_format(($nodeDetails['mastery_level'] ?? 0) * 100, 1) }}%</span>
+                        </div>
+                        <div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
+                            <div 
+                                class="h-full rounded-full transition-all duration-500"
+                                style="width: {{ ($nodeDetails['mastery_level'] ?? 0) * 100 }}%; 
+                                       background: linear-gradient(90deg, #ef4444 0%, #eab308 50%, #22c55e 100%);"
+                            ></div>
+                        </div>
+                        <div class="mt-3 grid grid-cols-2 gap-2 text-xs">
+                            <div>
+                                <span class="text-gray-600">练习次数:</span>
+                                <span class="font-semibold ml-1">{{ $nodeDetails['total_attempts'] ?? 0 }}</span>
+                            </div>
+                            <div>
+                                <span class="text-gray-600">错误率:</span>
+                                <span class="font-semibold ml-1 text-red-600">{{ number_format(($nodeDetails['error_rate'] ?? 0) * 100, 1) }}%</span>
+                            </div>
+                        </div>
+                    </div>
+
+                    <!-- Prerequisites -->
+                    @if(!empty($nodeDetails['prerequisites']))
+                        <div class="mb-6">
+                            <h5 class="text-sm font-semibold text-gray-700 mb-3">前置知识</h5>
+                            <div class="space-y-2">
+                                @foreach($nodeDetails['prerequisites'] as $prereq)
+                                    <div class="flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer transition">
+                                        <span class="text-sm text-gray-700">{{ $prereq['name'] }}</span>
+                                        <span class="text-xs px-2 py-1 rounded-full {{ $prereq['mastery'] >= 0.6 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' }}">
+                                            {{ number_format($prereq['mastery'] * 100, 0) }}%
+                                        </span>
+                                    </div>
+                                @endforeach
+                            </div>
+                        </div>
+                    @endif
+
+                    <!-- Successors -->
+                    @if(!empty($nodeDetails['successors']))
+                        <div class="mb-6">
+                            <h5 class="text-sm font-semibold text-gray-700 mb-3">后续知识</h5>
+                            <div class="space-y-2">
+                                @foreach($nodeDetails['successors'] as $succ)
+                                    <div class="flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer transition">
+                                        <span class="text-sm text-gray-700">{{ $succ['name'] }}</span>
+                                        <span class="text-xs px-2 py-1 rounded-full {{ $succ['mastery'] >= 0.6 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' }}">
+                                            {{ number_format($succ['mastery'] * 100, 0) }}%
+                                        </span>
+                                    </div>
+                                @endforeach
+                            </div>
+                        </div>
+                    @endif
+
+                    <!-- Recommendations -->
+                    @if(!empty($nodeDetails['recommendations']))
+                        <div class="mb-6">
+                            <h5 class="text-sm font-semibold text-gray-700 mb-3">推荐练习</h5>
+                            <div class="space-y-3">
+                                @foreach($nodeDetails['recommendations'] as $rec)
+                                    <div class="p-3 border border-gray-200 rounded-lg hover:border-indigo-300 transition">
+                                        <div class="flex items-start justify-between mb-1">
+                                            <span class="text-sm font-medium text-gray-800">{{ $rec['title'] }}</span>
+                                            <span class="text-xs px-2 py-0.5 rounded-full 
+                                                {{ $rec['difficulty'] === '简单' ? 'bg-green-100 text-green-700' : '' }}
+                                                {{ $rec['difficulty'] === '中等' ? 'bg-yellow-100 text-yellow-700' : '' }}
+                                                {{ $rec['difficulty'] === '困难' ? 'bg-red-100 text-red-700' : '' }}
+                                            ">{{ $rec['difficulty'] }}</span>
+                                        </div>
+                                        <span class="text-xs text-gray-500">{{ $rec['type'] }}</span>
+                                    </div>
+                                @endforeach
+                            </div>
+                        </div>
+                    @endif
+
+                    <!-- Action Button -->
+                    <button class="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition shadow-md">
+                        开始练习
+                    </button>
+                @endif
+            </div>
+        </div>
+
+        <!-- Drawer Overlay -->
+        <div 
+            x-show="$wire.drawerOpen" 
+            @click="$wire.closeDrawer()"
+            x-transition:enter="transition ease-out duration-300"
+            x-transition:enter-start="opacity-0"
+            x-transition:enter-end="opacity-100"
+            x-transition:leave="transition ease-in duration-200"
+            x-transition:leave-start="opacity-100"
+            x-transition:leave-end="opacity-0"
+            class="fixed inset-0 bg-black bg-opacity-30 z-40"
+            style="display: none;"
+        ></div>
+    </div>
+
+    <style>
+        .knowledge-mindmap-container {
+            background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #60a5fa 100%);
+            position: relative;
+        }
+        
+        .knowledge-mindmap-container::before {
+            content: '';
+            position: absolute;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            background: 
+                radial-gradient(ellipse at 20% 30%, rgba(255,255,255,0.1) 0%, transparent 50%),
+                radial-gradient(ellipse at 80% 70%, rgba(255,255,255,0.08) 0%, transparent 50%);
+            pointer-events: none;
+        }
+    </style>
+</x-filament::page>
+
+@push('scripts')
+    <script src="https://gw.alipayobjects.com/os/lib/antv/g6/5.0.18/dist/g6.min.js"></script>
+    <script src="{{ asset('js/g6-custom-node.js') }}"></script>
+    <script src="{{ asset('js/knowledge-mindmap-graph.js') }}"></script>
+    <script>
+        document.addEventListener('alpine:init', () => {
+            window.knowledgeMindmap = () => ({
+                graphInstance: null,
+                stats: { nodes: 0, extraEdges: 0 },
+
+                async initMindmap() {
+                    try {
+                        if (!window.G6) {
+                            console.error('G6 未加载');
+                            return;
+                        }
+
+                        this.graphInstance = new KnowledgeMindmapGraph();
+                        await this.graphInstance.init();
+                        
+                        this.stats = this.graphInstance.stats;
+                    } catch (err) {
+                        console.error('初始化思维导图失败', err);
+                    }
+                },
+            });
+        });
+    </script>
+@endpush
+
+                graph: null,
+                treeData: null,
+                relationEdges: [],
+                stats: { nodes: 0, extraEdges: 0 },
+
+                // 学生选择相关
+                // 学生选择相关 - 现在由Livewire管理
+                masteryData: {}, // 存储掌握度数据 { 'F01': 80, 'F02': 65, ... }
+
+
+                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 Promise.all([
+                            this.loadData(),
+                        ]);
+                        this.applyInitialCollapse(this.treeData);
+                        this.renderGraph();
+                        window.addEventListener('resize', () => this.resizeGraph());
+
+                        // 监听 Livewire 事件
+                        window.addEventListener('mastery-updated', (event) => {
+                            console.log('Mastery updated:', event.detail.data);
+                            this.masteryData = event.detail.data || {};
+                            this.refreshGraph();
+                        });
+                    } catch (err) {
+                        console.error('初始化思维导图失败', err);
+                    }
+                },
+                // loadTeachers, loadStudents, loadMasteryData 已移除,由 Livewire 处理
+                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,
+                    };
+                },
+                refreshGraph() {
+                    if (this.graph && this.treeData) {
+                        // 重新装饰树数据以应用掌握度
+                        const decoratedData = this.decorateTree(this.treeData);
+                        this.graph.changeData(decoratedData);
+                        this.graph.render();
+                        this.graph.fitView(24);
+                    }
+                },
+                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 dynamicMastery = this.masteryData[id] || this.masteryData[node.code] || 0;
+                    const staticMastery = node.mastery_level || 0;
+
+                    const meta = {
+                        code: node.code || node.id || '',
+                        name: label,
+                        direct_score: node.direct_score || [],
+                        related_score: node.related_score || [],
+                        skills: node.skills || [],
+                        mastery_level: dynamicMastery || staticMastery, // 动态掌握度优先
+                    };
+                    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 id = node.id;
+                    const code = node.meta?.code;
+                    // masteryData 现在是对象 { 'KP_CODE': { mastery_level: 0.8, total_attempts: 5, ... } }
+                    const masteryInfo = this.masteryData[id] || (code && this.masteryData[code]) || null;
+                    
+                    const masteryLevel = masteryInfo ? (masteryInfo.mastery_level || 0) : (node.meta.mastery_level || 0);
+                    const totalAttempts = masteryInfo ? (masteryInfo.total_attempts || 0) : 0;
+
+                    const { nodeStyle, labelCfg, size, icon } = this.getNodeLevelStyle(node.depth, masteryLevel, totalAttempts);
+                    
+                    // 构建带图标的标签
+                    let label = `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`;
+                    if (icon) {
+                        label += ` ${icon}`;
+                    }
+
+                    // 更新meta中的掌握度,以便tooltip使用
+                    const meta = { 
+                        ...node.meta, 
+                        mastery_level: masteryLevel,
+                        total_attempts: totalAttempts,
+                        mastery_info: masteryInfo 
+                    };
+
+                    return {
+                        id: node.id,
+                        label: label,
+                        meta: meta,
+                        collapsed: node.collapsed,
+                        depth: node.depth,
+                        size,
+                        style: nodeStyle,
+                        labelCfg,
+                        children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
+                    };
+                },
+                getNodeLevelStyle(depth = 0, masteryLevel = 0, totalAttempts = 0) {
+                    const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
+
+                    // 根据掌握度调整颜色和样式
+                    let fillColor, strokeColor, shadowColor, shadowBlur, icon;
+                    
+                    // 只要有答题记录(totalAttempts > 0),即使掌握度为0,也视为"薄弱"(红色)
+                    // 如果没有答题记录,则保持默认样式(白色)
+                    const hasAttempts = totalAttempts > 0;
+
+                    if (masteryLevel >= 85) {
+                        // 85%以上:大师级(绿色 + 光晕 + 星星)
+                        fillColor = '#dcfce7'; // green-100
+                        strokeColor = '#16a34a'; // green-600
+                        shadowColor = 'rgba(34, 197, 94, 0.6)';
+                        shadowBlur = 10;
+                        icon = '★';
+                    } else if (masteryLevel >= 60) {
+                        // 60-85%:良好(黄色)
+                        fillColor = '#fef9c3'; // yellow-100
+                        strokeColor = '#ca8a04'; // yellow-600
+                        shadowColor = undefined;
+                        shadowBlur = 0;
+                        icon = '';
+                    } else if (masteryLevel > 0 || hasAttempts) {
+                        // 1-60% 或 掌握度为0但有答题记录:薄弱(红色)
+                        fillColor = '#fee2e2'; // red-100
+                        strokeColor = '#dc2626'; // red-600
+                        shadowColor = undefined;
+                        shadowBlur = 0;
+                        icon = '';
+                    } else {
+                        // 未掌握且无记录:默认
+                        fillColor = style.fill || '#fff';
+                        strokeColor = style.stroke || '#cbd5f5';
+                        shadowColor = undefined;
+                        shadowBlur = 0;
+                        icon = '';
+                    }
+
+                    return {
+                        size: style.size || 22,
+                        icon,
+                        nodeStyle: {
+                            fill: fillColor,
+                            stroke: strokeColor,
+                            lineWidth: masteryLevel > 0 ? 3 : 3, // 保持一致线条宽度,靠颜色区分
+                            radius: 6,
+                            shadowColor: shadowColor,
+                            shadowBlur: shadowBlur,
+                            cursor: 'pointer',
+                        },
+                        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 mastery = meta.mastery_level || 0;
+                    const attempts = meta.total_attempts || 0;
+                    
+                    // 进度条颜色
+                    let progressColorClass = 'bg-gray-300';
+                    let masteryColor = '#9ca3af';
+                    
+                    if (mastery >= 85) {
+                        progressColorClass = 'bg-green-500';
+                        masteryColor = '#22c55e';
+                    } else if (mastery >= 60) {
+                        progressColorClass = 'bg-yellow-500';
+                        masteryColor = '#eab308';
+                    } else if (mastery > 0 || attempts > 0) {
+                        progressColorClass = 'bg-red-500';
+                        masteryColor = '#ef4444';
+                    }
+
+                    const skills = (meta.skills || []).map(s => `<li class="text-[10px] text-gray-600">• ${s}</li>`).join('') || '<li class="text-[10px] text-gray-400 italic">暂无技能要点</li>';
+
+                    // 下一级所需经验(模拟)
+                    const nextLevel = mastery >= 100 ? '已满级' : `距离下一级还需 ${Math.max(0, 100 - mastery)} 点`;
+
+                    return `
+                        <div class="min-w-[260px] max-w-sm rounded-lg border border-gray-200 bg-white p-4 text-xs text-gray-700 shadow-xl">
+                            <div class="flex items-center justify-between mb-2">
+                                <div class="text-sm font-bold text-gray-900">${meta.code || model.id} · ${meta.name}</div>
+                                ${mastery >= 85 ? '<span class="px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 text-[10px] font-bold border border-yellow-200">★ 大师</span>' : ''}
+                            </div>
+                            
+                            <!-- 掌握度进度条 -->
+                            <div class="mb-3">
+                                <div class="flex justify-between text-[10px] text-gray-500 mb-1">
+                                    <span>掌握度 Lv.${Math.floor(mastery / 10)} <span class="text-gray-400 ml-1">(${attempts}次练习)</span></span>
+                                    <span class="font-medium" style="color: ${masteryColor}">${mastery}%</span>
+                                </div>
+                                <div class="h-2 w-full rounded-full bg-gray-100 overflow-hidden">
+                                    <div class="h-full rounded-full ${progressColorClass} transition-all duration-500" style="width: ${mastery}%"></div>
+                                </div>
+                                <div class="mt-1 text-[10px] text-gray-400 text-right">${nextLevel}</div>
+                            </div>
+
+                            <div class="grid grid-cols-2 gap-2 mb-3 bg-gray-50 p-2 rounded border border-gray-100">
+                                <div>
+                                    <div class="text-[10px] text-gray-500">直接得分</div>
+                                    <div class="font-medium">${range(meta.direct_score)}</div>
+                                </div>
+                                <div>
+                                    <div class="text-[10px] text-gray-500">关联得分</div>
+                                    <div class="font-medium">${range(meta.related_score)}</div>
+                                </div>
+                            </div>
+
+                            <div>
+                                <div class="font-medium mb-1 flex items-center gap-1">
+                                    <svg class="w-3 h-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
+                                    技能要点
+                                </div>
+                                <ul class="list-none space-y-1 pl-1">
+                                    ${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

+ 72 - 0
resources/views/filament/pages/student-dashboard.blade.php

@@ -87,6 +87,40 @@
         </div>
     </div>
 
+    @php
+        $mindmapAvg = collect($mindmapMasteryData ?? [])
+            ->pluck('mastery_level')
+            ->filter(fn ($v) => is_numeric($v))
+            ->avg() ?? 0;
+        $mindmapError = collect($mindmapMasteryData ?? [])
+            ->map(fn ($item) => is_array($item) ? max(0, 1 - ($item['accuracy_rate'] ?? 0)) : 0)
+            ->avg() ?? 0;
+    @endphp
+
+    <div
+        class="mb-10 space-y-4"
+        x-data="studentMindmapPanel('{{ $this->getId() }}')"
+        x-init="initMindmap()"
+        data-knowledge-mindmap-root
+    >
+        <div class="relative overflow-hidden rounded-2xl border border-slate-200 shadow-sm bg-white">
+            <div
+                wire:ignore
+                id="student-mindmap"
+                class="knowledge-mindmap-canvas relative h-[78vh] min-h-[680px] w-full"
+            >
+            </div>
+        </div>
+
+        <x-mindmap.detail-drawer
+            :open="$mindmapDrawerOpen"
+            :details="$mindmapNodeDetails"
+            closeAction="closeMindmapDrawer"
+            selectAction="openMindmapDrawer"
+            panelTitle="知识点详情"
+        />
+    </div>
+
     {{-- 错误提示 --}}
     @if ($errorMessage)
         <div class="mb-8">
@@ -698,6 +732,44 @@
 </div>
 @endif
 
+@push('styles')
+    <style>
+        .knowledge-mindmap-canvas {
+            background: #ffffff;
+        }
+    </style>
+@endpush
+
+@push('scripts')
+    <script src="https://gw.alipayobjects.com/os/lib/antv/g6/4.8.24/dist/g6.min.js"></script>
+    <script src="{{ asset('js/g6-custom-node.js') }}"></script>
+    <script src="{{ asset('js/knowledge-mindmap-graph.js') }}"></script>
+    <script>
+        document.addEventListener('alpine:init', () => {
+            window.studentMindmapPanel = (livewireId = null) => ({
+                graphInstance: null,
+                stats: { nodes: 0, extraEdges: 0 },
+                livewireId,
+                async initMindmap() {
+                    if (!window.KnowledgeMindmapGraph) {
+                        return;
+                    }
+
+                    this.graphInstance = new KnowledgeMindmapGraph({
+                        containerId: 'student-mindmap',
+                        livewireMethod: 'openMindmapDrawer',
+                        highlightLowMastery: true,
+                        livewireId: this.livewireId,
+                    });
+                    this.graphInstance.masteryData = @js($mindmapMasteryData ?? []);
+                    await this.graphInstance.init();
+                    this.stats = this.graphInstance.stats;
+                },
+            });
+        });
+    </script>
+@endpush
+
 {{-- 通知脚本 --}}
 <script>
     document.addEventListener('notify', (event) => {