Преглед изворни кода

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

过卫栋 пре 2 дана
родитељ
комит
857665a777

+ 58 - 0
app/Http/Controllers/QuestionPreviewController.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Services\ExamPdfExportService;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+
+class QuestionPreviewController extends Controller
+{
+    /**
+     * 显示预览工具页面
+     */
+    public function index()
+    {
+        return view('tools.question-preview');
+    }
+
+    /**
+     * 生成 PDF 预览
+     */
+    public function generatePdf(Request $request)
+    {
+        $request->validate([
+            'stem' => 'required|string',
+            'options' => 'nullable|array',
+            'answer' => 'nullable|string',
+            'solution' => 'nullable|string',
+        ]);
+
+        try {
+            $service = app(ExamPdfExportService::class);
+
+            // 过滤空选项
+            $options = array_filter($request->input('options', []), fn($v) => !empty($v));
+
+            $pdfPath = $service->generatePreviewPdf(
+                stem: $request->input('stem'),
+                options: $options,
+                answer: $request->input('answer'),
+                solution: $request->input('solution')
+            );
+
+            // 返回可访问的 URL
+            $url = Storage::disk('public')->url($pdfPath);
+
+            return response()->json([
+                'success' => true,
+                'url' => $url,
+            ]);
+        } catch (\Exception $e) {
+            return response()->json([
+                'success' => false,
+                'error' => $e->getMessage(),
+            ], 500);
+        }
+    }
+}

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

@@ -57,11 +57,27 @@
         }
         }
     </style>
     </style>
 
 
+    <!-- Livewire Styles -->
+    <style>
+        [wire\:loading], [wire\:loading\.delay], [wire\:loading\.inline-block], [wire\:loading\.inline], [wire\:loading\.block], [wire\:loading\.flex], [wire\:loading\.table], [wire\:loading\.grid], [wire\:loading\.inline-flex] {
+            display: none;
+        }
+        [wire\:loading\.delay\.shortest], [wire\:loading\.delay\.shorter], [wire\:loading\.delay\.short], [wire\:loading\.delay\.long], [wire\:loading\.delay\.longer], [wire\:loading\.delay\.longest] {
+            display: none;
+        }
+        [wire\:offline] {
+            display: none;
+        }
+        [wire\:dirty]:not(textarea):not(input):not(select) {
+            display: none;
+        }
+    </style>
     @livewireStyles
     @livewireStyles
 </head>
 </head>
 <body class="bg-gray-100 min-h-screen">
 <body class="bg-gray-100 min-h-screen">
     {{ $slot }}
     {{ $slot }}
 
 
+    <!-- Livewire Scripts -->
     @livewireScripts
     @livewireScripts
 
 
     <script>
     <script>

+ 474 - 0
resources/views/tools/question-preview.blade.php

@@ -0,0 +1,474 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="csrf-token" content="{{ csrf_token() }}">
+    <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>
+
+    <!-- Alpine.js -->
+    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></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>
+</head>
+<body class="bg-gray-100 min-h-screen">
+    <div class="max-w-7xl mx-auto py-8 px-4" x-data="questionPreview()" x-cloak>
+        <!-- 标题 -->
+        <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
+                        x-model="form.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"
+                                x-model="form.options.A"
+                                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"
+                                x-model="form.options.B"
+                                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"
+                                x-model="form.options.C"
+                                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"
+                                x-model="form.options.D"
+                                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"
+                        x-model="form.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
+                        x-model="form.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
+                        @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
+                        @click="previewPdf()"
+                        :disabled="pdfLoading"
+                        :class="pdfLoading ? 'opacity-50 cursor-wait' : ''"
+                        class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition flex items-center gap-2"
+                    >
+                        <svg x-show="!pdfLoading" 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 x-show="pdfLoading" 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 x-text="pdfLoading ? '生成中...' : 'PDF 预览'"></span>
+                    </button>
+
+                    <button
+                        @click="previewBoth()"
+                        :disabled="pdfLoading"
+                        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
+                        @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">
+                <!-- 网页预览 -->
+                <div x-show="showWebPreview" 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">
+                        <!-- 题干 -->
+                        <template x-if="form.stem">
+                            <div class="question-stem" x-html="renderMath(form.stem)"></div>
+                        </template>
+
+                        <!-- 选项 -->
+                        <template x-if="hasOptions()">
+                            <div class="question-options">
+                                <template x-if="form.options.A">
+                                    <div class="question-option">
+                                        <span class="font-medium">A.</span>
+                                        <span x-html="renderMath(form.options.A)"></span>
+                                    </div>
+                                </template>
+                                <template x-if="form.options.B">
+                                    <div class="question-option">
+                                        <span class="font-medium">B.</span>
+                                        <span x-html="renderMath(form.options.B)"></span>
+                                    </div>
+                                </template>
+                                <template x-if="form.options.C">
+                                    <div class="question-option">
+                                        <span class="font-medium">C.</span>
+                                        <span x-html="renderMath(form.options.C)"></span>
+                                    </div>
+                                </template>
+                                <template x-if="form.options.D">
+                                    <div class="question-option">
+                                        <span class="font-medium">D.</span>
+                                        <span x-html="renderMath(form.options.D)"></span>
+                                    </div>
+                                </template>
+                            </div>
+                        </template>
+
+                        <!-- 答案 -->
+                        <template x-if="form.answer">
+                            <div class="question-answer">
+                                <span class="font-medium text-green-700">答案:</span>
+                                <span x-html="renderMath(form.answer)"></span>
+                            </div>
+                        </template>
+
+                        <!-- 解析 -->
+                        <template x-if="form.solution">
+                            <div class="question-solution">
+                                <div class="font-medium text-gray-700 mb-2">解题思路:</div>
+                                <div x-html="renderMath(form.solution)"></div>
+                            </div>
+                        </template>
+                    </div>
+                </div>
+
+                <!-- PDF 预览 -->
+                <div x-show="showPdfPreview || pdfError" 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>
+                        <a x-show="pdfUrl" :href="pdfUrl" target="_blank" class="ml-auto text-sm text-blue-600 hover:underline">
+                            新窗口打开
+                        </a>
+                    </div>
+                    <div class="p-4">
+                        <template x-if="pdfError">
+                            <div class="text-red-600 bg-red-50 p-4 rounded">
+                                <p class="font-medium">生成失败</p>
+                                <p class="text-sm mt-1" x-text="pdfError"></p>
+                            </div>
+                        </template>
+                        <template x-if="pdfUrl && !pdfError">
+                            <iframe
+                                :src="pdfUrl"
+                                class="w-full h-[600px] border border-gray-200 rounded"
+                            ></iframe>
+                        </template>
+                    </div>
+                </div>
+
+                <!-- 使用提示 -->
+                <div x-show="!showWebPreview && !showPdfPreview && !pdfError" 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>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        /**
+         * 数学公式渲染器
+         */
+        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;
+            }
+        };
+
+        function questionPreview() {
+            return {
+                form: {
+                    stem: '',
+                    options: { A: '', B: '', C: '', D: '' },
+                    answer: '',
+                    solution: ''
+                },
+                showWebPreview: false,
+                showPdfPreview: false,
+                pdfUrl: '',
+                pdfError: '',
+                pdfLoading: false,
+
+                hasOptions() {
+                    return this.form.options.A || this.form.options.B || this.form.options.C || this.form.options.D;
+                },
+
+                renderMath(text) {
+                    return MathRenderer.render(text);
+                },
+
+                previewWeb() {
+                    if (!this.form.stem) {
+                        alert('请输入题干内容');
+                        return;
+                    }
+                    this.showWebPreview = true;
+                },
+
+                async previewPdf() {
+                    if (!this.form.stem) {
+                        alert('请输入题干内容');
+                        return;
+                    }
+
+                    this.pdfLoading = true;
+                    this.pdfError = '';
+                    this.pdfUrl = '';
+
+                    try {
+                        const response = await fetch('{{ route("tools.question-preview.pdf") }}', {
+                            method: 'POST',
+                            headers: {
+                                'Content-Type': 'application/json',
+                                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
+                                'Accept': 'application/json'
+                            },
+                            body: JSON.stringify(this.form)
+                        });
+
+                        const data = await response.json();
+
+                        if (data.success) {
+                            this.pdfUrl = data.url;
+                            this.showPdfPreview = true;
+                        } else {
+                            this.pdfError = data.error || '生成失败';
+                            this.showPdfPreview = true;
+                        }
+                    } catch (e) {
+                        this.pdfError = e.message || '网络错误';
+                        this.showPdfPreview = true;
+                    } finally {
+                        this.pdfLoading = false;
+                    }
+                },
+
+                previewBoth() {
+                    this.previewWeb();
+                    this.previewPdf();
+                },
+
+                clearForm() {
+                    this.form = {
+                        stem: '',
+                        options: { A: '', B: '', C: '', D: '' },
+                        answer: '',
+                        solution: ''
+                    };
+                    this.showWebPreview = false;
+                    this.showPdfPreview = false;
+                    this.pdfUrl = '';
+                    this.pdfError = '';
+                }
+            };
+        }
+    </script>
+</body>
+</html>

+ 3 - 1
routes/web.php

@@ -37,5 +37,7 @@ Route::get('/admin/markdown-imports/stream', [ImportStreamController::class, 'st
     ->name('filament.admin.markdown-imports.stream');
     ->name('filament.admin.markdown-imports.stream');
 
 
 // 题目预览验证工具(公开访问,无需登录)
 // 题目预览验证工具(公开访问,无需登录)
-Route::get('/tools/question-preview', \App\Livewire\QuestionPreviewTool::class)
+Route::get('/tools/question-preview', [\App\Http\Controllers\QuestionPreviewController::class, 'index'])
     ->name('tools.question-preview');
     ->name('tools.question-preview');
+Route::post('/tools/question-preview/pdf', [\App\Http\Controllers\QuestionPreviewController::class, 'generatePdf'])
+    ->name('tools.question-preview.pdf');