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

refine v3 analysis module view and data wiring

Align the PDF cluster/module sections with real mastery sources, add impact-aware module metrics, and streamline the merged table layout for clearer actionable insights.

Made-with: Cursor
yemeishu 3 недель назад
Родитель
Сommit
1d0e1b21a8

+ 6 - 0
app/DTO/ExamAnalysisDataDto.php

@@ -14,8 +14,10 @@ class ExamAnalysisDataDto
         public readonly array $teacher,
         public readonly array $questions,
         public readonly array $mastery,
+        public readonly array $masteryMap,
         public readonly array $examHitKpCodes,
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
+        public readonly array $fullParentMasteryLevels, // 新增:全量父节点掌握度
         public readonly array $insights,
         public readonly array $recommendations,
         public readonly array $rawAnalysisData = [],
@@ -33,8 +35,10 @@ class ExamAnalysisDataDto
             teacher: $data['teacher'] ?? [],
             questions: $data['questions'] ?? [],
             mastery: $data['mastery'] ?? [],
+            masteryMap: $data['mastery_map'] ?? [],
             examHitKpCodes: $data['exam_hit_kp_codes'] ?? [],
             parentMasteryLevels: $data['parent_mastery_levels'] ?? [], // 新增:父节点掌握度数据
+            fullParentMasteryLevels: $data['full_parent_mastery_levels'] ?? [],
             insights: $data['insights'] ?? [],
             recommendations: $data['recommendations'] ?? [],
             rawAnalysisData: $data['analysis_data'] ?? [],
@@ -53,8 +57,10 @@ class ExamAnalysisDataDto
             'teacher' => $this->teacher,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
+            'mastery_map' => $this->masteryMap,
             'exam_hit_kp_codes' => $this->examHitKpCodes,
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
+            'full_parent_mastery_levels' => $this->fullParentMasteryLevels,
             'insights' => $this->insights,
             'recommendations' => $this->recommendations,
             'analysis_data' => $this->rawAnalysisData,

+ 6 - 0
app/DTO/ReportPayloadDto.php

@@ -14,8 +14,10 @@ class ReportPayloadDto
         public readonly array $teacher,
         public readonly array $questions,
         public readonly array $mastery,
+        public readonly array $masteryMap,
         public readonly array $examHitKpCodes,
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
+        public readonly array $fullParentMasteryLevels, // 新增:全量父节点掌握度
         public readonly array $questionInsights,
         public readonly array $recommendations,
         public readonly array $analysisData = []
@@ -32,8 +34,10 @@ class ReportPayloadDto
             teacher: $dto->teacher,
             questions: $dto->questions,
             mastery: $dto->mastery,
+            masteryMap: $dto->masteryMap,
             examHitKpCodes: $dto->examHitKpCodes,
             parentMasteryLevels: $dto->parentMasteryLevels, // 新增:父节点掌握度数据
+            fullParentMasteryLevels: $dto->fullParentMasteryLevels,
             questionInsights: $dto->insights,
             recommendations: $dto->recommendations,
             // 必须透传原始 analysis_data,模板依赖 question_analysis/knowledge_point_analysis 原始结构
@@ -52,8 +56,10 @@ class ReportPayloadDto
             'teacher' => $this->teacher,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
+            'mastery_map' => $this->masteryMap,
             'exam_hit_kp_codes' => $this->examHitKpCodes,
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
+            'full_parent_mastery_levels' => $this->fullParentMasteryLevels,
             'question_insights' => $this->questionInsights,
             'recommendations' => $this->recommendations,
             'analysis_data' => $this->analysisData,

+ 130 - 42
app/Services/ExamPdfExportService.php

@@ -426,6 +426,7 @@ class ExamPdfExportService
                 'mastery_count' => 0,
                 'question_max' => 0.0,
                 'question_obtained' => 0.0,
+                'question_count' => 0,
             ];
         }
 
@@ -475,6 +476,7 @@ class ExamPdfExportService
 
             $moduleAgg[$moduleCode]['question_max'] += $maxScore;
             $moduleAgg[$moduleCode]['question_obtained'] += max(0.0, min($obtainedScore, $maxScore));
+            $moduleAgg[$moduleCode]['question_count']++;
         }
 
         $moduleRows = [];
@@ -507,10 +509,10 @@ class ExamPdfExportService
                 'mastery_score_5' => $masteryScore5,
                 'status' => $this->resolveMasteryStatus($masteryLevel),
                 'kp_count' => $kpCount,
+                'question_count' => (int) ($agg['question_count'] ?? 0),
                 'exam_max_score' => round($examMax, 2),
                 'exam_obtained_score' => round($examObtained, 2),
                 'exam_score_rate' => $examRate,
-                'ability_text' => $this->defaultModuleAbilityText($moduleCode, $moduleNames[$moduleCode] ?? $moduleCode),
             ];
         }
 
@@ -594,11 +596,118 @@ class ExamPdfExportService
             $hitParentMap = [];
         }
 
-        // 第二块雷达图补充:父轴下子知识点扩散(突出本卷掌握度变化
+        // 第二块聚类视图数据:严格按学段知识树叶子节点分组(与 math.client-pc 层级一致
         $radarChildrenByModule = [];
+        $examHitCodes = $templateData['exam_hit_kp_codes'] ?? [];
+        if (! is_array($examHitCodes)) {
+            $examHitCodes = [];
+        }
+        $examHitSet = array_fill_keys(array_map(static fn ($v) => (string) $v, $examHitCodes), true);
+        $masteryMapForCluster = $templateData['mastery_map'] ?? [];
+        if (! is_array($masteryMapForCluster)) {
+            $masteryMapForCluster = [];
+        }
+        $kpChangeMap = [];
         foreach ($kpRows as $row) {
-            $kpId = trim((string) ($row['kp_id'] ?? ''));
-            if ($kpId === '') {
+            $code = trim((string) ($row['kp_code'] ?? $row['knowledge_point_code'] ?? $row['code'] ?? ''));
+            if ($code === '') {
+                continue;
+            }
+            $changeRaw = $row['mastery_change'] ?? $row['change'] ?? null;
+            if ($changeRaw === null || $changeRaw === '') {
+                continue;
+            }
+            $kpChangeMap[$code] = (float) $changeRaw;
+        }
+        if (empty($kpChangeMap)) {
+            $studentId = (string) ($templateData['student']['id'] ?? '');
+            $paperId = (string) ($templateData['paper']['id'] ?? '');
+            if ($studentId !== '' && $paperId !== '') {
+                try {
+                    $snapshot = DB::connection('mysql')
+                        ->table('knowledge_point_mastery_snapshots')
+                        ->where('student_id', $studentId)
+                        ->where('paper_id', $paperId)
+                        ->latest('snapshot_time')
+                        ->first();
+                    if ($snapshot && ! empty($snapshot->mastery_data)) {
+                        $snapshotData = json_decode((string) $snapshot->mastery_data, true);
+                        if (is_array($snapshotData)) {
+                            foreach ($snapshotData as $kpCode => $node) {
+                                if (! is_array($node)) {
+                                    continue;
+                                }
+                                $change = $node['change'] ?? null;
+                                if ($change === null && isset($node['current_mastery'], $node['previous_mastery'])) {
+                                    $change = floatval($node['current_mastery']) - floatval($node['previous_mastery']);
+                                }
+                                if ($change === null || $change === '') {
+                                    continue;
+                                }
+                                $code = trim((string) $kpCode);
+                                if ($code === '') {
+                                    continue;
+                                }
+                                $kpChangeMap[$code] = (float) $change;
+                            }
+                        }
+                    }
+                } catch (\Throwable $e) {
+                    Log::warning('ExamPdfExportService: 聚类变化值快照回填失败', [
+                        'paper_id' => $paperId,
+                        'student_id' => $studentId,
+                        'error' => $e->getMessage(),
+                    ]);
+                }
+            }
+        }
+        $stageCodes = $this->collectStageKnowledgePoints($rootCode);
+        $childrenByParent = [];
+        foreach ($kpMeta as $code => $meta) {
+            $p = trim((string) ($meta['parent_kp_code'] ?? ''));
+            if ($p !== '') {
+                $childrenByParent[$p][] = (string) $code;
+            }
+        }
+        $resolveHierarchyForCluster = function (string $kpId) use ($kpMeta, $rootCode): array {
+            $rootName = trim((string) ($kpMeta[$rootCode]['name'] ?? $rootCode));
+            $lineage = [];
+            $cursor = $kpId;
+            $guard = 0;
+            while ($cursor !== '' && isset($kpMeta[$cursor]) && $guard < 24) {
+                $nodeName = trim((string) ($kpMeta[$cursor]['name'] ?? $cursor));
+                if ($nodeName !== '') {
+                    $lineage[] = $nodeName;
+                }
+                $parent = trim((string) ($kpMeta[$cursor]['parent_kp_code'] ?? ''));
+                if ($parent === '' || $parent === $cursor || $parent === $rootCode) {
+                    break;
+                }
+                $cursor = $parent;
+                $guard++;
+            }
+            $nonRootPath = array_reverse($lineage); // 等价前端去 root 后的路径
+            $safePath = !empty($nonRootPath) ? $nonRootPath : [trim((string) ($kpMeta[$kpId]['name'] ?? $kpId))];
+            $pathCount = count($safePath);
+            $parentName = $safePath[$pathCount - 2] ?? $safePath[$pathCount - 1] ?? $rootName;
+            $grandParentName = $safePath[$pathCount - 3] ?? $safePath[0] ?? $parentName;
+            $greatGrandParentName = $safePath[$pathCount - 4] ?? $safePath[0] ?? $grandParentName;
+            $path = implode(' > ', array_filter(array_merge([$rootName], $safePath)));
+
+            return [
+                'parent_name' => $parentName,
+                'grand_parent_name' => $grandParentName,
+                'great_grand_parent_name' => $greatGrandParentName,
+                'path' => $path,
+            ];
+        };
+        foreach ($stageCodes as $kpId) {
+            $kpId = trim((string) $kpId);
+            if ($kpId === '' || ! isset($kpMeta[$kpId])) {
+                continue;
+            }
+            // 仅纳入叶子知识点,保证与前端 flattenKnowledgePoints 一致
+            if (! empty($childrenByParent[$kpId] ?? [])) {
                 continue;
             }
             $moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
@@ -607,15 +716,27 @@ class ExamPdfExportService
             }
             $parentCode = (string) ($kpMeta[$kpId]['parent_kp_code'] ?? '');
             $depth = $this->resolveDepthToModule($kpId, $moduleCode, $kpMeta);
-            $change = $row['change'] ?? null;
+            $hierarchy = $resolveHierarchyForCluster($kpId);
+            $masteryValue = array_key_exists($kpId, $masteryMapForCluster)
+                ? floatval($masteryMapForCluster[$kpId])
+                : null;
+            $changeValue = array_key_exists($kpId, $kpChangeMap)
+                ? (float) $kpChangeMap[$kpId]
+                : null;
+            $isHit = isset($examHitSet[$kpId]);
             $radarChildrenByModule[$moduleCode][] = [
                 'code' => $kpId,
                 'name' => $kpMeta[$kpId]['name'] ?? $kpId,
                 'parent_code' => $parentCode,
+                'parent_name' => $hierarchy['parent_name'],
+                'grand_parent_name' => $hierarchy['grand_parent_name'],
+                'great_grand_parent_name' => $hierarchy['great_grand_parent_name'],
+                'path' => $hierarchy['path'],
                 'depth' => $depth,
-                'change' => $change !== null ? round((float) $change, 4) : null,
-                'mastery_level' => isset($row['mastery_level']) ? round((float) $row['mastery_level'], 4) : null,
-                'changed' => $change !== null && abs((float) $change) > 0.0001,
+                'change' => $changeValue !== null ? round($changeValue, 4) : null,
+                'mastery_level' => $masteryValue !== null ? round($masteryValue, 4) : null,
+                'changed' => $changeValue !== null,
+                'is_hit' => $isHit,
             ];
         }
         foreach ($radarChildrenByModule as $moduleCode => $items) {
@@ -663,10 +784,6 @@ class ExamPdfExportService
             ];
         }
 
-        $foundation = $paths['keep'][0]['name'] ?? ($paths['boost'][0]['name'] ?? '核心模块');
-        $breakthrough = $paths['boost'][0]['name'] ?? ($paths['key'][0]['name'] ?? '重点模块');
-        $minimum = $paths['key'][0]['name'] ?? ($paths['boost'][0]['name'] ?? '薄弱模块');
-
         return [
             'summary' => [
                 'score_obtained' => round($allObtained, 1),
@@ -683,11 +800,6 @@ class ExamPdfExportService
             'modules' => $moduleRows,
             'dimensions' => $dimensions,
             'paths' => $paths,
-            'overall_plan' => [
-                "保基础(60%):稳住{$foundation}",
-                "拉中档(30%):突破{$breakthrough}",
-                "冲压轴(10%):优先补齐{$minimum}",
-            ],
         ];
     }
 
@@ -1387,31 +1499,6 @@ class ExamPdfExportService
         }
     }
 
-    private function defaultModuleAbilityText(string $moduleCode, ?string $moduleName = null): string
-    {
-        return match ($moduleCode) {
-            'M01' => '数感与运算、代数式处理、基础建模',
-            'M02' => '方程化归、分类讨论、不等式推理',
-            'M03' => '图形性质识别、辅助线与逻辑证明',
-            'M04' => '图形变化、度量计算、坐标与变换',
-            'M05' => '相似迁移、比例推导、勾股应用',
-            'M06' => '数据分析、统计图表、概率判断',
-            'M07' => '函数建模、图像理解、函数性质迁移',
-            'S01_000' => '集合表示、集合运算、集合关系判断',
-            'S02_000' => '命题结构、逻辑推理、充分必要条件辨析',
-            'S03_000' => '不等式变形、比较法、恒成立问题处理',
-            'S04_000' => '函数模型、图像性质、函数迁移应用',
-            'S05_000' => '导数概念、单调最值、切线与优化问题',
-            'S06_000' => '三角函数图像、恒等变换、向量运算',
-            'S07_000' => '空间想象、立体几何证明与计算',
-            'S08_000' => '坐标法、轨迹方程、圆锥曲线综合',
-            'S09_000' => '递推与通项、求和技巧、数列建模',
-            'S10_000' => '概率模型、统计推断、随机变量分析',
-            'S11_000' => '复数运算、代数几何意义转换',
-            default => '综合能力',
-        };
-    }
-
     private function resolveRadarStage(string $grade, array $fullParentLevels = []): string
     {
         $g = trim($grade);
@@ -2359,6 +2446,7 @@ class ExamPdfExportService
             'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
             'recommendations' => $recommendations,
             'analysis_data' => $analysisData,
+            'mastery_map' => $masteryMap,
         ];
     }
 

+ 654 - 152
resources/views/exam-analysis/pdf-report-v3.blade.php

@@ -4,7 +4,6 @@
     $radar = $v3['radar'] ?? [];
     $modules = $v3['modules'] ?? [];
     $paths = $v3['paths'] ?? ['keep' => [], 'boost' => [], 'key' => []];
-    $overallPlan = $v3['overall_plan'] ?? [];
 
     $rawPaperId = $paper['id'] ?? $paper['paper_id'] ?? 'unknown';
     preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
@@ -162,6 +161,399 @@
         return $b['rate'] <=> $a['rate'];
     });
 
+    $childMasteryStatus = function ($mastery): string {
+        if ($mastery === null) {
+            return '未学习';
+        }
+        $m = (float) $mastery * 100; // 与 math.client-pc 统一:0-100 阈值(85/60)
+        if ($m >= 85) {
+            return '已掌握';
+        }
+        if ($m >= 60) {
+            return '薄弱';
+        }
+        return '未入门';
+    };
+    $childStatusColor = function ($status): string {
+        return match ($status) {
+            '已掌握' => '#52c41a',
+            '薄弱' => '#faad14',
+            '未入门' => '#f5222d',
+            default => '#d9d9d9',
+        };
+    };
+    $calcStats = function (array $points): array {
+        $total = count($points);
+        $learned = 0;
+        $mastered = 0;
+        $weak = 0;
+        $beginner = 0;
+        $unlearned = 0;
+        foreach ($points as $p) {
+            if (($p['mastery_level'] ?? null) !== null) {
+                $learned++;
+            }
+            $status = (string) ($p['status'] ?? '未学习');
+            if ($status === '已掌握') {
+                $mastered++;
+            } elseif ($status === '薄弱') {
+                $weak++;
+            } elseif ($status === '未入门') {
+                $beginner++;
+            } else {
+                $unlearned++;
+            }
+        }
+        return [
+            'total' => $total,
+            'learned' => $learned,
+            'mastered' => $mastered,
+            'weak' => $weak,
+            'beginner' => $beginner,
+            'unlearned' => $unlearned,
+        ];
+    };
+
+    $clusterCards = [];
+    $allClusterPoints = [];
+    foreach ($radar as $moduleItem) {
+        $children = is_array($moduleItem['children'] ?? null) ? $moduleItem['children'] : [];
+        $greatMap = [];
+        foreach ($children as $child) {
+            $greatKey = trim((string) ($child['great_grand_parent_name'] ?? ''));
+            $greatKey = $greatKey !== '' ? $greatKey : '未分组';
+            $grandKey = trim((string) ($child['grand_parent_name'] ?? ''));
+            $grandKey = $grandKey !== '' ? $grandKey : '未分组';
+            $parentName = trim((string) ($child['parent_name'] ?? ''));
+            if ($parentName === '') {
+                $parentCode = trim((string) ($child['parent_code'] ?? ''));
+                $parentName = $parentCode !== '' ? $parentCode : '未分组';
+            }
+            $mastery = isset($child['mastery_level']) ? (float) $child['mastery_level'] : null;
+            $status = $childMasteryStatus($mastery);
+            if (!isset($greatMap[$greatKey])) {
+                $greatMap[$greatKey] = [];
+            }
+            if (!isset($greatMap[$greatKey][$grandKey])) {
+                $greatMap[$greatKey][$grandKey] = [];
+            }
+            if (!isset($greatMap[$greatKey][$grandKey][$parentName])) {
+                $greatMap[$greatKey][$grandKey][$parentName] = [];
+            }
+            $greatMap[$greatKey][$grandKey][$parentName][] = [
+                'code' => (string) ($child['code'] ?? ''),
+                'name' => (string) ($child['name'] ?? '未命名知识点'),
+                'path' => (string) ($child['path'] ?? ''),
+                'mastery_level' => $mastery,
+                'change' => isset($child['change']) ? (float) $child['change'] : null,
+                'status' => $status,
+                'color' => $childStatusColor($status),
+                'is_hit' => !empty($child['is_hit']),
+            ];
+        }
+
+        $greatGroups = [];
+        foreach ($greatMap as $greatName => $grandMap) {
+            $grandGroups = [];
+            foreach ($grandMap as $grandName => $parentMap) {
+                $parentGroups = [];
+                foreach ($parentMap as $parentName => $points) {
+                    usort($points, function ($a, $b) {
+                        $am = $a['mastery_level'] ?? -1;
+                        $bm = $b['mastery_level'] ?? -1;
+                        if ($am === $bm) {
+                            return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
+                        }
+                        return $bm <=> $am;
+                    });
+                    $parentGroups[] = [
+                        'parent_name' => $parentName,
+                        'points' => $points,
+                        'stats' => $calcStats($points),
+                    ];
+                }
+                // 子模块级过滤:整行没有任何掌握度数字则不显示
+                $parentGroups = array_values(array_filter($parentGroups, function ($pg) {
+                    return (($pg['stats']['learned'] ?? 0) > 0);
+                }));
+                if (empty($parentGroups)) {
+                    continue;
+                }
+                usort($parentGroups, function ($a, $b) {
+                    $sa = $a['stats'];
+                    $sb = $b['stats'];
+                    return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
+                });
+                $allGrandPoints = [];
+                foreach ($parentGroups as $pg) {
+                    $allGrandPoints = array_merge($allGrandPoints, $pg['points']);
+                }
+                $grandGroups[] = [
+                    'grand_name' => $grandName,
+                    'parent_groups' => $parentGroups,
+                    'stats' => $calcStats($allGrandPoints),
+                ];
+            }
+            // 大块级过滤:整块没有任何掌握度数字则不显示
+            $grandGroups = array_values(array_filter($grandGroups, function ($gg) {
+                return (($gg['stats']['learned'] ?? 0) > 0);
+            }));
+            if (empty($grandGroups)) {
+                continue;
+            }
+            usort($grandGroups, function ($a, $b) {
+                $sa = $a['stats'];
+                $sb = $b['stats'];
+                return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
+            });
+            $allGreatPoints = [];
+            foreach ($grandGroups as $gg) {
+                foreach ($gg['parent_groups'] as $pg) {
+                    $allGreatPoints = array_merge($allGreatPoints, $pg['points']);
+                }
+            }
+            $greatGroups[] = [
+                'great_name' => $greatName,
+                'grand_groups' => $grandGroups,
+                'stats' => $calcStats($allGreatPoints),
+            ];
+        }
+        usort($greatGroups, function ($a, $b) {
+            $sa = $a['stats'];
+            $sb = $b['stats'];
+            return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
+        });
+
+        // 严格参考 math.client-pc:扁平化为“grand 层卡片”(展示大块)
+        foreach ($greatGroups as $great) {
+            foreach (($great['grand_groups'] ?? []) as $grand) {
+                $gStats = $grand['stats'] ?? ['learned' => 0, 'total' => 0];
+                $clusterCards[] = [
+                    'great_name' => $great['great_name'] ?? '未分组',
+                    'grand_name' => $grand['grand_name'] ?? '未分组',
+                    'parent_groups' => $grand['parent_groups'] ?? [],
+                    'stats' => $gStats,
+                ];
+            }
+        }
+    }
+    usort($clusterCards, function ($a, $b) {
+        $sa = $a['stats'] ?? ['learned' => 0, 'total' => 0];
+        $sb = $b['stats'] ?? ['learned' => 0, 'total' => 0];
+        return (($sb['learned'] ?? 0) <=> ($sa['learned'] ?? 0))
+            ?: (($sb['total'] ?? 0) <=> ($sa['total'] ?? 0));
+    });
+    foreach ($clusterCards as $card) {
+        foreach (($card['parent_groups'] ?? []) as $pg) {
+            foreach (($pg['points'] ?? []) as $p) {
+                $allClusterPoints[] = $p;
+            }
+        }
+    }
+    $kpStatsTotal = [
+        'total' => count($allClusterPoints),
+        'mastered' => 0,
+        'weak' => 0,
+        'beginner' => 0,
+        'unlearned' => 0,
+    ];
+    foreach ($allClusterPoints as $p) {
+        $st = (string) ($p['status'] ?? '未学习');
+        if ($st === '已掌握') {
+            $kpStatsTotal['mastered']++;
+        } elseif ($st === '薄弱') {
+            $kpStatsTotal['weak']++;
+        } elseif ($st === '未入门') {
+            $kpStatsTotal['beginner']++;
+        } else {
+            $kpStatsTotal['unlearned']++;
+        }
+    }
+    $moduleRowsWithStatus = array_values(array_filter($modules, function ($m) {
+        $status = trim((string) ($m['status'] ?? ''));
+        $masteryLevel = $m['mastery_level'] ?? null;
+        if ($masteryLevel !== null) {
+            return true;
+        }
+        return $status !== '' && ! in_array($status, ['暂无', '-', '未涉及'], true);
+    }));
+    $pathTagByModuleName = [];
+    foreach (['keep' => '保分不错', 'boost' => '需要加强', 'key' => '优先加强'] as $bucket => $tagName) {
+        foreach (($paths[$bucket] ?? []) as $item) {
+            $n = trim((string) ($item['name'] ?? ''));
+            if ($n === '') {
+                continue;
+            }
+            $pathTagByModuleName[$n] = $tagName;
+        }
+    }
+    $impactedModules = array_values(array_filter($moduleRowsWithStatus, function ($m) {
+        return ((int) ($m['question_count'] ?? 0)) > 0;
+    }));
+    $radarModuleMap = [];
+    foreach ($radar as $moduleItem) {
+        $code = (string) ($moduleItem['code'] ?? '');
+        if ($code !== '') {
+            $radarModuleMap[$code] = $moduleItem;
+        }
+    }
+    $moduleImpactChangeMap = [];
+    foreach ($radarModuleMap as $moduleCode => $moduleItem) {
+        $hitChanges = [];
+        foreach (($moduleItem['children'] ?? []) as $child) {
+            if (empty($child['is_hit'])) {
+                continue;
+            }
+            $change = $child['change'] ?? null;
+            if ($change === null || ! is_numeric($change)) {
+                continue;
+            }
+            $hitChanges[] = (float) $change;
+        }
+        if (empty($hitChanges)) {
+            continue;
+        }
+        $moduleImpactChangeMap[$moduleCode] = array_sum($hitChanges) / count($hitChanges);
+    }
+    $questionTypeLabelMap = [
+        'choice' => '选择题',
+        'multiple_choice' => '选择题',
+        'single_choice' => '选择题',
+        'select' => '选择题',
+        'fill' => '填空题',
+        'blank' => '填空题',
+        'answer' => '解答题',
+        'solution' => '解答题',
+    ];
+    $kpQuestionTypeMap = [];
+    foreach (($questions ?? []) as $qItem) {
+        $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? ''));
+        if ($kpName === '') {
+            continue;
+        }
+        $rawType = strtolower(trim((string) ($qItem['question_type'] ?? '')));
+        $typeLabel = $questionTypeLabelMap[$rawType] ?? ((string) ($qItem['question_type'] ?? '未知题型'));
+        if ($typeLabel === '') {
+            $typeLabel = '未知题型';
+        }
+        if (! isset($kpQuestionTypeMap[$kpName])) {
+            $kpQuestionTypeMap[$kpName] = [];
+        }
+        $kpQuestionTypeMap[$kpName][$typeLabel] = true;
+    }
+    $moduleKpSuggestions = [];
+    foreach ($moduleRowsWithStatus as $m) {
+        $moduleCode = (string) ($m['module_code'] ?? '');
+        $moduleName = (string) ($m['module_name'] ?? '-');
+        $moduleChildren = $radarModuleMap[$moduleCode]['children'] ?? [];
+        if (! is_array($moduleChildren) || empty($moduleChildren)) {
+            continue;
+        }
+        $started = array_values(array_filter($moduleChildren, function ($c) {
+            return isset($c['mastery_level']) && $c['mastery_level'] !== null;
+        }));
+        usort($started, function ($a, $b) {
+            $am = (float) ($a['mastery_level'] ?? 0);
+            $bm = (float) ($b['mastery_level'] ?? 0);
+            if ($am === $bm) {
+                return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
+            }
+            return $am <=> $bm;
+        });
+
+        $weakest = null;
+        if (! empty($started)) {
+            $lowestStarted = $started[0];
+            $lowestStartedLevel = isset($lowestStarted['mastery_level']) ? (float) $lowestStarted['mastery_level'] : null;
+            if ($lowestStartedLevel !== null && $lowestStartedLevel < 0.85) {
+                // 规则1:已开始学习中掌握度最低
+                $weakest = $lowestStarted;
+            } else {
+                // 规则2:若已开始学习均达标(>=85%),取“最近的未学习”
+                $unlearned = array_values(array_filter($moduleChildren, function ($c) {
+                    return !isset($c['mastery_level']) || $c['mastery_level'] === null;
+                }));
+                if (! empty($unlearned)) {
+                    $anchorParent = (string) ($lowestStarted['parent_name'] ?? '');
+                    $anchorGrand = (string) ($lowestStarted['grand_parent_name'] ?? '');
+                    usort($unlearned, function ($a, $b) use ($anchorParent, $anchorGrand) {
+                        $score = function ($node) use ($anchorParent, $anchorGrand) {
+                            $parent = (string) ($node['parent_name'] ?? '');
+                            $grand = (string) ($node['grand_parent_name'] ?? '');
+                            if ($anchorParent !== '' && $parent === $anchorParent) {
+                                return 0;
+                            }
+                            if ($anchorGrand !== '' && $grand === $anchorGrand) {
+                                return 1;
+                            }
+                            return 2;
+                        };
+                        $sa = $score($a);
+                        $sb = $score($b);
+                        if ($sa === $sb) {
+                            return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
+                        }
+                        return $sa <=> $sb;
+                    });
+                    $weakest = $unlearned[0];
+                } else {
+                    $weakest = $lowestStarted;
+                }
+            }
+        } else {
+            // 没有已开始学习数据时,回退到模块内任一未学习点
+            $unlearned = array_values(array_filter($moduleChildren, function ($c) {
+                return !isset($c['mastery_level']) || $c['mastery_level'] === null;
+            }));
+            if (! empty($unlearned)) {
+                usort($unlearned, fn ($a, $b) => strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')));
+                $weakest = $unlearned[0];
+            }
+        }
+        if (! is_array($weakest)) {
+            continue;
+        }
+        $kpName = (string) ($weakest['name'] ?? '');
+        if ($kpName === '') {
+            continue;
+        }
+        $types = array_keys($kpQuestionTypeMap[$kpName] ?? []);
+        $moduleKpSuggestions[] = [
+            'module_name' => $moduleName,
+            'path_tag' => $pathTagByModuleName[$moduleName] ?? '待观察',
+            'kp_name' => $kpName,
+            'mastery_level' => $weakest['mastery_level'] ?? null,
+            'status' => $childMasteryStatus($weakest['mastery_level'] ?? null),
+            'question_types' => $types,
+        ];
+    }
+    $moduleSuggestionByName = [];
+    foreach ($moduleKpSuggestions as $sug) {
+        $name = trim((string) ($sug['module_name'] ?? ''));
+        if ($name !== '') {
+            $moduleSuggestionByName[$name] = $sug;
+        }
+    }
+    $kpChangeItems = array_values(array_filter($allClusterPoints, function ($p) {
+        $change = $p['change'] ?? null;
+        return $change !== null && is_numeric($change) && !empty($p['is_hit']);
+    }));
+    if (empty($kpChangeItems)) {
+        $kpChangeItems = array_values(array_filter($allClusterPoints, function ($p) {
+            $change = $p['change'] ?? null;
+            return $change !== null && is_numeric($change);
+        }));
+    }
+    usort($kpChangeItems, function ($a, $b) {
+        return abs((float) ($b['change'] ?? 0)) <=> abs((float) ($a['change'] ?? 0));
+    });
+    $kpPct = function (int $count, int $total): string {
+        if ($total <= 0) {
+            return '0.0%';
+        }
+        return number_format(($count * 100.0) / $total, 1) . '%';
+    };
+
 @endphp
 <!DOCTYPE html>
 <html lang="zh-CN">
@@ -266,6 +658,81 @@
         .radar-right { width: 100%; padding-left: 0; margin-top: 10px; }
         .radar-desc { border: 1px solid #dbeafe; background: #f8fbff; border-radius: 12px; padding: 12px; text-align: left; }
         .radar-item { display: block; margin: 6px 0; font-size: 12px; }
+        .cluster-toolbar {
+            margin-bottom: 8px;
+            font-size: 12px;
+            color: #475569;
+        }
+        .cluster-legend { display: inline-block; margin-right: 12px; }
+        .cluster-grid {
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 10px;
+        }
+        .cluster-card {
+            border: 1px solid #e2e8f0;
+            border-radius: 10px;
+            padding: 10px;
+            background: #fff;
+        }
+        .cluster-card-title {
+            font-size: 14px;
+            font-weight: 700;
+            color: #0f172a;
+            margin-bottom: 8px;
+        }
+        .cluster-subgroup {
+            border-left: 2px solid #e5e7eb;
+            padding-left: 8px;
+            margin-bottom: 8px;
+        }
+        .cluster-subgroup:last-child { margin-bottom: 0; }
+        .cluster-subgroup-title {
+            font-size: 12px;
+            font-weight: 600;
+            color: #334155;
+            margin-bottom: 4px;
+        }
+        .cluster-points {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 4px;
+        }
+        .cluster-point {
+            width: 10px;
+            height: 10px;
+            border-radius: 2px;
+            display: inline-block;
+            border: 1px solid rgba(148, 163, 184, 0.35);
+        }
+        .cluster-empty {
+            font-size: 12px;
+            color: #64748b;
+            background: #f8fafc;
+            border: 1px dashed #cbd5e1;
+            border-radius: 8px;
+            padding: 10px;
+        }
+        .kp-stats-grid {
+            display: grid;
+            grid-template-columns: repeat(5, 1fr);
+            border: 1px solid #e5e7eb;
+            border-radius: 10px;
+            overflow: hidden;
+            margin-bottom: 10px;
+        }
+        .kp-stat-item {
+            padding: 8px 10px;
+            border-right: 1px solid #e5e7eb;
+            background: #fff;
+        }
+        .kp-stat-item:last-child { border-right: none; }
+        .kp-stat-label { font-size: 11px; color: #64748b; }
+        .kp-stat-value { font-size: 18px; font-weight: 700; color: #111827; line-height: 1.2; margin-top: 2px; }
+        .kp-stat-rate { font-size: 11px; margin-left: 4px; font-weight: 600; }
+        .kp-change-box { margin-bottom: 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; padding: 10px 12px; }
+        .kp-change-list { margin: 4px 0 0 16px; padding: 0; }
+        .kp-change-list li { margin: 2px 0; color: #334155; }
         .kp-burst-card { margin-top: 10px; border: 1px solid #dbeafe; border-radius: 12px; padding: 10px; background: #fff; }
         .kp-burst-title { font-size: 13px; font-weight: 700; margin-bottom: 6px; color: #0b3a75; }
         .kp-burst-meta { font-size: 12px; color: #334155; margin-top: 6px; line-height: 1.6; }
@@ -275,16 +742,24 @@
         th, td { border: 1px solid #d0d7e2; padding: 8px 10px; text-align: left; vertical-align: top; }
         th { background: #f1f5f9; color: #1e293b; font-weight: 700; }
         .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; color: #fff; font-size: 11px; font-weight: 600; }
-        .path-stack { display: block; }
-        .path-box { border: 1px solid #e5e7eb; border-radius: 12px; padding: 12px 14px; margin-bottom: 10px; }
-        .path-box.keep { background: #f0fdf4; border-color: #86efac; }
-        .path-box.boost { background: #fff7ed; border-color: #fdba74; }
-        .path-box.key { background: #fff1f2; border-color: #fda4af; }
-        .path-title { font-size: 16px; font-weight: 700; margin-bottom: 6px; color: #111827; }
-        .path-box ul { margin: 0; padding-left: 16px; }
-        .path-box li { font-size: 13px; margin: 2px 0; }
-        .plan { background: #eef4ff; border-left: 4px solid #3b82f6; border-radius: 12px; padding: 12px 14px; }
-        .plan ol { margin: 0; padding-left: 18px; }
+        .module-table th { background: #edf2ff; color: #0f172a; }
+        .module-table th { text-align: center; }
+        .module-table td { line-height: 1.45; }
+        .module-table th,
+        .module-table td { vertical-align: middle; }
+        .module-table th:nth-child(8),
+        .module-table td:nth-child(8) { vertical-align: top; }
+        .module-table th:nth-child(1),
+        .module-table td:nth-child(1) { text-align: center; }
+        .module-table td:nth-child(2),
+        .module-table td:nth-child(3),
+        .module-table td:nth-child(5),
+        .module-table td:nth-child(6),
+        .module-table td:nth-child(7) { text-align: center; white-space: nowrap; }
+        .module-table td:nth-child(8) { font-size: 11px; color: #334155; }
+        .module-table tbody tr:nth-child(even) td { background: #fcfdff; }
+        .module-name { font-weight: 600; color: #0f172a; }
+        .impact-yes { color:#2563eb; font-weight:600; }
         .tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #334155; background: #e5e7eb; }
         .error-kp-tag { display: inline-block; margin: 0 6px 6px 0; padding: 1px 7px; border-radius: 999px; font-size: 10px; color: #334155; background: #f8fafc; border: 1px solid #d1d5db; }
         .error-kp-tag.high-risk { color: #b91c1c; border-color: #fca5a5; background: #fff; font-weight: 600; }
@@ -441,175 +916,202 @@
     </div>
 
     <div class="section">
-        <div class="section-title">二、知识点掌握雷达图</div>
-        <div class="radar-split">
-            <div class="radar-left">
-            <svg width="430" height="320" viewBox="0 0 430 320">
-                <polygon points="{{ $outerPoints }}" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
-                <polygon points="{{ $innerPoints }}" fill="rgba(59,130,246,0.28)" stroke="#3b82f6" stroke-width="2"/>
-                @foreach($outer as $i => $p)
-                    <line x1="{{ $cx }}" y1="{{ $cy }}" x2="{{ round($p[0],2) }}" y2="{{ round($p[1],2) }}" stroke="#e2e8f0" stroke-width="1"/>
-                @endforeach
-                @foreach($outer as $i => $p)
-                    @php
-                        $name = $radar[$i]['name'] ?? '';
-                        $children = $radar[$i]['children'] ?? [];
-                        $labelX = $p[0] + (($p[0] >= $cx) ? 9 : -9);
-                        $labelY = $p[1] + (($p[1] >= $cy) ? 12 : -8);
-                        $anchor = $p[0] >= $cx ? 'start' : 'end';
-                        $dotColor = $statusColor((string) ($radar[$i]['status'] ?? '暂无'));
-                        $value = number_format((float) ($radar[$i]['value'] ?? 0), 2);
-                        $axisAngle = atan2(($p[1] - $cy), ($p[0] - $cx));
-                    @endphp
-                    <text x="{{ round($labelX,2) }}" y="{{ round($labelY,2) }}" font-size="11" fill="#334155" text-anchor="{{ $anchor }}">{{ $name }} {{ $value }}</text>
-                    <circle cx="{{ round($inner[$i][0],2) }}" cy="{{ round($inner[$i][1],2) }}" r="4" fill="{{ $dotColor }}" />
-                    @if(!empty($children))
-                        @foreach($children as $cIdx => $child)
-                            @php
-                                $childN = max(1, count($children));
-                                $depth = max(1, intval($child['depth'] ?? 1));
-                                $offset = ($cIdx - (($childN - 1) / 2)) * 0.04;
-                                $childAngle = $axisAngle + $offset;
-                                // 子知识点必须从父轴外圈向外发散,避免父轴值低时挤在中心
-                                $axisOuterR = sqrt(pow(($outer[$i][0] - $cx), 2) + pow(($outer[$i][1] - $cy), 2));
-                                $startR = max($axisOuterR + 4, 112 + (($depth - 1) * 10));
-                                $endR = $startR + 12 + (($depth - 1) * 4);
-                                $sx = $cx + $startR * cos($childAngle);
-                                $sy = $cy + $startR * sin($childAngle);
-                                $ex = $cx + $endR * cos($childAngle);
-                                $ey = $cy + $endR * sin($childAngle);
-                                $changed = !empty($child['changed']);
-                                $cColor = $changed ? '#e11d48' : '#94a3b8';
-                                $cWidth = $changed ? 1.5 : 0.8;
-                                $masteryPct = isset($child['mastery_level']) ? max(0, min(100, (float) $child['mastery_level'] * 100)) : null;
-                                $label = $masteryPct !== null ? number_format($masteryPct, 1) . '%' : '—';
-                                $tx = $cx + ($endR + 5 + (($depth - 1) * 2)) * cos($childAngle);
-                                $ty = $cy + ($endR + 5 + (($depth - 1) * 2)) * sin($childAngle);
-                                $anchor = cos($childAngle) >= 0 ? 'start' : 'end';
-                            @endphp
-                            <line x1="{{ round($sx,2) }}" y1="{{ round($sy,2) }}" x2="{{ round($ex,2) }}" y2="{{ round($ey,2) }}"
-                                  stroke="{{ $cColor }}" stroke-width="{{ $cWidth }}" opacity="{{ $changed ? 0.95 : 0.85 }}"/>
-                            <circle cx="{{ round($ex,2) }}" cy="{{ round($ey,2) }}" r="{{ $changed ? 1.6 : 1.0 }}" fill="{{ $cColor }}" />
-                            <text x="{{ round($tx,2) }}" y="{{ round($ty,2) }}" font-size="9" fill="{{ $cColor }}" text-anchor="{{ $anchor }}">{{ $label }}</text>
+        <div class="section-title">二、知识点掌握聚类视图</div>
+        <div class="cluster-toolbar">
+            <span class="cluster-legend"><i class="dot" style="background:#52c41a"></i>已掌握</span>
+            <span class="cluster-legend"><i class="dot" style="background:#faad14"></i>薄弱</span>
+            <span class="cluster-legend"><i class="dot" style="background:#f5222d"></i>未入门</span>
+            <span class="cluster-legend"><i class="dot" style="background:#d9d9d9"></i>未学习</span>
+            <span>按“模块 → 子模块 → 知识点”聚类展示</span>
+        </div>
+        <div class="cluster-grid">
+            @foreach($clusterCards as $cluster)
+                <div class="cluster-card">
+                    <div class="cluster-card-title">
+                        {{ $cluster['grand_name'] }}
+                    </div>
+                    @if(!empty($cluster['parent_groups']))
+                        @foreach($cluster['parent_groups'] as $parent)
+                            <div class="cluster-subgroup">
+                                <div class="cluster-subgroup-title">{{ $parent['parent_name'] }}</div>
+                                <div class="cluster-points">
+                                    @foreach($parent['points'] as $point)
+                                        <span class="cluster-point"
+                                              style="background:{{ $point['color'] }}"
+                                              title="{{ $point['name'] }} · {{ $point['status'] }}{{ $point['mastery_level'] !== null ? '(' . number_format((float)$point['mastery_level'] * 100, 1) . '%)' : '' }}{{ $point['path'] !== '' ? ' · ' . $point['path'] : '' }}"></span>
+                                    @endforeach
+                                </div>
+                            </div>
                         @endforeach
+                    @else
+                        <div class="cluster-empty">当前模块暂无可展示的子知识点。</div>
                     @endif
-                @endforeach
-            </svg>
-            <div class="legend">
-                <span><i class="dot" style="background:#16a34a"></i>良好(4.0-5.0)</span>
-                <span><i class="dot" style="background:#f97316"></i>一般(2.0-3.9)</span>
-                <span><i class="dot" style="background:#e11d48"></i>薄弱(0-1.9)</span>
-                <span><i class="dot" style="background:#64748b"></i>未涉及</span>
-                <span><i class="dot" style="background:#94a3b8"></i>子知识点</span>
-                <span><i class="dot" style="background:#e11d48"></i>子知识点变化</span>
-                <span>外圈越远表示层级越深</span>
-            </div>
-            </div>
-            <div class="radar-right">
-            <div class="radar-desc">
-                <strong>雷达图解读</strong>
-                @foreach($radar as $item)
-                    @php $color = $statusColor((string) ($item['status'] ?? '暂无')); @endphp
-                    <span class="radar-item">
-                        <i class="dot" style="background:{{ $color }}"></i>
-                        {{ $item['name'] }}:{{ $item['status'] }}
-                        ({{ !empty($item['has_mastery']) ? number_format((float) ($item['value'] ?? 0), 2) . '/5' : '—' }})
-                        @if(!empty($item['children']))
-                            ,子知识点 {{ count($item['children']) }} 个
-                        @endif
-                    </span>
-                @endforeach
-            </div>
+                </div>
+            @endforeach
+        </div>
+        <div style="margin-top:10px;">
+            <div class="kp-stats-grid">
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">总知识点数</div>
+                    <div class="kp-stat-value">{{ $kpStatsTotal['total'] }}</div>
+                </div>
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">已掌握</div>
+                    <div class="kp-stat-value" style="color:#52c41a;">
+                        {{ $kpStatsTotal['mastered'] }}<span class="kp-stat-rate" style="color:#52c41a;">({{ $kpPct($kpStatsTotal['mastered'], $kpStatsTotal['total']) }})</span>
+                    </div>
+                </div>
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">薄弱</div>
+                    <div class="kp-stat-value" style="color:#faad14;">
+                        {{ $kpStatsTotal['weak'] }}<span class="kp-stat-rate" style="color:#faad14;">({{ $kpPct($kpStatsTotal['weak'], $kpStatsTotal['total']) }})</span>
+                    </div>
+                </div>
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">未入门</div>
+                    <div class="kp-stat-value" style="color:#f5222d;">
+                        {{ $kpStatsTotal['beginner'] }}<span class="kp-stat-rate" style="color:#f5222d;">({{ $kpPct($kpStatsTotal['beginner'], $kpStatsTotal['total']) }})</span>
+                    </div>
+                </div>
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">未学习</div>
+                    <div class="kp-stat-value" style="color:#9ca3af;">
+                        {{ $kpStatsTotal['unlearned'] }}<span class="kp-stat-rate" style="color:#9ca3af;">({{ $kpPct($kpStatsTotal['unlearned'], $kpStatsTotal['total']) }})</span>
+                    </div>
+                </div>
             </div>
         </div>
     </div>
 
     <div class="section">
-        <div class="section-title">三、模块能力分析表</div>
-        <table>
+        <div class="section-title">三、模块现状与提分路径(全局+本学案影响)</div>
+        <div class="kp-change-box">
+            <div style="font-size:12px;font-weight:700;color:#0f172a;">本学案知识点变化情况</div>
+            @if(!empty($kpChangeItems))
+                <ul class="kp-change-list">
+                    @foreach($kpChangeItems as $item)
+                        @php
+                            $delta = (float) ($item['change'] ?? 0);
+                            $deltaText = ($delta >= 0 ? '+' : '') . number_format($delta * 100, 1) . '%';
+                            $deltaColor = $delta > 0 ? '#16a34a' : ($delta < 0 ? '#dc2626' : '#64748b');
+                            $masteryText = isset($item['mastery_level']) && $item['mastery_level'] !== null
+                                ? number_format((float) $item['mastery_level'] * 100, 1) . '%'
+                                : '--';
+                        @endphp
+                        <li>
+                            {{ $item['name'] ?? '-' }}:
+                            <span style="color:{{ $deltaColor }};font-weight:600;">{{ $deltaText }}</span>
+                            (掌握度{{ $masteryText }},{{ $item['status'] ?? '未学习' }})
+                        </li>
+                    @endforeach
+                </ul>
+            @else
+                <div class="muted" style="margin-top:4px;">暂无可用的知识点变化数据</div>
+            @endif
+        </div>
+        <div style="margin-bottom:8px; padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff;">
+            <div style="font-size:12px;color:#334155;">
+                本次学案影响模块:
+                @if(!empty($impactedModules))
+                    @foreach($impactedModules as $idx => $im)
+                        @php $mName = $im['module_name'] ?? '-'; @endphp
+                        <span style="display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#3730a3;margin-right:4px;">
+                            {{ $mName }}({{ $im['question_count'] ?? 0 }}题)
+                        </span>
+                    @endforeach
+                @else
+                    <span class="muted">暂无命中模块</span>
+                @endif
+            </div>
+        </div>
+        <table class="module-table">
             <thead>
             <tr>
-                <th style="width: 16%;">模块</th>
-                <th style="width: 13%;">掌握分值</th>
+                <th style="width: 14%;">模块</th>
+                <th style="width: 10%; white-space: nowrap;">本次影响</th>
+                <th style="width: 18%;">掌握度<br>(学案影响)</th>
                 <th style="width: 12%;">掌握状态</th>
-                <th style="width: 10%;">样本数</th>
-                <th style="width: 12%;">得分率</th>
-                <th>学生当前能力</th>
+                <th style="width: 9%;">题目数</th>
+                <th style="width: 11%;">得分率</th>
+                <th style="width: 11%;">路径建议</th>
+                <th style="width: 25%;">关注知识点</th>
             </tr>
             </thead>
             <tbody>
-            @foreach($modules as $m)
+            @forelse($moduleRowsWithStatus as $m)
                 @php
                     $status = (string) ($m['status'] ?? '暂无');
                     $color = $statusColor($status);
                     $rate = $m['exam_score_rate'] ?? null;
+                    $qCount = (int) ($m['question_count'] ?? 0);
+                    $isImpacted = $qCount > 0;
+                    $pathTag = $pathTagByModuleName[(string) ($m['module_name'] ?? '')] ?? '待观察';
+                    $pathColor = match ($pathTag) {
+                        '优先加强' => '#ef4444',
+                        '需要加强' => '#f59e0b',
+                        '保分不错' => '#16a34a',
+                        default => '#64748b',
+                    };
+                    $moduleCode = (string) ($m['module_code'] ?? '');
+                    $impactDelta = $moduleImpactChangeMap[$moduleCode] ?? null;
+                    $impactArrow = $impactDelta === null
+                        ? ''
+                        : ($impactDelta > 0.0005 ? '↑' : ($impactDelta < -0.0005 ? '↓' : '→'));
+                    $impactColor = $impactDelta === null
+                        ? '#64748b'
+                        : ($impactDelta > 0.0005 ? '#16a34a' : ($impactDelta < -0.0005 ? '#dc2626' : '#64748b'));
+                    $impactText = $impactDelta === null ? '' : number_format(abs($impactDelta) * 100, 1) . '%';
+                    $impactSuffix = $impactArrow === '→' ? '' : $impactText;
+                    $moduleName = (string) ($m['module_name'] ?? '');
+                    $focus = $moduleSuggestionByName[$moduleName] ?? null;
+                    $focusText = '-';
+                    if (is_array($focus)) {
+                        $focusName = (string) ($focus['kp_name'] ?? '');
+                        $focusTypes = !empty($focus['question_types']) ? implode('、', $focus['question_types']) : '';
+                        $focusMastery = isset($focus['mastery_level']) && $focus['mastery_level'] !== null
+                            ? number_format((float) $focus['mastery_level'] * 100, 1) . '%'
+                            : '--';
+                        $focusSuffix = $focusTypes !== '' ? (',' . $focusTypes) : '';
+                        $focusText = $focusName !== ''
+                            ? ($focusName . '(' . $focusMastery . $focusSuffix . ')')
+                            : '-';
+                    }
                 @endphp
                 <tr>
-                    <td>{{ $m['module_name'] ?? '-' }}</td>
-                    <td>{{ $m['mastery_score_5'] !== null ? number_format((float) $m['mastery_score_5'], 2) . '/5' : '-' }}</td>
+                    <td><span class="module-name">{{ $m['module_name'] ?? '-' }}</span></td>
+                    <td>
+                        @if($isImpacted)
+                            <span class="impact-yes">是</span>
+                        @else
+                            <span class="muted">否</span>
+                        @endif
+                    </td>
+                    <td>
+                        {{ isset($m['mastery_level']) && $m['mastery_level'] !== null ? number_format((float) $m['mastery_level'] * 100, 1) . '%' : '-' }}
+                        @if($impactArrow !== '')
+                            <span style="margin-left:4px;color:{{ $impactColor }};font-weight:700;">
+                                {{ $impactArrow }}{{ $impactSuffix }}
+                            </span>
+                        @endif
+                    </td>
                     <td><span class="badge" style="background:{{ $color }}">{{ $status }}</span></td>
-                    <td>{{ $m['kp_count'] ?? 0 }}</td>
+                    <td>{{ $m['question_count'] ?? 0 }}</td>
                     <td>{{ $rate !== null ? number_format((float) $rate * 100, 1) . '%' : '-' }}</td>
-                    <td>{{ $m['ability_text'] ?? '-' }}</td>
+                    <td><span style="color:{{ $pathColor }}; font-weight:700;">{{ $pathTag }}</span></td>
+                    <td>{{ $focusText }}</td>
                 </tr>
-            @endforeach
+            @empty
+                <tr>
+                    <td colspan="8" class="muted">暂无掌握状态数据</td>
+                </tr>
+            @endforelse
             </tbody>
         </table>
     </div>
 
-    <div class="section">
-        <div class="section-title">四、分模块提分路径</div>
-        <div class="path-stack">
-            <div class="path-box keep">
-                <div class="path-title">保分模块(保持优势)</div>
-                <ul>
-                    @foreach(($paths['keep'] ?? []) as $item)
-                        <li>{{ $item['name'] }}:掌握度 {{ number_format((float) ($item['mastery_level'] ?? 0) * 100, 1) }}%</li>
-                    @endforeach
-                </ul>
-                @if(empty($paths['keep']))
-                    <div class="muted">暂无数据</div>
-                @endif
-            </div>
-            <div class="path-box boost">
-                <div class="path-title">涨分模块(重点突破)</div>
-                <ul>
-                    @foreach(($paths['boost'] ?? []) as $item)
-                        <li>{{ $item['name'] }}:掌握度 {{ number_format((float) ($item['mastery_level'] ?? 0) * 100, 1) }}%</li>
-                    @endforeach
-                </ul>
-                @if(empty($paths['boost']))
-                    <div class="muted">暂无数据</div>
-                @endif
-            </div>
-            <div class="path-box key">
-                <div class="path-title">提分关键(优先补短)</div>
-                <ul>
-                    @foreach(($paths['key'] ?? []) as $item)
-                        <li>{{ $item['name'] }}:掌握度 {{ number_format((float) ($item['mastery_level'] ?? 0) * 100, 1) }}%</li>
-                    @endforeach
-                </ul>
-                @if(empty($paths['key']))
-                    <div class="muted">暂无数据</div>
-                @endif
-            </div>
-        </div>
-    </div>
-
-    <div class="section">
-        <div class="section-title">五、整体提升方案</div>
-        <div class="plan">
-            <ol>
-                @foreach($overallPlan as $line)
-                    <li>{{ $line }}</li>
-                @endforeach
-            </ol>
-        </div>
-    </div>
-
     @if(!empty($wrongQuestions))
         <div class="section" style="page-break-inside:auto; break-inside:auto;">
-            <div class="section-title">、这次错题记录</div>
+            <div class="section-title">四、这次错题记录</div>
             @if(!empty($kpWrongStats))
                 <div style="margin-bottom:8px; padding:8px; border:1px solid #e5e7eb; border-radius:6px; background:#f8fafc;">
                     <div style="font-size:12px; font-weight:600; margin-bottom:6px;">知识点错误率</div>