Kaynağa Gözat

feat: 公式调试预览页面,支持页面和pdf

过卫栋 2 gün önce
ebeveyn
işleme
3d7bd12989

+ 140 - 0
app/Livewire/QuestionPreviewTool.php

@@ -0,0 +1,140 @@
+<?php
+
+namespace App\Livewire;
+
+use App\Services\ExamPdfExportService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+use Livewire\Component;
+
+/**
+ * 题目预览验证工具
+ * 用于验证题目在网页和PDF中的显示效果
+ */
+class QuestionPreviewTool extends Component
+{
+    // 表单输入
+    public string $stem = '';
+    public string $optionA = '';
+    public string $optionB = '';
+    public string $optionC = '';
+    public string $optionD = '';
+    public string $answer = '';
+    public string $solution = '';
+
+    // 预览状态
+    public bool $showWebPreview = false;
+    public bool $showPdfPreview = false;
+    public ?string $pdfUrl = null;
+    public ?string $pdfError = null;
+
+    /**
+     * 触发网页预览
+     */
+    public function previewWeb(): void
+    {
+        $this->showWebPreview = true;
+        $this->dispatch('render-math');
+    }
+
+    /**
+     * 触发 PDF 预览
+     */
+    public function previewPdf(): void
+    {
+        $this->pdfError = null;
+        $this->pdfUrl = null;
+
+        try {
+            // 构建题目数据
+            $questionData = $this->buildQuestionData();
+
+            // 调用 PDF 生成服务
+            $pdfService = app(ExamPdfExportService::class);
+            $result = $pdfService->generatePreviewPdf($questionData);
+
+            if ($result && isset($result['url'])) {
+                $this->pdfUrl = $result['url'];
+                $this->showPdfPreview = true;
+            } else {
+                $this->pdfError = 'PDF 生成失败,请检查输入内容';
+            }
+        } catch (\Exception $e) {
+            Log::error('QuestionPreviewTool: PDF生成失败', [
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+            $this->pdfError = 'PDF 生成出错: ' . $e->getMessage();
+        }
+    }
+
+    /**
+     * 同时预览网页和 PDF
+     */
+    public function previewBoth(): void
+    {
+        $this->previewWeb();
+        $this->previewPdf();
+    }
+
+    /**
+     * 清空表单
+     */
+    public function clearForm(): void
+    {
+        $this->stem = '';
+        $this->optionA = '';
+        $this->optionB = '';
+        $this->optionC = '';
+        $this->optionD = '';
+        $this->answer = '';
+        $this->solution = '';
+        $this->showWebPreview = false;
+        $this->showPdfPreview = false;
+        $this->pdfUrl = null;
+        $this->pdfError = null;
+    }
+
+    /**
+     * 构建题目数据结构
+     */
+    private function buildQuestionData(): array
+    {
+        $hasOptions = !empty($this->optionA) || !empty($this->optionB) ||
+                      !empty($this->optionC) || !empty($this->optionD);
+
+        $options = null;
+        $questionType = 'fill'; // 默认填空题
+
+        if ($hasOptions) {
+            $questionType = 'choice';
+            $options = [];
+            if (!empty($this->optionA)) $options['A'] = $this->optionA;
+            if (!empty($this->optionB)) $options['B'] = $this->optionB;
+            if (!empty($this->optionC)) $options['C'] = $this->optionC;
+            if (!empty($this->optionD)) $options['D'] = $this->optionD;
+        }
+
+        return [
+            'stem' => $this->stem,
+            'options' => $options,
+            'answer' => $this->answer,
+            'solution' => $this->solution,
+            'question_type' => $questionType,
+        ];
+    }
+
+    /**
+     * 获取用于网页预览的数据
+     */
+    public function getPreviewDataProperty(): array
+    {
+        return $this->buildQuestionData();
+    }
+
+    public function render()
+    {
+        return view('livewire.question-preview-tool')
+            ->layout('layouts.preview-tool');
+    }
+}

+ 77 - 0
app/Services/ExamPdfExportService.php

@@ -2240,4 +2240,81 @@ class ExamPdfExportService
             return null;
         }
     }
+
+    /**
+     * 生成预览 PDF(用于题目预览验证工具)
+     *
+     * @param array $questionData 题目数据 ['stem', 'options', 'answer', 'solution', 'question_type']
+     * @return array|null 返回 ['url' => '...'] 或 null
+     */
+    public function generatePreviewPdf(array $questionData): ?array
+    {
+        try {
+            Log::info('generatePreviewPdf 开始', ['question_data' => $questionData]);
+
+            // 渲染 HTML
+            $html = $this->renderPreviewHtml($questionData);
+            if (empty($html)) {
+                Log::error('generatePreviewPdf: HTML 渲染为空');
+                return null;
+            }
+
+            // 生成 PDF
+            $pdfContent = $this->buildPdf($html);
+            if (empty($pdfContent)) {
+                Log::error('generatePreviewPdf: PDF 生成为空');
+                return null;
+            }
+
+            // 保存到临时目录
+            $filename = 'preview_' . uniqid() . '.pdf';
+            $path = "previews/{$filename}";
+            $url = $this->pdfStorageService->put($path, $pdfContent);
+
+            Log::info('generatePreviewPdf 成功', ['url' => $url]);
+
+            return ['url' => $url];
+
+        } catch (\Exception $e) {
+            Log::error('generatePreviewPdf 失败', [
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+            return null;
+        }
+    }
+
+    /**
+     * 渲染预览 HTML
+     */
+    private function renderPreviewHtml(array $questionData): string
+    {
+        $stem = $questionData['stem'] ?? '';
+        $options = $questionData['options'] ?? null;
+        $answer = $questionData['answer'] ?? '';
+        $solution = $questionData['solution'] ?? '';
+        $questionType = $questionData['question_type'] ?? 'fill';
+
+        // 使用 MathFormulaProcessor 处理公式
+        $processedStem = MathFormulaProcessor::processFormulas($stem);
+        $processedAnswer = MathFormulaProcessor::processFormulas($answer);
+        $processedSolution = MathFormulaProcessor::processFormulas($this->formatNewlines($solution));
+
+        $processedOptions = null;
+        if ($options && is_array($options)) {
+            $processedOptions = [];
+            foreach ($options as $key => $value) {
+                $processedOptions[$key] = MathFormulaProcessor::processFormulas($value);
+            }
+        }
+
+        // 渲染 HTML
+        return view('pdf.question-preview', [
+            'stem' => $processedStem,
+            'options' => $processedOptions,
+            'answer' => $processedAnswer,
+            'solution' => $processedSolution,
+            'questionType' => $questionType,
+        ])->render();
+    }
 }

+ 173 - 0
resources/views/layouts/preview-tool.blade.php

@@ -0,0 +1,173 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>题目预览验证工具 - Math CMS</title>
+
+    <!-- KaTeX CSS -->
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/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>
+
+    <style>
+        [x-cloak] { display: none !important; }
+
+        .math-preview {
+            font-family: "SimSun", "Songti SC", serif;
+            line-height: 1.8;
+        }
+
+        .math-preview .katex {
+            font-size: 1.1em;
+        }
+
+        .math-preview .katex-display {
+            margin: 0.5em 0;
+        }
+
+        /* 题目样式 */
+        .question-stem {
+            margin-bottom: 1rem;
+        }
+
+        .question-options {
+            margin-left: 1rem;
+        }
+
+        .question-option {
+            margin: 0.5rem 0;
+        }
+
+        .question-answer {
+            margin-top: 1rem;
+            padding-top: 0.5rem;
+            border-top: 1px dashed #ccc;
+        }
+
+        .question-solution {
+            margin-top: 1rem;
+            padding: 0.75rem;
+            background: #f9f9f9;
+            border-radius: 4px;
+        }
+    </style>
+
+    @livewireStyles
+</head>
+<body class="bg-gray-100 min-h-screen">
+    {{ $slot }}
+
+    @livewireScripts
+
+    <script>
+        /**
+         * 数学公式渲染器
+         * 与前端 MathText.tsx 逻辑保持一致
+         */
+        const MathRenderer = {
+            /**
+             * 预处理文本:处理双反斜杠、换行符等
+             */
+            preprocessText(text) {
+                let result = text;
+
+                // 1. 处理公式内的双反斜杠 -> 单反斜杠
+                // 块级公式 $$...$$
+                result = result.replace(/\$\$([\s\S]*?)\$\$/g, (_, tex) => {
+                    return '$$' + tex.replace(/\\\\/g, '\\') + '$$';
+                });
+                // 行内公式 $...$
+                result = result.replace(/\$([^$\n]+?)\$/g, (_, tex) => {
+                    return '$' + tex.replace(/\\\\/g, '\\') + '$';
+                });
+
+                // 2. 换行符处理:\n -> <br>,但保护 LaTeX 命令如 \neq, \nu
+                result = result.replace(/\\n(?![a-zA-Z])/g, '<br>');
+
+                // 3. 统一 LaTeX 分隔符格式
+                result = result.replace(/\\\[([\s\S]*?)\\\]/g, (_, tex) => `$$${tex}$$`);
+                result = result.replace(/\\\(([\s\S]*?)\\\)/g, (_, tex) => `$${tex}$`);
+
+                return result;
+            },
+
+            /**
+             * 渲染数学公式
+             */
+            render(text) {
+                if (!text) return '';
+
+                let result = this.preprocessText(text);
+
+                // 渲染块级公式 $$...$$
+                result = result.replace(/\$\$([\s\S]*?)\$\$/g, (_, tex) => {
+                    try {
+                        return katex.renderToString(tex.trim(), {
+                            displayMode: true,
+                            throwOnError: false,
+                            strict: false,
+                        });
+                    } catch (e) {
+                        console.error('KaTeX render error:', e);
+                        return `<span class="text-red-500">$$${tex}$$</span>`;
+                    }
+                });
+
+                // 渲染行内公式 $...$
+                result = result.replace(/\$([^$\n]+?)\$/g, (_, tex) => {
+                    try {
+                        return katex.renderToString(tex.trim(), {
+                            displayMode: false,
+                            throwOnError: false,
+                            strict: false,
+                        });
+                    } catch (e) {
+                        console.error('KaTeX render error:', e);
+                        return `<span class="text-red-500">$${tex}$</span>`;
+                    }
+                });
+
+                return result;
+            },
+
+            /**
+             * 渲染指定元素内的所有数学公式
+             */
+            renderElement(element) {
+                const mathElements = element.querySelectorAll('[data-math]');
+                mathElements.forEach(el => {
+                    const rawText = el.getAttribute('data-math');
+                    if (rawText) {
+                        el.innerHTML = this.render(rawText);
+                    }
+                });
+            }
+        };
+
+        // 监听 Livewire 事件
+        document.addEventListener('livewire:init', () => {
+            Livewire.on('render-math', () => {
+                setTimeout(() => {
+                    const previewArea = document.getElementById('web-preview-area');
+                    if (previewArea) {
+                        MathRenderer.renderElement(previewArea);
+                    }
+                }, 100);
+            });
+        });
+
+        // 页面加载完成后渲染
+        document.addEventListener('DOMContentLoaded', () => {
+            const previewArea = document.getElementById('web-preview-area');
+            if (previewArea) {
+                MathRenderer.renderElement(previewArea);
+            }
+        });
+    </script>
+</body>
+</html>

+ 268 - 0
resources/views/livewire/question-preview-tool.blade.php

@@ -0,0 +1,268 @@
+<div class="max-w-7xl mx-auto py-8 px-4">
+    <!-- 标题 -->
+    <div class="text-center mb-8">
+        <h1 class="text-2xl font-bold text-gray-800">题目预览验证工具</h1>
+        <p class="text-gray-500 mt-2">验证题目在网页和PDF中的显示效果</p>
+    </div>
+
+    <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
+        <!-- 左侧:输入表单 -->
+        <div class="bg-white rounded-lg shadow p-6">
+            <h2 class="text-lg font-semibold text-gray-700 mb-4">输入题目内容</h2>
+
+            <!-- 题干 -->
+            <div class="mb-4">
+                <label class="block text-sm font-medium text-gray-700 mb-1">
+                    题干 <span class="text-red-500">*</span>
+                </label>
+                <textarea
+                    wire:model="stem"
+                    rows="5"
+                    class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                    placeholder="粘贴题干内容,支持 LaTeX 公式(如 $$\sqrt{2}$$ 或 $x^2$)"
+                ></textarea>
+            </div>
+
+            <!-- 选项 -->
+            <div class="mb-4">
+                <label class="block text-sm font-medium text-gray-700 mb-2">
+                    选项(选填,不填则为填空/解答题)
+                </label>
+                <div class="grid grid-cols-2 gap-3">
+                    <div>
+                        <label class="text-xs text-gray-500">A</label>
+                        <input
+                            type="text"
+                            wire:model="optionA"
+                            class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500"
+                            placeholder="选项 A"
+                        >
+                    </div>
+                    <div>
+                        <label class="text-xs text-gray-500">B</label>
+                        <input
+                            type="text"
+                            wire:model="optionB"
+                            class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500"
+                            placeholder="选项 B"
+                        >
+                    </div>
+                    <div>
+                        <label class="text-xs text-gray-500">C</label>
+                        <input
+                            type="text"
+                            wire:model="optionC"
+                            class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500"
+                            placeholder="选项 C"
+                        >
+                    </div>
+                    <div>
+                        <label class="text-xs text-gray-500">D</label>
+                        <input
+                            type="text"
+                            wire:model="optionD"
+                            class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500"
+                            placeholder="选项 D"
+                        >
+                    </div>
+                </div>
+            </div>
+
+            <!-- 答案 -->
+            <div class="mb-4">
+                <label class="block text-sm font-medium text-gray-700 mb-1">答案</label>
+                <input
+                    type="text"
+                    wire:model="answer"
+                    class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500"
+                    placeholder="正确答案"
+                >
+            </div>
+
+            <!-- 解析 -->
+            <div class="mb-6">
+                <label class="block text-sm font-medium text-gray-700 mb-1">解析/解题思路</label>
+                <textarea
+                    wire:model="solution"
+                    rows="4"
+                    class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500"
+                    placeholder="解题思路或解析内容"
+                ></textarea>
+            </div>
+
+            <!-- 操作按钮 -->
+            <div class="flex flex-wrap gap-3">
+                <button
+                    wire:click="previewWeb"
+                    class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition flex items-center gap-2"
+                >
+                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
+                    </svg>
+                    网页预览
+                </button>
+
+                <button
+                    wire:click="previewPdf"
+                    wire:loading.attr="disabled"
+                    wire:loading.class="opacity-50 cursor-wait"
+                    wire:target="previewPdf"
+                    class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition flex items-center gap-2"
+                >
+                    <svg wire:loading.remove wire:target="previewPdf" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
+                    </svg>
+                    <svg wire:loading wire:target="previewPdf" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+                        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+                        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+                    </svg>
+                    <span wire:loading.remove wire:target="previewPdf">PDF 预览</span>
+                    <span wire:loading wire:target="previewPdf">生成中...</span>
+                </button>
+
+                <button
+                    wire:click="previewBoth"
+                    wire:loading.attr="disabled"
+                    class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition flex items-center gap-2"
+                >
+                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>
+                    </svg>
+                    同时预览
+                </button>
+
+                <button
+                    wire:click="clearForm"
+                    class="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition"
+                >
+                    清空
+                </button>
+            </div>
+        </div>
+
+        <!-- 右侧:预览区域 -->
+        <div class="space-y-6">
+            <!-- 网页预览 -->
+            @if($showWebPreview)
+            <div class="bg-white rounded-lg shadow">
+                <div class="px-4 py-3 border-b border-gray-200 flex items-center gap-2">
+                    <span class="text-blue-600">
+                        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
+                        </svg>
+                    </span>
+                    <h3 class="font-semibold text-gray-700">网页效果预览</h3>
+                </div>
+                <div id="web-preview-area" class="p-4 math-preview">
+                    <!-- 题干 -->
+                    @if($stem)
+                    <div class="question-stem" data-math="{{ $stem }}">
+                        {{ $stem }}
+                    </div>
+                    @endif
+
+                    <!-- 选项 -->
+                    @if($optionA || $optionB || $optionC || $optionD)
+                    <div class="question-options">
+                        @if($optionA)
+                        <div class="question-option">
+                            <span class="font-medium">A.</span>
+                            <span data-math="{{ $optionA }}">{{ $optionA }}</span>
+                        </div>
+                        @endif
+                        @if($optionB)
+                        <div class="question-option">
+                            <span class="font-medium">B.</span>
+                            <span data-math="{{ $optionB }}">{{ $optionB }}</span>
+                        </div>
+                        @endif
+                        @if($optionC)
+                        <div class="question-option">
+                            <span class="font-medium">C.</span>
+                            <span data-math="{{ $optionC }}">{{ $optionC }}</span>
+                        </div>
+                        @endif
+                        @if($optionD)
+                        <div class="question-option">
+                            <span class="font-medium">D.</span>
+                            <span data-math="{{ $optionD }}">{{ $optionD }}</span>
+                        </div>
+                        @endif
+                    </div>
+                    @endif
+
+                    <!-- 答案 -->
+                    @if($answer)
+                    <div class="question-answer">
+                        <span class="font-medium text-green-700">答案:</span>
+                        <span data-math="{{ $answer }}">{{ $answer }}</span>
+                    </div>
+                    @endif
+
+                    <!-- 解析 -->
+                    @if($solution)
+                    <div class="question-solution">
+                        <div class="font-medium text-gray-700 mb-2">解题思路:</div>
+                        <div data-math="{{ $solution }}">{{ $solution }}</div>
+                    </div>
+                    @endif
+                </div>
+            </div>
+            @endif
+
+            <!-- PDF 预览 -->
+            @if($showPdfPreview || $pdfError)
+            <div class="bg-white rounded-lg shadow">
+                <div class="px-4 py-3 border-b border-gray-200 flex items-center gap-2">
+                    <span class="text-green-600">
+                        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
+                        </svg>
+                    </span>
+                    <h3 class="font-semibold text-gray-700">PDF 效果预览</h3>
+                    @if($pdfUrl)
+                    <a href="{{ $pdfUrl }}" target="_blank" class="ml-auto text-sm text-blue-600 hover:underline">
+                        新窗口打开
+                    </a>
+                    @endif
+                </div>
+                <div class="p-4">
+                    @if($pdfError)
+                    <div class="text-red-600 bg-red-50 p-4 rounded">
+                        <p class="font-medium">生成失败</p>
+                        <p class="text-sm mt-1">{{ $pdfError }}</p>
+                    </div>
+                    @elseif($pdfUrl)
+                    <iframe
+                        src="{{ $pdfUrl }}"
+                        class="w-full h-[600px] border border-gray-200 rounded"
+                    ></iframe>
+                    @endif
+                </div>
+            </div>
+            @endif
+
+            <!-- 使用提示 -->
+            @if(!$showWebPreview && !$showPdfPreview && !$pdfError)
+            <div class="bg-gray-50 rounded-lg p-6 text-center text-gray-500">
+                <svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
+                </svg>
+                <p>在左侧输入题目内容</p>
+                <p class="text-sm mt-1">点击预览按钮查看渲染效果</p>
+
+                <div class="mt-4 text-left text-xs text-gray-400 bg-white p-3 rounded border">
+                    <p class="font-medium mb-2">支持的 LaTeX 格式:</p>
+                    <ul class="space-y-1">
+                        <li><code class="bg-gray-100 px-1">$...$</code> 行内公式</li>
+                        <li><code class="bg-gray-100 px-1">$$...$$</code> 块级公式</li>
+                        <li><code class="bg-gray-100 px-1">\(...\)</code> 行内公式(会自动转换)</li>
+                        <li><code class="bg-gray-100 px-1">\[...\]</code> 块级公式(会自动转换)</li>
+                    </ul>
+                </div>
+            </div>
+            @endif
+        </div>
+    </div>
+</div>

+ 134 - 0
resources/views/pdf/question-preview.blade.php

@@ -0,0 +1,134 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>题目预览</title>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
+    <style>
+        @page {
+            size: A4;
+            margin: 2cm;
+        }
+        body {
+            font-family: "SimSun", "Songti SC", serif;
+            line-height: 1.8;
+            color: #000;
+            background: #fff;
+            font-size: 14px;
+        }
+        .page {
+            max-width: 720px;
+            margin: 0 auto;
+            padding: 20px;
+        }
+        .header {
+            text-align: center;
+            margin-bottom: 20px;
+            padding-bottom: 10px;
+            border-bottom: 2px solid #333;
+        }
+        .header h1 {
+            font-size: 18px;
+            margin: 0;
+        }
+        .question-section {
+            margin-bottom: 20px;
+        }
+        .section-title {
+            font-weight: bold;
+            font-size: 14px;
+            color: #333;
+            margin-bottom: 8px;
+        }
+        .question-stem {
+            margin-bottom: 15px;
+            line-height: 2;
+        }
+        .question-options {
+            margin-left: 20px;
+            margin-bottom: 15px;
+        }
+        .option-item {
+            margin: 8px 0;
+            line-height: 1.8;
+        }
+        .option-label {
+            font-weight: bold;
+            margin-right: 8px;
+        }
+        .answer-section {
+            margin-top: 20px;
+            padding-top: 15px;
+            border-top: 1px dashed #999;
+        }
+        .answer-label {
+            font-weight: bold;
+            color: #2563eb;
+        }
+        .solution-section {
+            margin-top: 15px;
+            padding: 15px;
+            background: #f5f5f5;
+            border-radius: 4px;
+        }
+        .solution-title {
+            font-weight: bold;
+            margin-bottom: 10px;
+        }
+        .solution-content {
+            line-height: 2;
+        }
+        /* KaTeX 样式调整 */
+        .katex {
+            font-size: 1.1em;
+        }
+        .katex-display {
+            margin: 0.5em 0;
+        }
+    </style>
+</head>
+<body>
+    <div class="page">
+        <div class="header">
+            <h1>题目预览</h1>
+        </div>
+
+        <!-- 题干 -->
+        <div class="question-section">
+            <div class="section-title">题目</div>
+            <div class="question-stem">{!! $stem !!}</div>
+        </div>
+
+        <!-- 选项(如果有) -->
+        @if($options && count($options) > 0)
+        <div class="question-section">
+            <div class="section-title">选项</div>
+            <div class="question-options">
+                @foreach($options as $key => $value)
+                <div class="option-item">
+                    <span class="option-label">{{ $key }}.</span>
+                    <span>{!! $value !!}</span>
+                </div>
+                @endforeach
+            </div>
+        </div>
+        @endif
+
+        <!-- 答案 -->
+        @if($answer)
+        <div class="answer-section">
+            <span class="answer-label">正确答案:</span>
+            <span>{!! $answer !!}</span>
+        </div>
+        @endif
+
+        <!-- 解析 -->
+        @if($solution)
+        <div class="solution-section">
+            <div class="solution-title">解题思路</div>
+            <div class="solution-content">{!! $solution !!}</div>
+        </div>
+        @endif
+    </div>
+</body>
+</html>

+ 4 - 0
routes/web.php

@@ -35,3 +35,7 @@ Route::get('/admin/textbooks/{id}/delete', [\App\Http\Controllers\TextbookContro
 
 Route::get('/admin/markdown-imports/stream', [ImportStreamController::class, 'stream'])
     ->name('filament.admin.markdown-imports.stream');
+
+// 题目预览验证工具(公开访问,无需登录)
+Route::get('/tools/question-preview', \App\Livewire\QuestionPreviewTool::class)
+    ->name('tools.question-preview');