Переглянути джерело

得分公式修复和知识点讲解功能添加(半成品)

yemeishu 2 тижнів тому
батько
коміт
c93befdbdd

+ 231 - 165
app/Http/Controllers/Api/IntelligentExamController.php

@@ -3,31 +3,32 @@
 namespace App\Http\Controllers\Api;
 
 use App\Http\Controllers\Controller;
+use App\Models\MistakeRecord;
 use App\Models\Paper;
-use App\Models\PaperQuestion;
-use App\Services\LearningAnalyticsService;
+use App\Models\Student;
 use App\Services\ExamPdfExportService;
 use App\Services\ExternalIdService;
-use App\Services\QuestionBankService;
+use App\Services\LearningAnalyticsService;
 use App\Services\PaperPayloadService;
+use App\Services\QuestionBankService;
 use App\Services\TaskManager;
-use App\Models\MistakeRecord;
-use App\Models\Student;
-use App\Models\Teacher;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\URL;
 
 class IntelligentExamController extends Controller
 {
     private LearningAnalyticsService $learningAnalyticsService;
+
     private QuestionBankService $questionBankService;
+
     private ExamPdfExportService $pdfExportService;
+
     private PaperPayloadService $paperPayloadService;
+
     private TaskManager $taskManager;
+
     private ExternalIdService $externalIdService;
 
     public function __construct(
@@ -135,13 +136,13 @@ class IntelligentExamController extends Controller
 
         // 确保 kp_codes 是数组
         $data['kp_codes'] = $data['kp_codes'] ?? [];
-        if (!is_array($data['kp_codes'])) {
+        if (! is_array($data['kp_codes'])) {
             $data['kp_codes'] = [];
         }
 
         $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
         // 注意: difficulty_ratio 参数已废弃,使用 difficulty_category 控制难度分布
-        $paperName = $data['paper_name'] ?? ('智能试卷_' . now()->format('Ymd_His'));
+        $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His'));
         $difficultyCategory = $data['difficulty_category'] ?? 1; // 直接使用数字,不转换
         $mistakeIds = $data['mistake_ids'] ?? [];
         $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
@@ -152,7 +153,7 @@ class IntelligentExamController extends Controller
             $questions = [];
             $result = null;
 
-            if (!empty($mistakeIds) || !empty($mistakeQuestionIds)) {
+            if (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
                 $questionIds = $this->resolveMistakeQuestionIds(
                     $data['student_id'],
                     $mistakeIds,
@@ -176,7 +177,7 @@ class IntelligentExamController extends Controller
 
                 $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes']);
                 $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
-                $paperName = $data['paper_name'] ?? ('错题复习_' . $data['student_id'] . '_' . now()->format('Ymd_His'));
+                $paperName = $data['paper_name'] ?? ('错题复习_'.$data['student_id'].'_'.now()->format('Ymd_His'));
             } else {
                 // 第一步:生成智能试卷(同步)
                 $params = [
@@ -206,7 +207,7 @@ class IntelligentExamController extends Controller
                     $errorMsg = $result['message'] ?? '智能出卷失败';
                     Log::error('智能出卷失败', [
                         'student_id' => $data['student_id'],
-                        'error' => $result
+                        'error' => $result,
                     ]);
 
                     // 提供更详细的错误信息
@@ -238,7 +239,7 @@ class IntelligentExamController extends Controller
                 // 错题本:使用所有错题,不限制数量
                 Log::info('错题本类型,使用所有错题', [
                     'assemble_type' => $assembleType,
-                    'question_count' => count($questions)
+                    'question_count' => count($questions),
                 ]);
             } else {
                 // 其他类型:限制题目数量
@@ -246,8 +247,9 @@ class IntelligentExamController extends Controller
                 $questions = array_slice($questions, 0, $totalQuestions);
             }
 
-            // 调整题目分值,确保符合中国中学卷子标准(总分100分)
-            $questions = $this->adjustQuestionScores($questions, 100.0);
+            // 调整题目分值,确保符合目标总分
+            $targetTotalScore = $data['total_score'] ?? 100.0;
+            $questions = $this->adjustQuestionScores($questions, $targetTotalScore);
 
             // 计算总分
             $totalScore = array_sum(array_column($questions, 'score'));
@@ -259,11 +261,11 @@ class IntelligentExamController extends Controller
                 'teacher_id' => $data['teacher_id'] ?? null,
                 'assembleType' => $assembleType,
                 'difficulty_category' => $difficultyCategory,
-                'total_score' => $data['total_score'] ?? 100.0, // 默认100
+                'total_score' => $totalScore, // 使用计算后的实际总
                 'questions' => $questions,
             ]);
 
-            if (!$paperId) {
+            if (! $paperId) {
                 return response()->json([
                     'success' => false,
                     'message' => '试卷保存失败',
@@ -301,6 +303,7 @@ class IntelligentExamController extends Controller
                     'urls' => [
                         'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]),
                         'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']),
+                        'knowledge_explanation_url' => route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]),
                     ],
                     'pdfs' => [
                         'exam_paper_pdf' => null,
@@ -308,7 +311,7 @@ class IntelligentExamController extends Controller
                     ],
                     'stats' => $result['stats'] ?? [
                         'total_selected' => count($questions),
-                        'mistake_based' => !empty($mistakeIds) || !empty($mistakeQuestionIds),
+                        'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds),
                     ],
                     'created_at' => now()->toISOString(),
                 ],
@@ -349,7 +352,7 @@ class IntelligentExamController extends Controller
         try {
             $task = $this->taskManager->getTaskStatus($taskId);
 
-            if (!$task) {
+            if (! $task) {
                 return response()->json([
                     'success' => false,
                     'message' => '任务不存在',
@@ -385,14 +388,14 @@ class IntelligentExamController extends Controller
             dispatch(new \App\Jobs\GenerateExamPdfJob($taskId, $paperId));
             Log::info('PDF生成任务已加入队列', [
                 'task_id' => $taskId,
-                'paper_id' => $paperId
+                'paper_id' => $paperId,
             ]);
         } catch (\Exception $e) {
             Log::error('PDF生成任务队列失败,不回退到同步处理', [
                 'task_id' => $taskId,
                 'paper_id' => $paperId,
                 'error' => $e->getMessage(),
-                'note' => '依赖队列重试机制,不进行同步处理以避免并发冲突'
+                'note' => '依赖队列重试机制,不进行同步处理以避免并发冲突',
             ]);
             // 【优化】不回退到同步处理,避免与队列任务并发冲突
             // 队列系统有重试机制,会自动处理失败情况
@@ -462,7 +465,7 @@ class IntelligentExamController extends Controller
     private function normalizePayload(array $payload): array
     {
         // 处理 question_count 参数:转换为 total_questions
-        if (isset($payload['question_count']) && !isset($payload['total_questions'])) {
+        if (isset($payload['question_count']) && ! isset($payload['total_questions'])) {
             $payload['total_questions'] = $payload['question_count'];
             unset($payload['question_count']);
         }
@@ -487,7 +490,7 @@ class IntelligentExamController extends Controller
                 } else {
                     $payload['kp_codes'] = array_values(array_filter(array_map('trim', explode(',', $kpCodes))));
                 }
-            } elseif (!is_array($payload['kp_codes'])) {
+            } elseif (! is_array($payload['kp_codes'])) {
                 $payload['kp_codes'] = [];
             }
         } else {
@@ -505,7 +508,7 @@ class IntelligentExamController extends Controller
                     $payload[$key] = $raw === ''
                         ? []
                         : array_values(array_filter(array_map('trim', explode(',', $raw))));
-                } elseif (!is_array($payload[$key])) {
+                } elseif (! is_array($payload[$key])) {
                     $payload[$key] = [];
                 }
             }
@@ -519,7 +522,7 @@ class IntelligentExamController extends Controller
                     $payload[$key] = $raw === ''
                         ? []
                         : array_values(array_filter(array_map('trim', explode(',', $raw))));
-                } elseif (!is_array($payload[$key])) {
+                } elseif (! is_array($payload[$key])) {
                     $payload[$key] = [];
                 }
             } else {
@@ -534,7 +537,7 @@ class IntelligentExamController extends Controller
                 if ($payload['series_id'] <= 0) {
                     unset($payload['series_id']);
                 }
-            } elseif (!is_int($payload['series_id']) || $payload['series_id'] <= 0) {
+            } elseif (! is_int($payload['series_id']) || $payload['series_id'] <= 0) {
                 unset($payload['series_id']);
             }
         }
@@ -545,13 +548,13 @@ class IntelligentExamController extends Controller
                 $payload['semester_code'] = (int) trim($payload['semester_code']);
             }
             // 只保留1或2,其他值都移除
-            if (!in_array($payload['semester_code'], [1, 2], true)) {
+            if (! in_array($payload['semester_code'], [1, 2], true)) {
                 unset($payload['semester_code']);
             }
         }
 
         // 新增:处理组卷类型,默认值为 general
-        if (!isset($payload['exam_type'])) {
+        if (! isset($payload['exam_type'])) {
             $payload['exam_type'] = 'general';
         }
 
@@ -560,7 +563,7 @@ class IntelligentExamController extends Controller
             if (is_string($payload['practice_options'])) {
                 $decoded = json_decode($payload['practice_options'], true);
                 $payload['practice_options'] = is_array($decoded) ? $decoded : [];
-            } elseif (!is_array($payload['practice_options'])) {
+            } elseif (! is_array($payload['practice_options'])) {
                 $payload['practice_options'] = [];
             }
 
@@ -586,7 +589,7 @@ class IntelligentExamController extends Controller
             if (is_string($payload['mistake_options'])) {
                 $decoded = json_decode($payload['mistake_options'], true);
                 $payload['mistake_options'] = is_array($decoded) ? $decoded : [];
-            } elseif (!is_array($payload['mistake_options'])) {
+            } elseif (! is_array($payload['mistake_options'])) {
                 $payload['mistake_options'] = [];
             }
 
@@ -614,7 +617,7 @@ class IntelligentExamController extends Controller
             if (is_string($payload['knowledge_points_options'])) {
                 $decoded = json_decode($payload['knowledge_points_options'], true);
                 $payload['knowledge_points_options'] = is_array($decoded) ? $decoded : [];
-            } elseif (!is_array($payload['knowledge_points_options'])) {
+            } elseif (! is_array($payload['knowledge_points_options'])) {
                 $payload['knowledge_points_options'] = [];
             }
 
@@ -661,9 +664,10 @@ class IntelligentExamController extends Controller
             if ($teacherId > 0 && (int) $student->teacher_id !== $teacherId) {
                 $updates['teacher_id'] = $teacherId;
             }
-            if (!empty($updates)) {
+            if (! empty($updates)) {
                 $student->update($updates);
             }
+
             return;
         }
 
@@ -685,7 +689,7 @@ class IntelligentExamController extends Controller
 
         $normalized = [];
         foreach ($input as $key => $value) {
-            if (!is_numeric($value)) {
+            if (! is_numeric($value)) {
                 continue;
             }
             $type = $this->normalizeQuestionTypeKey($key);
@@ -733,7 +737,7 @@ class IntelligentExamController extends Controller
 
         $normalized = [];
         foreach ($input as $key => $value) {
-            if (!is_numeric($value)) {
+            if (! is_numeric($value)) {
                 continue;
             }
             $label = trim($key);
@@ -773,12 +777,12 @@ class IntelligentExamController extends Controller
             ];
         }
 
-        return array_values(array_filter($normalized, fn ($q) => !empty($q['id'])));
+        return array_values(array_filter($normalized, fn ($q) => ! empty($q['id'])));
     }
 
     private function guessType(array $question): string
     {
-        if (!empty($question['options']) && is_array($question['options'])) {
+        if (! empty($question['options']) && is_array($question['options'])) {
             return '选择题';
         }
 
@@ -821,174 +825,231 @@ class IntelligentExamController extends Controller
             return $questions;
         }
 
-        // 统计各类型题目数量
-        $typeCounts = ['choice' => 0, 'fill' => 0, 'answer' => 0];
+        // 第一步:按题型排序
+        $sortedQuestions = [];
+        $choiceQuestions = [];
+        $fillQuestions = [];
+        $answerQuestions = [];
 
         foreach ($questions as $question) {
-            $type = $question['question_type'] ?? 'answer';
-            if (in_array($type, ['CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
-                $type = 'choice';
-            } elseif (in_array($type, ['FILL_IN_THE_BLANK', 'FILL'], true)) {
-                $type = 'fill';
-            } elseif (in_array($type, ['CALCULATION', 'WORD_PROBLEM', 'PROOF', 'ANSWER'], true)) {
-                $type = 'answer';
-            }
-            if (isset($typeCounts[$type])) {
-                $typeCounts[$type]++;
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            if ($type === 'choice') {
+                $choiceQuestions[] = $question;
+            } elseif ($type === 'fill') {
+                $fillQuestions[] = $question;
+            } else {
+                $answerQuestions[] = $question;
             }
         }
 
-        // 标准分值范围
-        $standardScoreRanges = [
-            'choice' => ['min' => 4, 'max' => 6],
-            'fill' => ['min' => 4, 'max' => 6],
-            'answer' => ['min' => 8, 'max' => 12],
-        ];
+        $sortedQuestions = array_merge($choiceQuestions, $fillQuestions, $answerQuestions);
 
-        // 目标比例
-        $typeRatios = ['choice' => 0.40, 'fill' => 0.25, 'answer' => 0.35];
+        // 调试日志
+        \Illuminate\Support\Facades\Log::info('adjustQuestionScores 开始', [
+            'choice_count' => count($choiceQuestions),
+            'fill_count' => count($fillQuestions),
+            'answer_count' => count($answerQuestions),
+        ]);
 
-        // 检查可用题型
-        $availableTypes = array_filter($typeCounts, fn($count) => $count > 0);
-        $availableTypeCount = count($availableTypes);
-        $isPartialTypes = $availableTypeCount < 3 && $availableTypeCount > 0;
+        // 重新编号
+        foreach ($sortedQuestions as $idx => &$question) {
+            $question['question_number'] = $idx + 1;
+        }
+        unset($question);
 
-        if ($isPartialTypes) {
-            $equalRatio = 1.0 / $availableTypeCount;
-            foreach ($typeCounts as $type => $count) {
-                if ($count > 0) {
-                    $typeRatios[$type] = $equalRatio;
-                } else {
-                    $typeRatios[$type] = 0;
-                }
-            }
+        // 各题型数量
+        $typeCounts = [
+            'choice' => count($choiceQuestions),
+            'fill' => count($fillQuestions),
+            'answer' => count($answerQuestions),
+        ];
+
+        // 记录各题型索引
+        $typeIndexes = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($sortedQuestions as $index => $question) {
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            $typeIndexes[$type][] = $index;
         }
 
-        $typeQuestionIndexes = ['choice' => [], 'fill' => [], 'answer' => []];
-
-        // 记录每种题型的题目索引
-        foreach ($questions as $index => $question) {
-            $type = $question['question_type'] ?? 'answer';
-            if (in_array($type, ['CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
-                $type = 'choice';
-            } elseif (in_array($type, ['FILL_IN_THE_BLANK', 'FILL'], true)) {
-                $type = 'fill';
-            } elseif (in_array($type, ['CALCULATION', 'WORD_PROBLEM', 'PROOF', 'ANSWER'], true)) {
-                $type = 'answer';
+        // 第二步:分配分值
+        $questionScores = [];
+
+        $totalQuestions = $typeCounts['choice'] + $typeCounts['fill'] + $typeCounts['answer'];
+        $globalBaseScore = floor($targetTotalScore / $totalQuestions);
+        $globalBaseScore = max(1, $globalBaseScore);
+
+        // 确定题型处理顺序(基于 sortedQuestions 中的顺序)
+        $typeOrder = [];
+        foreach ($sortedQuestions as $question) {
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            if (! in_array($type, $typeOrder)) {
+                $typeOrder[] = $type;
             }
-            $typeQuestionIndexes[$type][] = $index;
         }
 
-        // 生成每种题型的可能分值选项
-        $typeScoreOptions = [];
-        foreach ($typeQuestionIndexes as $type => $indexes) {
-            if (empty($indexes)) {
+        // 记录当前剩余预算
+        $remainingBudget = $targetTotalScore;
+
+        // 按顺序处理每种题型
+        foreach ($typeOrder as $typeIndex => $type) {
+            $count = $typeCounts[$type];
+            if ($count === 0) {
                 continue;
             }
 
-            $typeQuestionCount = count($indexes);
-            $minScore = $standardScoreRanges[$type]['min'];
-            $maxScore = $standardScoreRanges[$type]['max'];
-            $targetTotal = $targetTotalScore * $typeRatios[$type];
-            $idealPerQuestion = $targetTotal / $typeQuestionCount;
-
-            $options = [];
-
-            // 添加标准范围内的选项
-            for ($score = $minScore; $score <= $maxScore; $score++) {
-                $total = $score * $typeQuestionCount;
-                $options[] = [
-                    'score' => $score,
-                    'total' => $total,
-                    'difference' => abs($targetTotalScore - $total),
-                ];
-            }
+            if ($typeIndex === 0) {
+                // 第一个题型:拿平均分,然后都-1
+                $thisBase = $globalBaseScore;
 
-            // 如果是部分题型,大幅扩展搜索范围
-            if ($isPartialTypes) {
-                $idealScore = (int) round($idealPerQuestion);
-                $searchMin = max($minScore, $idealScore - 10);
-                $searchMax = $idealScore + 10;
-
-                for ($score = $searchMin; $score <= $searchMax; $score++) {
-                    if ($score >= $minScore) {
-                        $total = $score * $typeQuestionCount;
-                        if (!in_array($total, array_column($options, 'total'))) {
-                            $options[] = [
-                                'score' => $score,
-                                'total' => $total,
-                                'difference' => abs($targetTotalScore - $total),
-                            ];
-                        }
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
+
+                // 都-1
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = max(1, $questionScores[$idx] - 1);
+                }
+
+                // 计算已分配的分数
+                $allocated = 0;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $allocated += $questionScores[$idx];
+                }
+                $remainingBudget -= $allocated;
+            } elseif ($typeIndex === count($typeOrder) - 1) {
+                // 最后一个题型:用剩余分数分配
+                $thisBase = floor($remainingBudget / $count);
+                $thisBase = max(1, $thisBase);
+
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
+
+                // 余数补偿:分散到多道题,从后往前各+1
+                $total = $thisBase * $count;
+                $remainder = $remainingBudget - $total;
+                if ($remainder > 0) {
+                    // 从最后一道题开始,往前 $remainder 道题各+1
+                    $answerIndexes = array_values($typeIndexes[$type]);
+                    $startIdx = max(0, count($answerIndexes) - $remainder);
+                    for ($i = $startIdx; $i < count($answerIndexes); $i++) {
+                        $questionScores[$answerIndexes[$i]] += 1;
                     }
                 }
-            }
+            } else {
+                // 中间的题型:直接用全局平均分(不减)
+                $thisBase = $globalBaseScore;
 
-            $typeScoreOptions[$type] = $options;
-        }
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
 
-        // 生成所有可能的组合
-        $types = array_keys(array_filter($typeQuestionIndexes, fn($indexes) => !empty($indexes)));
-        $allCombinations = [[]];
-
-        foreach ($types as $type) {
-            $newCombinations = [];
-            foreach ($allCombinations as $combo) {
-                foreach ($typeScoreOptions[$type] as $option) {
-                    $newCombo = $combo;
-                    $newCombo[$type] = $option;
-                    $newCombinations[] = $newCombo;
+                // 计算已分配的分数
+                $allocated = 0;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $allocated += $questionScores[$idx];
                 }
+                $remainingBudget -= $allocated;
             }
-            $allCombinations = $newCombinations;
         }
 
-        // 找到最佳组合(优先精确匹配,其次最接近)
-        $bestCombination = null;
-        $bestDifference = PHP_FLOAT_MAX;
-        $exactMatchFound = false;
+        // 第三步:确保最后一类题型分数 > 前面所有题型
+        if (count($typeOrder) > 1) {
+            $lastType = end($typeOrder);
+            $otherTypes = array_slice($typeOrder, 0, -1);
 
-        foreach ($allCombinations as $combo) {
-            $totalScore = array_sum(array_column($combo, 'total'));
-            $difference = abs($targetTotalScore - $totalScore);
+            // 前面题型的最高分
+            $maxOtherScore = 0;
+            foreach ($otherTypes as $type) {
+                foreach ($typeIndexes[$type] as $idx) {
+                    $maxOtherScore = max($maxOtherScore, $questionScores[$idx]);
+                }
+            }
 
-            if ($difference == 0) {
-                $bestCombination = $combo;
-                $exactMatchFound = true;
-                break;
+            // 最后一类题型的最低分
+            $minLastScore = PHP_INT_MAX;
+            foreach ($typeIndexes[$lastType] as $idx) {
+                $minLastScore = min($minLastScore, $questionScores[$idx]);
             }
 
-            if ($difference < $bestDifference) {
-                $bestDifference = $difference;
-                $bestCombination = $combo;
+            // 如果最后一类不够高,从前面扣分
+            if ($minLastScore <= $maxOtherScore) {
+                $diff = $maxOtherScore - $minLastScore + 1;
+
+                // 从前面题型扣分(每道最多扣2分)
+                $reductionPerQuestion = min($diff, 2);
+                foreach ($otherTypes as $type) {
+                    foreach ($typeIndexes[$type] as $idx) {
+                        $questionScores[$idx] = max(1, $questionScores[$idx] - $reductionPerQuestion);
+                    }
+                }
+
+                // 重新计算剩余给最后一类
+                $reallocated = $targetTotalScore;
+                foreach ($typeIndexes[$lastType] as $idx) {
+                    $reallocated -= $questionScores[$idx];
+                }
+                foreach ($otherTypes as $type) {
+                    foreach ($typeIndexes[$type] as $idx) {
+                        $reallocated -= $questionScores[$idx];
+                    }
+                }
+
+                if ($reallocated > 0) {
+                    $newBase = floor($reallocated / $typeCounts[$lastType]);
+                    foreach ($typeIndexes[$lastType] as $idx) {
+                        $questionScores[$idx] = $newBase;
+                    }
+
+                    $total = $newBase * $typeCounts[$lastType];
+                    $remainder = $reallocated - $total;
+                    if ($remainder > 0) {
+                        // 余数分散到多道题,从后往前各+1
+                        $lastIndexes = array_values($typeIndexes[$lastType]);
+                        $startIdx = max(0, count($lastIndexes) - $remainder);
+                        for ($i = $startIdx; $i < count($lastIndexes); $i++) {
+                            $questionScores[$lastIndexes[$i]] += 1;
+                        }
+                    }
+                }
             }
         }
 
-        // 应用最佳组合
+        // 第三步:构建结果
         $adjustedQuestions = [];
-        if ($bestCombination) {
-            foreach ($bestCombination as $type => $option) {
-                $score = $option['score'];
-                foreach ($typeQuestionIndexes[$type] as $index) {
-                    $question = $questions[$index];
-                    $question['score'] = $score;
-                    $adjustedQuestions[$index] = $question;
-                }
-            }
+        foreach ($sortedQuestions as $index => $question) {
+            $adjustedQuestions[$index] = $question;
+            $adjustedQuestions[$index]['score'] = $questionScores[$index] ?? 5;
+        }
+
+        return $adjustedQuestions;
+    }
+
+    /**
+     * 标准化题目类型
+     */
+    private function normalizeQuestionType(string $type): string
+    {
+        $type = strtolower(trim($type));
+        if (in_array($type, ['choice', 'single_choice', 'multiple_choice', '选择题', '单选', '多选'], true)) {
+            return 'choice';
+        }
+        if (in_array($type, ['fill', 'fill_in_the_blank', 'blank', '填空题', '填空'], true)) {
+            return 'fill';
         }
 
-        return array_values($adjustedQuestions);
+        return 'answer';
     }
 
     private function resolveMistakeQuestionIds(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array
     {
         $questionIds = [];
 
-        if (!empty($mistakeQuestionIds)) {
+        if (! empty($mistakeQuestionIds)) {
             $questionIds = array_merge($questionIds, $mistakeQuestionIds);
         }
 
-        if (!empty($mistakeIds)) {
+        if (! empty($mistakeIds)) {
             $mistakeQuestionIdsFromDb = MistakeRecord::query()
                 ->where('student_id', $studentId)
                 ->whereIn('id', $mistakeIds)
@@ -1001,6 +1062,7 @@ class IntelligentExamController extends Controller
         }
 
         $questionIds = array_values(array_unique(array_filter($questionIds)));
+
         return $questionIds;
     }
 
@@ -1016,6 +1078,7 @@ class IntelligentExamController extends Controller
             $bId = (string) ($b['id'] ?? '');
             $aPos = $order[$aId] ?? PHP_INT_MAX;
             $bPos = $order[$bId] ?? PHP_INT_MAX;
+
             return $aPos <=> $bPos;
         });
 
@@ -1034,7 +1097,7 @@ class IntelligentExamController extends Controller
         $grade = $data['grade'] ?? null;
 
         // 如果没有提供series_id或semester_code,则不设置textbook_id
-        if (!$seriesId || !$semesterCode) {
+        if (! $seriesId || ! $semesterCode) {
             return null;
         }
 
@@ -1057,16 +1120,18 @@ class IntelligentExamController extends Controller
                     'series_id' => $seriesId,
                     'semester_code' => $semesterCode,
                     'grade' => $grade,
-                    'textbook_id' => $textbook->id
+                    'textbook_id' => $textbook->id,
                 ]);
+
                 return (int) $textbook->id;
             }
 
             Log::warning('未找到匹配的教材', [
                 'series_id' => $seriesId,
                 'semester_code' => $semesterCode,
-                'grade' => $grade
+                'grade' => $grade,
             ]);
+
             return null;
 
         } catch (\Exception $e) {
@@ -1074,8 +1139,9 @@ class IntelligentExamController extends Controller
                 'series_id' => $seriesId,
                 'semester_code' => $semesterCode,
                 'grade' => $grade,
-                'error' => $e->getMessage()
+                'error' => $e->getMessage(),
             ]);
+
             return null;
         }
     }

+ 79 - 1
app/Http/Controllers/ExamPdfController.php

@@ -985,6 +985,74 @@ class ExamPdfController extends Controller
         ]);
     }
 
+    /**
+     * 知识点讲解视图
+     */
+    public function showKnowledgeExplanation(Request $request, $paper_id)
+    {
+        // 获取试卷数据
+        $paper = \App\Models\Paper::where('paper_id', $paper_id)->first();
+
+        if (! $paper) {
+            abort(404, '试卷未找到');
+        }
+
+        // 提取15位paper_id数字部分作为学案编号
+        $rawPaperId = $paper->paper_id ?? $paper_id;
+        preg_match('/paper_(\d{15})/', (string) $rawPaperId, $matches);
+        $examCode = $matches[1] ?? preg_replace('/[^0-9]/', '', (string) $rawPaperId);
+
+        // 生成日期
+        $generateDate = now()->locale('zh_CN')->isoFormat('M月D日');
+
+        // 提取并去重知识点代码
+        $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)->get();
+        $kpCodes = [];
+        $seen = [];
+        foreach ($paperQuestions as $pq) {
+            $kpCode = trim((string) ($pq->knowledge_point ?? ''));
+            if ($kpCode === '') {
+                continue;
+            }
+            if (isset($seen[$kpCode])) {
+                continue;
+            }
+            $seen[$kpCode] = true;
+            $kpCodes[] = $kpCode;
+        }
+
+        // 使用 ExamPdfExportService 构建知识点数据
+        $pdfService = app(\App\Services\ExamPdfExportService::class);
+        // 获取知识点名称映射
+        $kpNameMap = [];
+        try {
+            $kpNameMap = app(\App\Services\QuestionServiceApi::class)->getKnowledgePointOptions();
+        } catch (\Throwable $e) {
+            // 静默失败,使用 code 作为名称
+        }
+
+        // 批量获取知识点讲解
+        $explanations = $pdfService->buildExplanations($kpCodes);
+
+        $knowledgePoints = [];
+        foreach ($kpCodes as $kpCode) {
+            $kpData = [
+                'kp_code' => $kpCode,
+                'kp_name' => $kpNameMap[$kpCode] ?? $kpCode,
+                'explanation' => $explanations[$kpCode] ?? '',
+            ];
+
+            $knowledgePoints[] = $kpData;
+        }
+
+        return view('pdf.exam-knowledge-explanation', [
+            'paperId' => $paper_id,
+            'examCode' => $examCode ?: $paper_id,
+            'generateDate' => $generateDate,
+            'knowledgePoints' => $knowledgePoints,
+        ]);
+    }
+
     /**
      * 重新生成 PDF(统一生成卷子和判卷)
      *
@@ -1008,8 +1076,18 @@ class ExamPdfController extends Controller
             // 调用 PDF 生成服务
             $pdfService = app(\App\Services\ExamPdfExportService::class);
 
+            // 是否包含知识点讲解(可选):未传参则使用 config/pdf.php 默认值
+            $includeKpExplain = null;
+            if ($request->has('include_kp_explain')) {
+                $includeKpExplain = filter_var(
+                    $request->input('include_kp_explain'),
+                    FILTER_VALIDATE_BOOLEAN,
+                    FILTER_NULL_ON_FAILURE
+                );
+            }
+
             // 生成统一 PDF(卷子 + 判卷)
-            $pdfUrl = $pdfService->generateUnifiedPdf($paper_id);
+            $pdfUrl = $pdfService->generateUnifiedPdf($paper_id, $includeKpExplain);
 
             if ($pdfUrl) {
                 Log::info('RegeneratePdf: PDF重新生成成功', [

+ 10 - 3
app/Jobs/GenerateExamPdfJob.php

@@ -4,8 +4,8 @@ namespace App\Jobs;
 
 use App\Models\Paper;
 use App\Services\ExamPdfExportService;
-use App\Services\QuestionBankService;
 use App\Services\PaperPayloadService;
+use App\Services\QuestionBankService;
 use App\Services\TaskManager;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
@@ -19,7 +19,9 @@ class GenerateExamPdfJob implements ShouldQueue
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     public string $taskId;
+
     public string $paperId;
+
     public int $maxAttempts = 3;
 
     public function __construct(string $taskId, string $paperId)
@@ -46,7 +48,7 @@ class GenerateExamPdfJob implements ShouldQueue
 
             // 【修复】首先检查试卷是否存在
             $paperModel = Paper::with('questions')->find($this->paperId);
-            if (!$paperModel) {
+            if (! $paperModel) {
                 Log::error('PDF生成队列任务失败:试卷不存在', [
                     'task_id' => $this->taskId,
                     'paper_id' => $this->paperId,
@@ -63,6 +65,7 @@ class GenerateExamPdfJob implements ShouldQueue
                     ]);
                     // 延迟2秒后重试(缩短间隔,减少对回调的影响)
                     $this->release(2);
+
                     return;
                 } else {
                     Log::error('试卷不存在且已达到最大重试次数,标记任务失败', [
@@ -71,6 +74,7 @@ class GenerateExamPdfJob implements ShouldQueue
                         'attempts' => $this->attempts(),
                     ]);
                     $taskManager->markTaskFailed($this->taskId, "试卷不存在: {$this->paperId}");
+
                     return;
                 }
             }
@@ -91,16 +95,18 @@ class GenerateExamPdfJob implements ShouldQueue
                     ]);
                     // 延迟1秒后重试(更短间隔)
                     $this->release(1);
+
                     return;
                 } else {
                     $taskManager->markTaskFailed($this->taskId, "试卷没有题目数据: {$this->paperId}");
+
                     return;
                 }
             }
 
             $taskManager->updateTaskProgress($this->taskId, 10, '开始生成统一PDF(直接合并两个页面,效率最高)...');
 
-            // 【终极优化】直接合并两个HTML页面生成一份PDF(无需生成单独PDF)
+            // 根据 config 或 env 配置决定是否包含知识点讲解
             $unifiedPdfUrl = $pdfExportService->generateUnifiedPdf($this->paperId);
 
             $taskManager->updateTaskProgress($this->taskId, 90, 'PDF生成完成,准备返回结果...');
@@ -141,6 +147,7 @@ class GenerateExamPdfJob implements ShouldQueue
                     'attempt' => $this->attempts(),
                 ]);
                 $this->release(2);
+
                 return;
             }
 

+ 1 - 0
app/Models/KnowledgePoint.php

@@ -21,6 +21,7 @@ class KnowledgePoint extends Model
         'dependent_kp_codes',
         'related_kp_codes',
         'stats',
+        'explanation',
     ];
 
     protected $casts = [

+ 350 - 239
app/Services/ExamPdfExportService.php

@@ -2,8 +2,6 @@
 
 namespace App\Services;
 
-use App\DTO\ExamAnalysisDataDto;
-use App\DTO\ReportPayloadDto;
 use App\Models\Paper;
 use App\Models\Student;
 use Illuminate\Support\Facades\DB;
@@ -71,12 +69,44 @@ class ExamPdfExportService
     /**
      * 【优化方案】生成统一PDF(卷子 + 判卷一页完成)
      * 效率提升40-50%,只需生成一次PDF
+     *
+     * @param  string  $paperId  试卷ID
+     * @param  bool|null  $includeKpExplain  是否包含知识点讲解,null则使用配置文件默认值
+     * @return string|null PDF URL
      */
-    public function generateUnifiedPdf(string $paperId): ?string
+    public function generateUnifiedPdf(string $paperId, ?bool $includeKpExplain = null): ?string
     {
-        Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', ['paper_id' => $paperId]);
+        // 【临时禁用】强制不包含知识点讲解,等待后续修复
+        $includeKpExplain = false;
+
+        // 决定是否包含知识点讲解
+        // if ($includeKpExplain === null) {
+        //     $includeKpExplain = config('pdf.include_kp_explain_default', false);
+        // }
+
+        Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [
+            'paper_id' => $paperId,
+            'include_kp_explain' => $includeKpExplain,
+        ]);
 
         try {
+            // 步骤0:获取知识点讲解HTML(如需要)
+            $kpExplainHtml = null;
+            if ($includeKpExplain) {
+                Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
+                $kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
+                if ($kpExplainHtml) {
+                    // 对知识点讲解HTML进行内联资源处理(与服务端公式渲染)
+                    $kpExplainHtml = $this->inlineExternalResources($kpExplainHtml);
+                    Log::info('generateUnifiedPdf: 知识点讲解HTML获取并处理成功', [
+                        'paper_id' => $paperId,
+                        'length' => strlen($kpExplainHtml),
+                    ]);
+                } else {
+                    Log::warning('generateUnifiedPdf: 知识点讲解HTML获取失败,将跳过', ['paper_id' => $paperId]);
+                }
+            }
+
             // 步骤1:同时渲染两个页面的HTML
             Log::info('generateUnifiedPdf: 开始渲染试卷HTML', ['paper_id' => $paperId]);
             $examHtml = $this->renderExamHtml($paperId, includeAnswer: false, useGradingView: false);
@@ -98,13 +128,17 @@ class ExamPdfExportService
 
             // 步骤2:插入分页符,合并HTML
             Log::info('generateUnifiedPdf: 开始合并HTML(保留原始样式)', ['paper_id' => $paperId]);
-            $unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml);
+            $unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml, $kpExplainHtml);
             if (! $unifiedHtml) {
                 Log::error('ExamPdfExportService: HTML合并失败', ['paper_id' => $paperId]);
 
                 return null;
             }
-            Log::info('generateUnifiedPdf: HTML合并完成(将直接生成PDF,不使用pdfunite)', ['paper_id' => $paperId, 'length' => strlen($unifiedHtml)]);
+            Log::info('generateUnifiedPdf: HTML合并完成(将直接生成PDF,不使用pdfunite)', [
+                'paper_id' => $paperId,
+                'length' => strlen($unifiedHtml),
+                'has_kp_explain' => ! empty($kpExplainHtml),
+            ]);
 
             // 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
             Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
@@ -298,174 +332,37 @@ class ExamPdfExportService
     }
 
     /**
-     * 保存合并PDF URL到数据库
-     */
-    private function saveAllPdfUrlToDatabase(string $paperId, string $url): void
-    {
-        try {
-            \App\Models\Paper::where('paper_id', $paperId)->update([
-                'all_pdf_url' => $url,
-            ]);
-            Log::debug('保存all_pdf_url成功', ['paper_id' => $paperId, 'url' => $url]);
-        } catch (\Exception $e) {
-            Log::error('保存all_pdf_url失败', [
-                'paper_id' => $paperId,
-                'url' => $url,
-                'error' => $e->getMessage(),
-            ]);
-            throw $e;
-        }
-    }
-
-    /**
-     * 生成学情分析 PDF
+     * 【新增】获取知识点讲解HTML
      */
-    public function generateAnalysisReportPdf(string $paperId, string $studentId, ?string $recordId = null): ?string
+    private function fetchKnowledgeExplanationHtml(string $paperId): ?string
     {
-        if (function_exists('set_time_limit')) {
-            @set_time_limit(240);
-        }
-
         try {
-            // 【调试】打印输入参数
-            Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'record_id' => $recordId,
-            ]);
-
-            // 构建分析数据
-            $analysisData = $this->buildAnalysisData($paperId, $studentId);
-            if (! $analysisData) {
-                Log::warning('ExamPdfExportService: buildAnalysisData返回空数据', [
-                    'paper_id' => $paperId,
-                    'student_id' => $studentId,
-                ]);
-
-                return null;
-            }
-
-            Log::info('ExamPdfExportService: buildAnalysisData返回数据', [
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'analysisData_keys' => array_keys($analysisData),
-                'mastery_count' => count($analysisData['mastery']['items'] ?? []),
-                'questions_count' => count($analysisData['questions'] ?? []),
-            ]);
-
-            // 创建DTO
-            $dto = ExamAnalysisDataDto::fromArray($analysisData);
-            $payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
-
-            // 【调试】打印传给模板的数据
-            $templateData = $payloadDto->toArray();
-            Log::info('ExamPdfExportService: 传给模板的数据', [
-                'paper' => $templateData['paper'] ?? null,
-                'student' => $templateData['student'] ?? null,
-                'mastery' => $templateData['mastery'] ?? null,
-                'parent_mastery_levels' => $templateData['parent_mastery_levels'] ?? null, // 新增:检查父节点掌握度
-                'questions_count' => count($templateData['questions'] ?? []),
-                'insights_count' => count($templateData['question_insights'] ?? []),
-                'recommendations_count' => count($templateData['recommendations'] ?? []),
-            ]);
-
-            // 渲染HTML
-            $html = view('exam-analysis.pdf-report', $templateData)->render();
-            if (! $html) {
-                Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
-
-                return null;
-            }
+            $url = route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]);
 
-            // 生成PDF
-            $pdfBinary = $this->buildPdf($html);
-            if (! $pdfBinary) {
-                return null;
-            }
-
-            // 保存PDF
-            $version = time();
-            $path = "analysis_reports/{$paperId}_{$studentId}_{$version}.pdf";
-            $url = $this->pdfStorageService->put($path, $pdfBinary);
-            if (! $url) {
-                Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]);
+            $response = Http::get($url);
+            if ($response->successful()) {
+                $html = $response->body();
+                if (! empty(trim($html))) {
+                    Log::info('ExamPdfExportService: 成功获取知识点讲解HTML', [
+                        'paper_id' => $paperId,
+                        'length' => strlen($html),
+                    ]);
 
-                return null;
+                    return $this->ensureUtf8Html($html);
+                }
             }
 
-            // 保存URL到数据库
-            $this->saveAnalysisPdfUrl($paperId, $studentId, $recordId, $url);
-
-            return $url;
-
-        } catch (\Throwable $e) {
-            Log::error('ExamPdfExportService: 生成学情分析PDF失败', [
+            Log::warning('ExamPdfExportService: 获取知识点讲解HTML失败', [
                 'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'record_id' => $recordId,
-                'error' => $e->getMessage(),
-                'exception' => get_class($e),
-                'trace' => $e->getTraceAsString(),
+                'url' => $url,
             ]);
 
             return null;
-        }
-    }
-
-    /**
-     * 渲染并存储试卷PDF
-     */
-    private function renderAndStoreExamPdf(
-        string $paperId,
-        bool $includeAnswer,
-        string $suffix,
-        bool $useGradingView = false
-    ): ?string {
-        // 放宽脚本执行时间
-        if (function_exists('set_time_limit')) {
-            @set_time_limit(240);
-        }
 
-        try {
-            $html = $this->renderExamHtml($paperId, $includeAnswer, $useGradingView);
-            if (! $html) {
-                Log::error('ExamPdfExportService: 渲染HTML为空', [
-                    'paper_id' => $paperId,
-                    'include_answer' => $includeAnswer,
-                    'use_grading_view' => $useGradingView,
-                ]);
-
-                return null;
-            }
-
-            $pdfBinary = $this->buildPdf($html);
-            if (! $pdfBinary) {
-                Log::error('ExamPdfExportService: buildPdf为空', [
-                    'paper_id' => $paperId,
-                    'include_answer' => $includeAnswer,
-                    'use_grading_view' => $useGradingView,
-                ]);
-
-                return null;
-            }
-
-            $path = "exams/{$paperId}_{$suffix}.pdf";
-            $url = $this->pdfStorageService->put($path, $pdfBinary);
-            if (! $url) {
-                Log::error('ExamPdfExportService: 保存PDF失败', ['path' => $path]);
-
-                return null;
-            }
-
-            return $url;
-
-        } catch (\Throwable $e) {
-            Log::error('ExamPdfExportService: 生成PDF失败', [
+        } catch (\Exception $e) {
+            Log::warning('ExamPdfExportService: 获取知识点讲解HTML异常', [
                 'paper_id' => $paperId,
-                'suffix' => $suffix,
                 'error' => $e->getMessage(),
-                'exception' => get_class($e),
-                'trace' => $e->getTraceAsString(),
             ]);
 
             return null;
@@ -473,52 +370,55 @@ class ExamPdfExportService
     }
 
     /**
-     * 渲染试卷HTML(重构版
+     * 【新增】渲染试卷HTML(通过HTTP调用路由
      */
     private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
     {
-        // 直接构造请求URL,使用路由生成HTML
-        $routeName = $useGradingView
-            ? 'filament.admin.auth.intelligent-exam.grading'
-            : 'filament.admin.auth.intelligent-exam.pdf';
+        try {
+            // 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑)
+            $routeName = $useGradingView
+                ? 'filament.admin.auth.intelligent-exam.grading'
+                : 'filament.admin.auth.intelligent-exam.pdf';
 
-        $url = route($routeName, ['paper_id' => $paperId, 'answer' => $includeAnswer ? 'true' : 'false']);
+            $url = route($routeName, ['paper_id' => $paperId, 'answer' => $includeAnswer ? 'true' : 'false']);
 
-        // 使用HTTP客户端获取渲染后的HTML
-        try {
             $response = Http::get($url);
             if ($response->successful()) {
                 $html = $response->body();
                 if (! empty(trim($html))) {
                     return $this->ensureUtf8Html($html);
-                } else {
-                    Log::warning('ExamPdfExportService: HTTP返回的HTML为空,使用备用方案', [
-                        'paper_id' => $paperId,
-                        'url' => $url,
-                    ]);
                 }
             }
+
+            Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败,使用备用方案', [
+                'paper_id' => $paperId,
+                'url' => $url,
+            ]);
+
         } catch (\Exception $e) {
-            Log::warning('ExamPdfExportService: 通过HTTP获取HTML失败,使用备用方案', [
+            Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML异常', [
                 'paper_id' => $paperId,
                 'error' => $e->getMessage(),
             ]);
         }
 
-        // 备用方案:直接渲染视图(如果路由不可用)
+        // 备用方案:直接渲染视图
+        return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
+    }
+
+    /**
+     * 备用方案:直接渲染视图生成试卷HTML
+     */
+    private function renderExamHtmlFromView(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
+    {
         try {
             $paper = Paper::with('questions')->find($paperId);
             if (! $paper) {
-                Log::error('ExamPdfExportService: 试卷不存在,备用方案无法渲染', [
-                    'paper_id' => $paperId,
-                    'include_answer' => $includeAnswer,
-                    'use_grading_view' => $useGradingView,
-                ]);
+                Log::error('ExamPdfExportService: 试卷不存在', ['paper_id' => $paperId]);
 
                 return null;
             }
 
-            // 检查试卷是否有题目
             if ($paper->questions->isEmpty()) {
                 Log::error('ExamPdfExportService: 试卷没有题目数据', [
                     'paper_id' => $paperId,
@@ -530,38 +430,25 @@ class ExamPdfExportService
 
             $viewName = $useGradingView ? 'pdf.exam-grading' : 'pdf.exam-paper';
 
-            // 【修复】构造视图需要的 $questions、$student、$teacher 变量
+            // 构造视图需要的变量
             $questions = ['choice' => [], 'fill' => [], 'answer' => []];
             foreach ($paper->questions as $pq) {
-                $type = strtolower($pq->question_type ?? 'answer');
-                if (! isset($questions[$type])) {
-                    $type = 'answer';
-                }
-                $questions[$type][] = (object) [
-                    'id' => $pq->question_bank_id,
-                    'question_number' => $pq->question_number,
-                    'content' => $pq->question_text ?? '',
-                    'stem' => $pq->question_text ?? '',
-                    'answer' => $pq->correct_answer ?? '',
-                    'solution' => $pq->solution ?? '',
-                    'difficulty' => $pq->difficulty ?? 0.5,
-                    'score' => $pq->score ?? 5,
-                    'question_type' => $type,
-                    'math_processed' => true,
-                ];
-            }
-            foreach (['choice', 'fill', 'answer'] as $t) {
-                if (! empty($questions[$t])) {
-                    usort($questions[$t], fn ($a, $b) => ($a->question_number ?? 0) <=> ($b->question_number ?? 0));
-                }
+                $qType = $this->normalizeQuestionType($pq->question_type ?? 'answer');
+                $questions[$qType][] = $pq;
             }
 
-            $studentModel = $paper->student_id ? \App\Models\Student::where('student_id', $paper->student_id)->first() : null;
-            $teacherModel = $paper->teacher_id ? \App\Models\Teacher::where('teacher_id', $paper->teacher_id)->first() : null;
+            $studentModel = \App\Models\Student::find($paper->student_id);
+            $teacherModel = \App\Models\Teacher::find($paper->teacher_id);
             $student = ['name' => $studentModel->name ?? ($paper->student_id ?? '________'), 'grade' => $studentModel->grade ?? '________'];
             $teacher = ['name' => $teacherModel->name ?? ($paper->teacher_id ?? '________')];
 
-            $html = view($viewName, ['paper' => $paper, 'questions' => $questions, 'includeAnswer' => $includeAnswer, 'student' => $student, 'teacher' => $teacher])->render();
+            $html = view($viewName, [
+                'paper' => $paper,
+                'questions' => $questions,
+                'includeAnswer' => $includeAnswer,
+                'student' => $student,
+                'teacher' => $teacher,
+            ])->render();
 
             if (empty(trim($html))) {
                 Log::error('ExamPdfExportService: 视图渲染结果为空', [
@@ -1323,39 +1210,41 @@ class ExamPdfExportService
 
     /**
      * 将CDN资源替换为内联资源
-     * 【关键修复】避免Chrome在容器中加载CDN资源超时
+     * 【关键修复】避免Chrome在容器中加载CDN资源超时,同时支持本地路径
      */
     private function inlineExternalResources(string $html): string
     {
+        // 检查是否包含 KaTeX 资源(CDN 或本地)
+        $hasKatexCdn = strpos($html, 'cdn.jsdelivr.net/npm/katex') !== false;
+        $hasKatexLocal = strpos($html, '/js/katex.min.js') !== false || strpos($html, '/css/katex/katex.min.css') !== false;
+
         // 【调试】记录HTML内容信息
         Log::warning('ExamPdfExportService: inlineExternalResources', [
             'html_length' => strlen($html),
-            'has_katex_cdn' => strpos($html, 'cdn.jsdelivr.net/npm/katex') !== false,
-            'html_head_preview' => substr($html, 0, 1000),
+            'has_katex_cdn' => $hasKatexCdn,
+            'has_katex_local' => $hasKatexLocal,
         ]);
 
-        // 检查是否包含KaTeX CDN链接
-        if (strpos($html, 'cdn.jsdelivr.net/npm/katex') === false) {
-            Log::warning('ExamPdfExportService: HTML中没有KaTeX CDN链接,跳过内联');
+        // 如果既没有 CDN 也没有本地链接,跳过
+        if (! $hasKatexCdn && ! $hasKatexLocal) {
+            Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
 
             return $html;
         }
 
         try {
-            // 读取本地KaTeX CSS文件并内联
+            // 读取并内联 KaTeX CSS(无论 CDN 还是本地)
             $katexCssPath = public_path('css/katex/katex.min.css');
             if (file_exists($katexCssPath)) {
                 $katexCss = file_get_contents($katexCssPath);
 
-                // 修复字体路径:将相对路径改为绝对路径(使用data URI或绝对路径)
-                // KaTeX CSS中的字体引用格式: url(fonts/KaTeX_xxx.woff2)
+                // 修复字体路径:将相对路径改为 data URI
                 $fontsDir = public_path('css/katex/fonts');
                 $katexCss = preg_replace_callback(
                     '/url\(["\']?fonts\/([^"\')\s]+)["\']?\)/i',
                     function ($matches) use ($fontsDir) {
                         $fontFile = $fontsDir.'/'.$matches[1];
                         if (file_exists($fontFile)) {
-                            // 将字体转换为data URI(适用于PDF生成)
                             $fontData = base64_encode(file_get_contents($fontFile));
                             $mimeType = str_ends_with($matches[1], '.woff2') ? 'font/woff2' : 'font/woff';
 
@@ -1367,17 +1256,28 @@ class ExamPdfExportService
                     $katexCss
                 );
 
-                // 替换CDN CSS链接为内联样式
-                $html = preg_replace(
-                    '/<link[^>]*href=["\']https:\/\/cdn\.jsdelivr\.net\/npm\/katex[^"\']*katex\.min\.css["\'][^>]*>/i',
-                    '<style type="text/css">'.$katexCss.'</style>',
-                    $html
-                );
+                // 替换 CDN CSS 链接
+                if ($hasKatexCdn) {
+                    $html = preg_replace(
+                        '/<link[^>]*href=["\']https:\/\/cdn\.jsdelivr\.net\/npm\/katex[^"\']*katex\.min\.css["\'][^>]*>/i',
+                        '<style type="text/css">'.$katexCss.'</style>',
+                        $html
+                    );
+                }
+
+                // 替换本地 CSS 链接
+                if ($hasKatexLocal) {
+                    $html = preg_replace(
+                        '/<link[^>]*href=["\']\/css\/katex\/katex\.min\.css["\'][^>]*>/i',
+                        '<style type="text/css">'.$katexCss.'</style>',
+                        $html
+                    );
+                }
 
-                Log::info('ExamPdfExportService: KaTeX CSS已内联(含字体data URI)');
+                Log::info('ExamPdfExportService: KaTeX CSS 已内联(含字体 data URI)');
             }
 
-            // 读取本地KaTeX JS并内联
+            // 读取本地 KaTeX JS(用于移除)
             $katexJsPath = public_path('js/katex.min.js');
             $autoRenderJsPath = public_path('js/auto-render.min.js');
 
@@ -1451,16 +1351,21 @@ class ExamPdfExportService
      * 【新增】合并两个HTML页面,插入分页符
      * 保留原始页面样式和结构,只在中间插入分页符
      */
-    private function mergeHtmlWithPageBreak(string $examHtml, string $gradingHtml): ?string
+    private function mergeHtmlWithPageBreak(string $examHtml, string $gradingHtml, ?string $kpExplainHtml = null): ?string
     {
         try {
             // 确保HTML编码正确
             $examHtml = $this->ensureUtf8Html($examHtml);
             $gradingHtml = $this->ensureUtf8Html($gradingHtml);
+            if ($kpExplainHtml) {
+                $kpExplainHtml = $this->ensureUtf8Html($kpExplainHtml);
+            }
 
             // 提取body内容
             $examBody = $this->extractBodyContent($examHtml);
             $gradingBody = $this->extractBodyContent($gradingHtml);
+            // 知识点讲解使用专门的提取方法,避免嵌套完整HTML结构
+            $kpExplainBody = $kpExplainHtml ? $this->extractKpExplainContent($kpExplainHtml) : null;
 
             if (empty($examBody) || empty($gradingBody)) {
                 Log::error('ExamPdfExportService: HTML内容提取失败', [
@@ -1473,15 +1378,18 @@ class ExamPdfExportService
 
             // 提取head内容(保留原始样式和meta信息)
             $examHead = $this->extractHeadContent($examHtml);
+            $kpExplainHead = $kpExplainHtml ? $this->extractHeadContent($kpExplainHtml) : null;
 
             // 构建统一HTML文档(保留原始结构)
-            $unifiedHtml = $this->buildUnifiedHtmlWithOriginalStructure($examHead, $examBody, $gradingBody);
+            $unifiedHtml = $this->buildUnifiedHtmlWithOriginalStructure($examHead, $examBody, $gradingBody, $kpExplainBody, $kpExplainHead);
 
             Log::info('HTML合并成功(保留原始样式)', [
                 'exam_length' => strlen($examBody),
                 'grading_length' => strlen($gradingBody),
+                'kp_explain_length' => $kpExplainBody ? strlen($kpExplainBody) : 0,
                 'unified_length' => strlen($unifiedHtml),
                 'head_length' => strlen($examHead),
+                'has_kp_explain' => ! empty($kpExplainBody),
             ]);
 
             return $unifiedHtml;
@@ -1525,27 +1433,89 @@ class ExamPdfExportService
         return '<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">';
     }
 
+    /**
+     * 【新增】提取知识点讲解的核心内容
+     * 只提取 kp-explain 容器内部的内容,避免嵌套完整的HTML结构
+     */
+    private function extractKpExplainContent(string $html): string
+    {
+        // 如果 HTML 中包含嵌套的 <html> 标签,提取嵌套内容
+        if (preg_match('/<html[^>]*>(.*)<\/html>/is', $html, $htmlMatches)) {
+            $html = $htmlMatches[1];
+        }
+
+        // 如果 HTML 中包含 <head> 标签,跳过 head
+        if (preg_match('/<head[^>]*>.*?<\/head>/is', $html, $headMatch)) {
+            $html = substr($html, strpos($html, '</head>') + 7);
+        }
+
+        // 如果 HTML 中包含 <body> 标签,提取 body 内容
+        if (preg_match('/<body[^>]*>(.*)<\/body>/is', $html, $bodyMatches)) {
+            $html = $bodyMatches[1];
+        }
+
+        // 移除可能存在的嵌套 <html>, <head>, <body> 标签
+        $html = preg_replace('/<\/?(html|head|body)[^>]*>/is', '', $html);
+
+        // 移除 script 标签和注释
+        $html = preg_replace('/<script[^>]*>.*?<\/script>/is', '', $html);
+        $html = preg_replace('/<!--[^>]*-->/is', '', $html);
+
+        return trim($html);
+    }
+
     /**
      * 【优化重构】构建统一的HTML文档(容器结构 + 消除空白页)
      * 使用容器结构替代空的 page-break div,避免中间出现空白页
      */
-    private function buildUnifiedHtmlWithOriginalStructure(string $examHead, string $examBody, string $gradingBody): string
+    private function buildUnifiedHtmlWithOriginalStructure(string $examHead, string $examBody, string $gradingBody, ?string $kpExplainBody = null, ?string $kpExplainHead = null): string
     {
         // 清洗内容:移除可能存在的分页符,避免双重分页
         $examBody = $this->stripPageBreakElements($examBody);
         $gradingBody = $this->stripPageBreakElements($gradingBody);
+        if ($kpExplainBody) {
+            $kpExplainBody = $this->stripPageBreakElements($kpExplainBody);
+        }
 
-        // 构建优化的HTML结构,使用容器控制分页
-        $headContent = $examHead.'
+        // 合并 head 内容:试卷 head + 知识点讲解 head(去重)+ 分页控制样式
+        $mergedHead = $examHead;
+
+        // 如果有知识点讲解 head,合并样式(避免重复)
+        if ($kpExplainHead) {
+            // 提取知识点讲解中的 <style> 内容并追加
+            if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $kpExplainHead, $styleMatches)) {
+                foreach ($styleMatches[0] as $idx => $styleTag) {
+                    // 避免重复添加相同的样式
+                    if (! str_contains($mergedHead, $styleMatches[1][$idx])) {
+                        $mergedHead .= "\n    ".$styleTag;
+                    }
+                }
+            }
+        }
+
+        // 添加分页控制样式
+        $headContent = $mergedHead.'
     <style>
         /* 容器基础样式 - 保持现有页面边距 */
         .exam-part,
-        .grading-part {
+        .grading-part,
+        .kp-explain-part {
             width: 100%;
             margin: 0;
             padding: 0;
         }
 
+        /* 试卷部分 - 只有在知识点讲解后才需要分页 */
+        .exam-part {
+            /* 如果有知识点讲解,需要在新页面开始(通过 kp-explain-part 的 break-after 控制) */
+            /* 如果没有知识点讲解,试卷从第一页开始,不需要分页 */
+            break-after: auto;
+            page-break-after: auto;
+            /* 确保顶部没有额外边距 */
+            margin-top: 0;
+            padding-top: 0;
+        }
+
         /* 核心分页控制:只在 grading-part 上设置 */
         .grading-part {
             /* CSS3 新标准 */
@@ -1557,6 +1527,12 @@ class ExamPdfExportService
             padding-top: 0;
         }
 
+        /* 知识点讲解部分末尾强制分页(确保试卷从新页面开始) */
+        .kp-explain-part {
+            break-after: page;
+            page-break-after: always;
+        }
+
         /* 防止试卷部分末尾分页 */
         .exam-part {
             /* 确保试卷内容连续 */
@@ -1591,6 +1567,35 @@ class ExamPdfExportService
         }
     </style>';
 
+        // 构建HTML内容
+        $bodyContent = '';
+
+        // 如果有知识点讲解,添加到最前面
+        if ($kpExplainBody) {
+            $bodyContent .= '
+    <!-- 知识点讲解部分 -->
+    <div class="kp-explain-part">
+'.$kpExplainBody.'
+    </div>
+';
+        }
+
+        // 添加试卷部分
+        $bodyContent .= '
+    <!-- 试卷部分 - 连续显示 -->
+    <div class="exam-part">
+'.$examBody.'
+    </div>
+';
+
+        // 添加判卷部分
+        $bodyContent .= '
+    <!-- 判卷部分 - 强制新页面开始 -->
+    <div class="grading-part">
+'.$gradingBody.'
+    </div>
+';
+
         return '<!DOCTYPE html>
 <html lang="zh-CN">
 <head>
@@ -1600,15 +1605,7 @@ class ExamPdfExportService
 '.$headContent.'
 </head>
 <body>
-    <!-- 试卷部分 - 连续显示 -->
-    <div class="exam-part">
-'.$examBody.'
-    </div>
-
-    <!-- 判卷部分 - 强制新页面开始(不使用空的 page-break div) -->
-    <div class="grading-part">
-'.$gradingBody.'
-    </div>
+'.$bodyContent.'
 </body>
 </html>';
     }
@@ -2402,4 +2399,118 @@ class ExamPdfExportService
             'questionType' => $questionType,
         ])->render();
     }
+
+    /**
+     * 获取知识点的讲解内容
+     *
+     * @param  string  $kpCode  知识点代码
+     * @param  string  $kpName  知识点名称
+     * @return string Markdown 格式的讲解内容
+     */
+    public function buildExplanation(string $kpCode, string $kpName): string
+    {
+        try {
+            // 从数据库获取知识点讲解
+            $kp = \App\Models\KnowledgePoint::where('kp_code', $kpCode)->first();
+
+            if ($kp && ! empty($kp->explanation)) {
+                return $kp->explanation;
+            }
+        } catch (\Throwable $e) {
+            Log::warning('获取知识点讲解失败', [
+                'kp_code' => $kpCode,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        // 如果数据库没有,返回默认讲解内容
+        return $this->getDefaultExplanation($kpCode, $kpName);
+    }
+
+    /**
+     * 批量获取知识点的讲解内容
+     *
+     * @param  array  $kpCodes  知识点代码数组
+     * @return array [kp_code => explanation]
+     */
+    public function buildExplanations(array $kpCodes): array
+    {
+        $result = [];
+
+        if (empty($kpCodes)) {
+            return $result;
+        }
+
+        try {
+            // 批量获取知识点讲解
+            $kps = \App\Models\KnowledgePoint::whereIn('kp_code', $kpCodes)->get()->keyBy('kp_code');
+
+            foreach ($kpCodes as $kpCode) {
+                $kp = $kps->get($kpCode);
+                if ($kp && ! empty($kp->explanation)) {
+                    $result[$kpCode] = $kp->explanation;
+                } else {
+                    $result[$kpCode] = $this->getDefaultExplanation($kpCode, $kpCode);
+                }
+            }
+        } catch (\Throwable $e) {
+            Log::warning('批量获取知识点讲解失败', [
+                'kp_codes' => $kpCodes,
+                'error' => $e->getMessage(),
+            ]);
+
+            // 失败时返回默认内容
+            foreach ($kpCodes as $kpCode) {
+                $result[$kpCode] = $this->getDefaultExplanation($kpCode, $kpCode);
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * 获取默认的知识点讲解内容
+     */
+    private function getDefaultExplanation(string $kpCode, string $kpName): string
+    {
+        // 默认讲解模板
+        return <<<MARKDOWN
+## 知识点
+(1) 核心定义:二元一次方程是含有两个未知数且每个未知数的次数都是 1 的方程,一般形式为 \$ax + by = c\$(其中 \$a,b,c\$ 为常数,且 \$a,b\$ 不同时为 0)。二元一次方程组是由两个(或两个以上)二元一次方程组成,常见为
+\$\$
+\begin{cases}
+a_1x + b_1y = c_1\\
+a_2x + b_2y = c_2
+\end{cases}
+\$\$
+方程组应用建模就是把题目中的数量关系翻译成方程组,通过求解得到实际问题的答案。
+
+(2) 性质定理:方程组的解必须同时满足方程组中的每一个方程,因此解是两个方程公共的 \$(x,y)\$。常用解法有代入消元法与加减消元法:代入消元法适合某个方程易表示成 \$x = \dots\$ 或 \$y = \dots\$;加减消元法适合两个方程中某个未知数的系数相同或相反,便于相加或相减消去一个未知数。建模时常用关系包括:总量关系(如"总数 = 部分之和")、单价数量总价关系(\$总价 = 单价 \times 数量\$)、路程速度时间关系(\$路程 = 速度 \times 时间\$)等。
+
+(3) 注意事项:设未知数要带单位与含义,例如"设甲买了 \$x\$ 支笔";列方程要对应同一类量,别把"支数"和"元数"混在一条等式里;检查条件是否满足题意(如数量应为整数且 \$> 0\$);消元后别忘了回代求另一个未知数;最后答案要写清对象与单位,并可把解代回原式检验。
+
+## 知识点应用
+- 典型例题:文具店买笔和本子。小明买了 2 支笔和 3 本本子共花 19 元;小红买了 3 支笔和 2 本本子共花 18 元。求每支笔和每本本子的单价。
+
+- 关键步骤:
+  1. 设未知数并写出含义:设每支笔单价为 \$x\$ 元,每本本子单价为 \$y\$ 元。
+  2. 根据题意列方程组:由"总价 = 单价 \times 数量"得
+     \$\$
+     \begin{cases}
+     2x + 3y = 19\\
+     3x + 2y = 18
+     \end{cases}
+     \$\$
+  3. 选择消元并求解:用加减消元。将第一式乘 3、第二式乘 2:
+     \$\$
+     \begin{cases}
+     6x + 9y = 57\\
+     6x + 4y = 36
+     \end{cases}
+     \$\$
+     相减得 \$5y = 21\$,所以 \$y = \dfrac{21}{5} = 4.2\$。代入 \$3x + 2y = 18\$ 得 \$3x + 8.4 = 18\$,所以 \$3x = 9.6\$,\$x = 3.2\$。
+
+- 结论:每支笔 3.2 元,每本本子 4.2 元(两者均 \$> 0\$,符合题意)。
+MARKDOWN;
+    }
 }

+ 37 - 23
app/Services/MathFormulaProcessor.php

@@ -51,6 +51,12 @@ class MathFormulaProcessor
                 // 混合内容,如 "已知函数 f(x) = x^2 - 4x + 5,求最小值"
                 return self::smartWrapMixedContent($content);
 
+            case 'delimited':
+                // 已包含定界符的内容($...$, $$...$$, \(...\), \[...\])
+                // 直接返回,cleanInsideDelimiters() 已经清理了内部内容
+                // 渲染工作由客户端 KaTeX 或服务端 KatexRenderer 完成
+                return $content;
+
             case 'plain_text':
             default:
                 // 纯文本,不需要处理
@@ -90,22 +96,22 @@ class MathFormulaProcessor
 
         // 1. 处理 $$...$$ 块级公式
         $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) use ($fixEscapedCommands) {
-            return '$$' . $fixEscapedCommands($matches[1]) . '$$';
+            return '$$'.$fixEscapedCommands($matches[1]).'$$';
         }, $content);
 
         // 2. 处理 $...$ 行内公式(避免与$$冲突)
         $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) use ($fixEscapedCommands) {
-            return '$' . $fixEscapedCommands($matches[1]) . '$';
+            return '$'.$fixEscapedCommands($matches[1]).'$';
         }, $content);
 
         // 3. 处理 \(...\) 行内公式
         $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) use ($fixEscapedCommands) {
-            return '\\(' . $fixEscapedCommands($matches[1]) . '\\)';
+            return '\\('.$fixEscapedCommands($matches[1]).'\\)';
         }, $content);
 
         // 4. 处理 \[...\] 块级公式
         $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) use ($fixEscapedCommands) {
-            return '\\[' . $fixEscapedCommands($matches[1]) . '\\]';
+            return '\\['.$fixEscapedCommands($matches[1]).'\\]';
         }, $content);
 
         return $content;
@@ -126,8 +132,8 @@ class MathFormulaProcessor
      */
     private static function detectContentType(string $content): string
     {
-        // 检查是否有定界符
-        if (self::hasDelimiters($content)) {
+        // 优先检查是否包含定界符(使用 containsDelimiters 检测混合内容中的公式)
+        if (self::containsDelimiters($content)) {
             return 'delimited';
         }
 
@@ -135,7 +141,7 @@ class MathFormulaProcessor
         $hasMathFeatures = self::containsMathFeatures($content);
 
         // 如果不包含数学特征,返回纯文本
-        if (!$hasMathFeatures) {
+        if (! $hasMathFeatures) {
             return 'plain_text';
         }
 
@@ -156,7 +162,7 @@ class MathFormulaProcessor
         // 检查是纯数学还是混合内容
         // 混合内容:同时包含数学特征和普通英文单词
         $hasPlainText = preg_match('/\b[a-zA-Z]{3,7}\b/', $content) &&
-                       !preg_match('/^[a-zA-Z0-9\+\-\*\/\=\s\.\^\(\)\_\{\}]+$/', $content);
+                       ! preg_match('/^[a-zA-Z0-9\+\-\*\/\=\s\.\^\(\)\_\{\}]+$/', $content);
 
         if ($hasPlainText) {
             return 'mixed_content';
@@ -172,7 +178,7 @@ class MathFormulaProcessor
     private static function wrapPureMath(string $content): string
     {
         // 已经是纯数学格式,直接用 $ 包裹
-        return '$' . $content . '$';
+        return '$'.$content.'$';
     }
 
     /**
@@ -188,7 +194,8 @@ class MathFormulaProcessor
             $cleanContent = strip_tags($matches[1]);
             $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
             $cleanContent = trim($cleanContent);
-            return '$$' . $cleanContent . '$$';
+
+            return '$$'.$cleanContent.'$$';
         }, $content);
 
         // 2. 处理 \(...\) 行内公式
@@ -196,7 +203,8 @@ class MathFormulaProcessor
             $cleanContent = strip_tags($matches[1]);
             $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
             $cleanContent = trim($cleanContent);
-            return '\\(' . $cleanContent . '\\)';
+
+            return '\\('.$cleanContent.'\\)';
         }, $content);
 
         // 3. 处理 \[...\] 显示公式
@@ -204,7 +212,8 @@ class MathFormulaProcessor
             $cleanContent = strip_tags($matches[1]);
             $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
             $cleanContent = trim($cleanContent);
-            return '\\[' . $cleanContent . '\\]';
+
+            return '\\['.$cleanContent.'\\]';
         }, $content);
 
         // 4. 最后处理 $...$ 行内公式(避免与$$冲突)
@@ -212,7 +221,8 @@ class MathFormulaProcessor
             $cleanContent = strip_tags($matches[1]);
             $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
             $cleanContent = trim($cleanContent);
-            return '$' . $cleanContent . '$';
+
+            return '$'.$cleanContent.'$';
         }, $content);
 
         return $content;
@@ -235,40 +245,40 @@ class MathFormulaProcessor
             // 2. 导数/函数调用: f'(1), g(5), sin(x)
             "[a-zA-Z]+'?\\([a-zA-Z0-9\\+\\-\\*\\/\\^\\s\\.]+\\)",
             // 3. LaTeX 命令: \frac{1}{2}
-            "\\\\[a-zA-Z]+\\{[^}]*\\}(?:\\{[^}]*\\})?",
+            '\\\\[a-zA-Z]+\\{[^}]*\\}(?:\\{[^}]*\\})?',
             // 4. 数学表达式: x^2 + y^2, 2x - 3
-            "[a-zA-Z0-9]+[\\^_][a-zA-Z0-9\\{\\}]+(?:\\s*[\\+\\-\\*\\/]\\s*[a-zA-Z0-9\\^_\\{\\}\\.]+)*",
+            '[a-zA-Z0-9]+[\\^_][a-zA-Z0-9\\{\\}]+(?:\\s*[\\+\\-\\*\\/]\\s*[a-zA-Z0-9\\^_\\{\\}\\.]+)*',
         ];
 
-        $mathPattern = '(?:' . implode('|', $patterns) . ')';
+        $mathPattern = '(?:'.implode('|', $patterns).')';
         $pattern = "/($tagPattern)|($existingDelimiterPattern)|($mathPattern)/u";
 
         return preg_replace_callback($pattern, function ($matches) {
             // HTML 标签,原样返回
-            if (!empty($matches[1])) {
+            if (! empty($matches[1])) {
                 return $matches[1];
             }
 
             // 已有的定界符,原样返回
-            if (!empty($matches[2])) {
+            if (! empty($matches[2])) {
                 return $matches[2];
             }
 
             // 数学公式,添加 $ 包裹
-            if (!empty($matches[3])) {
+            if (! empty($matches[3])) {
                 $math = trim($matches[3]);
                 // 再次检查是否已经包裹
                 if (str_contains($math, '$')) {
                     return $math;
                 }
-                return '$' . $math . '$';
+
+                return '$'.$math.'$';
             }
 
             return $matches[0];
         }, $content);
     }
 
-
     /**
      * 检查是否已有定界符
      */
@@ -291,6 +301,7 @@ class MathFormulaProcessor
         if (str_starts_with($content, '\\(') && str_ends_with($content, '\\)')) {
             return true;
         }
+
         return false;
     }
 
@@ -355,6 +366,7 @@ class MathFormulaProcessor
                 $value = self::processArray($value, $fieldsToProcess);
             }
         }
+
         return $data;
     }
 
@@ -371,6 +383,7 @@ class MathFormulaProcessor
                 $value = self::processArrayValues($value);
             }
         }
+
         return $arr;
     }
 
@@ -382,8 +395,9 @@ class MathFormulaProcessor
         $fieldsToProcess = [
             'stem', 'content', 'question_text', 'answer',
             'correct_answer', 'student_answer', 'explanation',
-            'solution', 'question_content', 'options'
+            'solution', 'question_content', 'options',
         ];
+
         return self::processArray($question, $fieldsToProcess);
     }
 
@@ -408,4 +422,4 @@ class MathFormulaProcessor
 
         return $content;
     }
-}
+}

Різницю між файлами не показано, бо вона завелика
+ 158 - 137
app/Services/QuestionBankService.php


+ 12 - 0
config/pdf.php

@@ -11,4 +11,16 @@ return [
     |
     */
     'debug_save_html' => env('PDF_DEBUG_SAVE_HTML', false),
+
+    /*
+    |--------------------------------------------------------------------------
+    | 统一PDF:是否包含“知识点讲解”章节(默认值)
+    |--------------------------------------------------------------------------
+    |
+    | 当生成统一PDF(卷子+判卷)时,可在最前面插入“知识点讲解”章节。
+    | - 该默认值可被请求参数覆盖(例如 include_kp_explain=true/false)
+    | - 关闭时保持现有“卷子+判卷”二合一行为不变
+    |
+    */
+    'include_kp_explain_default' => env('PDF_INCLUDE_KP_EXPLAIN', false),
 ];

Різницю між файлами не показано, бо вона завелика
+ 1 - 0
public/js/markdown-it.min.js


+ 1 - 3
resources/views/components/markdown-renderer.blade.php

@@ -26,8 +26,6 @@
 
 @once
     @push('scripts')
-        <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
-        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
-        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.13.11/katex.min.css">
+        <link rel="stylesheet" href="/css/katex/katex.min.css">
     @endpush
 @endonce

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

@@ -13,7 +13,7 @@
 <head>
     <meta charset="UTF-8">
     <title>学情报告 - {{ $paper['name'] ?? '试卷' }}</title>
-    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
     <style>
         @page {
             size: A4;
@@ -358,8 +358,8 @@
     </div>
     </div> {{-- 闭合page div --}}
 
-    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
-    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
+    <script src="/js/katex.min.js"></script>
+    <script src="/js/auto-render.min.js"></script>
     <script>
         document.addEventListener('DOMContentLoaded', function() {
             try {

+ 2 - 2
resources/views/layouts/preview-tool.blade.php

@@ -6,13 +6,13 @@
     <title>题目预览验证工具 - Math CMS</title>
 
     <!-- KaTeX CSS -->
-    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
 
     <!-- Tailwind CSS CDN -->
     <script src="https://cdn.tailwindcss.com"></script>
 
     <!-- KaTeX JS -->
-    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
+    <script src="/js/katex.min.js"></script>
 
     <style>
         [x-cloak] { display: none !important; }

+ 3 - 3
resources/views/pdf/exam-grading.blade.php

@@ -12,7 +12,7 @@
 <head>
     <meta charset="UTF-8">
     <title>{{ $paper->paper_name ?? '判卷预览' }}</title>
-    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
     <style>
         @page {
             size: A4;
@@ -298,8 +298,8 @@
 
 
     <!-- KaTeX -->
-    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
-    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
+    <script src="/js/katex.min.js"></script>
+    <script src="/js/auto-render.min.js"></script>
     <script>
         document.addEventListener('DOMContentLoaded', function() {
             try {

+ 99 - 0
resources/views/pdf/exam-knowledge-explanation.blade.php

@@ -0,0 +1,99 @@
+{{-- 知识点讲解完整模板 --}}
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>知识点讲解</title>
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
+    @include('pdf.partials.kp-explain-styles')
+    @include('pdf.partials.katex-scripts')
+    <script src="/js/math-render.js"></script>
+</head>
+<body>
+    <div class="kp-explain">
+        <div class="kp-explain-header">
+            <div class="kp-explain-title">知识点讲解</div>
+            <div class="kp-explain-subtitle">
+                本章节用于梳理本卷涉及的知识点,帮助学生在做题前完成预习/复盘。
+            </div>
+        </div>
+        @if(empty($knowledgePoints))
+            <div class="kp-empty">暂无知识点数据</div>
+        @else
+            <div class="kp-list">
+                @foreach($knowledgePoints as $index => $kp)
+                    <div class="kp-section">
+                        <div class="kp-section-head">
+                            <div class="kp-section-name">{{ $loop->iteration }}、{{ $kp['kp_name'] ?? ($kp['kp_code'] ?? '未命名知识点') }}</div>
+                        </div>
+                        <div class="kp-section-body">
+                            @if(!empty($kp['explanation']))
+                                {{-- 解析 ## 标题分段:## 知识点 和 ## 知识点应用 --}}
+                                @php
+                                    $explanation = $kp['explanation'];
+                                    // 提取 ## 知识点 部分
+                                    preg_match('/##\s*知识点\s*\n([\s\S]*?)(?=##\s*知识点应用|$)/u', $explanation, $knowledgeMatch);
+                                    $knowledgeContent = isset($knowledgeMatch[1]) ? trim($knowledgeMatch[1]) : '';
+
+                                    // 提取 ## 知识点应用 部分
+                                    preg_match('/##\s*知识点应用\s*\n([\s\S]*)/u', $explanation, $applicationMatch);
+                                    $applicationContent = isset($applicationMatch[1]) ? trim($applicationMatch[1]) : '';
+                                @endphp
+
+                                @if(!empty($knowledgeContent))
+                                    <div class="kp-block">
+                                        <div class="kp-block-title"><span class="check">✓</span>知识点</div>
+                                        <div class="kp-markdown-content">{!! \App\Services\MathFormulaProcessor::processFormulas($knowledgeContent) !!}</div>
+                                    </div>
+                                @endif
+
+                                @if(!empty($applicationContent))
+                                    <div class="kp-block">
+                                        <div class="kp-block-title"><span class="check">✓</span>知识点应用</div>
+                                        <div class="kp-markdown-content">{!! \App\Services\MathFormulaProcessor::processFormulas($applicationContent) !!}</div>
+                                    </div>
+                                @endif
+                            @endif
+                        </div>
+                    </div>
+                @endforeach
+            </div>
+        @endif
+    </div>
+    <script>
+        // 确保数学公式渲染系统已初始化
+        document.addEventListener('DOMContentLoaded', function() {
+            // 延迟渲染,确保所有脚本加载完成
+            setTimeout(function() {
+                // 渲染 kp-markdown-content 中的公式
+                document.querySelectorAll('.kp-markdown-content').forEach(function(el) {
+                    if (typeof window.renderMathElement === 'function' && el.dataset.rendered !== 'true') {
+                        window.renderMathElement(el);
+                    }
+                });
+                
+                // 如果 MathRender 系统可用,使用它
+                if (typeof window.MathRender !== 'undefined') {
+                    // 临时修改 selector 以渲染 kp-markdown-content
+                    var originalSelector = window.MathRenderConfig.selector;
+                    window.MathRenderConfig.selector = '.kp-markdown-content';
+                    window.MathRender.renderAll();
+                    window.MathRenderConfig.selector = originalSelector;
+                }
+                
+                // 触发自定义事件,让其他渲染系统也能响应
+                document.dispatchEvent(new CustomEvent('math:render'));
+            }, 100);
+            
+            // 再次尝试渲染,处理可能的异步加载情况
+            setTimeout(function() {
+                document.querySelectorAll('.kp-markdown-content').forEach(function(el) {
+                    if (typeof window.renderMathElement === 'function' && el.dataset.rendered !== 'true') {
+                        window.renderMathElement(el);
+                    }
+                });
+            }, 500);
+        });
+    </script>
+</body>
+</html>

+ 3 - 3
resources/views/pdf/exam-paper.blade.php

@@ -3,7 +3,7 @@
 <head>
     <meta charset="UTF-8">
     <title>{{ $paper->paper_name ?? '试卷预览' }}</title>
-    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
     @php
         // 提取15位paper_id数字部分作为学案编号
         $rawPaperId = $paper->paper_id ?? 'unknown';
@@ -492,8 +492,8 @@
 
 
     <!-- KaTeX JavaScript 库 -->
-    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
-    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
+    <script src="/js/katex.min.js"></script>
+    <script src="/js/auto-render.min.js"></script>
 
     <script>
         document.addEventListener('DOMContentLoaded', function() {

+ 350 - 0
resources/views/pdf/partials/common-styles.blade.php

@@ -0,0 +1,350 @@
+{{-- 
+    PDF通用样式片段
+    被卷子、判卷和知识点讲解模板共享
+    
+    包含:
+    1. 页面设置 (A4, 页边距, 页眉页脚)
+    2. 基础排版样式(不含body,因为知识点讲解有独立容器)
+    3. 题目相关通用样式
+    4. SVG/数学公式渲染样式
+    5. 图片容器样式
+    6. 打印相关样式
+--}}
+<style>
+    /* ==================== 页面设置 ==================== */
+    @page {
+        size: A4;
+        margin: 2cm 2cm 2.5cm 2cm;
+        @top-left {
+            content: string(top-left-text);
+            font-size: 12px;
+            color: #666;
+        }
+        @top-right {
+            content: string(top-right-text);
+            font-size: 12px;
+            color: #666;
+        }
+        @bottom-left {
+            content: string(bottom-left-text);
+            font-size: 12px;
+            color: #666;
+        }
+        @bottom-right {
+            content: counter(page) "/" counter(pages);
+            font-size: 12px;
+            color: #666;
+        }
+    }
+
+    /* ==================== 通用组件样式 ==================== */
+    /* 大题标题:不与后面内容分开 */
+    .section-title {
+        font-size: 16px;
+        font-weight: bold;
+        margin-top: 16px;
+        margin-bottom: 10px;
+        page-break-after: avoid;
+        break-after: avoid;
+    }
+    
+    /* 题目整体:不分页 */
+    .question {
+        margin-bottom: 14px;
+        page-break-inside: avoid;
+        break-inside: avoid;
+        -webkit-column-break-inside: avoid;
+    }
+    
+    /* 题目网格:不分页 */
+    .question-grid {
+        display: grid;
+        grid-template-columns: auto 1fr;
+        column-gap: 4px;
+        row-gap: 6px;
+        align-items: flex-start;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    
+    .question-lead {
+        display: flex;
+        gap: 4px;
+        align-items: flex-start;
+        font-weight: 600;
+        font-size: 14px;
+        line-height: 1.6;
+        margin-top: 2px;
+    }
+    .question-lead.spacer { visibility: hidden; }
+    .question-number { white-space: nowrap; margin-right: 2px; }
+    .grading-boxes { gap: 4px; flex-wrap: wrap; align-items: center; }
+    .grading-boxes span { vertical-align: middle; }
+    
+    .question-main {
+        font-size: 14px;
+        line-height: 1.65;
+        font-family: inherit;
+        display: block;
+        orphans: 3;
+        widows: 3;
+    }
+    
+    .question-score { margin-right: 6px; font-weight: 600; }
+    .question-score-inline {
+        display: block;
+        font-size: 13px;
+        color: #555;
+        margin: 0 0 2px 0;
+        white-space: nowrap;
+    }
+    
+    /* 题目内容:防止孤行 */
+    .question-stem {
+        display: block;
+        font-size: 14px;
+        font-family: inherit;
+        orphans: 3;
+        widows: 3;
+    }
+    .question-content {
+        font-size: 14px;
+        margin-bottom: 8px;
+        line-height: 1.6;
+        orphans: 3;
+        widows: 3;
+    }
+
+    /* ==================== 选项样式 ==================== */
+    /* 选项容器:不分页 */
+    .options {
+        display: grid;
+        row-gap: 8px;
+        margin-top: 8px;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    .options-grid-4 {
+        display: grid;
+        grid-template-columns: repeat(4, 1fr);
+        gap: 8px 12px;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    .options-grid-2 {
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: 8px 20px;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    .options-grid-1 {
+        display: grid;
+        grid-template-columns: 1fr;
+        gap: 8px;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    
+    /* 单个选项:不分页 */
+    .option {
+        width: 100%;
+        font-size: 14px;
+        line-height: 1.6;
+        word-wrap: break-word;
+        display: flex;
+        align-items: flex-start;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    .option strong { margin-right: 4px; }
+    .option-inline { display: inline-flex; align-items: baseline; margin-right: 20px; }
+    .option-compact { font-size: 14px; line-height: 1.6; }
+
+    /* ==================== 答案区域样式 ==================== */
+    /* 答案元信息:不分页 */
+    .answer-meta {
+        font-size: 12px;
+        color: #2f2f2f;
+        line-height: 1.75;
+        margin-top: 4px;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    .answer-line + .answer-line { margin-top: 4px; }
+    
+    .solution-step {
+        align-items: center;
+        gap: 6px;
+    }
+    .step-box { display: inline-block; }
+    .step-label { white-space: nowrap; }
+    .solution-heading { font-weight: 700; }
+    .solution-content { display: inline-block; line-height: 1.75; }
+    
+    /* 解析区域:不分页 */
+    .solution-section {
+        margin-top: 8px;
+        padding: 6px 8px;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    .solution-section strong {
+        font-size: 13px;
+    }
+    .solution-parsed {
+        margin-top: 6px;
+        line-height: 1.8;
+    }
+
+    /* 填空线样式 */
+    .fill-line {
+        display: inline-block;
+        border-bottom: 1px solid #000;
+        width: 100px;
+        text-align: center;
+    }
+    .wavy-underline {
+        display: inline-block;
+        min-width: 80px;
+        height: 22px;
+        border-bottom: 1.2px dashed #444;
+        vertical-align: middle;
+    }
+    .wavy-underline.short {
+        min-width: 60px;
+    }
+
+    /* 答案区域样式 */
+    .answer-area {
+        position: relative;
+        margin-top: 12px;
+        page-break-inside: avoid;
+        break-inside: avoid;
+    }
+    .answer-area .answer-label {
+        position: absolute;
+        top: -10px;
+        left: 10px;
+        font-size: 10px;
+        background: #fff;
+        padding: 0 4px;
+        color: #555;
+        letter-spacing: 1px;
+    }
+    .answer-area.wavy {
+        height: 28px;
+        border-bottom: 1.5px dashed #555;
+        background-image: repeating-linear-gradient(
+            -45deg,
+            rgba(0, 0, 0, 0.35),
+            rgba(0, 0, 0, 0.35) 4px,
+            transparent 4px,
+            transparent 8px
+        );
+        background-size: 16px 16px;
+        background-repeat: repeat-x;
+        background-position: bottom;
+    }
+    .answer-area.boxy {
+        min-height: 150px;
+        border: 1.5px solid #444;
+        border-radius: 6px;
+        padding: 14px;
+    }
+
+    /* ==================== SVG与数学公式样式 ==================== */
+    /* 让内嵌 SVG 按比例缩放展示 */
+    svg, .math-render svg {
+        max-width: 100%;
+        height: auto;
+        display: block;
+        shape-rendering: geometricPrecision;
+        text-rendering: geometricPrecision;
+    }
+    
+    /* 优化SVG中文字标签的显示 */
+    svg text {
+        font-family: "SimSun", "Times New Roman", serif !important;
+        font-size: 12px;
+        font-weight: bold;
+        dominant-baseline: middle;
+        text-anchor: middle;
+        alignment-baseline: central;
+        paint-order: stroke fill;
+        stroke: none;
+        fill: #000;
+    }
+    
+    /* SVG中点标签的精确对齐 */
+    svg text.label-point {
+        font-size: 14px;
+        font-weight: bold;
+        dx: 0;
+        dy: 0;
+    }
+    
+    /* 确保SVG中的圆形和线条正确渲染 */
+    svg circle, svg line, svg polygon, svg polyline {
+        shape-rendering: geometricPrecision;
+    }
+
+    /* ==================== 图片容器样式 ==================== */
+    .pdf-figure {
+        break-inside: avoid;
+        page-break-inside: avoid;
+        -webkit-column-break-inside: avoid;
+        break-before: avoid;
+        break-after: avoid;
+        page-break-before: avoid;
+        page-break-after: avoid;
+        margin: 8px 0;
+        display: block;
+        min-height: 30px;
+        max-height: 180mm;
+    }
+    .pdf-figure img {
+        max-width: 100%;
+        height: auto;
+        display: block;
+        margin: 0 auto;
+        object-fit: contain;
+        box-sizing: border-box;
+    }
+    
+    /* 题干中的图片样式 */
+    .question-stem img,
+    .question-main img,
+    .question-content img {
+        display: block;
+        max-width: 100%;
+        height: auto;
+        margin: 12px auto;
+        box-sizing: border-box;
+    }
+    
+    /* 选项中的图片样式 */
+    .option img {
+        max-width: 100%;
+        height: auto;
+        vertical-align: middle;
+    }
+
+    /* ==================== 打印样式 ==================== */
+    @media print {
+        .no-print {
+            display: none;
+        }
+        body {
+            -webkit-print-color-adjust: exact;
+        }
+    }
+
+    /* ==================== KaTeX数学公式样式 ==================== */
+    .katex {
+        font-size: 1.1em !important;
+    }
+    .katex-display {
+        margin: 0.5em 0 !important;
+    }
+</style>

+ 49 - 0
resources/views/pdf/partials/katex-scripts.blade.php

@@ -0,0 +1,49 @@
+{{-- 
+    KaTeX 数学公式渲染脚本
+    被卷子、判卷和知识点讲解模板共享
+    
+    注意:此片段仅包含客户端脚本。
+    服务端预渲染逻辑在 ExamPdfExportService::inlineExternalResources() 中处理。
+--}}
+<!-- KaTeX JavaScript 库 -->
+<script src="/js/katex.min.js"></script>
+<script src="/js/auto-render.min.js"></script>
+
+<script>
+    document.addEventListener('DOMContentLoaded', function() {
+        console.log('[KaTeX] 初始化数学公式渲染器');
+
+        // 配置 KaTeX 自动渲染
+        function renderMath() {
+            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,
+                    macros: {
+                        "\\f": "#1f(#2)"
+                    }
+                });
+                console.log('[KaTeX] 数学公式渲染完成');
+            } catch (e) {
+                console.warn('[KaTeX] 公式渲染警告:', e);
+            }
+        }
+
+        // 页面加载后渲染
+        renderMath();
+
+        // 如果页面是动态加载的,等待一段时间后再次渲染
+        setTimeout(renderMath, 500);
+        setTimeout(renderMath, 1000);
+
+        // 添加到全局,必要时手动调用
+        window.renderPdfMath = renderMath;
+    });
+</script>

+ 181 - 0
resources/views/pdf/partials/kp-explain-styles.blade.php

@@ -0,0 +1,181 @@
+{{-- 知识点讲解专用样式 --}}
+<style>
+    /* ========== 屏幕预览样式 ========== */
+    @media screen {
+        body {
+            background: #f5f5f5;
+            padding: 20px;
+            font-family: "SimSun", "Songti SC", serif;
+        }
+        .page {
+            background: #fff;
+            max-width: 720px;
+            margin: 0 auto;
+            padding: 40px;
+            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+            border-radius: 4px;
+        }
+        .header {
+            text-align: center;
+            margin-bottom: 30px;
+            padding-bottom: 20px;
+            border-bottom: 2px solid #000;
+        }
+        .school-name {
+            font-size: 28px;
+            font-weight: bold;
+            margin-bottom: 15px;
+        }
+        .paper-title {
+            font-size: 22px;
+            font-weight: bold;
+            margin-bottom: 15px;
+        }
+        .info-row {
+            display: flex;
+            justify-content: space-between;
+            font-size: 14px;
+            color: #666;
+        }
+    }
+
+    /* ========== PDF 打印样式(保持原有逻辑) ========== */
+    @media print {
+        body {
+            background: #fff;
+            padding: 0;
+        }
+        .page {
+            width: 100%;
+            max-width: none;
+            margin: 0;
+            padding: 0;
+            box-shadow: none;
+        }
+        .header {
+            text-align: center;
+            margin-bottom: 20px;
+            padding-bottom: 15px;
+            border-bottom: 2px solid #000;
+        }
+        .school-name {
+            font-size: 24px;
+            font-weight: bold;
+            margin-bottom: 10px;
+        }
+        .paper-title {
+            font-size: 20px;
+            font-weight: bold;
+            margin-bottom: 15px;
+        }
+        .info-row {
+            display: flex;
+            justify-content: space-between;
+            font-size: 14px;
+            color: #666;
+        }
+    }
+
+    /* ========== 通用内容样式(屏幕+打印共用) ========== */
+    .kp-empty { padding: 16px; color: #666; text-align: center; font-size: 13px; }
+    .kp-list { display: flex; flex-direction: column; gap: 12px; }
+    .kp-section { padding: 0; margin: 0; }
+    .kp-section-head {
+        padding: 8px 0;
+        border-bottom: 1px solid #ccc;
+        margin-bottom: 8px;
+    }
+    .kp-section-body { display: flex; flex-direction: column; gap: 10px; }
+    .kp-block { display: flex; flex-direction: column; gap: 4px; }
+    .kp-block-title { font-weight: bold; font-size: 14px; display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
+    .kp-block-title .check {
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        width: 14px;
+        height: 14px;
+        border: 1px solid #333;
+        font-size: 10px;
+    }
+    .kp-block-content {
+        font-size: 14px;
+        color: #333;
+        line-height: 1.75;
+        padding-left: 20px;
+    }
+
+    /* ========== Markdown 内容样式 ========== */
+    .kp-markdown,
+    .kp-markdown-content { font-size: 14px; line-height: 1.75; color: #333; }
+    .kp-markdown h1,
+    .kp-markdown h2,
+    .kp-markdown h3,
+    .kp-markdown-content h1,
+    .kp-markdown-content h2,
+    .kp-markdown-content h3 { font-weight: bold; font-size: 14px; margin: 8px 0 4px 0; color: #000; }
+    .kp-markdown h2,
+    .kp-markdown-content h2 { font-size: 15px; }
+    .kp-markdown h3,
+    .kp-markdown-content h3 { font-size: 14px; }
+    .kp-markdown p,
+    .kp-markdown-content p { margin: 4px 0; }
+    .kp-markdown ul, .kp-markdown ol,
+    .kp-markdown-content ul, .kp-markdown-content ol { margin: 4px 0; padding-left: 24px; }
+    .kp-markdown li,
+    .kp-markdown-content li { margin: 2px 0; }
+    .kp-markdown strong,
+    .kp-markdown-content strong { font-weight: bold; color: #000; }
+    .kp-markdown em,
+    .kp-markdown-content em { font-style: italic; }
+    .kp-markdown code,
+    .kp-markdown-content code {
+        font-family: "Consolas", "Monaco", monospace;
+        font-size: 13px;
+        background: #f5f5f5;
+        padding: 1px 4px;
+        border-radius: 2px;
+    }
+    .kp-markdown pre,
+    .kp-markdown-content pre {
+        background: #f5f5f5;
+        padding: 10px;
+        overflow-x: auto;
+        margin: 8px 0;
+        border-radius: 4px;
+    }
+    .kp-markdown pre code,
+    .kp-markdown-content pre code { background: none; padding: 0; }
+    .kp-markdown blockquote,
+    .kp-markdown-content blockquote {
+        margin: 8px 0;
+        padding: 6px 12px;
+        border-left: 3px solid #ddd;
+        color: #666;
+        background: #f9f9f9;
+    }
+    .kp-markdown table,
+    .kp-markdown-content table {
+        border-collapse: collapse;
+        margin: 8px 0;
+        width: 100%;
+    }
+    .kp-markdown th, .kp-markdown td,
+    .kp-markdown-content th, .kp-markdown-content td {
+        border: 1px solid #ddd;
+        padding: 6px 10px;
+        text-align: left;
+    }
+    .kp-markdown th,
+    .kp-markdown-content th { background: #f5f5f5; font-weight: bold; }
+
+    /* ========== 打印专用(不分页) ========== */
+    @media print {
+        .kp-block-content img,
+        .kp-example-content img {
+            break-inside: avoid;
+            page-break-inside: avoid;
+            -webkit-column-break-inside: avoid;
+            max-width: 100%;
+        }
+    }
+</style>

+ 1 - 1
resources/views/pdf/question-preview.blade.php

@@ -3,7 +3,7 @@
 <head>
     <meta charset="UTF-8">
     <title>题目预览</title>
-    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
     <style>
         @page {
             size: A4;

+ 1 - 0
routes/web.php

@@ -18,6 +18,7 @@ Route::get('/test-case', function () {
 Route::view('/knowledge-mindmap-public', 'public.knowledge-mindmap');
 Route::get('/admin/intelligent-exam/pdf/{paper_id}', [\App\Http\Controllers\ExamPdfController::class, 'show'])->name('filament.admin.auth.intelligent-exam.pdf');
 Route::get('/admin/intelligent-exam/grading/{paper_id}', [\App\Http\Controllers\ExamPdfController::class, 'showGrading'])->name('filament.admin.auth.intelligent-exam.grading');
+Route::get('/admin/intelligent-exam/knowledge-explanation/{paper_id}', [\App\Http\Controllers\ExamPdfController::class, 'showKnowledgeExplanation'])->name('filament.admin.auth.intelligent-exam.knowledge-explanation');
 
 Route::get('/admin/exam-analysis/pdf', [\App\Http\Controllers\ExamAnalysisPdfController::class, 'show'])->name('filament.admin.auth.exam-analysis.pdf');
 

Деякі файли не було показано, через те що забагато файлів було змінено