yemeishu 2 долоо хоног өмнө
parent
commit
bbac59ecdf

+ 3 - 0
app/DTO/ExamAnalysisDataDto.php

@@ -14,6 +14,7 @@ class ExamAnalysisDataDto
         public readonly array $teacher,
         public readonly array $questions,
         public readonly array $mastery,
+        public readonly array $examHitKpCodes,
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
         public readonly array $insights,
         public readonly array $recommendations,
@@ -32,6 +33,7 @@ class ExamAnalysisDataDto
             teacher: $data['teacher'] ?? [],
             questions: $data['questions'] ?? [],
             mastery: $data['mastery'] ?? [],
+            examHitKpCodes: $data['exam_hit_kp_codes'] ?? [],
             parentMasteryLevels: $data['parent_mastery_levels'] ?? [], // 新增:父节点掌握度数据
             insights: $data['insights'] ?? [],
             recommendations: $data['recommendations'] ?? [],
@@ -51,6 +53,7 @@ class ExamAnalysisDataDto
             'teacher' => $this->teacher,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
+            'exam_hit_kp_codes' => $this->examHitKpCodes,
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
             'insights' => $this->insights,
             'recommendations' => $this->recommendations,

+ 3 - 0
app/DTO/ReportPayloadDto.php

@@ -14,6 +14,7 @@ class ReportPayloadDto
         public readonly array $teacher,
         public readonly array $questions,
         public readonly array $mastery,
+        public readonly array $examHitKpCodes,
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
         public readonly array $questionInsights,
         public readonly array $recommendations,
@@ -31,6 +32,7 @@ class ReportPayloadDto
             teacher: $dto->teacher,
             questions: $dto->questions,
             mastery: $dto->mastery,
+            examHitKpCodes: $dto->examHitKpCodes,
             parentMasteryLevels: $dto->parentMasteryLevels, // 新增:父节点掌握度数据
             questionInsights: $dto->insights,
             recommendations: $dto->recommendations,
@@ -50,6 +52,7 @@ class ReportPayloadDto
             'teacher' => $this->teacher,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
+            'exam_hit_kp_codes' => $this->examHitKpCodes,
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
             'question_insights' => $this->questionInsights,
             'recommendations' => $this->recommendations,

+ 169 - 10
app/Services/ExamPdfExportService.php

@@ -1033,9 +1033,17 @@ class ExamPdfExportService
             }
         }
 
-        // 【修复】处理父节点掌握度数据:过滤零值、转换名称、构建层级关系
-        $examKpCodes = array_column($masteryData, 'kp_code'); // 本次考试涉及的知识点
-        $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes, $masteryMap, $snapshotMasteryData);
+        // 本卷命中知识点:严格按“这套卷子题目关联知识点”计算
+        $examQuestionKpCodes = array_values(array_unique(array_filter(array_map(
+            fn ($q) => trim((string) ($q['knowledge_point'] ?? '')),
+            $questions
+        ))));
+
+        // 父节点列表:只基于“本卷命中知识点”映射
+        $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examQuestionKpCodes, $masteryMap, $snapshotMasteryData);
+        if (empty($processedParentMastery) && !empty($examQuestionKpCodes)) {
+            $processedParentMastery = $this->buildParentMasteryFromHitCodes($examQuestionKpCodes, $kpNameMap, $masteryMap, $snapshotMasteryData);
+        }
 
         Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
             'raw_count' => count($parentMasteryLevels),
@@ -1057,6 +1065,7 @@ class ExamPdfExportService
             'teacher' => $teacherInfo,
             'questions' => $questions,
             'mastery' => $masterySummary,
+            'exam_hit_kp_codes' => $examQuestionKpCodes,
             'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
             'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
             'recommendations' => $recommendations,
@@ -1292,7 +1301,7 @@ class ExamPdfExportService
             'XDG_RUNTIME_DIR' => $runtimeXdg,
         ]);
 
-        $process->setTimeout(90); // 【修复】增加超时时间到90秒
+        $process->setTimeout(180); // 复杂学情报告页允许更长渲染时间,降低超时失败率
         $killSignal = \defined('SIGKILL') ? \SIGKILL : 9;
 
         Log::warning('ExamPdfExportService: [调试] Chrome命令准备执行', [
@@ -1341,7 +1350,11 @@ class ExamPdfExportService
                 'exception' => get_class($e),
             ]);
 
-            return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, $startedAt);
+            $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, $startedAt);
+            if ($result !== null) {
+                return $result;
+            }
+            return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
         } catch (\Throwable $e) {
             if ($process->isRunning()) {
                 $process->stop(3, $killSignal); // 【优化】减少停止等待时间
@@ -1351,10 +1364,18 @@ class ExamPdfExportService
                 'error' => $e->getMessage(),
             ]);
 
-            return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
+            $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
+            if ($result !== null) {
+                return $result;
+            }
+            return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
         }
 
-        return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
+        $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
+        if ($result !== null) {
+            return $result;
+        }
+        return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
     }
 
     /**
@@ -1398,6 +1419,47 @@ class ExamPdfExportService
         return null;
     }
 
+    /**
+     * Chrome主渲染失败时的最小参数兜底。
+     */
+    private function renderWithChromeMinimal(string $chromeBinary, string $htmlPath): ?string
+    {
+        $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_fallback_').'.pdf';
+        $process = new Process([
+            $chromeBinary,
+            '--headless=new',
+            '--disable-gpu',
+            '--no-sandbox',
+            '--print-to-pdf='.$tmpPdf,
+            'file://'.$htmlPath,
+        ]);
+        $process->setTimeout(180);
+
+        try {
+            $process->run();
+            if (file_exists($tmpPdf) && filesize($tmpPdf) > 1000) {
+                Log::warning('ExamPdfExportService: Chrome最小参数兜底成功', [
+                    'pdf_size' => filesize($tmpPdf),
+                    'exit_code' => $process->getExitCode(),
+                ]);
+                $pdfBinary = file_get_contents($tmpPdf);
+                @unlink($tmpPdf);
+                return $pdfBinary;
+            }
+            Log::error('ExamPdfExportService: Chrome最小参数兜底失败', [
+                'exit_code' => $process->getExitCode(),
+                'error' => $process->getErrorOutput(),
+                'output' => $process->getOutput(),
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('ExamPdfExportService: Chrome最小参数兜底异常', [
+                'error' => $e->getMessage(),
+            ]);
+        }
+        @unlink($tmpPdf);
+        return null;
+    }
+
     /**
      * 查找Chrome二进制文件
      */
@@ -2277,8 +2339,8 @@ class ExamPdfExportService
                 }
             }
 
-            // 过滤零值和空值(在统一口径重算后再过滤)
-            if ($masteryLevel === null || $masteryLevel === 0.0 || $masteryLevel <= 0.001) {
+            // 仅过滤空值;0 掌握度的命中父节点也要展示
+            if ($masteryLevel === null) {
                 continue;
             }
 
@@ -2287,6 +2349,12 @@ class ExamPdfExportService
                 $childrenData['hit_children'] ?? []
             );
             $hitAvg = ! empty($hitLevels) ? array_sum($hitLevels) / count($hitLevels) : null;
+            $hitCount = count($childrenData['hit_children'] ?? []);
+
+            // 父节点仅展示“本卷命中知识点”对应的父节点
+            if ($hitCount <= 0) {
+                continue;
+            }
 
             $processed[$kpCode] = [
                 'kp_code' => $kpCode,
@@ -2299,7 +2367,7 @@ class ExamPdfExportService
                 'children' => $childrenData['hit_children'] ?? [],
                 // 新增:全部直接子节点(含掌握度、是否命中)
                 'children_all' => $childrenData['all_children'] ?? [],
-                'children_hit_count' => count($childrenData['hit_children'] ?? []),
+                'children_hit_count' => $hitCount,
                 'children_total_count' => count($childrenData['all_children'] ?? []),
                 'children_hit_avg_mastery' => $hitAvg !== null ? round($hitAvg, 4) : null,
                 'level' => $this->calculateKnowledgePointLevel($kpCode),
@@ -2355,6 +2423,97 @@ class ExamPdfExportService
         ];
     }
 
+    /**
+     * 当历史父节点概览缺失时,直接由“本卷命中知识点”反推出父节点并构建展示数据。
+     */
+    private function buildParentMasteryFromHitCodes(
+        array $examKpCodes,
+        array $kpNameMap,
+        array $masteryMap = [],
+        array $snapshotMasteryData = []
+    ): array {
+        $codes = array_values(array_unique(array_filter(array_map(fn ($c) => trim((string) $c), $examKpCodes))));
+        if (empty($codes)) {
+            return [];
+        }
+
+        $rows = DB::connection('mysql')
+            ->table('knowledge_points')
+            ->whereIn('kp_code', $codes)
+            ->whereNotNull('parent_kp_code')
+            ->where('parent_kp_code', '!=', '')
+            ->select('kp_code', 'parent_kp_code')
+            ->get();
+
+        $parentMap = [];
+        foreach ($rows as $r) {
+            $parentCode = trim((string) ($r->parent_kp_code ?? ''));
+            $childCode = trim((string) ($r->kp_code ?? ''));
+            if ($parentCode === '' || $childCode === '') {
+                continue;
+            }
+            $parentMap[$parentCode][] = $childCode;
+        }
+        if (empty($parentMap)) {
+            return [];
+        }
+
+        $parents = DB::connection('mysql')
+            ->table('knowledge_points')
+            ->whereIn('kp_code', array_keys($parentMap))
+            ->pluck('name', 'kp_code')
+            ->toArray();
+
+        $processed = [];
+        foreach ($parentMap as $parentCode => $hitChildrenCodes) {
+            $childrenData = $this->getChildKnowledgePoints($parentCode, $kpNameMap, $codes, $masteryMap);
+            $allChildren = $childrenData['all_children'] ?? [];
+            $hitChildren = $childrenData['hit_children'] ?? [];
+            $hitCount = count($hitChildren);
+            if ($hitCount <= 0) {
+                continue;
+            }
+
+            $allLevels = array_map(fn ($c) => floatval($c['mastery_level'] ?? 0), $allChildren);
+            $masteryLevel = !empty($allLevels) ? array_sum($allLevels) / count($allLevels) : 0.0;
+
+            $prevLevels = [];
+            foreach ($allChildren as $child) {
+                $childCode = (string) ($child['kp_code'] ?? '');
+                if ($childCode === '') {
+                    continue;
+                }
+                $currentChild = floatval($masteryMap[$childCode] ?? 0);
+                $prevFromSnapshot = $snapshotMasteryData[$childCode]['previous_mastery'] ?? null;
+                $currFromSnapshot = $snapshotMasteryData[$childCode]['current_mastery'] ?? null;
+                $previousChild = $prevFromSnapshot ?? $currFromSnapshot ?? $currentChild;
+                $prevLevels[] = floatval($previousChild);
+            }
+            $masteryChange = !empty($prevLevels) ? ($masteryLevel - (array_sum($prevLevels) / count($prevLevels))) : null;
+
+            $hitLevels = array_map(fn ($c) => floatval($c['mastery_level'] ?? 0), $hitChildren);
+            $hitAvg = !empty($hitLevels) ? array_sum($hitLevels) / count($hitLevels) : null;
+
+            $processed[$parentCode] = [
+                'kp_code' => $parentCode,
+                'kp_name' => $parents[$parentCode] ?? ($kpNameMap[$parentCode] ?? $parentCode),
+                'mastery_level' => round(floatval($masteryLevel), 4),
+                'mastery_percentage' => round(floatval($masteryLevel) * 100, 2),
+                'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null,
+                'change_source' => 'hit_kp_parent_fallback',
+                'children' => $hitChildren,
+                'children_all' => $allChildren,
+                'children_hit_count' => $hitCount,
+                'children_total_count' => count($allChildren),
+                'children_hit_avg_mastery' => $hitAvg !== null ? round($hitAvg, 4) : null,
+                'level' => $this->calculateKnowledgePointLevel($parentCode),
+            ];
+        }
+
+        uasort($processed, fn ($a, $b) => $b['mastery_level'] <=> $a['mastery_level']);
+        return $processed;
+    }
+
     /**
      * 计算知识点层级深度
      */

+ 33 - 55
resources/views/exam-analysis/pdf-report.blade.php

@@ -62,7 +62,7 @@
             line-height: 1.6;
         }
         h1, h2, h3 { margin: 0; color: #000; }
-        .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); }
+        .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); break-inside: auto; page-break-inside: auto; }
         .header { text-align: center; margin-bottom: 1.5rem; border-bottom: 2px solid #000; padding-bottom: 1rem; }
         .paper-title { font-size: 22px; font-weight: bold; margin-bottom: 14px; }
         .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
@@ -79,12 +79,13 @@
         .progress-wrap { background: #f3f4f6; border-radius: 999px; overflow: hidden; height: 10px; }
         .progress-bar { height: 100%; background: linear-gradient(90deg, #4f46e5, #10b981); }
         .recommend-card { border: 1px dashed #cbd5e1; border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; background: #f8fafc; }
-        .relation-board { margin: 6px 0 10px; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; }
-        .relation-block { margin-bottom: 10px; padding: 8px; border: 1px solid #dbeafe; border-radius: 8px; background: #fff; }
+        .relation-board { margin: 6px 0 10px; break-inside: auto; page-break-inside: auto; }
+        .relation-block { margin-bottom: 10px; padding: 8px; border: 1px solid #dbeafe; border-radius: 8px; background: #fff; break-inside: auto; page-break-inside: auto; }
+        .relation-row { display: flex; align-items: flex-start; gap: 10px; }
         .tree-cards { width: 100%; border-collapse: collapse; table-layout: fixed; }
         .tree-cards td { border: none; padding: 0; vertical-align: top; }
-        .tree-left { width: 45%; padding-right: 10px; }
-        .tree-right { width: 55%; padding-left: 10px; }
+        .tree-left { width: 45%; flex: 0 0 45%; padding-right: 10px; }
+        .tree-right { width: 55%; flex: 1; padding-left: 10px; }
         .tree-parent {
             display: inline-block;
             margin-bottom: 6px;
@@ -119,6 +120,8 @@
             background: #f8fafc;
             padding: 6px 8px;
             margin-bottom: 5px;
+            break-inside: auto;
+            page-break-inside: auto;
         }
         .detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
         .detail-title { font-size: 11px; color: #0f172a; font-weight: 700; margin-bottom: 2px; }
@@ -235,58 +238,35 @@
     <div class="card">
         <div class="section-title">本次命中子知识点掌握度</div>
         @php
-            // 先收集“本次命中”子知识点(用于区分:已作答0分 vs 未学习0分)
-            $hitChildMeta = [];
-            foreach (($parent_mastery_levels ?? []) as $pData) {
-                foreach (($pData['children_all'] ?? []) as $child) {
-                    if (empty($child['is_hit'])) {
-                        continue;
-                    }
-                    $kpCode = (string) ($child['kp_code'] ?? '');
-                    if ($kpCode === '') {
-                        continue;
-                    }
-                    $hitChildMeta[$kpCode] = [
-                        'kp_code' => $kpCode,
-                        'kp_name' => $child['kp_name'] ?? $kpCode,
-                        'mastery_level' => floatval($child['mastery_level'] ?? 0),
-                        'is_hit' => true,
-                    ];
+            // 严格口径:顶部列表来自“本卷题目关联知识点”
+            $hitCodes = array_values(array_unique(array_filter($exam_hit_kp_codes ?? [])));
+            $masteryByCode = [];
+            $questionKpNameMap = [];
+            foreach (($questions ?? []) as $q) {
+                $code = trim((string) ($q['knowledge_point'] ?? ''));
+                $name = trim((string) ($q['knowledge_point_name'] ?? ''));
+                if ($code !== '' && $name !== '') {
+                    $questionKpNameMap[$code] = $name;
                 }
             }
-
-            // 过滤掉K-GENERAL等通用知识点:
-            // - 掌握度>0 的正常显示
-            // - 命中且0分(已作答)也显示,避免被误判为“未学习”
-            $filteredMasteryItems = [];
-            $filteredMasteryByCode = [];
-            if (!empty($mastery['items'])) {
-                foreach ($mastery['items'] as $item) {
-                    $kpCode = $item['kp_code'] ?? '';
-                    $masteryLevel = $item['mastery_level'] ?? 0;
-                    $isHit = isset($hitChildMeta[$kpCode]);
-
-                    if (!in_array($kpCode, ['K-GENERAL', 'GENERAL', 'DEFAULT']) && ($masteryLevel > 0 || $isHit)) {
-                        $item['is_hit'] = $isHit;
-                        $filteredMasteryItems[] = $item;
-                        $filteredMasteryByCode[$kpCode] = true;
-                    }
+            foreach (($mastery['items'] ?? []) as $item) {
+                $code = (string) ($item['kp_code'] ?? '');
+                if ($code !== '') {
+                    $masteryByCode[$code] = $item;
                 }
             }
 
-            // 补齐:如果命中子知识点没有出现在 mastery.items 中,也补一条0分记录用于展示和建议
-            foreach ($hitChildMeta as $kpCode => $meta) {
-                if (isset($filteredMasteryByCode[$kpCode])) {
-                    continue;
-                }
+            $filteredMasteryItems = [];
+            foreach ($hitCodes as $kpCode) {
                 if (in_array($kpCode, ['K-GENERAL', 'GENERAL', 'DEFAULT'])) {
                     continue;
                 }
+                $base = $masteryByCode[$kpCode] ?? null;
                 $filteredMasteryItems[] = [
                     'kp_code' => $kpCode,
-                    'kp_name' => $meta['kp_name'],
-                    'mastery_level' => $meta['mastery_level'],
-                    'mastery_change' => null,
+                    'kp_name' => $questionKpNameMap[$kpCode] ?? ($base['kp_name'] ?? $kpCode),
+                    'mastery_level' => floatval($base['mastery_level'] ?? 0),
+                    'mastery_change' => $base['mastery_change'] ?? null,
                     'is_hit' => true,
                 ];
             }
@@ -357,9 +337,8 @@
                         })) === count($childrenAll);
                     @endphp
                     <div class="relation-block">
-                        <table class="tree-cards">
-                            <tr>
-                                <td class="tree-left">
+                        <div class="relation-row">
+                                <div class="tree-left">
                                     <div class="tree-parent">{{ $parentData['kp_name'] ?? $parentData['kp_code'] }}</div>
                                     @if(!empty($childrenAll))
                                         <div class="tree-lines">
@@ -379,8 +358,8 @@
                                     @else
                                         <div class="muted">无命中子知识点</div>
                                     @endif
-                                </td>
-                                <td class="tree-right">
+                                </div>
+                                <div class="tree-right">
                                     <div class="detail-card">
                                         <div class="detail-head">
                                             <div class="detail-title">{{ $parentData['kp_name'] ?? $parentData['kp_code'] }}</div>
@@ -414,9 +393,8 @@
                                             子节点总数 {{ $childCount }} 个,本次命中 {{ $hitCount }} 个,命中均值 {{ $hitAvg !== null ? number_format(floatval($hitAvg) * 100, 1) . '%' : '-' }}(命中表示本次覆盖,不等于已掌握)
                                         </div>
                                     </div>
-                                </td>
-                            </tr>
-                        </table>
+                                </div>
+                        </div>
                     </div>
                 @endforeach
             </div>