Переглянути джерело

学情生成pdf效果制作

yemeishu 2 тижнів тому
батько
коміт
dde62ad870

+ 257 - 1
app/Services/ExamPdfExportService.php

@@ -3,11 +3,17 @@
 namespace App\Services;
 
 use App\Http\Controllers\ExamPdfController;
+use App\Models\Paper;
+use App\Models\PaperQuestion;
+use App\Models\Student;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\File;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\URL;
+use App\Services\LearningAnalyticsService;
+use App\Services\QuestionBankService;
+use App\Services\QuestionServiceApi;
 use Symfony\Component\Process\Exception\ProcessSignaledException;
 use Symfony\Component\Process\Exception\ProcessTimedOutException;
 use Symfony\Component\Process\Process;
@@ -15,10 +21,18 @@ use Symfony\Component\Process\Process;
 class ExamPdfExportService
 {
     private ExamPdfController $controller;
+    private LearningAnalyticsService $learningAnalyticsService;
+    private QuestionBankService $questionBankService;
 
-    public function __construct(ExamPdfController $controller)
+    public function __construct(
+        ExamPdfController $controller,
+        LearningAnalyticsService $learningAnalyticsService,
+        QuestionBankService $questionBankService
+    )
     {
         $this->controller = $controller;
+        $this->learningAnalyticsService = $learningAnalyticsService;
+        $this->questionBankService = $questionBankService;
     }
 
     /**
@@ -37,6 +51,43 @@ class ExamPdfExportService
         return $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
     }
 
+    /**
+     * 生成学情分析 PDF
+     */
+    public function generateAnalysisReportPdf(string $paperId, string $studentId): ?string
+    {
+        if (function_exists('set_time_limit')) {
+            @set_time_limit(240);
+        }
+
+        try {
+            $payload = $this->buildAnalysisPayload($paperId, $studentId);
+            if (!$payload) {
+                return null;
+            }
+
+            $html = view('exam-analysis.pdf-report', $payload)->render();
+            $pdfBinary = $this->buildPdf($html);
+            if (!$pdfBinary) {
+                return null;
+            }
+
+            $path = "analysis_reports/{$paperId}_{$studentId}.pdf";
+            Storage::disk('public')->put($path, $pdfBinary);
+
+            return URL::to(Storage::url($path));
+        } catch (\Throwable $e) {
+            Log::error('ExamPdfExportService: 生成学情分析 PDF 失败', [
+                'paper_id' => $paperId,
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+                'exception' => get_class($e),
+                'trace' => $e->getTraceAsString(),
+            ]);
+            return null;
+        }
+    }
+
     private function renderAndStore(
         string $paperId,
         bool $includeAnswer,
@@ -144,6 +195,16 @@ class ExamPdfExportService
             return null;
         }
 
+        // 为无权限环境设置可写的 HOME/XDG 目录,避免创建 /var/www/.local 报错
+        $runtimeHome = sys_get_temp_dir() . '/chrome-home';
+        $runtimeXdg = sys_get_temp_dir() . '/chrome-xdg';
+        if (!File::exists($runtimeHome)) {
+            @File::makeDirectory($runtimeHome, 0755, true);
+        }
+        if (!File::exists($runtimeXdg)) {
+            @File::makeDirectory($runtimeXdg, 0755, true);
+        }
+
         $process = new Process([
             $chromeBinary,
             '--headless',
@@ -166,11 +227,15 @@ class ExamPdfExportService
             '--no-default-browser-check',
             '--disable-crash-reporter',
             '--disable-print-preview',
+            '--disable-features=PrintHeaderFooter',
             '--user-data-dir=' . $userDataDir,
             '--print-to-pdf=' . $tmpPdf,
             '--print-to-pdf-no-header',
             '--allow-file-access-from-files',
             'file://' . $htmlPath,
+        ], null, [
+            'HOME' => $runtimeHome,
+            'XDG_RUNTIME_DIR' => $runtimeXdg,
         ]);
         $process->setTimeout(60);
 
@@ -319,6 +384,197 @@ class ExamPdfExportService
         return $pdfBinary ?: null;
     }
 
+    private function buildAnalysisPayload(string $paperId, string $studentId): ?array
+    {
+        $paper = Paper::with(['questions' => function ($query) {
+            $query->orderBy('question_number');
+        }])->find($paperId);
+        if (!$paper) {
+            Log::error('ExamPdfExportService: 未找到试卷,无法生成学情报告', [
+                'paper_id' => $paperId,
+                'student_id' => $studentId,
+            ]);
+            return null;
+        }
+
+        $student = Student::find($studentId);
+        $studentInfo = [
+            'id' => $student?->student_id ?? $studentId,
+            'name' => $student?->name ?? $studentId,
+            'grade' => $student?->grade ?? '未知年级',
+            'class' => $student?->class_name ?? '未知班级',
+        ];
+
+        // 调用学习分析服务获取本卷分析与掌握度
+        $analysisData = [];
+        if (!empty($paper->analysis_id)) {
+            $analysis = $this->learningAnalyticsService->getAnalysisResult($paper->analysis_id);
+            if (!empty($analysis['data'])) {
+                $analysisData = $analysis['data'];
+            }
+        }
+
+        $masteryData = [];
+        $masteryResponse = $this->learningAnalyticsService->getStudentMastery($studentId);
+        if (!empty($masteryResponse['data'])) {
+            $masteryData = $masteryResponse['data'];
+        }
+
+        $recommendations = [];
+        $recommendationResponse = $this->learningAnalyticsService->getLearningRecommendations($studentId);
+        if (!empty($recommendationResponse['data'])) {
+            $recommendations = $recommendationResponse['data'];
+        }
+
+        $kpNameMap = $this->buildKnowledgePointNameMap();
+
+        // 预取题库详情用于解析/解题思路
+        $questionDetails = [];
+        $questionIds = $paper->questions->pluck('question_id')->filter()->unique()->values();
+        foreach ($questionIds as $qid) {
+            try {
+                $detail = $this->questionBankService->getQuestion((string) $qid);
+                if (!empty($detail)) {
+                    $questionDetails[(string) $qid] = $detail;
+                }
+            } catch (\Throwable $e) {
+                Log::warning('ExamPdfExportService: 获取题库题目详情失败', [
+                    'question_id' => $qid,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+        }
+
+        $questions = $paper->questions
+            ->map(function (PaperQuestion $question) use ($kpNameMap, $questionDetails) {
+            $kpCode = $question->knowledge_point ?? '';
+            $kpName = $kpNameMap[$kpCode] ?? $kpCode ?: '未标注';
+            $detail = $questionDetails[(string) ($question->question_id ?? '')] ?? [];
+            $solution = $detail['solution'] ?? $detail['解析'] ?? $detail['analysis'] ?? null;
+            $typeRaw = $detail['question_type'] ?? $detail['type'] ?? $question->question_type ?? '';
+            $normalizedType = $this->normalizeQuestionType($typeRaw);
+
+            return [
+                'question_number' => $question->question_number,
+                'question_text' => is_array($question->question_text) ? json_encode($question->question_text, JSON_UNESCAPED_UNICODE) : ($question->question_text ?? ''),
+                'question_type' => $normalizedType,
+                'knowledge_point' => $kpCode,
+                'knowledge_point_name' => $kpName,
+                'score' => $question->score,
+                'solution' => $solution,
+            ];
+        })
+            ->sortBy('question_number')
+            ->values()
+            ->toArray();
+
+        $questionInsights = $analysisData['question_results'] ?? [];
+        $masterySummary = $this->buildMasterySummary($masteryData, $kpNameMap);
+
+        return [
+            'paper' => [
+                'id' => $paper->paper_id,
+                'name' => $paper->paper_name,
+                'total_questions' => $paper->question_count,
+                'total_score' => $paper->total_score,
+                'created_at' => $paper->created_at,
+            ],
+            'student' => $studentInfo,
+            'questions' => $questions,
+            'mastery' => $masterySummary,
+            'question_insights' => $questionInsights,
+            'recommendations' => $recommendations,
+            'analysis_data' => $analysisData,
+        ];
+    }
+
+    private function buildKnowledgePointNameMap(): array
+    {
+        try {
+            // 优先使用 QuestionServiceApi(已有知识点名称缓存)
+            if (class_exists(QuestionServiceApi::class)) {
+                /** @var QuestionServiceApi $service */
+                $service = app(QuestionServiceApi::class);
+                $options = $service->getKnowledgePointOptions();
+                if (!empty($options)) {
+                    return $options;
+                }
+            }
+
+            // 退回 QuestionBankService(可能缺少此方法)
+            if (method_exists($this->questionBankService, 'getKnowledgePointOptions')) {
+                $options = $this->questionBankService->getKnowledgePointOptions();
+                $map = [];
+                foreach ($options as $item) {
+                    if (is_array($item)) {
+                        $code = $item['kp_code'] ?? null;
+                        $name = $item['kp_name'] ?? $item['name'] ?? null;
+                        if ($code && $name) {
+                            $map[$code] = $name;
+                        }
+                    }
+                }
+                if (!empty($map)) {
+                    return $map;
+                }
+            }
+        } catch (\Throwable $e) {
+            Log::warning('ExamPdfExportService: 获取知识点名称失败,退回使用编码', [
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return [];
+    }
+
+    private function buildMasterySummary(array $masteryData, array $kpNameMap): array
+    {
+        $items = [];
+        $total = 0;
+        $count = 0;
+        $hasMap = !empty($kpNameMap);
+
+        foreach ($masteryData as $row) {
+            $code = $row['kp_code'] ?? null;
+            if ($hasMap && $code && !isset($kpNameMap[$code])) {
+                // 不在知识图谱中的知识点不呈现
+                continue;
+            }
+            $name = $row['kp_name'] ?? ($code ? ($kpNameMap[$code] ?? $code) : '未知知识点');
+            $level = (float) ($row['mastery_level'] ?? 0);
+            $delta = $row['mastery_change'] ?? null;
+            $items[] = [
+                'kp_code' => $code,
+                'kp_name' => $name,
+                'mastery_level' => $level,
+                'mastery_change' => $delta,
+            ];
+            $total += $level;
+            $count++;
+        }
+
+        $average = $count > 0 ? round($total / $count, 2) : null;
+
+        // 按掌握度从低到高排序,便于突出薄弱点
+        usort($items, fn($a, $b) => ($a['mastery_level'] <=> $b['mastery_level']));
+
+        return [
+            'items' => $items,
+            'average' => $average,
+            'weak_list' => array_slice($items, 0, 5),
+        ];
+    }
+
+    private function normalizeQuestionType(?string $type): string
+    {
+        $t = strtolower(trim((string) $type));
+        return match (true) {
+            str_contains($t, 'choice') || str_contains($t, '选择') => 'choice',
+            str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空') => 'fill',
+            default => 'answer',
+        };
+    }
+
     private function ensureUtf8Html(string $html): string
     {
         $meta = '<meta charset="UTF-8">';

+ 193 - 0
resources/views/exam-analysis/pdf-report.blade.php

@@ -0,0 +1,193 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>学情报告 - {{ $paper['name'] ?? '试卷' }}</title>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
+    <style>
+        * { box-sizing: border-box; }
+        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; margin: 24px; color: #1f2937; background: #f9fafb; }
+        h1, h2, h3 { margin: 0; color: #111827; }
+        .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06); }
+        .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
+        .tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; color: #374151; background: #e5e7eb; }
+        .section-title { font-size: 16px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
+        .pill { padding: 4px 10px; border-radius: 999px; font-size: 12px; }
+        .pill.green { background: #ecfdf3; color: #15803d; }
+        .pill.amber { background: #fef3c7; color: #b45309; }
+        .pill.red { background: #fef2f2; color: #b91c1c; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 8px 10px; border-bottom: 1px solid #e5e7eb; text-align: left; vertical-align: top; }
+        th { background: #f3f4f6; color: #111827; }
+        .muted { color: #6b7280; font-size: 12px; }
+        .progress-wrap { background: #f3f4f6; border-radius: 999px; overflow: hidden; height: 10px; }
+        .progress-bar { height: 100%; background: linear-gradient(90deg, #4f46e5, #10b981); }
+        .recommend-card { border: 1px dashed #cbd5e1; border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; background: #f8fafc; }
+    </style>
+</head>
+<body>
+    <div class="card" style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px;">
+        <div>
+            <h1>学情报告</h1>
+            <div class="muted" style="margin-top:6px;">卷子:{{ $paper['name'] ?? '-' }} | 学生:{{ $student['name'] ?? '-' }}({{ $student['id'] ?? '' }})</div>
+            <div class="muted">年级:{{ $student['grade'] ?? '-' }} | 班级:{{ $student['class'] ?? '-' }}</div>
+        </div>
+        <div style="text-align:right;">
+            <div class="pill {{ ($mastery['average'] ?? 0) >= 0.7 ? 'green' : (($mastery['average'] ?? 0) >= 0.5 ? 'amber' : 'red') }}">
+                平均掌握度 {{ isset($mastery['average']) ? number_format($mastery['average'] * 100, 1) . '%' : '无数据' }}
+            </div>
+            <div class="muted" style="margin-top:6px;">题目数:{{ is_array($questions ?? null) ? count($questions) : ($paper['total_questions'] ?? '-') }}</div>
+        </div>
+    </div>
+
+    <div class="card">
+        <div class="section-title">知识点掌握度</div>
+        @if(!empty($mastery['items']))
+            @foreach($mastery['items'] as $item)
+                @php
+                    $pct = min(100, max(0, $item['mastery_level'] * 100));
+                    $barColor = $pct >= 80 ? '#10b981' : ($pct >= 60 ? '#f59e0b' : '#ef4444');
+                    $delta = $item['mastery_change'] ?? null;
+                @endphp
+                <div style="margin-bottom:10px;">
+                    <div style="display:flex; justify-content:space-between; align-items:center;">
+                        <div><strong>{{ $item['kp_name'] }}</strong> <span class="muted">({{ $item['kp_code'] ?? '-' }})</span></div>
+                        <div>
+                            {{ number_format($pct, 1) }}%
+                            @if($delta !== null)
+                                <span class="muted" style="margin-left:6px;">{{ $delta > 0 ? '↑' : ($delta < 0 ? '↓' : '→') }} {{ number_format(abs($delta) * 100, 1) }}%</span>
+                            @endif
+                        </div>
+                    </div>
+                    <div class="progress-wrap">
+                        <div class="progress-bar" style="width: {{ $pct }}%; background: {{ $barColor }};"></div>
+                    </div>
+                </div>
+            @endforeach
+        @else
+            <div class="muted">暂无掌握度数据</div>
+        @endif
+    </div>
+
+    <div class="card">
+        <div class="section-title">解题思路与题目表现</div>
+        @php
+            $insightMap = [];
+            foreach (($question_insights ?? []) as $insight) {
+                $no = $insight['question_number'] ?? $insight['question_id'] ?? null;
+                if ($no !== null) {
+                    $insightMap[$no] = $insight;
+                }
+            }
+        @endphp
+        @foreach($questions as $q)
+            @php
+                $insight = $insightMap[$q['question_number']] ?? [];
+                $score = $insight['score'] ?? ($insight['student_score'] ?? null);
+                $fullScore = $insight['full_score'] ?? ($q['score'] ?? null);
+                $analysisRaw = $insight['analysis']
+                    ?? $insight['thinking_process']
+                    ?? $insight['feedback']
+                    ?? $insight['suggestions']
+                    ?? $insight['reason']
+                    ?? ($insight['correct_solution'] ?? null);
+                // 若有下一步建议,追加
+                if (empty($analysisRaw) && !empty($insight['next_steps'])) {
+                    $analysisRaw = '后续建议:' . (is_array($insight['next_steps']) ? implode(';', $insight['next_steps']) : $insight['next_steps']);
+                }
+                $analysis = is_array($analysisRaw) ? json_encode($analysisRaw, JSON_UNESCAPED_UNICODE) : $analysisRaw;
+                if ($analysis === null || $analysis === '') {
+                    $analysis = '暂无解题思路,待补充';
+                }
+                $stepsRaw = $insight['steps'] ?? $insight['solution_steps'] ?? $insight['analysis_steps'] ?? null;
+                $steps = [];
+                if (is_array($stepsRaw)) {
+                    $steps = $stepsRaw;
+                } elseif (is_string($stepsRaw) && trim($stepsRaw) !== '') {
+                    $steps = preg_split('/[\r\n]+/', trim($stepsRaw));
+                }
+                $isCorrect = $insight['is_correct'] ?? $insight['correct'] ?? null;
+                $badgeColor = $isCorrect === true ? '#10b981' : ($isCorrect === false ? '#ef4444' : '#6b7280');
+                $badgeText = $isCorrect === true ? '答对' : ($isCorrect === false ? '答错' : '待判');
+                $typeMap = ['choice' => '选择题', 'fill' => '填空题', 'answer' => '解答题'];
+                $typeLabel = $typeMap[$q['question_type'] ?? ''] ?? ($q['question_type'] ?? '题型未标注');
+                $questionText = is_string($q['question_text']) ? $q['question_text'] : json_encode($q['question_text'], JSON_UNESCAPED_UNICODE);
+                $solution = $q['solution'] ?? null;
+            @endphp
+            <div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px 14px; margin-bottom:10px; background:#fff; page-break-inside: avoid;">
+                <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:6px;">
+                    <div style="display:flex; align-items:center; gap:8px; font-weight:600;">
+                        <span class="tag">题号 {{ $q['question_number'] }}</span>
+                        <span class="tag" style="background: #eef2ff; color:#4338ca;">{{ $q['knowledge_point_name'] ?? $q['knowledge_point'] ?? '-' }}</span>
+                        <span class="tag" style="background: {{ $badgeColor }}; color:#fff;">{{ $badgeText }}</span>
+                    </div>
+                    <div class="muted">
+                        @if($score !== null && $fullScore !== null)
+                            得分 {{ $score }} / {{ $fullScore }}
+                        @else
+                            待评分
+                        @endif
+                    </div>
+                </div>
+                <div class="math-content" style="margin-bottom:6px;">{!! $questionText !!}</div>
+                <div class="muted" style="margin-bottom:6px;">题型:{{ $typeLabel }}</div>
+                <div style="font-size:13px; line-height:1.6;">{!! nl2br(e($analysis ?? '暂无解题思路记录')) !!}</div>
+                @if(!empty($steps))
+                    <div style="margin-top:6px; font-size:13px;">
+                        <div style="font-weight:600; margin-bottom:4px;">解题步骤</div>
+                        <ol style="margin:0; padding-left:18px;">
+                            @foreach($steps as $s)
+                                <li>{!! nl2br(e(is_array($s) ? json_encode($s, JSON_UNESCAPED_UNICODE) : $s)) !!}</li>
+                            @endforeach
+                        </ol>
+                    </div>
+                @elseif(!empty($solution))
+                    <div style="margin-top:6px; font-size:13px;">
+                        <div style="font-weight:600; margin-bottom:4px;">题库解析</div>
+                        <div class="math-content">{!! is_array($solution) ? json_encode($solution, JSON_UNESCAPED_UNICODE) : $solution !!}</div>
+                    </div>
+                @endif
+            </div>
+        @endforeach
+    </div>
+
+    <div class="card">
+        <div class="section-title">整体分析</div>
+        @php
+            $overallRaw = $analysis_data['summary'] ?? $analysis_data['overall_feedback'] ?? null;
+            $overall = is_array($overallRaw) ? json_encode($overallRaw, JSON_UNESCAPED_UNICODE) : $overallRaw;
+        @endphp
+        <div>
+            @if($overall)
+                {!! nl2br(e($overall)) !!}
+            @elseif(!empty($analysis_data['summary']))
+                {!! nl2br(e(json_encode($analysis_data['summary'], JSON_UNESCAPED_UNICODE))) !!}
+            @elseif(!empty($analysis_data))
+                {!! nl2br(e(json_encode($analysis_data, JSON_UNESCAPED_UNICODE))) !!}
+            @else
+                暂无整体分析,待分析服务返回后呈现。
+            @endif
+        </div>
+    </div>
+
+    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/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>

+ 18 - 0
routes/api.php

@@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Route;
 use App\Events\QuestionGenerationCompleted;
 use App\Events\QuestionGenerationFailed;
+use Illuminate\Auth\Middleware\Authenticate;
+use App\Http\Controllers\Api\ExamAnalysisApiController;
 
 /*
 |--------------------------------------------------------------------------
@@ -405,8 +407,24 @@ Route::get('/knowledge-points', function (QuestionServiceApi $service) {
 
 // 智能出卷对外接口:生成试卷并返回PDF/判卷地址
 Route::post('/intelligent-exams', [IntelligentExamController::class, 'store'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
     ->name('api.intelligent-exams.store');
 
+// 学情报告对外接口:生成并返回学情报告 PDF
+Route::post('/exam-analysis/report', [ExamAnalysisApiController::class, 'store'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.exam-analysis.report');
+
 /*
 |--------------------------------------------------------------------------
 | MathRecSys 集成 API 路由

+ 1 - 0
routes/web.php

@@ -15,6 +15,7 @@ Route::get('/test-case', function() { return view('test-case'); });
 Route::view('/knowledge-mindmap-public', 'public.knowledge-mindmap');
 Route::get('/admin/intelligent-exam/pdf/{paper_id}', [\App\Http\Controllers\ExamPdfController::class, 'show'])->name('filament.admin.auth.intelligent-exam.pdf');
 Route::get('/admin/intelligent-exam/grading/{paper_id}', [\App\Http\Controllers\ExamPdfController::class, 'showGrading'])->name('filament.admin.auth.intelligent-exam.grading');
+Route::get('/admin/exam-analysis/pdf', [\App\Http\Controllers\ExamAnalysisPdfController::class, 'show'])->name('filament.admin.auth.exam-analysis.pdf');
 
 // 检查通知的路由
 Route::get('/admin/question-management/check-notifications', [NotificationController::class, 'checkNotifications']);