Pārlūkot izejas kodu

feat(pdf): add isolated question-check rendering flow

Create a dedicated /api/questions/pdf quality-check path that renders stem-first pages, answer analysis, and scan-sheet output without touching normal exam assembly rendering behavior.

Made-with: Cursor
yemeishu 1 mēnesi atpakaļ
vecāks
revīzija
2c05796746

+ 10 - 14
app/Http/Controllers/Api/QuestionPdfController.php

@@ -39,7 +39,7 @@ class QuestionPdfController extends Controller
         $validator = Validator::make($request->all(), [
             'question_ids' => 'required|array|min:1|max:100',
             'question_ids.*' => 'required|integer',
-            'student_id' => 'required|string',
+            'student_id' => 'nullable|string',
             'student_name' => 'nullable|string|max:50',
             'student_grade' => 'nullable|string|max:20',
             'teacher_name' => 'nullable|string|max:50',
@@ -57,12 +57,11 @@ class QuestionPdfController extends Controller
         }
 
         $questionIds = $request->input('question_ids');
-        $studentId = $request->input('student_id');
-        $studentName = $request->input('student_name', '');
-        $studentGrade = $request->input('student_grade', '');
-        $teacherName = $request->input('teacher_name', '');
+        $studentId = (string) $request->input('student_id', 'question-check');
+        $studentName = $request->input('student_name', '________');
+        $studentGrade = $request->input('student_grade', '________');
+        $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, main=questions
 
         Log::info('生成指定题目PDF', [
@@ -98,21 +97,18 @@ class QuestionPdfController extends Controller
             // 3. Build virtual paper structure
             $paper = $this->buildVirtualPaper($paperName, $studentId, $groupedQuestions);
 
-            // 4. Generate PDF
-            $result = $this->pdfService->generateByQuestions(
+            // 4. 生成题目质检PDF(固定判题卡体系样式,不走正常组卷路径)
+            $result = $this->pdfService->generateQuestionCheckPdf(
                 $paper,
                 $groupedQuestions,
                 [
                     'name' => $studentName,
                     'grade' => $studentGrade,
                 ],
-                [
-                    'name' => $teacherName,
-                ],
-                $includeGrading
+                ['name' => $teacherName]
             );
 
-            Log::info('指定题目PDF生成成功', [
+            Log::info('题目质检PDF生成成功', [
                 'student_id' => $studentId,
                 'question_count' => count($questionIds),
                 'pdf_url' => $result['pdf_url'] ?? null,
@@ -120,7 +116,7 @@ class QuestionPdfController extends Controller
 
             return response()->json([
                 'success' => true,
-                'message' => 'PDF生成成功',
+                'message' => '题目质检PDF生成成功',
                 'data' => $result,
             ]);
 

+ 87 - 0
app/Services/ExamPdfExportService.php

@@ -3086,6 +3086,93 @@ class ExamPdfExportService
         }
     }
 
+    /**
+     * 题目质检专用 PDF:固定使用判题卡体系模板(答案详解 + 判题卡)。
+     * 不进入正常组卷流程,仅用于检查题干、答案、解题思路渲染效果。
+     *
+     * @return array{pdf_url?: string}
+     */
+    public function generateQuestionCheckPdf(
+        object $paper,
+        array $groupedQuestions,
+        array $student = [],
+        array $teacher = []
+    ): array {
+        Log::info('generateQuestionCheckPdf 开始', [
+            'paper_id' => $paper->paper_id ?? null,
+            'question_counts' => [
+                'choice' => count($groupedQuestions['choice'] ?? []),
+                'fill' => count($groupedQuestions['fill'] ?? []),
+                'answer' => count($groupedQuestions['answer'] ?? []),
+            ],
+        ]);
+
+        $studentName = $student['name'] ?? ($paper->student_id ?? '________');
+        $examCode = \App\Support\PaperNaming::extractExamCode((string) ($paper->paper_id ?? 'custom'));
+        $pdfMeta = [
+            'student_name' => $studentName,
+            'exam_code' => $examCode,
+            'assemble_type_label' => '题目质检',
+            'header_title' => $examCode,
+            'exam_pdf_title' => '题目质检_'.$examCode,
+            'grading_pdf_title' => '题目质检_'.$examCode,
+            'knowledge_pdf_title' => '题目质检_'.$examCode,
+        ];
+
+        try {
+            // 固定走题目质检专用模板:题干+答案+解题思路 + 判题卡附页
+            $html = view('pdf.question-check', [
+                'paper' => $paper,
+                'questions' => $groupedQuestions,
+                'student' => $student,
+                'teacher' => $teacher,
+                'pdfMeta' => $pdfMeta,
+            ])->render();
+
+            if (empty(trim($html))) {
+                Log::error('generateQuestionCheckPdf: HTML 渲染为空', [
+                    'paper_id' => $paper->paper_id ?? null,
+                ]);
+
+                return [];
+            }
+
+            if ($this->katexRenderer) {
+                $html = $this->katexRenderer->renderHtml($html);
+            }
+
+            $pdfBinary = $this->buildPdf($this->ensureUtf8Html($html));
+            if (empty($pdfBinary)) {
+                Log::error('generateQuestionCheckPdf: buildPdf 失败', [
+                    'paper_id' => $paper->paper_id ?? null,
+                ]);
+
+                return [];
+            }
+
+            $path = 'custom_exams/'.($paper->paper_id ?? ('custom_'.time())).'_question_check.pdf';
+            $url = $this->pdfStorageService->put($path, $pdfBinary);
+            if (! $url) {
+                Log::error('generateQuestionCheckPdf: 上传失败', [
+                    'paper_id' => $paper->paper_id ?? null,
+                    'path' => $path,
+                ]);
+
+                return [];
+            }
+
+            return ['pdf_url' => $url];
+        } catch (\Throwable $e) {
+            Log::error('generateQuestionCheckPdf 失败', [
+                'paper_id' => $paper->paper_id ?? null,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            throw $e;
+        }
+    }
+
     /**
      * 渲染自定义题目的HTML
      */

+ 127 - 0
resources/views/pdf/partials/paper-body-core-styles.blade.php

@@ -0,0 +1,127 @@
+/* 大题标题:不与后面内容分开 */
+.section-title {
+    font-size: 16px;
+    font-weight: bold;
+    margin-top: 20px;
+    margin-bottom: 10px;
+    page-break-after: avoid;
+    break-after: avoid;
+}
+/* 题目整体:不分页 */
+.question {
+    margin-bottom: 15px;
+    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.65;
+    margin-top: 1px;
+}
+.question-lead.spacer { visibility: hidden; }
+.question-number { white-space: nowrap; margin-right: 2px; }
+.question-main { font-size: 14px; line-height: 1.65; font-family: inherit; display: block; }
+/* 题目内容:防止孤行 */
+.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;
+}
+.question-main {
+    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: 13.2px;
+    line-height: 1.6;
+    word-wrap: break-word;
+    display: flex;
+    align-items: baseline;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.option strong { margin-right: 4px; flex: 0 0 auto; line-height: 1.6; }
+.option-value { display: inline; }
+.option-short { white-space: nowrap; }
+.option-long { white-space: normal; word-break: break-word; }
+.option-inline { display: inline-flex; align-items: baseline; margin-right: 20px; }
+.option p, .option div { margin: 0; display: inline; }
+.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.boxy {
+    min-height: 150px;
+    border: 1.5px solid #444;
+    border-radius: 6px;
+    padding: 14px;
+}
+.question-stem .katex, .question-main .katex, .question-content .katex { font-size: 1em !important; vertical-align: 0; }
+.question-stem .katex-display, .question-main .katex-display, .question-content .katex-display { margin: 0.35em 0 !important; }

+ 195 - 0
resources/views/pdf/partials/question-check-page.blade.php

@@ -0,0 +1,195 @@
+@php
+    $choiceQuestions = $questions['choice'] ?? [];
+    $fillQuestions = $questions['fill'] ?? [];
+    $answerQuestions = $questions['answer'] ?? [];
+    $stepPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?|第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)';
+
+    $allQuestions = collect()
+        ->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();
+
+    $normalizeAnswerText = function (?string $answer, bool $compact = false): string {
+        $answer = trim((string) $answer);
+        if ($answer === '') {
+            return '—';
+        }
+        $answer = preg_replace('/\s+/u', ' ', $answer) ?? $answer;
+        $answer = str_replace([';', ';'], [';<wbr>', ';<wbr>'], $answer);
+        if ($compact) {
+            $answer = preg_replace('/<br\s*\/?>/iu', ' ', $answer) ?? $answer;
+        }
+
+        return trim($answer);
+    };
+
+    $quickAnswers = $allQuestions->map(function ($item) use ($normalizeAnswerText) {
+        $q = $item['q'];
+        $answerText = $normalizeAnswerText($q->answer ?? '', true);
+        $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);
+
+        return [
+            'no' => (int) ($q->question_number ?? 0),
+            'answer' => $processedAnswer,
+            'long' => $isLong,
+        ];
+    })->values();
+
+    $normalizeDetailHtml = function (?string $text): string {
+        $text = trim((string) $text);
+        if ($text === '') {
+            return '';
+        }
+        $text = preg_replace('/<\s*image\b/iu', '<img', $text) ?? $text;
+        $text = preg_replace('/<\s*\/\s*image\s*>/iu', '', $text) ?? $text;
+        $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;
+
+        return $text;
+    };
+
+    $formatDetailForReadability = function (?string $text) use ($stepPattern): string {
+        $text = trim((string) $text);
+        if ($text === '') {
+            return '';
+        }
+
+        $text = preg_replace('/\s*(' . $stepPattern . ')/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 preg_replace('/^(?:\s*<br\s*\/?>\s*)+/iu', '', $text) ?? $text;
+    };
+
+    $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;
+        $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;
+            }
+        }
+
+        $solution = implode('', $processedSections);
+        $solution = preg_replace('/\n{3,}/u', "\n\n", $solution) ?? $solution;
+
+        return nl2br($solution);
+    };
+@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 $item)
+            @php
+                $q = $item['q'];
+                $no = (int) ($q->question_number ?? 0);
+                $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 = \App\Services\MathFormulaProcessor::processFormulas($normalizeAnswerText($rawAnswer, false));
+                if ($rawSolution === '') {
+                    $renderSolution = '(无详解)';
+                } elseif ($hasImageLikeSolution) {
+                    $renderSolution = $formatDetailForReadability($rawSolution);
+                } else {
+                    $renderSolution = \App\Services\MathFormulaProcessor::processFormulas($formatDetailForReadability($rawSolution));
+                }
+                $renderSolution = $renderSolutionLikeGrading($renderSolution, $isAnswerType);
+                $useSplitAnalysisLine = $isAnswerType && $showAnswer;
+            @endphp
+            <div class="answer-detail-item">
+                @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($showAnswer)
+                            <span class="answer-only">【答案】{!! $renderAnswer !!}</span>
+                        @endif
+                        <span class="analysis-tag">【解析】</span>
+                        <span class="solution-only">{!! $renderSolution !!}</span>
+                    </div>
+                @endif
+            </div>
+        @endforeach
+    </div>
+</div>

+ 94 - 0
resources/views/pdf/partials/question-check-scan-sheet.blade.php

@@ -0,0 +1,94 @@
+@php
+    $boxCounter = app(\App\Support\GradingMarkBoxCounter::class);
+    $scanSheetItems = [];
+    $countBlanks = fn($text): int => $boxCounter->countFillBlanks($text);
+    $countSteps = fn($text): int => $boxCounter->countAnswerSteps($text);
+
+    foreach (($questions['choice'] ?? []) as $q) {
+        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => 1];
+    }
+    foreach (($questions['fill'] ?? []) as $q) {
+        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countBlanks($q->content ?? '')];
+    }
+    foreach (($questions['answer'] ?? []) as $q) {
+        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countSteps($q->solution ?? '')];
+    }
+
+    usort($scanSheetItems, static function ($a, $b) {
+        return ($a['no'] <=> $b['no']);
+    });
+    $scanSheetItems = array_values(array_filter($scanSheetItems, static function ($item) {
+        return (int) ($item['no'] ?? 0) > 0;
+    }));
+
+    $assembleTypeLabel = $pdfMeta['assemble_type_label'] ?? null;
+    $showAssembleType = !empty($assembleTypeLabel) && $assembleTypeLabel !== '未知类型';
+    $scanPaperCode = (string) ($pdfMeta['paper_id_num'] ?? $pdfMeta['exam_code'] ?? '');
+    if ($scanPaperCode === '' && !empty($paper->paper_id)) {
+        $scanPaperCode = preg_replace('/^paper_/', '', (string) $paper->paper_id) ?: (string) $paper->paper_id;
+    }
+
+    $totalItems = count($scanSheetItems);
+    $leftCount = (int) ceil($totalItems / 2);
+    $leftItems = array_slice($scanSheetItems, 0, $leftCount);
+    $rightItems = array_slice($scanSheetItems, $leftCount);
+@endphp
+
+<div class="page scan-sheet-page" style="page-break-before: auto; break-before: auto; width:100%; max-width:100%; margin:0 auto; padding:0 8px; box-sizing:border-box;">
+    <div class="scan-sheet-header" style="text-align:center;margin-bottom:1.5rem;border-bottom:2px solid #000;padding-bottom:1rem;">
+        <div style="font-size:22px;font-weight:bold;">判题卡</div>
+        @if($scanPaperCode !== '')
+            <div class="scan-sheet-paper-code" style="margin-top:6px;font-size:18px;font-weight:700;letter-spacing:0.4px;">{{ $scanPaperCode }}</div>
+        @endif
+        <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
+            <span>老师:{{ $teacher['name'] ?? '________' }}</span>
+            <span>年级:@formatGrade($student['grade'] ?? '________')</span>
+            @if($showAssembleType)
+                <span>类型:{{ $assembleTypeLabel }}</span>
+            @endif
+            <span>姓名:{{ $student['name'] ?? '________' }}</span>
+            <span>得分:________</span>
+        </div>
+    </div>
+    <div class="scan-sheet-hint" style="font-size:14px;color:#444;margin-bottom:14px;line-height:1.5;">提示:请根据答案和解析进行批改,在回答正确的 □ 内划 / ,在回答错误的 □ 内打 X 或置空</div>
+
+    <div class="scan-sheet-two-cols" style="display:flex;align-items:flex-start;gap:18px;">
+        <div class="scan-sheet-col" style="flex:1 1 0;display:grid;row-gap:12px;">
+            @foreach($leftItems as $item)
+                @php
+                    $questionNo = (int) ($item['no'] ?? 0);
+                    $boxCount = max(1, (int) ($item['box_count'] ?? 1));
+                @endphp
+                <div class="scan-sheet-item" style="display:flex;align-items:center;min-height:24px;">
+                    <span class="scan-sheet-no" style="font-weight:700;font-size:14px;line-height:1.2;white-space:nowrap;margin-right:2px;">题目 {{ $questionNo }}.</span>
+                    <span class="scan-sheet-marks" style="display:inline-flex;align-items:center;gap:0;">
+                        @for($i = 0; $i < $boxCount; $i++)
+                            <span class="scan-grade-box" style="display:inline-block;width:21px;height:21px;border:1px solid #333;box-sizing:border-box;background:#fff;vertical-align:middle;margin-right:16px;"></span>
+                        @endfor
+                    </span>
+                </div>
+            @endforeach
+        </div>
+
+        <div class="scan-sheet-col" style="flex:1 1 0;display:grid;row-gap:12px;">
+            @foreach($rightItems as $item)
+                @php
+                    $questionNo = (int) ($item['no'] ?? 0);
+                    $boxCount = max(1, (int) ($item['box_count'] ?? 1));
+                @endphp
+                <div class="scan-sheet-item" style="display:flex;align-items:center;min-height:24px;">
+                    <span class="scan-sheet-no" style="font-weight:700;font-size:14px;line-height:1.2;white-space:nowrap;margin-right:2px;">题目 {{ $questionNo }}.</span>
+                    <span class="scan-sheet-marks" style="display:inline-flex;align-items:center;gap:0;">
+                        @for($i = 0; $i < $boxCount; $i++)
+                            <span class="scan-grade-box" style="display:inline-block;width:21px;height:21px;border:1px solid #333;box-sizing:border-box;background:#fff;vertical-align:middle;margin-right:16px;"></span>
+                        @endfor
+                    </span>
+                </div>
+            @endforeach
+        </div>
+    </div>
+
+    @if($totalItems === 0)
+        <div class="scan-sheet-empty" style="color:#888;font-size:13px;margin-top:6px;">暂无可渲染题目</div>
+    @endif
+</div>

+ 90 - 0
resources/views/pdf/question-check.blade.php

@@ -0,0 +1,90 @@
+@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');
+@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')
+        @include('pdf.partials.paper-body-core-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>
+                <span>类型:题目质检</span>
+                <span>姓名:{{ $student['name'] ?? '________' }}</span>
+                <span>得分:________</span>
+            </div>
+        </div>
+        @include('components.exam.paper-body', ['questions' => $questions, 'grading' => false])
+    </div>
+
+    <div class="page">
+        @include('pdf.partials.question-check-page', ['questions' => $questions])
+    </div>
+
+    @include('pdf.partials.question-check-scan-sheet', [
+        'questions' => $questions,
+        'gradingCode' => $gradingCode,
+        'teacher' => $teacher,
+        'student' => $student,
+        'pdfMeta' => $pdfMeta ?? [],
+    ])
+
+    <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>