Explorar o código

Merge branch 'ye/optimize-analysis-report-v3' into main

Made-with: Cursor
yemeishu hai 3 semanas
pai
achega
0f36c2188c

+ 6 - 0
app/DTO/ExamAnalysisDataDto.php

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

+ 6 - 0
app/DTO/ReportPayloadDto.php

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1281 - 14
app/Services/ExamPdfExportService.php


+ 143 - 0
config/exam.php

@@ -90,4 +90,147 @@ return [
             'blank_is_wrong' => true,
             'blank_is_wrong' => true,
         ],
         ],
     ],
     ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | 学情报告 V3 话术配置
+    |--------------------------------------------------------------------------
+    |
+    | difficulty_explain_messages: 难度说明话术池
+    | 第一层 key: 难度状态(匹配/偏难/偏易/暂无)
+    | 第二层 key: 得分率档位(high/mid/low/unknown)
+    |
+    */
+    'analysis_report_v3' => [
+        'first_exam_messages_by_bucket' => [
+            // 分档:A(90-100), B(75-89), C(60-74), D(40-59), E(0-39)
+            'A' => [
+                '这次开局很稳,说明你的基础和状态都在线。',
+                '第一次就拿到高分,后续保持节奏会更强。',
+                '你的学习方法是有效的,继续按这个路径推进。',
+                '这是一个很好的起点,接下来可以适度挑战难题。',
+                '成绩很亮眼,说明你已经具备较强的掌握能力。',
+                '你的投入有明显回报,继续保持就会持续领先。',
+                '开局高分值得肯定,下一步重点是稳定输出。',
+                '这次表现优秀,后续可以往“又快又准”再升级。',
+                '你已经在高水平区间,继续打磨细节会更出色。',
+                '这是非常有竞争力的起步,继续冲就对了。',
+            ],
+            'B' => [
+                '这个分数是很不错的起点,方向完全正确。',
+                '你已经进入良好区间,再补几处薄弱点就能上台阶。',
+                '开局表现可圈可点,继续练会更稳定。',
+                '说明你有扎实基础,后续提升空间也很清晰。',
+                '这次成绩不错,下一步就是把失分点逐个清掉。',
+                '起步良好,继续保持专注,进步会很快。',
+                '你已经具备不错的能力,差的是一点点细节打磨。',
+                '这个起点很健康,后续很有机会冲到更高档。',
+                '成绩说明你在正轨上,继续按计划推进就行。',
+                '这次发挥稳定,接下来把短板补齐会很明显。',
+            ],
+            'C' => [
+                '这是正常且可提升的起点,先稳住基础最关键。',
+                '你已经有一定掌握度,接下来重点是补薄弱模块。',
+                '这个分数段提升通常很快,方向对了就会涨。',
+                '开局在中位区间,不焦虑,持续练习就会突破。',
+                '先把常错题型吃透,你的分数会明显上来。',
+                '这次结果能帮我们精准定位问题,价值很大。',
+                '起点清晰、空间也清晰,后续提升可期待。',
+                '你的基础在,下一步要把稳定性做出来。',
+                '这个阶段最怕放弃,最值得坚持。',
+                '继续按节奏推进,很快就能看到上升曲线。',
+            ],
+            'D' => [
+                '第一次这个分数不代表上限,只代表当前起点。',
+                '现在最重要的是先建立信心,再逐步提分。',
+                '这次结果很有价值,能帮你更精准地补基础。',
+                '先把核心概念补牢,分数会先稳再升。',
+                '这个阶段提升潜力很大,方法对了进步会很快。',
+                '不用和别人比,先和昨天的自己比就很好。',
+                '先做对“会做的题”,再攻“有难度的题”。',
+                '你现在需要的是节奏和耐心,不是否定自己。',
+                '起步偏低很常见,持续练习就会逐渐反转。',
+                '只要不放弃,这个分段通常最容易拉开增幅。',
+            ],
+            'E' => [
+                '第一次分数偏低很正常,先把学习路径走顺。',
+                '这不是结论,只是起点,我们从基础一点点重建。',
+                '先把会做题做稳,信心会先回来。',
+                '现在最关键的是“稳基础、慢提速”。',
+                '低分并不定义能力,持续训练才会定义结果。',
+                '先把核心知识补齐,后续提升会很明显。',
+                '今天看到的是起点,不是终点。',
+                '你需要的是清晰步骤,不是压力。',
+                '每次进步一点点,累计起来会很惊人。',
+                '从现在开始,踏实走每一步,结果一定会变。',
+            ],
+        ],
+        'difficulty_explain_messages' => [
+            '匹配' => [
+                'high' => [
+                    '本次题目难度与目标基本一致,且得分表现优秀,说明当前掌握质量与稳定性都较好。',
+                    '本卷难度与学案目标贴合,你在该难度下保持了高得分,当前阶段学习效果较扎实。',
+                    '难度匹配且得分率高,结果可信度高,可作为当前能力水平的有效反映。',
+                ],
+                'mid' => [
+                    '本次题目难度与目标一致,当前得分处于可提升区间,建议围绕失分点做定向巩固。',
+                    '难度匹配,成绩能够真实反映现阶段水平;下一步重点是把薄弱题型转为稳定得分。',
+                    '在目标难度下表现中位,说明基础已建立,建议通过专题训练提升稳定性。',
+                ],
+                'low' => [
+                    '本次题目难度与目标一致,当前得分偏低主要反映掌握度不足,建议先补核心基础再提速。',
+                    '难度与目标基本匹配,分数偏低具有诊断价值,优先处理高频错因会更有效。',
+                    '在匹配难度下得分偏低,建议先稳基础模块,再逐步扩大题型覆盖面。',
+                ],
+                'unknown' => [
+                    '本次题目整体难度与学案目标基本一致,结果可直接反映当前掌握水平。',
+                ],
+            ],
+            '偏难' => [
+                'high' => [
+                    '本次题目整体偏难,但你仍保持了高得分,说明在高压难度下也具备较强解题稳定性。',
+                    '虽然本卷难度高于目标区间,你依然取得了优秀得分,体现出明显的能力上限优势。',
+                    '题目偏难且得分率仍高,当前阶段可适度增加高阶题比例,持续验证上限。',
+                ],
+                'mid' => [
+                    '本次题目偏难,当前得分处于中位区间属正常表现,建议先巩固中档再逐步冲高档。',
+                    '在高于目标的难度下取得当前成绩,说明基础可用;下一步可聚焦中高难过渡题。',
+                    '题目整体偏难,分数受客观难度影响,建议优先补齐同模块关键中档题。',
+                ],
+                'low' => [
+                    '本次题目整体偏难,低得分中包含客观难度因素,建议先回到目标难度做稳态提升。',
+                    '由于题目难度超出目标区间,当前分数偏低可理解,建议先补齐基础与中档能力。',
+                    '本卷偏难且得分偏低,建议先通过同模块中档题建立稳定正确率,再冲高难。',
+                ],
+                'unknown' => [
+                    '本次题目整体偏难,错误率偏高有客观因素,建议先补齐同模块中档题再冲高档。',
+                ],
+            ],
+            '偏易' => [
+                'high' => [
+                    '本次题目整体偏易且得分较高,说明基础掌握较稳,建议补充更高一档难度验证上限。',
+                    '在低于目标难度的试卷上保持高得分,建议增加中高难题比例做进一步校准。',
+                    '题目偏易时获得高分符合预期,下一步可通过更高难度题检验真实上限。',
+                ],
+                'mid' => [
+                    '本次题目偏易但得分仍有提升空间,建议先排查基础失分点并提高稳定正确率。',
+                    '在偏易试卷下表现中位,说明基础环节仍有波动,建议先做易中题稳定训练。',
+                    '难度低于目标但得分未显著拉开,建议优先修复粗心与步骤性失分。',
+                ],
+                'low' => [
+                    '本次题目整体偏易,得分偏低提示基础环节存在短板,建议先做基础题稳固训练。',
+                    '在偏易难度下分数仍偏低,建议优先回补核心概念与高频基础题型。',
+                    '题目偏易但得分不理想,需先解决基础正确率问题,再考虑提升难度。',
+                ],
+                'unknown' => [
+                    '本次题目整体偏易,若得分高不代表上限已到,建议补充更高一档难度验证稳定性。',
+                ],
+            ],
+            '暂无' => [
+                'unknown' => [
+                    '暂无足够数据评估难度匹配。',
+                ],
+            ],
+        ],
+    ],
 ];
 ];

+ 283 - 0
public/mockups/cluster-focus-demo.html

@@ -0,0 +1,283 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>知识点聚类连线 - PDF场景复刻</title>
+  <style>
+    :root {
+      --bg: #f5f7fb;
+      --card: #ffffff;
+      --line: #0f172a;         /* 连线颜色(独立于标签文字) */
+      --line-dense: #111827;   /* 密集场景更深线色 */
+      --text: #0f172a;
+      --muted: #64748b;
+      --tag-text: #92400e;     /* 标签文字色 */
+      --dot-mastered: #22c55e;
+      --dot-weak: #f59e0b;
+      --dot-beginner: #ef4444;
+      --dot-unlearned: #d1d5db;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
+      color: var(--text);
+      background: var(--bg);
+      padding: 24px;
+    }
+    .wrap { max-width: 1420px; margin: 0 auto; }
+    h1 { margin: 0 0 8px; font-size: 34px; }
+    .legend { margin-bottom: 18px; color: var(--muted); font-size: 18px; }
+    .legend .dot {
+      width: 13px; height: 13px; border-radius: 999px; display: inline-block; margin: 0 6px 0 16px;
+      vertical-align: middle;
+    }
+    .grid {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 16px;
+    }
+    .card {
+      background: var(--card);
+      border: 1px solid #dfe4ea;
+      border-radius: 16px;
+      padding: 14px 16px 16px;
+      min-height: 250px;
+    }
+    .card h2 { margin: 0 0 10px; font-size: 38px; }
+    .group {
+      border-left: 3px solid #e5e7eb;
+      margin-bottom: 10px;
+      padding-left: 10px;
+      position: relative;
+      padding-right: 230px; /* 右侧留给标签区 */
+    }
+    .group-title { font-size: 34px; font-weight: 700; margin-bottom: 6px; line-height: 1.25; }
+    .points {
+      display: flex;
+      gap: 7px;
+      align-items: center;
+      flex-wrap: wrap;
+      min-height: 20px;
+      position: relative;
+    }
+    .point {
+      width: 16px;
+      height: 16px;
+      border-radius: 4px;
+      border: 1px solid #bfc7d1;
+      position: relative;
+      flex: 0 0 auto;
+    }
+    .mastered { background: var(--dot-mastered); }
+    .weak { background: var(--dot-weak); }
+    .beginner { background: var(--dot-beginner); }
+    .unlearned { background: var(--dot-unlearned); }
+
+    .point.focus {
+      box-shadow: 0 0 0 3px rgba(253, 224, 71, .45);
+    }
+    .focus-tag {
+      position: absolute;
+      left: 138px; /* 与最右侧方块拉开安全间距 */
+      top: 50%;
+      transform: translateY(-50%);
+      border: 2px solid var(--line);
+      border-radius: 999px;
+      padding: 2px 10px;
+      font-size: 27px;
+      line-height: 1.3;
+      color: var(--tag-text);
+      background: #fff7ed;
+      white-space: nowrap;
+      max-width: none;
+      overflow: visible;
+      text-overflow: clip;
+    }
+    .focus-tag.dense { left: 178px; top: 50%; }
+    .focus-tag.bottom { left: 148px; top: 44%; }
+
+    .focus-svg {
+      position: absolute;
+      left: 0;
+      top: -36px;
+      width: 162px;
+      height: 64px;
+      overflow: visible;
+      pointer-events: none;
+    }
+    .focus-svg path {
+      fill: none;
+      stroke: var(--line);
+      stroke-width: 2.2;
+      stroke-linecap: round;
+    }
+    .focus-svg.dense {
+      width: 170px;
+      height: 58px;
+      top: -30px;
+    }
+    .focus-svg.dense path { stroke: var(--line-dense); }
+    .note {
+      margin-top: 16px;
+      padding: 10px 14px;
+      border: 1px dashed #cbd5e1;
+      border-radius: 10px;
+      color: #334155;
+      background: #fff;
+      font-size: 16px;
+    }
+    .span-2 { grid-column: 1 / -1; min-height: 170px; }
+    .mini { min-height: 190px; }
+  </style>
+</head>
+<body>
+  <div class="wrap">
+    <h1>二、知识点掌握聚类视图(PDF场景复刻)</h1>
+    <div class="legend">
+      <span class="dot" style="background:var(--dot-mastered)"></span>已掌握
+      <span class="dot" style="background:var(--dot-weak)"></span>薄弱
+      <span class="dot" style="background:var(--dot-beginner)"></span>未入门
+      <span class="dot" style="background:var(--dot-unlearned)"></span>未学习
+      按“模块 -> 子模块 -> 知识点”聚类展示
+    </div>
+
+    <div class="grid">
+      <section class="card">
+        <h2>函数</h2>
+        <div class="group">
+          <div class="group-title">二次函数</div>
+          <div class="points">
+            <span class="point weak"></span><span class="point weak"></span><span class="point beginner"></span><span class="point beginner"></span><span class="point beginner"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">锐角三角函数</div>
+          <div class="points">
+            <span class="point beginner"></span>
+            <span class="point beginner focus">
+              <svg class="focus-svg" viewBox="0 0 150 64" aria-hidden="true">
+                <!-- 普通场景:起点锚定点中心(8,40),终点进入标签内侧(136,40) -->
+                <path d="M8,40 C16,40 22,26 40,20 C84,16 114,34 130,39 L136,40" />
+              </svg>
+              <span class="focus-tag">三角函数的应用</span>
+            </span>
+            <span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">反比例函数</div>
+          <div class="points">
+            <span class="point mastered"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+
+      <section class="card">
+        <h2>相似与勾股</h2>
+        <div class="group">
+          <div class="group-title">相似三角形判定</div>
+          <div class="points">
+            <span class="point mastered"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">相似三角形性质</div>
+          <div class="points">
+            <span class="point weak"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">勾股定理与直角三角形</div>
+          <div class="points">
+            <span class="point beginner focus">
+              <svg class="focus-svg" viewBox="0 0 150 64" aria-hidden="true">
+                <path d="M8,40 C16,40 22,26 38,20 C80,16 110,34 130,39 L136,40" />
+              </svg>
+              <span class="focus-tag">直角三角形性质</span>
+            </span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">相似与勾股在压轴题中的整合</div>
+          <div class="points">
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+
+      <section class="card span-2 mini">
+        <h2>图形变化与度量 / 图形度量</h2>
+        <div class="group">
+          <div class="group-title">立体几何度量(表面积与体积)</div>
+          <div class="points">
+            <span class="point beginner focus">
+              <svg class="focus-svg bottom" viewBox="0 0 150 64" aria-hidden="true">
+                <!-- 跨列底部场景:弧线更高,避免贴近点阵 -->
+                <path d="M8,40 C16,40 22,24 40,18 C82,15 112,33 130,39 L136,40" />
+              </svg>
+              <span class="focus-tag bottom">立体几何展开图</span>
+            </span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+
+      <section class="card">
+        <h2>方程与不等式</h2>
+        <div class="group">
+          <div class="group-title">一元二次方程</div>
+          <div class="points">
+            <span class="point beginner"></span><span class="point beginner"></span><span class="point beginner"></span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">分式方程</div>
+          <div class="points">
+            <span class="point beginner focus">
+              <svg class="focus-svg" viewBox="0 0 150 64" aria-hidden="true">
+                <path d="M8,40 C16,40 22,26 38,20 C80,16 110,34 130,39 L136,40" />
+              </svg>
+              <span class="focus-tag">分式方程的应用</span>
+            </span>
+            <span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+
+      <section class="card">
+        <h2>数与代数</h2>
+        <div class="group">
+          <div class="group-title">数的认识与运算</div>
+          <div class="points">
+            <span class="point beginner"></span><span class="point beginner"></span><span class="point weak"></span>
+            <span class="point beginner focus">
+              <svg class="focus-svg dense" viewBox="0 0 150 64" aria-hidden="true">
+                <!-- 密集场景:前段快速抬升 + 终点内插,避免擦到后续灰块 -->
+                <path d="M8,40 C14,40 20,26 34,22 C78,18 124,26 154,34 L162,36" />
+              </svg>
+              <span class="focus-tag dense">幂与指数</span>
+            </span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">代数式与整式运算</div>
+          <div class="points">
+            <span class="point beginner"></span><span class="point beginner"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+    </div>
+
+    <div class="note">
+      这版覆盖了 PDF 中常见情况:长标题换行、密集点阵、上下卡片、多个关注点位置、右侧标签区。你确认这页视觉后,我再同步回正式模板。
+    </div>
+  </div>
+</body>
+</html>
+

+ 1286 - 0
resources/views/exam-analysis/pdf-report-v3.blade.php

@@ -0,0 +1,1286 @@
+@php
+    $v3 = $v3 ?? [];
+    $summary = $v3['summary'] ?? [];
+    $radar = $v3['radar'] ?? [];
+    $modules = $v3['modules'] ?? [];
+    $paths = $v3['paths'] ?? ['keep' => [], 'boost' => [], 'key' => []];
+
+    $rawPaperId = $paper['id'] ?? $paper['paper_id'] ?? 'unknown';
+    preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
+    $reportCode = $matches[1] ?? preg_replace('/[^0-9]/', '', (string) $rawPaperId);
+    $generateDateTime = now()->format('Y年m月d日 H:i:s');
+
+    $scoreObtained = $summary['score_obtained'] ?? null;
+    $scoreTotal = $summary['score_total'] ?? null;
+    $scoreRate = $summary['score_rate'] ?? null;
+    $averageMastery = $summary['average_mastery'] ?? null;
+    $examHitKpSet = array_fill_keys(array_map('strval', $exam_hit_kp_codes ?? []), true);
+    $difficultySummary = $summary['difficulty'] ?? [];
+    $comparisonSummary = $summary['comparison'] ?? [];
+    $overallLabelDetail = $summary['overall_label_detail'] ?? [];
+    $historySummary = $comparisonSummary['history'] ?? [];
+    $peerSummary = $comparisonSummary['peers'] ?? [];
+    $overallScore = isset($overallLabelDetail['composite_score']) ? (float) $overallLabelDetail['composite_score'] : null;
+    $overallGrade = (string) ($overallLabelDetail['grade'] ?? 'D');
+    $currentPart = (float) ($overallLabelDetail['current_score'] ?? 0);
+    $historyPart = (float) ($overallLabelDetail['history_score'] ?? 0);
+    $peerPart = (float) ($overallLabelDetail['peer_score'] ?? 0);
+    $adjustPart = (float) ($overallLabelDetail['difficulty_adjust'] ?? 0);
+    $compositeFormulaResult = (0.50 * $currentPart) + (0.25 * $historyPart) + (0.25 * $peerPart) + $adjustPart;
+    $overallBadge = function (string $grade): array {
+        return match ($grade) {
+            'S' => ['bg' => '#f5f3ff', 'border' => '#6d28d9', 'text' => '#6d28d9', 'class' => 'badge-s'],
+            'A' => ['bg' => '#ecfdf3', 'border' => '#22c55e', 'text' => '#166534', 'class' => 'badge-excellent'],
+            'B' => ['bg' => '#eff6ff', 'border' => '#3b82f6', 'text' => '#1d4ed8', 'class' => 'badge-good'],
+            'C' => ['bg' => '#fff7ed', 'border' => '#f59e0b', 'text' => '#b45309', 'class' => 'badge-average'],
+            default => ['bg' => '#fef2f2', 'border' => '#ef4444', 'text' => '#b91c1c', 'class' => 'badge-weak'],
+        };
+    };
+    $overallVisual = $overallBadge((string) $overallGrade);
+    $trendVisual = function (string $trend): array {
+        return match ($trend) {
+            '显著提升' => ['icon' => '▲', 'color' => '#16a34a'],
+            '小幅提升' => ['icon' => '↗', 'color' => '#0ea5e9'],
+            '基本持平' => ['icon' => '•', 'color' => '#64748b'],
+            '小幅回落' => ['icon' => '↘', 'color' => '#f59e0b'],
+            '明显回落' => ['icon' => '▼', 'color' => '#ef4444'],
+            default => ['icon' => '•', 'color' => '#64748b'],
+        };
+    };
+
+    $statusColor = function (string $status): string {
+        return match ($status) {
+            '已掌握' => '#16a34a',
+            '薄弱' => '#f59e0b',
+            '未入门' => '#ef4444',
+            default => '#64748b',
+        };
+    };
+
+    $analysisWrongMap = [];
+    foreach (($analysis_data['question_analysis'] ?? []) as $qa) {
+        $qid = $qa['question_bank_id'] ?? $qa['question_id'] ?? null;
+        if ($qid === null || $qid === '') {
+            continue;
+        }
+        $rawCorrect = $qa['is_correct'] ?? null;
+        $isWrongFromAnalysis = false;
+        if (is_array($rawCorrect)) {
+            $isWrongFromAnalysis = in_array(0, $rawCorrect, true);
+        } elseif ($rawCorrect !== null) {
+            $isWrongFromAnalysis = !boolval($rawCorrect);
+        }
+        if ($isWrongFromAnalysis) {
+            $analysisWrongMap[(string) $qid] = true;
+        }
+    }
+
+    $wrongQuestions = [];
+    foreach (($questions ?? []) as $qItem) {
+        $isCorrectProbe = $qItem['is_correct'] ?? null;
+        $studentAnswerProbe = $qItem['student_answer'] ?? null;
+        $correctAnswerProbe = $qItem['answer'] ?? ($qItem['correct_answer'] ?? null);
+        if ($isCorrectProbe === null && !empty($studentAnswerProbe) && !empty($correctAnswerProbe)) {
+            $isCorrectProbe = (trim((string) $studentAnswerProbe) === trim((string) $correctAnswerProbe)) ? 1 : 0;
+        }
+        $normalizedCorrect = $isCorrectProbe;
+        if ($isCorrectProbe !== null) {
+            $normalizedCorrect = is_bool($isCorrectProbe) ? ($isCorrectProbe ? 1 : 0) : intval($isCorrectProbe);
+        }
+        $qidProbe = (string) ($qItem['question_bank_id'] ?? $qItem['question_id'] ?? '');
+        $isWrongByAnalysis = ($qidProbe !== '' && isset($analysisWrongMap[$qidProbe]));
+        if ($normalizedCorrect === 0 || $isWrongByAnalysis) {
+            $wrongQuestions[] = $qItem;
+        }
+    }
+
+    $kpStats = [];
+    foreach (($questions ?? []) as $qItem) {
+        $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
+        $kpName = $kpName === '' ? '未标注知识点' : $kpName;
+        if (!isset($kpStats[$kpName])) {
+            $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
+        }
+        $kpStats[$kpName]['total']++;
+    }
+    foreach ($wrongQuestions as $qItem) {
+        $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
+        $kpName = $kpName === '' ? '未标注知识点' : $kpName;
+        if (!isset($kpStats[$kpName])) {
+            $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
+        }
+        $kpStats[$kpName]['wrong']++;
+    }
+    $kpWrongStats = [];
+    foreach ($kpStats as $kpName => $stat) {
+        if (($stat['wrong'] ?? 0) <= 0) {
+            continue;
+        }
+        $total = max(1, intval($stat['total'] ?? 0));
+        $wrong = intval($stat['wrong'] ?? 0);
+        $kpWrongStats[] = [
+            'kp_name' => $kpName,
+            'wrong' => $wrong,
+            'total' => $total,
+            'rate' => $wrong / $total,
+        ];
+    }
+    usort($kpWrongStats, function ($a, $b) {
+        if ($a['rate'] === $b['rate']) {
+            return $b['wrong'] <=> $a['wrong'];
+        }
+        return $b['rate'] <=> $a['rate'];
+    });
+
+    $pcMasteryPercent = function ($mastery): ?int {
+        if ($mastery === null) {
+            return null;
+        }
+        // 与 PC 保持一致:API 先保留 2 位小数,前端再 Math.round 到 0-100。
+        return (int) round(round((float) $mastery, 2) * 100);
+    };
+    $formatMasteryPct = function ($mastery) use ($pcMasteryPercent): string {
+        $percent = $pcMasteryPercent($mastery);
+        return $percent === null ? '-' : ($percent . '%');
+    };
+    $childMasteryStatus = function ($mastery) use ($pcMasteryPercent): string {
+        $m = $pcMasteryPercent($mastery);
+        if ($m === null) {
+            return '未学习';
+        }
+        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;
+        $hit = 0;
+        foreach ($points as $p) {
+            if (($p['mastery_level'] ?? null) !== null) {
+                $learned++;
+            }
+            if (! empty($p['is_hit'])) {
+                $hit++;
+            }
+            $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,
+            'hit' => $hit,
+        ];
+    };
+
+    $clusterCards = [];
+    $allStagePoints = [];
+    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 : '未分组';
+            }
+            $childMasteryLevel = isset($child['mastery_level']) ? (float) $child['mastery_level'] : null;
+            $status = $childMasteryStatus($childMasteryLevel);
+            if (!isset($greatMap[$greatKey])) {
+                $greatMap[$greatKey] = [];
+            }
+            if (!isset($greatMap[$greatKey][$grandKey])) {
+                $greatMap[$greatKey][$grandKey] = [];
+            }
+            if (!isset($greatMap[$greatKey][$grandKey][$parentName])) {
+                $greatMap[$greatKey][$grandKey][$parentName] = [];
+            }
+            $childCode = (string) ($child['code'] ?? '');
+            $childParentCode = (string) ($child['parent_code'] ?? '');
+            $isHit = !empty($child['is_hit'])
+                || ($childCode !== '' && isset($examHitKpSet[$childCode]))
+                || ($childParentCode !== '' && isset($examHitKpSet[$childParentCode]));
+            $greatMap[$greatKey][$grandKey][$parentName][] = [
+                'code' => $childCode,
+                'name' => (string) ($child['name'] ?? '未命名知识点'),
+                'parent_code' => $childParentCode,
+                'path' => (string) ($child['path'] ?? ''),
+                'mastery_level' => $childMasteryLevel,
+                'change' => isset($child['change']) ? (float) $child['change'] : null,
+                'status' => $status,
+                'color' => $childStatusColor($status),
+                'is_hit' => $isHit,
+            ];
+            $allStagePoints[] = [
+                'code' => $childCode,
+                'name' => (string) ($child['name'] ?? '未命名知识点'),
+                'parent_code' => $childParentCode,
+                'mastery_level' => $childMasteryLevel,
+                'status' => $status,
+                'change' => isset($child['change']) ? (float) $child['change'] : null,
+                'is_hit' => $isHit,
+            ];
+        }
+
+        $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) || (($pg['stats']['hit'] ?? 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) || (($gg['stats']['hit'] ?? 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[] = [
+                    'module_name' => (string) ($moduleItem['name'] ?? '未分组'),
+                    '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));
+    });
+    $kpStatsTotal = [
+        'total' => count($allStagePoints),
+        'mastered' => 0,
+        'weak' => 0,
+        'beginner' => 0,
+        'unlearned' => 0,
+    ];
+    foreach ($allStagePoints 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;
+        $questionCount = (int) ($m['question_count'] ?? 0);
+        if ($masteryLevel !== null) {
+            return true;
+        }
+        return $questionCount > 0 && $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;
+        }
+    }
+    $globalPathTagByMastery = function ($mastery) use ($pcMasteryPercent): string {
+        if ($mastery === null || ! is_numeric($mastery)) {
+            return '待观察';
+        }
+        $m = $pcMasteryPercent($mastery);
+        if ($m >= 85) {
+            return '保分不错';
+        }
+        if ($m >= 60) {
+            return '需要加强';
+        }
+        return '优先加强';
+    };
+    $overallPathTag = function (string $tag): string {
+        return match ($tag) {
+            '优先加强' => '整体优先',
+            '需要加强' => '整体加强',
+            '保分不错' => '整体巩固',
+            default => $tag,
+        };
+    };
+    $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;
+        }
+    }
+    $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;
+        }
+        $moduleHitCandidates = [];
+        foreach (($mastery['items'] ?? []) as $item) {
+            $hitCode = trim((string) ($item['kp_code'] ?? $item['code'] ?? ''));
+            if ($hitCode === '') {
+                continue;
+            }
+            if (! empty($examHitKpSet) && ! isset($examHitKpSet[$hitCode])) {
+                continue;
+            }
+            $hitLevel = $item['mastery_level'] ?? null;
+            if ($hitLevel === null || ! is_numeric($hitLevel)) {
+                continue;
+            }
+            $matchedChild = null;
+            foreach ($moduleChildren as $child) {
+                $childCode = trim((string) ($child['code'] ?? ''));
+                $parentCode = trim((string) ($child['parent_code'] ?? ''));
+                if ($childCode === $hitCode || $parentCode === $hitCode) {
+                    $matchedChild = $child;
+                    break;
+                }
+            }
+            if (! is_array($matchedChild)) {
+                continue;
+            }
+            $moduleHitCandidates[$hitCode] = [
+                'code' => $hitCode,
+                'name' => (string) ($item['kp_name'] ?? $item['name'] ?? ($matchedChild['parent_name'] ?? $matchedChild['name'] ?? $hitCode)),
+                'parent_code' => (string) ($matchedChild['parent_code'] ?? ''),
+                'parent_name' => (string) ($matchedChild['parent_name'] ?? ''),
+                'grand_parent_name' => (string) ($matchedChild['grand_parent_name'] ?? ''),
+                'mastery_level' => (float) $hitLevel,
+                'is_hit' => true,
+            ];
+        }
+        $startedByCode = $moduleHitCandidates;
+        foreach (array_values(array_filter($moduleChildren, function ($c) {
+            return isset($c['mastery_level']) && $c['mastery_level'] !== null;
+        })) as $child) {
+            $childCode = trim((string) ($child['code'] ?? ''));
+            if ($childCode !== '' && ! isset($startedByCode[$childCode])) {
+                $startedByCode[$childCode] = $child;
+            }
+        }
+        $started = array_values($startedByCode);
+        usort($started, function ($a, $b) {
+            $am = (float) ($a['mastery_level'] ?? 0);
+            $bm = (float) ($b['mastery_level'] ?? 0);
+            if ($am === $bm) {
+                $ah = !empty($a['is_hit']) ? 0 : 1;
+                $bh = !empty($b['is_hit']) ? 0 : 1;
+                if ($ah !== $bh) {
+                    return $ah <=> $bh;
+                }
+                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 && ($pcMasteryPercent($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 {
+            // 没有已开始学习数据时,回退到模块内任一未学习点
+            $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)) {
+            $moduleKpSuggestions[] = [
+                'module_name' => $moduleName,
+                'path_tag' => $pathTagByModuleName[$moduleName] ?? '待观察',
+                'kp_name' => '',
+                'kp_code' => '',
+                'mastery_level' => null,
+                'status' => '当前模块暂无需额外关注知识点',
+                'is_empty' => true,
+            ];
+            continue;
+        }
+        $kpName = (string) ($weakest['name'] ?? '');
+        if ($kpName === '') {
+            continue;
+        }
+        $kpCode = (string) ($weakest['code'] ?? '');
+        $moduleKpSuggestions[] = [
+            'module_name' => $moduleName,
+            'path_tag' => $pathTagByModuleName[$moduleName] ?? '待观察',
+            'kp_name' => $kpName,
+            'kp_code' => $kpCode,
+            'mastery_level' => $weakest['mastery_level'] ?? null,
+            'status' => $childMasteryStatus($weakest['mastery_level'] ?? null),
+            'is_empty' => false,
+        ];
+    }
+    $moduleSuggestionByName = [];
+    foreach ($moduleKpSuggestions as $sug) {
+        $name = trim((string) ($sug['module_name'] ?? ''));
+        if ($name !== '') {
+            $moduleSuggestionByName[$name] = $sug;
+        }
+    }
+    $focusMarkerByCode = [];
+    foreach ($moduleKpSuggestions as $sug) {
+        if (! empty($sug['is_empty'])) {
+            continue;
+        }
+        $code = trim((string) ($sug['kp_code'] ?? ''));
+        $name = trim((string) ($sug['kp_name'] ?? ''));
+        if ($code === '' || $name === '') {
+            continue;
+        }
+        $focusMarkerByCode[$code] = [
+            'name' => $name,
+            'module_name' => (string) ($sug['module_name'] ?? ''),
+            'mastery_level' => $sug['mastery_level'] ?? null,
+        ];
+    }
+    $renderedFocusMarkerCodes = [];
+    $kpChangeItems = [];
+    foreach (($mastery['items'] ?? []) as $item) {
+        $code = trim((string) ($item['kp_code'] ?? $item['code'] ?? ''));
+        if ($code !== '' && ! empty($examHitKpSet) && ! isset($examHitKpSet[$code])) {
+            continue;
+        }
+        $level = $item['mastery_level'] ?? null;
+        if ($level === null || ! is_numeric($level)) {
+            continue;
+        }
+        $change = $item['mastery_change'] ?? $item['change'] ?? 0.0;
+        $kpChangeItems[] = [
+            'code' => $code,
+            'name' => (string) ($item['kp_name'] ?? $item['name'] ?? ($code !== '' ? $code : '-')),
+            'mastery_level' => (float) $level,
+            'change' => is_numeric($change) ? (float) $change : 0.0,
+            'status' => $childMasteryStatus((float) $level),
+            'is_hit' => true,
+        ];
+    }
+    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) . '%';
+    };
+    $changeText = function ($change): string {
+        if ($change === null || ! is_numeric($change)) {
+            return '';
+        }
+        $delta = (float) $change;
+        $points = number_format(abs($delta) * 100, 1);
+        if ($delta > 0.0005) {
+            return '较上次提升' . $points . '个百分点';
+        }
+        if ($delta < -0.0005) {
+            return '较上次下降' . $points . '个百分点';
+        }
+        return '较上次基本持平';
+    };
+
+@endphp
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>学情分析报告</title>
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
+    <style>
+        @page {
+            size: A4;
+            margin: 2.2cm 2cm 2.3cm 2cm;
+            @top-left { content: "知了数学·{{ $generateDateTime }}"; font-size: 13px; color: #666; }
+            @top-center { content: "{{ $student['name'] ?? '-' }}"; font-size: 13px; color: #666; }
+            @top-right {
+                content: "{{ $reportCode }}";
+                font-size: 19px;
+                font-weight: 600;
+                font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
+                color: #222;
+            }
+            @bottom-left { content: "{{ $reportCode }}"; font-size: 11px; color: #666; }
+            @bottom-right { content: counter(page) "/" counter(pages); font-size: 13px; color: #666; }
+        }
+        * { box-sizing: border-box; }
+        body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0; color: #0f172a; font-size: 13px; line-height: 1.65; }
+        .page { page-break-after: auto; }
+        .header { text-align: left; margin-bottom: 16px; }
+        .paper-title { font-size: 30px; font-weight: 700; margin-bottom: 8px; color: #0b3a75; letter-spacing: 1px; }
+        .section { margin-bottom: 14px; page-break-inside: auto; break-inside: auto; }
+        .section-title { font-size: 20px; margin-bottom: 10px; font-weight: 700; color: #0b3a75; border-left: 5px solid #3b82f6; padding-left: 10px; line-height: 1.3; }
+        .card { border: 1px solid #dbeafe; border-radius: 12px; padding: 14px; background: #f8fbff; position: relative; }
+        .summary-list { margin: 0; padding-left: 18px; }
+        .summary-list li { margin: 6px 0; font-size: 13px; }
+        .overall-badge {
+            position: absolute;
+            right: 14px;
+            top: 12px;
+            border-radius: 12px;
+            border: 0;
+            padding: 9px 16px;
+            min-width: 0;
+            width: auto;
+            text-align: center;
+            position: absolute;
+            overflow: hidden;
+            display: inline-block;
+            white-space: nowrap;
+            background: transparent !important;
+        }
+        .overall-badge .level { font-size: 28px; font-weight: 800; line-height: 1.05; letter-spacing: 1px; }
+        .overall-badge .score { font-size: 13px; margin-top: 3px; }
+        .overall-badge.badge-s {
+            border: 5px solid #6d28d9;
+            border-radius: 14px;
+            box-shadow: none;
+            transform: rotate(-7deg);
+        }
+        .overall-badge.badge-s::before {
+            content: "";
+            position: absolute;
+            inset: 4px;
+            border: 2px dashed rgba(109, 40, 217, 0.65);
+            border-radius: 10px;
+            pointer-events: none;
+        }
+        .overall-badge.badge-s .level {
+            letter-spacing: 2px;
+            text-shadow: 0 1px 0 rgba(109, 40, 217, 0.24);
+        }
+        .overall-badge.badge-excellent {
+            border: 3px double #16a34a;
+            border-radius: 999px;
+            box-shadow: none;
+        }
+        .overall-badge.badge-good {
+            border: 2px solid #2563eb;
+            border-radius: 10px;
+            clip-path: polygon(6% 0, 94% 0, 100% 50%, 94% 100%, 6% 100%, 0 50%);
+            box-shadow: none;
+        }
+        .overall-badge.badge-average {
+            border: 2px dashed #d97706;
+            border-radius: 14px;
+            box-shadow: none;
+        }
+        .overall-badge.badge-weak {
+            border-left: 3px solid #ef4444;
+            border-right: 0;
+            border-top: 0;
+            border-bottom: 2px solid #ef4444;
+            border-radius: 0 10px 10px 0;
+            box-shadow: none;
+        }
+        .overall-meta { margin-top: 8px; font-size: 9px; color: #64748b; line-height: 1.6; white-space: nowrap; }
+        .dot {
+            display: inline-block;
+            width: 10px;
+            height: 10px;
+            border-radius: 2px;
+            margin-right: 4px;
+            vertical-align: middle;
+            border: 1px solid #374151;
+            background: #fff;
+        }
+        .dot-mastered {
+            background: #111827;
+            border-style: solid;
+        }
+        .dot-weak {
+            background: #9ca3af;
+            border-style: solid;
+        }
+        .dot-beginner {
+            background: #e5e7eb;
+            border: 1px dashed #6b7280;
+        }
+        .dot-unlearned {
+            background: #ffffff;
+            border-style: solid;
+            border-color: #9ca3af;
+        }
+        .cluster-toolbar {
+            margin-bottom: 8px;
+            font-size: 11px;
+            color: #475569;
+            white-space: nowrap;
+        }
+        .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;
+            position: relative;
+            overflow: visible;
+        }
+        .cluster-card-title {
+            font-size: 14px;
+            font-weight: 700;
+            color: #0f172a;
+            margin-bottom: 8px;
+        }
+        .cluster-subgroup {
+            border-left: 2px solid #e5e7eb;
+            padding-left: 8px;
+            padding-right: 128px; /* 右侧空白区域再缩小 */
+            margin-bottom: 8px;
+            position: relative;
+        }
+        .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);
+            position: relative;
+        }
+        .cluster-point.status-mastered {
+            background: #111827;
+            border: 1px solid #1f2937;
+        }
+        .cluster-point.status-weak {
+            background: #9ca3af;
+            border: 1px solid #1f2937;
+        }
+        .cluster-point.status-beginner {
+            background: #e5e7eb !important;
+            border: 1px dashed #6b7280;
+        }
+        .cluster-point.status-unlearned {
+            background: #ffffff !important;
+            border: 1px solid #9ca3af;
+        }
+        .cluster-point.focus-source {
+            border-color: rgba(148, 163, 184, 0.35);
+            box-shadow: 0 0 0 2px #fde68a, 0 0 0 4px rgba(251, 191, 36, 0.18);
+            margin-right: 4px;
+            margin-bottom: 4px;
+            z-index: 2;
+            overflow: visible;
+        }
+        .cluster-focus-connector {
+            position: absolute;
+            left: 0;
+            top: -12px;
+            width: 112px;
+            height: 46px;
+            overflow: visible;
+            pointer-events: none;
+            z-index: 2;
+        }
+        .cluster-focus-connector path {
+            fill: none;
+            stroke: #0f172a;
+            stroke-width: 1;
+            stroke-linecap: round;
+        }
+        .cluster-focus-connector.dense {
+            width: 128px;
+            height: 46px;
+        }
+        .cluster-focus-connector.bottom {
+            width: 118px;
+            height: 46px;
+        }
+        .cluster-point-focus-label {
+            position: absolute;
+            left: 102px; /* 放到右侧空白区,并远离最右侧方块 */
+            top: 50%;   /* 与点位在同一水平带,避免压住文字 */
+            transform: translateY(-50%);
+            display: inline-block;
+            max-width: none;
+            border: 1px solid #0f172a;
+            border-radius: 6px;
+            background: #fffbeb;
+            color: #92400e;
+            font-size: 9px;
+            font-weight: 700;
+            padding: 1px 6px;
+            line-height: 1.25;
+            white-space: nowrap;
+            z-index: 3;
+            overflow: visible;
+        }
+        .cluster-point-focus-label.focus-offset-a { top: 50%; left: 102px; transform: translateY(-50%); }
+        .cluster-point-focus-label.focus-offset-b { top: 42%; left: 102px; transform: translateY(-50%); }
+        .cluster-point-focus-label.focus-offset-c { top: 58%; left: 102px; transform: translateY(-50%); }
+        .cluster-point-focus-label.dense { left: 116px; top: 50%; transform: translateY(-50%); }
+        .cluster-point-focus-label.bottom { left: 108px; top: 44%; transform: translateY(-50%); }
+        .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; }
+        .kp-burst-list { margin-top: 6px; font-size: 11px; color: #334155; line-height: 1.5; }
+        .kp-burst-list span { display: inline-block; margin-right: 10px; margin-bottom: 3px; }
+        table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
+        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; }
+        .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(6) { vertical-align: middle; }
+        .module-table td:nth-child(6) { vertical-align: middle; text-align: center; }
+        .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(4),
+        .module-table td:nth-child(5) { text-align: center; white-space: nowrap; }
+        .module-table td:nth-child(6) { 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; }
+        .muted { color: #6b7280; font-size: 12px; }
+    </style>
+</head>
+<body>
+<div class="page">
+    <div class="header">
+        <h1 class="paper-title">学情分析报告</h1>
+    </div>
+
+    <div class="section">
+        <div class="section-title">一、总体评估</div>
+        <div class="card">
+            <div class="overall-badge {{ $overallVisual['class'] ?? '' }}"
+                 style="border-color:{{ $overallVisual['border'] }}; color:{{ $overallVisual['text'] }};">
+                <div class="level">{{ $overallGrade }}</div>
+            </div>
+            <ul class="summary-list">
+                <li>本次诊断得分:
+                    @if($scoreObtained !== null && $scoreTotal !== null && $scoreTotal > 0)
+                        {{ rtrim(rtrim(number_format((float) $scoreObtained, 1), '0'), '.') }}/{{ rtrim(rtrim(number_format((float) $scoreTotal, 1), '0'), '.') }}
+                    @else
+                        暂无得分数据
+                    @endif
+                </li>
+                <li>平均掌握度:{{ $averageMastery !== null ? number_format((float) $averageMastery * 100, 1) . '%' : '暂无掌握度' }}</li>
+                <li>
+                    难度匹配:
+                    @if(!empty($difficultySummary['target_label']) && isset($difficultySummary['actual_average_difficulty']))
+                        目标 {{ $difficultySummary['target_label'] }}
+                        @if(!empty($difficultySummary['target_range']))
+                            ({{ number_format((float)($difficultySummary['target_range']['min'] ?? 0), 2) }}~{{ number_format((float)($difficultySummary['target_range']['max'] ?? 0), 2) }})
+                        @endif
+                        ,实际 {{ number_format((float)($difficultySummary['actual_average_difficulty'] ?? 0), 3) }}
+                        ({{ $difficultySummary['status'] ?? '暂无' }})
+                    @else
+                        暂无难度匹配数据
+                    @endif
+                </li>
+                @if(!empty($difficultySummary['explain']))
+                    <li>难度说明:{{ $difficultySummary['explain'] }}</li>
+                @endif
+                <li>
+                    与历史自己对比:
+                    @if(!empty($historySummary['is_first_exam']))
+                        {{ $historySummary['message'] ?? '这是你的第一次分析报告,先积累样本再看趋势。' }}
+                    @elseif(!empty($historySummary['low_baseline_guard']))
+                        {{ $historySummary['message'] ?? '历史基线偏低,建议看连续趋势。' }}
+                    @elseif(!empty($historySummary['has_data']))
+                        @php
+                            $trendText = (string)($historySummary['trend'] ?? '—');
+                            $tVisual = $trendVisual($trendText);
+                        @endphp
+                        近几次均值对比:
+                        {{ number_format((float)($historySummary['baseline_score_rate'] ?? 0) * 100, 1) }}%,
+                        本次{{ ($historySummary['delta_score_rate'] ?? 0) >= 0 ? '提升' : '回落' }}
+                        {{ number_format(abs((float)($historySummary['delta_score_rate'] ?? 0)) * 100, 1) }}%
+                        (<span style="color:{{ $tVisual['color'] ?? '#64748b' }}; font-weight:600;">{{ $tVisual['icon'] ?? '•' }} {{ $trendText }}</span>)
+                    @else
+                        {{ $historySummary['message'] ?? '历史样本不足' }}
+                    @endif
+                </li>
+                @if(!empty($peerSummary['show_line']))
+                    <li>
+                        与同群体对比:
+                        {{ $peerSummary['message'] ?? '' }}
+                        (<span style="color:{{ $peerSummary['band_color'] ?? '#64748b' }}; font-weight:600;">{{ $peerSummary['band_icon'] ?? '•' }} {{ $peerSummary['band'] ?? '—' }}</span>)
+                    </li>
+                @endif
+                <li>
+                    整体水平:
+                    @if($overallScore !== null)
+                        {{ number_format($overallScore, 1) }} 分({{ $overallGrade }})
+                    @else
+                        待计算
+                    @endif
+                </li>
+            </ul>
+            <div class="overall-meta">
+                规则:综合分 = 当前50% + 历史25% + 同群体25% + 难度校正,即:(({{ number_format($scoreRate !== null ? (float)$scoreRate * 100 : 0, 1) }}×70% + {{ number_format($averageMastery !== null ? (float)$averageMastery * 100 : 0, 1) }}×30%)×50%) + {{ number_format($historyPart, 1) }}×25% + {{ number_format($peerPart, 1) }}×25% + {{ number_format($adjustPart, 1) }} = {{ number_format($overallScore ?? $compositeFormulaResult, 1) }}
+            </div>
+        </div>
+    </div>
+
+    <div class="section">
+        <div class="section-title">二、知识点掌握聚类视图</div>
+        <div class="cluster-toolbar">
+            <span class="cluster-legend"><i class="dot dot-mastered"></i>已掌握(深色实心)</span>
+            <span class="cluster-legend"><i class="dot dot-weak"></i>薄弱(浅灰实心)</span>
+            <span class="cluster-legend"><i class="dot dot-beginner"></i>未入门(浅灰虚线框)</span>
+            <span class="cluster-legend"><i class="dot dot-unlearned"></i>未学习(白色)</span>
+            <span>按“模块 → 子模块 → 知识点”聚类展示</span>
+        </div>
+        <div class="cluster-grid">
+	            @foreach($clusterCards as $cluster)
+	                <div class="cluster-card">
+                        @php
+                            $clusterModuleName = trim((string) ($cluster['module_name'] ?? '未分组'));
+                            $clusterGrandName = trim((string) ($cluster['grand_name'] ?? ''));
+                            $clusterTitle = ($clusterGrandName !== '' && $clusterGrandName !== $clusterModuleName)
+                                ? ($clusterModuleName . ' / ' . $clusterGrandName)
+                                : $clusterModuleName;
+                        @endphp
+	                    <div class="cluster-card-title">
+	                        {{ $clusterTitle }}
+	                    </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)
+                                            @php
+                                                $pointCode = trim((string) ($point['code'] ?? ''));
+                                                $pointParentCode = trim((string) ($point['parent_code'] ?? ''));
+                                                $focusMarker = null;
+                                                $focusMarkerCode = '';
+                                                if ($pointCode !== '' && isset($focusMarkerByCode[$pointCode]) && empty($renderedFocusMarkerCodes[$pointCode])) {
+                                                    $focusMarker = $focusMarkerByCode[$pointCode];
+                                                    $focusMarkerCode = $pointCode;
+                                                } elseif ($pointParentCode !== '' && isset($focusMarkerByCode[$pointParentCode]) && empty($renderedFocusMarkerCodes[$pointParentCode])) {
+                                                    $focusMarker = $focusMarkerByCode[$pointParentCode];
+                                                    $focusMarkerCode = $pointParentCode;
+                                                }
+                                                if ($focusMarkerCode !== '') {
+                                                    $renderedFocusMarkerCodes[$focusMarkerCode] = true;
+                                                }
+                                                $focusName = is_array($focusMarker) ? (string) ($focusMarker['name'] ?? '') : '';
+                                                $pointStatusClass = match ((string) ($point['status'] ?? '')) {
+                                                    '已掌握' => 'status-mastered',
+                                                    '薄弱' => 'status-weak',
+                                                    '未入门' => 'status-beginner',
+                                                    default => 'status-unlearned',
+                                                };
+                                                $focusLayoutClass = '';
+                                                if ($focusName === '幂与指数') {
+                                                    $focusLayoutClass = 'dense';
+                                                } elseif (str_contains($clusterModuleName, '图形变化') || str_contains($clusterModuleName, '图形度量')) {
+                                                    $focusLayoutClass = 'bottom';
+                                                }
+                                            @endphp
+	                                        <span class="cluster-point {{ $pointStatusClass }}{{ $focusName !== '' ? ' focus-source' : '' }}"
+		                                              title="{{ $point['name'] }} · {{ $point['status'] }}{{ $point['mastery_level'] !== null ? '(' . $formatMasteryPct($point['mastery_level']) . ')' : '' }}{{ $point['path'] !== '' ? ' · ' . $point['path'] : '' }}">
+                                                @if($focusName !== '')
+                                                    @php
+                                                        $focusOffsetClass = match ($loop->index % 3) {
+                                                            1 => 'focus-offset-b',
+                                                            2 => 'focus-offset-c',
+                                                            default => 'focus-offset-a',
+                                                        };
+                                                    @endphp
+                                                    <svg class="cluster-focus-connector {{ $focusLayoutClass }}" viewBox="0 0 150 64" preserveAspectRatio="none" aria-hidden="true">
+                                                        @if($focusLayoutClass === 'dense')
+                                                            <path d="M8,24 C18,24 24,18 38,16 C84,14 124,20 138,23 L146,24" />
+                                                        @elseif($focusLayoutClass === 'bottom')
+                                                            <path d="M8,23 C18,23 24,16 42,13 C86,11 114,20 132,23 L140,23" />
+                                                        @else
+                                                            <path d="M8,24 C18,24 24,18 42,15 C86,13 114,21 132,24 L142,24" />
+                                                        @endif
+                                                    </svg>
+                                                    <span class="cluster-point-focus-label {{ $focusOffsetClass }} {{ $focusLayoutClass }}">{{ $focusName }}</span>
+                                                @endif
+                                            </span>
+	                                    @endforeach
+                                </div>
+                            </div>
+                        @endforeach
+                    @else
+                        <div class="cluster-empty">当前模块暂无可展示的子知识点。</div>
+                    @endif
+                </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>
+        <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);
+	                            $deltaColor = $delta > 0 ? '#16a34a' : ($delta < 0 ? '#dc2626' : '#64748b');
+	                            $deltaText = $changeText($delta);
+		                            $masteryText = isset($item['mastery_level']) && $item['mastery_level'] !== null
+		                                ? $formatMasteryPct($item['mastery_level'])
+		                                : '--';
+	                        @endphp
+	                        <li>
+	                            {{ $item['name'] ?? '-' }}:
+	                            当前掌握度{{ $masteryText }}({{ $item['status'] ?? '未学习' }})
+	                            @if($deltaText !== '')
+	                                ,<span style="color:{{ $deltaColor }};font-weight:600;">{{ $deltaText }}</span>
+	                            @endif
+	                        </li>
+                    @endforeach
+                </ul>
+            @else
+                <div class="muted" style="margin-top:4px;">
+                    @if(!empty($kpWrongStats))
+                        暂无本学案命中知识点的掌握度数据,以下方知识点错误率作为本学案影响依据。
+                    @else
+                        暂无可用的知识点变化数据
+                    @endif
+                </div>
+            @endif
+        </div>
+        @if(!empty($kpWrongStats))
+            <div style="margin-bottom:8px; padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff7ed;">
+                <div style="font-size:12px; font-weight:700; color:#9a3412; margin-bottom:6px;">知识点错误率</div>
+                <div style="font-size:12px; color:#475569; line-height:1.7;">
+                    @foreach($kpWrongStats as $item)
+                        <span class="error-kp-tag {{ $item['rate'] > 0.5 ? 'high-risk' : '' }}">{{ $item['kp_name'] }}:{{ $item['wrong'] }}/{{ $item['total'] }}({{ number_format($item['rate'] * 100, 1) }}%)</span>
+                    @endforeach
+                </div>
+            </div>
+        @endif
+        <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'] ?? '-';
+                                $mQuestionCount = (int) ($im['question_count'] ?? 0);
+                                $mScore = $im['exam_obtained_score'] ?? null;
+                            @endphp
+	                        <span style="display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#3730a3;margin-right:4px;">
+	                            {{ $mName }}({{ $mQuestionCount }}题,得分{{ $mScore !== null ? number_format((float) $mScore, 1) : '-' }})
+	                        </span>
+	                    @endforeach
+                @else
+                    <span class="muted">暂无命中模块</span>
+                @endif
+            </div>
+        </div>
+        <table class="module-table">
+            <thead>
+            <tr>
+	                <th style="width: 18%;">模块</th>
+	                <th style="width: 12%; white-space: nowrap;">本次影响</th>
+	                <th style="width: 18%;">当前掌握度</th>
+	                <th style="width: 14%;">掌握状态</th>
+	                <th style="width: 14%;">路径建议</th>
+	                <th style="width: 24%;">关注知识点</th>
+            </tr>
+            </thead>
+            <tbody>
+            @forelse($moduleRowsWithStatus as $m)
+                @php
+	                    $status = (string) ($m['status'] ?? '暂无');
+	                    $color = $statusColor($status);
+	                    $qCount = (int) ($m['question_count'] ?? 0);
+                    $isImpacted = $qCount > 0;
+                    $basePathTag = $pathTagByModuleName[(string) ($m['module_name'] ?? '')]
+                        ?? $globalPathTagByMastery($m['mastery_level'] ?? null);
+                    $pathTag = $isImpacted ? $basePathTag : $overallPathTag($basePathTag);
+                    $pathColor = match ($pathTag) {
+                        '优先加强', '整体优先' => '#ef4444',
+                        '需要加强', '整体加强' => '#f59e0b',
+                        '保分不错', '整体巩固' => '#16a34a',
+                        default => '#64748b',
+                    };
+	                    $moduleName = (string) ($m['module_name'] ?? '');
+                    $focus = $moduleSuggestionByName[$moduleName] ?? null;
+                    $focusText = '-';
+                    if (is_array($focus)) {
+                        if (!empty($focus['is_empty'])) {
+                            $focusText = (string) ($focus['status'] ?? '当前模块暂无需额外关注知识点');
+                        } else {
+                            $focusName = (string) ($focus['kp_name'] ?? '');
+	                            $focusMastery = isset($focus['mastery_level']) && $focus['mastery_level'] !== null
+	                                ? $formatMasteryPct($focus['mastery_level'])
+	                                : '--';
+                            $focusText = $focusName !== ''
+                                ? ($focusName . '(' . $focusMastery . ')')
+                                : '当前模块暂无需额外关注知识点';
+                        }
+                    }
+                @endphp
+                <tr>
+                    <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 ? $formatMasteryPct($m['mastery_level']) : '-' }}</td>
+	                    <td><span class="badge" style="background:{{ $color }}">{{ $status }}</span></td>
+	                    <td><span style="color:{{ $pathColor }}; font-weight:700;">{{ $pathTag }}</span></td>
+	                    <td>{{ $focusText }}</td>
+                </tr>
+            @empty
+                <tr>
+	                    <td colspan="6" class="muted">暂无掌握状态数据</td>
+                </tr>
+            @endforelse
+            </tbody>
+        </table>
+    </div>
+</div>
+<script src="/js/katex.min.js"></script>
+<script src="/js/auto-render.min.js"></script>
+<script>
+    document.addEventListener('DOMContentLoaded', function() {
+        try {
+            renderMathInElement(document.body, {
+                delimiters: [
+                    {left: "$$", right: "$$", display: true},
+                    {left: "$", right: "$", display: false},
+                    {left: "\\(", right: "\\)", display: false},
+                    {left: "\\[", right: "\\]", display: true}
+                ],
+                throwOnError: false,
+                strict: false,
+                trust: true
+            });
+        } catch (e) {}
+    });
+</script>
+</body>
+</html>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio