Explorar o código

Merge branch 'codex/ye-answer-detail-page'

yemeishu hai 1 semana
pai
achega
c77d296f16

+ 38 - 3
app/Services/ExamPdfExportService.php

@@ -525,6 +525,11 @@ class ExamPdfExportService
      */
     private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
     {
+        // 判卷部分启用答案详情页时,优先本地渲染,避免跨进程配置不一致。
+        if ($useGradingView && config('exam.pdf_grading_append_scan_sheet', false)) {
+            return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
+        }
+
         try {
             // 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑)
             $routeName = $useGradingView
@@ -579,13 +584,13 @@ class ExamPdfExportService
                 return null;
             }
 
-            $viewName = $useGradingView ? 'pdf.exam-grading' : 'pdf.exam-paper';
+            $viewName = $this->resolveExamViewName($useGradingView);
 
             // 构造视图需要的变量
             $questions = ['choice' => [], 'fill' => [], 'answer' => []];
             foreach ($paper->questions as $pq) {
                 $qType = $this->normalizeQuestionType($pq->question_type ?? 'answer');
-                $questions[$qType][] = $pq;
+                $questions[$qType][] = $this->normalizeAnswerFieldForPdf($pq);
             }
 
             $studentModel = \App\Models\Student::find($paper->student_id);
@@ -2835,7 +2840,7 @@ class ExamPdfExportService
         bool $grading
     ): ?string {
         try {
-            $viewName = $grading ? 'pdf.exam-grading' : 'pdf.exam-paper';
+            $viewName = $this->resolveExamViewName($grading);
 
             $html = view($viewName, [
                 'paper' => $paper,
@@ -2868,6 +2873,36 @@ class ExamPdfExportService
         }
     }
 
+    private function resolveExamViewName(bool $useGradingView): string
+    {
+        if (! $useGradingView) {
+            return 'pdf.exam-paper';
+        }
+
+        return config('exam.pdf_grading_append_scan_sheet', false)
+            ? 'pdf.exam-answer-detail'
+            : 'pdf.exam-grading';
+    }
+
+    private function normalizeAnswerFieldForPdf(object $question): object
+    {
+        $normalizedQuestion = clone $question;
+        $answerText = trim((string) ($normalizedQuestion->answer ?? ''));
+        if ($answerText !== '') {
+            return $normalizedQuestion;
+        }
+
+        foreach (['correct_answer', 'standard_answer', 'reference_answer'] as $fallbackField) {
+            $candidate = trim((string) ($normalizedQuestion->{$fallbackField} ?? ''));
+            if ($candidate !== '') {
+                $normalizedQuestion->answer = $candidate;
+                break;
+            }
+        }
+
+        return $normalizedQuestion;
+    }
+
     /**
      * 生成预览 PDF(用于题目预览验证工具)
      *

+ 4 - 3
app/Services/MathFormulaProcessor.php

@@ -14,11 +14,12 @@ class MathFormulaProcessor
      * 2. 只在检测到明显错误时才进行修复
      * 3. 优先保护正确的数学表达式不被破坏
      */
-    public static function processFormulas(string $content): string
+    public static function processFormulas(?string $content): string
     {
-        if (empty($content)) {
-            return $content;
+        if ($content === null || $content === '') {
+            return '';
         }
+        $content = (string) $content;
 
         // 0. 基础清理:解码 HTML 实体
         $decoded = html_entity_decode($content, ENT_QUOTES, 'UTF-8');

+ 5 - 3
app/Support/GradingMarkBoxCounter.php

@@ -18,15 +18,17 @@ class GradingMarkBoxCounter
     public function countAnswerSteps(?string $text): int
     {
         $text = (string) $text;
-        if (!preg_match('/(步骤\s*\d+|第\s*\d+\s*步)/u', $text)) {
+        $stepPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?|第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)';
+
+        if (!preg_match('/' . $stepPattern . '/u', $text)) {
             return 1;
         }
 
-        $parts = preg_split('/(?=步骤\s*\d+|第\s*\d+\s*步)/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+        $parts = preg_split('/(?=' . $stepPattern . ')/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
         $count = 0;
         foreach ($parts as $part) {
             $stepText = trim((string) $part);
-            if ($stepText !== '' && preg_match('/^(步骤\s*\d+|第\s*\d+\s*步)/u', $stepText)) {
+            if ($stepText !== '' && preg_match('/^' . $stepPattern . '/u', $stepText)) {
                 $count++;
             }
         }

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

@@ -454,6 +454,7 @@
                         // 按section分割内容
                         $sections = explode('===SECTION_START===', $solutionProcessed);
                         $processedSections = [];
+                        $stepPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?|第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)';
 
                         foreach ($sections as $section) {
                             if (empty(trim($section))) continue;
@@ -468,9 +469,9 @@
 
                                 // 【修复】处理步骤 - 在每个"步骤N"或"第N步"前添加方框
                                 // 【优化】使用split分割步骤,为所有步骤添加方框(包括第一个)
-                                if (preg_match('/(步骤\s*\d+|第\s*\d+\s*步)/u', $sectionContent)) {
+                                if (preg_match('/' . $stepPattern . '/u', $sectionContent)) {
                                     // 使用前瞻断言分割,保留分隔符
-                                    $allSteps = preg_split('/(?=步骤\s*\d+|第\s*\d+\s*步)/u', $sectionContent, -1, PREG_SPLIT_NO_EMPTY);
+                                    $allSteps = preg_split('/(?=' . $stepPattern . ')/u', $sectionContent, -1, PREG_SPLIT_NO_EMPTY);
 
                                     if (count($allSteps) > 0) {
                                         $processed = '';
@@ -479,7 +480,7 @@
                                             $stepText = trim($allSteps[$i]);
                                             if (!empty($stepText)) {
                                                 $prefix = ($i > 0) ? '<br>' : '';
-                                                $isStep = preg_match('/^(步骤\s*\d+|第\s*\d+\s*步)/u', $stepText);
+                                                $isStep = preg_match('/^' . $stepPattern . '/u', $stepText);
                                                 if ($isStep) {
                                                     $processed .= $prefix . '<span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">' . $stepText . '</span></span>';
                                                 } else {
@@ -508,9 +509,9 @@
                         // 【新增】如果没有匹配到任何section标记,整个solution都没有方框,则默认加一个方框
                         if (empty($processedSections) || (count($processedSections) === 1 && !str_contains($processedSections[0] ?? '', 'step-box'))) {
                             // 检查是否有步骤关键词
-                            if (preg_match('/(步骤\s*\d+|第\s*\d+\s*步)/u', $solutionProcessed)) {
+                            if (preg_match('/' . $stepPattern . '/u', $solutionProcessed)) {
                                 // 有步骤关键词:为每个步骤添加方框
-                                $allSteps = preg_split('/(?=步骤\s*\d+|第\s*\d+\s*步)/u', $solutionProcessed, -1, PREG_SPLIT_NO_EMPTY);
+                                $allSteps = preg_split('/(?=' . $stepPattern . ')/u', $solutionProcessed, -1, PREG_SPLIT_NO_EMPTY);
                                 if (count($allSteps) > 0) {
                                     $processed = '';
                                     for ($i = 0; $i < count($allSteps); $i++) {
@@ -518,7 +519,7 @@
                                         if (!empty($stepText)) {
                                             // 只有真正以"步骤"或"第X步"开头的部分才加方框
                                             // 第一个部分如果不是步骤开头(如【分析】),则不加方框
-                                            $isStep = preg_match('/^(步骤\s*\d+|第\s*\d+\s*步)/u', $stepText);
+                                            $isStep = preg_match('/^' . $stepPattern . '/u', $stepText);
                                             $prefix = ($i > 0) ? '<br>' : '';
                                             if ($isStep) {
                                                 $processed .= $prefix . '<span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">' . $stepText . '</span></span>';

+ 127 - 49
resources/views/pdf/partials/answer-detail-page.blade.php

@@ -2,12 +2,13 @@
     $choiceQuestions = $questions['choice'] ?? [];
     $fillQuestions = $questions['fill'] ?? [];
     $answerQuestions = $questions['answer'] ?? [];
+    $stepPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?|第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)';
 
     $allQuestions = collect()
-        ->concat($choiceQuestions)
-        ->concat($fillQuestions)
-        ->concat($answerQuestions)
-        ->sortBy(fn ($q) => (int) ($q->question_number ?? 0))
+        ->concat(collect($choiceQuestions)->map(fn ($q) => ['q' => $q, 'detail_type' => 'choice']))
+        ->concat(collect($fillQuestions)->map(fn ($q) => ['q' => $q, 'detail_type' => 'fill']))
+        ->concat(collect($answerQuestions)->map(fn ($q) => ['q' => $q, 'detail_type' => 'answer']))
+        ->sortBy(fn ($item) => (int) (($item['q']->question_number ?? 0)))
         ->values();
 
     $normalizeQuickAnswer = function (?string $answer): string {
@@ -21,7 +22,8 @@
         return trim($answer);
     };
 
-    $quickAnswers = $allQuestions->map(function ($q) use ($normalizeQuickAnswer) {
+    $quickAnswers = $allQuestions->map(function ($item) use ($normalizeQuickAnswer) {
+        $q = $item['q'];
         $answerText = $normalizeQuickAnswer($q->answer ?? '');
         $isLong = mb_strlen(strip_tags($answerText)) > 20
             || str_contains($answerText, "\n")
@@ -37,7 +39,6 @@
             'no' => (int) ($q->question_number ?? 0),
             'answer' => $processedAnswer,
             'long' => $isLong,
-            'extended' => false,
         ];
     })->values();
 
@@ -46,6 +47,12 @@
         if ($text === '') {
             return '';
         }
+        // 兼容历史/AI返回的非标准图片标签,统一转为标准 <img>
+        $text = preg_replace('/<\s*image\b/iu', '<img', $text) ?? $text;
+        $text = preg_replace('/<\s*\/\s*image\s*>/iu', '', $text) ?? $text;
+        // 兼容被裁掉 "<" 的图片标签文本:img src="..."/>
+        $text = preg_replace('/(^|[\s>])img\s+src\s*=\s*([\'"][^\'"]+[\'"])\s*\/?>/iu', '$1<img src=$2 />', $text) ?? $text;
+
         $text = preg_replace('/font-size\s*:[^;"]+;?/iu', '', $text) ?? $text;
         $text = preg_replace('/line-height\s*:[^;"]+;?/iu', '', $text) ?? $text;
         $text = preg_replace('/style\s*=\s*([\'"])\s*\1/iu', '', $text) ?? $text;
@@ -53,15 +60,14 @@
         return $text;
     };
 
-    $formatDetailForReadability = function (?string $text): string {
+    $formatDetailForReadability = function (?string $text) use ($stepPattern): string {
         $text = trim((string) $text);
         if ($text === '') {
             return '';
         }
 
         // 仅做轻量分行:遇到“步骤X”时换行,避免整段拥挤
-        $text = preg_replace('/\s*(步骤\s*[0-9一二三四五六七八九十]+\s*[::])/u', '<br>$1', $text) ?? $text;
-        $text = preg_replace('/\s*(第\s*[0-9一二三四五六七八九十]+\s*步\s*[::])/u', '<br>$1', $text) ?? $text;
+        $text = preg_replace('/\s*(' . $stepPattern . ')/u', '<br>$1', $text) ?? $text;
 
         // 软换行点:分号后允许优先换行,缓解长公式串拥挤
         $text = str_replace([';', ';'], [';<wbr>', ';<wbr>'], $text);
@@ -71,47 +77,95 @@
         // 清理重复换行
         $text = preg_replace('/(?:<br>\s*){2,}/u', '<br>', $text) ?? $text;
 
-        return ltrim($text, '<br>');
+        // 仅移除前导 <br> 标签,避免把 <img> 这类标签的起始 "<" 误删
+        return preg_replace('/^(?:\s*<br\s*\/?>\s*)+/iu', '', $text) ?? $text;
     };
 
-    $renderAnswerSolutionWithStepBoxes = function (string $solutionHtml): string {
+    $renderSolutionLikeGrading = function (string $solutionHtml, bool $withStepBoxes = false) use ($stepPattern): string {
         $solution = trim($solutionHtml);
         if ($solution === '' || $solution === '(无详解)') {
             return $solutionHtml;
         }
 
-        // 去掉分步得分等分值标记,与判卷页一致
+        // 去掉分步得分等分值标记(与判卷页一致)
         $solution = preg_replace('/(\s*\d+\s*分\s*)/u', '', $solution) ?? $solution;
 
-        // 与判卷页一致:优先识别“步骤X / 第X步”
-        if (preg_match('/(步骤\s*\d+|第\s*\d+\s*步)/u', $solution)) {
-            $parts = preg_split('/(?=步骤\s*\d+|第\s*\d+\s*步)/u', $solution, -1, PREG_SPLIT_NO_EMPTY) ?: [];
-            $processed = '';
-            foreach ($parts as $index => $part) {
-                $stepText = trim((string) $part);
-                if ($stepText === '') {
-                    continue;
-                }
-                $prefix = $index > 0 ? '<br>' : '';
-                $isStep = preg_match('/^(步骤\s*\d+|第\s*\d+\s*步)/u', $stepText);
-                if ($isStep) {
-                    $processed .= $prefix
-                        . '<span class="solution-step"><span class="step-box"><span class="detail-grade-box"></span></span><span class="step-label">'
-                        . $stepText
-                        . '</span></span>';
-                } else {
-                    // 与判卷页保持一致:步骤前引导语不加方框
-                    $processed .= $prefix . '<span class="step-label">' . $stepText . '</span>';
+        // 与判卷页一致:先做“解题思路/解题过程”等分段
+        $solution = preg_replace('/【(解题思路|详细解答|最终答案)】/u', "\n\n===SECTION_START===\n【$1】\n===SECTION_END===\n\n", $solution) ?? $solution;
+        $solution = preg_replace('/(解题过程\s*[^:\n]*:)/u', "\n\n===SECTION_START===\n【解题过程】\n===SECTION_END===\n\n", $solution) ?? $solution;
+
+        $sections = explode('===SECTION_START===', $solution);
+        $processedSections = [];
+        foreach ($sections as $section) {
+            if (trim((string) $section) === '') {
+                continue;
+            }
+            $section = str_replace('===SECTION_END===', '', $section);
+
+            if (preg_match('/【(解题思路|详细解答|最终答案|解题过程)】/u', $section, $matches)) {
+                $sectionTitle = $matches[0];
+                $sectionContent = preg_replace('/【(解题思路|详细解答|最终答案|解题过程)】/u', '', $section) ?? $section;
+
+                if ($withStepBoxes) {
+                    if (preg_match('/' . $stepPattern . '/u', $sectionContent)) {
+                        $parts = preg_split('/(?=' . $stepPattern . ')/u', $sectionContent, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+                        $processed = '';
+                        foreach ($parts as $index => $part) {
+                            $stepText = trim((string) $part);
+                            if ($stepText === '') {
+                                continue;
+                            }
+                            $prefix = $index > 0 ? '<br>' : '';
+                            $isStep = preg_match('/^' . $stepPattern . '/u', $stepText);
+                            if ($isStep) {
+                                $processed .= $prefix
+                                    . '<span class="solution-step"><span class="step-box"><span class="detail-grade-box"></span></span><span class="step-label">'
+                                    . $stepText
+                                    . '</span></span>';
+                            } else {
+                                $processed .= $prefix . '<span class="step-label">' . $stepText . '</span>';
+                            }
+                        }
+                        $sectionContent = $processed;
+                    } else {
+                        $sectionContent = '<span class="solution-step"><span class="step-box"><span class="detail-grade-box"></span></span><span class="step-label">&nbsp;</span></span> ' . trim((string) $sectionContent);
+                    }
                 }
+
+                $processedSections[] = '<div class="solution-section"><strong>' . $sectionTitle . '</strong><br>' . $sectionContent . '</div>';
+            } else {
+                $processedSections[] = $section;
             }
+        }
 
-            return $processed !== '' ? $processed : $solution;
+        $solution = implode('', $processedSections);
+
+        if ($withStepBoxes && (empty($processedSections) || (count($processedSections) === 1 && !str_contains($processedSections[0] ?? '', 'step-box')))) {
+            if (preg_match('/' . $stepPattern . '/u', $solution)) {
+                $parts = preg_split('/(?=' . $stepPattern . ')/u', $solution, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+                $processed = '';
+                foreach ($parts as $index => $part) {
+                    $stepText = trim((string) $part);
+                    if ($stepText === '') {
+                        continue;
+                    }
+                    $prefix = $index > 0 ? '<br>' : '';
+                    $isStep = preg_match('/^' . $stepPattern . '/u', $stepText);
+                    if ($isStep) {
+                        $processed .= $prefix . '<span class="solution-step"><span class="step-box"><span class="detail-grade-box"></span></span><span class="step-label">' . $stepText . '</span></span>';
+                    } else {
+                        $processed .= $prefix . '<span class="step-label">' . $stepText . '</span>';
+                    }
+                }
+                $solution = $processed;
+            } else {
+                $solution = '<span class="solution-step"><span class="step-box"><span class="detail-grade-box"></span></span><span class="step-label">' . trim((string) $solution) . '</span></span>';
+            }
         }
 
-        // 无步骤关键词:与判卷页一致,在解析开头加一个判分框
-        return '<span class="solution-step"><span class="step-box"><span class="detail-grade-box"></span></span><span class="step-label">'
-            . $solution
-            . '</span></span>';
+        $solution = preg_replace('/\n{3,}/u', "\n\n", $solution) ?? $solution;
+
+        return nl2br($solution);
     };
 
 @endphp
@@ -129,36 +183,60 @@
     </div>
 
     <div class="answer-detail-two-cols">
-        @foreach($allQuestions as $q)
+        @foreach($allQuestions as $item)
             @php
+                $q = $item['q'];
                 $no = (int) ($q->question_number ?? 0);
-                $questionType = strtolower((string) ($q->question_type ?? ''));
-                $isAnswerType = $questionType === 'answer';
+                $detailType = strtolower((string) ($item['detail_type'] ?? ''));
+                $isAnswerType = $detailType === 'answer';
                 $rawAnswer = trim((string) ($q->answer ?? ''));
                 $rawSolution = $normalizeDetailHtml($q->solution ?? '');
                 $isSeeSolutionAnswer = (bool) preg_match('/^见\s*解析[。\.]?$|^详见解析/u', $rawAnswer);
+                $hasImageLikeSolution = (bool) preg_match('/<\s*img\b|<\s*image\b|(^|[\s>])img\s+src\s*=/iu', $rawSolution);
+                $showAnswer = !$isSeeSolutionAnswer;
 
                 $renderAnswer = $rawAnswer !== ''
                     ? \App\Services\MathFormulaProcessor::processFormulas($rawAnswer)
                     : '—';
-                $renderSolution = $rawSolution !== ''
-                    ? \App\Services\MathFormulaProcessor::processFormulas($formatDetailForReadability($rawSolution))
-                    : '(无详解)';
+                if ($rawSolution === '') {
+                    $renderSolution = '(无详解)';
+                } elseif ($hasImageLikeSolution) {
+                    // 与判卷页保持一致:图片型解析优先保留原始HTML结构,避免标签被公式处理链打散为纯文本
+                    $renderSolution = $formatDetailForReadability($rawSolution);
+                } else {
+                    $renderSolution = \App\Services\MathFormulaProcessor::processFormulas($formatDetailForReadability($rawSolution));
+                }
                 if ($isAnswerType) {
-                    $renderSolution = $renderAnswerSolutionWithStepBoxes($renderSolution);
+                    $renderSolution = $renderSolutionLikeGrading($renderSolution, true);
+                } else {
+                    $renderSolution = $renderSolutionLikeGrading($renderSolution, false);
                 }
+                // 版式规则:
+                // 1) 选择/填空同行
+                // 2) 简答默认解析另起一行
+                // 3) 若答案为“见解析”被隐藏,则题号与【解析】同行
+                $useSplitAnalysisLine = $isAnswerType && $showAnswer;
             @endphp
             <div class="answer-detail-item">
-                <div class="entry-line">
-                    <span class="entry-head">
+                @if($useSplitAnalysisLine)
+                    <div class="entry-line entry-answer-line">
+                        <span class="qno">{{ $no }}.</span>
+                        <span class="answer-only">{!! $renderAnswer !!}</span>
+                    </div>
+                    <div class="entry-line entry-analysis-line">
+                        <span class="analysis-tag">【解析】</span>
+                        <span class="solution-only">{!! $renderSolution !!}</span>
+                    </div>
+                @else
+                    <div class="entry-line entry-inline-line">
                         <span class="qno">{{ $no }}.</span>
-                        @if(!$isSeeSolutionAnswer)
+                        @if($showAnswer)
                             <span class="answer-only">{!! $renderAnswer !!}</span>
                         @endif
                         <span class="analysis-tag">【解析】</span>
-                    </span>
-                    <span class="solution-only">{!! $renderSolution !!}</span>
-                </div>
+                        <span class="solution-only">{!! $renderSolution !!}</span>
+                    </div>
+                @endif
             </div>
         @endforeach
     </div>

+ 21 - 7
resources/views/pdf/partials/answer-detail-styles.blade.php

@@ -135,16 +135,19 @@
 .answer-detail-two-cols {
     column-count: 2;
     column-gap: 20px;
-    column-fill: auto;
+    /* 减少分页时某一列提前留白的问题 */
+    column-fill: balance;
 }
 
 .answer-detail-item {
-    break-inside: auto;
-    page-break-inside: auto;
+    break-inside: auto !important;
+    page-break-inside: auto !important;
     margin-bottom: 7px;
     font-size: 12px;
     line-height: 1.52;
     text-align: left;
+    orphans: 2;
+    widows: 2;
 }
 
 .answer-detail-item .qno {
@@ -168,10 +171,14 @@
     font-weight: 400;
 }
 
-.answer-detail-item .entry-head {
-    display: inline;
-    white-space: normal;
+.answer-detail-item .entry-answer-line {
     font-weight: 700;
+    margin-bottom: 1px;
+}
+
+.answer-detail-item .entry-analysis-line {
+    display: block;
+    margin-top: 1px;
 }
 
 .answer-detail-item .answer-only,
@@ -187,6 +194,10 @@
 
 .answer-detail-item .analysis-tag {
     white-space: nowrap;
+    display: inline-block;
+    margin-right: 4px;
+    font-weight: 700;
+    vertical-align: top;
 }
 
 .answer-detail-item .solution-only {
@@ -263,8 +274,11 @@
 }
 
 .answer-detail-item .solution-step {
-    display: inline-block;
+    display: inline;
     margin: 2px 0;
+    break-inside: auto;
+    page-break-inside: auto;
+    -webkit-column-break-inside: auto;
 }
 
 .answer-detail-item .step-box {