Procházet zdrojové kódy

Merge branch 'main' into dev

gwd před 2 týdny
rodič
revize
6a87f28982

+ 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;
         }
     }

+ 7 - 2
app/Http/Controllers/Api/QuestionPdfController.php

@@ -45,6 +45,7 @@ class QuestionPdfController extends Controller
             'teacher_name' => 'nullable|string|max:50',
             'paper_name' => 'nullable|string|max:100',
             'include_grading' => 'nullable|boolean',
+            'source' => 'nullable|string|in:default,ai', // 题库来源:default=默认表(questions_tem),ai=ai表(questions_ai)
         ]);
 
         if ($validator->fails()) {
@@ -62,17 +63,21 @@ class QuestionPdfController extends Controller
         $teacherName = $request->input('teacher_name', '');
         $paperName = $request->input('paper_name', '专项练习');
         $includeGrading = $request->input('include_grading', false);
+        $source = $request->input('source', 'default'); // 题库来源:default=questions_tem, ai=questions_ai
 
         Log::info('生成指定题目PDF', [
             'question_ids' => $questionIds,
             'student_id' => $studentId,
             'count' => count($questionIds),
+            'source' => $source,
         ]);
 
         try {
-            // 1. Fetch questions from database (使用 questions_temp 临时表)
+            // 1. Fetch questions from database
+            $tableName = $source === 'ai' ? 'questions_ai' : 'questions_tem';
+
             $questions = DB::connection('remote_mysql')
-                ->table('questions_tem')
+                ->table($tableName)
                 ->whereIn('id', $questionIds)
                 ->get();
 

+ 373 - 88
app/Http/Controllers/ExamPdfController.php

@@ -4,8 +4,8 @@ namespace App\Http\Controllers;
 
 use App\Services\QuestionBankService;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 
 class ExamPdfController extends Controller
@@ -24,7 +24,7 @@ class ExamPdfController extends Controller
         }
 
         // 如果是对象格式 {"A": "值1", "B": "值2", ...}
-        if (is_array($options) && !isset($options[0])) {
+        if (is_array($options) && ! isset($options[0])) {
             return array_values($options);
         }
 
@@ -42,6 +42,7 @@ class ExamPdfController extends Controller
                     $normalized[] = is_array($opt) ? (string) reset($opt) : (string) $opt;
                 }
             }
+
             return $normalized;
         }
 
@@ -88,24 +89,42 @@ class ExamPdfController extends Controller
         // 2. 根据技能点判断
         if (is_array($skills)) {
             $skillsStr = implode(',', $skills);
-            if (strpos($skillsStr, '选择题') !== false) return 'choice';
-            if (strpos($skillsStr, '填空题') !== false) return 'fill';
-            if (strpos($skillsStr, '解答题') !== false) return 'answer';
+            if (strpos($skillsStr, '选择题') !== false) {
+                return 'choice';
+            }
+            if (strpos($skillsStr, '填空题') !== false) {
+                return 'fill';
+            }
+            if (strpos($skillsStr, '解答题') !== false) {
+                return 'answer';
+            }
         }
 
         // 3. 根据题目已有类型字段判断(作为后备)
-        if (!empty($question['question_type'])) {
+        if (! empty($question['question_type'])) {
             $type = strtolower(trim($question['question_type']));
-            if (in_array($type, ['choice', '选择题', 'choice question'])) return 'choice';
-            if (in_array($type, ['fill', '填空题', 'fill in the blank'])) return 'fill';
-            if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) return 'answer';
+            if (in_array($type, ['choice', '选择题', 'choice question'])) {
+                return 'choice';
+            }
+            if (in_array($type, ['fill', '填空题', 'fill in the blank'])) {
+                return 'fill';
+            }
+            if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) {
+                return 'answer';
+            }
         }
 
-        if (!empty($question['type'])) {
+        if (! empty($question['type'])) {
             $type = strtolower(trim($question['type']));
-            if (in_array($type, ['choice', '选择题', 'choice question'])) return 'choice';
-            if (in_array($type, ['fill', '填空题', 'fill in the blank'])) return 'fill';
-            if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) return 'answer';
+            if (in_array($type, ['choice', '选择题', 'choice question'])) {
+                return 'choice';
+            }
+            if (in_array($type, ['fill', '填空题', 'fill in the blank'])) {
+                return 'fill';
+            }
+            if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) {
+                return 'answer';
+            }
         }
 
         // 4. 根据标签判断
@@ -205,7 +224,7 @@ class ExamPdfController extends Controller
                 // 清理 LaTeX 格式但保留内容
                 $optionText = preg_replace('/^\$\$\s*/', '', $optionText);
                 $optionText = preg_replace('/\s*\$\$$/', '', $optionText);
-                if (!empty($optionText)) {
+                if (! empty($optionText)) {
                     $options[] = $optionText;
                 }
             }
@@ -219,7 +238,7 @@ class ExamPdfController extends Controller
                 // 【修复】行首匹配选项标记
                 if (preg_match('/^([A-D])[\.、:.:]\s*(.+)$/u', $line, $match)) {
                     $optionText = trim($match[2]);
-                    if (!empty($optionText)) {
+                    if (! empty($optionText)) {
                         $options[] = $optionText;
                     }
                 }
@@ -229,7 +248,7 @@ class ExamPdfController extends Controller
         Log::debug('选项提取结果', [
             'content_preview' => mb_substr($content, 0, 150),
             'options_count' => count($options),
-            'options' => $options
+            'options' => $options,
         ]);
 
         return $options;
@@ -246,7 +265,7 @@ class ExamPdfController extends Controller
         // 【修复】检测是否有选项时,要求选项标记在行首或空白后
         $hasOptions = preg_match('/(?:^|\s)[A-D][\.、:.:]/u', $contentWithoutSvg);
 
-        if (!$hasOptions) {
+        if (! $hasOptions) {
             return [$content, []];
         }
 
@@ -254,7 +273,7 @@ class ExamPdfController extends Controller
         $options = $this->extractOptions($content);
 
         // 如果提取到选项,分离题干
-        if (!empty($options)) {
+        if (! empty($options)) {
             // 【修复】找到第一个选项的位置,要求选项标记在行首或空白后
             if (preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、:.:])/su', $contentWithoutSvg, $match)) {
                 $stem = trim($match[1]);
@@ -265,7 +284,7 @@ class ExamPdfController extends Controller
                     // 使用更精确的方法:找到第一个有效选项标记的位置
                     foreach (['A.', 'A、', 'A:', 'A.', 'A:'] as $marker) {
                         // 只匹配在空白后的选项标记
-                        if (preg_match('/\s' . preg_quote($marker, '/') . '/', $content, $m, PREG_OFFSET_CAPTURE)) {
+                        if (preg_match('/\s'.preg_quote($marker, '/').'/', $content, $m, PREG_OFFSET_CAPTURE)) {
                             $pos = $m[0][1];
                             if ($pos > 0) {
                                 $stem = trim(mb_substr($content, 0, $pos));
@@ -279,7 +298,7 @@ class ExamPdfController extends Controller
                 $stem = $content;
                 foreach (['A.', 'A、', 'A:', 'A.', 'A:'] as $marker) {
                     // 【修复】只匹配在空白后的选项标记
-                    if (preg_match('/\s' . preg_quote($marker, '/') . '/', $content, $m, PREG_OFFSET_CAPTURE)) {
+                    if (preg_match('/\s'.preg_quote($marker, '/').'/', $content, $m, PREG_OFFSET_CAPTURE)) {
                         $pos = $m[0][1];
                         if ($pos > 0) {
                             $stem = trim(mb_substr($content, 0, $pos));
@@ -321,11 +340,11 @@ class ExamPdfController extends Controller
      */
     private function getStudentInfo(?string $studentId): array
     {
-        if (!$studentId) {
+        if (! $studentId) {
             return [
                 'name' => '未知学生',
                 'grade' => '未知年级',
-                'class' => '未知班级'
+                'class' => '未知班级',
             ];
         }
 
@@ -338,20 +357,20 @@ class ExamPdfController extends Controller
                 return [
                     'name' => $student->name ?? $studentId,
                     'grade' => $student->grade ?? '未知',
-                    'class' => $student->class ?? '未知'
+                    'class' => $student->class ?? '未知',
                 ];
             }
         } catch (\Exception $e) {
             Log::warning('获取学生信息失败', [
                 'student_id' => $studentId,
-                'error' => $e->getMessage()
+                'error' => $e->getMessage(),
             ]);
         }
 
         return [
             'name' => $studentId,
             'grade' => '未知',
-            'class' => '未知'
+            'class' => '未知',
         ];
     }
 
@@ -373,7 +392,7 @@ class ExamPdfController extends Controller
 
         foreach ($questions as $question) {
             $type = $this->determineQuestionType($question);
-            if (!isset($categorizedQuestions[$type])) {
+            if (! isset($categorizedQuestions[$type])) {
                 $type = 'answer';
             }
             $categorizedQuestions[$type][] = $question;
@@ -392,7 +411,7 @@ class ExamPdfController extends Controller
             $typeKey = $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer');
             $countForType = floor($targetCount * $ratio / 100);
 
-            if ($countForType > 0 && !empty($categorizedQuestions[$typeKey])) {
+            if ($countForType > 0 && ! empty($categorizedQuestions[$typeKey])) {
                 $availableCount = count($categorizedQuestions[$typeKey]);
                 $takeCount = min($countForType, $availableCount, $targetCount - count($selectedQuestions));
 
@@ -410,13 +429,14 @@ class ExamPdfController extends Controller
         // 4. 如果数量不足,随机补充
         while (count($selectedQuestions) < $targetCount) {
             $randomQuestion = $questions[array_rand($questions)];
-            if (!in_array($randomQuestion, $selectedQuestions)) {
+            if (! in_array($randomQuestion, $selectedQuestions)) {
                 $selectedQuestions[] = $randomQuestion;
             }
         }
 
         // 5. 限制数量并打乱
         shuffle($selectedQuestions);
+
         return array_slice($selectedQuestions, 0, $targetCount);
     }
 
@@ -425,9 +445,9 @@ class ExamPdfController extends Controller
      */
     private function getTeacherInfo(?string $teacherId): array
     {
-        if (!$teacherId) {
+        if (! $teacherId) {
             return [
-                'name' => '未知教师'
+                'name' => '未知教师',
             ];
         }
 
@@ -438,18 +458,18 @@ class ExamPdfController extends Controller
 
             if ($teacher) {
                 return [
-                    'name' => $teacher->name ?? $teacherId
+                    'name' => $teacher->name ?? $teacherId,
                 ];
             }
         } catch (\Exception $e) {
             Log::warning('获取教师信息失败', [
                 'teacher_id' => $teacherId,
-                'error' => $e->getMessage()
+                'error' => $e->getMessage(),
             ]);
         }
 
         return [
-            'name' => $teacherId
+            'name' => $teacherId,
         ];
     }
 
@@ -460,18 +480,18 @@ class ExamPdfController extends Controller
         // 使用 Eloquent 模型获取试卷数据
         $paper = \App\Models\Paper::where('paper_id', $paper_id)->first();
 
-        if (!$paper) {
+        if (! $paper) {
             // 尝试从缓存中获取生成的试卷数据(用于 demo 试卷)
-            $cached = Cache::get('generated_exam_' . $paper_id);
+            $cached = Cache::get('generated_exam_'.$paper_id);
             if ($cached) {
                 Log::info('从缓存获取试卷数据', [
                     'paper_id' => $paper_id,
                     'cached_count' => count($cached['questions'] ?? []),
-                    'cached_question_types' => array_column($cached['questions'] ?? [], 'question_type')
+                    'cached_question_types' => array_column($cached['questions'] ?? [], 'question_type'),
                 ]);
 
                 // 构造临时 Paper 对象
-                $paper = (object)[
+                $paper = (object) [
                     'paper_id' => $paper_id,
                     'paper_name' => $cached['paper_name'] ?? 'Demo Paper',
                     'student_id' => $cached['student_id'] ?? null,
@@ -484,26 +504,26 @@ class ExamPdfController extends Controller
                 $difficultyCategory = $cached['difficulty_category'] ?? '中等';
 
                 // 为 demo 试卷获取完整的题目详情(包括选项)
-                if (!empty($questionsData)) {
+                if (! empty($questionsData)) {
                     $questionBankService = app(QuestionBankService::class);
                     $questionIds = array_column($questionsData, 'id');
                     $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                     $responseData = $questionsResponse['data'] ?? [];
 
-                    if (!empty($responseData)) {
+                    if (! empty($responseData)) {
                         $responseDataMap = [];
                         foreach ($responseData as $respQ) {
                             $responseDataMap[$respQ['id']] = $respQ;
                         }
 
                         // 合并题库数据
-                        $questionsData = array_map(function($q) use ($responseDataMap) {
+                        $questionsData = array_map(function ($q) use ($responseDataMap) {
                             if (isset($responseDataMap[$q['id']])) {
                                 $apiData = $responseDataMap[$q['id']];
                                 $rawContent = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
 
                                 // 分离题干和选项
-                                list($stem, $extractedOptions) = $this->separateStemAndOptions($rawContent);
+                                [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
 
                                 $q['stem'] = $stem;
                                 $q['content'] = $stem; // 同时设置content字段
@@ -514,23 +534,24 @@ class ExamPdfController extends Controller
                                 // 优先使用API选项,支持多种数据格式
                                 $apiOptions = $apiData['options'] ?? null;
 
-                                if (!empty($apiOptions)) {
+                                if (! empty($apiOptions)) {
                                     // 标准化options格式为数组值列表
                                     $q['options'] = $this->normalizeOptions($apiOptions);
                                     Log::debug('使用标准化API options', [
                                         'question_id' => $q['id'],
                                         'raw_options' => $apiOptions,
-                                        'normalized_options' => $q['options']
+                                        'normalized_options' => $q['options'],
                                     ]);
                                 } else {
                                     // 备选:从题干中提取的选项
                                     $q['options'] = $extractedOptions;
                                     Log::debug('使用提取的options', [
                                         'question_id' => $q['id'],
-                                        'extracted_options' => $extractedOptions
+                                        'extracted_options' => $extractedOptions,
                                     ]);
                                 }
                             }
+
                             return $q;
                         }, $questionsData);
                     }
@@ -540,13 +561,13 @@ class ExamPdfController extends Controller
                     Log::info('PDF预览时发现题目过多,进行筛选', [
                         'paper_id' => $paper_id,
                         'cached_count' => count($questionsData),
-                        'required_count' => $totalQuestions
+                        'required_count' => $totalQuestions,
                     ]);
                     $questionsData = $this->selectBestQuestionsForPdf($questionsData, $totalQuestions, $difficultyCategory);
                     Log::info('筛选后题目数据', [
                         'paper_id' => $paper_id,
                         'filtered_count' => count($questionsData),
-                        'filtered_types' => array_column($questionsData, 'question_type')
+                        'filtered_types' => array_column($questionsData, 'question_type'),
                     ]);
                 }
             } else {
@@ -560,7 +581,7 @@ class ExamPdfController extends Controller
 
             Log::info('从数据库获取题目', [
                 'paper_id' => $paper_id,
-                'question_count' => $paperQuestions->count()
+                'question_count' => $paperQuestions->count(),
             ]);
 
             // 将 paper_questions 表的数据转换为题库格式
@@ -584,12 +605,12 @@ class ExamPdfController extends Controller
             Log::info('paper_questions表原始数据', [
                 'paper_id' => $paper_id,
                 'sample_questions' => array_slice($questionsData, 0, 3),
-                'all_types' => array_column($questionsData, 'question_type')
+                'all_types' => array_column($questionsData, 'question_type'),
             ]);
 
             // 如果需要完整题目详情(stem等),可以从题库获取
             // 但要严格限制只获取这8道题
-            if (!empty($questionsData)) {
+            if (! empty($questionsData)) {
                 $questionBankService = app(QuestionBankService::class);
                 $questionIds = array_column($questionsData, 'id');
 
@@ -597,7 +618,7 @@ class ExamPdfController extends Controller
                 $responseData = $questionsResponse['data'] ?? [];
 
                 // 确保只返回请求的ID对应的题目,并保留数据库中的 question_type
-                if (!empty($responseData)) {
+                if (! empty($responseData)) {
                     // 创建题库返回数据的映射
                     $responseDataMap = [];
                     foreach ($responseData as $respQ) {
@@ -605,14 +626,14 @@ class ExamPdfController extends Controller
                     }
 
                     // 遍历所有数据库中的题目,合并题库返回的数据
-                    $questionsData = array_map(function($q) use ($responseDataMap, $paperQuestions) {
+                    $questionsData = array_map(function ($q) use ($responseDataMap, $paperQuestions) {
                         // 从题库API获取的详细数据(如果有)
                         if (isset($responseDataMap[$q['id']])) {
                             $apiData = $responseDataMap[$q['id']];
                             $rawContent = $apiData['stem'] ?? $q['stem'] ?? '题目内容缺失';
 
                             // 分离题干和选项
-                            list($stem, $extractedOptions) = $this->separateStemAndOptions($rawContent);
+                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
 
                             // 合并数据,优先使用题库API的 stem、answer、solution、options
                             $q['stem'] = $stem;
@@ -624,26 +645,26 @@ class ExamPdfController extends Controller
                             // 优先使用API选项,支持多种数据格式
                             $apiOptions = $apiData['options'] ?? null;
 
-                            if (!empty($apiOptions)) {
+                            if (! empty($apiOptions)) {
                                 // 标准化options格式为数组值列表
                                 $q['options'] = $this->normalizeOptions($apiOptions);
                                 Log::debug('使用标准化API options', [
                                     'question_id' => $q['id'],
                                     'raw_options' => $apiOptions,
-                                    'normalized_options' => $q['options']
+                                    'normalized_options' => $q['options'],
                                 ]);
                             } else {
                                 // 备选:从题干中提取的选项
                                 $q['options'] = $extractedOptions;
                                 Log::debug('使用提取的options', [
                                     'question_id' => $q['id'],
-                                    'extracted_options' => $extractedOptions
+                                    'extracted_options' => $extractedOptions,
                                 ]);
                             }
                         }
 
                         // 从数据库 paper_questions 表中获取 question_type(已在前面设置,这里确保有值)
-                        if (!isset($q['question_type']) || empty($q['question_type'])) {
+                        if (! isset($q['question_type']) || empty($q['question_type'])) {
                             $dbQuestion = $paperQuestions->firstWhere('question_bank_id', $q['id']);
                             if ($dbQuestion && $dbQuestion->question_type) {
                                 $q['question_type'] = $dbQuestion->question_type;
@@ -663,7 +684,7 @@ class ExamPdfController extends Controller
             $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
 
             // 分离题干和选项
-            list($content, $extractedOptions) = $this->separateStemAndOptions($rawContent);
+            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
 
             // 如果从题库API获取了选项,优先使用
             $options = $q['options'] ?? $extractedOptions;
@@ -684,15 +705,15 @@ class ExamPdfController extends Controller
                 'tags' => $q['tags'] ?? '',
                 'stem_length' => mb_strlen($content),
                 'stem_preview' => mb_substr($content, 0, 100),
-                'has_extracted_options' => !empty($extractedOptions),
+                'has_extracted_options' => ! empty($extractedOptions),
                 'extracted_options_count' => count($extractedOptions),
-                'has_api_options' => isset($q['options']) && !empty($q['options']),
+                'has_api_options' => isset($q['options']) && ! empty($q['options']),
                 'api_options_count' => isset($q['options']) ? count($q['options']) : 0,
                 'final_options_count' => count($options),
-                'determined_type' => $type
+                'determined_type' => $type,
             ]);
 
-            if (!isset($questions[$type])) {
+            if (! isset($questions[$type])) {
                 $type = 'answer';
             }
 
@@ -722,10 +743,11 @@ class ExamPdfController extends Controller
 
         // 【关键】确保每个题型内的题目按 question_number 排序
         foreach (['choice', 'fill', 'answer'] as $type) {
-            if (!empty($questions[$type])) {
-                usort($questions[$type], function($a, $b) {
+            if (! empty($questions[$type])) {
+                usort($questions[$type], function ($a, $b) {
                     $aNum = $a->question_number ?? 0;
                     $bNum = $b->question_number ?? 0;
+
                     return $aNum <=> $bNum;
                 });
             }
@@ -737,17 +759,18 @@ class ExamPdfController extends Controller
             'choice_count' => count($questions['choice']),
             'fill_count' => count($questions['fill']),
             'answer_count' => count($questions['answer']),
-            'total' => count($questions['choice']) + count($questions['fill']) + count($questions['answer'])
+            'total' => count($questions['choice']) + count($questions['fill']) + count($questions['answer']),
         ]);
 
         // 渲染视图
         $viewName = $includeAnswer ? 'pdf.exam-grading' : 'pdf.exam-paper';
+
         return view($viewName, [
             'paper' => $paper,
             'questions' => $questions,
             'student' => $this->getStudentInfo($paper->student_id),
             'teacher' => $this->getTeacherInfo($paper->teacher_id),
-            'includeAnswer' => $includeAnswer
+            'includeAnswer' => $includeAnswer,
         ]);
     }
 
@@ -766,12 +789,12 @@ class ExamPdfController extends Controller
 
         // 使用 Eloquent 模型获取试卷数据
         $paper = \App\Models\Paper::where('paper_id', $paper_id)->first();
-        if (!$paper) {
-            $cached = Cache::get('generated_exam_' . $paper_id);
-            if (!$cached) {
+        if (! $paper) {
+            $cached = Cache::get('generated_exam_'.$paper_id);
+            if (! $cached) {
                 abort(404, '试卷未找到');
             }
-            $paper = (object)[
+            $paper = (object) [
                 'paper_id' => $paper_id,
                 'paper_name' => $cached['paper_name'] ?? 'Demo Paper',
                 'student_id' => $cached['student_id'] ?? null,
@@ -780,21 +803,21 @@ class ExamPdfController extends Controller
             $questionsData = $cached['questions'] ?? [];
             $totalQuestions = $cached['total_questions'] ?? count($questionsData);
             $difficultyCategory = $cached['difficulty_category'] ?? '中等';
-            if (!empty($questionsData)) {
+            if (! empty($questionsData)) {
                 $questionBankService = app(QuestionBankService::class);
                 $questionIds = array_column($questionsData, 'id');
                 $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                 $responseData = $questionsResponse['data'] ?? [];
-                if (!empty($responseData)) {
+                if (! empty($responseData)) {
                     $responseDataMap = [];
                     foreach ($responseData as $respQ) {
                         $responseDataMap[$respQ['id']] = $respQ;
                     }
-                    $questionsData = array_map(function($q) use ($responseDataMap) {
+                    $questionsData = array_map(function ($q) use ($responseDataMap) {
                         if (isset($responseDataMap[$q['id']])) {
                             $apiData = $responseDataMap[$q['id']];
                             $rawContent = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
-                            list($stem, $extractedOptions) = $this->separateStemAndOptions($rawContent);
+                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
                             $q['stem'] = $stem;
                             $q['content'] = $stem; // 同时设置content字段
                             $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
@@ -804,23 +827,24 @@ class ExamPdfController extends Controller
                             // 优先使用API选项,支持多种数据格式
                             $apiOptions = $apiData['options'] ?? null;
 
-                            if (!empty($apiOptions)) {
+                            if (! empty($apiOptions)) {
                                 // 标准化options格式为数组值列表
                                 $q['options'] = $this->normalizeOptions($apiOptions);
                                 Log::debug('使用标准化API options', [
                                     'question_id' => $q['id'],
                                     'raw_options' => $apiOptions,
-                                    'normalized_options' => $q['options']
+                                    'normalized_options' => $q['options'],
                                 ]);
                             } else {
                                 // 备选:从题干中提取的选项
                                 $q['options'] = $extractedOptions;
                                 Log::debug('使用提取的options', [
                                     'question_id' => $q['id'],
-                                    'extracted_options' => $extractedOptions
+                                    'extracted_options' => $extractedOptions,
                                 ]);
                             }
                         }
+
                         return $q;
                     }, $questionsData);
                 }
@@ -848,21 +872,21 @@ class ExamPdfController extends Controller
                     'content' => $pq->question_text ?? '',
                 ];
             }
-            if (!empty($questionsData)) {
+            if (! empty($questionsData)) {
                 $questionBankService = app(QuestionBankService::class);
                 $questionIds = array_column($questionsData, 'id');
                 $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
                 $responseData = $questionsResponse['data'] ?? [];
-                if (!empty($responseData)) {
+                if (! empty($responseData)) {
                     $responseDataMap = [];
                     foreach ($responseData as $respQ) {
                         $responseDataMap[$respQ['id']] = $respQ;
                     }
-                    $questionsData = array_map(function($q) use ($responseDataMap, $paperQuestions) {
+                    $questionsData = array_map(function ($q) use ($responseDataMap, $paperQuestions) {
                         if (isset($responseDataMap[$q['id']])) {
                             $apiData = $responseDataMap[$q['id']];
                             $rawContent = $apiData['stem'] ?? $q['stem'] ?? '题目内容缺失';
-                            list($stem, $extractedOptions) = $this->separateStemAndOptions($rawContent);
+                            [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
                             $q['stem'] = $stem;
                             $q['content'] = $stem; // 同时设置content字段
                             $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
@@ -872,29 +896,30 @@ class ExamPdfController extends Controller
                             // 优先使用API选项,支持多种数据格式
                             $apiOptions = $apiData['options'] ?? null;
 
-                            if (!empty($apiOptions)) {
+                            if (! empty($apiOptions)) {
                                 // 标准化options格式为数组值列表
                                 $q['options'] = $this->normalizeOptions($apiOptions);
                                 Log::debug('使用标准化API options', [
                                     'question_id' => $q['id'],
                                     'raw_options' => $apiOptions,
-                                    'normalized_options' => $q['options']
+                                    'normalized_options' => $q['options'],
                                 ]);
                             } else {
                                 // 备选:从题干中提取的选项
                                 $q['options'] = $extractedOptions;
                                 Log::debug('使用提取的options', [
                                     'question_id' => $q['id'],
-                                    'extracted_options' => $extractedOptions
+                                    'extracted_options' => $extractedOptions,
                                 ]);
                             }
                         }
-                        if (!isset($q['question_type']) || empty($q['question_type'])) {
+                        if (! isset($q['question_type']) || empty($q['question_type'])) {
                             $dbQuestion = $paperQuestions->firstWhere('question_bank_id', $q['id']);
                             if ($dbQuestion && $dbQuestion->question_type) {
                                 $q['question_type'] = $dbQuestion->question_type;
                             }
                         }
+
                         return $q;
                     }, $questionsData);
                 }
@@ -904,14 +929,14 @@ class ExamPdfController extends Controller
         $questions = ['choice' => [], 'fill' => [], 'answer' => []];
         foreach ($questionsData as $q) {
             $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
-            list($content, $extractedOptions) = $this->separateStemAndOptions($rawContent);
+            [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
             $options = $q['options'] ?? $extractedOptions;
             $answer = $q['answer'] ?? '';
             $solution = $q['solution'] ?? '';
             $type = isset($q['question_type'])
                 ? $this->normalizeQuestionTypeValue((string) $q['question_type'])
                 : $this->determineQuestionType($q);
-            if (!isset($questions[$type])) {
+            if (! isset($questions[$type])) {
                 $type = 'answer';
             }
 
@@ -941,10 +966,11 @@ class ExamPdfController extends Controller
 
         // 【关键】确保每个题型内的题目按 question_number 排序
         foreach (['choice', 'fill', 'answer'] as $type) {
-            if (!empty($questions[$type])) {
-                usort($questions[$type], function($a, $b) {
+            if (! empty($questions[$type])) {
+                usort($questions[$type], function ($a, $b) {
                     $aNum = $a->question_number ?? 0;
                     $bNum = $b->question_number ?? 0;
+
                     return $aNum <=> $bNum;
                 });
             }
@@ -958,4 +984,263 @@ class ExamPdfController extends Controller
             'includeAnswer' => true,
         ]);
     }
+
+    /**
+     * 知识点讲解视图
+     */
+    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日');
+
+        // 提取并去重知识点代码(优先 paper_questions.knowledge_point,缺失时回退到题库 kp_code)
+        $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)->get();
+        $kpCodes = [];
+        $seen = [];
+
+        $questionBankIds = $paperQuestions
+            ->pluck('question_bank_id')
+            ->filter()
+            ->unique()
+            ->values();
+        $questionKpMap = [];
+        if ($questionBankIds->isNotEmpty()) {
+            $questionKpMap = \App\Models\Question::whereIn('id', $questionBankIds)
+                ->pluck('kp_code', 'id')
+                ->toArray();
+        }
+
+        foreach ($paperQuestions as $pq) {
+            $kpCode = trim((string) ($pq->knowledge_point ?? ''));
+            if ($kpCode === '' && ! empty($pq->question_bank_id)) {
+                $kpCode = trim((string) ($questionKpMap[$pq->question_bank_id] ?? ''));
+            }
+            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(统一生成卷子和判卷)
+     *
+     * @param  string  $paper_id
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function regeneratePdf(Request $request, $paper_id)
+    {
+        Log::info('RegeneratePdf: 开始重新生成PDF', ['paper_id' => $paper_id]);
+
+        // 验证 paper_id 格式
+        if (empty($paper_id) || ! preg_match('/^paper_\d+$/', $paper_id)) {
+            return response()->json([
+                'success' => false,
+                'message' => '无效的试卷ID格式',
+                'paper_id' => $paper_id,
+            ], 400);
+        }
+
+        try {
+            // 调用 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, $includeKpExplain);
+
+            if ($pdfUrl) {
+                Log::info('RegeneratePdf: PDF重新生成成功', [
+                    'paper_id' => $paper_id,
+                    'url' => $pdfUrl,
+                ]);
+
+                return response()->json([
+                    'success' => true,
+                    'message' => 'PDF重新生成成功',
+                    'paper_id' => $paper_id,
+                    'pdf_url' => $pdfUrl,
+                ]);
+            }
+
+            Log::error('RegeneratePdf: PDF生成失败', ['paper_id' => $paper_id]);
+
+            return response()->json([
+                'success' => false,
+                'message' => 'PDF生成失败',
+                'paper_id' => $paper_id,
+            ], 500);
+
+        } catch (\Exception $e) {
+            Log::error('RegeneratePdf: 异常错误', [
+                'paper_id' => $paper_id,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => 'PDF生成异常:'.$e->getMessage(),
+                'paper_id' => $paper_id,
+            ], 500);
+        }
+    }
+
+    /**
+     * 重新生成试卷 PDF(不含答案)
+     *
+     * @param  string  $paper_id
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function regenerateExamPdf(Request $request, $paper_id)
+    {
+        Log::info('RegenerateExamPdf: 开始重新生成试卷PDF', ['paper_id' => $paper_id]);
+
+        if (empty($paper_id) || ! preg_match('/^paper_\d+$/', $paper_id)) {
+            return response()->json([
+                'success' => false,
+                'message' => '无效的试卷ID格式',
+                'paper_id' => $paper_id,
+            ], 400);
+        }
+
+        try {
+            $pdfService = app(\App\Services\ExamPdfExportService::class);
+            $pdfUrl = $pdfService->generateExamPdf($paper_id);
+
+            if ($pdfUrl) {
+                return response()->json([
+                    'success' => true,
+                    'message' => '试卷PDF重新生成成功',
+                    'paper_id' => $paper_id,
+                    'pdf_url' => $pdfUrl,
+                ]);
+            }
+
+            return response()->json([
+                'success' => false,
+                'message' => '试卷PDF生成失败',
+                'paper_id' => $paper_id,
+            ], 500);
+
+        } catch (\Exception $e) {
+            Log::error('RegenerateExamPdf: 异常错误', [
+                'paper_id' => $paper_id,
+                'error' => $e->getMessage(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => 'PDF生成异常:'.$e->getMessage(),
+                'paper_id' => $paper_id,
+            ], 500);
+        }
+    }
+
+    /**
+     * 重新生成判卷 PDF(含答案)
+     *
+     * @param  string  $paper_id
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function regenerateGradingPdf(Request $request, $paper_id)
+    {
+        Log::info('RegenerateGradingPdf: 开始重新生成判卷PDF', ['paper_id' => $paper_id]);
+
+        if (empty($paper_id) || ! preg_match('/^paper_\d+$/', $paper_id)) {
+            return response()->json([
+                'success' => false,
+                'message' => '无效的试卷ID格式',
+                'paper_id' => $paper_id,
+            ], 400);
+        }
+
+        try {
+            $pdfService = app(\App\Services\ExamPdfExportService::class);
+            $pdfUrl = $pdfService->generateGradingPdf($paper_id);
+
+            if ($pdfUrl) {
+                return response()->json([
+                    'success' => true,
+                    'message' => '判卷PDF重新生成成功',
+                    'paper_id' => $paper_id,
+                    'pdf_url' => $pdfUrl,
+                ]);
+            }
+
+            return response()->json([
+                'success' => false,
+                'message' => '判卷PDF生成失败',
+                'paper_id' => $paper_id,
+            ], 500);
+
+        } catch (\Exception $e) {
+            Log::error('RegenerateGradingPdf: 异常错误', [
+                'paper_id' => $paper_id,
+                'error' => $e->getMessage(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => 'PDF生成异常:'.$e->getMessage(),
+                'paper_id' => $paper_id,
+            ], 500);
+        }
+    }
 }

+ 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 = [

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 222 - 250
app/Services/ExamPdfExportService.php


+ 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;
     }
-}
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 158 - 137
app/Services/QuestionBankService.php


+ 10 - 6
app/Services/QuestionLocalService.php

@@ -725,17 +725,17 @@ class QuestionLocalService
     private function calculateDifficultyDistribution(int $category, int $totalQuestions): array
     {
         // 标准化:25% 低级,50% 基准,25% 拔高
-        $lowPercentage = 25;
-        $mediumPercentage = 50;
-        $highPercentage = 25;
+//        $lowPercentage = 25;
+//        $mediumPercentage = 50;
+//        $highPercentage = 25;
 
         // 根据难度类别调整分布
         switch ($category) {
             case 1:
                 // 基础型:0-0.25占50%,其他占50%
-                $mediumPercentage = 50; // 0-0.25作为基准
-                $lowPercentage = 25;    // 其他低难度
-                $highPercentage = 25;   // 其他高难度
+                $mediumPercentage = 90; // 0-0.25作为基准
+                $lowPercentage = 0;    // 其他低难度
+                $highPercentage = 10;   // 其他高难度
                 break;
 
             case 2:
@@ -758,6 +758,10 @@ class QuestionLocalService
                 $lowPercentage = 25;    // 其他低难度
                 $highPercentage = 25;   // 其他高难度
                 break;
+            default:
+                $lowPercentage = 25;
+                $mediumPercentage = 50;
+                $highPercentage = 25;
         }
 
         // 计算题目数量

+ 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),
 ];

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
public/js/markdown-it.min.js


+ 7 - 6
resources/views/components/exam/paper-body.blade.php

@@ -211,10 +211,10 @@
                         // 短选项(≤15字符)且选项数≤4:4列布局
                         // 中等选项(16-30字符)或选项数>4:2列布局
                         // 长选项(>30字符):1列布局
-                        if ($maxOptionLength <= 15 && $optCount <= 4) {
+                        if ($maxOptionLength <= 13) {
                             $optionsClass = 'options-grid-4';
                             $layoutDesc = '4列布局';
-                        } elseif ($maxOptionLength <= 30) {
+                        } elseif ($maxOptionLength <= 26) {
                             $optionsClass = 'options-grid-2';
                             $layoutDesc = '2列布局';
                         } else {
@@ -393,12 +393,13 @@
                 </div>
                 <div class="question-main">
                     @unless($gradingMode)
-                        <span class="question-score-inline">(本小题满分 {{ $q->score ?? 10 }} 分)</span>
+                        <span class="question-score-inline">(本小题满分 {{ $q->score ?? 10 }} 分)
+                            @if($showQuestionId && !empty($q->id))
+                                <span class="question-id" style="font-size:10px;color:#999;margin-left:4px;">{!! $formatQuestionId($q->id) !!}</span>
+                            @endif
+                        </span>
                     @endunless
                     <span class="question-stem">{!! $mathProcessed ? $q->content : \App\Services\MathFormulaProcessor::processFormulas($q->content) !!}</span>
-                    @if($showQuestionId && !empty($q->id))
-                        <span class="question-id" style="font-size:10px;color:#999;margin-left:4px;">{!! $formatQuestionId($q->id) !!}</span>
-                    @endif
                 </div>
                 @unless($gradingMode)
                     <div class="question-lead spacer"></div>

+ 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; }

+ 35 - 9
resources/views/pdf/exam-grading.blade.php

@@ -4,35 +4,37 @@
     $rawPaperId = $paper->paper_id ?? 'unknown';
     preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
     $gradingCode = $matches[1] ?? preg_replace('/[^0-9]/', '', $rawPaperId);
+    // 生成日期(格式:1月24日)
+    $generateDate = now()->locale('zh_CN')->isoFormat('M月D日');
 @endphp
 <!DOCTYPE html>
 <html lang="zh-CN">
 <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;
             margin: 2cm 2cm 2.5cm 2cm;
             @top-left {
-                content: "知了数学";
-                font-size: 10px;
+                content: "知了数学·{{ $generateDate }}";
+                font-size: 12px;
                 color: #666;
             }
             @top-right {
                 content: "{{ $gradingCode }}";
-                font-size: 10px;
+                font-size: 12px;
                 color: #666;
             }
             @bottom-left {
                 content: "{{ $gradingCode }}";
-                font-size: 10px;
+                font-size: 12px;
                 color: #666;
             }
             @bottom-right {
                 content: counter(page) "/" counter(pages);
-                font-size: 10px;
+                font-size: 12px;
                 color: #666;
             }
         }
@@ -235,7 +237,31 @@
         svg circle, svg line, svg polygon, svg polyline {
             shape-rendering: geometricPrecision;
         }
-        /* 题干中的图片样式 */
+        /* PDF图片容器:防止图片跨页分割 - 增强版 */
+        .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 {
@@ -272,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 {

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

@@ -0,0 +1,151 @@
+{{-- 知识点讲解模板 --}}
+<!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')
+</head>
+<body>
+    <div class="page">
+        <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']))
+                                {{-- 隐藏容器存储原始 Markdown --}}
+                                <div class="kp-markdown-source" style="display:none;">{!! $kp['explanation'] !!}</div>
+                                {{-- 渲染容器 --}}
+                                <div class="kp-markdown-container kp-markdown-content"></div>
+                            @endif
+                        </div>
+                    </div>
+                @endforeach
+            </div>
+        @endif
+    </div>
+
+    {{-- 引入脚本 --}}
+    <script src="/js/markdown-it.min.js"></script>
+    <script src="/js/katex.min.js"></script>
+
+    <script>
+    (function() {
+        'use strict';
+
+        function waitForLibs(callback) {
+            let attempts = 0;
+            const maxAttempts = 50;
+            const interval = setInterval(function() {
+                attempts++;
+                if (typeof window.markdownit === 'function') {
+                    clearInterval(interval);
+                    callback();
+                } else if (attempts >= maxAttempts) {
+                    clearInterval(interval);
+                    console.error('[Render] Libraries failed to load after', maxAttempts, 'attempts');
+                    console.log('[Render] markdownit:', typeof window.markdownit);
+                    callback();
+                }
+            }, 100);
+        }
+
+        function renderMarkdown(md, targetEl) {
+            if (!md) return;
+
+            if (typeof window.markdownit !== 'function') {
+                targetEl.textContent = md;
+                return;
+            }
+
+            const mdParser = window.markdownit({
+                html: false,
+                breaks: false,
+                linkify: true,
+                typographer: false
+            });
+
+            let html = mdParser.render(md);
+
+            if (typeof window.katex !== 'undefined') {
+                const katexOptions = {
+                    throwOnError: false,
+                    displayMode: false
+                };
+
+                function decodeEntities(input) {
+                    return input
+                        .replace(/&gt;/g, '>')
+                        .replace(/&lt;/g, '<')
+                        .replace(/&amp;/g, '&')
+                        .replace(/&quot;/g, '"')
+                        .replace(/&#39;/g, "'");
+                }
+
+                // 先渲染块级公式 $$...$$
+                html = html.replace(/\$\$([\s\S]*?)\$\$/g, function(_, tex) {
+                    try {
+                        const cleaned = decodeEntities(tex.trim());
+                        return window.katex.renderToString(cleaned, { ...katexOptions, displayMode: true });
+                    } catch (e) {
+                        return '<span style="color:red">[KaTeX error]</span>';
+                    }
+                });
+
+                // 再渲染行内公式 $...$
+                html = html.replace(/\$([^\$\n]+?)\$/g, function(_, tex) {
+                    try {
+                        const cleaned = decodeEntities(tex.trim());
+                        return window.katex.renderToString(cleaned, { ...katexOptions, displayMode: false });
+                    } catch (e) {
+                        return '<span style="color:red">[KaTeX error]</span>';
+                    }
+                });
+            }
+
+            targetEl.innerHTML = html;
+        }
+
+        function renderAll() {
+            const containers = document.querySelectorAll('.kp-markdown-container');
+            containers.forEach((container) => {
+                const sourceEl = container.previousElementSibling;
+                let markdown = '';
+                if (sourceEl && sourceEl.classList.contains('kp-markdown-source')) {
+                    markdown = sourceEl.textContent.trim();
+                }
+                if (!markdown) return;
+                renderMarkdown(markdown, container);
+            });
+        }
+
+        if (document.readyState === 'loading') {
+            document.addEventListener('DOMContentLoaded', function() {
+                waitForLibs(renderAll);
+            });
+        } else {
+            waitForLibs(renderAll);
+        }
+
+        document.addEventListener('livewire:initialized', function() {
+            waitForLibs(renderAll);
+        });
+        document.addEventListener('livewire:navigated', () => setTimeout(function() {
+            waitForLibs(renderAll);
+        }, 100));
+    })();
+    </script>
+</body>
+</html>

+ 36 - 10
resources/views/pdf/exam-paper.blade.php

@@ -3,35 +3,37 @@
 <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';
         preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
         $examCode = $matches[1] ?? preg_replace('/[^0-9]/', '', $rawPaperId);
+        // 生成日期(格式:1月24日)
+        $generateDate = now()->locale('zh_CN')->isoFormat('M月D日');
     @endphp
     <style>
         @page {
             size: A4;
             margin: 2cm 2cm 2.5cm 2cm;
             @top-left {
-                content: "知了数学";
-                font-size: 10px;
+                content: "知了数学·{{ $generateDate }}";
+                font-size: 12px;
                 color: #666;
             }
             @top-right {
                 content: "{{ $examCode }}";
-                font-size: 10px;
+                font-size: 12px;
                 color: #666;
             }
             @bottom-left {
                 content: "{{ $examCode }}";
-                font-size: 10px;
+                font-size: 12px;
                 color: #666;
             }
             @bottom-right {
                 content: counter(page) "/" counter(pages);
-                font-size: 10px;
+                font-size: 12px;
                 color: #666;
             }
         }
@@ -132,7 +134,7 @@
             display: block;
             font-size: 13px;
             color: #555;
-            margin: 0 0 4px 0;
+            margin: 0 0 2px 0;
             white-space: nowrap;
         }
         /* 题目内容:防止孤行 */
@@ -320,7 +322,31 @@
         .wavy-underline.short {
             min-width: 60px;
         }
-        /* 题干中的图片样式 */
+        /* PDF图片容器:防止图片跨页分割 - 增强版 */
+        .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 {
@@ -466,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: 0.95em !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>

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

@@ -0,0 +1,212 @@
+{{-- 知识点讲解专用样式 --}}
+<style>
+    /* ========== 屏幕预览样式 ========== */
+    @media screen {
+        body {
+            background: #fff;
+            padding: 0;
+            font-family: "SimSun", "Songti SC", serif;
+            line-height: 1.65;
+            color: #000;
+            font-size: 14px;
+        }
+        .page {
+            background: #fff;
+            max-width: 720px;
+            margin: 0 auto;
+            padding: 0 12px;
+            box-shadow: none;
+            border-radius: 0;
+        }
+        .header,
+        .kp-explain-header {
+            text-align: center;
+            margin-bottom: 1.5rem;
+            padding-bottom: 1rem;
+            border-bottom: 2px solid #000;
+        }
+        .school-name {
+            font-size: 28px;
+            font-weight: bold;
+            margin-bottom: 15px;
+        }
+        .paper-title {
+            font-size: 20px;
+            font-weight: bold;
+            margin-bottom: 15px;
+        }
+        .kp-explain-title {
+            font-size: 20px;
+            font-weight: bold;
+            margin-bottom: 10px;
+        }
+        .kp-explain-subtitle {
+            font-size: 14px;
+            color: #666;
+        }
+        .info-row {
+            display: flex;
+            justify-content: space-between;
+            font-size: 14px;
+            color: #666;
+        }
+    }
+
+    /* ========== PDF 打印样式(保持原有逻辑) ========== */
+    @media print {
+        body {
+            background: #fff;
+            padding: 0;
+            font-family: "SimSun", "Songti SC", serif;
+            line-height: 1.65;
+            color: #000;
+            font-size: 14px;
+        }
+        .page {
+            width: 100%;
+            max-width: none;
+            margin: 0;
+            padding: 0;
+            box-shadow: none;
+        }
+        .header,
+        .kp-explain-header {
+            text-align: center;
+            margin-bottom: 1.5rem;
+            padding-bottom: 1rem;
+            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;
+        }
+        .kp-explain-title {
+            font-size: 20px;
+            font-weight: bold;
+            margin-bottom: 10px;
+        }
+        .kp-explain-subtitle {
+            font-size: 14px;
+            color: #666;
+        }
+        .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; }
+
+    /* ========== KaTeX数学公式样式 ========== */
+    .katex { font-size: 0.95em !important; }
+    .katex-display { margin: 0.5em 0 !important; }
+
+    /* ========== 打印专用(不分页) ========== */
+    @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;

+ 116 - 78
routes/api.php

@@ -1,25 +1,23 @@
 <?php
 
+use App\Http\Controllers\Api\AbilityEvaluateController;
+use App\Http\Controllers\Api\ExamAnalysisApiController;
 use App\Http\Controllers\Api\IntelligentExamController;
-use App\Http\Controllers\Api\PreQuestionApiController;
-use App\Http\Controllers\Api\TextbookApiController;
+use App\Http\Controllers\Api\KnowledgeRecommendController;
 use App\Http\Controllers\Api\MistakeBookController;
-use App\Http\Controllers\Api\QuestionSearchController;
-use App\Http\Controllers\Api\QuestionRandomController;
 use App\Http\Controllers\Api\PaperAssembleController;
 use App\Http\Controllers\Api\PaperJsonController;
+use App\Http\Controllers\Api\PreQuestionApiController;
+use App\Http\Controllers\Api\QuestionRandomController;
+use App\Http\Controllers\Api\QuestionSearchController;
 use App\Http\Controllers\Api\QuestionSolutionController;
-use App\Http\Controllers\Api\KnowledgeRecommendController;
-use App\Http\Controllers\Api\AbilityEvaluateController;
+use App\Http\Controllers\Api\StudentAnswerAnalysisController;
+use App\Http\Controllers\Api\StudentKnowledgeController;
+use App\Http\Controllers\Api\TextbookApiController;
 use App\Services\QuestionServiceApi;
+use Illuminate\Auth\Middleware\Authenticate;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Route;
-use App\Events\QuestionGenerationCompleted;
-use App\Events\QuestionGenerationFailed;
-use Illuminate\Auth\Middleware\Authenticate;
-use App\Http\Controllers\Api\ExamAnalysisApiController;
-use App\Http\Controllers\Api\StudentAnswerAnalysisController;
-use App\Http\Controllers\Api\StudentKnowledgeController;
 
 /*
 |--------------------------------------------------------------------------
@@ -46,7 +44,7 @@ Route::post('/questions/callback', function () {
         Log::info('Received question generation callback', $data);
 
         // 验证回调数据
-        if (!isset($data['task_id']) || !isset($data['status'])) {
+        if (! isset($data['task_id']) || ! isset($data['status'])) {
             return response()->json(['error' => 'Invalid callback data'], 400);
         }
 
@@ -60,14 +58,14 @@ Route::post('/questions/callback', function () {
             session()->flash('notification', [
                 'type' => 'success',
                 'title' => '✅ 题目生成完成',
-                'body' => "任务 ID: {$data['task_id']}\n生成题目: {$total} 道" . ($kpCode ? "\n知识点: {$kpCode}" : ''),
-                'color' => 'success'
+                'body' => "任务 ID: {$data['task_id']}\n生成题目: {$total} 道".($kpCode ? "\n知识点: {$kpCode}" : ''),
+                'color' => 'success',
             ]);
 
-            Log::info("题目生成成功通知已存储", [
+            Log::info('题目生成成功通知已存储', [
                 'task_id' => $data['task_id'],
                 'total' => $total,
-                'kp_code' => $kpCode
+                'kp_code' => $kpCode,
             ]);
 
         } elseif ($data['status'] === 'failed') {
@@ -78,22 +76,23 @@ Route::post('/questions/callback', function () {
                 'type' => 'error',
                 'title' => '❌ 题目生成失败',
                 'body' => "任务 ID: {$data['task_id']}\n错误: {$error}",
-                'color' => 'danger'
+                'color' => 'danger',
             ]);
 
-            Log::error("题目生成失败通知已存储", [
+            Log::error('题目生成失败通知已存储', [
                 'task_id' => $data['task_id'],
-                'error' => $error
+                'error' => $error,
             ]);
         }
 
         return response()->json([
             'success' => true,
             'message' => 'Callback received and notification stored',
-            'status' => $data['status']
+            'status' => $data['status'],
         ]);
     } catch (\Exception $e) {
-        Log::error('Callback processing failed: ' . $e->getMessage());
+        Log::error('Callback processing failed: '.$e->getMessage());
+
         return response()->json(['error' => $e->getMessage()], 500);
     }
 })->name('api.questions.callback');
@@ -105,11 +104,12 @@ Route::post('/ocr-question-callback', function () {
         Log::info('Received OCR question generation callback', $data);
 
         // 验证必要的回调数据
-        if (!isset($data['task_id']) || !isset($data['status']) || !isset($data['ocr_record_id'])) {
+        if (! isset($data['task_id']) || ! isset($data['status']) || ! isset($data['ocr_record_id'])) {
             Log::error('OCR callback missing required fields', $data);
+
             return response()->json([
                 'success' => false,
-                'error' => 'Missing required fields: task_id, status, ocr_record_id'
+                'error' => 'Missing required fields: task_id, status, ocr_record_id',
             ], 400);
         }
 
@@ -126,7 +126,7 @@ Route::post('/ocr-question-callback', function () {
             'task_id' => $taskId,
             'status' => $status,
             'total_generated' => $data['result']['total_generated'] ?? 0,
-            'total_saved' => $data['result']['total_saved'] ?? 0
+            'total_saved' => $data['result']['total_saved'] ?? 0,
         ]);
 
         // 处理题目关联逻辑
@@ -135,10 +135,10 @@ Route::post('/ocr-question-callback', function () {
             // 从result中提取question_mappings(QuestionBank API将它放在result字段中)
             $mappings = $data['result']['question_mappings'] ?? $data['question_mappings'] ?? [];
 
-            Log::info("Processing OCR question associations", [
+            Log::info('Processing OCR question associations', [
                 'ocr_record_id' => $ocrRecordId,
                 'task_id' => $taskId,
-                'mappings_count' => count($mappings)
+                'mappings_count' => count($mappings),
             ]);
 
             // 更新ocr_question_results表中的关联关系
@@ -162,32 +162,32 @@ Route::post('/ocr-question-callback', function () {
 
                         if ($updated) {
                             $updatedCount++;
-                            Log::info("Updated OCR question association", [
+                            Log::info('Updated OCR question association', [
                                 'ocr_record_id' => $ocrRecordId,
                                 'question_number' => $ocrQuestionNumber,
                                 'question_bank_id' => $questionBankId,
-                                'question_code' => $questionCode
+                                'question_code' => $questionCode,
                             ]);
                         } else {
-                            Log::warning("No OCR question result found for association", [
+                            Log::warning('No OCR question result found for association', [
                                 'ocr_record_id' => $ocrRecordId,
-                                'question_number' => $ocrQuestionNumber
+                                'question_number' => $ocrQuestionNumber,
                             ]);
                         }
                     }
                 } catch (\Exception $e) {
-                    Log::error("Failed to update OCR question association", [
+                    Log::error('Failed to update OCR question association', [
                         'mapping' => $mapping,
-                        'error' => $e->getMessage()
+                        'error' => $e->getMessage(),
                     ]);
                 }
             }
 
-            Log::info("OCR question association completed", [
+            Log::info('OCR question association completed', [
                 'ocr_record_id' => $ocrRecordId,
                 'task_id' => $taskId,
                 'total_mappings' => count($mappings),
-                'updated_count' => $updatedCount
+                'updated_count' => $updatedCount,
             ]);
 
             // 更新OCR记录的整体状态为已完成
@@ -197,17 +197,17 @@ Route::post('/ocr-question-callback', function () {
                     ->update([
                         'status' => 'completed',
                         'processed_at' => now(),
-                        'updated_at' => now()
+                        'updated_at' => now(),
                     ]);
 
-                Log::info("Updated OCR record status to completed", [
+                Log::info('Updated OCR record status to completed', [
                     'ocr_record_id' => $ocrRecordId,
-                    'task_id' => $taskId
+                    'task_id' => $taskId,
                 ]);
             } catch (\Exception $e) {
-                Log::error("Failed to update OCR record status", [
+                Log::error('Failed to update OCR record status', [
                     'ocr_record_id' => $ocrRecordId,
-                    'error' => $e->getMessage()
+                    'error' => $e->getMessage(),
                 ]);
             }
 
@@ -223,11 +223,11 @@ Route::post('/ocr-question-callback', function () {
                         'generation_error' => $data['error'] ?? 'Unknown error',
                     ]);
 
-                Log::info("Updated OCR questions to failed status", [
+                Log::info('Updated OCR questions to failed status', [
                     'ocr_record_id' => $ocrRecordId,
                     'task_id' => $taskId,
                     'updated_count' => $updated,
-                    'error' => $data['error'] ?? 'Unknown error'
+                    'error' => $data['error'] ?? 'Unknown error',
                 ]);
 
                 // 更新OCR记录的状态为失败
@@ -236,18 +236,18 @@ Route::post('/ocr-question-callback', function () {
                     ->update([
                         'status' => 'failed',
                         'error_message' => $data['error'] ?? 'Question generation failed',
-                        'updated_at' => now()
+                        'updated_at' => now(),
                     ]);
 
-                Log::info("Updated OCR record status to failed", [
+                Log::info('Updated OCR record status to failed', [
                     'ocr_record_id' => $ocrRecordId,
                     'task_id' => $taskId,
-                    'error' => $data['error'] ?? 'Unknown error'
+                    'error' => $data['error'] ?? 'Unknown error',
                 ]);
             } catch (\Exception $e) {
-                Log::error("Failed to update OCR questions to failed status", [
+                Log::error('Failed to update OCR questions to failed status', [
                     'ocr_record_id' => $ocrRecordId,
-                    'error' => $e->getMessage()
+                    'error' => $e->getMessage(),
                 ]);
             }
         }
@@ -260,16 +260,17 @@ Route::post('/ocr-question-callback', function () {
                 'ocr_record_id' => $ocrRecordId,
                 'status' => $status,
                 'cache_key' => $cacheKey,
-                'associations_processed' => $status === 'completed' ? count($data['question_mappings'] ?? []) : 0
-            ]
+                'associations_processed' => $status === 'completed' ? count($data['question_mappings'] ?? []) : 0,
+            ],
         ]);
 
     } catch (\Exception $e) {
-        Log::error('OCR callback processing failed: ' . $e->getMessage());
-        Log::error('Exception details: ' . $e->getTraceAsString());
+        Log::error('OCR callback processing failed: '.$e->getMessage());
+        Log::error('Exception details: '.$e->getTraceAsString());
+
         return response()->json([
             'success' => false,
-            'error' => 'Callback processing failed: ' . $e->getMessage()
+            'error' => 'Callback processing failed: '.$e->getMessage(),
         ], 500);
     }
 })->name('api.ocr.callback');
@@ -282,15 +283,17 @@ Route::get('/questions/callback/{taskId}', function (string $taskId) {
     if ($callbackData) {
         // 清除已读取的回调数据
         cache()->forget($taskId);
-        session()->forget('question_gen_callback_' . $taskId);
+        session()->forget('question_gen_callback_'.$taskId);
+
         return response()->json($callbackData);
     }
 
     // 备选:从session读取
-    $sessionData = session('question_gen_callback_' . $taskId);
+    $sessionData = session('question_gen_callback_'.$taskId);
     if ($sessionData) {
         // 清除已读取的回调数据
-        session()->forget('question_gen_callback_' . $taskId);
+        session()->forget('question_gen_callback_'.$taskId);
+
         return response()->json($sessionData);
     }
 
@@ -306,16 +309,17 @@ Route::get('/ocr-question-callback/{ocrRecordId}/{taskId}', function (int $ocrRe
     if ($callbackData) {
         // 清除已读取的回调数据
         cache()->forget($cacheKey);
+
         return response()->json([
             'success' => true,
-            'data' => $callbackData
+            'data' => $callbackData,
         ]);
     }
 
     return response()->json([
         'success' => false,
         'status' => 'pending',
-        'message' => 'OCR callback not received yet'
+        'message' => 'OCR callback not received yet',
     ], 202);
 })->name('api.ocr.callback.get');
 
@@ -335,7 +339,8 @@ Route::get('/questions', function (QuestionServiceApi $service) {
 
         return response()->json($response);
     } catch (\Exception $e) {
-        \Log::error('Failed to fetch questions: ' . $e->getMessage());
+        \Log::error('Failed to fetch questions: '.$e->getMessage());
+
         return response()->json([
             'data' => [],
             'meta' => [
@@ -353,9 +358,11 @@ Route::get('/questions', function (QuestionServiceApi $service) {
 Route::get('/questions/statistics', function (QuestionServiceApi $service) {
     try {
         $stats = $service->getStatistics();
+
         return response()->json($stats);
     } catch (\Exception $e) {
-        \Log::error('Failed to get question statistics: ' . $e->getMessage());
+        \Log::error('Failed to get question statistics: '.$e->getMessage());
+
         return response()->json(['error' => $e->getMessage()], 500);
     }
 });
@@ -365,9 +372,11 @@ Route::post('/questions/search', function (QuestionServiceApi $service) {
     try {
         $data = request()->only(['query', 'limit']);
         $results = $service->searchQuestions($data['query'], $data['limit'] ?? 20);
+
         return response()->json($results);
     } catch (\Exception $e) {
-        \Log::error('Question search failed: ' . $e->getMessage());
+        \Log::error('Question search failed: '.$e->getMessage());
+
         return response()->json(['error' => $e->getMessage()], 500);
     }
 });
@@ -376,12 +385,14 @@ Route::post('/questions/search', function (QuestionServiceApi $service) {
 Route::get('/questions/{id}', function (int $id, QuestionServiceApi $service) {
     try {
         $question = $service->getQuestionById($id);
-        if (!$question) {
+        if (! $question) {
             return response()->json(['error' => 'Question not found'], 404);
         }
+
         return response()->json($question);
     } catch (\Exception $e) {
-        \Log::error("Failed to get question {$id}: " . $e->getMessage());
+        \Log::error("Failed to get question {$id}: ".$e->getMessage());
+
         return response()->json(['error' => $e->getMessage()], 500);
     }
 });
@@ -391,9 +402,11 @@ Route::post('/questions/generate', function (QuestionServiceApi $service) {
     try {
         $data = request()->only(['kp_code', 'keyword', 'count', 'strategy']);
         $result = $service->generateQuestions($data);
+
         return response()->json($result);
     } catch (\Exception $e) {
-        \Log::error('Question generation failed: ' . $e->getMessage());
+        \Log::error('Question generation failed: '.$e->getMessage());
+
         return response()->json([
             'success' => false,
             'message' => $e->getMessage(),
@@ -405,12 +418,14 @@ Route::post('/questions/generate', function (QuestionServiceApi $service) {
 Route::delete('/questions/{id}', function (int $id, QuestionServiceApi $service) {
     try {
         $deleted = $service->deleteQuestion($id);
+
         return response()->json([
             'success' => $deleted,
             'message' => $deleted ? 'Question deleted' : 'Failed to delete',
         ]);
     } catch (\Exception $e) {
-        \Log::error("Failed to delete question {$id}: " . $e->getMessage());
+        \Log::error("Failed to delete question {$id}: ".$e->getMessage());
+
         return response()->json([
             'success' => false,
             'message' => $e->getMessage(),
@@ -880,31 +895,30 @@ Route::get('/mathrecsys/test', function () {
     return response()->json([
         'success' => true,
         'message' => 'MathRecSys API integration is working',
-        'timestamp' => now()->toISOString()
+        'timestamp' => now()->toISOString(),
     ]);
 })->name('api.mathrecsys.test');
 
-
 // 测试OCR题目生成API调用
 Route::post('/test-ocr-generation', function () {
     try {
-        $service = new \App\Services\QuestionBankService();
+        $service = new \App\Services\QuestionBankService;
 
         // 模拟前端传递的OCR题目数据
         $questions = [
             [
                 'id' => 1,
-                'content' => '计算:2+3-4'
+                'content' => '计算:2+3-4',
             ],
             [
                 'id' => 2,
-                'content' => '解方程:x+5=10'
-            ]
+                'content' => '解方程:x+5=10',
+            ],
         ];
 
         Log::info('开始测试OCR题目生成', [
             'questions_count' => count($questions),
-            'ocr_record_id' => 12
+            'ocr_record_id' => 12,
         ]);
 
         // 使用异步API,系统自动生成回调URL
@@ -920,24 +934,24 @@ Route::post('/test-ocr-generation', function () {
         Log::info('OCR题目生成响应', [
             'response' => $response,
             'status' => $response['status'] ?? 'unknown',
-            'task_id' => $response['task_id'] ?? 'N/A'
+            'task_id' => $response['task_id'] ?? 'N/A',
         ]);
 
         return response()->json([
             'success' => true,
             'message' => 'OCR题目生成测试完成',
-            'data' => $response
+            'data' => $response,
         ]);
 
     } catch (\Exception $e) {
         Log::error('测试OCR题目生成失败', [
             'error' => $e->getMessage(),
-            'trace' => $e->getTraceAsString()
+            'trace' => $e->getTraceAsString(),
         ]);
 
         return response()->json([
             'success' => false,
-            'error' => $e->getMessage()
+            'error' => $e->getMessage(),
         ], 500);
     }
 })->name('api.test.ocr.generation');
@@ -985,8 +999,8 @@ Route::get('/student-answers/history/{studentId}', [StudentAnswerAnalysisControl
 */
 
 use App\Http\Controllers\Api\ExamAnswerAnalysisController;
-use App\Http\Controllers\Api\PaperSubmitAnalysisController;
 use App\Http\Controllers\Api\HealthCheckController;
+use App\Http\Controllers\Api\PaperSubmitAnalysisController;
 
 // 分析考试答题数据
 Route::post('/exam-answer-analysis', [ExamAnswerAnalysisController::class, 'analyze'])
@@ -1067,7 +1081,7 @@ Route::post('/exam-answer-analysis/batch', [ExamAnswerAnalysisController::class,
 
 Route::get('/tasks/status/{taskId}', function (string $taskId) {
     $task = app(\App\Services\TaskManager::class)->getTaskStatus($taskId);
-    if (!$task) {
+    if (! $task) {
         return response()->json([
             'success' => false,
             'message' => '任务不存在',
@@ -1128,8 +1142,8 @@ Route::get('/health', [HealthCheckController::class, 'index'])
 |--------------------------------------------------------------------------
 */
 
-use App\Http\Controllers\Api\StudentProgressController;
 use App\Http\Controllers\Api\QuestionPdfController;
+use App\Http\Controllers\Api\StudentProgressController;
 
 // 获取单个学生学习进度
 Route::get('/students/{studentId}/learning-progress', [StudentProgressController::class, 'show'])
@@ -1152,6 +1166,30 @@ Route::post('/questions/pdf', [QuestionPdfController::class, 'generate'])
     ->withoutMiddleware([Authenticate::class, 'auth', 'auth:sanctum', 'auth:api'])
     ->name('api.questions.pdf.generate');
 
+/*
+|--------------------------------------------------------------------------
+| 试卷 PDF 重新生成 API 路由
+|--------------------------------------------------------------------------
+*/
+
+// 重新生成统一 PDF(卷子 + 判卷)
+Route::post('/papers/{paper_id}/regenerate', [\App\Http\Controllers\ExamPdfController::class, 'regeneratePdf'])
+    ->withoutMiddleware([Authenticate::class, 'auth', 'auth:sanctum', 'auth:api'])
+    ->where('paper_id', '^paper_\d+$')
+    ->name('api.papers.regenerate');
+
+// 重新生成试卷 PDF(不含答案)
+Route::post('/papers/{paper_id}/regenerate-exam', [\App\Http\Controllers\ExamPdfController::class, 'regenerateExamPdf'])
+    ->withoutMiddleware([Authenticate::class, 'auth', 'auth:sanctum', 'auth:api'])
+    ->where('paper_id', '^paper_\d+$')
+    ->name('api.papers.regenerate-exam');
+
+// 重新生成判卷 PDF(含答案)
+Route::post('/papers/{paper_id}/regenerate-grading', [\App\Http\Controllers\ExamPdfController::class, 'regenerateGradingPdf'])
+    ->withoutMiddleware([Authenticate::class, 'auth', 'auth:sanctum', 'auth:api'])
+    ->where('paper_id', '^paper_\d+$')
+    ->name('api.papers.regenerate-grading');
+
 /*
 |--------------------------------------------------------------------------
 | 以下为旧代码(已迁移到 Controller,保留注释供参考)

+ 12 - 6
routes/web.php

@@ -1,19 +1,25 @@
 <?php
 
-use Illuminate\Support\Facades\Route;
-use App\Http\Controllers\NotificationController;
-use App\Http\Controllers\MenuVisibilityController;
 use App\Http\Controllers\ImportStreamController;
+use App\Http\Controllers\MenuVisibilityController;
+use App\Http\Controllers\NotificationController;
+use Illuminate\Support\Facades\Route;
 
 Route::get('/', function () {
     return redirect()->route('filament.admin.pages.dashboard');
 });
 
-Route::get('/test-math', function() { return view('test-math'); });
-Route::get('/test-case', function() { return view('test-case'); });
+Route::get('/test-math', function () {
+    return view('test-math');
+});
+Route::get('/test-case', function () {
+    return view('test-case');
+});
 Route::view('/knowledge-mindmap-public', 'public.knowledge-mindmap');
 Route::get('/admin/intelligent-exam/pdf/{paper_id}', [\App\Http\Controllers\ExamPdfController::class, 'show'])->name('filament.admin.auth.intelligent-exam.pdf');
 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');
 
 // 检查通知的路由
@@ -24,7 +30,7 @@ Route::post('/admin/toggle-menu-visibility', [MenuVisibilityController::class, '
     ->name('filament.admin.auth.toggle-menu-visibility');
 
 // Livewire测试路由
-Route::get('/test-livewire', function() {
+Route::get('/test-livewire', function () {
     return view('test-livewire');
 })->name('test.livewire');
 

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů