Explorar el Código

feat(pdf): add answer detail grading layout and optimize quick-answer flow

yemeishu hace 1 semana
padre
commit
1fec0eb4e4

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

@@ -1015,7 +1015,10 @@ class ExamPdfController extends Controller
         $teacherInfo = $this->getTeacherInfo($paper->teacher_id);
         $pdfMeta = $this->buildPdfMeta($paper, (string) $paper_id, $studentInfo);
 
-        return view('pdf.exam-grading', [
+        $appendScanSheet = config('exam.pdf_grading_append_scan_sheet', false);
+        $gradingView = $appendScanSheet ? 'pdf.exam-answer-detail' : 'pdf.exam-grading';
+
+        return view($gradingView, [
             'paper' => $paper,
             'questions' => $questions,
             'student' => $studentInfo,

+ 91 - 0
resources/views/pdf/exam-answer-detail.blade.php

@@ -0,0 +1,91 @@
+@php
+    $gradingCode = $pdfMeta['exam_code'] ?? ($paper->paper_id ?? 'unknown');
+    $studentName = $pdfMeta['student_name'] ?? ($student['name'] ?? ($paper->student_id ?? '________'));
+    $paperHeaderTitle = $pdfMeta['header_title'] ?? ($studentName . '|' . $gradingCode . '|未知类型');
+    $generateDateTime = now()->format('Y年m月d日 H:i:s');
+    $appendScanSheet = config('exam.pdf_grading_append_scan_sheet', false);
+@endphp
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>{{ $pdfMeta['grading_pdf_title'] ?? ($paper->paper_name ?? '答案详解预览') }}</title>
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
+    <style>
+        @page {
+            size: A4;
+            margin: 2.2cm 2cm 2.3cm 2cm;
+            @top-left { content: "知了数学·{{ $generateDateTime }}"; font-size: 13px; color: #666; }
+            @top-center { content: "{{ $studentName }}"; font-size: 13px; color: #666; }
+            @top-right {
+                content: "{{ $gradingCode }}";
+                font-size: 19px;
+                font-weight: 600;
+                font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
+                color: #222;
+            }
+            @bottom-left { content: "{{ $paperHeaderTitle }}"; font-size: 11px; color: #666; }
+            @bottom-right { content: counter(page) "/" counter(pages); font-size: 13px; color: #666; }
+        }
+        body {
+            font-family: "Noto Serif", "Noto Serif CJK SC", "Noto Sans CJK SC", "Noto Sans", "STSongti-SC", "PingFang SC", "Songti SC", serif;
+            line-height: 1.65;
+            color: #000;
+            background: #fff;
+            font-size: 14px;
+        }
+        .page { max-width: 720px; margin: 0 auto; padding: 0 12px; }
+        .header { text-align: center; margin-bottom: 1.1rem; border-bottom: 2px solid #000; padding-bottom: 0.8rem; }
+        @include('pdf.partials.answer-detail-styles')
+        @include('pdf.partials.grading-scan-sheet-styles')
+    </style>
+</head>
+<body style="page-break-before: always;">
+    <div class="page">
+        <div class="header">
+            <div style="font-size:22px;font-weight:bold;">答案详解</div>
+            <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
+                <span>老师:{{ $teacher['name'] ?? '________' }}</span>
+                <span>年级:@formatGrade($student['grade'] ?? '________')</span>
+                @if(!empty($pdfMeta['assemble_type_label']) && $pdfMeta['assemble_type_label'] !== '未知类型')
+                    <span>类型:{{ $pdfMeta['assemble_type_label'] }}</span>
+                @endif
+                <span>姓名:{{ $student['name'] ?? '________' }}</span>
+                <span>得分:________</span>
+            </div>
+        </div>
+
+        @include('pdf.partials.answer-detail-page', ['questions' => $questions])
+    </div>
+
+    @if($appendScanSheet)
+        @include('pdf.partials.grading-scan-sheet', [
+            'questions' => $questions,
+            'gradingCode' => $gradingCode,
+            'teacher' => $teacher,
+            'student' => $student,
+            'pdfMeta' => $pdfMeta ?? [],
+        ])
+    @endif
+
+    <script src="/js/katex.min.js"></script>
+    <script src="/js/auto-render.min.js"></script>
+    <script>
+        document.addEventListener('DOMContentLoaded', function() {
+            try {
+                renderMathInElement(document.body, {
+                    delimiters: [
+                        {left: '$$', right: '$$', display: true},
+                        {left: '$', right: '$', display: false},
+                        {left: '\\(', right: '\\)', display: false},
+                        {left: '\\[', right: '\\]', display: true}
+                    ],
+                    throwOnError: false,
+                    strict: false,
+                    trust: true
+                });
+            } catch (e) {}
+        });
+    </script>
+</body>
+</html>

+ 120 - 0
resources/views/pdf/partials/answer-detail-page.blade.php

@@ -0,0 +1,120 @@
+@php
+    $choiceQuestions = $questions['choice'] ?? [];
+    $fillQuestions = $questions['fill'] ?? [];
+    $answerQuestions = $questions['answer'] ?? [];
+
+    $allQuestions = collect()
+        ->concat($choiceQuestions)
+        ->concat($fillQuestions)
+        ->concat($answerQuestions)
+        ->sortBy(fn ($q) => (int) ($q->question_number ?? 0))
+        ->values();
+
+    $normalizeQuickAnswer = function (?string $answer): string {
+        $answer = trim((string) $answer);
+        if ($answer === '') {
+            return '—';
+        }
+        $answer = preg_replace('/\s+/u', ' ', $answer) ?? $answer;
+        $answer = str_replace([';', ';'], ' / ', $answer);
+
+        return trim($answer);
+    };
+
+    $quickAnswers = $allQuestions->map(function ($q) use ($normalizeQuickAnswer) {
+        $answerText = $normalizeQuickAnswer($q->answer ?? '');
+        $isLong = mb_strlen(strip_tags($answerText)) > 20
+            || str_contains($answerText, "\n")
+            || str_contains($answerText, "\r")
+            || str_contains($answerText, '{')
+            || str_contains($answerText, '\\frac')
+            || str_contains($answerText, '见解析');
+        $processedAnswer = \App\Services\MathFormulaProcessor::processFormulas($answerText);
+        // 速查统一为流式:移除显式换行
+        $processedAnswer = preg_replace('/<br\s*\/?>/iu', ' ', $processedAnswer) ?? $processedAnswer;
+
+        return [
+            'no' => (int) ($q->question_number ?? 0),
+            'answer' => $processedAnswer,
+            'long' => $isLong,
+            'extended' => false,
+        ];
+    })->values();
+
+    $normalizeDetailHtml = function (?string $text): string {
+        $text = trim((string) $text);
+        if ($text === '') {
+            return '';
+        }
+        $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;
+
+        return $text;
+    };
+
+    $formatDetailForReadability = function (?string $text): 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 = str_replace([';', ';'], [';<wbr>', ';<wbr>'], $text);
+        // 轻量语义断点:在“则/故/所以”前给换行机会,避免整段公式堆在一行
+        $text = preg_replace('/,\s*(则|故|所以)/u', ',<br>$1', $text) ?? $text;
+
+        // 清理重复换行
+        $text = preg_replace('/(?:<br>\s*){2,}/u', '<br>', $text) ?? $text;
+
+        return ltrim($text, '<br>');
+    };
+
+@endphp
+
+<div class="answer-detail-page">
+    <div class="answer-quick">
+        <div class="answer-quick-label">答案速查</div>
+        <div class="answer-quick-flow answer-quick-flow-ordered">
+            @foreach($quickAnswers as $item)
+                <span class="answer-quick-item {{ $item['long'] ? 'answer-quick-item-long' : 'answer-quick-item-compact' }}">
+                    <strong>{{ $item['no'] }}.&nbsp;</strong>{!! $item['answer'] !!}
+                </span>
+            @endforeach
+        </div>
+    </div>
+
+    <div class="answer-detail-two-cols">
+        @foreach($allQuestions as $q)
+            @php
+                $no = (int) ($q->question_number ?? 0);
+                $rawAnswer = trim((string) ($q->answer ?? ''));
+                $rawSolution = $normalizeDetailHtml($q->solution ?? '');
+                $isSeeSolutionAnswer = (bool) preg_match('/^见\s*解析[。\.]?$|^详见解析/u', $rawAnswer);
+
+                $renderAnswer = $rawAnswer !== ''
+                    ? \App\Services\MathFormulaProcessor::processFormulas($rawAnswer)
+                    : '—';
+                $renderSolution = $rawSolution !== ''
+                    ? \App\Services\MathFormulaProcessor::processFormulas($formatDetailForReadability($rawSolution))
+                    : '(无详解)';
+            @endphp
+            <div class="answer-detail-item">
+                <div class="entry-line">
+                    <span class="entry-head">
+                        <span class="qno">{{ $no }}.</span>
+                        @if(!$isSeeSolutionAnswer)
+                            <span class="answer-only">{!! $renderAnswer !!}</span>
+                        @endif
+                        <span class="analysis-tag">【解析】</span>
+                    </span>
+                    <span class="solution-only">{!! $renderSolution !!}</span>
+                </div>
+            </div>
+        @endforeach
+    </div>
+</div>

+ 253 - 0
resources/views/pdf/partials/answer-detail-styles.blade.php

@@ -0,0 +1,253 @@
+.answer-detail-page {
+    max-width: 720px;
+    margin: 0 auto;
+    padding: 0 12px;
+}
+
+.answer-quick {
+    border: 1px solid #d8d8d8;
+    border-radius: 4px;
+    padding: 7px 8px;
+    margin-bottom: 8px;
+    font-size: 12px;
+    line-height: 1.32;
+    background: #fcfcfc;
+}
+
+.answer-quick-label {
+    font-weight: 700;
+    margin-bottom: 5px;
+}
+
+.answer-quick-flow {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px 6px;
+}
+
+.answer-quick-flow-ordered {
+    justify-content: flex-start;
+    align-items: flex-start;
+}
+
+.answer-quick-item {
+    display: inline-flex;
+    align-items: center;
+    white-space: normal;
+    word-break: normal;
+    overflow-wrap: normal;
+    line-height: 1.2;
+    border: 1px dashed #e3e3e3;
+    border-radius: 3px;
+    padding: 2px 6px;
+    background: #fff;
+    margin: 0;
+    flex: 0 1 auto;
+    max-width: 100%;
+    vertical-align: middle;
+    box-sizing: border-box;
+    min-height: 30px;
+    font-size: 12px;
+}
+
+.answer-quick-item-compact {
+    text-align: center;
+    line-height: 1;
+    display: inline-flex;
+    align-items: center;
+    min-height: 30px;
+}
+
+.answer-quick-item-compact .katex {
+    vertical-align: middle;
+    font-size: 1em;
+}
+
+.answer-quick-item .katex {
+    vertical-align: middle;
+}
+
+/* 速查公式全部走流式,不使用块级公式排版 */
+.answer-quick .katex-display {
+    display: inline-flex !important;
+    align-items: center;
+    margin: 0 0.03em !important;
+    vertical-align: middle;
+}
+
+.answer-quick .katex-display > .katex {
+    display: inline-block !important;
+    vertical-align: middle;
+}
+
+.answer-quick .katex {
+    vertical-align: middle;
+    white-space: nowrap;
+}
+
+.answer-quick .katex * {
+    white-space: nowrap;
+}
+
+.answer-quick-item-long {
+    text-align: left;
+    line-height: 1.2;
+    display: inline-flex;
+    align-items: center;
+    flex-wrap: wrap;
+    max-width: 100%;
+    vertical-align: top;
+    font-size: 12px;
+    min-height: 30px;
+    padding: 1px 4px;
+    border-color: #e7e7e7;
+}
+
+.answer-quick-item-long .katex {
+    font-size: 1em;
+    vertical-align: middle;
+}
+
+.answer-quick-item-long .katex-display {
+    margin: 0 0.03em !important;
+}
+
+.answer-quick-item-extended {
+    text-align: left;
+    align-items: center;
+    width: auto;
+    max-width: 100%;
+    min-height: 30px;
+    padding-top: 2px;
+    padding-bottom: 2px;
+    line-height: 1.2;
+}
+
+.answer-quick-item strong {
+    font-weight: 700;
+    margin-right: 1px;
+    white-space: nowrap;
+    display: inline-flex;
+    align-items: center;
+    align-self: center;
+}
+
+.answer-detail-two-cols {
+    column-count: 2;
+    column-gap: 20px;
+    column-fill: auto;
+}
+
+.answer-detail-item {
+    break-inside: auto;
+    page-break-inside: auto;
+    margin-bottom: 7px;
+    font-size: 12px;
+    line-height: 1.52;
+    text-align: left;
+}
+
+.answer-detail-item .qno {
+    font-size: 12px;
+    font-weight: 700;
+    margin-right: 2px;
+}
+
+.answer-detail-item .entry-line {
+    display: block;
+    font-weight: 400;
+}
+
+.answer-detail-item .entry-head {
+    display: inline;
+    white-space: normal;
+    font-weight: 700;
+}
+
+.answer-detail-item .answer-only,
+.answer-detail-item .analysis-tag {
+    font-weight: 700;
+    margin-right: 4px;
+}
+
+.answer-detail-item .answer-only {
+    word-break: break-word;
+    overflow-wrap: break-word;
+}
+
+.answer-detail-item .analysis-tag {
+    white-space: nowrap;
+}
+
+.answer-detail-item .solution-only {
+    color: #242424;
+    font-weight: 400;
+    display: inline;
+    word-break: normal;
+    overflow-wrap: break-word;
+    line-height: 1.58;
+}
+
+.answer-detail-item .solution-only p,
+.answer-detail-item .solution-only div {
+    margin: 0;
+    display: inline;
+    font-size: inherit !important;
+    line-height: inherit !important;
+}
+
+.answer-detail-item .solution-only img {
+    display: block;
+    max-width: 162px;
+    max-height: 108px;
+    width: auto;
+    height: auto;
+    margin: 4px 0;
+    object-fit: contain;
+}
+
+.answer-detail-item,
+.answer-detail-item div,
+.answer-detail-item p,
+.answer-detail-item strong {
+    font-size: 12px !important;
+    line-height: 1.54 !important;
+}
+
+.answer-detail-item .katex {
+    font-size: 1em !important;
+    vertical-align: 0;
+    display: inline;
+}
+
+/* 不覆盖 KaTeX 内部排版,避免分数上下叠压 */
+.answer-detail-item .katex,
+.answer-detail-item .katex * {
+    line-height: normal !important;
+}
+
+.answer-detail-item .katex .mfrac {
+    margin-left: 0.01em;
+    margin-right: 0.01em;
+}
+
+.answer-detail-item .katex .mfrac .frac-line {
+    margin-top: 0.07em;
+    margin-bottom: 0.07em;
+}
+
+.answer-detail-item .katex .sqrt .vlist-t {
+    padding-top: 0.02em;
+}
+
+.answer-detail-item .katex-display {
+    margin: 0.34em 0 !important;
+    padding-left: 0.25em;
+    border-left: 1px solid #ececec;
+    display: block !important;
+    clear: both;
+}
+
+.answer-detail-item .solution-only br {
+    line-height: 1.4;
+}