Explorar o código

修改追练的bug

yemeishu hai 3 horas
pai
achega
69c7f90e81

+ 32 - 0
app/Helpers/GradeHelper.php

@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * 格式化年级显示
+ * 将数字年级转换为中文显示
+ */
+
+if (!function_exists('format_grade')) {
+    function format_grade($grade)
+    {
+        $gradeMap = [
+            7 => '初一',
+            8 => '初二',
+            9 => '初三',
+            10 => '高一',
+            11 => '高二',
+            12 => '高三',
+        ];
+
+        // 如果是字符串且包含"初"或"高",直接返回
+        if (is_string($grade) && (strpos($grade, '初') !== false || strpos($grade, '高') !== false)) {
+            return $grade;
+        }
+
+        // 如果是数字,使用映射表
+        if (is_numeric($grade)) {
+            return $gradeMap[intval($grade)] ?? $grade;
+        }
+
+        return $grade;
+    }
+}

+ 3 - 2
app/Http/Controllers/ExamPdfController.php

@@ -702,7 +702,8 @@ class ExamPdfController extends Controller
         ]);
 
         // 渲染视图
-        return view('pdf.exam-paper', [
+        $viewName = $includeAnswer ? 'pdf.exam-grading' : 'pdf.exam-paper';
+        return view($viewName, [
             'paper' => $paper,
             'questions' => $questions,
             'student' => $this->getStudentInfo($paper->student_id),
@@ -712,7 +713,7 @@ class ExamPdfController extends Controller
     }
 
     /**
-     * 判卷视图:题目前带方框,题后附“正确答案+解题思路”
+     * 判卷视图:题目前带方框,题后附"正确答案+解题思路"
      */
     public function showGrading(Request $request, $paper_id)
     {

+ 9 - 2
app/Jobs/GenerateExamPdfJob.php

@@ -108,15 +108,21 @@ class GenerateExamPdfJob implements ShouldQueue
             $gradingPdfUrl = $pdfExportService->generateGradingPdf($this->paperId)
                 ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $this->paperId, 'answer' => 'true']);
 
+            $taskManager->updateTaskProgress($this->taskId, 70, '判卷PDF生成完成,开始合并PDF...');
+
+            // 生成合并PDF(试卷 + 判卷)
+            $mergedPdfUrl = $pdfExportService->generateMergedPdf($this->paperId);
+
             // 构建完整的试卷内容
             $examContent = $paperPayloadService->buildExamContent($paperModel);
 
-            // 标记任务完成
+            // 标记任务完成(包含合并后的PDF URL)
             $taskManager->markTaskCompleted($this->taskId, [
                 'exam_content' => $examContent,
                 'pdfs' => [
                     'exam_paper_pdf' => $pdfUrl,
                     'grading_pdf' => $gradingPdfUrl,
+                    'all_pdf' => $mergedPdfUrl, // 【新增】合并后的完整PDF
                 ],
             ]);
 
@@ -125,10 +131,11 @@ class GenerateExamPdfJob implements ShouldQueue
                 'paper_id' => $this->paperId,
                 'pdf_url' => $pdfUrl,
                 'grading_pdf_url' => $gradingPdfUrl,
+                'merged_pdf_url' => $mergedPdfUrl,
                 'question_count' => $paperModel->questions->count(),
             ]);
 
-            // 发送回调通知
+            // 发送回调通知(在合并PDF完成后)
             $taskManager->sendCallback($this->taskId);
 
         } catch (\Exception $e) {

+ 11 - 0
app/Providers/AppServiceProvider.php

@@ -5,6 +5,7 @@ namespace App\Providers;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\Facades\URL;
 use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Blade;
 use App\Models\MarkdownImport;
 use App\Models\PreQuestionCandidate;
 
@@ -26,6 +27,11 @@ class AppServiceProvider extends ServiceProvider
                 $app->make(\App\Services\MathRecSysService::class)
             );
         });
+
+        // 注册PDF合并工具
+        $this->app->singleton(\App\Services\PdfMerger::class, function () {
+            return new \App\Services\PdfMerger();
+        });
     }
 
     /**
@@ -37,6 +43,11 @@ class AppServiceProvider extends ServiceProvider
             URL::forceScheme('https');
         }
 
+        // 注册年级格式化 Blade 组件
+        Blade::directive('formatGrade', function ($expression) {
+            return "<?php echo format_grade($expression); ?>";
+        });
+
         MarkdownImport::updated(function (MarkdownImport $import): void {
             try {
                 Redis::publish('markdown-imports', json_encode([

+ 332 - 1
app/Services/ExamPdfExportService.php

@@ -29,7 +29,8 @@ class ExamPdfExportService
         private readonly QuestionBankService $questionBankService,
         private readonly QuestionServiceApi $questionServiceApi,
         private readonly PdfStorageService $pdfStorageService,
-        private readonly MasteryCalculator $masteryCalculator
+        private readonly MasteryCalculator $masteryCalculator,
+        private readonly PdfMerger $pdfMerger
     ) {}
 
     /**
@@ -64,6 +65,164 @@ class ExamPdfExportService
         return $url;
     }
 
+    /**
+     * 生成合并PDF(试卷 + 判卷)
+     * 先分别生成两个PDF,然后合并
+     */
+    public function generateMergedPdf(string $paperId): ?string
+    {
+        Log::info('generateMergedPdf 开始:', ['paper_id' => $paperId]);
+
+        $tempDir = storage_path("app/temp");
+        if (!is_dir($tempDir)) {
+            mkdir($tempDir, 0755, true);
+        }
+
+        $examPdfPath = null;
+        $gradingPdfPath = null;
+        $mergedPdfPath = null;
+
+        try {
+            // 先生成试卷PDF
+            $examPdfUrl = $this->generateExamPdf($paperId);
+            if (!$examPdfUrl) {
+                Log::error('ExamPdfExportService: 生成试卷PDF失败', ['paper_id' => $paperId]);
+                return null;
+            }
+
+            // 再生成判卷PDF
+            $gradingPdfUrl = $this->generateGradingPdf($paperId);
+            if (!$gradingPdfUrl) {
+                Log::error('ExamPdfExportService: 生成判卷PDF失败', ['paper_id' => $paperId]);
+                return null;
+            }
+
+            // 【修复】下载PDF文件到本地临时目录
+            Log::info('开始下载PDF文件到本地', [
+                'exam_url' => $examPdfUrl,
+                'grading_url' => $gradingPdfUrl
+            ]);
+
+            $examPdfPath = $tempDir . "/{$paperId}_exam.pdf";
+            $gradingPdfPath = $tempDir . "/{$paperId}_grading.pdf";
+
+            // 下载试卷PDF
+            $examContent = Http::get($examPdfUrl)->body();
+            if (empty($examContent)) {
+                Log::error('ExamPdfExportService: 下载试卷PDF失败', ['url' => $examPdfUrl]);
+                return null;
+            }
+            file_put_contents($examPdfPath, $examContent);
+
+            // 下载判卷PDF
+            $gradingContent = Http::get($gradingPdfUrl)->body();
+            if (empty($gradingContent)) {
+                Log::error('ExamPdfExportService: 下载判卷PDF失败', ['url' => $gradingPdfUrl]);
+                return null;
+            }
+            file_put_contents($gradingPdfPath, $gradingContent);
+
+            Log::info('PDF文件下载完成', [
+                'exam_size' => filesize($examPdfPath),
+                'grading_size' => filesize($gradingPdfPath)
+            ]);
+
+            // 合并PDF文件
+            $mergedPdfPath = $tempDir . "/{$paperId}_merged.pdf";
+            $merged = $this->pdfMerger->merge([$examPdfPath, $gradingPdfPath], $mergedPdfPath);
+
+            if (!$merged) {
+                Log::error('ExamPdfExportService: PDF文件合并失败', [
+                    'tool' => $this->pdfMerger->getMergeTool()
+                ]);
+                return null;
+            }
+
+            // 读取合并后的PDF内容并上传到云存储
+            $mergedPdfContent = file_get_contents($mergedPdfPath);
+            $path = "exams/{$paperId}_all.pdf";
+            $mergedUrl = $this->pdfStorageService->put($path, $mergedPdfContent);
+
+            if (!$mergedUrl) {
+                Log::error('ExamPdfExportService: 保存合并PDF失败', ['path' => $path]);
+                return null;
+            }
+
+            // 保存到数据库的all_pdf_url字段
+            $this->saveAllPdfUrlToDatabase($paperId, $mergedUrl);
+
+            Log::info('generateMergedPdf 完成:', [
+                'paper_id' => $paperId,
+                'url' => $mergedUrl,
+                'tool' => $this->pdfMerger->getMergeTool()
+            ]);
+            return $mergedUrl;
+
+        } catch (\Throwable $e) {
+            Log::error('ExamPdfExportService: 生成合并PDF失败', [
+                'paper_id' => $paperId,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+            return null;
+        } finally {
+            // 【修复】清理临时文件
+            $tempFiles = [$examPdfPath, $gradingPdfPath, $mergedPdfPath];
+            foreach ($tempFiles as $file) {
+                if ($file && file_exists($file)) {
+                    @unlink($file);
+                }
+            }
+            Log::debug('清理临时文件完成');
+        }
+    }
+
+    /**
+     * 将URL转换为本地文件路径
+     */
+    private function convertUrlToPath(string $url): ?string
+    {
+        // 如果是本地存储,URL格式类似:/storage/exams/paper_id_exam.pdf
+        // 需要转换为绝对路径
+        if (strpos($url, '/storage/') === 0) {
+            return public_path(ltrim($url, '/'));
+        }
+
+        // 如果是完整路径,直接返回
+        if (strpos($url, '/') === 0 && file_exists($url)) {
+            return $url;
+        }
+
+        // 如果是相对路径,转换为绝对路径
+        $path = public_path($url);
+        if (file_exists($path)) {
+            return $path;
+        }
+
+        return null;
+    }
+
+    /**
+     * 保存合并PDF URL到数据库
+     */
+    private function saveAllPdfUrlToDatabase(string $paperId, string $url): void
+    {
+        try {
+            \App\Models\Paper::where('paper_id', $paperId)->update([
+                'all_pdf_url' => $url
+            ]);
+            Log::debug('保存all_pdf_url成功', ['paper_id' => $paperId, 'url' => $url]);
+        } catch (\Exception $e) {
+            Log::error('保存all_pdf_url失败', [
+                'paper_id' => $paperId,
+                'url' => $url,
+                'error' => $e->getMessage()
+            ]);
+            throw $e;
+        }
+    }
+
+
     /**
      * 生成学情分析 PDF
      */
@@ -1227,4 +1386,176 @@ class ExamPdfExportService
 
         return 1; // 默认一级
     }
+
+    /**
+     * 构建题目数据(用于PDF生成)
+     */
+    private function buildQuestionsData(Paper $paper): array
+    {
+        $paperQuestions = $paper->questions()->orderBy('question_number')->get();
+        $questionsData = [];
+        foreach ($paperQuestions as $pq) {
+            $questionsData[] = [
+                'id' => $pq->question_bank_id,
+                'kp_code' => $pq->knowledge_point,
+                'question_type' => $pq->question_type ?? 'answer',
+                'stem' => $pq->question_text ?? '题目内容缺失',
+                'solution' => $pq->solution ?? '',
+                'answer' => $pq->correct_answer ?? '',
+                'difficulty' => $pq->difficulty ?? 0.5,
+                'score' => $pq->score ?? 5,
+                'tags' => '',
+                'content' => $pq->question_text ?? '',
+            ];
+        }
+
+        // 获取完整题目详情
+        if (!empty($questionsData)) {
+            $questionIds = array_column($questionsData, 'id');
+            $questionsResponse = $this->questionServiceApi->getQuestionsByIds($questionIds);
+            $responseData = $questionsResponse['data'] ?? [];
+
+            if (!empty($responseData)) {
+                $responseDataMap = [];
+                foreach ($responseData as $respQ) {
+                    $responseDataMap[$respQ['id']] = $respQ;
+                }
+
+                $questionsData = array_map(function($q) use ($responseDataMap) {
+                    if (isset($responseDataMap[$q['id']])) {
+                        $apiData = $responseDataMap[$q['id']];
+                        $q['stem'] = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
+                        $q['content'] = $q['stem'];
+                        $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
+                        $q['solution'] = $apiData['solution'] ?? $q['solution'] ?? '';
+                        $q['tags'] = $apiData['tags'] ?? $q['tags'] ?? '';
+                        $q['options'] = $apiData['options'] ?? [];
+                    }
+                    return $q;
+                }, $questionsData);
+            }
+        }
+
+        // 按题型分类
+        $classified = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($questionsData as $q) {
+            $type = $this->determineQuestionType($q);
+            $classified[$type][] = (object) $q;
+        }
+
+        return $classified;
+    }
+
+    /**
+     * 获取学生信息
+     */
+    private function getStudentInfo(?string $studentId): array
+    {
+        if (!$studentId) {
+            return [
+                'name' => '未知学生',
+                'grade' => '未知年级',
+                'class' => '未知班级'
+            ];
+        }
+
+        try {
+            $student = DB::table('students')
+                ->where('student_id', $studentId)
+                ->first();
+
+            if ($student) {
+                return [
+                    'name' => $student->name ?? $studentId,
+                    'grade' => $student->grade ?? '未知',
+                    'class' => $student->class ?? '未知'
+                ];
+            }
+        } catch (\Exception $e) {
+            Log::warning('获取学生信息失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage()
+            ]);
+        }
+
+        return [
+            'name' => $studentId,
+            'grade' => '未知',
+            'class' => '未知'
+        ];
+    }
+
+    /**
+     * 获取教师信息
+     */
+    private function getTeacherInfo(?string $teacherId): array
+    {
+        if (!$teacherId) {
+            return [
+                'name' => '未知老师',
+                'subject' => '数学'
+            ];
+        }
+
+        try {
+            $teacher = DB::table('teachers')
+                ->where('teacher_id', $teacherId)
+                ->first();
+
+            if ($teacher) {
+                return [
+                    'name' => $teacher->name ?? $teacherId,
+                    'subject' => $teacher->subject ?? '数学'
+                ];
+            }
+        } catch (\Exception $e) {
+            Log::warning('获取教师信息失败', [
+                'teacher_id' => $teacherId,
+                'error' => $e->getMessage()
+            ]);
+        }
+
+        return [
+            'name' => $teacherId,
+            'subject' => '数学'
+        ];
+    }
+
+    /**
+     * 判断题目类型
+     */
+    private function determineQuestionType(array $question): string
+    {
+        $stem = $question['stem'] ?? $question['content'] ?? '';
+        $tags = $question['tags'] ?? '';
+
+        // 根据题干内容判断选择题
+        if (is_string($stem)) {
+            $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem);
+            $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem);
+            $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem);
+            $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem);
+
+            $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0);
+            if ($optionCount >= 2) {
+                return 'choice';
+            }
+
+            // 检查是否有填空标记
+            if (preg_match('/(\s*)|\(\s*\)/', $stem)) {
+                return 'fill';
+            }
+        }
+
+        // 根据已有类型字段判断
+        if (!empty($question['question_type'])) {
+            $type = strtolower(trim($question['question_type']));
+            if (in_array($type, ['choice', '选择题'])) return 'choice';
+            if (in_array($type, ['fill', '填空题'])) return 'fill';
+            if (in_array($type, ['answer', '解答题'])) return 'answer';
+        }
+
+        // 默认返回解答题
+        return 'answer';
+    }
 }

+ 205 - 0
app/Services/PdfMerger.php

@@ -0,0 +1,205 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Process;
+use Illuminate\Support\Str;
+
+/**
+ * PDF合并工具类
+ * 支持pdfunite(生产环境)和qpdf(本地开发)
+ */
+class PdfMerger
+{
+    private string $mergeTool;
+    private bool $isProduction;
+
+    public function __construct()
+    {
+        $this->isProduction = app()->environment('production');
+        $this->mergeTool = $this->detectMergeTool();
+    }
+
+    /**
+     * 检测系统中可用的PDF合并工具
+     */
+    private function detectMergeTool(): string
+    {
+        // 生产环境优先使用pdfunite
+        if ($this->isProduction) {
+            if ($this->commandExists('pdfunite')) {
+                return 'pdfunite';
+            }
+        }
+
+        // 本地开发环境使用qpdf
+        if ($this->commandExists('qpdf')) {
+            return 'qpdf';
+        }
+
+        // 备选:尝试pdfunite
+        if ($this->commandExists('pdfunite')) {
+            return 'pdfunite';
+        }
+
+        throw new \Exception('未找到可用的PDF合并工具(pdfunite或qpdf)');
+    }
+
+    /**
+     * 检查命令是否存在
+     */
+    private function commandExists(string $command): bool
+    {
+        // 【修复】异步环境中PATH可能不完整,尝试绝对路径
+        $fullPaths = [
+            "/opt/homebrew/bin/{$command}",
+            "/usr/bin/{$command}",
+            "/usr/local/bin/{$command}",
+        ];
+
+        Log::debug("检查命令是否存在: {$command}", [
+            'checking_absolute_paths' => $fullPaths
+        ]);
+
+        // 首先尝试绝对路径
+        foreach ($fullPaths as $path) {
+            if (file_exists($path) && is_executable($path)) {
+                Log::debug("找到命令(绝对路径): {$command} -> {$path}");
+                return true;
+            }
+        }
+
+        // 如果绝对路径都不行,再尝试which命令
+        Log::debug("绝对路径未找到,尝试which命令: {$command}");
+        $output = Process::run("which {$command}");
+        $result = $output->successful();
+
+        Log::debug("which命令结果", [
+            'command' => $command,
+            'successful' => $result,
+            'output' => $output->output(),
+            'error' => $output->errorOutput()
+        ]);
+
+        return $result;
+    }
+
+    /**
+     * 合并多个PDF文件
+     *
+     * @param array $pdfPaths PDF文件路径数组
+     * @param string $outputPath 输出文件路径
+     * @return bool 合并是否成功
+     * @throws \Exception
+     */
+    public function merge(array $pdfPaths, string $outputPath): bool
+    {
+        // 验证输入文件
+        foreach ($pdfPaths as $path) {
+            if (!file_exists($path)) {
+                throw new \Exception("PDF文件不存在: {$path}");
+            }
+        }
+
+        // 确保输出目录存在
+        $outputDir = dirname($outputPath);
+        if (!is_dir($outputDir)) {
+            mkdir($outputDir, 0755, true);
+        }
+
+        Log::info('开始合并PDF', [
+            'tool' => $this->mergeTool,
+            'input_count' => count($pdfPaths),
+            'output_path' => $outputPath
+        ]);
+
+        try {
+            switch ($this->mergeTool) {
+                case 'pdfunite':
+                    return $this->mergeWithPdfunite($pdfPaths, $outputPath);
+                case 'qpdf':
+                    return $this->mergeWithQpdf($pdfPaths, $outputPath);
+                default:
+                    throw new \Exception("不支持的合并工具: {$this->mergeTool}");
+            }
+        } catch (\Exception $e) {
+            Log::error('PDF合并失败', [
+                'tool' => $this->mergeTool,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+            throw $e;
+        }
+    }
+
+    /**
+     * 使用pdfunite合并PDF
+     * pdfunite file1.pdf file2.pdf ... output.pdf
+     */
+    private function mergeWithPdfunite(array $pdfPaths, string $outputPath): bool
+    {
+        $command = 'pdfunite ' . implode(' ', array_map('escapeshellarg', $pdfPaths)) . ' ' . escapeshellarg($outputPath);
+
+        Log::debug('执行pdfunite命令', ['command' => $command]);
+
+        $output = Process::run($command);
+
+        if ($output->successful()) {
+            Log::info('pdfunite合并成功', ['output_path' => $outputPath]);
+            return true;
+        }
+
+        Log::error('pdfunite合并失败', [
+            'exit_code' => $output->exitCode(),
+            'output' => $output->output(),
+            'error' => $output->errorOutput()
+        ]);
+
+        return false;
+    }
+
+    /**
+     * 使用qpdf合并PDF
+     * qpdf --empty --pages file1.pdf file2.pdf -- -- output.pdf
+     */
+    private function mergeWithQpdf(array $pdfPaths, string $outputPath): bool
+    {
+        // 构建qpdf命令
+        $pagesArg = implode(' ', array_map('escapeshellarg', $pdfPaths));
+        $command = "qpdf --empty --pages {$pagesArg} -- -- " . escapeshellarg($outputPath);
+
+        Log::debug('执行qpdf命令', ['command' => $command]);
+
+        $output = Process::run($command);
+
+        if ($output->successful()) {
+            Log::info('qpdf合并成功', ['output_path' => $outputPath]);
+            return true;
+        }
+
+        Log::error('qpdf合并失败', [
+            'exit_code' => $output->exitCode(),
+            'output' => $output->output(),
+            'error' => $output->errorOutput()
+        ]);
+
+        return false;
+    }
+
+    /**
+     * 获取当前使用的合并工具
+     */
+    public function getMergeTool(): string
+    {
+        return $this->mergeTool;
+    }
+
+    /**
+     * 检查环境是否支持PDF合并
+     */
+    public function isSupported(): bool
+    {
+        return in_array($this->mergeTool, ['pdfunite', 'qpdf']);
+    }
+}

+ 4 - 1
composer.json

@@ -33,7 +33,10 @@
             "App\\": "app/",
             "Database\\Factories\\": "database/factories/",
             "Database\\Seeders\\": "database/seeders/"
-        }
+        },
+        "files": [
+            "app/Helpers/GradeHelper.php"
+        ]
     },
     "autoload-dev": {
         "psr-4": {

+ 92 - 26
resources/views/components/exam/paper-body.blade.php

@@ -34,10 +34,42 @@
 
     $renderBoxes = function($num) {
         // 判卷方框放大 1.2 倍,保持单行布局
+        if ($num == 2) {
+            // 两个方框时,使用右对齐布局
+            return '<div style="display:flex;justify-content:flex-end;gap:4px;">' .
+                   str_repeat('<span style="display:inline-block;width:17px;height:17px;line-height:17px;border:1px solid #333;"></span>', $num) .
+                   '</div>';
+        }
         return str_repeat('<span style="display:inline-block;width:17px;height:17px;line-height:17px;border:1px solid #333;margin-right:4px;vertical-align:middle;"></span>', $num);
     };
 @endphp
 
+{{-- 【新增】步骤方框CSS样式 --}}
+<style>
+.solution-step {
+    display: block;
+    margin: 8px 0;
+    padding: 4px 0;
+}
+
+.step-box {
+    display: inline-block;
+    margin-right: 8px;
+    vertical-align: middle;
+}
+
+.step-label {
+    white-space: normal;
+    vertical-align: middle;
+}
+
+.solution-section {
+    margin: 10px 0;
+    padding: 8px;
+    background-color: #f9f9f9;
+}
+</style>
+
 <!-- 一、选择题 -->
 <div class="section-title">一、选择题
     @if(count($choiceQuestions) > 0)
@@ -257,35 +289,69 @@
                         // 去掉分步得分等分值标记
                         $solutionProcessed = preg_replace('/(\s*\d+\s*分\s*)/u', '', $solutionProcessed);
 
-                        // 优化解析分段格式
-                        $solutionProcessed = preg_replace('/【(解题思路|详细解答|最终答案)】/u', "\n\n【$1】\n\n", $solutionProcessed);
-                        $solutionProcessed = preg_replace('/^【(解题思路|详细解答|最终答案)】\n\n/u', '<div class="solution-section"><strong>【$1】</strong><br>', $solutionProcessed);
-                        $solutionProcessed = preg_replace('/\n\n【(解题思路|详细解答|最终答案)】\n\n/u', '</div><div class="solution-section"><strong>【$1】</strong><br>', $solutionProcessed);
-                        $solutionProcessed = preg_replace('/\n\n/u', '<br>', $solutionProcessed);
+                        // 【修复】优化解析分段格式 - 支持两种格式:
+                        // 1. 【解题思路】格式
+                        // 2. 解题过程:格式
 
-                        // 为每个"第 N 步"前添加方框;若没有,则在【详细解答】段落开头添加一个方框
-                        if (preg_match('/第\s*\d+\s*步/u', $solutionProcessed)) {
-                            $solutionProcessed = preg_replace_callback('/第\s*\d+\s*步/u', function($m) use ($renderBoxes) {
-                                return '<br><span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">' . $m[0] . '</span></span>';
-                            }, $solutionProcessed);
-                        } else {
-                            // 在【详细解答】标题后追加方框;若无标题则在正文最前补一个
-                            $injected = false;
-                            $count = 0;
-                            $solutionProcessed = preg_replace(
-                                '/(<strong>【详细解答】<\/strong><br>)/u',
-                                '$1' . '<span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">&nbsp;</span></span> ',
-                                $solutionProcessed,
-                                1,
-                                $count
-                            );
-                            if (!empty($count)) {
-                                $injected = true;
-                            }
-                            if (!$injected) {
-                                $solutionProcessed = '<span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">&nbsp;</span></span> ' . ltrim($solutionProcessed);
+                        // 先处理【】格式
+                        $solutionProcessed = preg_replace('/【(解题思路|详细解答|最终答案)】/u', "\n\n===SECTION_START===\n【$1】\n===SECTION_END===\n\n", $solutionProcessed);
+
+                        // 再处理"解题过程:"格式
+                        $solutionProcessed = preg_replace('/(解题过程\s*:)/u', "\n\n===SECTION_START===\n【解题过程】\n===SECTION_END===\n\n", $solutionProcessed);
+
+                        // 按section分割内容
+                        $sections = explode('===SECTION_START===', $solutionProcessed);
+                        $processedSections = [];
+
+                        foreach ($sections as $section) {
+                            if (empty(trim($section))) continue;
+
+                            // 去掉结尾标记
+                            $section = str_replace('===SECTION_END===', '', $section);
+
+                            // 检查是否是解题相关部分
+                            if (preg_match('/【(解题思路|详细解答|最终答案|解题过程)】/u', $section, $matches)) {
+                                $sectionTitle = $matches[0];
+                                $sectionContent = preg_replace('/【(解题思路|详细解答|最终答案|解题过程)】/u', '', $section);
+
+                                // 【修复】处理步骤 - 在每个"步骤N"或"第N步"前添加方框
+                                // 【简化】使用split分割步骤,然后在每个步骤前添加方框
+                                if (preg_match('/(步骤\s*\d+|第\s*\d+\s*步)/u', $sectionContent)) {
+                                    // 使用前瞻断言分割,保留分隔符
+                                    $allSteps = preg_split('/(?=步骤\s*\d+|第\s*\d+\s*步)/u', $sectionContent, -1, PREG_SPLIT_NO_EMPTY);
+
+                                    if (count($allSteps) > 1) {
+                                        // 第一部分通常不是步骤,直接保留
+                                        $processed = trim($allSteps[0]);
+                                        // 从第二个元素开始,每个都是步骤
+                                        for ($i = 1; $i < count($allSteps); $i++) {
+                                            $stepText = trim($allSteps[$i]);
+                                            if (!empty($stepText)) {
+                                                // 在步骤前面添加方框和换行
+                                                $processed .= '<br><span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">' . $stepText . '</span></span>';
+                                            }
+                                        }
+                                        $sectionContent = $processed;
+                                    }
+                                } else {
+                                    // 没有明确步骤:在标题后添加一个方框作为开始
+                                    $sectionContent = '<span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">&nbsp;</span></span> ' . trim($sectionContent);
+                                }
+
+                                // 包装section
+                                $processedSections[] = '<div class="solution-section"><strong>' . $sectionTitle . '</strong><br>' . $sectionContent . '</div>';
+                            } else {
+                                // 非解题部分直接保留
+                                $processedSections[] = $section;
                             }
                         }
+
+                        // 重新组合所有部分
+                        $solutionProcessed = implode('', $processedSections);
+
+                        // 将多余的换行转换为<br>,但保留合理的段落间距
+                        $solutionProcessed = preg_replace('/\n{3,}/u', "\n\n", $solutionProcessed);
+                        $solutionProcessed = nl2br($solutionProcessed);
                     @endphp
                     <div class="question-lead spacer"></div>
                     <div class="answer-meta">

+ 1 - 1
resources/views/pdf/exam-grading.blade.php

@@ -188,7 +188,7 @@
         <div style="font-size:18px;">{{ $gradingCode }}</div>
         <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
             <span>老师:{{ $teacher['name'] ?? '________' }}</span>
-            <span>年级:{{ $student['grade'] ?? '________' }}</span>
+            <span>年级:@formatGrade($student['grade'] ?? '________')</span>
             <span>姓名:{{ $student['name'] ?? '________' }}</span>
             <span>得分:________</span>
         </div>

+ 1 - 1
resources/views/pdf/exam-paper.blade.php

@@ -289,7 +289,7 @@
         <div class="paper-title">{{ $examCode }}</div>
         <div class="info-row">
             <span>老师:{{ $teacher['name'] ?? '________' }}</span>
-            <span>年级:{{ $student['grade'] ?? '________' }}</span>
+            <span>年级:@formatGrade($student['grade'] ?? '________')</span>
             <span>姓名:{{ $student['name'] ?? '________' }}</span>
             <span>得分:________</span>
         </div>