Procházet zdrojové kódy

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

yemeishu před 1 měsícem
rodič
revize
2e7c8a1874

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

@@ -21,4 +21,105 @@ class KnowledgeMindmap extends Page
     protected static ?string $title = '知识图谱脑图';
 
     protected string $view = 'filament.pages.knowledge-mindmap';
+
+    public $teachers = [];
+    public $students = [];
+    public $selectedTeacherId = '';
+    public $selectedStudentId = '';
+    public $selectedStudentName = '';
+    public $masteryData = [];
+
+    public function mount(): void
+    {
+        $this->loadTeachers();
+    }
+
+    public function loadTeachers(): void
+    {
+        // Use the same logic as the controller but directly here
+        $teachers = \App\Models\Teacher::query()
+            ->select('teacher_id', 'name')
+            ->limit(10)
+            ->get();
+
+        if ($teachers->isEmpty()) {
+            $this->teachers = [
+                ['teacher_id' => 1, 'name' => '张老师'],
+                ['teacher_id' => 2, 'name' => '李老师'],
+                ['teacher_id' => 3, 'name' => '王老师'],
+            ];
+        } else {
+            $this->teachers = $teachers->toArray();
+        }
+    }
+
+    public function updatedSelectedTeacherId(): void
+    {
+        $this->students = [];
+        $this->selectedStudentId = '';
+        $this->selectedStudentName = '';
+        $this->masteryData = [];
+        
+        if (!$this->selectedTeacherId) {
+            return;
+        }
+
+        $students = \App\Models\Student::query()
+            ->where('teacher_id', $this->selectedTeacherId)
+            ->select('student_id', 'name')
+            ->limit(20)
+            ->get();
+
+        if ($students->isEmpty()) {
+             $this->students = [
+                ['student_id' => 101, 'name' => '学生A'],
+                ['student_id' => 102, 'name' => '学生B'],
+                ['student_id' => 103, 'name' => '学生C'],
+            ];
+        } else {
+            $this->students = $students->toArray();
+        }
+    }
+
+    public function updatedSelectedStudentId(): void
+    {
+        $this->masteryData = [];
+        $this->selectedStudentName = '';
+        
+        if (!$this->selectedStudentId) {
+            $this->dispatch('mastery-updated', data: []);
+            return;
+        }
+
+        // Find student name
+        $student = collect($this->students)->firstWhere('student_id', $this->selectedStudentId);
+        $this->selectedStudentName = $student['name'] ?? '';
+
+        try {
+            $service = app(\App\Services\LearningAnalyticsService::class);
+            $response = $service->getStudentMasteryList($this->selectedStudentId);
+            
+            $data = $response['data'] ?? [];
+            
+            // Transform to map: { 'KP_CODE': { mastery_level: 0.8, total_attempts: 5, ... } }
+            $masteryMap = [];
+            foreach ($data as $item) {
+                if (isset($item['kp_code'])) {
+                    $masteryMap[$item['kp_code']] = $item;
+                }
+            }
+            
+            $this->masteryData = $masteryMap;
+            
+            // Dispatch event to notify frontend to refresh graph
+            $this->dispatch('mastery-updated', data: $masteryMap);
+
+        } catch (\Exception $e) {
+            \Illuminate\Support\Facades\Log::error('Failed to fetch mastery data', [
+                'student_id' => $this->selectedStudentId,
+                'error' => $e->getMessage()
+            ]);
+            $this->dispatch('mastery-updated', data: []);
+        }
+    }
 }

+ 82 - 0
app/Http/Controllers/Admin/KnowledgeMindmapController.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Http\Controllers\Controller;
+use App\Models\Student;
+use App\Models\Teacher;
+use Illuminate\Http\Request;
+
+class KnowledgeMindmapController extends Controller
+{
+    public function getTeachers()
+    {
+        // For demonstration, return all teachers or a subset
+        // In a real app, this might be filtered by the logged-in user's permissions
+        $teachers = Teacher::query()
+            ->select('id as teacher_id', 'name')
+            ->limit(10)
+            ->get();
+
+        // If no teachers found, return some mock data for testing
+        if ($teachers->isEmpty()) {
+            return response()->json([
+                ['teacher_id' => 1, 'name' => '张老师'],
+                ['teacher_id' => 2, 'name' => '李老师'],
+                ['teacher_id' => 3, 'name' => '王老师'],
+            ]);
+        }
+
+        return response()->json($teachers);
+    }
+
+    public function getStudents($teacherId)
+    {
+        // Return students associated with the teacher
+        // Mocking logic if no relationship exists in DB for this demo
+        $students = Student::query()
+            ->select('id as student_id', 'name')
+            ->limit(20)
+            ->get();
+
+        if ($students->isEmpty()) {
+             return response()->json([
+                ['student_id' => 101, 'name' => '学生A'],
+                ['student_id' => 102, 'name' => '学生B'],
+                ['student_id' => 103, 'name' => '学生C'],
+            ]);
+        }
+        
+        return response()->json($students);
+    }
+
+    public function getMastery($studentId)
+    {
+        try {
+            $service = app(\App\Services\LearningAnalyticsService::class);
+            $response = $service->getStudentMasteryList($studentId);
+            
+            if (isset($response['error'])) {
+                return response()->json([]);
+            }
+
+            // The service returns data in a 'data' key or directly as an array depending on the endpoint
+            // Based on getStudentMastery implementation, it returns the full JSON response from the API
+            // which likely contains a 'data' key.
+            $data = $response['data'] ?? [];
+            
+            // Transform if necessary, but the view expects { kp_code: '...', mastery_level: ... }
+            // The API response format should be verified, but assuming it matches what we need
+            // or we map it here.
+            
+            // Let's ensure we return a list of { kp_code, mastery_level }
+            return response()->json($data);
+        } catch (\Exception $e) {
+            \Illuminate\Support\Facades\Log::error('Failed to fetch mastery data for mindmap', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage()
+            ]);
+            return response()->json([]);
+        }
+    }
+}

+ 214 - 29
resources/views/filament/pages/knowledge-mindmap.blade.php

@@ -6,14 +6,42 @@
     >
         <div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
             <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
-                <div>
-                    <h2 class="text-lg font-semibold text-gray-900">初中数学知识图谱 · 思维导图</h2>
-                    <p class="text-sm text-gray-500">
-                        tree.json 提供完整层级(模块 → 知识点),edges.json 描述跨节点关系;基于 AntV G6 MindMap 布局,节点可逐层展开/折叠并叠加前置/后继/兄弟/联合连线。
-                    </p>
+                <div class="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="stats.extraEdges"></span></div>
+                        <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">
@@ -29,11 +57,27 @@
                     <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="h-[80vh] min-h-[720px] w-full rounded-lg border border-gray-200 bg-white"
         ></div>
@@ -49,6 +93,12 @@
                 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);
@@ -180,14 +230,24 @@
                                 rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
                             }
                         });
-                        await this.loadData();
+                        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'),
@@ -203,18 +263,33 @@
                         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,
@@ -423,11 +498,36 @@
                     if (!node) {
                         return null;
                     }
-                    const { nodeStyle, labelCfg, size } = this.getNodeLevelStyle(node.depth);
+
+                    // 动态获取最新掌握度数据对象
+                    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: `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`,
-                        meta: node.meta,
+                        label: label,
+                        meta: meta,
                         collapsed: node.collapsed,
                         depth: node.depth,
                         size,
@@ -436,17 +536,57 @@
                         children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
                     };
                 },
-                getNodeLevelStyle(depth = 0) {
+                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: style.fill || '#fff',
-                            stroke: style.stroke || '#cbd5f5',
-                            lineWidth: 3,
+                            fill: fillColor,
+                            stroke: strokeColor,
+                            lineWidth: masteryLevel > 0 ? 3 : 3, // 保持一致线条宽度,靠颜色区分
                             radius: 6,
-                            shadowColor: undefined,
-                            shadowBlur: 0,
+                            shadowColor: shadowColor,
+                            shadowBlur: shadowBlur,
+                            cursor: 'pointer',
                         },
                         labelCfg: {
                             position: 'right',
@@ -501,20 +641,65 @@
                         return '<div class="text-xs text-gray-600">无数据</div>';
                     }
                     const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
-                    const skills = (meta.skills || [])
-                        .slice(0, 6)
-                        .map((skill) => `<li class="leading-snug">${skill.trim()}</li>`)
-                        .join('') || '<li>暂无技能</li>';
+                    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-[230px] max-w-sm rounded-md border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-lg">
-                            <div class="text-sm font-semibold text-gray-900 mb-1">${meta.code || model.id} · ${meta.name}</div>
-                            <div class="flex gap-3 text-xs">
-                                <span>直接:${range(meta.direct_score)}</span>
-                                <span>关联:${range(meta.related_score)}</span>
+                        <div 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="mt-2">
-                                <div class="font-medium">技能要点</div>
-                                <ul class="list-disc pl-5 space-y-0.5">
+                            
+                            <!-- 掌握度进度条 -->
+                            <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>

+ 2 - 0
routes/web.php

@@ -13,3 +13,5 @@ Route::get('/test-case', function() { return view('test-case'); });
 Route::view('/knowledge-mindmap-public', 'public.knowledge-mindmap');
 Route::get('/admin/intelligent-exam/pdf/{paper_id}', [\App\Http\Controllers\ExamPdfController::class, 'show'])->name('filament.admin.auth.intelligent-exam.pdf');
 
+
+